Назад в блог
7 октября 2026 г.Sergei Solod21 мин чтения

Как я восстановил файлы из повреждённого клона APFS после того, как GNU ddrescue скопировал 99,999654% диска на 4 ТБ

GNU ddrescue скопировал 99,999654% выходившего из строя 4-ТБ HDD, но клон APFS всё равно не проходил проверки файловой системы, а The Sleuth Kit мог перечислять пути, одновременно возвращая файлы нулевого размера. Я восстановил выбранные деревья каталогов, сохранив эталонный клон в режиме только для чтения, перейдя на libfsapfs и построив возобновляемый конвейер извлечения.

APFSGNU ddrescueВосстановление данныхlibfsapfsmacOS

GNU ddrescue показал 100.00%. Первый структурированный проход восстановления файлов дал 0 useful recovered user files.

Оба результата относились к одной и той же аварии 4-ТБ жёсткого диска, и именно это противоречие сделало восстановление намного интереснее, чем «склонировать неисправный диск и скопировать файлы». ddrescue выполнил свою работу исключительно хорошо: он скопировал примерно 99,999654% физического источника. Клон находился на исправном носителе. Но APFS по-прежнему был повреждён, macOS не давала нормально получить доступ к файловой системе, а первый набор инструментов восстановления APFS мог перечислять большие части дерева каталогов, одновременно не умея читать содержимое обычных файлов.

В итоге я восстановил все выбранные и обнаруженные деревья папок, которые мне были нужны, но только после того, как разделил происходящее на три отдельные задачи: физическое восстановление блоков, интерпретацию повреждённой файловой системы и массовое извлечение файлов с проверкой и поддержкой возобновления.

Сначала проблема перестала быть проблемой файловой системы

Оригинальный 4-ТБ Toshiba дошёл до состояния, в котором я больше не доверял ему обычные операции файловой системы. Некоторые чтения занимали 60–75 секунд. Операции могли зависать. Диск периодически исчезал из macOS, отчётливо щёлкал и иногда отключался.

Незадолго до отказа я записал примерно 300 ГБ дополнительных данных и выполнил массовое переименование, затронувшее около 500 000 файлов и каталогов. По времени это выглядело подозрительно: нагрузка была тяжёлой для метаданных, но я не могу доказать, что именно она вызвала аппаратный отказ. Возможно, она лишь достаточно сильно нагрузила уже нездоровый диск и тем самым проявила проблему.

Что я мог установить наверняка, так это поведение самого диска. Когда механический накопитель начинает зависать, исчезать и щёлкать, повторно ходить по каталогам — неправильный уровень абстракции. Обход каталогов может запускать дополнительные чтения и перемещения головок. Монтирование файловой системы может запускать работу с метаданными. Каждый эксперимент отнимает время у единственного компонента, оставшийся полезный ресурс которого неизвестен.

Поэтому я поменял цель с:

recover my files

на:

recover as many readable sectors as possible

Я использовал GNU ddrescue 1.30 с постоянным mapfile. Точный физический размер исходного диска был:

4,000,787,027,968 bytes

Mapfile был критически важен, потому что источник был недостаточно стабилен для одноразового копирования. Он позволял восстановлению переживать зависания, отключения, перезапуски и последующие проходы, не забывая, какие области уже удалось считать.

Я также столкнулся с практической проблемой небуферизованного пути устройства в macOS: видимый диапазон восстановления мог становиться бессмысленным вместо того, чтобы заканчиваться на реальной границе устройства. Поэтому я ограничил область восстановления известным физическим размером, указанным выше. В данном случае явное ограничение входа было мерой корректности, а не оптимизацией скорости.

Почему 100.00% в ddrescue не означало, что файлы в безопасности

Ближе к концу физического восстановления 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%

Это отличный результат восстановления блоков. Но это не результат проверки целостности файлов.

Расположение недостающих байтов важнее красивой итоговой цифры. Несколько потерянных мегабайт в неиспользуемом пространстве могут вообще никак не проявиться. Маленькая нечитаемая область внутри видео может повредить один файл. Ещё меньшая потеря в метаданных файловой системы может сделать труднодоступными многие в остальном целые экстенты данных.

На оставшуюся часть восстановления я принял следующую модель:

УровеньНа какой вопрос отвечаетЧего успех не доказывает
Восстановление блоковБыли ли скопированы физические сектора?Что APFS сможет восстановить каждый файл
Восстановление файловой системыМожно ли разрешить пути, метаданные и экстенты?Что каждый извлечённый байт корректен
Проверка файловПолучен ли файл ожидаемого размера или хеша?Что никогда не существовало файла, который теперь невозможно обнаружить

Я сделал ещё несколько последних попыток прочитать оставшиеся проблемные области. В конце концов они перестали давать полезные новые данные, а Toshiba при этом сильно щёлкал. На этом я прекратил использовать оригинал как активный источник восстановления.

Почему для восстановления одного 4-ТБ диска я купил два диска по 5 ТБ

Первым новым диском стал Seagate Expansion на 5 ТБ с точной физической ёмкостью:

5,000,981,077,504 bytes

На него я записал поблочный клон Toshiba. Исходная разметка занимала примерно первые 4 ТБ, оставляя около 1 ТБ за пределами скопированной разметки. Эту дополнительную ёмкость я намеренно не трогал.

Я не увеличивал контейнер APFS. Не переразмечал клон ради удобства. Не запускал на нём восстановление файловой системы. Этот диск стал эталонным клоном.

Затем я купил второй диск на 5 ТБ. Он был заново отформатирован, доступен на запись, отдельно протестирован и использовался только как место для восстановленных данных.

failing 4 TB HDD
        │
        │ GNU ddrescue
        ▼
5 TB drive #1
master block-level clone
READ-ONLY
        │
        │ APFS parsing and extraction
        ▼
5 TB drive #2
recovered files
WRITABLE

То есть ради восстановления тома примерно с 3,26 ТБ занятых данных я купил около 10 ТБ номинально нового пространства. Дополнительный диск был нужен не ради ёмкости. Он был нужен ради сохранения одного инварианта:

If an experiment is wrong, I can return to the same untouched master clone.

Ремонт, изменение размера, переразметка или запись восстановленных данных на эталонный клон смешали бы сохранность источника с экспериментами. Разделение источника и назначения на два физических диска делало ошибки обратимыми.

Прежде чем доверять диску назначения, я провёл тест записи и чтения примерно 10 ГБ. В этом тесте он стабильно держал около 144,4 МБ/с в обоих направлениях. Пробные небуферизованные чтения с эталонного клона давали примерно 28–49 МБ/с и во время этих проверок не воспроизводили характер физических ошибок 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 показывали тот же класс проблемы.

Ключевое различие было таким:

directory traversal works

не означало:

file content retrieval works

Парсер может иметь достаточно уцелевших метаданных, чтобы найти путь, но позже всё равно не справиться с разрешением объекта файла, метаданных экстентов или блоков содержимого, необходимых для возврата потока байтов.

Первый движок массового восстановления я сделал устойчивым к ошибкам, но парсер всё равно не подходил для такого повреждения

Сначала я попытался сделать извлечение через TSK более устойчивым, а не сразу менять парсер.

Я написал на Python обёртку восстановления вокруг fls и icat. Он хранил долговечное состояние в SQLite, журналировал ошибки, поддерживал возобновление, отдельно записывал частичные результаты и на первом проходе снижал приоритет малополезных служебных данных macOS.

Я также добавил правило «hotspot»: если четыре последовательных файла в одном каталоге завершались одинаковой ошибкой APFSBlock с нулевым результатом, скрипт переставал тратить время на остальную часть этой ветки, откладывал её и продолжал в другом месте. Рабочая гипотеза была такой: группа одинаковых сбоев может зависеть от одного повреждённого элемента метаданных, а не означать сотни независимо уничтоженных файловых данных.

Оркестрация была полезной. Базовый читатель 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 несовпадений размера выявили ошибку уже в моей обёртке: некоторые системные файлы метаданных возвращали данные, но мой парсер записывал ожидаемый размер равным нулю. Исправить эту интерпретацию было важно, однако основной результат не изменился. Обычные пользовательские файлы по-прежнему заканчивались нулём байтов с ошибкой could not read APFSBlock.

Я выбрал один небольшой AVIF-файл как воспроизводимый тестовый случай. TSK знал его ожидаемый размер:

expected size: 56,309 bytes
recovered:             0 bytes

Этот файл стал намного ценнее очередного многочасового массового прохода. Если новый подход не мог восстановить один тестовый файл размером 56 КБ, который стабильно падал, он не заслуживал доступа к оставшимся терабайтам.

Блоки диска и путь метаданных APFS были разными областями отказа

К этому моменту у меня было три наблюдения:

GNU ddrescue:
almost the entire physical source was copied

TSK fls:
many real paths were discoverable

TSK icat:
many ordinary file contents still failed

Эти наблюдения совместимы, если мысленно разделить цепочку поиска:

pathname
   ↓
directory record
   ↓
file object / inode metadata
   ↓
extent mapping
   ↓
physical data blocks

Блоки данных ближе к нижней части цепочки могут сохраниться, даже если более высокое звено цепочки метаданных повреждено. Есть и другая возможность: две реализации APFS по-разному проходят одни и те же повреждённые структуры.

Я не выделил один конкретный повреждённый объект APFS, который объяснял бы все сбои, поэтому не стал бы называть это подтверждённой корневой причиной. Зато имеющиеся данные поддерживали намного более полезный эксперимент: оставить склонированные байты неизменными и поменять парсер.

142 контрольные точки APFS не стали мгновенным способом отката

Низкоуровневое сканирование области дескрипторов контрольных точек APFS в режиме только для чтения нашло 142 кандидатных суперблока NXSB с идентификаторами транзакций от:

223133

вниз до:

222992

Очевидный вопрос состоял в том, не ссылалась ли более старая контрольная точка на более здоровое дерево метаданных.

Я собрал apfs-fuse и через fuse-t пробовал разные идентификаторы транзакций контрольных точек. Самая новая контрольная точка зависала. Старые XID вели себя так же. Я добавил жёсткие тайм-ауты для каждой контрольной точки, и более 50 последовательных попыток не дали пригодного монтирования. Я также пробовал бэкенды NFS и SMB для fuse-t.

Это не доказывало, что все контрольные точки повреждены. Эксперимент зависел от самой контрольной точки, apfs-fuse, fuse-t, поведения устройства в macOS и бэкенда монтирования. Сбой на любом уровне этого стека мог дать тот же видимый результат.

Позднее другой парсер сообщил о нуле снимков APFS, что дополнительно напомнило о важном различии: эти транзакционные состояния контрольных точек — не то же самое, что пользовательские снимки APFS.

Парсер, который зависает на --help, не может диагностировать мой диск

Я также собрал go-apfs-v2. Сборка завершилась, но даже:

apfs --help

зависало и требовало принудительного завершения по тайм-ауту.

Минимальная проверка блоков через raw- и буферизованный пути устройства тоже зависала. Такой же ограниченный по времени тест я провёл с apfsutil; обе формы устройства уходили в тайм-аут примерно через 15 секунд.

Это были полезные неудачи, потому что они не позволили сделать неправильный вывод. Инструмент, который не может надёжно пройти даже собственный путь справки, — слабое доказательство того, можно ли восстановить повреждённый файл.

Моё правило стало таким:

Сначала проверяй сам инструмент восстановления, и только потом считай его сбой свидетельством о состоянии данных.

Запрет автоматического монтирования клона в 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 может указывать на другое физическое устройство. В качестве идентичности я использовал UUID APFS и известный физический размер, а уже по ним определял текущий путь устройства.

libfsapfs восстановил тот же файл, который TSK возвращал как ноль байтов

Прорыв произошёл с libfsapfs — другой реализацией APFS.

Я собрал её из исходников на macOS. Получившийся бинарник fsapfsinfo представлялся как:

fsapfsinfo 20260923

После этого обнаружилось важное различие между интерфейсами устройств macOS.

Раздел через небуферизованное символьное устройство:

/dev/rdiskNs2

быстро завершался ошибкой чтения «invalid argument» около смещения 4096.

Буферизованный вариант блочного устройства:

/dev/diskNs2

работал.

Тот же физический клон. Тот же раздел APFS. Другой интерфейс устройства macOS.

fsapfsinfo открыл контейнер и нашёл один том. Затем я проверил тот самый файл размером 56 309 байт, который TSK не смог извлечь.

TSK выдавал:

expected:  56,309
recovered:      0
APFSBlock failure

Через libfsapfs запись файла дала:

size: 56,309
MD5:  c6f56db33eafc1de0f52a035bc255dc7
RC:   0

Это был первый результат, который существенно изменил диагноз. Склонированные байты не изменились. Тестовый файл не изменился. Повреждённый APFS не был починен.

Изменилась реализация APFS.

Как минимум теперь я знал, что реальный пользовательский файл, который через TSK выглядел невосстановимым, всё ещё был достаточно доступен через libfsapfs, чтобы прочитать всё его содержимое и вычислить дайджест.

Я извлёк один заведомо проблемный файл, прежде чем доверять libfsapfs терабайты данных

Успешного дайджеста мне было недостаточно, чтобы запускать многотерабайтное восстановление. Я хотел получить реальные байты на диске назначения.

Вместо того чтобы гадать о C API libfsapfs, я изучил исходники библиотеки и проследил путь чтения, который fsapfsinfo уже использовал при вычислении дайджеста. Затем я написал небольшой экстрактор только для чтения для одного известного тестового файла.

Экстрактор записал ровно:

56,309 bytes

на отдельный диск восстановления. MD5 извлечённого файла был:

c6f56db33eafc1de0f52a035bc255dc7

Он совпал с предыдущим дайджестом.

Только после этого я масштабировал подход. Тест одного файла доказал три отдельные вещи: путь удавалось разрешить, можно было извлечь полный ожидаемый объём байтов, а извлечённые байты давали тот же дайджест, что и предыдущий полный проход чтения содержимого.

Массовое восстановление в основном оказалось задачей изоляции сбоев и возобновления

Как только libfsapfs смог восстановить файл, который не мог TSK, сложная часть снова изменилась. Мне нужна была система, способная обработать очень большое дерево каталогов так, чтобы одна повреждённая ветка, одна перезагрузка или одно Ctrl+C не превращали всю работу в старт с нуля.

Поэтому конвейер массового восстановления опирался на несколько строгих инвариантов:

  • Эталонный клон открывался только для чтения.
  • Восстановленные данные записывались только на второй 5-ТБ диск.
  • Имена каталогов и файлов сохранялись.
  • Прогресс хранился в SQLite и поэтому переживал завершения процессов и перезагрузки.
  • Каждый файл сначала записывался во временный путь.
  • Временный файл переименовывался в окончательный путь только после записи полного ожидаемого размера.
  • При возобновлении можно было распознавать уже существующие файлы ожидаемого размера.
  • Сбойная или проблемная работа хранилась отдельно от завершённой.
  • Свободное место проверялось, и сохранялся резерв безопасности.
  • Воспроизводимые артефакты разработки и малоценные системные метаданные можно было пропускать или понижать в приоритете.

Одно изменение производительности сразу оказалось важным: я перестал отдельно переоткрывать контейнер APFS для каждого файла.

Быстрый путь обрабатывал по одному каталогу за раз. Воркер открывал источник, перечислял содержимое каталога, восстанавливал обычные файлы непосредственно в нём и возвращал дочерние каталоги в очередь. Если каталог завершался ошибкой или тайм-аутом, контроллер помечал его как DEFERRED и двигался дальше вместо блокировки всего прохода.

Когда обычной ожидающей работы больше не оставалось, отложенные каталоги повторно проходились по более медленному резервному пути с более изолированной работой по отдельным файлам. Оставшиеся ошибки после этого можно было повторять независимо.

discover directory
    ↓
recover immediate files
    ↓
verify expected sizes
    ↓
commit durable state
    ↓
queue child directories
    ↓
defer local failures
    ↓
continue globally
    ↓
fallback and retry later

Такая архитектура намного лучше соответствовала реальному распределению повреждений, чем одна огромная рекурсивная команда. Повреждения были неравномерными, поэтому и система восстановления не должна была требовать равномерного прогресса.

Почему я использовал проверку ожидаемого размера и атомарное переименование вместо хеширования миллионов файлов

MD5 был полезен в доказательстве на одном файле, потому что мне требовалось сильное подтверждение того, что парсер действительно читает полное содержимое файла, который TSK прочитать не мог.

Второй полный проход чтения каждого восстановленного байта только ради хеширования миллионов файлов добавил бы огромное количество I/O. Для основного прохода извлечения я использовал другой инвариант.

Для каждого обычного файла метаданные APFS давали ожидаемый размер. Воркер писал во временный файл и переводил его в окончательный путь только после того, как полный объём чтения совпадал с ожидаемым размером.

Это означает, что прерывание не должно оставлять укороченный файл, который выглядит завершённым под окончательным именем.

Совпадение размера не является криптографическим доказательством целостности. Я его таковым не считаю. Но проверка ожидаемого размера вместе с атомарным переименованием стала практической границей корректности для массового прохода, а целевые хеши оставались полезными для выборок и известных проблемных случаев.

SQLite превратил перезагрузку из катастрофы в рутину

Восстановление шло достаточно долго, чтобы мне приходилось выключать компьютер и продолжать позже. Это требование изменило дизайн со «скрипта» на «возобновляемый процесс восстановления».

Ctrl+C не просто бросало активный дочерний процесс. Контроллер перехватывал прерывание, останавливал воркер, возвращал активный каталог в восстанавливаемое состояние, фиксировал SQLite и завершался.

Чистая остановка концептуально выглядела так:

CURRENT DIRECTORY -> PENDING

CTRL+C: RECOVERY STOPPED SAFELY
STATE SAVED. RUN THE SAME COMMAND TO RESUME.

После перезагрузки я снова проверял идентичность дисков и запускал ту же команду восстановления. База состояния продолжала существующую очередь.

Один из более поздних перезапусков начался с:

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 файлов доказало, что тот же подход выдерживает существенную реальную иерархию с логикой возобновления, обнаружением уже существующих файлов и без зарегистрированных ошибок файлов в этом завершённом проходе.

До завершения крупное восстановление превысило 1,6 миллиона новых файлов

После того как приоритетное дерево завершилось чисто, я расширил восстановление на остальные выбранные данные верхнего уровня.

Во время одной намеренной безопасной остановки 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 не означал потерю данных. Это означало, что быстрый путь намеренно перестал позволять локальной проблеме задерживать независимую работу. Резервная фаза существовала именно для того, чтобы вернуться к таким случаям позже.

Последующие сессии возобновлялись из той же базы. Число завершённых каталогов росло, а очередь ожидания уменьшалась. В итоге я восстановил все выбранные и обнаруженные деревья папок, которые мне были нужны.

Что я действительно могу утверждать о конечном восстановлении

Я не буду описывать результат словами «восстановлен каждый байт». Данные этого не подтверждают.

В исходной карте ddrescue всё ещё оставалось примерно 13,84 МБ, успешное копирование которых не было подтверждено. Я также не могу доказать, что ни один объект файловой системы не стал полностью необнаружимым из-за того, что необходимые для его перечисления метаданные находились в повреждённых областях.

Эти ограничения важны, потому что структурированное восстановление может доказать, что перечисленный объект был извлечён; оно не может доказать историческое отсутствие объекта, который повреждённое пространство имён больше не способно показать.

Самое сильное итоговое утверждение уже:

Все выбранные и обнаруженные деревья папок, которые мне были нужны, были успешно восстановлены через структурированный процесс восстановления.

Мне не пришлось чинить эталонный клон на месте. Не пришлось снова использовать оригинальный щёлкающий Toshiba для массового извлечения. Не потребовался и полнодисковый карвинг в стиле PhotoRec, который пожертвовал бы структурой каталогов и именами файлов.

Даже неудачные инструменты всё равно дали полезные свидетельства

Задним числом успешный путь выглядит просто:

ddrescue clone
    ↓
libfsapfs
    ↓
resumable extractor
    ↓
recovered files

Но во время расследования всё ощущалось совсем не так, и удаление неудачных подходов убрало бы большую часть полезного инженерного урока.

TSK показал мне, что обход пространства имён APFS и получение содержимого — разные режимы отказа.

Первая ошибка в моей обёртке показала, что собственная классификация ошибок скрипта восстановления не является абсолютной истиной.

Механизм hotspot научил меня изолировать локальный сбой, а не позволять ему останавливать общий прогресс.

Эксперимент с контрольными точками научил меня не путать транзакционные контрольные точки APFS со снимками.

Попытки с FUSE показали, что неудачное монтирование может указывать на проблемы в нескольких слоях помимо самих данных файловой системы.

Читатель APFS, зависавший на --help, научил меня сначала проверять инструмент, а уже потом интерпретировать его диагностику.

Разница между /dev/rdiskNs2 и /dev/diskNs2 показала мне, что путь I/O операционной системы может менять поведение парсера даже при полностью одинаковом базовом диске.

А архитектура с двумя дисками дала мне возможность ошибаться во всём остальном, оставляя эталонный клон неизменным.

Процесс восстановления, которым я бы воспользовался снова

  1. Прекратить обычную активность файловой системы на механически умирающем накопителе. Если чтения зависают, устройство исчезает или щёлкает, я бы поставил возобновляемый поблочный клон выше изучения содержимого через Finder.
  2. Использовать GNU ddrescue с постоянным mapfile и проверенной областью восстановления. Mapfile сохраняет прогресс; известный размер источника не позволяет путанице с размером устройства стать частью самой задачи восстановления.
  3. Держать один эталонный клон только для чтения. Не чинить его, не менять размер, не переразмечать и не использовать как хранилище восстановленных файлов.
  4. Записывать восстановленные файлы на второй физический диск. Сохранность источника и хранение результата — разные задачи.
  5. Сначала диагностировать склонированную файловую систему только для чтения. Исправный физический клон с повреждёнными метаданными APFS — это логическая задача восстановления, а не та же самая проблема, что щёлкающий исходный диск.
  6. Выбрать один воспроизводимо проблемный файл как тест парсера. Заведомо проблемный файл 56 КБ рассказал мне об альтернативных парсерах больше, чем часы слепого массового извлечения.
  7. Проверить сам парсер. Если инструмент зависает до того, как осмысленно начинает читать источник, не считай это доказательством того, что данные потеряны.
  8. Не считать, что одна реализация APFS определяет саму возможность восстановления. TSK и libfsapfs очень по-разному работали с одними и теми же склонированными байтами.
  9. Идентифицировать диски по стабильным свойствам, а не по временным номерам устройств. UUID файловой системы и известный физический размер безопаснее вчерашнего /dev/diskN.
  10. С самого начала делать длительное извлечение возобновляемым. Долговечное состояние, временные файлы, атомарное переименование, отложенная работа, ограниченные повторы и корректная обработка остановки — часть правильности на таком масштабе.
  11. Разделять уровни проверки. Процент ddrescue, видимый путь, совпадение ожидаемого размера и хеш содержимого доказывают разные вещи.

Число, выглядевшее как финиш, было лишь концом первого этапа

Самым обманчивым числом во всём восстановлении всё равно оставалось:

100.00%

Оно выглядело как ответ на вопрос «Я спас диск?».

На самом деле оно отвечало на гораздо более узкий вопрос:

How much of the physical rescue domain did ddrescue successfully copy?

Оно не отвечало на вопрос, может ли APFS восстановить пространство имён. Не отвечало, сможет ли один парсер пройти повреждённые метаданные, которые другой парсер отвергает. Не говорило, имеет ли восстановленный файл ожидаемую длину. И не говорило, переживёт ли извлечение миллионов файлов ошибки и перезагрузки, не повредив собственное состояние.

Для каждого уровня мне понадобились отдельные доказательства.

Сначала успешно завершилось физическое восстановление. Файловая система всё ещё была повреждена. Первый парсер мог показывать имена, но не справлялся с содержимым многих файлов. Другая реализация APFS успешно прочитала тот же тестовый файл. Это доказательство превратилось в экстрактор одного файла, экстрактор — в возобновляемый движок восстановления, а движок в итоге восстановил нужные мне выбранные деревья папок на второй диск, пока эталонный клон оставался нетронутым.

Исходный жёсткий диск так и не стал исправным. APFS не починился магическим образом. Изменилась модель восстановления.

Восстановление блоков, восстановление файловой системы и проверка файлов — отдельные инженерные этапы. Когда я воспринимал их как одну проблему, ситуация казалась почти безнадёжной. Разделение сделало её управляемой.