Delphi arba FPC funkcija, grąžinanti įrašą, negauna naujo, nuliais užpildyto Result per kiekvieną iškvietimą. Tas paslėptas Result kintamasis tik vieną kartą pradžioje būna lygus nuliui, o tarp iškvietimų jis automatiškai iš naujo nenunulinamas, todėl tuo turi pasirūpinti pati funkcija. Jei tai daroma naudojant FillChar(Result, SizeOf(Result), 0), nuo antrojo iškvietimo procedūra perrašo gyvą eilutės arba dinaminio masyvo nuorodą jos neatlaisvinusi, todėl prarandamas bet kuris atminties kaupo blokas, į kurį ta nuoroda rodė
Situacija, kurioje ši klaida pasireiškia, yra kasdieniška. Paketinis procesas atveria trečiųjų šalių PDF rinkinius ir pereina per kiekvieno puslapio anotacijas, įtraukdamas komentarų tekstą į audito žurnalą. Šiame cikle niekas neatrodo pavojinga: kiekvienas iškvietimas yra paprasta funkcija, grąžinanti paprastą įrašą, nematyti jokių rodyklių ir nieko, kas primintų rankinį atminties valdymą. Nuorodų skaičiavimas įraše yra įprasta Object Pascal apskaitos taisyklė, o ne vienai bibliotekai būdinga išimtis, todėl bet kuri Delphi arba FPC kodų bazė, kurioje FillChar derinamas su įrašų tipais, turinčiais eilučių ar dinaminių masyvų, susiduria su tuo pačiu defektu
Kodėl FillChar įrašo rezultate sukelia eilučių nuotėkį?
FillChar sukelia eilučių nuotėkį, nes nežino, kokio tipo duomenis perrašo. FillChar(X, Count, Value) veikia su bet kuriuo kintamuoju: ji paima netipizuotą Count baitų bloką ir kiekvieną jo baitą užpildo reikšme Value — tuo apsiriboja visa sutartis. Būtent dėl to FillChar yra greita ir universali, nes ji netikrina X tipo ir neatsižvelgia į tai, ką reiškia apatinio lygio baitai. Įraše esantis UnicodeString arba WideString laukas nėra patys simboliai — tai rodyklė į atminties kaupo bloką, prieš simbolių duomenis turintį nuorodų skaitiklį. FillChar mato kelis baitus, kuriuose atsitiktinai laikoma rodyklės reikšmė, ir perrašo juos nuliais lygiai taip pat, kaip perrašytų Integer arba Double lauką. Rodyklė dingsta, nuorodų skaitiklis, kurį prieš tai reikėjo sumažinti, nepakeičiamas, o blokas, į kurį ji rodė, lieka paskirtas ir nebeturi nė vienos jį nurodančios nuorodos
Kaip kompiliatorius seka eilutes ir dinaminius masyvus įraše
Object Pascal tipą vadina valdomu, kai kompiliatorius turi paleisti papildomą kodą, kad jis išliktų taisyklingas priskyrimo ir srities pabaigos metu. Ilgų eilučių tipai, tokie kaip AnsiString, UnicodeString ir WideString, yra valdomi, taip pat dinaminiai masyvai, sąsajos ir Variant reikšmės, be to, bet kuris įrašas ar fiksuoto dydžio masyvas, kurio laukas yra vienas iš šių tipų. Kiekvienam valdomam laukui kompiliatorius tyliai sugeneruoja apskaitą, kurią rankiniu būdu būtų nuobodu ir lengva atlikti neteisingai: priskiriant padidinti nuorodų skaitiklį, perrašant turintį kintamąjį arba išeinant iš srities jį sumažinti, o skaičiui pasiekus nulį atlaisvinti apatinio lygio bloką. Dėl šio mechanizmo įprastame Pascal kode nereikia rankiniu būdu paskirti ar atlaisvinti string, o vieno dinaminio masyvo priskyrimas kitam yra pigi ir saugi operacija, o ne rankinio kopijavimo ciklas. System.Default ir Finalize yra du dokumentuoti būdai prireikus iškviesti tą pačią atlaisvinimo logiką, todėl juos įrašo išvalymo kodas turi naudoti vietoje neapdoroto atminties užpildymo
type
TLineItem = record
Description: string; // managed: reference-counted
Quantity: Integer; // unmanaged: plain ordinal
end;
function GetLineItem(Index: Integer): TLineItem;
begin
FillChar(Result, SizeOf(Result), 0); // clears bytes, not the reference
Result.Quantity := Source[Index].Qty;
Result.Description := Source[Index].Text;
end;
var
Item: TLineItem;
I: Integer;
begin
for I := 0 to High(Source) do
begin
Item := GetLineItem(I); // second pass onward: leaks the prior Description
Log.Add(Item.Description);
end;
end;
Kodėl nuotėkis prasideda tik nuo antrojo iškvietimo?
Pirmasis iškvietimas cikle visada yra nepavojingas, todėl šį defektą lengva praleisti testuojant. Vietinis valdomo įrašo tipo kintamasis pradžioje būna lygus nuliui, o tarp vieno ciklo praėjimo ir kito automatiškai iš naujo nenunulinamas, todėl pirmą kartą ciklui priskyrus funkcijos grąžinamą reikšmę, jo Description arba ContentsText laukas vis dar yra nil. FillChar perrašo nil nuliu, o tai nuorodų skaičiavimo požiūriu nieko nepakeičia, ir iškvietimo rezultatas atrodo visiškai teisingas. Antrasis iškvietimas kitoks: tas pats vietinis kintamasis jau turi tai, ką į jį įrašė pirmasis iškvietimas, o naujo iškvietimo Result įrašomas tiesiai į tą pačią saugyklą, o ne į naują tuščią atmintį. FillChar antrojo iškvietimo pradžioje nunulina lauką, kuris nebėra nil, ir nuo tada viskas, kas priklauso nuo šio baitų rašto, tyliai tampa neteisinga. Testas, kuris funkciją iškviečia vieną kartą ir patikrina rezultatą, problemos niekada nepamatys; ją atskleidžia tik ciklas arba bet kuris kitas kelias, kelis kartus iškviečiantis funkciją tam pačiam paskirties kintamajam
Tikras nuotėkis: anotacijų, žymių ir nuorodų įrašai
PDFiumPas iki 1.56.4 versijos turėjo būtent šį defektą trijose funkcijose, kurių kiekviena grąžina įrašą su bent vienu valdomu lauku: puslapio lygio anotacijų skaitytuvas grąžina TPdfAnnotation, kuriame yra ContentsText ir AuthorText eilutės, žymių skaitytuvas grąžina TBookmark su Title eilute, o nuorodos anotacijos skaitytuvas grąžina TLinkAnnotation su ActionPath eilute ir dinaminiu masyvu Points. Visos trys funkcijos prasidėdavo taip, kaip parodyta toliau: neapdorotu Result išvalydavo naudodamos FillChar, tada po vieną užpildydavo laukus iš apatinio puslapio duomenų. Pereinant per kiekvieno puslapio anotacijas po vieną, kaip įprastai kuriant audito sąrašą ar peržiūros skydelį, anotacijų skaitytuvas cikle kiekvienu praėjimu po pirmojo nutekindavo ankstesnės anotacijos tekstą; PDF faile, kuriame būtų neįprastai daug anotacijų su tekstu, ilgai veikiantis procesas galėtų auginti atminties naudojimą visą savo veikimo laiką. Taisymas pakeitė po vieną eilutę kiekvienoje funkcijoje: FillChar(Result, SizeOf(Result), 0) pakeitus į Result := Default(TPdfAnnotation) pakako, nes priskyrus Default valdomam įrašui kompiliatorius vykdo įprastą atlaisvinimo ir išvalymo seką, o ne neapdorotą atminties užpildymą
function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
Annotation: FPDF_ANNOTATION;
ContentLength: LongWord;
begin
Annotation := FPDFPage_GetAnnot(Page, Index);
FillChar(Result, SizeOf(Result), 0); // clears bytes, not a live reference
Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
ContentLength := FPDFAnnot_GetStringValue(Annotation,
FPDFANNOT_TEXTTYPE_Contents, nil, 0);
if ContentLength >= 4 then
begin
SetLength(Result.ContentsText, ContentLength div 2 - 1);
FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
Pointer(Result.ContentsText), ContentLength);
end;
end;
Tas pats pavojus naudojant var parametrą
Žymių skaitytuvas parodo subtilesnę tos pačios problemos versiją, nes FillChar išvalomas įrašas yra ne pačios funkcijos Result, o vienu iškvietimu žemiau perduodamas var parametras. SetBookmarkData gauna išvestį kaip var Data: TBookmark ir anksčiau savo kūno pradžioje išvalydavo Data naudodamas FillChar; viešoji funkcija GetBookmark, iš tikrųjų grąžinanti TBookmark, iškviečia SetBookmarkData ir perduoda jai savo Result kaip šį var argumentą. var parametras perduodamas pagal nuorodą, todėl Data funkcijos SetBookmarkData viduje ir Result funkcijos GetBookmark viduje yra ta pati saugykla dviem vardais, o bet kokia aliasavimo rizika, taikoma pačios funkcijos Result, lygiai taip pat taikoma pagal nuorodą jį gaunančiai pagalbinei procedūrai. Peržiūrint tik tas funkcijas, kurios tiesiogiai deklaruoja įrašo grąžinimo tipą, ši forma praleidžiama; paieška turi sekti kiekvieną var ir out parametrą, į kurį perduodamas Result
procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
BufferSize: LongWord;
begin
Data := Default(TBookmark); // fixed: was FillChar(Data, SizeOf(Data), 0)
Data.Handle := Bookmark;
if Bookmark <> nil then
begin
BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
if BufferSize >= 4 then
begin
SetLength(Data.Title, BufferSize div 2 - 1);
FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
end;
end;
end;
function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
CheckActive;
SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;
Kada FillChar vis dar yra tinkamas pasirinkimas?
FillChar vis dar yra tinkama ir dažnai šiek tiek pigesnė priemonė įrašui, sudarytam vien iš sveikųjų arba slankiojo kablelio laukų, fiksuoto dydžio tokių laukų masyvų ar kitų paprastų įrašų, nes jame nėra nieko, ką kompiliatorius turėtų užbaigti. Pačios PDFiumPas stačiakampio tipas yra būtent toks atvejis: TPdfRectangle turi keturis Double laukus ir nieko daugiau, todėl jį išvalius FillChar niekas neatlaisvinama, nes nėra nieko, kam būtų taikomas nuorodų skaičiavimas. Klausimą, skiriantį šiuos du atvejus, galima suformuluoti paprastai: ar kuriame nors įrašo lauke, bet kuriame įdėjimo gylyje, yra tipas string, AnsiString, WideString, dinaminis masyvas, sąsaja arba Variant? Įrašas iš pirmo žvilgsnio gali atrodyti visiškai skaitinis, tačiau šio testo neatitikti, jei vienas jo laukų yra kitas įrašas, keliuose lygiuose slepiantis eilutę, todėl reikia pereiti per įdėtus įrašus iki galo, o ne sustoti ties išoriniu laukų sąrašu. Esamos kodų bazės auditas dėl šio šablono yra mechaninis, o ne išsamus: reikia rasti kiekvieną FillChar iškvietimą, kurio paskirtis yra įrašo kintamasis, ir patikrinti to įrašo laukų sąrašą pagal pirmiau pateiktą valdomų tipų sąrašą. Pačios PDFiumPas v1.56.4 auditas atliko būtent tokią paiešką visoje bibliotekoje ir rado šį pažeidžiamumą viename modulyje; visi kiti FillChar iškvietimai jau valė paprastus skaitinius įrašus, kuriems FillChar buvo ir tebėra tinkamas įrankis
Tas pats kompiliatoriaus elgesys, dėl kurio pakartotinai naudojamas Result čia tampa pavojingas, kitose šios kodų bazės vietose taip pat lemia susijusią Delphi ir FPC nesutarimų grupę; papildomame straipsnyje apie kelių kompiliatorių pinkles aprašomas atvejis, kai FPC ir Delphi nesutaria, kada tiksliai užbaigiamas įrašo rezultato laikinasis objektas vienoje išraiškoje — tai kitas to paties pagrindinio fakto požymis, kad funkcijos įrašo Result ne visada yra tokia nauja ir privati saugykla, kokia atrodo. Šiame straipsnyje naudojamas anotacijų ciklas taip pat nėra hipotetinis: tai tas pats puslapis po puslapio einantis ciklas, kurį parašytumėte kurdami anotacijų peržiūros skydelį, ir būtent tokia kodo forma vienos eilutės FillChar pavertė lėtu atminties nuotėkiu
Nereikia keisti bibliotekų ar ieškoti klaidos svetimame sukompiliuotame kode: tai pačios Object Pascal kalbos savybė, su kuria kasdien dirba kiekvienas Delphi ir FPC kūrėjas, o ją žinant taisymas yra vienas funkcijos iškvietimas. Čia aprašytos anotacijų, žymių ir nuorodų anotacijų API yra PDFium Component, skirto Delphi, C++Builder ir Lazarus/FPC, dalis kartu su kitomis PDF skaitymo, atvaizdavimo ir anotacijų galimybėmis, aprašytomis kituose šio tinklaraščio straipsniuose