Artigo Técnico

Código Delphi que só funciona por acidente: bugs de port FPC

A PDF Library for Delphi encontrou cinco defeitos de decoder ao portar seu código de CCITT, TIFF, PNG, Flate e buffer de stream para o Free Pascal — e todos eles passavam na suíte de testes completa do Delphi havia anos. Nenhum era bug de compilador. Cada um era Pascal que o Delphi executava corretamente por acidente, por conta de um detalhe de implementação: um parâmetro de resultado oculto que virava alias do array do chamador, um ramo fora de faixa que ninguém nunca lia além, um buffer de comprimento zero cuja única proteção era uma diretiva de range check, um offset 1-based que só um caminho de código passava como 1, e um contrato de TStream.Read que streams em memória nunca exercitam. Troque o compilador, ou entregue o mesmo código a um arquivo malformado, e o acidente deixa de se sustentar

O que segue é a forma específica de cada um, a correção e a disciplina que saiu disso: o mesmo código-fonte agora tem de produzir a mesma semântica de documento nos dois compiladores, e um include de teste verifica que isso acontece. O artigo irmão sobre blindar um parser de PDF em Pascal contra arquivos maliciosos cobriu largura de inteiro, profundidade de recursão e buffers não inicializados. Este aqui trata de outra classe de falha: código que estava errado desde sempre e tinha um compilador cobrindo por ele em silêncio

Por que uma função que devolve array dinâmico funciona sem SetLength no Delphi?

Porque o Delphi passa a própria variável do chamador como parâmetro de resultado oculto, então uma função que nunca aloca o próprio resultado ainda consegue escrever num array que o chamador alocou. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray é a busca na linha de referência que fica no coração da decodificação bidimensional Group 3 e Group 4: dada a posição atual a0 e a cor do run atual, ela procura os changing elements da scanline anterior — os b1 e b2 do esquema de codificação bidimensional ITU-T T.4 e T.6 — e os devolve num array de dois slots. A função original escrevia Result[0] e Result[1] e nunca chamava SetLength em Result

Isso deveria dar fault na primeira escrita, e no Free Pascal dá. No Delphi nunca deu, porque os dois call sites do decoder são assim: declaram b: TCCITTIntegerArray, rodam SetLength(b, 2) uma vez antes do loop de scanline e, dentro do loop, fazem b := GetNextChangingElement(a0, IsWhite) e leem b[0] e b[1]. O guia da linguagem Delphi diz que uma função cujo resultado é string longa, array dinâmico ou outro tipo gerenciado recebe esse resultado como parâmetro var adicional e, na prática, o compilador passa o endereço do alvo da atribuição. Então Result dentro da função é o próprio b, já com dois elementos, e toda escrita cai em memória do chamador. O Free Pascal entrega à função um array nil novo e só depois o atribui a b — que é a leitura do contrato contra a qual o código deveria ter sido escrito desde o começo

Divergência na decodificação CCITT do PDFlibPas: o Delphi passa o array b do chamador como Result var oculto de GetNextChangingElement, então as escritas caem em memória do chamador e uma busca perdida mantém os valores anteriores, enquanto o Free Pascal entrega à função um array nil novo que a guarda de Length precisa dimensionar com SetLength antes da primeira escrita
O Delphi faz alias do array do chamador como parâmetro Result oculto, então escritas sem guarda continuam caindo em memória própria, enquanto o Free Pascal chega com nil e a guarda de uma linha transforma o fault no comportamento pretendido sem mexer no caminho de decodificação do Delphi

O alias também carregava uma semântica da qual o decoder depende. Result[0] só é atribuído quando a varredura encontra um elemento maior que a0, e Result[1] só quando existe um elemento depois dele, então numa busca perdida os slots mantêm o que a iteração anterior deixou em b. A correção óbvia — alocar dois slots e zerá-los a cada chamada — teria destruído esse carry-over e mudado a saída decodificada no Delphi. A correção que foi entregue é uma guarda em vez de um reset: no Delphi é código morto e o caminho de decodificação continua byte a byte o que era, e no Free Pascal transforma um fault no comportamento pretendido. Essa assimetria é o ponto inteiro, já que a correção tinha de ser no-op no compilador onde o código já produzia saída verificada

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // O Delphi chega aqui com o array de dois elementos do chamador
  // apelidado como Result, então isso é no-op lá. O FPC chega com nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] ainda são escritos só num hit, então um miss
  // mantém os valores da iteração anterior exatamente como antes
End;

Uma contagem que sobreviveu aos dados: a entrada de diretório TIFF

Quando você invalida um array, precisa invalidar a contagem dele na mesma instrução, senão essa contagem vai ser levada a sério por código que nunca vê o array. Uma entrada de image file directory do TIFF (TIFF 6.0 §2, o layout de 12 bytes com tag, tipo, contagem e valor-ou-offset) carrega uma contagem de 32 bits direto do arquivo, e a PDF Library for Delphi lê cada uma via PopDE: TTIFFEntry, um record com Tag, TagType, Length, Offset e os arrays decodificados IntegerValues e DoubleValues. O código original verificava se Offset + TypeSize * Length passava do fim do arquivo e, se passasse, zerava o comprimento dos dois arrays. Só que deixava Result.Length no valor que veio do arquivo

Duas coisas deram errado a partir daí. A função termina com um fallback que diz "se Length for zero, dê à entrada um elemento de valor zero", para que os chamadores sempre possam ler o elemento zero. Como Length nunca era limpo no caminho fora de faixa, esse fallback nunca disparava justamente no caso para o qual existia. E os chamadores leem o elemento zero sim, incondicionalmente: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip e mais uma dúzia pegam E.IntegerValues[0], e as strip tables fazem Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), copiando Length vezes quatro bytes de um array que não tem nenhum. Um array limpo com uma contagem viva é estritamente mais perigoso que um sem verificação, porque o sem verificação ao menos contém os bytes que afirma conter

O segundo problema era a ordem. As duas chamadas a SetLength rodavam antes do teste de faixa, dimensionadas pela contagem do arquivo, então uma entrada hostil conseguia pedir uma alocação de vários gigabytes antes de uma única checagem de validade. No Delphi a exceção resultante era capturada por um handler mais acima no caminho de carregamento de imagem e o arquivo simplesmente não carregava, e é por isso que ninguém percebeu; o que acontecia de fato era um evento de out-of-memory escolhido pelo arquivo. A correção move a alocação para depois do teste e faz a contagem viajar junto com os dados

Hardening da entrada de diretório TIFF no PDFlibPas: a entrada de 12 bytes carrega uma contagem fornecida pelo arquivo, a ordem quebrada alocava os arrays a partir dessa contagem antes do teste de faixa e deixava Result.Length vivo depois de limpá-los, e a ordem corrigida testa a aritmética em Int64 contra o comprimento do arquivo primeiro, de modo que a contagem é limpa junto com os arrays
Alocar antes do teste de faixa deixava uma contagem hostil pedir gigabytes e deixava uma contagem viva num array esvaziado, então a correção testa o offset primeiro e limpa Result.Length na mesma instrução dos arrays
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // a contagem vai junto com os valores
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // só agora
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... mais adiante, o fallback existente finalmente chega ao caso para o qual existia:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Nada nessa correção é específico de compilador, e é justamente isso que a coloca nesta lista. O defeito estava latente no Delphi pelo mesmo motivo que estava latente no Free Pascal: nenhum arquivo de teste tinha uma entrada de diretório apontando para além do fim do arquivo. O port não o expôs. Quem o expôs foi ler o código com a pergunta "o que o Delphi faz por mim aqui que eu não estou fazendo sozinho"

O que acontece quando um IHDR de PNG declara um color type que o formato não define?

A PDF Library for Delphi agora rejeita a imagem antes que os filtros de linha rodem; antes da v3.539.2 ela calculava uma scanline de zero bytes e entregava aos loops de unfilter um buffer vazio. A ISO 15948 §11.2.2 define o chunk IHDR e a Tabela 11.1 lista as seis combinações legais de color type e bit depth: grayscale em 1, 2, 4, 8 ou 16 bits, indexed color em 1, 2, 4 ou 8, e truecolor, grayscale com alpha e truecolor com alpha em 8 ou 16. O TPNGReader validava os campos compression method e filter method do IHDR e deixava FColorType e o bit depth passarem intocados

O código do filtro de linha dimensiona tudo a partir de um Case FColorType Of que mapeia cada color type para uma contagem de componentes. Um color type fora dos seis cai no ramo Else, onde SourceComponents é 0, logo ScanlineByteCount é 0, logo SetLength(PreviousScanline, 0) é seguido imediatamente por FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indexar o elemento zero de um array dinâmico vazio é um endereço calculado a partir de nil. Com range checking desligado, um fill de zero byte nesse endereço é um no-op silencioso e o decoder segue marchando por linhas que não existem; com range checking ligado é um ERangeError na primeira imagem; e as chamadas Move que vêm depois estão a um passo de um access violation. Qual dessas coisas você recebe depende do compilador e das diretivas de build, não de qualquer decisão do decoder — e é aí que se vê que o decoder nunca decidiu nada

A correção é a tabela da especificação, aplicada onde os outros campos do IHDR já eram checados: COLOR_GRAYSCALE aceita FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE aceita [1, 2, 4, 8], e COLOR_RGB, COLOR_GRAYSCALEALPHA e COLOR_RGBALPHA aceitam [8, 16]; qualquer outra coisa limpa ValidImage e a imagem é recusada com largura e altura intactas para fins de diagnóstico. Um chunk pHYs menor que seus nove bytes foi fechado na mesma passada, já que o leitor de DPI indexava S[1] até S[8] de uma string que o chunk curto tinha deixado vazia

Um offset 1-based tratado como ponteiro 0-based

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString recebe um StartPos 1-based, porque sua entrada é uma AnsiString e a implementação Delphi endereça a entrada do zlib como @Input[StartPos]. A implementação Free Pascal, escrita contra a paszlib para que os dois alvos Windows liguem compressão estaticamente, definia next_in como PAnsiChar(Input) + StartPos e avail_in como Length(Input) - StartPos. Isso é aritmética de ponteiro, e é 0-based. Passe 1, que é o que "começar do início" significa para essa função, e o build FPC começa a inflar no segundo byte e para um byte antes do fim

A razão de ele ter sobrevivido é que o único caller que a maioria dos testes alcança é InflateStr, que passa 0. Zero por acaso é o offset 0-based correto, então os dois builds concordavam em toda chamada simples a InflateStr e em todo teste que passava por ali. O TPDFDocument.DecodeAllStreams, a rotina que SaveQDFToFile e ConvertFileToQDF usam para expandir streams de FlateDecode único em forma legível, passa 1. No build FPC o header do zlib pulado fazia o inflate falhar, mas o stream zlib ainda reportava um Consumed diferente de zero pelos bytes que tinha examinado, então o DecodeAllStreams tomava o payload vazio como decodificação bem-sucedida e substituía todo content stream por uma string vazia. O QDF resultante tinha a contagem de páginas certa, estrutura válida e nenhum conteúdo de página — um arquivo que abre sem erro em qualquer viewer e não mostra nada

// Ramo FPC de InflateStrFromPosition, depois da v3.539.16.
// StartPos é 1-based como no ramo Delphi; faça o clamp e converta
// para um offset de ponteiro 0-based uma única vez, na fronteira.
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;

O teste de regressão que protege isso é o menor possível: comprima um payload com deflate, infle-o a partir da posição 0 e da posição 1, e verifique que os dois devolvem o mesmo payload e reportam Consumed igual ao comprimento total do stream. Um stream RFC 1950 tem um header de dois bytes e um trailer Adler-32 de quatro bytes, então um off-by-one em qualquer das pontas não é corrupção sutil — é um stream que ou não começa ou não termina. A lição é sobre a fronteira, não sobre o zlib: quando o parâmetro de uma função é definido numa base de índice e a implementação por baixo usa a outra, a conversão pertence a exatamente uma linha, e um teste precisa chamá-la com o valor que distingue as duas bases

Por que um TStream.Read curto não é o fim do stream?

Porque TStream.Read pode devolver menos bytes que o pedido por qualquer motivo que quiser, e só um retorno de 0 significa que não há mais nada. TMemoryStream e TFileStream em disco local quase sempre preenchem o pedido, e é por isso que código que trata "devolveu menos do que pedi" como fim de arquivo passa em todo teste que usa esses dois. Streams sobre rede, streams de descompressão e qualquer descendente de TStream escrito por um cliente podem devolver dois bytes quando se pedem sessenta e quatro mil e ainda ter gigabytes atrás

O TPLBuffer é o leitor pelo qual passa todo parser da PDF Library for Delphi, e ele consegue envolver uma AnsiString, um ponteiro, um array de bytes ou um TStream. Suas quatro consultas de varredura — DistanceToByte, DistanceToOtherByte, DistanceToAnyByte e DistanceToOtherBytes, todas devolvendo Int64 — leem a fonte em blocos de 64 KB procurando um delimitador e informam a que distância ele está sem mover a posição lógica. Cada loop terminava com Until ReadCount < BlockSize. Para as três fontes em memória isso está correto, já que ReadIntoBuffer sempre entrega o bloco inteiro até o último. Para a fonte de stream significa que a varredura desiste na primeira leitura curta, informa o delimitador como ausente, e o tokenizer acima dela decide que o objeto termina onde não termina

Tratamento de leitura curta no buffer de stream do PDFlibPas: DistanceToByte varre blocos de 64 KB, o loop antigo tratava Until ReadCount < BlockSize como fim de dados e desistia na primeira leitura curta, enquanto o loop corrigido roda até ReadCount ser zero, encontra o delimitador e restaura a posição num bloco finally
Um stream pode devolver dois bytes quando se pedem sessenta e quatro mil, então zero é o único sinal de fim de dados em que a varredura pode confiar, e a cláusula finally restaura a posição lógica quando o delimitador é encontrado e o loop sai antes
// TPLBuffer.DistanceToByte, o loop depois da v3.539.6.
// Zero é o único sinal de fim de dados que TStream.Read define.
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;   // um peek não pode mover o leitor
End;

O teste que fixa isso é um descendente de TMemoryStream cujo override de Read limita todo pedido a dois bytes. Envolva nele a string aaaaaX, ponha a posição do buffer em 1, e as quatro consultas precisam reportar distância 4 até o X, deixar a posição em 1 depois e devolver -1 para um byte que não está lá. Antes da correção a primeira consulta via dois bytes, concluía que o stream estava esgotado e devolvia -1. O finally importa tanto quanto a condição do loop: um Exit de dentro da varredura é o caminho normal de sucesso, e a posição lógica tem de ser restaurada nesse caminho também, não só quando o loop roda até o fim

Um código-fonte, dois compiladores, um conjunto de asserções

A disciplina que saiu desses cinco é que "o build Delphi passa" é evidência sobre o Delphi, não sobre o código-fonte. Desde a v3.539.16 a suíte DUnitX do Delphi e a suíte console do Free Pascal incluem o mesmo Tests\CrossCompilerSemantics.inc, uma única rotina, RunCrossCompilerFileSemantics, que monta um documento de duas páginas com conteúdo comprimido via TPDFlib, salva, salva de novo como QDF via SaveQDFToFile, repara o QDF com RepairQDFFile, criptografa o arquivo plano com AES-128 via EncryptFile e uma máscara de permissões de EncodePermissions, e então recarrega cada artefato e afirma as mesmas coisas nos dois compiladores: a contagem de páginas é 2, o título sobrevive, o texto da página dois é extraído intacto dos arquivos plano, reparado e criptografado, a senha errada é recusada com um LastErrorCode diferente de zero, EncryptionStrength é 128, EncryptionAlgorithm é 2, e os bits individuais de permissão de GetUserPermissions voltam exatamente como foram codificados

A comparação é deliberadamente normalizada, não byte a byte. A criptografia sorteia salts aleatórios e o writer atribui identificadores de documento, então não se espera que os dois builds emitam arquivos idênticos; espera-se que emitam arquivos que significam a mesma coisa, e as asserções estão escritas nesse nível. A perna do QDF está lá especificamente por causa do bug de offset: um QDF com duas páginas e nenhum conteúdo passa numa checagem de contagem de páginas e falha numa de extração de texto, e a matriz afirma a segunda. Qualquer correção futura que seja no-op num compilador e mudança de comportamento no outro — o que descreve quatro dos cinco acima — agora precisa passar pelas mesmas asserções duas vezes antes de sair

A metade de link-time do mesmo port — fazer os objetos OMF do Delphi e as expectativas COFF do Free Pascal concordarem — tem história própria em linkagem de objetos OMF para COFF no FPC Win32, e o hardening estrutural do mesmo leitor TIFF contra BigTIFF e arquivos tiled está nas notas do decoder TIFF embutido. Os decoders deste artigo, e o teste cross-compiler que agora fica embaixo deles, vão na PDF Library for Delphi para Delphi, C++Builder e Free Pascal, onde se espera que o mesmo código-fonte conquiste o mesmo resultado em cada compilador que ele tem como alvo, em vez de recebê-lo de um só