返回博客
2026年10月7日Sergei Solod23 分钟阅读

GNU ddrescue 复制 4 TB 硬盘的 99.999654% 后,我如何从损坏的 APFS 克隆中恢复文件

GNU ddrescue 从一块故障中的 4 TB HDD 复制了 99.999654% 的数据,但 APFS 克隆仍无法通过文件系统检查,而 The Sleuth Kit 虽能列出路径,却返回零字节文件。我通过保留只读主克隆、改用 libfsapfs,并构建可断点续传的提取流水线,恢复了选定的目录树。

APFSGNU ddrescue数据恢复libfsapfsmacOS

GNU ddrescue 显示 100.00%。而我的第一次结构化文件恢复流程得到的却是 0 useful recovered user files。

这两个结果来自同一块 4 TB 硬盘的故障,也正是这种矛盾,让这次恢复远比“克隆坏盘,然后复制文件”更复杂、更有意思。ddrescue 的工作其实完成得非常好:它复制了物理源盘大约 99.999654% 的数据。克隆位于健康的硬件上。然而 APFS 仍然损坏,macOS 无法正常提供这个文件系统,而我最初使用的 APFS 恢复栈虽然能枚举目录树的大部分内容,却无法读取普通文件的内容。

最终,我恢复了自己需要的每一棵已选定、可枚举的文件夹树,但前提是把这次事故拆成三个独立问题来处理:物理块恢复、损坏文件系统的解析,以及带验证和断点续传能力的大规模文件提取。

首先,这已经不再只是文件系统问题

原来的 4 TB Toshiba 已经到了让我不敢再进行常规文件系统操作的程度。有些读取要耗时 60–75 秒,操作可能直接卡住。硬盘会间歇性地从 macOS 中消失,发出清晰的咔哒声,有时还会断电。

故障发生前不久,我又写入了大约 300 GB 数据,并对约 500,000 个文件和目录做过批量重命名。时间上的接近让这个元数据密集型负载显得可疑,但我无法证明它导致了硬件故障。也有可能只是它给一块本就不健康的硬盘施加了足够压力,从而暴露了问题。

我能确定的是硬盘本身的表现。一旦机械存储开始卡顿、掉盘并发出咔哒声,反复浏览目录就已经不是正确的处理抽象。遍历目录会触发更多读取和寻道,挂载文件系统会触发元数据操作。每一次实验都在消耗唯一那个剩余寿命未知的部件。

因此,我把目标从:

恢复我的文件

改成:

尽可能恢复所有仍可读取的扇区

我使用 GNU ddrescue 1.30,并配合持久化 mapfile。源盘的精确物理大小是:

4,000,787,027,968 bytes

mapfile 至关重要,因为源盘已经不够稳定,无法指望一次复制完成。它让恢复流程能够经受卡顿、断连、重启和后续多轮读取,同时不会忘记哪些区域已经恢复过。

我还遇到了一个与 macOS 原始设备路径有关的实际问题:表面上看到的恢复范围可能变得毫无意义,而不是在真实设备边界结束。因此,我把恢复域明确限制为上面的已知物理大小。在这个案例中,显式限制输入范围是正确性措施,而不是速度优化。

为什么 ddrescue 显示 100.00% 并不代表文件已经安全

接近物理恢复结束时,ddrescue 大致报告为:

domain size:  4000 GB
rescued:      4000 GB
non-tried:   13481 kB
non-trimmed: 327680 B
non-scraped:      0 B
bad-sector:   27136 B

最显眼的百分比是:

100.00%

但尚未解决的状态合计仍有:

13,835,816 bytes

约为:

13.84 MB

相对于一个 4,000,787,027,968 字节的源盘,实际恢复比例大约是:

99.999654%

作为块级恢复结果,这非常优秀。但它不是文件完整性结果。

丢失字节的位置,比表面上的总量更重要。未使用空间里丢几 MB,可能不会造成任何可见影响;视频内部一个很小的不可读区域,可能只损坏一个文件;而文件系统元数据里更小的一处缺失,却可能让许多原本完整的数据 extent 难以定位。

这成了我后续恢复过程里最核心的思维模型:

层级它回答的问题成功并不能证明什么
块恢复物理扇区是否复制成功?APFS 能重建每一个文件
文件系统恢复路径、元数据和 extent 是否能解析?每一个提取出来的字节都有效
文件验证文件是否达到预期大小或哈希?历史上从未存在过任何已经无法发现的文件

我又对剩余不可读区域做了几次最后尝试。最终,在 Toshiba 持续剧烈发出咔哒声的同时,这些尝试也不再产生有用的新读取。到这里,我停止把原盘继续作为主动恢复源。

为什么为了恢复一块 4 TB 硬盘,我买了两块 5 TB 硬盘

第一块新盘是 5 TB 的 Seagate Expansion,精确物理容量为:

5,000,981,077,504 bytes

我把 Toshiba 的块级克隆写到这块盘上。源盘布局大约占据前 4 TB,因此复制布局之后还剩约 1 TB。我有意完全不动这部分额外容量。

我没有扩大 APFS 容器,没有为了方便重新分区,也没有在克隆上运行文件系统修复。这块盘就成了主克隆。

然后我又买了第二块 5 TB 硬盘。它是新格式化的、可写的、单独测试过的,并且只用于存放恢复结果。

故障中的 4 TB HDD
        │
        │ GNU ddrescue
        ▼
5 TB 硬盘 #1
块级主克隆
只读
        │
        │ APFS 解析与提取
        ▼
5 TB 硬盘 #2
已恢复文件
可写

这意味着,为了恢复一个已用数据约 3.26 TB 的卷,我购买了标称总容量约 10 TB 的新存储。第二块盘的意义并不在容量,而在于维持一个不变量:

如果某个实验是错的,我仍然可以回到同一个完全未修改的主克隆。

如果修复、扩容、重新分区主克隆,或者把恢复出来的结果写回主克隆,就会把“保全证据”和“做实验”混在一起。把源和目标放在两个独立物理硬盘上,意味着即使操作出错,也还能恢复。

在信任目标盘之前,我做了一次约 10 GB 的写入/读取测试。测试中,两个方向都能维持大约 144.4 MB/s。对主克隆做的几次原始读取样本大约是 28–49 MB/s,并且在这些检查中没有复现原盘的物理 I/O 故障模式。

到了这个阶段,问题已经变了。我不再是在调试一块正在故障的硬件,而是在正常工作的硬件上,调试被保留下来的损坏 APFS 元数据。

APFS 克隆作为设备可以读取,但作为文件系统无效

克隆后的 APFS 物理存储大小是:

4,000,650,887,168 bytes

相关分区从这个扇区开始:

264192

按每扇区 512 字节计算,对应的字节偏移为:

135,266,304 bytes

APFS 卷报告的已用空间大约为:

3,260,976,717,824 bytes

一次只读 APFS 检查最终到达 fsroot 树,并报告:

Checking fsroot tree.
error: (oid 0xe36cb) apfs_root:
btn: dev_read_finish(3828538, 1): Input/output error
fsroot tree is invalid.

到这里,“ddrescue 几乎复制了整块硬盘”已经不足以作为完整诊断。原始克隆确实存在,但其内部的文件系统结构仍然不一致。

我刻意让文件系统检查保持不修改数据。我没有把 fsck_apfs -n 变成针对唯一一份高质量主克隆的修复操作。在普通存储上,修复可能是合理选择;但在这里,它会改变我仍在尝试理解的证据。

The Sleuth Kit 能列出 APFS 命名空间,却无法读取文件内容

The Sleuth Kit 4.15.0 是第一个让这个克隆看起来很有希望的恢复栈。使用整个克隆设备、已知的分区偏移,以及我定位到的 APFS 超级块后,我可以用 fls 枚举出真实目录名:

fls \\\\
  -o 264192 \\\\
  -B 4594668 \\\\
  -p \\\\
  /dev/rdiskN

我特意使用 N。macOS 的磁盘编号在重新连接和重启后会变化,所以我不会把之前的 /dev/disk6 分配结果当成设备身份。

fls 能遍历命名空间的很大一部分。仅其中一棵大型目录树就暴露出大约 8,900 个目录。有那么一刻,看起来最难的问题已经解决了。

然后我开始尝试读取文件内容。

一个有代表性的失败是:

libc++abi: terminating due to uncaught exception
of type std::runtime_error:
could not read APFSBlock

tsk_recover 可以开始提取,然后在某个 APFS 块上失败。单独尝试 icat 时,也出现了同一类问题。

关键区别在于:

目录遍历可用

并不意味着:

文件内容读取可用

解析器可能还保留着足够多的元数据,可以发现路径名,但之后在解析文件对象、extent 元数据或返回字节流所需的内容块时仍然失败。

我先让第一版批量恢复引擎具备容错能力,但这个解析器仍不适合这种损坏

我的第一反应并不是立刻换解析器,而是先让 TSK 提取流程更有韧性。

我围绕 fls 和 icat 写了一个 Python 恢复 wrapper。它把持久状态保存到 SQLite、记录失败、支持断点续传、把部分输出单独写入,并在第一轮降低低价值 macOS 维护数据的优先级。

我还加入了一条“热点”规则:如果同一目录中连续四个文件都以相同的零字节 APFSBlock 模式失败,脚本就不再继续把时间花在这个分支剩余部分,而是将其延后处理,再去处理其他位置。工作假设是,一簇相同失败可能共享同一个损坏的元数据依赖,而不是意味着几百个 payload 各自独立损坏。

调度逻辑是有用的,但底层 APFS 读取器并不是。

在某个记录下来的时点,恢复数据库包含:

DEFERRED_HOTSPOT: 30,356 files
FAILED:              486 files

这 486 个失败可分为:

409  APFSBlock crashes
75   rc=0 but output-size mismatch
2    other rc=1 failures

而结构化恢复流程得到的是:

0 useful recovered user files

这 75 个大小不匹配暴露了我自己 wrapper 里的一个 bug:一些系统元数据文件其实返回了数据,但我的解析器却把预期大小记录成了零。修正这个解释很重要,但并没有改变主导结果。普通用户文件仍然以零字节结束,并伴随 could not read APFSBlock。

我选了一个很小的 AVIF 文件作为可复现测试样本。TSK 知道它的预期大小:

expected size: 56,309 bytes
recovered:             0 bytes

这个文件比再跑一次耗时数小时的批量恢复更有价值。如果一种新方法连这个持续稳定失败的 56 KB 测试文件都恢复不了,那它就不值得直接面对剩下的数 TB 数据。

磁盘数据块和 APFS 元数据路径属于不同的故障域

到这个阶段,我有三个观察结果:

GNU ddrescue:
几乎整个物理源盘都已复制

TSK fls:
可以发现大量真实路径

TSK icat:
许多普通文件的内容仍然读取失败

把查找链在概念上拆开后,这三个观察结果完全可以同时成立:

路径名
   ↓
目录记录
   ↓
文件对象 / inode 元数据
   ↓
extent 映射
   ↓
物理数据块

靠近链条底部的数据块可能完好无损,但上层元数据链中的某个环节已经损坏。另一种可能是,两种 APFS 实现以不同方式遍历同一套损坏结构。

我没有定位出某一个损坏的 APFS 对象能够解释所有失败,因此不会把它声称为已确认的根因。证据真正支持的是一个更有用的实验:保持克隆字节完全不变,只更换解析器。

142 个 APFS checkpoint 并没有变成即时回滚路径

对 APFS checkpoint 描述符区域进行原始只读扫描后,我找到了 142 个候选 NXSB checkpoint 超级块,其事务 ID 范围从:

223133

一直到:

222992

一个很自然的问题是:更早的 checkpoint 会不会引用一棵更健康的元数据树?

我编译了 apfs-fuse,并通过 fuse-t 尝试不同的 checkpoint 事务 ID。最新 checkpoint 会卡住,较旧的 XID 也一样。我为每个 checkpoint 加上严格超时,连续 50 多次尝试都无法得到可用挂载。我还尝试了 NFS 和 SMB 两种 fuse-t 后端。

这并不能证明所有 checkpoint 都已经损坏。这个实验同时依赖 checkpoint、apfs-fuse、fuse-t、macOS 设备行为和挂载 backend。栈中任何一层失败,都可能表现为同样的结果。

后来另一个解析器报告 APFS 快照数量为零,这也再次提醒我必须明确区分:这些 checkpoint 事务状态与用户可见的 APFS 快照并不是同一回事。

连 --help 都会卡住的解析器无法诊断我的磁盘

我还编译了 go-apfs-v2。构建成功了,但即使只是:

apfs --help

也会卡住,最后只能由超时机制杀掉。

无论使用原始设备路径还是缓冲设备路径,最小化块检查同样会卡住。我也用 apfsutil 做了同类的有界测试;两种设备形式都在大约 15 秒后超时。

这些失败反而很有价值,因为它们阻止我得出错误结论。如果一个工具连自己的 help 路径都无法稳定完成,那么它对“损坏文件究竟还能不能恢复”所提供的证据就很弱。

于是我的原则变成:

在把恢复工具的失败当成数据状态的证据之前,先验证恢复工具本身。

阻止 macOS 自动挂载克隆,消除了另一项风险

macOS 本身也是另一个变量。我希望健康的目标盘正常挂载,但不希望每次重新连接存储时,Disk Arbitration 都自动尝试挂载已经损坏的 APFS 克隆。

我最终使用的安全顺序是:

  1. 连接并挂载健康的恢复目标盘。
  2. 验证其卷身份和可用空间。
  3. 冻结 diskarbitrationd。
  4. 确认没有活动的 mount_apfs 进程。
  5. 连接主克隆。
  6. 根据已知物理大小和 APFS 身份动态识别主克隆。
  7. 确认主克隆未挂载。
  8. 执行只读恢复工作。
  9. 停止时先保存状态,再恢复 Disk Arbitration,然后正常关机或弹出设备。

我实际用来冻结 Disk Arbitration 的命令是:

sudo kill -STOP "$(pgrep -x diskarbitrationd)"

我确认进程状态中包含 T,并在继续之前检查是否有残留的 mount_apfs 进程。

真正重要的安全经验不是某个具体磁盘编号,而恰恰相反:绝不要相信昨天的磁盘编号。重启后,之前的 /dev/disk6 可能已经指向另一台物理设备。我把 APFS UUID 和已知物理大小视为设备身份,再据此推导当前设备路径。

libfsapfs 恢复了同一个被 TSK 读成零字节的文件

突破来自另一种 APFS 实现 libfsapfs。

我在 macOS 上从源码编译了它。生成的 fsapfsinfo 二进制文件标识自己为:

fsapfsinfo 20260923

随后出现了一个重要的 macOS 设备接口差异。

原始字符设备分区:

/dev/rdiskNs2

会在偏移约 4096 的位置很快因参数无效的读取错误而失败。

而缓冲块设备形式:

/dev/diskNs2

可以工作。

同一个物理克隆,同一个 APFS 分区,不同的 macOS 设备接口。

fsapfsinfo 成功打开容器并找到一个卷。接着,我测试了那个 TSK 无法提取、大小正好为 56,309 字节的文件。

TSK 的结果是:

expected:  56,309
recovered:      0
APFSBlock failure

通过 libfsapfs,这个文件条目得到:

size: 56,309
MD5:  c6f56db33eafc1de0f52a035bc255dc7
RC:   0

这是第一个真正改变诊断方向的结果。克隆中的字节没有变化,测试文件没有变化,损坏的 APFS 也没有被修复。

改变的是 APFS 实现。

至少这时我已经知道:一个通过 TSK 看起来无法恢复的真实用户文件,仍然可以通过 libfsapfs 深入访问到足以完整读取其内容并计算 digest 的程度。

在把数 TB 数据交给 libfsapfs 之前,我先提取了一个已知失败文件

一次成功的 digest 还不足以让我直接启动数 TB 的恢复。我需要看到真实字节被写到目标盘上。

我没有猜测 libfsapfs 的 C API,而是直接阅读库源码,并沿用 fsapfsinfo 计算 digest 时已经使用的读取路径。随后,我为这个已知测试文件写了一个小型只读提取器。

这个提取器精确写出了:

56,309 bytes

到单独的恢复盘。提取后文件的 MD5 是:

c6f56db33eafc1de0f52a035bc255dc7

它与之前的 digest 一致。

只有到这一步,我才扩大这种方法的规模。单文件测试分别证明了三件事:路径名可以解析、可以提取完整的预期字节数,而且提取出来的字节会得到与此前完整内容读取相同的 digest。

批量恢复真正困难的主要是故障隔离和断点续传

当 libfsapfs 能恢复 TSK 无法恢复的文件后,最难的问题又一次发生了变化。我需要一个能处理超大目录树的系统,而且不能因为一个损坏分支、一次重启或一次 Ctrl+C,就让整项工作重新从零开始。

因此,批量恢复流水线采用了几个严格不变量:

  • 主克隆始终以只读方式打开。
  • 恢复输出只写到第二块 5 TB 硬盘。
  • 保留目录名和文件名。
  • 进度保存在 SQLite 中,因此即使进程退出或重启也能保留。
  • 每个文件先写入临时路径。
  • 只有完整写入预期大小后,临时文件才重命名为最终路径。
  • 断点续传时,可以识别已存在且大小符合预期的文件。
  • 失败或有问题的任务与已完成任务分开保存。
  • 检查可用空间,并保留安全余量。
  • 可重新生成的开发产物和低价值系统元数据可以跳过或降低优先级。

有一项性能改动立刻起效:我不再为每个文件单独重新打开 APFS 容器。

快速路径一次处理一个目录。worker 打开源、枚举该目录、恢复其中直接包含的普通文件,然后把子目录放回队列。如果某个目录失败或超时,controller 会把它标记为 DEFERRED,然后继续,而不是阻塞整个全局流程。

当正常 pending 工作全部处理完后,延后的目录会通过更慢的 fallback 路径重新处理,其中每个文件的工作隔离程度更高。剩余失败随后也可以独立重试。

发现目录
    ↓
恢复当前目录中的普通文件
    ↓
验证预期大小
    ↓
提交持久状态
    ↓
将子目录加入队列
    ↓
延后局部失败
    ↓
继续全局处理
    ↓
稍后进入 fallback 并重试

这种架构比一个巨大的递归命令更符合实际故障模式。损坏并不均匀,因此恢复系统也不应该强迫进度均匀推进。

为什么我用预期大小检查和原子重命名,而不是给数百万文件全部算哈希

在单文件验证阶段,MD5 很有用,因为我需要强证据来证明解析器确实读到了一个 TSK 无法读取文件的完整内容。

如果只是为了给数百万文件计算哈希,就对每一个已恢复字节再完整读取一遍,会增加大量 I/O。主提取流程因此采用了另一条不变量。

对于每个普通文件,APFS 元数据都会给出预期大小。worker 先写临时文件,只有完整读取结果与该预期大小一致后,才把它提升为最终路径。

这意味着,一次中断不应该留下一个短文件,却让它伪装成最终文件名下的完整文件。

大小一致并不是密码学意义上的完整性证明,我也没有把它当成这种证明。但对高吞吐量主流程来说,“预期大小验证 + 原子重命名”是一个实用的正确性边界,而有针对性的哈希仍可用于抽样和已知失败案例。

SQLite 让重启变得平淡,而不是灾难

恢复持续得足够久,以至于我必须关机并在之后继续。这个要求把设计从“脚本”变成了“可恢复工作流”。

收到 Ctrl+C 时,并不是直接丢下正在运行的子进程。controller 会捕获中断、停止 worker、把当前目录恢复到可继续处理的状态、提交 SQLite,然后退出。

一次干净停止在概念上类似这样:

当前目录 -> PENDING

CTRL+C:恢复已安全停止
状态已保存。运行同一条命令即可继续。

重启后,我重新做磁盘身份检查,然后运行同一条恢复命令。状态数据库会继续原有队列。

后来有一次重启从这个状态开始:

DEFERRED:     1
DONE:     73,015
PENDING:   7,254

这比一个普通进度条有意义得多。它说明数以万计已经完成的目录单元经受住了重启,而且剩余队列是明确可见的。

在更早的一次续传中,上一个会话被中断的目录重新被拾起。已经存在且大小符合预期的文件会被识别出来,因此只需写入缺失的工作。这正是我想要的行为:重启恢复流程应该是例行操作,而不是一件让人害怕的事。

326,799 个文件首次证明这种方法可以扩展

在把新提取器扩展到所有选定数据之前,我先选了一棵大型高优先级目录树作为批量验证目标。

完成状态报告为:

directories completed:     8,940
new files written:       302,541
existing/resumed files:   24,258
recorded failed files:         0

目标盘包含:

326,799 files
145,039,215,948 bytes

约为:

135.08 GiB

文件数量可以精确对上:

302,541 + 24,258 = 326,799

这棵已经完成的目录树中,没有遗留任何 .partial.* 文件。

那个 56,309 字节测试文件证明了解析器可以在 TSK 失败的地方成功;而这次 326,799 个文件的恢复则证明,同一种方法可以在一个规模可观的真实目录层级上工作,支持断点续传、识别已有文件,并且这次已完成流程中没有记录到文件失败。

更大规模的恢复在完成前已经跨过 160 万个新文件

高优先级目录树干净完成后,我把恢复范围扩大到剩余已选定的顶层数据。

在一次有意安排的安全停止点,SQLite 报告:

DONE directories:       39,015
PENDING directories:    17,017
DEFERRED directories:        1

new files written:   1,636,305
new bytes written: 902,715,716,335

这大约是:

840.72 GiB

这是当时各目录处理流程所记录的新写入数据量。

唯一一个 DEFERRED 目录并不意味着数据丢失。它意味着快速路径有意不再让这个局部问题拖慢无关工作。fallback 阶段的存在,本来就是为了以后专门回来处理这类情况。

后续会话都从同一个数据库继续。已完成数量不断增加,pending 队列不断减少。最终,我恢复了自己需要的每一棵已选定、可枚举的文件夹树。

关于最终恢复结果,我能诚实声称什么

我不会把结果描述成“每一个字节都恢复了”。证据并不支持这种说法。

原始 ddrescue map 中仍有大约 13.84 MB 没有被确认成功复制。我也无法证明,不存在某个文件系统对象因为用于枚举它的元数据恰好位于损坏区域,而彻底变得不可发现。

这些限制很重要,因为结构化恢复可以证明“一个已枚举对象被成功提取”,却无法证明“损坏后的命名空间如今已经无法揭示的某个对象,在历史上从未存在过”。

最强而且可靠的最终表述要更窄一些:

我需要的每一棵已选定、可枚举的文件夹树,都通过结构化恢复流程成功恢复。

我不需要就地修复主克隆,不需要再次使用那块持续咔哒作响的原始 Toshiba 来做批量提取,也不需要做一次牺牲目录结构和文件名的整盘 PhotoRec 式 carving。

失败的工具仍然提供了有价值的证据

回头看,成功路径似乎很简单:

ddrescue 克隆
    ↓
libfsapfs
    ↓
可断点续传提取器
    ↓
已恢复文件

但实际调查过程中并不是这种感受。如果把失败过的方法都删掉,也会同时删掉很大一部分有价值的工程经验。

TSK 让我知道,APFS 命名空间遍历和内容读取是两种不同的故障模式。

第一版 wrapper 的 bug 让我知道,恢复脚本自己的错误分类并不等于事实本身。

热点机制让我学会隔离局部失败,而不是允许它阻塞全局进度。

checkpoint 实验让我学会不要把 APFS 事务 checkpoint 和 snapshot 混为一谈。

FUSE 尝试让我知道,挂载失败除了文件系统数据本身之外,还可能牵涉多个层级。

那个会卡在 --help 上的 APFS 读取器让我知道,在解释工具诊断之前,必须先验证工具本身。

/dev/rdiskNs2 与 /dev/diskNs2 的差异让我知道,即使底层磁盘完全相同,操作系统 I/O 路径也可能改变解析器的行为。

而双盘架构让我可以放心在其他地方犯错,同时始终保持主克隆不变。

如果再做一次,我会沿用的恢复流程

  1. 对出现机械故障的存储停止常规文件系统操作。如果读取卡住、设备掉线或发出咔哒声,我会优先做可断点续传的块级克隆,而不是继续用 Finder 浏览。
  2. 使用 GNU ddrescue,并配合持久化 mapfile 和已验证的恢复域。mapfile 保留进度;已知源盘大小可以防止设备大小识别混乱本身成为恢复问题的一部分。
  3. 始终保留一份只读主克隆。不要修复、扩容、重新分区它,也不要把它当成恢复文件的存储盘。
  4. 把恢复文件写到第二块物理硬盘。源数据保全和输出存储是两种不同工作。
  5. 先以只读方式诊断克隆中的文件系统。一份位于健康硬件上、但包含损坏 APFS 元数据的物理克隆,属于逻辑恢复问题;它与一块正在发出咔哒声的源盘不是同一个问题。
  6. 选择一个可稳定复现的失败文件作为解析器测试。一个已知失败的 56 KB 文件,比几个小时的盲目批量提取更能说明替代解析器是否有效。
  7. 验证解析器本身。如果一个工具在真正读取源数据之前就卡住,不要把这种现象解释为“数据已经没了”的证据。
  8. 不要假设某一种 APFS 实现能够定义什么叫“可恢复”。面对同一份克隆字节,TSK 和 libfsapfs 的表现非常不同。
  9. 使用稳定属性识别磁盘,而不是临时设备编号。文件系统 UUID 和已知物理大小,都比昨天的 /dev/diskN 更可靠。
  10. 从一开始就让长时间运行的提取支持断点续传。持久状态、临时文件、原子重命名、延后任务、有界重试和干净停机处理,在这个规模上都属于正确性的一部分。
  11. 区分不同验证层级。ddrescue 百分比、可见路径名、预期大小匹配和内容哈希,证明的是不同事情。

那个看起来像终点的数字,其实只是第一阶段的终点

整个恢复过程中,最容易误导人的数字仍然是:

100.00%

它看起来像是在回答:“我把这块盘救回来了吗?”

但它真正回答的问题要窄得多:

ddrescue 成功复制了物理恢复域中的多少数据?

它没有回答 APFS 是否能重建命名空间,没有回答某个解析器是否能遍历另一个解析器拒绝的损坏元数据,没有回答恢复文件是否达到了预期长度,也没有回答一个包含数百万文件的提取流程能否在错误和重启发生后继续运行而不破坏自己的状态。

每一个层级都需要独立证据。

先成功的是物理恢复。文件系统仍然损坏。第一个解析器能暴露名称,却无法读取很多文件内容。另一种 APFS 实现成功读取了同一个测试文件。这个证明后来变成单文件提取器,提取器又变成可断点续传的恢复引擎,而这个引擎最终把我需要的已选定文件夹树恢复到了第二块硬盘上,同时主克隆始终保持未修改。

原始硬盘从未恢复健康,APFS 也从未神奇地自行修复。真正改变的是恢复模型。

块恢复、文件系统恢复和文件验证,是三个独立的工程阶段。把它们当成一个问题时,局面看起来几乎无解;把它们拆开后,问题就变得可以处理。