Portare su Free Pascal il codice CCITT, TIFF, PNG, Flate e stream buffer è un modo affidabile per trovare Pascal che funziona su Delphi solo per caso. Nessuno dei cinque pattern qui sotto implica un bug del compilatore. Ognuno è codice che Delphi esegue correttamente grazie a un dettaglio implementativo: un parametro result nascosto che aliasa l'array del chiamante, un ramo fuori intervallo oltre il quale nessuno ha mai letto, un buffer di lunghezza zero la cui unica protezione è uno switch di range checking, un offset 1-based che un solo percorso di codice passa mai come 1, e un contratto di TStream.Read che gli stream in memoria non esercitano mai. Cambiate compilatore, o date allo stesso codice un file malformato, e il caso smette di reggere
Quello che segue è la forma specifica di ogni trappola, il modo in cui PDF Library for Delphi la gestisce, e la disciplina che sta dietro: lo stesso sorgente deve produrre la stessa semantica di documento su entrambi i compilatori, e l'unico modo per saperlo è verificare su entrambi. L'articolo gemello sul rafforzare un parser PDF Pascal contro file malevoli copre larghezza degli interi, profondità di ricorsione e buffer non inizializzati. Questo riguarda una classe di guasto diversa: codice sbagliato fin dall'inizio mentre un compilatore gli copre le spalle in silenzio
Perché una funzione che ritorna un array dinamico funziona senza SetLength in Delphi?
Perché Delphi passa la variabile del chiamante come parametro result nascosto, quindi una funzione che non alloca mai il suo risultato può comunque scrivere in un array che ha allocato il chiamante. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray è la ricerca sulla linea di riferimento al cuore della decodifica bidimensionale Group 3 e Group 4: data la posizione corrente a0 e il colore del run corrente, cerca gli elementi di cambiamento della scanline precedente, i b1 e b2 dello schema di codifica bidimensionale ITU-T T.4 e T.6, e li restituisce come array di due slot. L'implementazione allettante scrive Result[0] e Result[1] e non chiama mai SetLength su Result
Dovrebbe andare in fault alla prima scrittura, e su Free Pascal lo fa. Su Delphi no, finché i call site hanno questo aspetto: dichiarate b: TCCITTIntegerArray, eseguite SetLength(b, 2) una volta prima del ciclo sulle scanline, poi dentro il ciclo assegnate b := GetNextChangingElement(a0, IsWhite) e leggete b[0] e b[1]. La guida al linguaggio Delphi afferma che una funzione il cui risultato è una long string, un array dinamico o un altro tipo managed riceve quel risultato come parametro var aggiuntivo, e in pratica il compilatore passa l'indirizzo della destinazione dell'assegnazione. Quindi Result dentro la funzione è b stesso, già lungo due elementi, e ogni scrittura finisce in memoria di proprietà del chiamante. Free Pascal passa alla funzione un array nil nuovo e lo assegna a b dopo, che è la lettura del contratto contro cui il codice avrebbe dovuto essere scritto fin dall'inizio
L'aliasing porta con sé anche una semantica da cui il decoder dipende. Result[0] viene assegnato solo quando la scansione trova un elemento maggiore di a0, e Result[1] solo quando c'è un elemento dopo di esso, quindi in caso di miss gli slot conservano quello che l'iterazione precedente aveva lasciato in b. La correzione ovvia, allocare due slot e azzerarli a ogni chiamata, distruggerebbe quel carry-over e cambierebbe l'output decodificato su Delphi. PDF Library for Delphi usa una guardia invece di un reset: su Delphi è codice morto e il percorso di decodifica resta byte per byte lo stesso, e su Free Pascal trasforma un fault nel comportamento voluto. Quell'asimmetria è tutto il punto, visto che la guardia deve essere un no-op sul compilatore dove il codice produce già output verificato
Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
IsWhite: Boolean): TCCITTIntegerArray;
Begin
// Delphi arriva qui con l'array di due elementi del chiamante aliasato
// come Result, quindi qui è un no-op. FPC arriva con nil.
If (Length(Result) < 2) Then
SetLength(Result, 2);
...
// Result[0] / Result[1] vengono ancora scritti solo su un hit, quindi un miss
// conserva i valori dell'iterazione precedente esattamente come prima
End;
Un contatore che sopravvive ai suoi dati: la directory entry TIFF
Perché quando invalida un array dovete invalidarne il contatore nella stessa istruzione, altrimenti il contatore verrà creduto da codice che l'array non lo vede mai. Una image file directory entry TIFF (TIFF 6.0 §2, la struttura da 12 byte con tag, type, count e value-or-offset) porta con sé un count a 32 bit letto direttamente dal file, e PDF Library for Delphi legge ognuna tramite PopDE: TTIFFEntry, un record con Tag, TagType, Length, Offset e gli array decodificati IntegerValues e DoubleValues. Il range check ingenuo testa se Offset + TypeSize * Length va oltre la fine del file e, in quel caso, azzera entrambi gli array lasciando Result.Length al valore letto dal file
Da lì vanno storte due cose. La funzione termina con un fallback che dice «se Length è zero, dai alla entry un elemento a valore zero», così i chiamanti possono sempre leggere l'elemento zero. Con Length mai azzerato sul percorso fuori intervallo, quel fallback non scatta mai per l'unico caso per cui esiste. E i chiamanti l'elemento zero lo leggono, incondizionatamente: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip e una dozzina d'altri prendono E.IntegerValues[0], e le tabelle degli strip fanno Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), copiando Length volte quattro byte da un array che non ne ha nessuno. Un array azzerato con un contatore ancora vivo è decisamente più pericoloso di uno non controllato, perché quello non controllato almeno contiene i byte che dichiara
La seconda trappola è l'ordine. Eseguite le due chiamate a SetLength prima del test di intervallo, dimensionate sul count del file, e una entry ostile può chiedere un'allocazione da diversi gigabyte prima di un singolo controllo di validità. Su Delphi l'eccezione risultante viene tipicamente raccolta da un handler più in alto nel percorso di caricamento dell'immagine e il file semplicemente non si carica, ed è per questo che il problema è facile da mancare; quello che succede davvero è un evento di out-of-memory scelto dal file. L'ordine corretto alloca dopo il test e fa viaggiare il count insieme ai dati
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
> Length(Source);
If OutOfRange Then
Begin
Result.Length := 0; // il count va insieme ai valori
SetLength(Result.IntegerValues, 0);
SetLength(Result.DoubleValues, 0);
End
Else
Begin
SetLength(Result.IntegerValues, Result.Length); // solo ora
SetLength(Result.DoubleValues, Result.Length);
End;
// ... più avanti, il fallback arriva al caso per cui esiste:
If (Result.Length = 0) Then
Begin
SetLength(Result.IntegerValues, 1);
Result.IntegerValues[0] := 0;
End;
Nulla in questo caso è specifico del compilatore, ed è proprio questo che lo fa stare in questa lista. La trappola resta latente su Delphi per la stessa ragione per cui resta latente su Free Pascal: serve una directory entry che punti oltre la fine del file, e i file di test ordinari non ne hanno una. Un porting non la espone. La espone leggere il codice con la domanda «cosa fa Delphi per me qui che non sto già facendo da solo»
Cosa succede quando un IHDR PNG dichiara un color type che il formato non definisce?
PDF Library for Delphi rifiuta l'immagine prima che girino i row filter. ISO 15948 §11.2.2 definisce il chunk IHDR e la Table 11.1 elenca le sei combinazioni legali di color type e bit depth: grayscale a 1, 2, 4, 8 o 16 bit, indexed color a 1, 2, 4 o 8, e truecolor, grayscale con alpha e truecolor con alpha a 8 o 16. La trappola è un lettore che valida i campi compression method e filter method di IHDR e lascia passare FColorType e il bit depth intatti: un color type illegale produce allora una scanline da zero byte, e i cicli di unfilter ricevono un buffer vuoto
Il codice dei row filter tipicamente dimensiona tutto a partire da un Case FColorType Of che mappa ogni color type su un numero di componenti. Un color type fuori dai sei finisce nel ramo Else, dove SourceComponents è 0, quindi ScanlineByteCount è 0, quindi a SetLength(PreviousScanline, 0) segue immediatamente FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indicizzare l'elemento zero di un array dinamico vuoto è un indirizzo calcolato da nil. Con il range checking disattivato, un fill da zero byte attraverso quell'indirizzo è un no-op silenzioso e il decoder prosegue su righe che non esistono; con il range checking attivo è un ERangeError alla prima immagine; e le chiamate a Move che seguono sono a un passo da una access violation. Quella che vi capita dipende dal compilatore e dagli switch di build piuttosto che da qualcosa che il decoder abbia deciso, e questo è il segnale che il decoder non ha mai deciso nulla
PDF Library for Delphi applica la tabella della specifica dove vengono controllati gli altri campi di IHDR: COLOR_GRAYSCALE accetta FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE accetta [1, 2, 4, 8], e COLOR_RGB, COLOR_GRAYSCALEALPHA e COLOR_RGBALPHA accettano [8, 16]; qualsiasi altro valore azzera ValidImage e l'immagine viene rifiutata con larghezza e altezza intatte per la diagnostica. Un chunk pHYs più corto dei suoi nove byte viene controllato nello stesso punto, così il lettore DPI non indicizza mai S[1] fino a S[8] di una stringa che il chunk corto ha lasciato vuota
Un offset 1-based trattato come puntatore 0-based
InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString prende uno StartPos 1-based, perché il suo input è un AnsiString e l'implementazione Delphi indirizza l'input zlib come @Input[StartPos]. L'implementazione Free Pascal, scritta contro paszlib perché entrambi i target Windows linkino la compressione staticamente, deve indirizzare lo stesso input attraverso aritmetica sui puntatori, e l'aritmetica sui puntatori è 0-based. Impostate next_in a PAnsiChar(Input) + StartPos e avail_in a Length(Input) - StartPos, passate 1, che è ciò che «parti dall'inizio» significa per questa funzione, e la build FPC inizia a sgonfiare dal secondo byte e si ferma un byte prima della fine
Questa si nasconde facilmente perché il chiamante che la maggior parte dei test raggiunge è InflateStr, che passa 0. Zero per l'appunto è l'offset 0-based corretto, quindi le due build concordano su ogni chiamata semplice a InflateStr e su ogni test che ci passa attraverso. TPDFDocument.DecodeAllStreams, la routine che SaveQDFToFile e ConvertFileToQDF usano per espandere gli stream con solo FlateDecode in forma leggibile, passa 1. Con l'off-by-one in atto, l'header zlib saltato fa fallire l'inflate, ma lo stream zlib riporta comunque un Consumed diverso da zero per i byte che ha esaminato, quindi un chiamante che si fida di Consumed prende il payload vuoto per una decodifica riuscita e sostituisce ogni content stream con una stringa vuota. Il QDF risultante ha il numero di pagine giusto, struttura valida e nessun contenuto di pagina, cioè un file che si apre senza errori in ogni viewer e non mostra niente
// Ramo FPC di InflateStrFromPosition.
// StartPos è 1-based come nel ramo Delphi; la si limita, poi la si converte
// in un offset di puntatore 0-based esattamente una volta, al confine.
If (StartPos < 1) Then
StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
Exit;
...
strm.next_in := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;
Il controllo che la becca è il più piccolo possibile: comprimete un payload, espandetelo dalla posizione 0 e dalla posizione 1, e verificate che entrambi restituiscano lo stesso payload e riportino entrambi Consumed uguale alla lunghezza intera dello stream. Uno stream RFC 1950 ha un header di due byte e un trailer Adler-32 di quattro, quindi un off-by-one su una delle due estremità non è una corruzione sottile, è uno stream che o non parte o non finisce. La lezione riguarda il confine, non zlib: quando il parametro di una funzione è definito in una base di indici e l'implementazione sotto usa l'altra, la conversione va in una sola riga, e un test deve chiamarla con il valore che distingue le due basi
Perché un TStream.Read corto non è la fine dello stream?
Perché TStream.Read può restituire meno byte di quelli richiesti per qualunque motivo gli piaccia, e solo un ritorno di 0 significa che non c'è più niente. TMemoryStream e TFileStream su disco locale riempiono quasi sempre la richiesta, ed è per questo che il codice che tratta «ha restituito meno di quanto avevo chiesto» come end-of-file passa ogni test che li usa. Stream su rete, stream di decompressione e qualunque discendente di TStream scritto da un cliente possono restituire due byte quando ne chiedi sessantaquattromila e avere comunque gigabyte dietro
TPLBuffer è il lettore attraverso cui passa ogni parser di PDF Library for Delphi, e può incapsulare un AnsiString, un puntatore, un array di byte o un TStream. Le sue quattro query di scansione, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte e DistanceToOtherBytes, tutte con ritorno Int64, leggono la sorgente in blocchi da 64 KB cercando un delimitatore e riportano quanto è lontano senza muovere la posizione logica. Il modo naturale di terminare un simile ciclo è Until ReadCount < BlockSize. Per le tre sorgenti in memoria è corretto, dato che ReadIntoBuffer consegna sempre il blocco intero fino all'ultimo. Per una sorgente stream significa che la scansione si arrende al primo short read, riporta il delimitatore come assente, e il tokenizer sopra di essa decide che l'oggetto finisce dove non finisce, ed è per questo che TPLBuffer cicla finché una lettura restituisce 0
// Ciclo di scansione di TPLBuffer.DistanceToByte.
// Zero è l'unico segnale di fine dati che TStream.Read definisce.
TempPosition := FPosition;
Try
Repeat
ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
For TestPos := 0 To ReadCount - 1 Do
If TempBuffer[TestPos] = Value Then
Begin
Result := TotalSkipped + TestPos;
Exit;
End;
Inc(TotalSkipped, ReadCount);
Until ReadCount = 0;
Finally
FPosition := TempPosition; // una peek non deve muovere il lettore
End;
Il modo di fissare questo comportamento è un discendente di TMemoryStream il cui override di Read limita ogni richiesta a due byte. Avvolgeteci la stringa aaaaaX, portate la posizione del buffer a 1, e tutte e quattro le query devono riportare una distanza di 4 dalla X, lasciare la posizione a 1 dopo la chiamata e riportare -1 per un byte che non c'è. Una scansione che si ferma al primo short read vede due byte, conclude che lo stream è esaurito e restituisce -1. Il finally conta quanto la condizione del ciclo: un Exit dall'interno della scansione è il normale percorso di successo, e la posizione logica va ripristinata anche su quel percorso, non solo quando il ciclo arriva in fondo
Un sorgente, due compilatori, un solo set di asserzioni
La disciplina dietro questi cinque casi è che «la build Delphi passa» è una prova su Delphi, non sul sorgente. Un controllo cross-compiler che vale la pena avere esegue una routine sotto entrambi i compilatori: costruite un documento di due pagine con contenuto compresso tramite TPDFlib, salvatelo, salvatelo di nuovo come QDF con SaveQDFToFile, riparate il QDF con RepairQDFFile, cifrate il file in chiaro con AES-128 tramite EncryptFile e una maschera di permessi da EncodePermissions, e poi ricaricate ogni artefatto e verificate le stesse cose su entrambi i compilatori: il numero di pagine è 2, il titolo sopravvive, il testo della seconda pagina si estrae intatto dai file in chiaro, riparato e cifrato, la password sbagliata viene rifiutata con un LastErrorCode diverso da zero, EncryptionStrength è 128, EncryptionAlgorithm è 2, e i singoli bit di permesso da GetUserPermissions tornano esattamente come codificati
Confrontate semantica normalizzata invece che byte. La cifratura pesca salt casuali e il writer assegna identificatori di documento, quindi non ci si aspetta che le due build emettano file identici; ci si aspetta che emettano file che significano la stessa cosa, e i controlli vanno formulati a quel livello. Il ramo QDF si guadagna il suo posto per via della trappola dell'offset: un QDF con due pagine e nessun contenuto passa un controllo sul numero di pagine e fallisce un controllo di estrazione del testo, quindi controllate il secondo. Qualunque cambiamento che sia un no-op su un compilatore e un cambio di comportamento sull'altro, il che descrive quattro dei cinque casi qui sopra, deve superare gli stessi controlli su entrambi
La metà di questo porting che riguarda il link time, far accordare gli oggetti OMF di Delphi con le aspettative COFF di Free Pascal, è una storia a sé in il linking di oggetti FPC Win32 da OMF a COFF, e l'hardening strutturale dello stesso lettore TIFF contro BigTIFF e file tiled è in le note sul decoder TIFF integrato. I decoder di questo articolo sono distribuiti nella PDF Library for Delphi per Delphi, C++Builder e Free Pascal, dove ci si aspetta che lo stesso sorgente si guadagni lo stesso risultato su ogni compilatore che ha come target, invece di riceverlo in regalo da uno solo