Teknisk artikel

Atomisk PDF-reparation i Delphi: rename och DACL-säkerhet

PDF Library for Delphi publicerar utdatan från RepairQDFFile genom en intern skrivare, TPDFQDFFileWriter, som aldrig öppnar målet för skrivning: de reparerade byten går in i en exklusivt skapad temporär fil i samma katalog, filen flushas och stängs, och först därefter byter den namn över målet med MoveFileExW i Windows eller rename(2) i POSIX. Om något misslyckas före namnbytet behåller målet varje byte det hade, och anroparen ser LastErrorCode 305. Att reparera ett dokument i minnet är den lätta halvan av en reparationsfunktion. Att få resultatet ned på disk utan att någonsin lämna användaren med en tom eller halvskriven fil är den halva den här artikeln handlar om

Varför kan en reparation som misslyckas ändå förstöra målfilen?

Därför att ordningen på operationerna var fel. Före v3.539.13 öppnade RepairQDFFile utdatan med PLCreateFileStream(OutputFileName, fmCreate) och lämnade sedan den strömmen till parsern. fmCreate trunkerar vid öppning, så när QDF-skanningen väl beslutade att indatan inte gick att reparera hade målet redan tömts. Reparation på plats, där InputFileName och OutputFileName är samma sökväg, förvandlade en avvisad indata till en förlorad fil. Parsern själv skötte sig: den lågnivåfunktionen PDFQDFRepair lämnar målströmmen orörd när den avvisar tvetydiga markörer. Det skyddet var helt enkelt irrelevant, eftersom det publika API:t hade trunkerat filen ett anrop tidigare

Fixen i v3.539.13 flyttade reparationen in i en TMemoryStream och öppnade utdatan först efter att PDFQDFRepair hade lyckats. Det stänger hålet vid parsningsfel och ingenting mer. Skrivfasen var fortfarande fmCreate följt av CopyFrom, så ett fullt diskutrymme, en delningskonflikt halvvägs igenom eller ett undantag mellan trunkeringen och den sista WriteBuffer lämnade fortfarande ett skadat mål. Minnesförst-reparation skyddar mot dålig indata. Publicering till disk behöver sin egen gräns, och v3.539.14 och v3.539.15 byggde en

Hur RepairQDFFile i PDF Library for Delphi slutade förstöra sitt eget mål: v3.539.12 öppnade utdatan med PLCreateFileStream och fmCreate, vilket trunkerar innan PDFQDFRepair hinner avvisa indatan, v3.539.13 reparerade först in i en TMemoryStream, och v3.539.15 lämnar byten till TPDFQDFFileWriter för atomisk publicering
Fixen för parsningsfel och fixen för publicering är olika gränser: minnesförst-reparation skyddar mot dålig indata, medan skrivaren finns för att en full disk eller ett fel halvvägs genom en skrivning inte längre ska kunna lämna målet skadat
// v3.539.12: målet trunkeras innan indatan har validerats
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // för sent att säga nej
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: reparera i minnet, lämna sedan byten till publiceringsskrivaren
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // målet öppnas aldrig
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Vad garanterar atomisk publicering egentligen?

TPDFQDFFileWriter.Save garanterar att målsökvägen antingen är den kompletta gamla filen eller den kompletta nya filen, aldrig en blandning, för varje fel biblioteket självt kan observera. Skrivaren gör detta i fyra steg som vart och ett vägrar fortsätta om inte det föregående blev klart. Först löser den upp målet med GetFullPathNameW, anropar den två gånger och allokerar bufferten från den returnerade längden i stället för att anta MAX_PATH, så långa sökvägar inte tyst kapas. Sedan skapar den en temporär fil med namnet .pdflib-qdf- plus ett GUID plus .tmp i målkatalogen, med CreateFileW och CREATE_NEW i Windows samt open(2) med O_CREAT or O_EXCL och mode 0600 i POSIX. Båda flaggorna gör att skapandet misslyckas om namnet redan finns, så två processer som tävlar om samma GUID kan inte dela handtag. Tredje steget kopierar den reparerade strömmen i 64 KiB-block genom WriteBuffer, som kastar vid en kort skrivning i stället för att returnera ett antal ingen kontrollerar, och anropar sedan FlushFileBuffers eller fsync(2) och stänger handtaget. Fjärde steget byter namn

De fyra atomiska stegen i TPDFQDFFileWriter.Save i PDF Library for Delphi: lös upp sökvägen två gånger med GetFullPathNameW, skapa den temporära filen .pdflib-qdf med CREATE_NEW eller O_EXCL så att tävlande processer inte kan dela handtag, kopiera i 64 KiB-block via WriteBuffer och flusha, byt sedan namn med MoveFileExW och REPLACE_EXISTING och WRITE_THROUGH
Varje steg vägrar fortsätta om inte det föregående blev klart, den temporära filen ligger på målvolymen till sin konstruktion, ett fönster där målet först raderas finns aldrig, och städningen i en finally lämnar inga .tmp-rester efter sig
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
  // Tillåt inte en kopiering mellan volymer eller att först radera målet
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Namnbytessteget är där de flesta hemgjorda "säkra sparande"-rutiner tyst går sönder. MoveFileExW med MOVEFILE_REPLACE_EXISTING ersätter målet i en enda filsystemoperation på samma volym. Skrivaren utelämnar medvetet MOVEFILE_COPY_ALLOWED, eftersom en flytt mellan volymer degenererar till kopiera-sedan-radera, vilket är precis den icke-atomiska sekvens hela designen finns för att undvika. Eftersom den temporära filen ligger i målkatalogen ligger den på målvolymen till sin konstruktion. Skrivaren raderar heller aldrig den gamla filen först; ett par med radera-sedan-byta-namn har ett fönster där sökvägen inte existerar alls, och en krasch inuti det fönstret förlorar dokumentet. MOVEFILE_WRITE_THROUGH ber anropet att inte återvända förrän namnbytet har nått disken, vilket paras ihop med den explicita flushen av datan. I POSIX garanterar rename(2) redan att det nya namnet atomiskt ersätter en befintlig fil, och samma placering i katalogen hindrar det från att fallera med EXDEV. Städningen är symmetrisk. Det temporära namnet tas bort i ett finally-block på varje väg, vilket vid framgång är en no-op eftersom namnbytet redan har konsumerat det, och vid fel tar bort den partiella filen så att katalogen inte samlar .tmp-rester. Regressionen i Tests\QDFFileRegression.inc kontrollerar exakt det: efter varje injicerat fel matchar målbyten originalet, källbyten matchar originalet, och katalogen innehåller ingenting annat än de två fixturena

Varför luckrar en temporär fil upp behörigheterna i Windows?

En fil som skapas med en nil-säkerhetsdescriptor ärver sin DACL från föräldrakatalogen, inte från filen den ska ersätta. Det är rätt standard för ett helt nytt dokument och fel för en reparation på plats. Anta att en operatör har låst contract.pdf till ett enda konto med en skyddad, icke-ärvd DACL. En temporär fil bredvid den ärver katalogens bredare behörigheter, och när den väl har bytt namn över contract.pdf bär den omdöpta filen den breda DACL:en, eftersom NTFS-säkerhet följer med filobjektet, inte med namnet. Reparationen lyckas, byten är rätt, och den åtkomstkontroll operatören konfigurerade är tyst borta. Ingenting i returvärdet antyder det

PDF Library for Delphi läser därför målets DACL innan den temporära filen skapas och skickar in den som argumentet lpSecurityAttributes till CreateFileW, så den nya filen föds med den gamla filens behörigheter och namnbytet ändrar ingenting operatören skulle märka. Läsningen använder GetFileSecurityW med DACL_SECURITY_INFORMATION och dimensionerar bufferten från första anropets ERROR_INSUFFICIENT_BUFFER-resultat. Tre villkor gör att skrivaren fallerar stängt i stället för att gissa. Om DACL:en inte går att läsa stannar publiceringen med en EWriteError, som det publika API:t mappar till 305. Om descriptorn kommer tillbaka utan SE_DACL_PRESENT satt stannar publiceringen också, eftersom att skicka en sådan descriptor till CreateFileW skulle låta kärnan falla tillbaka på processens standard-DACL och ändra åtkomstsemantiken utan att någon bad om det. Och om målet bär FILE_ATTRIBUTE_ENCRYPTED vägrar skrivaren rakt av: den temporära filen skulle vara klartext, och att byta namn på en klartextfil över en EFS-skyddad publicerar en okrypterad ersättning av något användaren valde att kryptera på filsystemsnivå. EFS har inget med PDF:s standard-säkerhetshanterare att göra, som är ämnet för artikeln om inläsning av krypterade dokument, men feltillståndet är samma sorts tysta nedgradering

Varför QDF-publiceringsskrivaren kopierar målets DACL innan den skapar sin temporära fil: en nil-descriptor skulle ärva katalogens bredare behörigheter och namnbytet skulle tyst vidga åtkomsten, så GetFileSecurityW läser DACL:en, en saknad SE_DACL_PRESENT-bit eller ett EFS-attribut stoppar publiceringen med 305, och CreateFileW föds med de gamla behörigheterna
NTFS-säkerhet följer med filobjektet, inte med namnet: att skicka in den lästa descriptorn som lpSecurityAttributes gör att namnbytet inte ändrar något operatören konfigurerat, och varje grind fallerar stängt i stället för att gissa
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');
  // dimensionera descriptorn, läs sedan bara DACL-delen av den
  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;   // skickas till CreateFileW / CREATE_NEW
end;

En detalj från regressionen är värd att ha i minnet om du skriver ett liknande test själv. För att bygga den begränsade fixturen tillämpar testet en DACL som bara ägaren har och måste sätta SE_DACL_PROTECTED i descriptorns control-fält explicit; att bara skicka den skyddade flaggan i SecurityInformation-argumentet till SetFileSecurityW gör inte en oskyddad descriptor skyddad. Hävdandet efteråt är att den publicerade filen fortfarande rapporterar den skyddade biten och en explicit, icke-null DACL, både för en separat utdatasökväg och för reparation över källfilen själv

Vilken LastErrorCode säger vad som misslyckades?

RepairQDFFile returnerar 1 vid framgång och 0 vid varje fel, och LastErrorCode säger vilket steg som vägrade. En källa som inte går att läsa, inklusive en som en annan process håller med ett exklusivt lås, rapporterar 401; läsningen är nu inlindad så att ett undantag under indata mappas till 401 i stället för att läcka in i skrivfelet. Ogiltig eller tvetydig QDF-struktur, som en dubblerad strömmarkör för samma objekt, rapporterar PDFLIB_ERROR_QDF_REPAIR, vilket är 107, och målet har inte rörts eftersom skrivaren aldrig konstruerades. Allt efter reparationen, från skapandet av den temporära filen genom flush och namnbyte, rapporterar PDFLIB_ERROR_QDF_WRITE, vilket är 305. Regressionen övar de realistiska fallen: ett mål som öppnats av ett annat handtag utan raderingsdelning, ett skrivskyddat mål, en saknad målkatalog, och vart och ett av skrivarens tre steg som fallerar genom injektion. I alla dem är returvärdet 0, koden 305, och inget nytt eller partiellt mål finns efteråt. Vanan att läsa koden i stället för bara returvärdet är densamma som beskrivs i artikeln om att diagnosticera tysta fel i biblioteket

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Reparation på plats: samma sökväg är indata och utdata
    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;

Var garantin tar slut

Skrivaren lovar konsekvens mot fel processen kan se, och den är ärlig om dem den inte kan se. Om processen dödas mellan skapandet av den temporära filen och namnbytet körs aldrig finally-blocket och en .pdflib-qdf-<GUID>.tmp-fil lämnas i katalogen; målet är fortfarande intakt, vilket är egenskapen som spelar roll, men resterna är dina att sopa upp. Strömavbrott ligger också utanför löftet: datan flushas och namnbytet är write-through, vilket är det bästa ett användarlägesbibliotek kan begära, men skrivaren fsyncar inte katalogposten och gör inget hållbarhetsanspråk utöver vad filsystemet erbjuder. En andra skrivare som ändrar målet samtidigt upptäcks inte, eftersom DACL:en och attributen läses innan den temporära filen skapas och ingenting kontrollerar dem igen vid namnbytet. Och ett lyckat namnbyte skapar en ny filidentitet, så alternativa dataströmmar och vanliga attribut som arkiv- eller dold-biten på den gamla filen överlever inte; bara DACL:en förs över medvetet

Den snävare gränsen är vilket API som ens använder den här vägen. Bara RepairQDFFile går genom TPDFQDFFileWriter. SaveQDFToFile och ConvertFileToQDF öppnar fortfarande sin utdata med PLCreateFileStream(FileName, fmCreate) och strömmar QDF-konverteringen rakt in i den, på samma sätt som den inkrementella vägen som beskrivs i artikeln om att lägga till uppdateringar i en ström skriver till vilken ström du än ger den. De två anropen producerar en ny felsökningsartefakt från ett dokument som redan har lästs in och validerats, så hålet vid parsningsfel gällde aldrig dem, men de ärver inte heller den namnbytesbaserade publiceringen. Läs inte den här artikeln som "varje QDF-export är atomisk". Det är en enda utgång, den vars indata är en opålitlig, handredigerad fil och vars utdata rutinmässigt är samma sökväg, och den kombinationen är vad som förtjänade den extra maskineriet. Felinjektionen som bevisar allt detta är billig eftersom skrivarens tre steg, WriteData, Flush och Publish, är virtual. Testsubklassen skriver över ett av dem för att kasta efter att det riktiga arbetet har börjat, anropar Save på en reparerad ström, och hävdar att undantaget fortplantar sig, att käll- och målbyten är oförändrade, och att ingen temporär fil finns kvar. Inget globalt fil-API krokas, ingen riktig användarfil rörs, och de tre stegen mappar ett-till-ett mot de tre sätt en publicering kan fallera i produktion: disken fylls, flushen avvisas, eller namnbytet nekas för att någon annan håller målet

API:t RepairQDFFile, dess atomiska publiceringsskrivare och resten av QDF-felsökningsarbetsflödet är en del av PDF Library for Delphi, tillsammans med återställning av cross-referenser, inkrementella uppdateringar och krypteringsfunktioner som täcks på andra ställen i den här bloggen