ينشر PDF Library for Delphi مخرج RepairQDFFile عبر كاتب داخلي، TPDFQDFFileWriter، لا يفتح الوجهة للكتابة قط: بايتات الإصلاح تذهب إلى ملف مؤقت يُنشأ حصرياً في الدليل نفسه، ويُدفَق الملف ويُغلق، وفقط حينها يُعاد تسميته فوق الهدف بـ MoveFileExW على Windows أو rename(2) على POSIX. وإذا فشل أي شيء قبل التسمية، تحتفظ الوجهة بكل بايت كان فيها، ويرى المستدعي LastErrorCode بقيمة 305. إصلاح مستند في الذاكرة هو النصف السهل من ميزة إصلاح. أما إيصال النتيجة إلى القرص دون أن تترك المستخدم أبداً بملف بطول صفر أو نصف مكتوب، فهو النصف الذي تتحدث عنه هذه المقالة
لماذا يستطيع إصلاح يفشل أن يدمّر الملف الهدف مع ذلك؟
لأن ترتيب العمليات كان خاطئاً. قبل v3.539.13 كانت RepairQDFFile تفتح المخرج بـ PLCreateFileStream(OutputFileName, fmCreate) ثم تسلّم ذلك التدفق إلى المحلل. و fmCreate يقصّ عند الفتح، فعندما قرر مسح QDF أن المدخل غير قابل للإصلاح كانت الوجهة قد فُرّغت سلفاً. الإصلاح في الموقع، حيث InputFileName و OutputFileName هما المسار نفسه، حوّل مدخلاً مرفوضاً إلى ملف مفقود. كان المحلل نفسه حسن السلوك: الدالة منخفضة المستوى PDFQDFRepair تُبقي التدفق الهدف بلا مساس حين ترفض العلامات الملتبسة. كانت تلك الحماية بلا جدوى ببساطة، لأن واجهة API العامة كانت قد قصّت الملف قبل استدعاء واحد
نقل إصلاح v3.539.13 الإصلاح إلى TMemoryStream وفتح المخرج فقط بعد نجاح PDFQDFRepair. ذلك سدّ ثغرة فشل التحليل ولا شيء غيرها. ظلّت مرحلة الكتابة fmCreate يليها CopyFrom، فامتلاء القرص، أو مخالفة مشاركة في المنتصف، أو استثناء بين القصّ وآخر WriteBuffer كانت تترك وجهة تالفة مع ذلك. الإصلاح بالذاكرة أولاً يحمي من المدخل الرديء. أما نشر القرص فيحتاج حده الخاص، وبنَت v3.539.14 و v3.539.15 ذلك الحد
// v3.539.12: تُقطع الوجهة قبل التحقق من المدخل
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // فات الأوان للقول لا
Result := 1;
finally
Output.Free;
end;
// v3.539.15: أصلح في الذاكرة ثم سلّم البايتات لكاتب النشر
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // الوجهة لم تُفتح قط
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
ماذا يضمن النشر الذري فعلاً؟
يضمن TPDFQDFFileWriter.Save أن مسار الوجهة هو إما الملف القديم الكامل أو الملف الجديد الكامل، أبداً خليطاً منهما، لكل إخفاق تستطيع المكتبة ملاحظته. يفعل الكاتب ذلك في أربع خطوات يرفض كل واحدة منها المتابعة إلا إذا اكتملت التي قبلها. أولاً يحل الوجهة بـ GetFullPathNameW، مستدعياً مرتين ومخصصاً المخزن من الطول المُعاد بدل افتراض MAX_PATH، حتى لا تُقص المسارات الطويلة بصمت. ثانياً ينشئ ملفاً مؤقتاً مسمى .pdflib-qdf- زائد GUID زائد .tmp في دليل الوجهة، مستخدماً CreateFileW مع CREATE_NEW على Windows و open(2) مع O_CREAT or O_EXCL والوضع 0600 على POSIX. وكلا العَلَمَين يجعل الإنشاء يفشل إن كان الاسم موجوداً، فلا يمكن لعمليتين تتنافسان على GUID واحد أن تتشاركا مقبضاً. ثالثاً ينسخ التدفق المصلح في كتل من 64 KiB عبر WriteBuffer، الذي يرفع عند كتابة ناقصة بدل إعادة عدّاً لا يفحصه أحد، ثم يستدعي FlushFileBuffers أو fsync(2) ويغلق المقبض. ورابعاً يُعيد التسمية
procedure TPDFQDFFileWriter.Flush(Target: TStream);
begin
if not FlushFileBuffers(THandleStream(Target).Handle) then
raise EWriteError.Create('Unable to flush QDF output');
end;
procedure TPDFQDFFileWriter.Publish(const TempFileName, FileName: WideString);
begin
// لا تجز نسخاً عبر حجمين ولا تحذف الوجهة أولاً
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
خطوة التسمية هي حيث تنكسر بهدوء معظم روتينات "الحفظ الآمن" المنزلية الصنع. يستبدل MoveFileExW مع MOVEFILE_REPLACE_EXISTING الهدف في عملية نظام ملفات واحدة على الحجم نفسه. ويترك الكاتب عمداً MOVEFILE_COPY_ALLOWED خارج الحساب، لأن النقل عبر حجمين يتدهور إلى نسخ-ثم-حذف، وهو بالضبط التتابع غير الذري الذي وُجد التصميم كله لتفاديه. وبما أن الملف المؤقت يعيش في دليل الوجهة فهو على حجم الوجهة بالبناء. ولا يحذف الكاتب الملف القديم أولاً أبداً؛ فزوج احذف-ثم-سمِّ له نافذة لا يوجد فيها المسار إطلاقاً، والانهيار داخل تلك النافذة يضيّع المستند. يطلب MOVEFILE_WRITE_THROUGH ألا تعيد الاستدعاء قبل أن تبلغ التسمية القرص، وهو ما يقترن بالتدفّق الصريح للبيانات. وعلى POSIX يضمن rename(2) أصلاً أن يُحل الاسم الجديد بذريّة محل أي ملف قائم، والموضع في الدليل نفسه يمنعه من الفشل بـ EXDEV. والتنظيف متناظر. يُزال الاسم المؤقت في كتلة finally على كل مسار، وهو عند النجاح عملية بلا أثر لأن التسمية استهلكته سلفاً، وعند الفشل يزيل الملف الجزئي فلا يتراكم في الدليل مخلفات .tmp. ويفحص اختبار الانحدار في Tests\QDFFileRegression.inc ذلك بالضبط: بعد كل إخفاق محقون تطابق بايتات الوجهة الأصل وتطابق بايتات المصدر الأصل ولا يحوي الدليل شيئاً سوى العينتين
لماذا تُرخي ملف مؤقت الأذونات على Windows؟
الملف المنشأ بوصف أمان nil يرث DACL الخاص به من الدليل الأم، لا من الملف الذي سيحل محله. ذلك الافتراضي الصحيح لمستند جديد كلياً وهو الخطأ لإصلاح في الموقع. افترض أن مشغّلاً قفل contract.pdf على حساب واحد بـ DACL محمي غير موروث. ملف مؤقت بجانبه يرث أذونات الدليل الأوسع، وبمجرد تسميته فوق contract.pdf يحمل الملف المعاد تسميته DACL الواسع، لأن أمان NTFS يتنقل مع كائن الملف لا مع الاسم. ينجح الإصلاح، والبايتات صحيحة، وتحكم الوصول الذي ضبطه المشغّل ذهب بصمت. ولا شيء في القيمة المُعادة يلمّح إليه
لهذا يقرأ PDF Library for Delphi وصف أمان الوجهة DACL قبل إنشاء الملف المؤقت ويمرره في وسيط lpSecurityAttributes إلى CreateFileW، فيولد الملف الجديد بأذونات الملف القديم ولا تغيّر التسمية شيئاً ينتبه إليه المشغّل. القراءة تستخدم GetFileSecurityW مع DACL_SECURITY_INFORMATION، بحجم مخزن مأخوذ من نتيجة ERROR_INSUFFICIENT_BUFFER للاستدعاء الأول. ثلاثة شروط تجعل الكاتب يفشل مغلق الأبواب بدل أن يخمّن. إذا تعذر قراءة DACL يتوقف النشر بـ EWriteError، تربطه واجهة API العامة بالقيمة 305. وإذا عاد الوصف دون ضبط SE_DACL_PRESENT توقف النشر هو الآخر، لأن تمرير وصف كهذا إلى CreateFileW سيسمح للنواة بالعودة إلى DACL الافتراضي للعملية وتغيير دلالات الوصول دون أن يطلبها أحد. وإذا حمل الهدف FILE_ATTRIBUTE_ENCRYPTED رفضه الكاتب رفضاً قاطعاً: سيكون الملف المؤقت نصاً صريحاً، وإعادة تسمية ملف نص صريح فوق ملف محمي بـ EFS تنشر بديلاً غير مشفر لشيء اختار المستخدم تشفيره على مستوى نظام الملفات. لا علاقة لـ EFS بمعالِجات أمان PDF القياسية، وهي موضوع مقالة تحميل المستندات المشفرة، لكن نمط الفشل من النوع نفسه: ترقية هادئة للوصول
Attributes := GetFileAttributesW(PWideChar(Destination));
if Attributes <> INVALID_FILE_ATTRIBUTES then
begin
if (Attributes and FILE_ATTRIBUTE_ENCRYPTED) <> 0 then
raise EWriteError.Create('QDF replacement of an EFS encrypted file is not supported');
// حدّد حجم الوصف ثم اقرأ منه جزء DACL وحده
if not GetFileSecurityW(PWideChar(Destination), DACL_SECURITY_INFORMATION,
@Security[0], SecuritySize, SecuritySize) then
raise EWriteError.Create('Unable to read QDF destination permissions');
if not QDFGetSecurityDescriptorControl(@Security[0], Control, Revision) or
((Control and SE_DACL_PRESENT) = 0) then
raise EWriteError.Create('QDF destination has no explicit DACL');
SecurityAttributes.lpSecurityDescriptor := @Security[0];
SecurityPointer := @SecurityAttributes; // يُسلَّم إلى CreateFileW / CREATE_NEW
end;
تفصيل من الانحدار يستحق أن يُبقى في الذهن إن كتبت اختباراً مماثلاً بنفسك. لبناء العينة المقيدة يطبق الاختبار DACL للمالك وحده ويجب أن يضبط SE_DACL_PROTECTED في التحكم بالوصف صراحة؛ فمجرد تمرير عَلَم الحماية في وسيط SecurityInformation لـ SetFileSecurityW لا يحوّل وصفاً غير محمي إلى محمي. والتأكيد بعدها أن الملف المنشور ما زال يبلّغ عن بت الحماية وعن DACL صريح غير معدوم، سواء لمسار إخراج منفصل أو لإصلاح فوق ملف المصدر نفسه
أي LastErrorCode يخبرك بما فشل؟
تعيد RepairQDFFile القيمة 1 عند النجاح و0 عند أي إخفاق، و LastErrorCode يقول أي مرحلة رفضت. مصدر لا يمكن قراءته، بما في ذلك ما يحمله آخر بمقبض حصري، يبلّغ 401؛ والقراءة ملفوفة الآن بحيث يربط استثناء أثناء الإدخال بالقيمة 401 بدل أن يتسرب إلى خطأ الكتابة. وبنية QDF غير صالحة أو ملتبسة، مثل علامة تدفق مكررة للكائن نفسه، تبلّغ PDFLIB_ERROR_QDF_REPAIR التي هي 107، ولم تُمسّ الوجهة لأن الكاتب لم يُبنَ قط. وكل شيء بعد الإصلاح، من إنشاء الملف المؤقت إلى التدفّق والتسمية، يبلّغ PDFLIB_ERROR_QDF_WRITE التي هي 305. ويجسّد الانحدار الحالات الواقعية: وجهة مفتوحة بمقبض آخر دون مشاركة حذف، ووجهة للقراءة فقط، ودليل وجهة مفقود، وكل واحدة من مراحل الكاتب الثلاث فاشلة عبر الحقن. وفيها جميعاً العائد 0 والرمز 305 ولا هدف جديد أو جزئي موجود بعد ذلك. وعادة قراءة الرمز لا القيمة المُعادة وحدها هي نفسها الموصوفة في مقالة تشخيص الإخفاقات الصامتة في المكتبة
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// إصلاح في الموقع: المسار نفسه مدخل ومخرج
if Pdf.RepairQDFFile('edited.qdf.pdf', 'edited.qdf.pdf') = 1 then
Log('published; the previous bytes were replaced in one rename')
else
case Pdf.LastErrorCode of
401: Log('could not read the input; it was not modified');
107: Log('QDF structure rejected; the destination was never opened');
305: Log('write, flush or replace failed; the destination still holds its old bytes');
end;
finally
Pdf.Free;
end;
end;
أين تتوقف الضمانة؟
يَعِد الكاتب بالاتساق أمام الإخفاقات التي يستطيع الأمر رؤيتها، وهو صادق بشأن ما لا يستطيع رؤيته. إذا قُتل الأمر بين إنشاء الملف المؤقت والتسمية، لا تجري كتلة finally قط ويُترك ملف .pdflib-qdf-<GUID>.tmp في الدليل؛ وما زالت الوجهة سليمة، وهي الخاصية التي تهم، لكن مخلفات الترتيب عليك أنت. وانقطاع الطاقة خارج الوعد كذلك: البيانات مدفوقة والتسمية بالكتابة المباشرة، وهو أفضل ما يستطيعه طالب من وضع المستخدم، لكن الكاتب لا ينفذ fsync لإدخال الدليل ولا يدّعي متانة فوق ما يقدمه نظام الملفات. وكاتب ثانٍ يعدّل الوجهة تزامنياً لا يُكتشف، لأن DACL والسمات تُقرآن قبل إنشاء الملف المؤقت ولا شيء يعيد فحصهما وقت التسمية. والتسمية الناجحة تنشئ هوية ملف جديدة، فالتدفقات البديلة للبيانات والسمات العادية مثل بت الأرشفة أو الإخفاء على الملف القديم لا تنجو؛ فقط DACL يُحمل عبرها عمداً
والحد الأضيق هو أي API يستخدم هذا المسار أصلاً. RepairQDFFile وحده يمر عبر TPDFQDFFileWriter. ما زالت SaveQDFToFile و ConvertFileToQDF تفتحان مخرجهما بـ PLCreateFileStream(FileName, fmCreate) وتصرّفان تحويل QDF مباشرة فيه، بالطريقة نفسها التي يكتب بها المسار التزايدي الموصوف في مقالة إلحاق التحديثات بتدفق إلى أي تدفق تسلّمه إياه. فتلك الاستدعاءان ينتجان أداة تصحيح جديدة من مستند حمّل وتحقق سلفاً، فلم تنطبق عليهما ثغرة فشل التحليل قط، لكنهما لا يرثان النشر القائم على التسمية أيضاً. لا تقرأ هذه المقالة كأنها تقول "كل تصدير QDF ذري". إنها مخرج واحد، هو الذي مدخله ملف غير موثوق محرَّر يدوياً ومخرجه غالباً المسار نفسه، وتلك التركيبة هي ما أكسبه الآلية الإضافية. وحقن الأعطال الذي يثبت كل هذا رخيص لأن مراحل الكاتب الثلاث، WriteData و Flush و Publish، هي virtual. يتجاوز الصنف الفرعي للاختبار إحداها لترفع بعد بدء العمل الحقيقي، ويستدعي Save على تدفق مصلح، ويؤكد أن الاستثناء يتسرب، وأن بايتات المصدر والوجهة لم تتغيرا، وأنه لم يبق ملف مؤقت. لا خطّاف على واجهة ملفات عامة، ولا ملف مستخدم حقيقي يُلمس، والمراحل الثلاث تقابل واحداً لواحد الطرقَ الثلاث التي يفشل بها نشر في الإنتاج: يمتلئ القرص، أو يُرفض التدفّق، أو تُرفض التسمية لأن أحدهم يقبض على الهدف
واجهة RepairQDFFile وكاتب نشرها الذري وبقية سير عمل تصحيح QDF جزء من PDF Library for Delphi، إلى جانب ميزات تعافي المرجع المتقاطع والتحديث التزايدي والتشفير المغطاة في أماكن أخرى من هذا المدونة