写一个能接入脚本的文件校验工具:退出码与两个输出流
文件校验脚本里,最麻烦的不是算不出 SHA-256,而是“打印了错误,退出码却是零”,或者在摘要前面多写了一行进度,导致后面的程序把整份输出当成哈希。一个命令行工具要能接进自动化流程,输出与失败合同必须和算法一起设计。
这里实现一个单文件 Java 17 工具:传文件路径,标准输出只给一行小写摘要;可选传入预期摘要,内容不匹配时返回不同状态。它不是兼容某个现成校验命令的替代品,而是一份可以完整运行、观察协议边界的实现。
先约定脚本可以依赖什么
退出码零代表计算完成且可选校验通过;一代表摘要不匹配;二代表参数或路径语法错误;三代表读取或输出失败;四代表超过本工具的大小限制。文件不存在与内容不同不是同一个问题,因此没有压成一个含糊的“失败”。
正常计算后,stdout 只输出 64 位十六进制摘要和换行,不附文件名。这样路径里出现空格、制表符或换行,也不会污染摘要格式。错误说明进入 stderr,脚本必须同时检查退出码;尤其摘要不匹配时,stdout 仍给出实际计算值,便于受控地比对,不能只因输出非空就判定通过。
输入上限为 64 MiB,用实际读取字节计数执行。不是先检查 Files.size 就相信文件不会增长,也不一次性 readAllBytes。这里保证的是读取阶段的预算和有限缓冲区,不保证一个被其他进程持续修改的文件具有快照一致性。
一份源码,包含自检与实际入口
把下面代码保存为 FileDigest.java。默认入口遵守前面的文件校验协议,--self-test 则运行内置样本并清理临时文件。所有能力来自 JDK 17,不需要 Maven 依赖。
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.*;
import java.util.HexFormat;
public class FileDigest {
static final long MAX_BYTES = 64L * 1024 * 1024;
static final class TooLarge extends IOException {}
record Result(int code, String out, String err) {}
static byte[] digest(Path path, long maxBytes) throws IOException {
MessageDigest md;
try { md = MessageDigest.getInstance("SHA-256"); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 unavailable", e); }
long count = 0;
try (InputStream input = Files.newInputStream(path)) {
byte[] buffer = new byte[8192];
for (;;) {
int n = input.read(buffer);
if (n < 0) break;
if (n == 0) throw new IOException("input made no progress");
if (n > maxBytes - count) throw new TooLarge();
count += n;
md.update(buffer, 0, n);
}
}
return md.digest();
}
static int run(String[] args, PrintStream out, PrintStream err, long limit) {
if (args.length < 1 || args.length > 2
|| (args.length == 2 && !args[1].matches("[0-9a-fA-F]{64}"))) {
err.println("USAGE: FileDigest <file> [expected-sha256]");
return 2;
}
final Path path;
try { path = Path.of(args[0]); }
catch (InvalidPathException e) { err.println("PATH: invalid syntax"); return 2; }
final String actual;
try { actual = HexFormat.of().formatHex(digest(path, limit)); }
catch (TooLarge e) { err.println("LIMIT: file exceeds byte budget"); return 4; }
catch (IOException e) { err.println("IO: " + e.getClass().getSimpleName()); return 3; }
out.println(actual);
if (out.checkError()) { err.println("OUTPUT: write failed"); return 3; }
if (args.length == 2 && !actual.equalsIgnoreCase(args[1])) {
err.println("MISMATCH: computed digest differs");
return 1;
}
return 0;
}
static Result capture(String[] args, long limit) throws Exception {
var out = new ByteArrayOutputStream();
var err = new ByteArrayOutputStream();
int code;
try (var o = new PrintStream(out, true, StandardCharsets.UTF_8);
var e = new PrintStream(err, true, StandardCharsets.UTF_8)) {
code = run(args, o, e, limit);
}
return new Result(code, out.toString(StandardCharsets.UTF_8), err.toString(StandardCharsets.UTF_8));
}
static void selfTest() throws Exception {
Path dir = Files.createTempDirectory("digest-lab-");
Path file = dir.resolve("sample.bin");
try {
Files.writeString(file, "abc", StandardCharsets.UTF_8);
String known = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
Result ok = capture(new String[]{file.toString(), known.toUpperCase(java.util.Locale.ROOT)}, MAX_BYTES);
require(ok.code() == 0 && ok.out().strip().equals(known) && ok.err().isEmpty(), "match");
Result mismatch = capture(new String[]{file.toString(), "0".repeat(64)}, MAX_BYTES);
require(mismatch.code() == 1 && mismatch.out().strip().equals(known)
&& mismatch.err().contains("MISMATCH"), "mismatch");
Result missing = capture(new String[]{dir.resolve("missing").toString()}, MAX_BYTES);
require(missing.code() == 3 && missing.out().isEmpty(), "missing");
Result invalid = capture(new String[]{file.toString(), "xyz"}, MAX_BYTES);
require(invalid.code() == 2 && invalid.out().isEmpty(), "arguments");
Result limited = capture(new String[]{file.toString()}, 2);
require(limited.code() == 4 && limited.out().isEmpty(), "limit");
Files.write(file, new byte[0]);
Result empty = capture(new String[]{file.toString()}, MAX_BYTES);
require(empty.code() == 0 && empty.out().strip().equals(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), "empty");
System.out.println("match=0, mismatch=1, arguments=2, missing=3, limit=4");
System.out.println("stdout-stderr=verified, empty-file=verified");
} finally { Files.deleteIfExists(file); Files.deleteIfExists(dir); }
}
static void require(boolean condition, String name) {
if (!condition) throw new AssertionError(name);
}
public static void main(String[] args) throws Exception {
if (args.length == 1 && args[0].equals("--self-test")) { selfTest(); return; }
System.exit(run(args, System.out, System.err, MAX_BYTES));
}
}
先执行:
java FileDigest.java --self-test
本环境实际输出为:
match=0, mismatch=1, arguments=2, missing=3, limit=4
stdout-stderr=verified, empty-file=verified
自检使用 abc 的已知摘要,覆盖大小写合法的预期值、不匹配、缺失文件、非法摘要、空文件及大小预算。限额分支通过同一个 run 方法注入两字节预算,对三字节文件验证,避免为了测一个分支创建大文件;正常 CLI 始终使用 64 MiB 常量。这里没有把小样本宣称成大文件吞吐测试。
把它放进一次真实命令调用
可以在临时工作目录创建样本,再分别捕获两个输出流:
printf abc > sample.bin
java FileDigest.java sample.bin > digest.txt 2> digest.err
digest_status=$?
printf 'status=%s\n' "$digest_status"
cat digest.txt
cat digest.err
状态应为零,digest.txt 只有 abc 对应的那一行摘要,digest.err 为空。换成一个 64 位全零预期值,命令返回一,stderr 给出 MISMATCH,stdout 保留实际摘要。调用脚本应在状态为零后再进入依赖校验成功的动作,不要用“文件已生成”当作门槛。
如果需要反复运行,可以先 javac FileDigest.java,再执行 java FileDigest sample.bin;源文件启动方式会包含源码编译启动步骤,不能把两种启动方式的耗时混在一起。部署时应明确使用的 JDK 版本与文件权限,示例没有要求更改系统默认 Java。
如果把命令接到管道,例如后面再跟一个 tee,整个管道返回哪个状态还取决于 shell 的规则。很多 shell 默认只返回最后一个命令的状态,摘要工具返回一、tee 成功时,流水线仍可能看起来成功。调用端应按所用 shell 配置并核查管道失败传播,或像上面的样例先独立捕获工具状态,再处理结果文件。
同样,必须紧接命令保存退出码。若先执行 cat 或输出一行提示,再读取 $?,拿到的已经是后来那条命令的结果。这里的问题不在 Java 的 System.exit,而在调用脚本是否保留了它表达的合同。测试流水线时,应故意传一个合法但不匹配的摘要,确认依赖步骤确实停止,而不只是手动看到 stderr 上有红字。
错误为什么在这些位置停止
预期摘要先验证是否为 64 位十六进制,再打开文件。明显错误的参数不应该触发一次昂贵读取;允许大小写只是十六进制表示的兼容选择,最终输出统一为小写。摘要长度与格式不合法返回二,内容合法但不同返回一,这样脚本能区分调用错误和校验失败。
文件流在 try-with-resources 中关闭,close 失败同样会让整个操作作为 I/O 失败返回,不发布一个被称为成功的结果。每次读取后先比较剩余预算,再更新 MessageDigest;超过限制时不会输出部分内容的摘要。零字节的文件则是正常输入,它有确定摘要,不应该因为没有进入更新分支就被当成缺失。
maxBytes - count 在 count 从零且始终不超过上限的条件下使用,避开累计后再判断可能造成的溢出。示例常量远小于 long 上界,但这种写法同时清楚表达了剩余预算。输入流若在非空缓冲区读取时返回零,程序按无进展失败,避免把循环写成无条件空转。
MessageDigest.getInstance("SHA-256") 的选择发生在进程本地。若运行环境连这个预期算法都无法提供,代码抛出环境错误,没有伪装成某个文件内容不正确。用户输入错误、I/O 故障和部署能力缺失,各自拥有不同责任方,统一捕获 Exception 反而会让排查更困难。
标准输出也可能写失败
PrintStream 常把底层写入错误记录在内部状态,调用 println 不一定向外抛 IOException。因此代码输出后调用 checkError,并在失败时返回非零。接收方提前关闭管道、磁盘写满等情况,都不能仅凭“摘要已经算完”就报告整个命令成功。
检查输出错误并不能倒回已经写出的部分字节。若脚本把 stdout 重定向到结果文件,失败后应丢弃那份文件,不能让下游继续读取。若要求结果文件原子出现,应由调用端先写临时文件,在确认命令成功后再按目标文件系统的规则替换正式结果;这与摘要计算是两个边界。
工具也没有把 System.out 包在 try-with-resources 里关闭。它是进程的共享标准流;可复用的 run 方法接收输出对象,生命周期由调用方管理。自检使用自己的内存流,可以自行关闭,真实入口则只在最外层根据状态退出 JVM,不会在算法函数中突然终止进程。
能证明文件相同,不能证明来源可信
摘要用于核对字节内容。预期值若与文件一起来自同一份被篡改的输入,二者一致并不能证明来源可信。部署包真实性还需要可信发布渠道或签名校验,不能把一段 SHA-256 字符串当作完整信任链。
另一个实际边界是文件读取过程中的变化。本程序按一次打开的流读取,但普通文件没有因此获得不可变快照。对于发布流水线,先写完并固定制品,再计算并保存摘要,往往比一边写一边校验更容易说明结果代表什么。若源内容会变化,需要由制品管理或文件系统快照机制解决,而不是给摘要函数加一次结束时的 size 比较。
示例错误信息只输出故障类别,没有直接回显路径和原始异常消息,避免路径里的换行把诊断拆成多条记录。需要更多排障信息时可以另加受控详细模式,但保持 stdout 的摘要协议不变。这样工具既能让人手动使用,也能被脚本准确判断结果,而不依赖解析一段随时可能修改的提示文案。