Tekninen artikkeli

PDF-korjauksen atominen julkaisu Delphissä ja DACL-turva

PDF Library for Delphi julkaisee RepairQDFFile-kutsun tulosteen sisäisen kirjoittajan, TPDFQDFFileWriter-luokan, kautta, eikä se koskaan avaa kohdetta kirjoittamista varten: korjatut tavut menevät yksinomaisesti luotuun väliaikaistiedostoon samaan hakemistoon, tiedosto huuhdellaan ja suljetaan, ja vasta sitten se nimetään kohteen päälle MoveFileExW-kutsulla Windowsissa tai rename(2)-kutsulla POSIXissa. Jos jokin epäonnistuu ennen uudelleennimeämistä, kohde säilyttää jokaisen tavunsa ja kutsuja näkee LastErrorCode-arvon 305. Asiakirjan korjaaminen muistissa on korjausominaisuuden helppo puolisko. Tuloksen saaminen levylle niin, ettei käyttäjälle koskaan jää nollapituista tai puoliksi kirjoitettua tiedostoa, on se puolisko, josta tämä artikkeli kertoo

Miksi epäonnistuva korjaus voi silti tuhota kohdetiedoston?

Koska toimintojen järjestys oli väärä. Ennen versiota v3.539.13 RepairQDFFile avasi tulosteen PLCreateFileStream(OutputFileName, fmCreate) -kutsulla ja antoi sitten tuon streamin jäsentimelle. fmCreate katkaisee tiedoston avattaessa, joten siihen mennessä kun QDF-läpikäynti päätti, ettei syöte ollut korjattavissa, kohde oli jo tyhjennetty. Paikan päällä tehtävä korjaus, jossa InputFileName ja OutputFileName ovat sama polku, muutti hylätyn syötteen menetetyksi tiedostoksi. Jäsennin itse käyttäytyi hyvin: matalan tason PDFQDFRepair-funktio jättää kohdestreamin koskemattomaksi, kun se hylkää monitulkintaiset merkit. Tuo suoja oli yksinkertaisesti merkityksetön, koska julkinen rajapinta oli katkaissut tiedoston yhtä kutsua aiemmin

Version v3.539.13 korjaus siirsi korjauksen TMemoryStream-streamiin ja avasi tulosteen vasta kun PDFQDFRepair oli onnistunut. Se sulkee jäsennysvirheen reiän eikä mitään muuta. Kirjoitusvaihe oli edelleen fmCreate ja sen perään CopyFrom, joten levyn täyttyminen, kesken kaiken tullut jakorikkomus tai poikkeus katkaisun ja viimeisen WriteBuffer-kutsun välissä jätti kohteen silti vahingoittuneeksi. Muisti edellä tehty korjaus suojaa huonolta syötteeltä. Levylle julkaiseminen tarvitsee oman rajansa, ja versiot v3.539.14 ja v3.539.15 rakensivat sellaisen

Miten PDF Library for Delphin RepairQDFFile lakkasi tuhoamasta omaa kohdettaan: v3.539.12 avasi tulosteen PLCreateFileStream- ja fmCreate-kutsulla, mikä katkaisee tiedoston ennen kuin PDFQDFRepair ehtii hylätä syötteen, v3.539.13 korjasi ensin TMemoryStream-streamiin, ja v3.539.15 antaa tavut TPDFQDFFileWriterille atomista julkaisua varten
Jäsennysvirheen korjaus ja julkaisukorjaus ovat eri rajoja: muisti edellä tehty korjaus suojaa huonolta syötteeltä, kun taas kirjoittaja on olemassa siksi, ettei täysi levy tai kesken kirjoituksen tullut virhe voi enää jättää kohdetta vahingoittuneeksi
// v3.539.12: kohde katkaistaan ennen kuin syöte validoidaan
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // liian myöhäistä sanoa ei
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: korjaus muistissa, sitten tavut julkaisukirjoittajalle
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // kohdetta ei koskaan avattu
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Mitä atominen julkaisu oikeastaan takaa?

TPDFQDFFileWriter.Save takaa, että kohdepolku on joko kokonaan vanha tiedosto tai kokonaan uusi tiedosto, ei koskaan sekoitus, jokaisen sellaisen vian osalta, jonka kirjasto itse pystyy havaitsemaan. Kirjoittaja tekee tämän neljässä vaiheessa, joista jokainen kieltäytyy jatkamasta, ellei edellinen päättynyt. Ensimmäiseksi se ratkaisee kohteen GetFullPathNameW-kutsulla, kutsuen sitä kahdesti ja varaamalla puskurin palautetun pituuden mukaan sen sijaan että olettaisi MAX_PATH-arvon, joten pitkät polut eivät tule hiljaa katkaistuiksi. Toiseksi se luo kohdehakemistoon väliaikaistiedoston, jonka nimi on .pdflib-qdf- plus GUID plus .tmp, käyttäen CreateFileW-kutsua CREATE_NEW-lipulla Windowsissa ja open(2)-kutsua O_CREAT or O_EXCL -lipuilla sekä tilalla 0600 POSIXissa. Molemmat liput saavat luonnin epäonnistumaan, jos nimi on jo olemassa, joten kaksi samasta GUIDista kilpailevaa prosessia ei voi jakaa kahvaa. Kolmanneksi se kopioi korjatun streamin 64 kilotavun paloina WriteBuffer-kutsun kautta, joka nostaa virheen vajaasta kirjoituksesta sen sijaan että palauttaisi luvun, jota kukaan ei tarkista, ja kutsuu sitten FlushFileBuffers- tai fsync(2)-funktiota ja sulkee kahvan. Neljänneksi se nimeää tiedoston uudelleen

TPDFQDFFileWriter.Saven neljä atomista vaihetta PDF Library for Delphissä: polku ratkaistaan kahdella GetFullPathNameW-kutsulla, .pdflib-qdf-väliaikaistiedosto luodaan CREATE_NEW- tai O_EXCL-lipulla niin etteivät kilpailevat prosessit voi jakaa kahvaa, kopiointi tehdään 64 kilotavun WriteBuffer-paloina ja tiedosto huuhdellaan, minkä jälkeen suoritetaan MoveFileExW REPLACE_EXISTING- ja WRITE_THROUGH-lipuilla
Jokainen vaihe kieltäytyy jatkamasta, ellei edellinen päättynyt, väliaikaistiedosto on rakenteellisesti kohdelevyllä, ensin poistavan välivaiheen ikkunaa ei koskaan synny, ja finally-lohkon siivous ei jätä jälkeensä .tmp-jäänteitä
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
  // Älä salli levyjen välistä kopiota äläkä poista kohdetta ensin
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Uudelleennimeämisvaihe on se kohta, jossa useimmat itse kyhätyt ”turvallinen tallennus” -rutiinit hajoavat hiljaa. MoveFileExW MOVEFILE_REPLACE_EXISTING-lipulla korvaa kohteen yhdellä tiedostojärjestelmäoperaatiolla samalla levyllä. Kirjoittaja jättää tarkoituksella pois MOVEFILE_COPY_ALLOWED-lipun, koska levyjen välinen siirto rappeutuu kopioi ja poista -sekvenssiksi, joka on täsmälleen se ei-atominen jono, jonka välttämiseksi koko suunnittelu on olemassa. Koska väliaikaistiedosto on kohdehakemistossa, se on rakenteellisesti kohdelevyllä. Kirjoittaja ei myöskään koskaan poista vanhaa tiedostoa ensin; poista ja nimeä uudelleen -parilla on ikkuna, jossa polkua ei ole lainkaan olemassa, ja siihen ikkunaan osuva kaatuminen hävittää asiakirjan. MOVEFILE_WRITE_THROUGH pyytää kutsua olemaan palaamatta ennen kuin uudelleennimeäminen on tavoittanut levyn, mikä sopii yhteen datan nimenomaisen huuhtelun kanssa. POSIXissa rename(2) takaa jo, että uusi nimi korvaa minkä tahansa olemassa olevan tiedoston atomisesti, ja sama hakemistosijoittelu estää sen epäonnistumisen EXDEV-virheellä. Siivous on symmetrinen. Väliaikainen nimi poistetaan finally-lohkossa jokaisella polulla, mikä onnistuessa on no-op, koska uudelleennimeäminen on jo kuluttanut sen, ja epäonnistuessa poistaa osittaisen tiedoston, jottei hakemistoon kerry .tmp-jäänteitä. Tests\QDFFileRegression.inc-tiedoston regressio tarkistaa täsmälleen tämän: jokaisen injektoidun vian jälkeen kohteen tavut vastaavat alkuperäistä, lähteen tavut vastaavat alkuperäistä ja hakemistossa ei ole mitään muuta kuin ne kaksi fixturea

Miksi väliaikaistiedosto löysentää käyttöoikeuksia Windowsissa?

Nil-turvakuvauksella luotu tiedosto perii DACL:n ylähakemistosta eikä siitä tiedostosta, jonka se on korvaamassa. Se on oikea oletus aivan uudelle asiakirjalle ja väärä paikan päällä tehtävälle korjaukselle. Oletetaan, että ylläpitäjä on lukinnut contract.pdf-tiedoston yhdelle tilille suojatulla, ei-perityllä DACL:llä. Sen viereen syntyvä väliaikaistiedosto perii hakemiston laajemmat käyttöoikeudet, ja kun se nimetään contract.pdf-tiedoston päälle, uudelleennimetty tiedosto kantaa laajaa DACL:ää, koska NTFS:n tietoturva kulkee tiedosto-objektin mukana eikä nimen mukana. Korjaus onnistuu, tavut ovat oikein, ja ylläpitäjän konfiguroima käyttöoikeushallinta on hiljaa poissa. Mikään paluuarvossa ei vihjaa siihen

PDF Library for Delphi lukee siksi kohteen DACL:n ennen väliaikaistiedoston luomista ja välittää sen CreateFileW-kutsun lpSecurityAttributes-argumenttina, joten uusi tiedosto syntyy vanhan tiedoston käyttöoikeuksilla eikä uudelleennimeäminen muuta mitään sellaista, minkä ylläpitäjä huomaisi. Luku käyttää GetFileSecurityW-funktiota DACL_SECURITY_INFORMATION-lipulla ja mitoittaa puskurin ensimmäisen kutsun ERROR_INSUFFICIENT_BUFFER-tuloksen mukaan. Kolme ehtoa saavat kirjoittajan epäonnistumaan suljetusti sen sijaan että se arvailisi. Jos DACL:ää ei voi lukea, julkaisu pysähtyy EWriteError-virheeseen, jonka julkinen rajapinta kuvaa arvoksi 305. Jos kuvaus palaa ilman asetettua SE_DACL_PRESENT-bittiä, julkaisu pysähtyy myös, koska sellaisen kuvauksen antaminen CreateFileW-kutsulle päästäisi ytimen turvautumaan prosessin oletus-DACL:ään ja muuttaisi käyttöoikeussemantiikkaa kenenkään sitä pyytämättä. Ja jos kohteessa on FILE_ATTRIBUTE_ENCRYPTED-attribuutti, kirjoittaja kieltäytyy suoraan: väliaikaistiedosto olisi selväkielinen, ja selväkielisen tiedoston nimeäminen EFS-suojatun päälle julkaisee salaamattoman korvaajan jollekin, jonka käyttäjä valitsi salattavaksi tiedostojärjestelmätasolla. EFS ei liity PDF:n standardeihin turvakäsittelijöihin, joita käsitellään salatun asiakirjan latausta käsittelevässä artikkelissa, mutta vikatila on samaa hiljaista heikennystä

Miksi QDF-julkaisukirjoittaja kopioi kohteen DACL:n ennen väliaikaistiedoston luomista: nil-kuvaus perisi hakemiston laajemmat käyttöoikeudet ja uudelleennimeäminen leventäisi käyttöoikeuksia hiljaa, joten GetFileSecurityW lukee DACL:n, puuttuva SE_DACL_PRESENT-bitti tai EFS-attribuutti pysäyttää julkaisun arvolla 305, ja CreateFileW syntyy vanhoilla käyttöoikeuksilla
NTFS:n tietoturva kulkee tiedosto-objektin mukana eikä nimen mukana: luetun kuvauksen välittäminen lpSecurityAttributes-argumenttina saa uudelleennimeämisen olemaan muuttamatta mitään ylläpitäjän konfiguroimaa, ja jokainen portti epäonnistuu suljetusti arvailematta
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');
  // mitoita kuvaus ja lue siitä sitten vain DACL-osuus
  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;   // välitetään CreateFileW:lle / CREATE_NEW
end;

Yksi regressiotestin yksityiskohta kannattaa pitää mielessä, jos kirjoitat vastaavan testin itse. Rajoitetun fixturen rakentamiseksi testi soveltaa vain omistajalle tarkoitettua DACL:ää ja sen on asetettava SE_DACL_PROTECTED kuvauksen ohjauskenttään nimenomaisesti; pelkkä suojatun lipun välittäminen SetFileSecurityW-kutsun SecurityInformation-argumentissa ei muuta suojaamatonta kuvausta suojatuksi. Jälkeinen väite on, että julkaistu tiedosto ilmoittaa edelleen suojatun bitin ja nimenomaisen, ei-null DACL:n, sekä erilliselle tulostepolulle että korjaukselle lähdetiedoston itsensä päälle

Mikä LastErrorCode kertoo, mikä epäonnistui?

RepairQDFFile palauttaa onnistuessa 1 ja epäonnistuessa 0, ja LastErrorCode kertoo, mikä vaihe kieltäytyi. Lähde, jota ei voi lukea, mukaan lukien sellainen, jota toinen prosessi pitää yksinomaisessa lukossa, ilmoittaa 401; luku on nyt kääritty niin, että syötteen aikana tullut poikkeus kuvautuu arvoksi 401 eikä vuoda kirjoitusvirheeksi. Virheellinen tai monitulkintainen QDF-rakenne, kuten samalle objektille kahdesti esiintyvä stream-merkki, ilmoittaa PDFLIB_ERROR_QDF_REPAIR-virheen, joka on 107, eikä kohteeseen ole koskettu, koska kirjoittajaa ei koskaan rakennettu. Kaikki korjauksen jälkeinen, väliaikaistiedoston luonnista huuhtelun ja uudelleennimeämisen kautta, ilmoittaa PDFLIB_ERROR_QDF_WRITE-virheen, joka on 305. Regressio käy läpi realistiset tapaukset: kohteen, jonka toinen kahva on avannut ilman poisto-jakoa, kirjoitussuojatun kohteen, puuttuvan kohdehakemiston sekä jokaisen kolmesta kirjoittajavaiheesta injektion kautta epäonnistuvana. Kaikissa niissä paluuarvo on 0, koodi on 305, eikä uutta tai osittaista kohdetta ole sen jälkeen olemassa. Yleinen tapa lukea koodi eikä pelkkää paluuarvoa on sama, jota kuvataan kirjaston hiljaisten vikojen diagnosointia käsittelevässä artikkelissa

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Paikan päällä tehtävä korjaus: sama polku on syöte ja tuloste
    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;

Mihin takeet päättyvät

Kirjoittaja lupaa yhdenmukaisuuden niitä vikoja vastaan, jotka prosessi pystyy näkemään, ja on rehellinen niistä, joita se ei näe. Jos prosessi tapetaan väliaikaistiedoston luonnin ja uudelleennimeämisen välissä, finally-lohko ei koskaan aja ja hakemistoon jää .pdflib-qdf-<GUID>.tmp-tiedosto; kohde on silti ehjä, mikä on se ominaisuus jolla on väliä, mutta roskat ovat sinun siivottaviasi. Sähkökatko on lupauksen ulkopuolella samoin: data huuhdellaan ja uudelleennimeäminen on write-through, mikä on parasta mitä käyttäjätilan kirjasto voi pyytää, mutta kirjoittaja ei fsync-kutsua hakemistomerkintää eikä esitä kestävyysväitteitä sen päälle, mitä tiedostojärjestelmä tarjoaa. Toista kirjoittajaa, joka muokkaa kohdetta samanaikaisesti, ei havaita, koska DACL ja attribuutit luetaan ennen väliaikaistiedoston luomista eikä mikään tarkista niitä uudelleen uudelleennimeämishetkellä. Ja onnistunut uudelleennimeäminen luo uuden tiedostoidentiteetin, joten vaihtoehtoiset datavirrat ja tavalliset attribuutit kuten vanhan tiedoston arkisto- tai piilotettu-bitti eivät säily; vain DACL siirtyy tarkoituksella mukana

Kapeampi raja on se, mikä rajapinta ylipäätään käyttää tätä polkua. Vain RepairQDFFile kulkee TPDFQDFFileWriter-luokan kautta. SaveQDFToFile ja ConvertFileToQDF avaavat tulosteensa edelleen PLCreateFileStream(FileName, fmCreate) -kutsulla ja kirjoittavat QDF-muunnoksen suoraan siihen, samalla tavalla kuin streamiin lisääviä päivityksiä käsittelevässä artikkelissa kuvattu inkrementaalinen polku kirjoittaa mihin tahansa streamiin, jonka sille antaa. Nuo kaksi kutsua tuottavat uuden virheenjäljitysartefaktin asiakirjasta, joka on jo ladattu ja validoitu, joten jäsennysvirheen reikä ei koskaan koskenut niihin, mutta ne eivät peri myöskään uudelleennimeämiseen perustuvaa julkaisua. Älä lue tätä artikkelia niin, että ”jokainen QDF-vienti on atominen”. Kyse on yhdestä uloskäynnistä, siitä jonka syöte on epäluotettava, käsin muokattu tiedosto ja jonka tuloste on rutiininomaisesti sama polku, ja juuri tuo yhdistelmä ansaitsi sille ylimääräisen koneiston. Kaiken tämän todistava vikainjektio on halpaa, koska kirjoittajan kolme vaihetta, WriteData, Flush ja Publish, ovat virtual. Testin aliluokka ylikirjoittaa yhden niistä nostamaan virheen sen jälkeen kun varsinainen työ on alkanut, kutsuu Save-metodia korjatulle streamille ja varmistaa, että poikkeus etenee, että lähteen ja kohteen tavut ovat muuttumattomia eivätkä mitkään väliaikaistiedostot jää jäljelle. Mitään globaalia tiedostorajapintaa ei koukuteta, mihinkään oikeaan käyttäjän tiedostoon ei kosketa, ja kolme vaihetta vastaavat yksi yhteen niitä kolmea tapaa, joilla julkaisu voi tuotannossa epäonnistua: levy täyttyy, huuhtelu hylätään tai uudelleennimeäminen evätään, koska joku muu pitää kohdetta hallussaan

RepairQDFFile-rajapinta, sen atominen julkaisukirjoittaja ja muu QDF-virheenjäljitystyönkulku ovat osa PDF Library for Delphiä, yhdessä ristiviitteiden palautuksen, inkrementaalisten päivitysten ja salausominaisuuksien kanssa, joita käsitellään muualla tässä blogissa