مقاله فنی

خروجی اتمیک ترمیم PDF در Delphi: rename و امنیت DACL

PDF Library for Delphi خروجی RepairQDFFile را از طریق یک نویسندهٔ داخلی به نام TPDFQDFFileWriter منتشر می‌کند که هرگز مقصد را برای نوشتن باز نمی‌کند: byteهای ترمیم‌شده به یک فایل موقت می‌روند که به‌صورت انحصاری در همان دایرکتوری ساخته شده، فایل flush و بسته می‌شود و تنها پس از آن است که روی هدف rename می‌شود، با MoveFileExW روی ویندوز یا rename(2) روی POSIX. اگر پیش از rename هر چیزی شکست بخورد، مقصد هر byte ای را که داشت نگه می‌دارد و caller LastErrorCode برابر 305 را می‌بیند. ترمیم یک سند در حافظه نیمهٔ آسان یک قابلیت ترمیم است. رساندن نتیجه به دیسک بدون اینکه کاربر هرگز با فایلی صفر-طول یا نیمه‌نوشته رها شود، همان نیمه‌ای است که این مقاله دربارهٔ آن است

چرا یک ترمیم ناموفق باز هم می‌تواند فایل هدف را نابود کند؟

چون ترتیب عملیات غلط بود. پیش از v3.539.13، RepairQDFFile خروجی را با PLCreateFileStream(OutputFileName, fmCreate) باز می‌کرد و بعد همان stream را به parser می‌داد. حالت fmCreate هنگام باز کردن فایل را می‌بُرد، پس تا وقتی اسکن QDF تصمیم می‌گرفت که ورودی ترمیم‌پذیر نیست، مقصد از قبل تهی شده بود. ترمیم درجا، جایی که InputFileName و OutputFileName یک مسیر واحدند، یک ورودی رد‌شده را به یک فایل از‌دست‌رفته تبدیل می‌کرد. خود parser خوش‌رفتار بود: تابع سطح-پایین PDFQDFRepair وقتی نشانگرهای مبهم را رد می‌کند، stream هدف را دست‌نخورده نگه می‌دارد. آن محافظت صرفاً بی‌ربط بود، چون API عمومی یک فراخوانی پیش‌تر فایل را بریده بود

fix مربوط به v3.539.13 ترمیم را به یک TMemoryStream برد و خروجی را فقط بعد از موفق شدن PDFQDFRepair باز کرد. این فقط حفرهٔ شکست در parse را می‌بندد و هیچ چیز دیگر. فاز نوشتن هنوز fmCreate و بعد CopyFrom بود، پس یک وضعیت پر بودن دیسک، یا یک sharing violation در میانهٔ کار، یا یک استثنا بین بریدن فایل و آخرین WriteBuffer باز هم مقصدی آسیب‌دیده جا می‌گذاشت. ترمیم حافظه‌محور در برابر ورودی بد محافظت می‌کند. انتشار روی دیسک مرز خودش را لازم دارد و v3.539.14 و v3.539.15 یکی ساختند

چگونه RepairQDFFile در PDF Library for Delphi دست از نابود کردن هدف خودش برداشت: v3.539.12 خروجی را با PLCreateFileStream و fmCreate باز می‌کرد که پیش از آنکه PDFQDFRepair بتواند ورودی را رد کند فایل را می‌بُرید، v3.539.13 اول در یک TMemoryStream ترمیم می‌کرد و v3.539.15 byteها را برای انتشار اتمیک به TPDFQDFFileWriter می‌سپارد
fix شکست در parse و fix انتشار دو مرز متفاوت‌اند: ترمیم حافظه‌محور در برابر ورودی بد محافظت می‌کند، در حالی که نویسنده وجود دارد تا دیسک پر یا شکستی در میانهٔ نوشتن دیگر نتواند مقصد را آسیب‌دیده رها کند
// v3.539.12: مقصد پیش از اعتبارسنجی ورودی بریده می‌شود
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // دیگر برای نه گفتن دیر است
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: ترمیم در حافظه، بعد تحویل byteها به نویسندهٔ انتشار
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 resolve می‌کند، آن را دو بار صدا می‌زند و بافر را از طول برگشتی تخصیص می‌دهد نه با فرض MAX_PATH، پس مسیرهای بلند بی‌صدا بریده نمی‌شوند. دوم یک فایل موقت به نام .pdflib-qdf- به‌علاوهٔ یک GUID به‌علاوهٔ .tmp در دایرکتوری مقصد می‌سازد، با CreateFileW و CREATE_NEW روی ویندوز و open(2) با O_CREAT or O_EXCL و mode 0600 روی POSIX. هر دو پرچم باعث می‌شوند اگر نام از قبل وجود داشته باشد ساخت شکست بخورد، پس دو پروسه که روی یک GUID مسابقه می‌دهند نمی‌توانند یک handle را شریک شوند. سوم stream ترمیم‌شده را در تکه‌های 64 KiB از طریق WriteBuffer کپی می‌کند، که روی یک نوشتن کوتاه raise می‌کند نه اینکه عددی برگرداند که کسی بررسی‌اش نمی‌کند، و بعد FlushFileBuffers یا fsync(2) را صدا می‌زند و handle را می‌بندد. چهارم rename می‌کند

چهار مرحلهٔ اتمیک TPDFQDFFileWriter.Save در PDF Library for Delphi: resolve کردن مسیر دو بار با GetFullPathNameW، ساختن فایل موقت .pdflib-qdf با CREATE_NEW یا O_EXCL تا پروسه‌های مسابقه‌دهنده نتوانند یک handle را شریک شوند، کپی در تکه‌های 64 KiB با WriteBuffer و flush، و بعد MoveFileExW با REPLACE_EXISTING و WRITE_THROUGH
هر مرحله تا مرحلهٔ قبلی تمام نشده باشد جلو نمی‌رود، فایل موقت بنا به ساختار روی همان volume مقصد است، هیچ پنجره‌ای برای اول-پاک-کردن وجود ندارد و پاک‌سازی در یک finally هیچ باقی‌ماندهٔ tmp جا نمی‌گذارد
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
  // اجازهٔ کپی میان volumeها نده و اول مقصد را پاک نکن
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

مرحلهٔ rename همان‌جایی است که بیشتر رویه‌های خانگی «safe save» بی‌صدا می‌شکنند. MoveFileExW با MOVEFILE_REPLACE_EXISTING هدف را در یک عملیات واحد فایل‌سیستم روی همان volume جایگزین می‌کند. نویسنده عمداً MOVEFILE_COPY_ALLOWED را کنار می‌گذارد، چون یک جابه‌جایی میان volumeها به کپی-و-بعد-حذف تنزل پیدا می‌کند، که دقیقاً همان توالی غیراتمیکی است که کل این طراحی برای پرهیز از آن وجود دارد. چون فایل موقت در دایرکتوری مقصد زندگی می‌کند، بنا به ساختار روی همان volume مقصد است. نویسنده همچنین هرگز اول فایل قدیمی را پاک نمی‌کند؛ جفت پاک-کردن-و-بعد-rename پنجره‌ای دارد که در آن مسیر اصلاً وجود ندارد و یک crash داخل آن پنجره سند را از دست می‌دهد. MOVEFILE_WRITE_THROUGH می‌خواهد که این فراخوانی تا رسیدن rename به دیسک برنگردد، که با flush صریح داده جفت می‌شود. روی POSIX، rename(2) از قبل تضمین می‌کند که نام جدید هر فایل موجودی را به‌صورت اتمیک جایگزین می‌کند و همان قرار گرفتن در یک دایرکتوری نمی‌گذارد با EXDEV شکست بخورد. پاک‌سازی هم متقارن است. نام موقت در یک بلوک finally روی هر مسیری حذف می‌شود، که در حالت موفقیت یک no-op است چون rename از قبل مصرفش کرده، و در حالت شکست فایل ناقص را پاک می‌کند تا دایرکتوری باقی‌ماندهٔ .tmp جمع نکند. رگرسیون موجود در Tests\QDFFileRegression.inc دقیقاً همین را بررسی می‌کند: پس از هر شکست تزریق‌شده، byteهای مقصد با اصل یکی‌اند، byteهای منبع با اصل یکی‌اند و دایرکتوری جز همان دو fixture چیزی ندارد

چرا یک فایل موقت روی ویندوز مجوزها را شل می‌کند؟

فایلی که با یک security descriptor تهی ساخته می‌شود، DACL اش را از دایرکتوری والد به ارث می‌برد، نه از فایلی که قرار است جایگزینش کند. این برای یک سند کاملاً جدید پیش‌فرض درستی است و برای یک ترمیم درجا پیش‌فرض غلطی. فرض کن اپراتوری contract.pdf را قفل کرده و به یک حساب واحد محدود کرده، با یک DACL محافظت‌شده و غیروارثی. یک فایل موقت کنارش مجوزهای بازتر دایرکتوری را به ارث می‌برد و وقتی روی contract.pdf rename شود، فایل تغییرنام‌یافته همان DACL باز را حمل می‌کند، چون امنیت NTFS با خود شیء فایل سفر می‌کند، نه با نام. ترمیم موفق می‌شود، byteها درست‌اند و کنترل دسترسی‌ای که اپراتور تنظیم کرده بی‌صدا از دست رفته. هیچ‌چیز در مقدار برگشتی اشاره‌ای به آن نمی‌کند

پس PDF Library for Delphi پیش از ساختن فایل موقت، DACL مقصد را می‌خواند و آن را به‌عنوان آرگومان lpSecurityAttributes به CreateFileW می‌دهد، تا فایل جدید با مجوزهای فایل قدیمی زاده شود و rename چیزی را عوض نکند که اپراتور متوجه شود. این خواندن از GetFileSecurityW با DACL_SECURITY_INFORMATION استفاده می‌کند و بافر را از نتیجهٔ ERROR_INSUFFICIENT_BUFFER فراخوانی اول اندازه می‌گیرد. سه شرط نویسنده را وامی‌دارند به‌جای حدس زدن، بسته شکست بخورد. اگر DACL خوانده نشود، انتشار با یک EWriteError می‌ایستد که API عمومی آن را به 305 نگاشت می‌کند. اگر descriptor بدون پرچم SE_DACL_PRESENT برگردد، انتشار هم متوقف می‌شود، چون دادن چنین descriptorی به CreateFileW می‌گذارد هسته به DACL پیش‌فرض پروسه برگردد و بی‌آنکه کسی بخواهد معناشناسی دسترسی را عوض کند. و اگر هدف FILE_ATTRIBUTE_ENCRYPTED داشته باشد، نویسنده یکسره رد می‌کند: فایل موقت متن‌روشن می‌شد و rename کردن یک فایل متن‌روشن روی فایلی که با EFS محافظت شده، جایگزینی رمزنگاری‌نشده برای چیزی منتشر می‌کند که کاربر انتخاب کرده بود در سطح فایل‌سیستم رمزنگاری شود. EFS به handlerهای امنیتی استاندارد PDF ربطی ندارد که موضوع مقالهٔ بارگذاری اسناد رمزنگاری‌شده است، اما حالت شکست از همان جنس تنزل بی‌صدا است

چرا نویسندهٔ انتشار QDF پیش از ساختن فایل موقت DACL مقصد را کپی می‌کند: یک descriptor تهی مجوزهای بازتر دایرکتوری را به ارث می‌برد و rename بی‌صدا دسترسی را گشاد می‌کند، پس GetFileSecurityW آن DACL را می‌خواند، نبود بیت SE_DACL_PRESENT یا وجود صفت EFS انتشار را با 305 متوقف می‌کند و CreateFileW با همان مجوزهای قدیمی زاده می‌شود
امنیت NTFS با خود شیء فایل سفر می‌کند، نه با نام: دادن descriptor خوانده‌شده به‌عنوان lpSecurityAttributes باعث می‌شود rename چیزی را که اپراتور تنظیم کرده عوض نکند، و هر دروازه به‌جای حدس زدن بسته شکست می‌خورد
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');
  // اول اندازهٔ descriptor را بگیر، بعد فقط بخش 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;

یک جزئیات از رگرسیون ارزش دارد اگر خودت تست مشابهی می‌نویسی در ذهن نگهش داری. برای ساختن fixture محدود، تست یک DACL فقط-مالک اعمال می‌کند و باید SE_DACL_PROTECTED را به‌صراحت در کنترل descriptor ست کند؛ صرفاً پاس دادن پرچم محافظت‌شده در آرگومان SecurityInformation مربوط به SetFileSecurityW یک descriptor محافظت‌نشده را به محافظت‌شده تبدیل نمی‌کند. assertion بعدی این است که فایل منتشرشده هنوز بیت محافظت‌شده و یک DACL صریح و غیرتهی گزارش می‌کند، هم برای یک مسیر خروجی جداگانه و هم برای ترمیم روی خود فایل منبع

کدام LastErrorCode می‌گوید چه چیزی شکست خورده؟

RepairQDFFile در موفقیت 1 و در هر شکستی 0 برمی‌گرداند و LastErrorCode می‌گوید کدام مرحله رد کرده. منبعی که خوانده نمی‌شود، از جمله منبعی که پروسهٔ دیگری با یک قفل انحصاری گرفته، 401 گزارش می‌کند؛ خواندن حالا داخل یک wrapper است تا استثنا در ورودی به 401 نگاشت شود و به خطای نوشتن نشت نکند. ساختار QDF نامعتبر یا مبهم، مثل یک نشانگر stream تکراری برای همان شیء، PDFLIB_ERROR_QDF_REPAIR گزارش می‌کند که 107 است، و به مقصد دست نخورده چون نویسنده هرگز ساخته نشد. هر چیز بعد از ترمیم، از ساختن فایل موقت تا flush و rename، PDFLIB_ERROR_QDF_WRITE گزارش می‌کند که 305 است. رگرسیون همان‌های واقع‌بینانه را می‌آزماید: مقصدی که handle دیگری بدون اشتراک‌گذاری حذف بازش کرده، مقصد فقط-خواندنی، دایرکتوری مقصد غایب، و شکست هر سه مرحلهٔ نویسنده از طریق تزریق. در همهٔ آن‌ها مقدار برگشتی 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;

تضمین کجا تمام می‌شود

نویسنده در برابر شکست‌هایی که پروسه می‌تواند ببیند سازگاری را وعده می‌دهد و دربارهٔ آن‌هایی که نمی‌تواند صادق است. اگر پروسه بین ساختن فایل موقت و rename کشته شود، بلوک finally هرگز اجرا نمی‌شود و یک فایل .pdflib-qdf-<GUID>.tmp در دایرکتوری جا می‌ماند؛ مقصد هنوز سالم است که همان خصیصهٔ مهم است، اما جمع کردن این باقی‌مانده کار خودت است. قطع برق هم بیرون از این وعده است: داده flush می‌شود و rename از نوع write-through است که بهترین چیزی است که یک کتابخانهٔ user-mode می‌تواند بخواهد، اما نویسنده درایهٔ دایرکتوری را fsync نمی‌کند و روی آنچه فایل‌سیستم فراهم می‌کند ادعای دوام نمی‌کند. نویسندهٔ دومی که مقصد را هم‌زمان تغییر می‌دهد شناسایی نمی‌شود، چون DACL و صفت‌ها پیش از ساختن فایل موقت خوانده می‌شوند و هیچ‌چیز موقع rename دوباره بررسی‌شان نمی‌کند. و یک rename موفق یک هویت فایل جدید می‌سازد، پس alternate data streamها و صفت‌های معمولی مثل بیت archive یا hidden روی فایل قدیمی زنده نمی‌مانند؛ فقط DACL است که عمداً منتقل می‌شود

مرز باریک‌تر این است که اصلاً کدام API از این مسیر استفاده می‌کند. فقط RepairQDFFile از TPDFQDFFileWriter می‌گذرد. SaveQDFToFile و ConvertFileToQDF باز هم خروجی‌شان را با PLCreateFileStream(FileName, fmCreate) باز می‌کنند و تبدیل QDF را مستقیم در آن stream می‌ریزند، همان‌طور که مسیر افزایشی توصیف‌شده در مقالهٔ ضمیمه کردن به‌روزرسانی‌ها به یک stream در هر streamی که به آن بدهی می‌نویسد. آن دو فراخوانی یک artifact اشکال‌زدایی تازه از سندی می‌سازند که از قبل بارگذاری و اعتبارسنجی شده، پس حفرهٔ شکست در parse هرگز به آن‌ها مربوط نبود، اما آن‌ها هم انتشار مبتنی بر rename را به ارث نمی‌برند. این مقاله را این‌طور نخوان که «هر خروجی QDF اتمیک است». این یک خروجی است، همانی که ورودی‌اش فایلی غیرقابل‌اعتماد و دست‌ویرایش‌شده است و خروجی‌اش به‌طور روتین همان مسیر، و همین ترکیب بود که این ماشین‌آلات اضافه را برایش به دست آورد. تزریق خطایی که همهٔ این‌ها را اثبات می‌کند ارزان است چون سه مرحلهٔ نویسنده یعنی WriteData و Flush و Publish از نوع virtual هستند. زیرکلاس تست یکی از آن‌ها را override می‌کند تا بعد از شروع کار واقعی raise کند، Save را روی یک stream ترمیم‌شده صدا می‌زند و assertion می‌کند که استثنا منتشر می‌شود، byteهای منبع و مقصد تغییری نکرده‌اند و هیچ فایل موقتی باقی نمانده. هیچ API سراسری فایل قلاب نمی‌شود، هیچ فایل واقعی کاربر دست نمی‌خورد و سه مرحله یک-به-یک روی سه راهی نگاشت می‌شوند که یک انتشار در محیط عملیاتی می‌تواند شکست بخورد: دیسک پر می‌شود، flush رد می‌شود، یا rename رد می‌شود چون کس دیگری هدف را گرفته

API مربوط به RepairQDFFile و نویسندهٔ انتشار اتمیکش و بقیهٔ گردش‌کار اشکال‌زدایی QDF بخشی از PDF Library for Delphi هستند، در کنار قابلیت‌های بازیابی cross-reference و به‌روزرسانی افزایشی و رمزنگاری که جای دیگری در همین وبلاگ پوشش داده شده‌اند