备份显示“成功”之后,怎样证明文件能恢复?
一个备份任务每天显示绿色,等电脑坏了,才发现备份里少了一层目录,或者恢复后留下了上次演练的旧文件。这里的场景是用来说明问题的假设,并非我遇到过的故障;但它提出了一个值得认真回答的问题:任务执行成功以后,还需要什么证据,才能相信资料可以取回来?
我把这个问题缩小到一个可以完整验证的对象:已经停止修改、只包含普通文件和目录的小型资料集。今天用 Node.js 建立它的内容清单、复制一份备份,然后删除实验中的源目录,从备份恢复到新目录。接着主动制造几种差异,检查校验器是否会拒绝它们。
这次实验于 2026-09-28 在 macOS、Node.js v22.23.1 上运行。完整恢复演练脚本可以下载。它只创建和删除自己生成的临时目录,使用虚构内容,不接受真实资料目录作为参数。本文也没有验证云盘、硬盘故障、数据库恢复或其他操作系统。
下载后在对应目录执行以下命令。正常输出会列出每个案例,最后显示 All restore-drill assertions passed.;破坏样本得到 ok: false 是预期结果,只有断言不符合预期或操作失败时,整个脚本才会失败。
node backup-restore-drill.mjs
1. 先把“成功”拆成三个可验证的判断
复制函数没有抛出错误,是一条有用的证据:程序完成了自己执行的操作。但它不说明输入范围是否选对,也不说明拿到恢复结果以后,预期的文件是否全部存在。假如程序一开始漏掉了隐藏文件,所有后续操作仍然可能顺利结束。
第二层证据是内容与结构一致。将一份事先保存的清单,与备份或恢复目录重新生成的清单比较,可以回答哪些文件少了、哪些多了、哪些字节改变了。数字保存领域把检查内容是否保持不变称为 fixity 检查,校验和是常用方法,见 Digital Preservation Coalition 的说明。
第三层证据是资料仍然可用。一个字节完全相同的文件,也可能本来就是损坏的,或者缺少打开它所需的程序、字体、附件。美国国会图书馆的个人数字资料保存建议同时提到保留不同地点的副本,以及定期确认文件可以读取。这两项检查承担不同职责。
所以我会让恢复报告分别说明:复制是否完成、与基线是否一致、哪些内容已检查可读。不要把三个判断压成一个没有范围说明的“恢复成功”。本文的自动检查覆盖前两层,并对少量自拟文本、JSON 与二进制内容做了读取断言,没有证明任意文档格式都能打开。
2. 备份范围要先写清,再计算摘要
这个实验资料集有六个文件:隐藏设置文件、Markdown 草稿、日文名称中含空格的文本、名称中含换行的文本、一份四字节二进制数据、一个零字节文件。它还包含 assets、notes 与一个空目录 empty。
选择这些样本,是为了暴露几个容易遗漏的约定。零字节文件仍然是文件,不能因为没有内容就省略。空目录也属于本例要恢复的结构。隐藏文件不能只靠界面里看见的列表判断是否存在。名称中的空格或换行,也不应被一个随手写的文本分割器当作记录边界。
清单因此记录相对路径和类型;文件额外记录字节数与 SHA-256,目录则记录存在性。它不记录所有者、权限、修改时间、扩展属性或硬链接关系,也不承诺恢复这些属性。资料若依赖这些信息,清单设计与恢复验证都需要扩展。范围说明是验证结果的一部分,否则“相同”这个词没有清楚的对象。
import { createHash } from 'node:crypto';
import { lstat, readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
async function inventory(root) {
if (!(await lstat(root)).isDirectory()) throw new Error('Root must be a directory');
const entries = [];
async function walk(directory, relative = '') {
const names = (await readdir(directory)).sort();
for (const name of names) {
const full = path.join(directory, name);
const relativePath = relative ? `${relative}/${name}` : name;
const stat = await lstat(full);
if (stat.isDirectory()) {
entries.push({ path: relativePath, type: 'directory' });
await walk(full, relativePath);
} else if (stat.isFile()) {
const bytes = await readFile(full);
entries.push({ path: relativePath, type: 'file', bytes: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex') });
} else {
throw new Error(`Unsupported entry: ${JSON.stringify(relativePath)}`);
}
}
}
await walk(root);
return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
}
这里用 lstat 判断目录项本身,遇到符号链接或其他不支持的类型就报错。不是把它们偷偷跳过,也不是把它们指向的内容自动算进范围。这项规则已用符号链接样本测试;但这段先检查、后读取的代码没有解决目录被并发替换的竞争问题,适用前提仍是实验中的静止资料集。
文件读取为原始字节,不先转换成 UTF-8 文本,所以二进制文件也能按相同方式计算摘要。Node 的 Hash API说明了 update 与 digest 的使用。当前实现每次把一个文件读入内存,适合本次小样本,没有大文件吞吐量测试;处理大文件需要另外设计流式读取与失败处理。
3. 建立可信基线,再检查备份副本
实验先在源目录生成清单,写入备份包的 manifest.json;文件内容放在同级的 payload/。把清单与内容分开,可以避免清单试图包含自己,从而产生循环定义。
清单外层还有格式标识 gumi-content-snapshot-v1、算法名 sha256 与创建时间。标识用来说明字段约定,算法名用来避免以后猜测摘要的含义。创建时间是这次清单生成的记录,不是每个文件内容曾同时存在于该时刻的证明。本文说的备份包是一次停写后的文件集合,没有把递归复制叫作文件系统原子快照。
复制使用以下选项,完整脚本里会先准备不存在的目标路径:
await cp(source, payload, {
recursive: true,
force: false,
errorOnExist: true,
});
recursive 允许复制目录树,force: false 与 errorOnExist: true 配合,使遇到已有目标项时抛错,具体语义见 Node 文件系统 API。这一设置表达了本例的使用方式:为一次演练建立新的目录,而不是把两次恢复混在一起。它不是全目录事务保证,部分复制后发生错误仍应把本次结果视为失败。
复制结束后,脚本重新扫描 payload/,与原清单比较,通过后才进入恢复环节。基线不能从待检查的恢复目录重新生成;否则损坏后的状态就会被当成新的正确答案。也不能在发现差异以后,直接更新摘要并宣布问题解决。该更新是否合理,取决于内容是不是一次经过确认的有意变更。
4. 比较器要检查集合,也要检查内容
只循环清单里的文件并验证摘要,可以发现缺失与内容变化,却会漏掉清单以外的旧文件。这个实验采用精确匹配的政策:列出的项都应存在,实际目录里也不能多出其他项。完整比较函数如下:
function compare(expectedEntries, actualEntries) {
const expected = new Map(expectedEntries.map(entry => [entry.path, entry]));
const actual = new Map(actualEntries.map(entry => [entry.path, entry]));
const issues = [];
for (const [name, before] of expected) {
const after = actual.get(name);
if (!after) issues.push({ kind: 'missing', path: name });
else if (before.type !== after.type) issues.push({ kind: 'type-changed', path: name });
else if (before.type === 'file' &&
(before.bytes !== after.bytes || before.sha256 !== after.sha256)) {
issues.push({ kind: 'content-changed', path: name });
}
}
for (const name of actual.keys()) {
if (!expected.has(name)) issues.push({ kind: 'unexpected', path: name });
}
return { ok: issues.length === 0, issues };
}
路径在程序中作为完整字符串键处理,清单用 JSON 保存,换行由序列化器转义。没有通过“每行一条文件名,再按空格切分”的方式重建路径。也没有自动把名字改成另一种拼写。这让比较政策与资料范围保持一致。
比较器接受的是脚本自行生成的、路径唯一的清单,不是完整的外部清单验证器。格式和算法检查、错误读取以及复制失败会使脚本终止;未声明支持的目录项也会使扫描失败。这里没有用空数组代替读取错误,所以“无法检查”不会被冒充为“没有差异”。
5. 源目录已经消失时,恢复演练得到什么结果
确认备份副本之后,脚本删除自己创建的实验源目录,并断言它已不存在。接着只使用备份包内的内容和已保存的清单,恢复到新的临时目录。这个步骤很小,却排除了一个常见的演练捷径:恢复过程中仍然从原目录补齐漏掉的文件。
本次脚本得到以下结果。每项差异测试都从未改动的备份内容重新复制一份,避免前一次破坏影响后一次判断。
| 场景 | 检查结果 | 报告原因 |
|---|---|---|
| 备份内容与源清单比较 | 通过 | 无差异 |
| 恢复目标目录已经存在 | 复制被拒绝 | ERR_FS_CP_EEXIST |
| 源目录删除后,正常恢复 | 通过 | 无差异 |
把 # Draft 改成等长的 # draft | 不通过 | content-changed |
删除零字节文件 zero.txt | 不通过 | missing |
多放一份 old.txt | 不通过 | unexpected |
删除空目录 empty | 不通过 | missing |
| 把草稿文件替换为空目录 | 不通过 | type-changed |
添加符号链接 shortcut | 扫描被拒绝 | 不支持的目录项 |
等长修改很关键:文件大小没有变,只比较大小会漏掉这次差异。源清单中的摘要与重新计算的摘要不同,校验器才报告内容改变。另一个容易忽略的结果是空目录缺失;因为目录也在清单中,这次变化同样没有被“文件都还在”掩盖。
正常恢复后,脚本还读回 Markdown 草稿,检查二进制字节,并解析隐藏设置文件中的 JSON。这些读取断言全部通过。它们证明了这个自拟资料集的几个预期内容,而不是证明所有照片、电子表格或加密文件都可用。真实资料还应选择有代表性的格式,用预计将来会使用的程序打开检查。
6. 摘要能定位差异,不能包办恢复
校验器会告诉我哪条路径出现问题,不会凭空还原缺失的字节。恢复仍需要另一份已确认可用的副本。备份包、原目录和恢复目录在本次实验里都位于同一台电脑的临时区域,所以这只是流程验证,没有形成抵抗设备损坏的独立备份。不能把三个目录误当成三份独立的故障隔离。
同样,摘要的可信度取决于基线如何保管。如果内容和清单都被一起改写,再比较也可能得到一致。本文没有提供签名、独立信任记录或对抗主动篡改的机制。这个边界并非 JSON 清单独有:RFC 8493 的 BagIt 介绍也区分了检测数据损坏与抵抗主动攻击。这里仅引用这项边界,本例的自定义格式没有实现 BagIt 标准。
另外,已知摘要只说明字节与已知版本一致,不说明版本选得正确。若复制前的源文件已经错误,基线就会准确地记录那个错误。使用者仍需了解备份对应哪一批资料、哪一个版本,以及为什么这批资料值得保留。这是清单之外的说明信息,不能由一个哈希值推导出来。
7. 把一次演练变成可以重复的工作
如果把这套思路用于自己的笔记、照片说明或项目资料,我会先列出范围与排除项,确定哪类内容必须停止写入,再保存带版本说明的清单。对于持续变化的数据,应使用能给出一致状态的导出或快照机制;这次对静止目录的测试不能替它提供证据。
每次恢复建立一个干净的独立位置,记录使用的备份版本、校验结果、差异路径、读取检查和剩余问题。恢复所需的说明、程序和访问条件也要能找到。一个只有本人记得怎样解包的副本,换电脑以后仍可能成为无法使用的资料。操作说明本身应当随资料保存,并在演练中实际照着执行。
演练频率取决于资料更新速度和恢复需要,不必从一个博客实验推出所有人的统一周期。更重要的是形成事件触发的复核:修改备份范围、换存储位置、改变加密方式或升级恢复工具后,都有理由检查原来的恢复约定是否仍成立。日志可以证明任务运行过,新的演练结果才能说明改变后的流程仍然能取回资料。
备份的价值最终落在一次可完成的恢复上。先明确保存什么,再检查副本是否符合清单,最后在不依赖源目录的条件下读回需要的内容——每一步留下可以复查的证据,“成功”才会有具体含义。