فایلی که بازیابی کردید ممکن است یک شبح باشد: بررسی سلامت فقط از روی بایتها
یک ابزار بازیابی را روی یک درایو اجرا میکنید. با عددی تمام میشود که بوی خبرِ خوب میدهد — ۳٬۱۴۸ فایل پیدا شد. بعد شروع میکنید به باز کردنشان. شاید نصفشان کار نکند. بعضی باز میشوند و یک مستطیل خاکستری نشان میدهند. بعضی را نمایشدهنده میپذیرد و عکسِ اشتباهی را نشان میدهد. بعضی ۴ مگابایت روی دیسکاند و اصلاً هیچی درونشان نیست.
نرمافزار بازیابی به شما دروغ نمیگوید. مدخلِ فهرست را پیدا کرده: نام، اندازه، و جایی که فایل قبلاً اشغال میکرد. چیزی که نمیتواند بداند این است که آیا خوشههای پشتِ آن مدخل هنوز همان خوشههایی هستند که به آن تعلق دارند یا نه.
پاک کردنِ یک فایل آن را محو نمیکند. در NTFS مدخل علامت میخورد و فضا آزاد اعلام میشود، و بایتها همانجا میمانند تا چیزی دیگر رویشان نوشته شود. یعنی سه چیزِ مستقل میتواند خراب شود، و ابزارهای بازیابی هر سه را با عنوان «پیدا شد» گزارش میکنند.
روی داده نوشته شده. نام برگشته؛ محتوا حالا مالِ شخصِ دیگری است.
رویِ بخشی از داده نوشته شده. N خوشهٔ اول برگشته، بقیه رفته. این همان موردِ بدجنس است — فایل اندازهای باورپذیر و سرآیندی درست دارد، و در بعضی نمایشدهندهها باز میشود در حالی که آشغال رندر میکند.
داده اصلاً در فایلی که گرفتهاید وجود نداشته. ابزار میدانسته فایل قرار است ۴ مگابایت باشد، پس ۴ مگابایت برایتان نوشته. دو بایتِ اول و دو بایتِ آخر از قالب میآیند؛ هرچه میانشان است پُرکننده است.
نمیتوانید از فایلسیستم بپرسید کدام حالت هستید. اما میتوانید از بایتها بپرسید.
گام ۱: سر و ته
بیشتر قالبها یک امضای ثابت در ابتدا و یک تمامکننده در انتها میگذارند. اگر هرکدام نباشد، فایل بریده شده.
| قالب | باید با این شروع شود | باید با این تمام شود |
|---|---|---|
| JPEG | FF D8 FF | FF D9 (EOI) |
| PNG | 89 50 4E 47 0D 0A 1A 0A | IEND + CRC (49 45 4E 44 AE 42 60 82) |
%PDF- | %%EOF در دو کیلوبایتِ پایانی | |
| DOCX / XLSX / ZIP | PK | پایانِ فهرست مرکزی 50 4B 05 06 |
| MP4 / MOV | یک جعبهٔ ftyp | یک نمایهٔ moov، در هر کدام از دو سر |
به نابرابری توجه کنید، چون اشتباه در اینجا فایلهای معتبر را خراب علامت میزند. EOIِ یک JPEG خودِ دو بایت آخر است، پس به یک پنجرهٔ ۲ بایتی نگاه میکنید. اما EOCD یک ZIP جایی در ۶۴ کیلوبایتِ آخر به علاوهٔ ۲۲ بایت است، چون یک توضیحِ آرشیو میتواند بعد از آن بیاید. و %%EOF یک PDF میتواند هر جایِ ۲ کیلوبایتِ پایانی باشد — و اگر فایل بهروزرسانیِ افزایشی شده باشد، میتواند بیش از یکی باشد. اگر «چک کردنِ N بایت آخر» را روی همهٔ قالبها ثابت کنید، تمام بعدازظهر هشدارِ اشتباه خواهید گرفت.
و بعد MP4 است، که در آن تمامکننده اصلاً در انتها نیست. moov یک جعبهٔ سطحبالاست که میتواند قبل یا بعدِ mdat بنشیند، پس پنجرهٔ انتها آن را پیدا نمیکند. به جایش درختِ جعبهها را قدم میزنید — هر جعبه یک اندازهٔ ۴ بایتی است و بعد یک نوعِ ۴ بایتی، پس میتوانید از یکی به بعدی بپرید:
async function hasMoov(blob) {
let off = 0;
for (let i = 0; i < 64; i++) {
const h = await range(blob, off, 16);
if (h.length < 8) return false;
if (String.fromCharCode(h[4], h[5], h[6], h[7]) === 'moov') return true;
let sz = h[0] * 2 ** 24 + h[1] * 2 ** 16 + h[2] * 256 + h[3];
if (sz === 1) { sz = 0; for (let k = 8; k < 16; k++) sz = sz * 256 + h[k]; }
if (sz === 0 || sz < 8) return false; // 0 means "extends to end of file"
off += sz;
if (off >= blob.size) return false;
}
return false;
}
سقفِ آن حلقه تزیینی نیست. یک فایلِ بریده اندازهٔ جعبهای به شما میدهد که به بعد از انتهایِ داده اشاره میکند، و یک قدمزدنِ بیمرز با خوشحالی تا ابد دنبالش میرود.
گام ۲: حفرهٔ میانه
این همان موردی است که هر بررسیِ «آیا معتبر است» را شکست میدهد: فایلی با سرآیندِ درست، تمامکنندهٔ درست، و هیچ در میانه.
ابزارهای بازیابی بهطورِ روتین فایل را تا اندازهٔ اعلامشدهاش بازسازی میکنند. اگر خوشههایی که میانه را نگه میداشتهاند رفته باشند، ابزار پُر میکند. یک JPEG چهار مگابایتی میگیرید که دو بایتِ اول و دو بایتِ آخرش کامل است و محتوایش پُرکننده است. بررسیای که فقط به سر و ته نگاه کند، آن را سالم گزارش میدهد.
پس از میانه نمونه میگیرید. و واضحترین راه انجام این کار — شمردنِ بایتهای صفر — حفرهای در خود دارد که یک کارِ بازیابی از آن رد میشود.
هر ابزاری با صفر پُر نمیکند. بعضی با 0xFF پُر میکنند. بعضی یک الگوی کوتاه را تکرار میکنند. اگر آشکارسازِ شما if (zeroRatio > 0.98) باشد، فایلی که با 0xFF پُر شده مستقیم از آن رد میشود، و این فایلها کمیاب نیستند.
اصلاح این است که دنبالِ یک مقدارِ خاص نگردید و در عوض دنبالِ نبودِ تنوع بگردید. یک تکهٔ ۸ کیلوبایتی از دادهٔ فشردهٔ واقعی عملاً از همهٔ ۲۵۶ مقدارِ بایت استفاده میکند. یک تکهٔ پُرکننده از یکی استفاده میکند، شاید چهار تا. این تمایز همهٔ سبکهای پُر کردن را یکجا میگیرد:
function analyze(u8) {
const seen = new Uint8Array(256);
let zero = 0;
for (let i = 0; i < u8.length; i++) { const b = u8[i]; if (b === 0) zero++; seen[b] = 1; }
let distinct = 0;
for (let i = 0; i < 256; i++) if (seen[i]) distinct++;
return { zeroRatio: +(zero / u8.length).toFixed(3), distinct };
}
const isHole = (a) => a.zeroRatio > 0.98 || (a.distinct > 0 && a.distinct <= 4);
آزمونِ صفر را هم نگه دارید — جای خودش را دارد، چون تکهای که ۹۹٪ صفر است با چند بایتِ جانبهدربرده هنوز یک حفره است، و ۹۹٪ صفر به شکل، مثلاً، ۳۰ مقدارِ متمایز ظاهر میشود.
گام ۳: نمونهگیری متناسب با اندازه
سه نقطه با فاصلهٔ مساوی یک حفرهٔ پیوسته را میگیرد، که همان چیزی است که بازنویسی معمولاً تولید میکند. اما سه نقطه روی یک ویدیوی ۲ گیگابایتی یک شکافِ ۶ مگابایتی را از دست میدهد، و ۶ مگابایت ویدیو کم نیست.
پس تعداد را با اندازه بزرگ کنید و برایش سقف بگذارید، چون کلِ فایدهٔ نمونهگیری این است که فایل را نخوانید:
function sampleOffsets(size) {
if (size < 128 * 1024) return [];
const n = Math.min(32, Math.max(3, Math.ceil(size / (4 * 1024 * 1024))));
const step = size / (n + 1);
const pts = [];
for (let i = 1; i <= n; i++) pts.push(Math.floor(i * step));
return pts;
}
فایلهای کوچک عمداً چیزی برنمیگردانند. زیرِ حدود ۱۲۸ کیلوبایت فایل همهاش لبه است و میانه ندارد — نمونهگیری از آن نویز تولید میکند نه سیگنال، و یک JPEG در اندازهٔ تصویرِ بندانگشتی بهخاطرِ داشتنِ درونِ کسالتباری علامت میخورد.
جایی که این عمداً میایستد
قالبهای فشردهنشده معافاند. یک BMP مشکیِ واقعی واقعاً و بهدرستی تقریباً همهاش صفر است. یک دقیقه سکوتِ دیجیتال در یک WAV هم همینطور. اگر آشکارسازِ حفره را روی اینها اجرا کنید، فایلهای کاملاً خوب را محکوم میکنید؛ پس اول قالب را چک کنید و وقتی فشرده نیست، نمونهگیری را رد کنید. این تنها جایی است که دانستنِ نوع قبل از قضاوت دربارهٔ محتوا اهمیت دارد.
ساختار محتوا نیست. یک JPEG با SOI و EOI و بدون حفره میتواند با این حال عکسِ اشتباه را دربر داشته باشد، چون خوشههایی که از آن بازسازی شده متعلق به فایلِ دیگری بوده که اتفاقاً نزدیک آن قرار داشته. هیچ بررسیِ سطحبایتی نمیتواند این را تشخیص دهد. فقط یک انسان که آن را باز کند میتواند.
خراب بهمعنای بیارزش نیست. فایلی که ما ناقص مینامیم هنوزهر کسری که جان بهدر برده را دارد. بازیابیِ عکس بهطورِ خاص: یک JPEG که تهاش را ندارد اغلب با این حال بیشترِ تصویر را رندر میکند، و اگر تنها نسخهٔ یک عکس باشد، «بیشترِ تصویر» نتیجهٔ بسیار خوبی است. نگذارید یک ابزارِ بررسی شما را قانع کند چیزی را پاک کنید.
و یک نکتهٔ فایلسیستمی، چون احتمالهایتان را قبل از شروع تغییر میدهد: ext4 اشارهگرهای بلوک را هنگامِ پاک کردن خالی میکند، در حالی که NTFS بیشتر وقتها آنها را باقی میگذارد. نامِ فایلها روی ext4 معمولاً یکسره از دست میرود، و این دلیلی است که بازیابی در لینوکس حتی وقتی خودِ داده جان بهدر برده سختتر از بازیابی در ویندوز است. اگر روی یک درایوِ ext4 کار میکنید، از یک USB زنده انجامش دهید — هرگز از سیستمِ نصبشده، که دارد روی همان دیسکی مینویسد که میخواهید نجاتش دهید.
کلِ چیز، آمادهٔ پیست کردن
سرهمشده. یک File (یا Blob) میگیرد، فقط پنجرههایی را که لازم دارد از طریق Blob.slice() میخواند — که تنبل است، پس تا وقتی بایتها را نخواهید چیزی خوانده نمیشود — و یک حکم همراه با شواهدِ پشتش برمیگرداند.
آن را در کنسولِ DevTools پیست کنید، بعد یا روی یک دستهفایل که دارید صدایش بزنید، یا یک هدفِ دراگانددراپ بسازید و یک پوشه را در آن بکشید:
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', async (e) => {
e.preventDefault();
console.table(await ghostCheck.bulk(e.dataTransfer.files));
});
const ghostCheck = (() => {
const KB = 1024, MB = 1024 * 1024;
const CHUNK = 8 * KB;
const MIN_SAMPLABLE = 128 * KB;
// Blob.slice() is lazy: nothing is read until you ask for the bytes.
async function range(blob, start, len) {
const s = Math.max(0, Math.round(start));
const e = Math.min(blob.size, s + len);
if (e <= s) return new Uint8Array(0);
return new Uint8Array(await blob.slice(s, e).arrayBuffer());
}
function startsWith(u8, sig) {
if (u8.length < sig.length) return false;
for (let i = 0; i < sig.length; i++) if (u8[i] !== sig[i]) return false;
return true;
}
function indexOf(u8, sig) {
outer: for (let i = 0; i + sig.length <= u8.length; i++) {
for (let j = 0; j < sig.length; j++) if (u8[i + j] !== sig[j]) continue outer;
return i;
}
return -1;
}
const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
function analyze(u8) {
const seen = new Uint8Array(256);
let zero = 0;
for (let i = 0; i < u8.length; i++) { const b = u8[i]; if (b === 0) zero++; seen[b] = 1; }
let distinct = 0;
for (let i = 0; i < 256; i++) if (seen[i]) distinct++;
return { zeroRatio: +(zero / u8.length).toFixed(3), distinct };
}
// A hole is not always zeros. Recovery tools pad with 0x00, sometimes 0xFF,
// sometimes a short repeating pattern. Both look the same from here:
// a chunk of real compressed data uses ~256 distinct byte values; padding uses ~1.
const isHole = (a) => a.zeroRatio > 0.98 || (a.distinct > 0 && a.distinct <= 4);
function sampleOffsets(size) {
if (size < MIN_SAMPLABLE) return [];
const n = Math.min(32, Math.max(3, Math.ceil(size / (4 * MB))));
const step = size / (n + 1);
const pts = [];
for (let i = 1; i <= n; i++) pts.push(Math.floor(i * step));
return pts;
}
const FORMATS = [
{ id: 'jpeg', label: 'JPEG', head: [0xff, 0xd8, 0xff], tail: [0xff, 0xd9], tailWindow: 2, compressed: true },
{ id: 'png', label: 'PNG', head: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], tail: [0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82], tailWindow: 16, compressed: true },
{ id: 'zip', label: 'ZIP / Office', head: [0x50, 0x4b], tail: [0x50, 0x4b, 0x05, 0x06], tailWindow: 65557, compressed: true },
{ id: 'pdf', label: 'PDF', head: ascii('%PDF-'), tailText: '%%EOF', tailWindow: 2048, compressed: true },
{ id: 'bmp', label: 'BMP', head: ascii('BM'), compressed: false },
{ id: 'wav', label: 'WAV', head: ascii('RIFF'), compressed: false },
];
const FTYP = ascii('ftyp');
// Walk top-level ISO-BMFF boxes looking for `moov`. Capped: a truncated file
// can point the offset past the end, and stopping beats looping.
async function hasMoov(blob) {
let off = 0;
for (let i = 0; i < 64; i++) {
const h = await range(blob, off, 16);
if (h.length < 8) return false;
if (String.fromCharCode(h[4], h[5], h[6], h[7]) === 'moov') return true;
let sz = h[0] * 2 ** 24 + h[1] * 2 ** 16 + h[2] * 256 + h[3];
if (sz === 1) { // 64-bit extended size
sz = 0;
for (let k = 8; k < 16; k++) sz = sz * 256 + h[k];
}
if (sz === 0 || sz < 8) return false; // 0 means "to end of file"
off += sz;
if (off >= blob.size) return false;
}
return false;
}
async function check(blob) {
const size = blob.size;
let bytesRead = 0;
const headBytes = await range(blob, 0, 16); bytesRead += headBytes.length;
const fmt = FORMATS.find((f) => startsWith(headBytes, f.head)) || null;
let type = fmt ? fmt.label : 'unknown';
const compressed = fmt ? fmt.compressed : true;
if (!fmt && startsWith(headBytes.subarray(4, 8), FTYP)) type = 'MP4 / MOV';
let tail = null; // null = this format has no terminator worth checking
if (type === 'MP4 / MOV') {
tail = await hasMoov(blob);
} else if (fmt && (fmt.tail || fmt.tailText)) {
const sig = fmt.tail || ascii(fmt.tailText);
const win = await range(blob, size - fmt.tailWindow, fmt.tailWindow);
bytesRead += win.length;
tail = indexOf(win, sig) >= 0;
}
const holes = [];
let minDistinct = null;
if (compressed) {
for (const at of sampleOffsets(size)) {
const c = await range(blob, at, CHUNK); bytesRead += c.length;
if (c.length < 512) continue;
const a = analyze(c);
if (minDistinct === null || a.distinct < minDistinct) minDistinct = a.distinct;
if (isHole(a)) holes.push({ at, ...a });
}
}
const cut = tail === false;
const holey = holes.length > 0;
let verdict;
if (type === 'unknown') verdict = 'UNRECOGNISED';
else if (!cut && !holey) verdict = tail === null ? 'PLAUSIBLE (no terminator)' : 'INTACT';
else if (!cut && holey) verdict = 'GHOST';
else if (cut && holey) verdict = 'GHOST + PARTIAL';
else verdict = 'PARTIAL';
return {
type, size, tail: tail === null ? '—' : tail, holes: holes.length,
minDistinct: minDistinct === null ? '—' : minDistinct,
kbRead: +(bytesRead / 1024).toFixed(2), verdict,
};
}
check.bulk = async (files) => Promise.all(
[...files].map(async (f) => ({ file: f.name, ...(await check(f)) }))
);
return check;
})();
در برابر فایلهای مصنوعی — یک JPEG کامل، یکی که قبل از EOI بریده شده، یک PNG کامل، یک DOCX بدون EOCD، یک JPEG با یک حفره، یک JPEG پُرشده، همان پُرشده با 0xFF به جای صفر، یک BMP مشکیِ مشروع، یک MP4 کامل و یکی بریده، و یک تصویرِ بندانگشتی ۴۰ کیلوبایتی:
┌─────────┬──────────────────────┬────────────────┬───────┬───────┬─────────────┬────────┬─────────────────────────────┐
│ (index) │ file │ type │ tail │ holes │ minDistinct │ kbRead │ verdict │
├─────────┼──────────────────────┼────────────────┼───────┼───────┼─────────────┼────────┼─────────────────────────────┤
│ 0 │ 'photo-complete.jpg' │ 'JPEG' │ true │ 0 │ 256 │ 24.02 │ 'INTACT' │
│ 1 │ 'photo-cut.jpg' │ 'JPEG' │ false │ 0 │ 256 │ 24.02 │ 'PARTIAL' │
│ 2 │ 'scan.png' │ 'PNG' │ true │ 0 │ 256 │ 24.03 │ 'INTACT' │
│ 3 │ 'report.docx' │ 'ZIP / Office' │ false │ 0 │ 256 │ 88.04 │ 'PARTIAL' │
│ 4 │ 'photo-hole.jpg' │ 'JPEG' │ true │ 2 │ 1 │ 24.02 │ 'GHOST' │
│ 5 │ 'photo-ghost.jpg' │ 'JPEG' │ true │ 3 │ 1 │ 24.02 │ 'GHOST' │
│ 6 │ 'photo-ghost-ff.jpg' │ 'JPEG' │ true │ 3 │ 1 │ 24.02 │ 'GHOST' │
│ 7 │ 'black.bmp' │ 'BMP' │ '—' │ 0 │ '—' │ 0.02 │ 'PLAUSIBLE (no terminator)' │
│ 8 │ 'clip.mp4' │ 'MP4 / MOV' │ true │ 0 │ 256 │ 24.02 │ 'INTACT' │
│ 9 │ 'clip-truncated.mp4' │ 'MP4 / MOV' │ false │ 0 │ 256 │ 24.02 │ 'PARTIAL' │
│ 10 │ 'thumb-small.jpg' │ 'JPEG' │ true │ 0 │ '—' │ 0.02 │ 'INTACT' │
└─────────┴──────────────────────┴────────────────┴───────┴───────┴─────────────┴────────┴─────────────────────────────┘
سه ردیف در آناند که ارزشِ این زحمت را دارند.
ردیف ۶ همان فایلِ پُرشده با 0xFF است. سر سالم، ته سالم، و حتی یک بایتِ صفر در آن نیست. آشکارسازی که صفرها را میشمارد آن را کاملاً تمیز نمره میدهد.
ردیف ۷ یک BMP مشکیِ واقعی است، معاف از آشکارسازیِ حفره چون فشرده نشده. تقریباً یکسره صفر است و فایلِ کاملاً خوبی است.
ردیف ۳ برای قضاوت دربارهٔ یک فایلِ ۵۰۰ کیلوبایتی ۸۸ کیلوبایت هزینه برداشت، چون پایانِ فهرست مرکزیِ ZIP میتواند در هر جای ۶۴ کیلوبایتِ آخر پنهان شود. این بهایِ پنجرههایِ جداگانه برای هر قالب است — و با این حال یکپنجمِ فایل است، نه کلِ آن.
نکتههای عملی
این را قبل از باز کردنِ فایلهای بازیابیشده اجرا کنید، نه بعد. باز کردنِ یک فایلِ خراب معمولاً بیضرر است، اما باز کردنش با برنامهای که مینویسد — یک ویرایشگرِ عکس که تصویرِ بندانگشتی میسازد، یک پایگاه داده که فایلِ قفل میسازد — روی همان درایوی مینویسد که دارید از آن بازیابی میکنید.
اندازهای که ابزارِ بازیابی گزارش داده را با اندازهای که واقعاً گرفتهاید مقایسه کنید. اگر ابزار گفته ۴ مگابایت و فایل روی دیسک ۹۰۰ کیلوبایت است، به هیچکدام از بالا نیاز ندارید.
قبل از هر چیزِ دیگری بر اساسِ حکم مرتب کنید، و سالمها را اول کپی کنید. روی درایوی که واقعاً در حالِ خراب شدن است، ترتیبی که کار میکنید از ابزاری که انتخاب کردهاید باارزشتر است.
این چطور نوشته شد
افشا، اول از همه: پیشنویسِ این پست را یک عاملِ هوش مصنوعی از مسئلهای که خودم انتخاب کردم نوشت، با یک دستورِ همیشگی — هیچ ادعایی نوشته نمیشود مگر اینکه اجرا شده باشد. یازده موردِ آزمایشی در جدولِ بالا بهشکل آرایهٔ بایت ساخته شدند، از تابع رد شدند، و خروجیشان اینجا پیست شده. هیچکدام تزیینی نیست.
میگویم این چه چیزی خرید، چون این تنها بخشِ این فرایند است که ارزشِ خواندن دارد.
نقشهٔ اصلی این بود که فایلِ میانتهی را به روشِ واضح تشخیص دهم: بایتهای صفرِ میانه را بشمار و آن را خالی بنام. بعد یک JPEG جعلی ساختم که با 0xFF به جای 0x00 پُر شده بود — سر سالم، ته سالم، دو مگابایت هیچ، و حتی یک بایت صفر در هیچ کجایش. بررسیِ سادهلوحانه آن را فایلی سالم گزارش داد. و هر تکهکدِ «فایلهای بازیابیشدهات را بررسی کن» که پیدا کردم هم همینطور، چون همهشان یک سؤال میپرسند و آن سؤال اشتباه است.
این بود که قانونِ واقعیِ این پست را تولید کرد: بپرس آیا یک تکه از فایل تنوع دارد، نه اینکه صفر دارد. دادهٔ فشردهٔ واقعی در یک نمونهٔ ۸ کیلوبایتی به همهٔ ۲۵۶ مقدارِ بایت دست میزند. پُرکننده به یکی دست میزند. سؤالِ بازنویسیشده پُر کردن با صفر، پُر کردن با 0xFF و الگوهای کوتاهِ تکراری را با همان سه خط کد میگیرد.
که نسخهای مشخص و کوچک از یک چیزِ کلی است: آزمونها از نوشتن باارزشتر بودند. یک مدل در همان چند ثانیه دربارهٔ هر کدام از دو رویکرد یک پاراگرافِ مطمئن مینویسد. فقط اجرا است که میگوید کدام اشتباه است.