Techninis straipsnis

Atominė PDF taisymo išvestis Delphi: pervadinimas ir DACL

PDF Library for Delphi RepairQDFFile išvestį paskelbia per vidinį rašytoją TPDFQDFFileWriter, kuris niekada neatidaro paskirties vietos rašymui: sutaisyti baitai patenka į išskirtinai sukurtą laikiną failą tame pačiame kataloge, failo buferiai išstumiami ir failas uždaromas, ir tik tada jis pervadinamas per tikslą su MoveFileExW Windows aplinkoje arba rename(2) POSIX aplinkoje. Jei kas nors nepavyksta prieš pervadinimą, paskirties vieta išlaiko kiekvieną turėtą baitą, o kviečiantysis mato LastErrorCode 305. Dokumento sutaisymas atmintyje yra lengvoji taisymo funkcijos pusė. Rezultato perkėlimas į diską taip, kad naudotojui niekada neliktų nulinio ilgio ar pusiau įrašyto failo, yra ta pusė, apie kurią ir rašoma šiame straipsnyje

Kodėl nepavykęs taisymas vis tiek gali sunaikinti tikslinį failą?

Nes operacijų tvarka buvo neteisinga. Iki v3.539.13 RepairQDFFile atidarydavo išvestį su PLCreateFileStream(OutputFileName, fmCreate) ir tada tą srautą perduodavo analizatoriui. fmCreate atidarant nukirpia failą, tad tuo metu, kai QDF skenavimas nuspręsdavo, kad įvestis nesutaisoma, paskirties vieta jau būdavo ištuštinta. Taisymas vietoje, kai InputFileName ir OutputFileName yra tas pats kelias, atmestą įvestį paversdavo prarastu failu. Pats analizatorius elgėsi tvarkingai: žemo lygio funkcija PDFQDFRepair palieka tikslinį srautą nepaliestą, kai atmeta dviprasmiškus žymenis. Ta apsauga buvo tiesiog neaktuali, nes viešasis API failą buvo nukirpęs vienu kvietimu anksčiau

v3.539.13 pataisa perkėlė taisymą į TMemoryStream ir išvestį atidarė tik po to, kai PDFQDFRepair jau buvo pavykęs. Tai uždaro analizės nesėkmės skylę ir nieko daugiau. Rašymo fazė vis dar buvo fmCreate ir po jo CopyFrom, tad pilno disko sąlyga, bendro naudojimo pažeidimas įpusėjus arba išimtis tarp nukirpimo ir paskutinio WriteBuffer vis tiek palikdavo sugadintą paskirties vietą. Taisymas pirma atmintyje apsaugo nuo blogos įvesties. Publikavimui į diską reikia savo ribos, ir v3.539.14 bei v3.539.15 ją pastatė

Kaip RepairQDFFile PDF Library for Delphi nustojo naikinti savo paties tikslą: v3.539.12 atidarydavo išvestį su PLCreateFileStream ir fmCreate, kuris nukirpia failą dar prieš PDFQDFRepair galint atmesti įvestį, v3.539.13 pirmiausia taisydavo į TMemoryStream, o v3.539.15 perduoda baitus TPDFQDFFileWriter atominiam paskelbimui
Analizės nesėkmės pataisa ir publikavimo pataisa yra skirtingos ribos: taisymas pirma atmintyje apsaugo nuo blogos įvesties, o rašytojas egzistuoja tam, kad pilnas diskas arba įpusėjęs rašymas nebegalėtų palikti sugadintos paskirties vietos
// v3.539.12: paskirties vieta nukirpiama dar nepatikrinus įvesties
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // per vėlu pasakyti ne
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: taisoma atmintyje, tada baitai perduodami publikavimo rašytojui
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // paskirties vieta neatidaryta
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Ką atominis publikavimas iš tikrųjų garantuoja?

TPDFQDFFileWriter.Save garantuoja, kad paskirties kelias yra arba visas senas failas, arba visas naujas failas, niekada mišinys – kiekvienam gedimui, kurį pati biblioteka gali pastebėti. Rašytojas tai daro keturiais žingsniais, ir kiekvienas atsisako tęsti, jei ankstesnis nebuvo baigtas. Pirmiausia jis išsprendžia paskirties kelią su GetFullPathNameW, iškviesdamas ją du kartus ir alokuodamas buferį iš grąžinto ilgio, o ne manydamas MAX_PATH, tad ilgi keliai tyliai nenukirpami. Antra, jis sukuria laikiną failą, pavadintą .pdflib-qdf- plius GUID plius .tmp, paskirties kataloge, naudodamas CreateFileW su CREATE_NEW Windows aplinkoje ir open(2) su O_CREAT or O_EXCL bei 0600 režimu POSIX aplinkoje. Abu žymenys padaro taip, kad kūrimas nepavyksta, jei vardas jau egzistuoja, tad du procesai, lenktyniaujantys dėl to paties GUID, negali pasidalinti rankenos. Trečia, jis nukopijuoja sutaisytą srautą 64 KiB gabalais per WriteBuffer, kuris kelia klaidą dėl trumpo įrašymo, o ne grąžina skaičių, kurio niekas netikrina, tada iškviečia FlushFileBuffers arba fsync(2) ir uždaro rankeną. Ketvirta, jis pervadina

Keturi atominiai TPDFQDFFileWriter.Save žingsniai PDF Library for Delphi: kelias išsprendžiamas du kartus su GetFullPathNameW, laikinas .pdflib-qdf failas sukuriamas su CREATE_NEW arba O_EXCL, kad lenktyniaujantys procesai negalėtų pasidalinti rankenos, kopijuojama 64 KiB WriteBuffer gabalais ir išstumiami buferiai, o tada MoveFileExW su REPLACE_EXISTING ir WRITE_THROUGH
Kiekvienas žingsnis atsisako tęsti, jei ankstesnis nebuvo baigtas, laikinas failas iš paties konstrukto gyvena paskirties tome, trynimo-pirmiausia lango niekada nebūna, o finally valymas nepalieka jokių .tmp liekanų
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
  // Neleidžiama kopijuoti tarp tomų arba pirmiau ištrinti paskirties vietos
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Pervadinimo žingsnis yra vieta, kur dauguma savadarbių „saugaus išsaugojimo“ rutinų tyliai sulūžta. MoveFileExW su MOVEFILE_REPLACE_EXISTING pakeičia tikslą viena failų sistemos operacija tame pačiame tome. Rašytojas sąmoningai praleidžia MOVEFILE_COPY_ALLOWED, nes perkėlimas tarp tomų išsigimsta į kopijavimą ir trynimą – būtent tą neatominę seką, kurios visas sumanymas ir siekia išvengti. Kadangi laikinas failas gyvena paskirties kataloge, jis iš paties konstrukto yra paskirties tome. Rašytojas taip pat niekada nepirmiau neištrina seno failo; trynimo ir pervadinimo pora turi langą, kuriame kelio apskritai nėra, o avarija tame lange praranda dokumentą. MOVEFILE_WRITE_THROUGH prašo kvietimo negrįžti, kol pervadinimas nepasiekė disko, ir tai poruojasi su aiškiu duomenų buferių išstūmimu. POSIX aplinkoje rename(2) jau garantuoja, kad naujasis vardas atomiškai pakeičia bet kurį esamą failą, o tas pats vietos kataloge parinkimas neleidžia jam nepavykti su EXDEV. Valymas yra simetriškas. Laikinas vardas pašalinamas finally bloke kiekviename kelyje, o tai sėkmės atveju yra no-op, nes pervadinimas jį jau suvartojo, o nesėkmės atveju pašalina dalinį failą, kad katalogas nekauptų .tmp liekanų. Regresija Tests\QDFFileRegression.inc tikrina būtent tai: po kiekvieno įterpto gedimo paskirties baitai sutampa su originalu, šaltinio baitai sutampa su originalu, o kataloge nėra nieko, išskyrus du testinius failus

Kodėl laikinas failas Windows aplinkoje susilpnina teises?

Failas, sukurtas su nuliniu saugumo deskriptoriumi, paveldi savo DACL iš tėvinio katalogo, o ne iš failo, kurį ruošiasi pakeisti. Tai teisinga numatytoji reikšmė visiškai naujam dokumentui ir neteisinga taisymui vietoje. Tarkime, operatorius contract.pdf užrakino vienai paskyrai su apsaugotu, nepaveldimu DACL. Laikinas failas šalia jo paveldi platesnes katalogo teises, ir kai jis pervadinamas per contract.pdf, pervadintas failas nešasi platųjį DACL, nes NTFS saugumas keliauja su failo objektu, o ne su vardu. Taisymas pavyksta, baitai teisingi, o operatoriaus sukonfigūruota prieigos kontrolė tyliai dingusi. Niekas grąžintoje reikšmėje į tai neužsimena

Todėl PDF Library for Delphi perskaito paskirties vietos DACL dar prieš sukurdama laikiną failą ir perduoda jį kaip lpSecurityAttributes argumentą CreateFileW, tad naujasis failas gimsta su senojo failo teisėmis, ir pervadinimas nepakeičia nieko, ką operatorius pastebėtų. Skaitymas naudoja GetFileSecurityW su DACL_SECURITY_INFORMATION, o buferio dydį nustato pagal pirmo kvietimo ERROR_INSUFFICIENT_BUFFER rezultatą. Trys sąlygos verčia rašytoją užsidaryti saugiai, o ne spėlioti. Jei DACL perskaityti nepavyksta, publikavimas sustoja su EWriteError, kurį viešasis API susieja su 305. Jei deskriptorius grįžta be nustatyto SE_DACL_PRESENT, publikavimas taip pat sustoja, nes tokio deskriptoriaus perdavimas CreateFileW leistų branduoliui nukristi į proceso numatytąjį DACL ir pakeisti prieigos semantiką niekam to neprašius. O jei tikslas turi FILE_ATTRIBUTE_ENCRYPTED, rašytojas atsisako iš karto: laikinas failas būtų atviru tekstu, o atviro teksto failo pervadinimas per EFS saugomą failą paskelbia neužšifruotą pakaitalą to, ką naudotojas pasirinko šifruoti failų sistemos lygiu. EFS nesusijęs su PDF standartiniais saugumo apdorojimo blokais, kurie yra užšifruotų dokumentų įkėlimo straipsnio tema, bet gedimo pobūdis yra tas pats tylus nuosmukis

Kodėl QDF publikavimo rašytojas nukopijuoja paskirties vietos DACL prieš kurdamas savo laikiną failą: nulinis deskriptorius paveldėtų platesnes katalogo teises ir pervadinimas tyliai praplėstų prieigą, tad GetFileSecurityW perskaito DACL, trūkstamas SE_DACL_PRESENT bitas arba EFS požymis sustabdo publikavimą su 305, o CreateFileW gimsta su senosiomis teisėmis
NTFS saugumas keliauja su failo objektu, o ne su vardu: perskaityto deskriptoriaus perdavimas kaip lpSecurityAttributes padaro taip, kad pervadinimas nepakeičia nieko, ką operatorius sukonfigūravo, ir kiekvieni vartai užsidaro saugiai, o ne spėlioja
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');
  // nustatomas deskriptoriaus dydis, tada skaitoma tik jo DACL dalis
  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;   // perduodamas CreateFileW / CREATE_NEW
end;

Vieną regresijos niuansą verta turėti omenyje, jei patys rašote panašų testą. Norėdamas pastatyti apribotą testinį failą, testas pritaiko DACL, leidžiantį tik savininkui, ir turi aiškiai nustatyti SE_DACL_PROTECTED deskriptoriaus valdymo lauke; vien apsaugotos vėliavėlės perdavimas SetFileSecurityW SecurityInformation argumente neapsaugoto deskriptoriaus apsaugotu nepadaro. Po to tvirtinama, kad paskelbtas failas vis dar praneša apsaugotą bitą ir aiškų, ne nulinį DACL – tiek atskiram išvesties keliui, tiek taisymui per patį šaltinio failą

Kuris LastErrorCode pasako, kas nepavyko?

RepairQDFFile grąžina 1 sėkmės atveju ir 0 dėl bet kokios nesėkmės, o LastErrorCode pasako, kuris etapas atsisakė. Šaltinis, kurio neįmanoma perskaityti, įskaitant tokį, kurį kitas procesas laiko išskirtiniu užraktu, praneša 401; skaitymas dabar apgaubtas taip, kad išimtis skaitant įvestį susietų su 401, o ne nutekėtų į rašymo klaidą. Negaliojanti ar dviprasmiška QDF struktūra, pavyzdžiui pasikartojantis srauto žymuo tam pačiam objektui, praneša PDFLIB_ERROR_QDF_REPAIR, tai yra 107, ir paskirties vieta neliesta, nes rašytojas nebuvo sukurtas. Viskas po taisymo, nuo laikino failo sukūrimo iki buferių išstūmimo ir pervadinimo, praneša PDFLIB_ERROR_QDF_WRITE, tai yra 305. Regresija išbando realistinius atvejus: paskirties vietą, atidarytą kita rankena be trynimo bendro naudojimo, tik skaitomą paskirties vietą, nerastą paskirties katalogą ir kiekvieną iš trijų rašytojo etapų, nepavykstantį per įterpimą. Visais jais grąžinama 0, kodas yra 305, ir po to neegzistuoja joks naujas ar dalinis tikslas. Bendras įprotis skaityti kodą, o ne vien grąžinamą reikšmę, yra tas pats, aprašytas tylių gedimų diagnostikos straipsnyje

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Taisymas vietoje: tas pats kelias yra įvestis ir išvestis
    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;

Kur garantija sustoja

Rašytojas žada nuoseklumą prieš gedimus, kuriuos procesas gali pastebėti, ir yra sąžiningas dėl tų, kurių negali. Jei procesas nužudomas tarp laikino failo sukūrimo ir pervadinimo, finally blokas niekada neįvyksta, ir kataloge lieka .pdflib-qdf-<GUID>.tmp failas; paskirties vieta vis tiek sveika – tai savybė, kuri ir svarbi – bet tas šiukšles teks iššluoti patiems. Elektros dingimas taip pat nepatenka į pažadą: duomenų buferiai išstumiami, o pervadinimas yra write-through, ir tai yra geriausia, ko gali prašyti naudotojo režimo biblioteka, bet rašytojas nesinchronizuoja katalogo įrašo ir neteikia jokio patvarumo pažado virš to, ką duoda failų sistema. Antras rašytojas, lygiagrečiai keičiantis paskirties vietą, nepastebimas, nes DACL ir požymiai perskaitomi prieš sukuriant laikiną failą, ir niekas jų iš naujo nepatikrina pervadinimo metu. O sėkmingas pervadinimas sukuria naują failo tapatybę, tad alternatyvūs duomenų srautai ir įprasti požymiai, tokie kaip archyvo ar paslėptojo bitas senajame faile, neišlieka; sąmoningai perkeliamas tik DACL

Dar siauresnė riba yra tai, kuris API apskritai naudoja šį kelią. Tik RepairQDFFile eina per TPDFQDFFileWriter. SaveQDFToFile ir ConvertFileToQDF vis dar atidaro savo išvestį su PLCreateFileStream(FileName, fmCreate) ir QDF konvertavimą transliuoja tiesiai į ją, taip pat kaip inkrementinis kelias, aprašytas atnaujinimų priedų rašymo į srautą straipsnyje, rašo į bet kurį jiems paduotą srautą. Tie du kvietimai gamina naują derinimo artefaktą iš dokumento, kuris jau buvo įkeltas ir patikrintas, tad analizės nesėkmės skylė jiems niekada negaliojo, bet jie taip pat nepaveldi pervadinimu grįsto publikavimo. Neskaitykite šio straipsnio kaip „kiekvienas QDF eksportas yra atominis“. Tai vienas išėjimas – tas, kurio įvestis yra nepatikimas, rankomis redaguotas failas, o išvestis paprastai yra tas pats kelias – ir būtent ta kombinacija užsitarnavo papildomą mechanizmą. Gedimų įterpimas, kuris visa tai įrodo, yra pigus, nes rašytojo trys etapai WriteData, Flush ir Publish yra virtual. Testo poklasis perrašo vieną iš jų, kad keltų klaidą jau prasidėjus tikram darbui, iškviečia Save su sutaisytu srautu ir tvirtina, kad išimtis praeina, kad šaltinio ir paskirties baitai nepakitę, ir kad joks laikinas failas nelieka. Joks globalus failų API nėra užkabinamas, joks tikras naudotojo failas neliečiamas, o trys etapai vienas prie vieno atitinka tris būdus, kaip publikavimas gali nepavykti produkcijoje: diskas prisipildo, buferių išstūmimas atmetamas arba pervadinimas atsisakomas, nes tikslą laiko kas nors kitas

RepairQDFFile API, jo atominio publikavimo rašytojas ir likusi QDF derinimo eiga yra PDF Library for Delphi dalis, kartu su kryžminių nuorodų atkūrimo, inkrementinio atnaujinimo ir šifravimo funkcijomis, aprašytomis kitur šiame bloge