Восстановление данных f2fs: fsck не видит чекпоинт

Восстановление данных с f2fs: как вытащить файлы, если fsck.f2fs не находит чекпоинт

«Мёртвый чекпоинт»: разбор структур файловой системы вручную, 6408 файлов с именами и деревом папок — и поиск того, кто всё это устроил

Как вытащить почти все данные с f2fs-карты, на которой сдался fsck, — и найти виновника

Есть особый сорт паники — когда файловый менеджер сообщает «Структуру необходимо почистить», а на карточке лежит диссертация. Штатный fsck.f2fs в такой ситуации разводит руками и выходит, ничего не сделав. R-Studio показывает всё дерево целиком, но за файлы просит денег.

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

Поэтому дальше f2fs разбирается руками — суперблок, NAT, иноды, dentry, — из образа извлекается 6408 файлов с именами и структурой, а в конце проводится форензика и находится тот, кто всё это устроил. Спойлер: это не «износ флеша». Это конкретный баг конкретной железки, и у него есть узнаваемые отпечатки пальцев.

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

Часть 1. Теория: что значит «данные пропали»

Три этажа

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

Этаж 1 — физический носитель. У HDD это сектора на пластинах. У флеш-памяти (SD, USB, SSD, eMMC) — страницы и блоки NAND, поверх которых работает FTL (Flash Translation Layer) — микропрограмма контроллера. FTL занимается тем, что делает флеш похожим на честный диск: транслирует логические адреса в физические, распределяет износ (wear leveling), собирает мусор.

Отсюда важнейшее следствие: логический адрес не равен физическому. При перезаписи сектора 12345 контроллер пишет данные в другую физическую страницу, а старую помечает мусором. Через блочный интерфейс до старой копии не добраться никогда, сколько ни сканируй. Именно поэтому существует chip-off — выпаивание чипа и чтение NAND напрямую, в обход FTL.

Этаж 2 — блочное устройство. /dev/sdb, /dev/mmcblk0. Плоский массив секторов по 512 или 4096 байт. Здесь лежит таблица разделов (MBR/GPT) — крохотная структура в начале диска, которая говорит «раздел начинается с сектора 2048». Потеря таблицы разделов выглядит катастрофой, а лечится за минуту: разделы находятся по сигнатурам их суперблоков.

Этаж 3 — файловая система. Вот тут и начинается всё интересное.

Из чего состоит файловая система

Любая ФС, от FAT до btrfs, решает четыре задачи, и каждая — отдельная структура на диске:

  1. Геометрия — где что лежит. Это суперблок (или boot sector в FAT/NTFS). Маленькая, почти никогда не меняющаяся структура. Обычно продублирована.
  2. Учёт свободного места — битмапы, таблицы аллокации.
  3. Метаданные — иноды (кто владелец, какой размер, права, где блоки) и каталоги (имя → номер инода).
  4. Данные — собственно байты файлов.

И пятая, необязательная, но самая коварная сущность: точка консистентности. Она нужна, чтобы ФС переживала внезапное отключение питания, а реализуют её по-разному. Классический журнал (ext4, NTFS, XFS) записывает намерение до изменения метаданных, и после сбоя либо докатывает операцию, либо откатывает. Log-structured ФС вроде f2fs журнала намерений не ведёт вовсе: она пишет всё новыми блоками, а время от времени сбрасывает чекпоинт — снимок согласованного состояния, к которому и откатывается после сбоя. Механика разная, роль одна, поэтому дальше слово «журнал» иногда употребляется про то и другое разом. Но стоит помнить: в f2fs это чекпоинт, а не лог.

Главное: ни журнал, ни чекпоинт не хранят пользовательских данных. Это служебный индекс, который избавляет от полной проверки ФС после сбоя. Но он же — самая часто перезаписываемая область на всём разделе. А значит, статистически именно он умирает первым. Что в описываемом случае и произошло.

Почему удаление — это не стирание

При удалении файла ФС делает три копеечных действия: убирает запись из каталога, помечает инод свободным, помечает блоки свободными. Сами байты остаются на месте. На этом построена вся индустрия восстановления.

Но у этой идиллии есть два убийцы.

Убийца первый — TRIM/discard. Современные ФС на SSD и картах (в том числе f2fs, у которой discard включён по умолчанию) сообщают контроллеру: «блоки 100–200 больше не нужны». Контроллер физически стирает соответствующие страницы NAND — иногда сразу, иногда при сборке мусора. После этого через блочный интерфейс обычно читаются нули: большинство контроллеров отдают их детерминированно, не дожидаясь физического стирания. Исходить надо из того, что удалённый файл на SSD с TRIM не восстанавливается. Строго говоря, гарантии нет ни в ту, ни в другую сторону — попадаются накопители, отдающие старое содержимое до сборки мусора, — но строить на этом план спасения нельзя. Если пропало что-то важное, носитель нужно немедленно размонтировать и не давать системе времени на фоновый discard.

Убийца второй — перезапись. Диск продолжают использовать, и новые файлы ложатся поверх старых.

Отсюда правило: при потере данных первым делом прекращается любая запись. Не «допишу отчёт и займусь», а прямо сейчас, umount, и в ящик стола.

Классификация повреждений

Что сломалось Симптом Насколько страшно
Таблица разделов «Диск не размечен» Пустяк. Ищется по сигнатурам суперблоков
Суперблок «Не удалось определить ФС» Пустяк. Есть резервные копии
Журнал / чекпоинт «Не удаётся смонтировать», fsck отказывается Средне. Штатный fsck часто бессилен, но данные целы
Метаданные (иноды, каталоги) Часть файлов пропала, имена потерялись Серьёзно. Спасает избыточность и самоидентификация
Сами данные Файлы есть, но битые Только частичная реконструкция
Физическая деградация Ошибки чтения, dmesg полон I/O error Клонировать немедленно, пока читается

Разбираемый случай — третья строка, самая обидная и самая благодарная. Сломался индекс, но не содержимое.

Два способа воскрешения

Способ А — восстановление по метаданным. Структуры ФС читаются в обход сломанного, дерево каталогов обходится, файлы вытаскиваются. Даёт имена, папки, даты. Требует понимания формата. Это то, чем занимаются R-Studio, DMDE, btrfs restore — и то, что ниже делается руками.

Способ Б — карвинг по сигнатурам (file carving). ФС игнорируется полностью. По диску ищутся узнаваемые начала файлов: JPEG начинается с FF D8 FF, PNG — с 89 50 4E 47, PDF — с %PDF, ZIP/DOCX — с PK\x03\x04. Инструменты: PhotoRec, scalpel, foremost.

Карвинг работает почти всегда, но цена высока: никаких имён и папок (на выходе f0123456.jpg), проблемы с фрагментированными файлами, гора мусора. Это последний рубеж, когда метаданные уничтожены полностью.

Почему дальше идёт возня с NAT и dentry, а не запуск PhotoRec на пятнадцать минут? Затем, что дерево каталогов — это тоже данные, иногда более ценные, чем содержимое файлов. Имя Объяснительная записка к выбору темы диссертационной работы.docx в папке Документы/Ильенковские чтения 2018/ несёт смысл, которого нет ни в одном байте самого файла. f0004821.docx не несёт ничего.

Практический вывод: сначала всегда пробуется способ А. Карвинг — от отчаяния и только тогда, когда метаданных больше нет.

Главный принцип: самоидентификация

Восстановление по метаданным возможно ровно настолько, насколько структуры ФС умеют называть себя сами. Если у структуры есть магическая сигнатура, контрольная сумма и (в идеале) собственный адрес, записанный внутри неё, — её можно найти тупым перебором по всему диску и проверить на подлинность. Никакого индекса не нужно: индекс восстанавливается перебором + валидацией.

Небольшой словарь сигнатур:

Структура Магия
Суперблок ext2/3/4 0xEF53 по смещению 0x38 в суперблоке
Заголовок extent ext4 0xF30A
Суперблок f2fs 0xF2F52010
Узел f2fs в футере записан собственный nid
Узел btrfs контрольная сумма + собственный bytenr + fsid
Запись MFT в NTFS ASCII FILE
Индексный блок NTFS ASCII INDX
Суперблок XFS XFSB

Строчка про f2fs — ключ ко всей статье. Подробности — в разделе про футер.

Где у кого что лежит

ФС Суперблок Точка консистентности Избыточность Чем чинить
ext4 смещение 1024, копии в группах блоков журнал jbd2 (инод 8) резервные суперблоки e2fsck -b 32768, debugfs, extundelete
XFS заголовок в каждой AG log (внутренний/внешний) по суперблоку на AG xfs_repair -L, xfs_db
btrfs 64 КБ, 64 МБ, 256 ГБ CoW + деревья, плюс log tree для fsync 3 копии SB, дубли метаданных btrfs restore -t, btrfs-find-root
f2fs смещение 1024, две копии checkpoint ×2 NAT ×2, SIT ×2 fsck.f2fs, dump.f2fs
NTFS boot-сектор + копия в конце тома $LogFile $MFTMirr ntfsfix, chkdsk, ntfsundelete

Закономерность видна сразу: у всех есть дубли и у всех — своя точка консистентности (btrfs держит её на CoW, а его log tree отвечает только за fsync). Дубли спасают, журнал убивает.

Три золотых правила

  1. Остановить запись. Немедленно.
  2. Снять образ. ddrescue с mapfile. Всегда, даже если «там точно всё хорошо».
  3. Экспериментировать только на копии. Образ подключается через losetup, и дальше с ним можно делать что угодно: сломался — развернуть заново из бэкапа образа.

Теперь, когда теория на месте, — к пациенту.

Часть 2. Пациент

Micro-SD на 512 ГБ. В анамнезе — f2fs, много папок, видео с телефона, папка «философия» с диссертацией. В один прекрасный день карта перестала монтироваться.

Первым делом — не паниковать и посмотреть, что живо. Таблица разделов:

# fdisk -l /dev/mmcblk0
Диск /dev/mmcblk0: 471,02 GiB, 505749176320 байт, 987791360 секторов
Тип метки диска: dos

Устр-во        Загрузочный начало     Конец   Секторы Размер Тип
/dev/mmcblk0p1 *             2048 987787263 987785216   471G Linux

Раздел на месте, начинается с сектора 2048 — это число ещё выстрелит. Дальше суперблок:

# blkid /dev/mmcblk0p1
/dev/mmcblk0p1: LABEL="sd512" UUID="21b2c39b-..." BLOCK_SIZE="4096" TYPE="f2fs"

Отличная новость: blkid читает метку, UUID и размер блока — значит суперблок цел. Восстанавливать таблицу разделов, как советуют на форумах, не нужно. Проблема глубже. Дальше dump.f2fs:

# dump.f2fs /dev/mmcblk0p1
Info: MKFS version
  "Linux version 6.0.2-artix1-1.1 (linux@artixlinux) (gcc (GCC) 12.2.0,
   GNU ld (GNU Binutils) 2.39.0) #1 SMP PREEMPT_DYNAMIC Sun, 16 Oct 2022"
Info: total FS sectors = 987785216 (482317 MB)
        Invalid CP CRC offset: 4294967295
        Invalid CP CRC offset: 4294967295
[f2fs_do_mount:3637] Can't find valid checkpoint

Прежде чем перейти к главному — маленькое лирическое отступление. Первая строка, MKFS version, это версия ядра той машины, на которой файловую систему создавали. То есть f2fs хранит паспорт своего родителя. И паспорт гласит: artix1, linux@artixlinux.

Да, разметка делалась под Артиксом. Btw.

Улика, впрочем, слабее, чем кажется: это дата сборки ядра, а не дата форматирования. Строго говоря, она доказывает лишь, что карту разметили не раньше 16 октября 2022 года. Привычка не путать доказательство с догадкой ещё пригодится к Части 7 — и вырабатывается она дорогой ценой.

Ну а теперь главное. Обе контрольные точки невалидны. Ядро говорит то же самое:

F2FS-fs (mmcblk0p1): invalid crc_offset: 4294967295
F2FS-fs (mmcblk0p1): Failed to get valid F2FS checkpoint

Число 4294967295 — это 0xFFFFFFFF. Его стоит запомнить: в конце статьи выяснится, откуда оно там взялось на самом деле. Забегая вперёд: очевидная версия про «стёртую страницу NAND» окажется неверной.

Часть 3. Сначала образ

Карта читалась медленно — средняя скорость за всё клонирование составила 2068 кБ/с, около двух мегабайт в секунду, — и подкидывала редкие ошибки чтения. Этого достаточно, чтобы не рисковать: никакого «поковыряюсь на живую», только ddrescue.

Тогда это списали на «износ флеша». В Части 7 выяснится, что диагноз был неверен, а решение — верным.

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

# проход 1: без скрапинга — забираем всё, что читается влёт
ddrescue -n /dev/mmcblk0p1 /mnt/backup/sd512.img /mnt/backup/sd512.map

# проход 2: добиваем плохие места, три попытки на сектор
ddrescue -r3 /dev/mmcblk0p1 /mnt/backup/sd512.img /mnt/backup/sd512.map

Финал через двое суток и почти двадцать часов:

  rescued:  505746 MB,   bad areas:          0,       run time:  2d 19h 54m
pct rescued:  100.00%, read errors:          7, remaining time:          0s

100%, ноль плохих областей. По дороге ddrescue семь раз споткнулся о чтение и отложил 344 КБ «на потом» — но добивочные проходы дочитали и их. В финале не потеряно ни байта. Повезло.

Почему NTFS-диск не монтируется сразу

Отдельная сага. Образ лился на внешний USB-диск на 3,6 ТБ с NTFS, и тот регулярно отказывался монтироваться. В dmesg — приговор:

ntfs3(sdb1): It is recommended to use chkdsk.
ntfs3(sdb1): volume is dirty and "force" flag is not set!

Стоит разобрать по косточкам, потому что грабли массовые.

Что такое dirty bit. В NTFS есть флаг в $Volume — «том смонтирован / изменялся и не был корректно отмонтирован». Его ставят при монтировании на запись и снимают при чистом размонтировании. Если систему выключили жёстко, кабель выдернули или драйвер упал — флаг остаётся взведённым. Это сигнал: «в журнале $LogFile могут лежать незавершённые транзакции».

Три типовые причины взведённого флага:

  1. Небезопасное извлечение. Классика для USB-дисков.
  2. Windows Fast Startup / гибернация. «Завершение работы» в Windows 8+ по умолчанию — это не выключение, а гибернация ядра. Том остаётся «занятым», в корне лежит hiberfil.sys. Linux, увидев это, откажется монтировать в rw — и правильно сделает: Windows держит метаданные в своём кеше и при пробуждении затрёт чужие изменения.
  3. Предыдущий сбой драйвера.

Два драйвера, разные характеры. В Linux их два, и это корень путаницы:

  • ntfs3 — в ядре с версии 5.15, написан Paragon. Быстрый. И принципиальный: увидел dirty bit — отказался монтировать, если не передан force.
  • ntfs-3g — userspace-драйвер поверх FUSE. Медленнее (данные ходят через userspace), зато старый, обкатанный и куда более покладистый: он сам разбирается с журналом и в большинстве случаев монтирует грязный том без пререканий.

Отсюда — та самая команда:

sudo mount -t ntfs-3g /dev/sdb1 /mnt/backup && echo "СМОНТИРОВАНО"

Здесь два трюка, и оба стоит понимать.

-t ntfs-3g — это не «тип файловой системы». Это указание mount вызвать хелпер /sbin/mount.ntfs-3g, то есть явно выбрать FUSE-драйвер вместо ядерного ntfs3. Просто -t ntfs на разных дистрибутивах уедет то в один, то в другой — а нужна определённость. Проверить, что сработал именно ntfs-3g, можно так:

$ findmnt /mnt/backup -o TARGET,SOURCE,FSTYPE
TARGET      SOURCE    FSTYPE
/mnt/backup /dev/sdb1 fuseblk      ← fuseblk = это FUSE, то есть ntfs-3g

Если бы примонтировался ядерный драйвер, в FSTYPE было бы ntfs3.

&& echo "СМОНТИРОВАНО" — про философию Unix. mount при успехе молчит, и непонятно, отработал он или вывод куда-то делся. Оператор && запускает вторую команду, только если первая вернула код 0, — получается явное подтверждение, а при падении mount виден только текст ошибки. Тот же приём полезен везде, где команда молчалива.

Альтернатива — снять флаг явно. Если хочется остаться на быстром ntfs3:

sudo ntfsfix -d /dev/sdb1                      # -d = снять dirty bit
sudo mount -t ntfs3 /dev/sdb1 /mnt/backup

ntfsfix — не chkdsk. Он не чинит структуру, а делает три вещи: проверяет базовую консистентность, очищает $LogFile, снимает dirty bit. Для «просто дай смонтировать» этого хватает. Для реально побитого NTFS — только chkdsk из-под Windows.

Аварийный вариант, когда надо здесь и сейчас:

sudo mount -t ntfs3 -o force /dev/sdb1 /mnt/backup

force означает «том грязный, но монтировать всё равно на запись». Если причина грязи — гибернация Windows, это прямой путь к потере данных. С диском, который делится с Windows, так делать нельзя.

И ещё соображение на будущее: NTFS — плохой приёмник для образа. Через FUSE запись 470 ГБ идёт медленно, а dirty bit будет преследовать вечно. Если есть выбор — приёмник форматируется в ext4.

Минутка ненависти: sudo

В разгар работы sudo перестал принимать пароль. Причина — русская раскладка плюс faillock с deny=3 и unlock_time=600. Три опечатки кириллицей — и доступ заблокирован на десять минут, причём внятного сообщения об этом никто не покажет. Диагностика: faillock --user $USER. Лечение: faillock --user $USER --reset (с другого терминала под root) или просто подождать.

Часть 4. fsck сдаётся

Образ подключается, и чинится копия:

losetup -f --show /mnt/backup/sd512.img   # → /dev/loop0
fsck.f2fs --dry-run /dev/loop0
fsck.f2fs -f /dev/loop0                   # -f = force fix

Оба раза одно и то же:

Invalid CP CRC offset: 4294967295
[f2fs_do_mount:3637] Can't find valid checkpoint

fsck.f2fs не умеет работать без чекпоинта. Он от него отталкивается: берёт из CP валидный nat_bitmap, актуальные указатели, счётчики — и только потом проверяет консистентность. Нет CP — нет точки опоры, программа выходит, ничего не записав.

Хорошая новость: она действительно ничего не записала. Плохая: штатными средствами f2fs тут ловить нечего.

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

Часть 5. Луч надежды из неожиданного места

От отчаяния запускается R-Studio (триал). И она показывает всё дерево — папки, имена файлов, кириллицу. Отдавать файлы отказывается: требует денег.

Но это бесценная информация. Раз коммерческий парсер строит дерево — значит иноды, dentry и NAT физически целы. Умер только чекпоинт. R-Studio, в отличие от fsck.f2fs, на него просто забивает и читает структуры напрямую.

Вывод: восстановление по метаданным (способ А) реально. Нужен инструмент, который читает f2fs в обход CP.

Ревизия бесплатного арсенала

Прежде чем писать свой велосипед, стоило честно перебрать всё, что можно скачать даром. Раскладка получилась такая.

Снять образ. Здесь всё хорошо.

  • GNU ddrescue — стандарт де-факто, GPL, mapfile, многопроходность. Выбор для этой задачи.
  • OpenSuperClone (GPL-форк заброшенного HDDSuperClone) — тяжёлая артиллерия для умирающих HDD: общается с диском через ATA/SCSI pass-through, читает регистры ошибок, умеет распознать сбойную головку и пропустить принадлежащие ей зоны LBA, чтобы сначала снять данные со здоровых. Отдаёт сессию как виртуальное блочное устройство, которое можно скормить DMDE или R-Studio. Для SD-карты избыточно, для сыплющегося винта — незаменимо.

Карвинг по сигнатурам. Тоже отлично.

  • PhotoRec (и GUI-обёртка QPhotoRec) — 480+ расширений, GPL.
  • foremost, scalpel, bulk_extractor — то же самое, разными руками.

Разбор ФС и восстановление по метаданным. А вот тут начинается интересное.

  • TestDisk — разделы, загрузочные секторы, undelete на FAT/exFAT/NTFS/ext2. На ext3 и ext4 undelete уже не работает: имена удалённых файлов он покажет, но адреса блоков в иноде к этому моменту обнулены, доставать нечего — там нужен extundelete.
  • The Sleuth Kit + Autopsy — форензический комбайн: NTFS, FAT, exFAT, ext2‑4, HFS+, UFS, YAFFS2.
  • extundelete, ext4magic — ext3/ext4.
  • btrfs restore — штатно в btrfs-progs.
  • ntfsundelete — NTFS.
  • DMDE Free Edition — восстанавливает до 4000 файлов за одну операцию из активной панели (вложенные каталоги в бесплатной версии ограничены).

Закономерность бросается в глаза с первого прохода по списку. Ни в одном из них нет f2fs.

DMDE официально заявляет FAT12/16/32, exFAT, NTFS, ReFS, Ext2/3/4, HFS+/HFSX, APFS, btrfs — и всё. Sleuth Kit — не поддерживает. TestDisk — не поддерживает. Форензическая литература подтверждает: f2fs остаётся вне зоны внимания почти всех инструментов, а немногочисленные исключения — коммерческие.

Из тех, кто f2fs действительно понимает:

  • R-Studio — умеет, дерево строит (это уже видели), но в демо-режиме не сохраняет файлы больше 256 КБ. Документы вытащит, видео — нет.
  • fsck.f2fs / dump.f2fs из f2fs-tools — умеют по определению, но, как уже выяснено, без живого чекпоинта не делают ничего.

Итог ревизии: бесплатного инструмента, который читает f2fs в обход убитого чекпоинта, не существует. Значит, придётся написать.

Часть 6. Парсер f2fs

Суперблок

Суперблок f2fs лежит по смещению 1024, магия 0xF2F52010. Оттуда достаётся геометрия (все значения ниже — реальный дамп пациента):

magic                  = 0xF2F52010
log_blocksize          = 12          → блок 4096 байт
log_blocks_per_seg     = 9           → сегмент 512 блоков = 2 МБ
block_count            = 123473152
segment_count_ckpt     = 2
segment_count_nat      = 102
cp_blkaddr             = 512         ← начало чекпоинта
sit_blkaddr            = 1536
nat_blkaddr            = 10752       ← начало NAT
ssa_blkaddr            = 62976
main_blkaddr           = 304128      ← начало данных
root_ino               = 3

Всё, что нужно для обхода дерева, — здесь. Чекпоинт не упомянут ни разу.

NAT и первый блин

Запись NAT — упакованная структура из девяти байт:

struct f2fs_nat_entry {
    __u8    version;
    __le32  ino;
    __le32  block_addr;
} __attribute__((packed));   // 9 байт

Отсюда NAT_ENTRY_PER_BLOCK = 4096 / 9 = 455.

Наивный nat_lookup считает, в каком блоке NAT лежит нужный nid, читает обе копии и берёт ту, у которой version больше.

def nat_lookup(self, nid):
    block_off = nid // NAT_ENTRY_PER_BLOCK
    seg_off   = block_off >> LOG_BPS
    base = NAT_BLKADDR + (seg_off << LOG_BPS << 1) + (block_off & (BLOCKS_PER_SEG - 1))
    idx  = (nid % NAT_ENTRY_PER_BLOCK) * 9
    best, bestver = 0, -1
    for phys in (base, base + BLOCKS_PER_SEG):     # две копии NAT
        ver, ino, addr = struct.unpack_from("<BII", self._nat_raw(phys), idx)
        if valid_addr(addr) and ver > bestver:
            bestver, best = ver, addr
    return best

Дерево обходится от root_ino = 3, читаются dentry, считается результат:

dirs  = 148
files = 6563
bytes = 33789961113676155709 (31469353580.55 GiB)
errors= 16

Тридцать миллиардов гигабайт на карточке в полтерабайта. Дерево обходится, имена читаются — но часть nid резолвится в мусор. Беда в том, что version вообще не про свежесть копии: какая из двух копий NAT актуальна, решает исключительно битмап из чекпоинта, а поле версии для этого никогда не предназначалось. Эвристика оказалась не той, и регулярно выбирается устаревшая копия NAT, которая указывает на давно переиспользованный блок. Блок честно читается, а случайные байты интерпретируются как инод с размером в экзабайты.

Спасение в футере

Каждый узловой блок f2fs (и инод, и индексный) заканчивается футером:

struct node_footer {
    __le32 nid;            // собственный nid узла
    __le32 ino;            // nid инода-владельца
    __le32 flag;
    __le64 cp_ver;
    __le32 next_blkaddr;
};                         // 24 байта; 4072 + 24 = 4096

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

def resolve_node(self, nid):
    """nid -> (addr, block) — выбираем ту копию NAT, чей узел
    в футере действительно называет себя nid."""
    ...
    best, bestver = None, -1
    for phys in (base, base + BLOCKS_PER_SEG):
        ver, ino, addr = struct.unpack_from("<BII", self._nat_raw(phys), idx)
        if not valid_addr(addr):
            continue
        blk   = self.rdblk(addr)
        f_nid = struct.unpack_from("<I", blk, NODE_FOOTER_OFF)[0]
        if f_nid == nid and ver > bestver:          # вот она, валидация
            bestver, best = ver, (addr, blk)
    return best

Для инода проверка ещё строже — у него в футере nid == ino == искомый номер, плюс i_mode обязан быть каталогом, файлом или симлинком:

def get_inode(self, ino):
    r = self.resolve_node(ino)
    if r is None: return None
    addr, blk = r
    f_nid, f_ino = struct.unpack_from("<II", blk, NODE_FOOTER_OFF)
    if f_nid != ino or f_ino != ino:
        return None
    info = self.parse_inode(blk)
    if (info['mode'] & 0xF000) not in (0x4000, 0x8000, 0xA000):
        return None
    return info

Перезапуск скана:

dirs  = 148
files = 6408
bytes = 22284094461 (20.75 GiB)
errors= 171

Цифры стали реальными. 171 ошибка — записи, не резолвящиеся ни в одном из двух NAT. Образ снят на 100%, так что нечитаемые секторы ни при чём: причина логическая. Часть этих записей — заведомо мёртвые узлы: удалённые файлы в .Trash-1000 и освобождённые иноды. Остальные — настоящая потеря, и в Части 7 выяснится, откуда она взялась. Около 2,6% — для восстановления без чекпоинта результат отличный.

Это главная идея всей статьи. Чекпоинт — индекс поверх данных, а не данные. Если структуры самоидентифицируются, индекс восстанавливается перебором и валидацией.

Адресация: как найти байты файла

У инода f2fs 923 слота адресов (i_addr, смещения 360…4052) и пять узловых указателей (i_nid[5] по смещению 4052): два прямых, два косвенных, один двойной. Прямой узел — блок из 1018 адресов данных, косвенный — блок из 1018 nid. Числа совпадают потому, что и там и там это __le32 в блоке за вычетом футера.

def data_addrs(self, info, max_blocks):
    ndirect = (I_NID_OFF - info['addr_base']) // 4
    out = list(struct.unpack_from("<%dI" % ndirect, info['blk'], info['addr_base']))
    i_nid = struct.unpack_from("<5I", info['blk'], I_NID_OFF)
    for fn, nid in ((direct, i_nid[0]), (direct, i_nid[1]),
                    (indirect, i_nid[2]), (indirect, i_nid[3]),
                    (double, i_nid[4])):
        if len(out) >= max_blocks: break
        fn(nid)
    return out[:max_blocks]

Полное извлечение даёт 21 ГБ файлов с именами и структурой папок. file уверенно опознаёт DjVu, ZIP, DOC (с метаданными автора и датами!), PDF, RTF. Работает.

Радость была недолгой.

Один баг, два симптома

Два неприятных факта:

  1. Папка Документы/философия — та самая, ради которой всё затевалось, — извлеклась пустой. Ноль записей.
  2. Мелкие видео целы, а большие битые: ffprobe говорит moov atom not found, хвост файла потерян.

Сначала это выглядело как два разных бага. Диагностика «философии» показала у неё в иноде:

i_inline = 0x05

Это F2FS_INLINE_DENTRY | F2FS_INLINE_XATTR. И всё встало на свои места.

Когда у инода взведён флаг INLINE_XATTR, последние 50 слотов массива i_addr (ровно 200 байт) отданы под расширенные атрибуты, а не под адреса данных.

Один и тот же недосмотр бил в двух местах:

  • для inline-каталогов он сбивал расчёт размера inline-области → область парсилась не с того места → ноль записей;
  • для больших файлов эти 50 xattr-слотов принимались за нормальные адреса блоков → в середину файла вставлялся мусор и сдвигал весь хвост. Потому moov, который лежит в конце, и оказывался не там, где его ищет плеер.

Одна строчка на два симптома:

INLINE_XATTR_ADDRS = 50    # 200 байт в хвосте i_addr

xattr   = INLINE_XATTR_ADDRS if (info['inline'] & F2FS_INLINE_XATTR) else 0
ndirect = (I_NID_OFF - info['addr_base']) // 4 - xattr

Но «философия» всё равно осталась пустой. Пришлось вывалить сырые байты inline-области — и в них прекрасно читались имена: otchet-po-praktike.docx, uch_plan_full.pdf. Значит, смещение массива записей считалось неправильно.

Анатомия dentry

Каталог f2fs (и обычный блочный, и inline) устроен одинаково:

[ битмап занятости ][ reserved ][ массив записей по 11 байт ][ слоты имён по 8 байт ]

Запись — 11 байт: hash (4) + ino (4) + namelen (2) + ftype (1). Имя длиннее восьми байт занимает несколько соседних слотов. Про reserved между битмапом и записями в первой версии парсера просто забыли.

Для обычного блока: NR = 214, битмап 27 байт, reserved 3 → записи начинаются со смещения 30. Для inline-каталога NR зависит от размера области. У «философии» (инод с inline-xattr) вышло NR = 182, битмап 23 байта, reserved 7 — и записи опять начинаются с 30. Совпадение забавное, но полагаться на него нельзя, считать надо честно:

NR       = (max_inline * 8) // (11 * 8 + 8 * 8 + 1)
bm_size  = (NR + 7) // 8
reserved = max_inline - ((11 + 8) * NR + bm_size)
de_off   = bm_size + reserved
name_off = de_off + NR * 11

Запуск — и «философия» оживает: 21 запись, подпапки «Ильенковские чтения 2018», «рабочая», файл «Объяснительная записка к выбору темы диссертационной работы.docx». А заодно чинятся хвосты больших видео. Один баг, два симптома, один фикс.

Итог парсера: 148 папок, 6408 файлов, 20,75 ГиБ, с именами и деревом. То, за что R-Studio просила денег. Триста с небольшим строк на Python.

Часть 7. Кто убил чекпоинт

Данные спасены, вопрос можно закрывать. Но остаётся то самое 0xFFFFFFFF. Очевидная версия — «стёртая страница NAND»: пустая ячейка флеша читается как 0xFF. Красиво, логично и, как выяснилось, неверно.

Что физически лежит в блоке чекпоинта (cp_blkaddr = 512):

$ xxd -l 32 -s $((512*4096)) sd512.img
00200000: 5553 4243 886b 0400 0030 0000 0000 0a2a  USBC.k...0.....*
00200010: 0000 0018 0000 0018 0000 0000 0000 00f5  ................

USBC. Это не мусор. Это сигнатура Command Block Wrapper протокола USB Mass Storage (dCBWSignature = 0x43425355). Структура целиком:

dCBWSignature          = 0x43425355 (USBC)
dCBWTag                = 0x00046b88
dCBWDataTransferLength = 12288 байт
bmCBWFlags             = 0x00  → OUT, то есть ЗАПИСЬ на устройство
bCBWLUN                = 0
bCBWCBLength           = 10
SCSI CDB: 2a 00 00001800 00 0018 00
   opcode = 0x2A  WRITE(10)
   LBA    = 0x1800 = 6144
   длина  = 24 сектора (12288 байт)

В блоке чекпоинта лежит команда SCSI WRITE(10), завёрнутая в USB-транспорт. И теперь — гвоздь программы. Раздел начинается с сектора 2048. Блок 512 в системе координат f2fs — это байтовое смещение 512 × 4096 = 2 097 152, то есть сектор 4096 внутри раздела. Абсолютный сектор на карте: 2048 + 4096 = 6144.

LBA в команде равен адресу того самого блока, в котором эта команда лежит.

Иначе говоря: контроллер получил команду «запиши 12288 байт по LBA 6144» — и записал по LBA 6144 саму команду вместо данных. Заголовок вместо посылки.

Дальше надо проверить, единичный ли это сбой. Весь образ целиком — все 471 ГиБ, 123 473 152 блока — прогоняется в поисках блочно-выровненных сигнатур USBC, и каждый кандидат проверяется на самосогласованность: равен ли LBA внутри команды абсолютному адресу того блока, где команда лежит. Восемьдесят пять минут чтения:

всего кандидатов USBC : 174
настоящих CBW (LBA == свой адрес): 173
ложных срабатываний  : 1

по областям: {'до CP': 1, 'CP': 6, 'SIT': 34, 'NAT': 43, 'SSA': 2, 'MAIN': 87}
opcodes: {'0x2a': 173}          flags: {'0x00': 173}   (все — WRITE, все — OUT)
dCBWTag: 0x3611 .. 0x6d469, уникальных 173
длина команды (блоков): 1×74, 3×39, 4×37, 8×6, 30×6, 5×4, 12×2, остальные по одной
CBW-сигнатура только в первом блоке команды: 99 из 99

Сто семьдесят три затёртых блока, и у всех ста семидесяти трёх LBA указывает ровно на себя. Все до единого — WRITE(10), все с флагом OUT. Теги уникальны: dCBWTag — сквозной счётчик USB-команд, так что разброс 0x3611…0x6d469 укладывается в одну рабочую сессию примерно из 430 тысяч команд.

Итого 173 испорченных блока по 4 КиБ — 692 КиБ. На карте в 471 ГиБ это 0,00014% — примерно одна семисоттысячная. И этой семисоттысячной хватило, чтобы убить файловую систему целиком.

Картина сложилась:

  • 6 попаданий в чекпоинт. Причём оба пакета, CP0 и CP1, получили пакет ровно в заголовочный блок — тот, где лежат checkpoint_ver и checksum_offset. Отсюда и Invalid CP CRC offset: 4294967295: f2fs читает поле checksum_offset по смещению 164, а там — байты FF FF FF FF из набивки CBW. И тут важная деталь: по одному этому полю версии «стёртый NAND» и «набивка CBW» неразличимы — обе дают FF. Различает их только сигнатура USBC четырьмя байтами раньше. Но и это ещё не вся правда — к этой мысли стоит вернуться через два абзаца, она перевернётся.
  • 43 попадания в NAT и 34 в SIT. Вот откуда брались «устаревшие копии NAT», указывающие в никуда, и та часть errors = 171, которую не списать на мусорку. Валидация по футеру спасала именно от этого.
  • Одно попадание в блок 16 — резервная зона перед чекпоинтом. Мимо суперблока. Промах, которому обязана вся эта статья: попади пакет в блок 0 или 1, где лежат обе копии суперблока, и blkid не сказал бы даже, что это f2fs.
  • 87 попаданий в область данных, двумя кучными группами: 42 блока в первых 32 ГиБ и 45 блоков между 384 и 416 ГиБ. Похоже на две сессии копирования файлов.

А теперь — цифра, которая объясняет всё. Плотность попаданий:

метаданные:      86 блоков на 304 128  →  1 затёртый блок на 3 536
область данных:  87 блоков на 123 169 024  →  1 затёртый блок на 1 415 736

В служебных областях порча в четыреста раз плотнее, чем в пользовательских данных. Мост ронял свои заголовки по всей сессии, но метаданные в ту сессию переписывались несопоставимо чаще любого отдельного участка данных — при создании каждого файла, при каждой синхронизации NAT/SIT/чекпоинта, — и потому собрали на себя почти всю дробь. Файлы, записанные один раз и больше не тронутые, попали под обстрел лишь дважды.

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

Мёртв? Он просто сдвинут

А теперь та самая мысль. Всю статью чекпоинт назывался мёртвым, он даже вынесен в заголовок. Так вот: чекпоинт жив.

Стоит присмотреться, что именно сделал кардридер. Он ведь не перезаписал блок — он вставил свой сектор в поток. Команда писала три блока подряд, мост воткнул в начало буфера собственный 512-байтный заголовок, всё остальное сдвинулось на один сектор вниз, а последний сектор буфера свалился за край и потерялся. Классический сдвиг на единицу.

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

# снимаем сдвиг — пропускаем вставленный CBW-сектор
cp = read(block=512, plus=512)         # 4096 байт со смещения 512×4096 + 512
co = u32(cp, 164)                       # checksum_offset
# co == 4092 — уже не 0xFFFFFFFF, а совершенно нормальное значение!
stored = u32(cp, co)                    # CRC, которую записала сама f2fs

# ВАЖНО: f2fs считает crc32 как ядро (crc32_le) — с сидом-магией и БЕЗ
# входной/выходной инверсии. Наивный zlib.crc32(cp[:co], magic) даст НЕ то
# (0x8c2bd614). Инверсии снимаются двойным XOR с 0xFFFFFFFF:
import zlib
MAGIC = 0xF2F52010
calc  = zlib.crc32(cp[:co], MAGIC ^ 0xFFFFFFFF) ^ 0xFFFFFFFF
CP pack 0:  ver=979279901  checksum_offset=4092  CRC записана 0xec10d29d  посчитана 0xec10d29d  ✓
CP pack 1:  ver=979279902  checksum_offset=4092  CRC записана 0x7c7ef728  посчитана 0x7c7ef728  ✓

Контрольная сумма сошлась. На обоих пакетах. Побайтово. Версии — 979279901 и 979279902, две подряд идущие штатные контрольные точки. Это не обломки. Это целый, валидный чекпоинт, просто сдвинутый на один сектор.

fsck.f2fs читает checksum_offset жёстко по смещению 164 от начала блока, видит там 0xFFFFFFFF — набивку чужого CBW — и сдаётся. Он в двух шагах от цели: сдвинься его указатель на 512 байт вправо, и он нашёл бы нетронутую контрольную точку с правильной CRC. Но он читает строго по спецификации, а спецификация не знает про кардридеры.

Больнее всего вот что. В этом самом CRC-валидном заголовочном блоке лежит nat_bitmap — те 3264 байта, что говорят, какая из двух копий NAT актуальна для каждого узла. То есть ответ на вопрос, ради которого писалась вся валидация по футерам, всё это время лежал в чекпоинте, целый и с правильной контрольной суммой, в 512 байтах от того места, куда смотрел fsck.

Две недели данные вытаскивались в обход «мёртвой» контрольной точки, которая была жива.

Оправдание есть, и то отчасти. Даже с воскрешённым чекпоинтом остаются 173 раны: в NAT, SIT и данных, и каждая уронила свой последний сектор. nat_bitmap укажет актуальную копию NAT — но в 43 местах сама эта «актуальная» копия сдвинута в мусор, и там всё равно нужен запасной план. Валидация по футеру — и есть этот запасной план, причём работающий вообще без чекпоинта: она проверяет каждый узел независимо и объезжает все 173 раны разом. Так что перебор с футерами оказался даже надёжнее, чем «починить чекпоинт и натравить fsck». Посылка была ложной, но метод свой хлеб отработал.

И это вторая мораль статьи, дороже первой: «не читается штатным инструментом» и «уничтожено» — разные вещи. Между ними иногда ровно 512 байт.

Диагноз с именем

У болезни, как выяснилось при поиске, есть имя. Специалист по восстановлению данных Joep с сайта DiskTuna описал ровно это: внезапно появляющийся на карте файл-призрак с именем USBC и разъехавшаяся файловая система. Механизм совпадает слово в слово: «USBC-сектор не перезаписывает, а вставляется; остаток буфера сдвигается на сектор, и последний сектор буфера теряется». Есть и утилита usbc-sector-patcher, которая лечит это автоматически: находит CBW, сдвигает все секторы посылки на один LBA назад и добивает нулями потерянный хвостовой сектор. Метод в её README приписан Nguyen Vu Ha из DRC Recovery. Скрипт рабочий, но сырой — в комментариях у Joep жалуются на бесконечный цикл и сбитый стартовый индекс, так что перед запуском стоит заглянуть в issues и работать только с копией образа.

Так что новый баг здесь не открыт — самостоятельно переоткрыт известный, весь путь от 0xFFFFFFFF до USB-транспорта пройден своими ногами. Что, впрочем, тоже неплохо: теперь он узнаётся grep-ом за секунду.

Призрак предыдущей жертвы

Остался один кандидат из 174 — тот самый, что не прошёл валидацию. Структурно это безупречный CBW: сигнатура на месте, bCBWCBLength = 10, внутри WRITE(10). Но его LBA указывает на блок 2 130 210, а сам он лежит в блоке 42 052 774. И тег 0x2b72 не попадает в диапазон остальных ста семидесяти трёх.

Значит, на месте его не писали. Это содержимое файла — копия блока, затёртого когда-то на другом носителе и позже перенесённая сюда вместе с архивом. В корне карты, между прочим, лежат папки Архив флешки и Архив красной флешки.

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

Кто стрелял

Важная деталь: LBA в этих командах отсчитываются от начала SD-карты (2048 + блок × 8), а не от начала раздела, не от начала образа и уж точно не от начала внешнего USB-диска, на который лился образ. Значит, порча возникла на самой карте, её виновник знал абсолютную геометрию карты — и произошло это до снятия образа: сдвинутые секторы были на месте уже в момент самой первой диагностики.

Дальше — простая дедукция. Образ снимался через встроенный слот (/dev/mmcblk0), а там нет никакого USB Mass Storage: SD-хост общается с картой напрямую, командами SD, безо всяких CBW. Структура USBC физически не может родиться на этом пути. Зато карта, как вспомнилось по ходу расследования, подолгу работала в USB-кардридере.

Вот и убийца. Мост кардридера, получая команду записи, вместо полезной нагрузки укладывал на носитель собственный командный заголовок. Не «износ флеша», не космические лучи, не «китайская подделка на 512 ГБ» (подделка выдала бы себя иначе: образ снялся на все 471 ГиБ без единой дыры, а попадания CBW есть и в районе 384–416 ГиБ — карта честно пишет и отдаёт данные по всему объёму) — кривая или умирающая прошивка переходника ценой в три доллара.

Стоит присмотреться, что именно он делал. Команды были разной длины — от одного блока до тридцати. Из 173 пакетов 99 описывают запись длиннее блока, и во всех девяноста девяти случаях сигнатура USBC стоит ровно в первом блоке команды — внутри посылки второго заголовка нет ни разу. Один сбой на команду, всегда в голове буфера.

Только «в голове буфера» не значит «дальше всё долетело». Заголовок не заменил собой сектор данных, а встал перед ними: остаток посылки уехал на 512 байт, а последний её сектор свалился за край и пропал. Тридцатиблочная запись доехала целиком — но сдвинутой и потеряв на выходе последние полкилобайта из ста двадцати килобайт.

Напрямую это проверено только на блоках чекпоинта — там сдвиг снимается и CRC сходится (см. выше, «Мёртв? Он просто сдвинут»). На остальных попаданиях приходится опираться на механизм, независимо описанный DiskTuna и реализованный в usbc-sector-patcher: оба сдвигают всю посылку, а не первый блок.

Главное здесь то, что данные не уничтожены, а смещены. Поэтому ФС и не рассыпалась мгновенно, и поэтому её вообще удалось потом собрать. Карта честно отработала три с лишним года и умерла не от износа, а разом — за одну сессию (все теги лежат в одном диапазоне, о чём ниже). Удар пришёлся туда, где в ту сессию шла самая частая перезапись, — в служебные области.

Спусковой крючок

Остаётся вопрос: почему всё случилось именно в тот день? Ответ дают сами теги команд — и он в точности совпадает с обстоятельствами отказа. Карта отказала во время большого копирования в KDE Plasma, когда файлы лились с нескольких источников в несколько потоков сразу.

dCBWTag — счётчик, который на каждую команду увеличивает хост, а не мост: в Linux это одна строчка в drivers/usb/storage/transport.cbcb->Tag = ++us->tag;. Устройство лишь возвращает тег обратно в статусном блоке. Для расследования это удача: счётчик заводится драйвером при подключении устройства и с него же начинает считать, так что непрерывный диапазон тегов — не догадка, а прямое свидетельство одной сессии. Раскладка 173 попаданий по тегам и адресам:

вся сессия        : теги 0x3611 … 0x6d469   (≈ 430 000 команд)
метаданные        : 0x3611 … 0x6d469,  порядок относительно адреса СБИТ (12 инверсий на 86)
данные 0–32 ГиБ   : 0x3d3ad … 0x6d466  (растянуты почти на всю сессию)
данные 384–416 ГиБ: 0x3d3aa … 0x3d9f7  (плотный кусок, строго по возрастанию, с шагом в одну команду)

Читается это так. Метаданные получали пулю с первой команды сессии до последней и вперемешку с адресами — то есть NAT, SIT и чекпоинт переписывались непрерывно всё копирование, чередуясь с потоками данных. А два района данных, разнесённые на сотни гигабайт, писались с перекрывающимися диапазонами тегов: пока один поток последовательно, блок за блоком, заливал область под 400 ГиБ (теги 0x3d3aa…0x3d9f7, явно один большой источник), другой в те же мгновения долбил начало карты (теги оттуда — вплоть до 0x6d466, почти до конца сессии). Две записи в два разных конца диска в один и тот же тег-момент — это и есть подпись параллельного копирования.

Вот когда мост и надорвался. Dolphin, файловый менеджер KDE, ставит копирование в несколько заданий разом; несколько источников в несколько потоков — ровно та конкурентная нагрузка, на которой дешёвый переходник теряет синхронизацию очереди «команда → данные» и роняет заголовок в поток данных. Что срыв случается именно под параллельной нагрузкой — это уже реконструкция по тегам, а не цитата: сам Joep о причине появления USBC-сектора честно писал «no idea really». Карта не изнашивалась годами — её убили за одну сессию массового копирования, и по тегам видно, как именно.

Практический вывод, который стоит вынести в рамочку:

WARNING

Отдельная ирония на прощание: если бы красивая версия про стёртый NAND была принята на веру и природа 0xFFFFFFFF осталась неисследованной, убийца не нашёлся бы, карта вернулась бы в тот же кардридер и история повторилась. А заодно так и осталось бы неизвестным, что хоронили живого.

Часть 8. Что из этого применимо к другим ФС

Главный урок универсален: журнал/чекпоинт — точка консистентности, а не хранилище пользовательских данных. Ломается почти всегда именно он, потому что его перезаписывают чаще всего. Данные и метаданные при этом обычно живы. А иногда, как выяснилось, жив и сам чекпоинт — просто штатный инструмент не смог его прочитать. «Не читается» и «уничтожено» — разные вещи, и это стоит проверить, прежде чем поверить.

Дальше вопрос только в том, самоидентифицируются ли структуры. В f2fs — да, через node_footer.nid. Аналоги в других ФС:

  • ext4. Суперблок продублирован в группах блоков: mke2fs -n /dev/sdX покажет, где лежат копии, e2fsck -b 32768 поднимет ФС с резервной. Иноды лежат в таблице инодов по фиксированному смещению — их можно читать напрямую. Если убит журнал: tune2fs -O ^has_journal (на копии!) и затем e2fsck. extent-узлы начинаются с магии 0xF30A и ищутся перебором.
  • btrfs. Три копии суперблока (64 КБ, 64 МБ, 256 ГБ). Каждый узел дерева содержит собственный bytenr и контрольную сумму — та же самая самоидентификация. btrfs restore вытаскивает файлы из повреждённой ФС без монтирования, -t позволяет указать альтернативный корень, а btrfs-find-root этот корень ищет.
  • XFS. xfs_repair -L обнуляет журнал — иногда этого достаточно. Суперблок продублирован в каждой группе распределения.
  • NTFS. Первые записи $MFT продублированы в $MFTMirr, а каждая запись начинается с сигнатуры FILE — ищется перебором по всему тому. ntfsundelete умеет доставать удалённое.
  • exFAT. Самоидентификации почти никакой: ни $MFT, ни сигнатур у записей каталога, а FAT, в отличие от FAT32, всего одна. Зато есть резервная копия загрузочной области (секторы 12–23), а каталоги — простые массивы 32-байтных записей, которые находятся вручную.

Общий алгоритм на все случаи жизни:

  1. Остановить запись. Отмонтировать, вынуть, положить в ящик.
  2. Снять образ. ddrescue с mapfile. Через заведомо исправный интерфейс.
  3. Диагностировать по копии. losetup + любые эксперименты.
  4. Определить, что именно умерло. Суперблок? Журнал? Таблица разделов? Чаще всего — журнал, и это лучший из вариантов.
  5. Если штатный fsck упирается — не давить на него. -f, --force и прочие «ну давай же» на сломанном журнале либо не сделают ничего, либо сделают хуже.
  6. Искать самоидентифицирующиеся структуры и собирать индекс заново. Это реалистичнее, чем кажется: весь парсер f2fs из этой статьи — 336 строк на Python.
  7. Разобраться в первопричине. Иначе носитель вернётся в ту же мясорубку.

Какую ФС надо было выбрать

Начать стоит с покаяния. Карта была отформатирована в f2fs по соображению «Samsung придумал её для флеша». Пора честно разобрать, что стоило выбрать.

f2fs для флеша действительно хороша — log-structured, дружит с FTL, TRIM из коробки. И, справедливости ради, по скорости выбор был не так уж глуп: на флеш-носителе f2fs уверенно бьёт ext4 в том паттерне, который для карт важнее всего, — случайной записи мелкими блоками. В замерах LeMaker (Banana Pi Pro, карта Samsung PRO Endurance 32 ГБ, ядро 6.6, корневая ФС) она даёт около 1800 IOPS на 4K-случайной записи против ~800 у ext4 (в 2,2 раза быстрее), создаёт 10 000 файлов за 22 с против 45 с, а полный fsck тех же 32 ГБ проходит за 30–90 с вместо 1–3 минут. Это один прогон на одной конфигурации, а не закон природы, но порядок величин правдоподобен. Против exFAT картина смешанная — с оговоркой, что единственное прямое сравнение, бенчмарки Phoronix на USB-флешке, сделано в марте 2013 года на ядре 3.9-rc1, когда exFAT работал только через FUSE: ядерного драйвера ещё не существовало. Сегодняшнюю картину эти цифры не описывают.

Так что глупостью был не выбор быстрой ФС, а выбор экзотической. Цена оказалась вполне ощутимой: f2fs не понимает ни один бесплатный инструмент восстановления общего назначения (DMDE, TestDisk, Sleuth Kit — мимо; штатные fsck.f2fs и dump.f2fs не в счёт — без чекпоинта они бесполезны), и существует она только под Linux. Карту, которая кочует между телефоном, камерой и чужими компьютерами, нельзя запирать в формат, который читает одна ОС и умеет вскрыть один человек со спецификацией ядра в руках.

Для съёмного носителя правильный выбор — exFAT. Не потому что он надёжнее по устройству: журнала у него нет, и грязное отключение рвёт метаданные больнее, чем у журналируемой f2fs. А потому что его читает всё (Windows, macOS, Linux, камеры, телевизоры) и понимает любой восстановитель, включая бесплатные. Надёжность съёмной карты — это не изящество её ФС, а вероятность, что данные потом достанут. exFAT достанут где угодно и за десять минут; f2fs — только если рядом окажется энтузиаст с f2fs_fs.h.

Правило простое: ФС с контролем целостности (ext4, f2fs, btrfs) — стационарному носителю на одном хосте; exFAT — всему, что вынимается и путешествует.

Но вся соль в том, что в этом конкретном случае выбор ФС не спас бы ничего. Ридер втыкал свои CBW-заголовки в самые часто перезаписываемые блоки — метаданные. У exFAT это битмап аллокации и каталоги (а FAT, в отличие от FAT32, даже не зеркалируется), и пакет туда уронил бы карту ровно так же — только чинилась бы она потом легче. Ни одна файловая система не переживёт железку, которая пишет мусор вместо данных.

Баги флешек: слабое звено — не карта

Отсюда — вывод о надёжности флеш-носителей, который дороже спора «f2fs против exFAT»:

  • Слабое звено — не карта и не ФС, а прошивка контроллера или моста. Контроллер карты, флешки или ридера — это отдельный микрокомпьютер, и именно в его прошивке заводятся баги, тихо портящие данные при записи: вставленные CBW-секторы, перепутанные очереди команд, потерянные под нагрузкой блоки. Сама NAND-память при этом исправна.
  • Писать надо через заведомо исправный интерфейс. Встроенный слот mmcblk надёжнее случайного переходника за три доллара — там нет прослойки USB Mass Storage, а значит, неоткуда взяться USBC в потоке данных.
  • Ответственную запись нельзя доверять безымянным ридерам и хабам — особенно многопоточное копирование больших объёмов, ровно ту нагрузку, на которой дешёвый мост срывается (это видно по тегам).
  • Нужен бэкап. Единственная стопроцентная защита от бага прошивки, который нельзя ни увидеть, ни починить, — вторая копия на другом носителе. Никакая файловая система её не заменит.

И два слова напоследок.

Первое: не стоит соглашаться на карвинг раньше времени. PhotoRec отработал бы за вечер и вернул бы закаты, котов и мешок безымянных .docx. Дерево каталогов — это структура, которую кто-то выстраивал руками годами, и она хранит смысл, не записанный больше нигде. Восстановить содержимое без имён — значит восстановить половину.

Второе: прежде чем платить за R-Studio, DMDE или «специалистов по восстановлению», стоит открыть спецификацию файловой системы. Она бесплатная и лежит в исходниках ядра. Парсер из этой статьи писался два вечера, а окупился диссертацией и найденным убийцей.

Источники: кто что нашёл и чем лечить

Кто что открыл

  • Форматы f2fs on-disk — Jaegeuk Kim (Samsung) и сообщество ядра. Первоисточник, по которому сверялся каждый оффсет в этой статье: include/linux/f2fs_fs.h (f2fs_super_block, f2fs_nat_entry, f2fs_inode, node_footer, f2fs_dir_entry, f2fs_checkpoint) и Documentation/filesystems/f2fs.rst.
  • «USBC»-повреждение (вставка CBW со сдвигом сектора) — первым внятно описал Joep van Steen, DiskTuna: «Sudden appearance of USBC named folder and files» («Внезапное появление папки и файлов с именем USBC»). В этой статье к тому же механизму пришли независимо, от 0xFFFFFFFF; подтверждение обнаружилось уже потом.
  • Автолечение сдвига — утилита usbc-sector-patcher (DRCRecoveryData): находит CBW-секторы, сдвигает все секторы посылки на один LBA назад, добивает нулём. Метод в README приписан Nguyen Vu Ha из DRC Recovery; скрипт сырой, стоит заглянуть в issues перед запуском.
  • Глючные UAS-мосты (JMicron, ASMedia) и обход через квирки — коллективный опыт линуксового сообщества; параметр usb-storage.quirks в документации ядра. Смежная, но другая болезнь: к вставке CBW эти квирки отношения не имеют.
  • Форензика удалённых данных в f2fs — академический разбор той же задачи: «Advanced forensic recovery of deleted file data in F2FS» («Продвинутое восстановление удалённых данных в F2FS»), журнал Forensic Science International: Digital Investigation, 2025.
  • Структура CBW — спецификация транспорта USB Mass Storage Bulk-Only Transport (USB-IF, usbmassbulk_10.pdf): в неё завёрнуты SCSI-команды, ходящие по USB.

Чем снимать образ

  • GNU ddrescue — стандарт де-факто, mapfile, многопроходность. Использован здесь.
  • OpenSuperClone — GPL-форк HDDSuperClone Скотта Дуайера; ATA/SCSI pass-through, пропуск сбойных головок. Для умирающих HDD.

Чем разбирать ФС и карвить (бесплатное)

  • PhotoRec / TestDisk — Christophe Grenier, карвинг 480+ типов и восстановление разделов.
  • The Sleuth Kit / Autopsy — Brian Carrier, форензический разбор NTFS/FAT/exFAT/ext/HFS+/UFS/YAFFS2.
  • DMDE Free — до 4000 файлов за операцию. f2fs не поддерживает (FAT/exFAT/NTFS/ReFS/Ext2‑4/HFS+/APFS/btrfs).
  • extundelete, ext4magic, btrfs restore, ntfsundelete — по своим ФС.

Коммерческое

  • R-Studio — строит дерево f2fs; демо сохраняет только файлы < 256 КБ.
  • IsoBuster — f2fs не умеет, но начиная с версии 4.6 заявляет учёт USBC-секторов. Если чинить сдвиг руками не хочется, начинать стоит с него.

Бенчмарки файловых систем

  • F2FS Results Mixed Against Microsoft’s exFAT On Linux (Phoronix) («Смешанные результаты F2FS против exFAT на Linux») — прямое сравнение f2fs, exFAT и NTFS на USB-флешке. Внимание на дату: март 2013, ядро 3.9-rc1, exFAT ещё через FUSE.
  • ext4 vs F2FS on SBC Root Filesystems (LeMaker) («ext4 против F2FS для корневой ФС одноплатников») — поведение при потере питания и цифры случайной записи (~1800 против ~800 IOPS). Один прогон: Banana Pi Pro, Samsung PRO Endurance 32 ГБ, ядро 6.6; в посте есть affiliate-дисклеймер.
  • File System Performance: F2FS vs EXT4 (XDA) — замеры на Android-устройствах с eMMC/SD.

Как лечить именно этот баг: краткий рецепт

  1. Снять образ через заведомо исправный интерфейс — встроенный слот mmcblk или другой ридер, ddrescue. Не через тот, что убил карту.
  2. Подтвердить диагноз: grep -abo --binary-files=text 'USBC' /dev/sdX. Для каждого попадания проверить, что LBA внутри CDB равен адресу блока — тогда это точно вставленный CBW.
  3. Вылечить сдвигusbc-sector-patcher автоматически, или вручную: на каждом CBW-блоке сдвинуть хвост записи на сектор назад и добить нулём потерянный сектор. Для метаданных f2fs достаточно читать сдвинутый блок со смещением +512 — контрольная точка там валидна (см. Часть 7).
  4. Устранить причину: выбросить ридер. Квирки тут не работают — usb-storage.quirks=VID:PID:u (IGNORE_UAS) как раз переводит устройство в BOT, а USBC появляется именно в BOT. Этот параметр решает другую задачу, нестабильность UAS у отдельных мостов.

Об инструментах

Разбор структур, отладка парсера и финальная форензика делались в паре с агентом Claude Code: гипотезы, чтение спецификаций ядра и итерации по багам шли в диалоге, код запускался тут же на образе. Ключевые инсайты — валидация через node_footer.nid и разгадка USBC — родились из одного и того же приёма: не рассуждать, а проверять на образе. Красивая версия про стёртый NAND держалась ровно до первого xxd, а «мёртвый чекпоинт» — до первого пересчёта CRC. Проверяемая гипотеза лучше умного рассуждения.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *