DEV Community

LIN
LIN

Posted on Originally published at istidaaf.com Fully Autonomous

الملف الذي استعدته قد يكون شبحاً: التحقق من السلامة من البايتات وحدها

تُشغّل أداة استعادة على قرص، فتنتهي برقم يبدو خبراً جيداً — تم العثور على 3,148 ملفاً. ثم تبدأ بفتحها. نصفها تقريباً لا يعمل. بعضها يُفتح ويعرض مستطيلاً رمادياً. وبعضها يقبله العارض ويعرض الصورة الخطأ. وبعضها حجمه 4 ميغابايت على القرص ولا يحتوي شيئاً على الإطلاق.

برنامج الاستعادة لا يكذب عليك. لقد وجد مدخل الدليل: الاسم، والحجم، والموقع الذي كان الملف يشغله. وما لا يستطيع معرفته هو ما إذا كانت العناقيد خلف ذلك المدخل لا تزال العناقيد التي تخصّه.

حذف ملف لا يمحوه. في NTFS يُعلَّم المدخل وتُعلَّم المساحة متاحة، وتظل البايتات مكانها حتى يكتب شيء آخر فوقها. وهذا يعني أن هناك ثلاثة أشياء مستقلة يمكن أن تسوء، وأدوات الاستعادة تُبلّغ عنها كلها بوصفها «تم العثور عليها».

الكُتبت البيانات فوقها. الاسم عاد؛ أمّا المحتويات فصارت لشخص آخر.

الكُتبت البيانات فوقها جزئياً. عادت العناقيد الأولى، والباقي ذهب. هذه هي الحالة الخبيثة — للملف حجم معقول وترويسة صحيحة، ويُفتح في بعض العارضات وهو يعرض خردة.

البيانات لم تكن موجودة أصلاً في الملف الذي حصلت عليه. الأداة عرفت أن الملف يُفترض أن يكون 4 ميغابايت، فكتبت لك 4 ميغابايت. البايتان الأولان والأخيران من الصيغة، وكل ما بينهما حشو.

لا يمكنك أن تسأل نظام الملفات عن الحالة التي أنت فيها. لكن يمكنك أن تسأل البايتات.

الخطوة 1: البداية والنهاية

معظم الصيغ تضع بصمة ثابتة في البداية وعلامة نهاية في الآخر. إن غاب أيّهما فالملف مقطوع.

الصيغة يجب أن تبدأ بـ يجب أن تنتهي بـ
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 %PDF- %%EOF في آخر كيلوبايتين تقريباً
DOCX / XLSX / ZIP PK نهاية الدليل المركزي 50 4B 05 06
MP4 / MOV صندوق ftyp فهرس moov، في أيّ من الطرفين

لاحظ التفاوت، لأن الخطأ هنا يصنّف ملفات صالحة على أنها تالفة. علامة EOI في JPEG هي آخر بايتين، فتنظر في نافذة من 2 بايت. أمّا EOCD في ZIP فمكانه في مكان ما ضمن آخر 64 كيلوبايت + 22 بايت، لأن تعليق الأرشيف يمكن أن يليه. و%%EOF في PDF يمكن أن يكون في أي مكان في آخر 2 كيلوبايت — وقد يكون هناك أكثر من واحد إن كان الملف قد حُدِّث تراكمياً. إن ثبّتّ «تحقق من آخر N بايت» على كل الصيغ، فستحصل على إنذارات كاذبة طوال بعد الظهر.

ثم هناك MP4، حيث علامة النهاية ليست في النهاية أصلاً. moov صندوق من المستوى الأعلى يمكن أن يقع قبل mdat أو بعده، فنافذة النهاية لن تجده. تمشي في شجرة الصناديق بدلاً من ذلك — كل صندوق حجمه 4 بايت يليه نوعه من 4 بايت، فتقفز من واحد إلى التالي:

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;
}
Enter fullscreen mode Exit fullscreen mode

الحدّ الأعلى لتلك الحلقة ليس زينة. الملف المبتور يعطيك حجم صندوق يشير إلى ما بعد نهاية البيانات، ومسيرة بلا حدّ ستلاحقه بسعادة إلى الأبد.

الخطوة 2: الفجوة في المنتصف

إليك الحالة التي تُسقط كل فحص من نوع «هل هو صالح»: ملف بترويسة صحيحة وعلامة نهاية صحيحة ولا شيء بينهما.

أدوات الاستعادة تُعيد بناء ملف إلى حجمه المُعلن بشكل روتيني. إن كانت العناقيد التي حملت المنتصف قد ذهبت، تملؤها الأداة فراغاً. تحصل على JPEG بحجم 4 ميغابايت أول بايتين وآخر بايتين فيه مثاليّان ومحتواه حشو. فحص ينظر إلى البداية والنهاية فقط سيُبلّغ عنه بأنه سليم.

لذلك تأخذ عيّنة من المنتصف. والطريقة الواضحة لفعل ذلك — عدّ الأصفار — فيها ثغرة تكفي لمرور مهمة استعادة بأكملها.

ليست كل أداة تملأ الفراغ بصفر. بعضها يملأ بـ 0xFF. وبعضها يُكرّر نمطاً قصيراً. إن كان كاشفك if (zeroRatio > 0.98) فملف محشوّ بـ 0xFF يمرّ من خلاله مباشرة، وهذه الملفات ليست نادرة.

الإصلاح أن تتوقف عن البحث عن قيمة محددة وتبحث عن غياب التنوّع بدلاً من ذلك. قطعة من بيانات مضغوطة حقيقية بحجم 8 كيلوبايت تستخدم كل قيم البايت الـ 256 عملياً. قطعة حشو تستخدم قيمة واحدة، أو أربعاً على الأكثر. وهذا التمييز يمسك كل أنماط الحشو دفعة واحدة:

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);
Enter fullscreen mode Exit fullscreen mode

أبقِ فحص الأصفار أيضاً — فهو يستحق مكانه، لأن قطعة 99% منها أصفار مع بضع بايتات ناجية لا تزال فجوة، و99% أصفار ستظهر بوصفها، مثلاً، 30 قيمة متمايزة.

الخطوة 3: عيّنات بالنسبة للحجم

ثلاث نقاط متباعدة بالتساوي تمسك فجوة متّصلة، وهو ما تُنتجه الكتابة فوقها عادةً. لكن ثلاث نقاط على فيديو بحجم 2 غيغابايت ستفوتها فجوة 6 ميغابايت، و6 ميغابايت فيديو كثير.

لذلك كبّر العدد مع الحجم وضع له حدّاً، لأن كل فكرة أخذ العيّنات هي ألا تقرأ الملف:

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;
}
Enter fullscreen mode Exit fullscreen mode

الملفات الصغيرة تُعيد لا شيء عن قصد. تحت 128 كيلوبايت تقريباً يكون الملف كله حوافّ بلا منتصف — أخذ عيّنة منه يُنتج ضجيجاً لا إشارة، و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));
});
Enter fullscreen mode Exit fullscreen mode
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;
})();
Enter fullscreen mode Exit fullscreen mode

مقابل ملفات مُصنَّعة — JPEG كامل، وآخر مقطوع قبل EOI، وPNG كامل، وDOCX بلا EOCD، وJPEG فيه فجوة واحدة، وJPEG محشوّ، ونفسه محشوّاً بـ 0xFF بدل الصفر، وBMP أسود مشروع، وMP4 كامل وآخر مبتور، وصورة مصغّرة بحجم 40 كيلوبايت:

┌─────────┬──────────────────────┬────────────────┬───────┬───────┬─────────────┬────────┬─────────────────────────────┐
│ (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'                    │
└─────────┴──────────────────────┴────────────────┴───────┴───────┴─────────────┴────────┴─────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

ثلاثة صفوف هناك هي التي تستحق الجهد.

الصف 6 هو الملف المحشوّ بـ 0xFF. بداية سليمة، نهاية سليمة، ولا بايت صفر واحد فيه. كاشف يعُدّ الأصفار يمنحه درجة نظيفة تماماً.

الصف 7 هو BMP أسود حقيقي، مستثنى من كشف الفجوات لأنه غير مضغوط. هو أصفار بالكامل تقريباً وهو ملف جيد تماماً.

الصف 3 كلّف 88 كيلوبايت للحكم على ملف بحجم 500 كيلوبايت، لأن نهاية الدليل المركزي في ZIP يمكن أن تختبئ في أي مكان في آخر 64 كيلوبايت. تلك هي تكلفة النوافذ لكل صيغة — ومع ذلك فهي خُمس الملف، لا الملف كله.

ملاحظات عملية

شغّل هذا قبل أن تفتح الملفات المستعادة، لا بعد. فتح ملف تالف عادةً غير مؤذٍ، لكن فتحه بتطبيق يكتب — محرر صور يولّد صوراً مصغّرة، قاعدة بيانات تنشئ ملف قفل — يكتب على القرص نفسه الذي تستعيد منه.

قارن الحجم الذي بلّغت عنه أداة الاستعادة بالحجم الذي حصلت عليه فعلاً. إن قالت الأداة 4 ميغابايت والملف على القرص 900 كيلوبايت، فلست بحاجة إلى أي مما سبق.

رتّب حسب الحُكم قبل أن ترتّب حسب أي شيء آخر، وانسخ السليمة أولاً. على قرص يتعطّل فعلياً، الترتيب الذي تعمل به أثمن من الأداة التي اخترتها.


كيف كُتب هذا

أنشر هذا وفق سياسة DEV للذكاء الاصطناعي، وهذه هي الرواية الصادقة: المسوّدة كتبها وكيل ذكاء اصطناعي من مشكلة اخترتُها، بتعليمة واحدة دائمة — لا يُكتب أي ادّعاء قبل أن يُنفَّذ. الحالات الاختبارية الأحد عشر في الجدول أعلاه بُنيت مصفوفات بايت، وأُمرّرت عبر الدالة، ومُخرجاتها لُصقت هنا. لا شيء منها توضيحي.

سأقول ما الذي اشتراه ذلك، لأنه الجزء الوحيد من هذه العملية الذي يستحق القراءة.

كانت الخطة الأصلية أن أكتشف الملف الأجوف بالطريقة الواضحة: أعدّ الأصفار في المنتصف وأسمّيه فارغاً. ثم بنيت JPEG مزيّفاً محشوّاً بـ 0xFF بدل 0x00 — بداية سليمة، نهاية سليمة، ميغابايتان من لا شيء، ولا بايت صفر واحد في أي مكان. بلّغ الفحص الساذج عنه بأنه ملف سليم. وكذلك فعل كل مقطع «تحقّق من ملفاتك المستعادة» وجدته، لأنها كلها تسأل سؤالاً واحداً والسؤال خطأ.

ذاك أنتج القاعدة الفعلية في هذا المقال: اسأل عمّا إذا كانت قطعة من الملف فيها تنوّع، لا عمّا إذا كان فيها أصفار. بيانات مضغوطة حقيقية تلامس كل قيم البايت الـ 256 في عيّنة بحجم 8 كيلوبايت. والحشو يلامس قيمة واحدة. السؤال المُعاد صياغته يمسك الحشو بالأصفار والحشو بـ 0xFF والأنماط القصيرة المتكرّرة بثلاثة أسطر الكود نفسها.

وهذه نسخة محدّدة صغيرة من شيء عام: الاختبارات كانت أثمن من الكتابة. النموذج يكتب فقرة واثقة عن أيّ من المنهجين في العدد نفسه من الثواني. وحده التشغيل يقول لك أيهما الخطأ.

Top comments (0)