מאמר טכני

פלט תיקון PDF אטומי בדלפי: rename ובטיחות DACL

‏PDF Library for Delphi מפרסמת את הפלט של RepairQDFFile דרך כותב פנימי, TPDFQDFFileWriter, שאף פעם לא פותח את היעד לכתיבה: הבתים המתוקנים נכנסים לקובץ זמני שנוצר באופן בלעדי באותה תיקייה, הקובץ נשטף ונסגר, ורק אז הוא עובר rename מעל היעד עם MoveFileExW ב-Windows או rename(2) ב-POSIX. אם משהו נכשל לפני ה-rename, היעד שומר על כל בית שהיה בו, והקורא רואה 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 בנו כזה

איך RepairQDFFile ב-PDF Library for Delphi הפסיקה להרוס את היעד של עצמה: v3.539.12 פתחה את הפלט עם PLCreateFileStream ו-fmCreate, שמקצץ לפני ש-PDFQDFRepair יכולה לדחות את הקלט, v3.539.13 תיקנה קודם ל-TMemoryStream, ו-v3.539.15 מוסרת את הבתים ל-TPDFQDFFileWriter לפרסום אטומי
התיקון לכשל פענוח והתיקון לפרסום הם שני גבולות שונים: תיקון שמתחיל בזיכרון מגן מפני קלט רע, בעוד הכותב קיים כדי שדיסק מלא או כשל באמצע הכתיבה כבר לא יוכלו להשאיר את היעד פגום
// 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 לא יכולים לחלוק handle. שלישית הוא מעתיק את הזרם המתוקן במקטעים של 64 KiB דרך WriteBuffer, שמעלה חריגה בכתיבה קצרה במקום להחזיר מונה שאף אחד לא בודק, ואז קורא ל-FlushFileBuffers או ל-fsync(2) וסוגר את ה-handle. רביעית, הוא מבצע rename

ארבעת השלבים האטומיים של TPDFQDFFileWriter.Save ב-PDF Library for Delphi: פענוח הנתיב פעמיים עם GetFullPathNameW, יצירת הקובץ הזמני .pdflib-qdf עם CREATE_NEW או O_EXCL כך שתהליכים מתחרים לא יחלקו handle, העתקה במקטעי WriteBuffer של 64 KiB ו-flush, ואז MoveFileExW עם REPLACE_EXISTING ו-WRITE_THROUGH
כל שלב מסרב להמשיך אלא אם הקודם הסתיים, הקובץ הזמני יושב על כרך היעד מעצם בנייתו, חלון של מחיקה-תחילה אף פעם לא קיים, והניקוי ב-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
  // בלי לאפשר העתקה בין כרכים ובלי למחוק את היעד קודם
  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 הוא המקום שבו רוב שגרות ה"שמירה בטוחה" שנכתבו בבית נשברות בשקט. MoveFileExW עם MOVEFILE_REPLACE_EXISTING מחליף את היעד בפעולת מערכת קבצים אחת באותו כרך. הכותב משמיט בכוונה את MOVEFILE_COPY_ALLOWED, כי העברה בין כרכים מתדרדרת להעתקה ואז מחיקה, וזה בדיוק הרצף הלא-אטומי שכל התכנון קיים כדי להימנע ממנו. מכיוון שהקובץ הזמני יושב בתיקיית היעד, הוא נמצא על כרך היעד מעצם בנייתו. הכותב גם אף פעם לא מוחק את הקובץ הישן קודם; זוג של מחיקה ואז rename מותיר חלון שבו הנתיב לא קיים בכלל, וקריסה בתוך החלון הזה מאבדת את המסמך. MOVEFILE_WRITE_THROUGH מבקש שהקריאה לא תחזור עד שה-rename הגיע לדיסק, וזה מתחבר ל-flush המפורש של הנתונים. ב-POSIX, rename(2) כבר מבטיח שהשם החדש מחליף באופן אטומי כל קובץ קיים, ואותו מיקום בתיקייה מונע ממנו להיכשל עם EXDEV. הניקוי סימטרי. השם הזמני מוסר בבלוק finally בכל נתיב, שבמקרה של הצלחה הוא no-op כי ה-rename כבר צרך אותו, ובמקרה של כשל מסיר את הקובץ החלקי כך שהתיקייה לא צוברת שאריות .tmp. הרגרסיה ב-Tests\QDFFileRegression.inc בודקת בדיוק את זה: אחרי כל כשל מוזרק, בתי היעד תואמים למקור, בתי המקור תואמים למקור, והתיקייה לא מכילה דבר מלבד שני קובצי הבדיקה

למה קובץ זמני מרפה הרשאות ב-Windows?

קובץ שנוצר עם security descriptor של nil יורש את ה-DACL שלו מתיקיית האב, ולא מהקובץ שהוא עומד להחליף. זו ברירת המחדל הנכונה למסמך חדש לגמרי והשגויה לתיקון במקום. נניח שמפעיל נעל את contract.pdf לחשבון אחד עם DACL מוגן ושאינו עובר בירושה. קובץ זמני שלידו יורש את ההרשאות הרחבות יותר של התיקייה, וברגע שהוא עובר rename מעל contract.pdf הקובץ המשונה נושא את ה-DACL הרחב, כי אבטחת NTFS נוסעת עם אובייקט הקובץ ולא עם השם. התיקון מצליח, הבתים נכונים, ובקרת הגישה שהמפעיל הגדיר נעלמה בשקט. שום דבר בערך המוחזר לא מרמז על זה

לכן 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 הייתה מאפשרת ל-kernel לחזור ל-DACL ברירת המחדל של התהליך ולשנות סמנטיקת גישה בלי שאיש ביקש. ואם ליעד יש FILE_ATTRIBUTE_ENCRYPTED, הכותב פשוט מסרב: הקובץ הזמני יהיה טקסט גלוי, ו-rename של קובץ טקסט גלוי מעל קובץ מוגן ב-EFS מפרסם תחליף לא מוצפן למשהו שהמשתמש בחר להצפין ברמת מערכת הקבצים. ‏EFS לא קשור ל-security handlers התקניים של PDF, שהם הנושא של המאמר על טעינת מסמכים מוצפנים, אבל מצב הכשל הוא אותו סוג של הורדת דרגה שקטה

למה כותב הפרסום של QDF מעתיק את ה-DACL של היעד לפני יצירת הקובץ הזמני: descriptor של nil היה יורש את ההרשאות הרחבות של התיקייה וה-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;

פרט אחד מהרגרסיה שווה לזכור אם תכתבו בדיקה דומה בעצמכם. כדי לבנות את קובץ הבדיקה המוגבל, הבדיקה מחילה DACL לבעלים בלבד וחייבת לקבוע SE_DACL_PROTECTED בבקרת ה-descriptor במפורש; העברה של הדגל המוגן בארגומנט ה-SecurityInformation של SetFileSecurityW בלבד לא הופכת descriptor לא מוגן למוגן. הטענה שאחר כך היא שהקובץ שפורסם עדיין מדווח על הסיבית המוגנת ועל DACL מפורש שאינו null, גם עבור נתיב פלט נפרד וגם עבור תיקון מעל קובץ המקור עצמו

איזה LastErrorCode אומר לכם מה נכשל?

RepairQDFFile מחזירה 1 בהצלחה ו-0 בכל כשל, ו-LastErrorCode אומר איזה שלב סירב. מקור שאינו ניתן לקריאה, כולל כזה שתהליך אחר מחזיק בנעילה בלעדית, מדווח 401; הקריאה עטופה כעת כך שחריגה בזמן הקלט ממופה ל-401 ולא דולפת לשגיאת הכתיבה. מבנה QDF שגוי או דו-משמעי, כמו סמן זרם כפול לאותו אובייקט, מדווח 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; היעד עדיין שלם, וזו התכונה שחשובה, אבל השאריות הן באחריותכם לנקות. גם הפסקת חשמל נמצאת מחוץ להבטחה: הנתונים נשטפים וה-rename הוא write-through, וזה הטוב ביותר שספרייה במרחב משתמש יכולה לבקש, אבל הכותב לא עושה fsync לרשומת התיקייה ולא טוען טענת עמידות מעבר למה שמערכת הקבצים מספקת. כותב שני שמשנה את היעד במקביל לא מזוהה, כי ה-DACL והמאפיינים נקראים לפני יצירת הקובץ הזמני ושום דבר לא בודק אותם שוב בזמן ה-rename. ו-rename מוצלח יוצר זהות קובץ חדשה, כך ש-alternate data streams ומאפיינים רגילים כמו סיבית archive או hidden בקובץ הישן לא שורדים; רק ה-DACL מועבר בכוונה

הגבול הצר יותר הוא איזה API בכלל משתמש בנתיב הזה. רק RepairQDFFile עוברת דרך TPDFQDFFileWriter. ‏SaveQDFToFile ו-ConvertFileToQDF עדיין פותחות את הפלט שלהן עם PLCreateFileStream(FileName, fmCreate) וזורמות את המרת ה-QDF ישר לתוכו, כמו שהנתיב המצטבר המתואר במאמר על הוספת עדכונים לזרם כותב לכל זרם שתמסרו לו. שתי הקריאות האלה מייצרות תוצר ניפוי באגים חדש ממסמך שכבר נטען ואומת, כך שחור כשל הפענוח מעולם לא חל עליהן, אבל הן גם לא יורשות את הפרסום מבוסס ה-rename. אל תקראו את המאמר הזה בתור "כל ייצוא QDF הוא אטומי". זו יציאה אחת, זו שהקלט שלה הוא קובץ לא מהימן שנערך ביד ושהפלט שלה הוא באופן שגרתי אותו נתיב, והשילוב הזה הוא מה שזיכה אותה במנגנון הנוסף. הזרקת התקלות שמוכיחה את כל זה זולה כי שלושת השלבים של הכותב, WriteData, Flush ו-Publish, הם virtual. תת-המחלקה של הבדיקה דורסת אחד מהם כדי להעלות חריגה אחרי שהעבודה האמיתית התחילה, קוראת ל-Save על זרם מתוקן, ומאמתת שהחריגה מתפשטת, שבתי המקור והיעד לא השתנו, ושאין קובץ זמני שנשאר. שום API קבצים גלובלי לא נתפס ב-hook, שום קובץ משתמש אמיתי לא נגוע, ושלושת השלבים ממופים אחד לאחד לשלוש הדרכים שבהן פרסום יכול להיכשל בפרודקשן: הדיסק מתמלא, ה-flush נדחה, או ה-rename נדחה כי מישהו אחר מחזיק את היעד

ה-API של RepairQDFFile, כותב הפרסום האטומי שלו ושאר זרימת העבודה של ניפוי באגים ב-QDF הם חלק מPDF Library for Delphi, לצד יכולות שחזור ה-cross-reference, העדכון המצטבר וההצפנה המכוסות במקומות אחרים בבלוג הזה