بازگشت به بلاگ
۱۵ مهر ۱۴۰۵Sergei Solod24 دقیقه مطالعه

چطور فایل‌ها را از یک کلون خراب APFS بازیابی کردم، بعد از اینکه GNU ddrescue 99.999654% از یک درایو 4 TB را کپی کرد

GNU ddrescue 99.999654% از یک HDD در حال خرابی 4 TB را کپی کرد، اما کلون 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 فایل و دایرکتوری را تحت تأثیر قرار می‌داد. زمان‌بندی باعث شد این بار سنگینِ متادیتا مشکوک به نظر برسد، اما نمی‌توانم ثابت کنم که علت خرابی سخت‌افزار بوده است. شاید فقط به درایوی که از قبل ناسالم بود آن‌قدر فشار آورده که مشکل آشکار شود.

چیزی که می‌توانستم مشخص کنم رفتار خود درایو بود. وقتی ذخیره‌ساز مکانیکی مکث می‌کند، ناپدید می‌شود و کلیک می‌زند، مرور مکرر دایرکتوری‌ها انتزاع اشتباهی برای مسئله است. پیمایش دایرکتوری می‌تواند خواندن‌ها و جابه‌جایی‌های بیشتری روی دیسک ایجاد کند. مونت کردن فایل‌سیستم می‌تواند کار متادیتایی راه بیندازد. هر آزمایش از زمان تنها قطعه‌ای مصرف می‌کند که معلوم نیست چه مقدار عمر مفید برایش باقی مانده است.

پس هدف را از این:

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 TB دو درایو 5 TB خریدم

اولین درایو جدید یک Seagate Expansion با ظرفیت 5 TB بود که ظرفیت فیزیکی دقیقش این مقدار بود:

5,000,981,077,504 bytes

کلون سطح‌بلوک Toshiba را روی آن نوشتم. چیدمان منبع تقریباً 4 TB اول را اشغال می‌کرد و حدود 1 TB بعد از چیدمان کپی‌شده باقی می‌ماند. عمداً به آن ظرفیت اضافه دست نزدم.

کانتینر APFS را بزرگ نکردم. برای راحتی کلون را دوباره پارتیشن نکردم. هیچ تعمیر فایل‌سیستمی روی آن اجرا نکردم. آن دیسک به کلون اصلی تبدیل شد.

بعد یک درایو 5 TB دوم خریدم. تازه فرمت شده بود، قابل‌نوشتن بود، مستقل آزمایش شده بود و فقط برای خروجی بازیابی استفاده می‌شد.

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 TB دادهٔ استفاده‌شده، نزدیک 10 TB فضای ذخیره‌سازی اسمی جدید خریدم. درایو اضافه برای ظرفیت نبود؛ برای حفظ یک اصل ثابت بود:

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

تعمیر، تغییر اندازه، پارتیشن مجدد یا نوشتن خروجی بازیابی‌شده روی کلون اصلی، حفاظت را با آزمایش قاطی می‌کرد. جدا نگه داشتن منبع و مقصد روی دو درایو فیزیکی باعث می‌شد اشتباه‌ها قابل‌جبران باشند.

پیش از اعتماد به مقصد، یک آزمایش نوشتن/خواندن تقریباً 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 هم همان نوع مشکل را نشان دادند.

تمایز مهم این بود:

directory traversal works

به این معنی نبود که:

file content retrieval works

ممکن است تجزیه‌گر به‌اندازهٔ کافی متادیتای سالم داشته باشد که مسیر فایل را پیدا کند، اما بعداً هنگام حل کردن شیء فایل، متادیتای اکستنت یا بلوک‌های محتوایی لازم برای برگرداندن جریان بایت‌ها شکست بخورد.

اولین موتور بازیابی انبوه را مقاوم به خطا کردم، اما تجزیه‌گر هنوز برای این نوع آسیب مناسب نبود

اولین واکنش من این بود که به‌جای تعویض فوری تجزیه‌گر، استخراج TSK را مقاوم‌تر کنم.

یک لایهٔ واسط بازیابی Python حول fls و icat ساختم. وضعیت پایدار را در SQLite نگه می‌داشت، خطاها را ثبت می‌کرد، امکان ادامه را فراهم می‌کرد، خروجی ناقص را جدا می‌نوشت و در مرحلهٔ اول، داده‌های کم‌ارزش نگه‌داری سیستم در macOS را در اولویت پایین‌تری قرار می‌داد.

یک قانون «نقطهٔ داغ» هم اضافه کردم: اگر چهار فایل پشت‌سرهم در یک دایرکتوری با همان الگوی صفر بایتی 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 KB را که همیشه به یک شکل شکست می‌خورد بازیابی کند، شایستهٔ دسترسی به ترابایت‌های باقی‌مانده نبود.

بلوک‌های دیسک و مسیر متادیتای 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

گیر کرد و مجبور شدم پس از پایان مهلت آن را متوقف کنم.

بازرسی حداقلی بلوک روی هر دو مسیر خام و بافرشدهٔ دستگاه هم گیر کرد. همان نوع آزمون محدود را با 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

خیلی سریع با خطای خواندنِ «آرگومان نامعتبر» نزدیک آفست 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 TB نوشته می‌شد.
  • نام دایرکتوری‌ها و فایل‌ها حفظ می‌شد.
  • پیشرفت در 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 MB داشت که موفقیت کپی شدنشان تأیید نشده بود. همچنین نمی‌توانم ثابت کنم هیچ شیء فایل‌سیستمی کاملاً غیرقابل‌کشف نشده است، چون ممکن است متادیتای لازم برای فهرست کردن آن در ناحیه‌های آسیب‌دیده بوده باشد.

این محدودیت‌ها مهم‌اند، چون بازیابی ساختاریافته می‌تواند ثابت کند یک شیء فهرست‌شده استخراج شده است؛ اما نمی‌تواند عدم وجود تاریخی شیئی را ثابت کند که فضای نام آسیب‌دیده دیگر قادر به آشکار کردنش نیست.

قوی‌ترین بیان نهایی محدودتر است:

همهٔ درخت‌های پوشهٔ انتخاب‌شده و قابل‌فهرستی که نیاز داشتم، از طریق فرایند بازیابی ساختاریافته با موفقیت بازیابی شدند.

لازم نبود کلون اصلی را درجا تعمیر کنم. لازم نبود Toshiba اصلیِ کلیک‌زن را برای استخراج انبوه دوباره به کار بگیرم. و لازم نبود یک مرحله از نوع استخراج خام شبیه PhotoRec روی کل دیسک اجرا کنم که ساختار دایرکتوری و نام فایل‌ها را از بین می‌برد.

ابزارهای ناموفق هم همچنان شواهد مفیدی بودند

در نگاه به گذشته، مسیر موفق ساده به نظر می‌رسد:

ddrescue clone
    ↓
libfsapfs
    ↓
resumable extractor
    ↓
recovered files

اما هنگام انجام تحقیق چنین حسی نداشت، و حذف روش‌های ناموفق بخش بزرگی از درس مهندسی مفید را حذف می‌کند.

TSK به من نشان داد پیمایش فضای نام APFS و بازیابی محتوا دو حالت خرابی متفاوت‌اند.

باگ اولین لایهٔ واسط من نشان داد طبقه‌بندی خطا توسط خود اسکریپت بازیابی حقیقت نهایی نیست.

مکانیزم نقطهٔ داغ به من یاد داد خطای محلی را ایزوله کنم، نه اینکه اجازه بدهم پیشرفت کلی را متوقف کند.

آزمایش نقاط بررسی یادم داد نقاط بررسی تراکنش APFS را با اسنپ‌شات‌ها اشتباه نگیرم.

تلاش‌های FUSE نشان دادند مونت ناموفق می‌تواند چندین لایه غیر از خود دادهٔ فایل‌سیستم را درگیر کند.

خوانشگر APFS که روی --help گیر کرد به من یاد داد پیش از تفسیر پیام‌های تشخیصی، خود ابزار را اعتبارسنجی کنم.

تفاوت رفتار /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%

شبیه پاسخ به این پرسش بود: «آیا دیسک را نجات دادم؟»

اما چیزی که واقعاً پاسخ می‌داد بسیار محدودتر بود:

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

این عدد نمی‌گفت آیا APFS می‌تواند فضای نام را بازسازی کند. نمی‌گفت آیا یک تجزیه‌گر می‌تواند متادیتای آسیب‌دیده‌ای را پیمایش کند که تجزیه‌گر دیگر رد کرده است. نمی‌گفت آیا فایل بازیابی‌شده طول مورد انتظار را دارد. و نمی‌گفت آیا استخراج چندمیلیون‌فایلی می‌تواند خطاها و راه‌اندازی‌های مجدد را بدون خراب کردن وضعیت خودش پشت سر بگذارد.

برای هر لایه به شاهد جداگانه نیاز داشتم.

اول بازیابی فیزیکی موفق شد. فایل‌سیستم هنوز خراب بود. تجزیه‌گر اول می‌توانست نام‌ها را آشکار کند اما روی محتوای بسیاری از فایل‌ها شکست می‌خورد. یک پیاده‌سازی متفاوت APFS همان فایل آزمایشی را با موفقیت خواند. آن اثبات به استخراج‌گر تک‌فایل تبدیل شد، استخراج‌گر به موتور بازیابی قابل‌ادامه تبدیل شد، و در نهایت موتور درخت‌های پوشهٔ انتخاب‌شده‌ای را که لازم داشتم روی دیسک دوم بازیابی کرد، در حالی که کلون اصلی دست‌نخورده باقی ماند.

هارد اصلی هرگز دوباره سالم نشد. APFS به‌طور جادویی خودش را تعمیر نکرد. چیزی که عوض شد مدل بازیابی بود.

بازیابی بلوک، بازیابی فایل‌سیستم و اعتبارسنجی فایل سه مرحلهٔ مهندسی جدا هستند. یکی گرفتن آن‌ها باعث می‌شد وضعیت تقریباً ناامیدکننده به نظر برسد. جدا کردنشان مسئله را قابل‌حل کرد.