Artigo Técnico

Código Delphi que só funciona por acidente: armadilhas FPC

Trazer CCITT, TIFF, PNG, Flate e código de buffer de stream para o Free Pascal é um jeito confiável de achar Pascal que só funciona no Delphi por acidente. Nenhum dos cinco padrões abaixo envolve um bug de compilador. Cada um é código que o Delphi por acaso executa corretamente por causa de um detalhe de implementação: um parâmetro de resultado oculto que é alias do array do caller, um branch fora do intervalo que ninguém jamais lê além dele, um buffer de comprimento zero cuja única proteção é um switch de range check, um offset 1-based que um único caminho de código passa como 1, e um contrato de TStream.Read que streams em memória nunca exercitam. Troque o compilador, ou dê ao mesmo código um arquivo malformado, e o acidente para de se sustentar

O que segue é a forma específica de cada armadilha, o modo como a PDF Library for Delphi a trata e a disciplina por trás: o mesmo source tem que produzir a mesma semântica de documento nos dois compiladores, e o único jeito de saber é checar nos dois. O artigo irmão sobre endurecer um parser PDF em Pascal contra arquivos maliciosos cobre largura de inteiros, profundidade de recursão e buffers não inicializados. Este é sobre uma classe de falha diferente: código que esteve errado o tempo todo enquanto um compilador o cobria silenciosamente

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 caller como o parâmetro de resultado oculto, então uma função que nunca aloca seu resultado ainda pode escrever num array que o caller alocou. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray é a consulta de reference-line no coração da decodificação bidimensional Group 3 e Group 4: dada a posição corrente a0 e a cor do run atual, ela procura os changing elements da scanline anterior, o b1 e b2 do esquema de codificação bidimensional ITU-T T.4 e T.6, e os retorna como um array de dois slots. A implementação tentadora escreve Result[0] e Result[1] e nunca chama SetLength no Result

Isso deveria falhar na primeira escrita, e no Free Pascal falha. No Delphi não, contanto que os call sites se pareçam com isto: declare b: TCCITTIntegerArray, rode SetLength(b, 2) uma vez antes do loop de scanline, e dentro do loop atribua b := GetNextChangingElement(a0, IsWhite) e leia b[0] e b[1]. O guia de linguagem do Delphi afirma que uma função cujo resultado é uma long string, dynamic array ou outro tipo gerenciado recebe esse resultado como um parâmetro var adicional, e na prática o compilador passa o endereço do alvo da atribuição. Então o Result dentro da função é o próprio b, já com dois elementos, e toda escrita cai em memória que o caller possui. O Free Pascal entrega à função um array nil fresco e o atribui a b depois, que é a leitura do contrato contra a qual o código deveria ser escrito desde o início

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 aliasing também carrega uma semântica de que o decoder depende. O Result[0] só é atribuído quando o scan acha um elemento maior que a0, e o Result[1] só quando existe um elemento depois dele, então numa falta os slots mantêm o que a iteração anterior deixou em b. O conserto óbvio, alocar dois slots e zerá-los a cada chamada, destrói esse carry-over e muda o output decodificado no Delphi. A PDF Library for Delphi usa um guard em vez de um reset: no Delphi ele é código morto e o caminho de decode continua byte a byte igual, e no Free Pascal transforma uma falha no comportamento pretendido. Essa assimetria é o ponto todo, já que o guard tem que ser um no-op no compilador em que o código já produz output verificado

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, tem que invalidar a contagem dele na mesma instrução, ou a contagem será acreditada por código que nunca vê o array. Uma entrada de image file directory TIFF (TIFF 6.0 §2, o layout de 12 bytes de tag, type, count e value-ou-offset) carrega uma contagem de 32 bits direto do arquivo, e a PDF Library for Delphi lê cada uma por meio de PopDE: TTIFFEntry, um record com Tag, TagType, Length, Offset e os arrays decodificados IntegerValues e DoubleValues. A checagem de intervalo ingênua testa se Offset + TypeSize * Length passa do fim do arquivo e, se passa, define ambos os arrays como comprimento zero deixando o Result.Length no valor do arquivo

Duas coisas dão errado a partir daí. A função termina com um fallback que lê "se Length é zero, dê à entrada um elemento zerado" para que os callers sempre possam ler o elemento zero. Com Length nunca limpo no caminho fora do intervalo, esse fallback nunca dispara para o único caso para o qual existe. E os callers leem o elemento zero, incondicionalmente: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip e uma dúzia mais tomam E.IntegerValues[0], e as tabelas de strip 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 contagem viva é estritamente mais perigoso que um não checado, porque o não checado ao menos guarda os bytes que diz ter

A segunda armadilha é a ordenação. Rode as duas chamadas de SetLength antes do teste de intervalo, dimensionadas pela contagem do arquivo, e uma entrada hostil pode pedir uma alocação de vários gigabytes antes de uma única checagem de validade. No Delphi a exceção resultante é tipicamente capturada por um handler mais acima no caminho de carregamento da imagem e o arquivo simplesmente falha ao carregar, e é por isso que o problema é fácil de perder de vista; o que acontece de fato é um evento de out-of-memory que o arquivo escolheu. A ordem correta aloca depois do teste e faz a contagem viajar junto com os dados

Endurecimento de entrada de diretório TIFF no PDFlibPas: a entrada de 12 bytes carrega uma contagem vinda do arquivo, a ordem ingênua aloca arrays a partir dessa contagem antes do teste de intervalo e deixa o Result.Length vivo depois de limpá-los, e a ordem segura testa a aritmética Int64 contra o comprimento do arquivo primeiro para que a contagem seja limpa junto com os arrays
Alocar antes do teste de intervalo deixa uma contagem hostil pedir gigabytes e deixa uma contagem viva num array esvaziado, então a ordem segura testa o offset primeiro e limpa o 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 alcança o caso para o qual existe:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Nada aqui é específico de compilador, e é o que o faz pertencer a esta lista. A armadilha fica latente no Delphi pela mesma razão que fica latente no Free Pascal: ela precisa de uma entrada de diretório apontando além do fim do arquivo, e arquivos de teste comuns não têm uma. Um port não a expõe. Ler o código com a pergunta "o que o Delphi faz por mim aqui que eu não estou fazendo eu mesmo" expõe

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

A PDF Library for Delphi rejeita a imagem antes de os row filters rodarem. 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: tons de cinza a 1, 2, 4, 8 ou 16 bits, cor indexada a 1, 2, 4 ou 8, e truecolor, tons de cinza com alpha e truecolor com alpha a 8 ou 16. A armadilha é um reader que valida os campos compression method e filter method do IHDR e deixa passar FColorType e o bit depth intocados: um color type ilegal produz então uma scanline de zero bytes, e os loops de unfilter recebem um buffer vazio

Código de row filter tipicamente 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 branch Else, em que SourceComponents é 0, então ScanlineByteCount é 0, então SetLength(PreviousScanline, 0) é seguido imediatamente por FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indexar o elemento zero de um dynamic array vazio é um endereço computado a partir de nil. Com range checking desligado, um fill de zero bytes por esse endereço é um no-op silencioso e o decoder marcha por linhas que não existem; com range checking ligado é um ERangeError na primeira imagem; e as chamadas de Move que o seguem estão a um passo de um access violation. Qual delas você leva depende do compilador e dos switches de build, não de nada que o decoder tenha decidido, e esse é o indício de que o decoder nunca decidiu nada

A PDF Library for Delphi aplica a tabela da especificação onde os outros campos do IHDR são 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 o ValidImage e a imagem é recusada com width e height intactos para diagnóstico. Um chunk pHYs menor que seus nove bytes é checado no mesmo lugar, então o leitor de DPI nunca indexa S[1] até S[8] de uma string que o chunk curto deixou 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 seu input é um AnsiString e a implementação Delphi endereça o input zlib como @Input[StartPos]. A implementação Free Pascal, escrita contra o paszlib para que ambos os alvos Windows liguem compressão estaticamente, precisa endereçar o mesmo input por aritmética de ponteiros, e aritmética de ponteiros é 0-based. Defina next_in como PAnsiChar(Input) + StartPos e avail_in como Length(Input) - StartPos, passe 1, que é o que "começar do começo" significa para esta função, e o build FPC começa a inflar no segundo byte e para um byte antes do fim

Este se esconde com facilidade porque o caller que a maioria dos testes alcança é o InflateStr, que passa 0. Zero por acaso é o offset 0-based correto, então os dois builds concordam em toda chamada plain de InflateStr e em todo teste que passa por ele. O TPDFDocument.DecodeAllStreams, a rotina que SaveQDFToFile e ConvertFileToQDF usam para expandir streams FlateDecode únicos em forma legível, passa 1. Com o off-by-one no lugar, o header zlib pulado faz o inflate falhar, mas o stream zlib ainda reporta um Consumed não zero pelos bytes que examinou, então um caller que confia no Consumed leva o payload vazio como decode bem-sucedido e substitui todo content stream por uma string vazia. O QDF resultante tem a contagem de páginas certa, estrutura válida e nenhum conteúdo de página, um arquivo que abre sem erro em todo viewer e não mostra nada

// Branch FPC do InflateStrFromPosition.
// StartPos é 1-based como no branch Delphi; faça o clamp e converta
// para um offset de ponteiro 0-based exatamente uma 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;

A checagem que o pega é a menor possível: deflate um payload, infle a partir da posição 0 e da posição 1, e confirme que ambos retornam o mesmo payload e ambos reportam Consumed igual ao comprimento completo 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 ponta não é uma corrupção sutil, é um stream que ou não começa ou não termina. A lição é sobre a fronteira, não sobre 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

TPLBuffer é o reader pelo qual todo parser da PDF Library for Delphi passa, e ele pode envolver um AnsiString, um ponteiro, um array de bytes ou um TStream. Suas quatro consultas de scan, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte e DistanceToOtherBytes, todas retornando Int64, leem o source em blocos de 64 KB procurando um delimitador e reportam a distância até ele sem mover a posição lógica. O jeito natural de terminar esse loop é Until ReadCount < BlockSize. Para as três fontes em memória isso é correto, já que o ReadIntoBuffer sempre entrega o bloco cheio até o último. Para uma fonte stream significa que o scan desiste no primeiro short read, reporta o delimitador como ausente, e o tokenizer acima decide que o objeto termina onde não termina, e é por isso que o TPLBuffer faz loop até um read retornar 0

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
// Loop de scan do TPLBuffer.DistanceToByte.
// Zero é o único sinal de fim de dados que o 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 deve mover o reader
End;

O jeito de cravar isso é um descendente de TMemoryStream cujo override de Read limita toda requisição a dois bytes. Envolva a string aaaaaX nele, posicione o buffer em 1, e as quatro consultas devem reportar distância 4 até o X, deixar a posição em 1 depois e reportar -1 para um byte que não está lá. Um scan que para no primeiro short read vê dois bytes, conclui que o stream se esgotou e retorna -1. O finally importa tanto quanto a condição do loop: um Exit de dentro do scan é o caminho normal de sucesso, e a posição lógica tem que ser restaurada nesse caminho também, não só quando o loop corre até o fim

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

A disciplina por trás destes cinco é que "o build Delphi passa" é evidência sobre o Delphi, não sobre o source. Uma checagem de cross-compiler que vale ter roda uma rotina sob os dois compiladores: construa um documento de duas páginas com conteúdo comprimido por meio do TPDFlib, salve, salve de novo como QDF por meio do SaveQDFToFile, repare o QDF com o RepairQDFFile, encripte o arquivo plain com AES-128 por meio do EncryptFile e uma máscara de permissões do EncodePermissions, e depois recarregue cada artefato e cheque as mesmas coisas nos dois compiladores: a contagem de páginas é 2, o título sobrevive, o texto da página dois extrai intacto dos arquivos plain, reparado e encriptado, a senha errada é recusada com LastErrorCode não zero, EncryptionStrength é 128, EncryptionAlgorithm é 2, e os bits individuais de permissão do GetUserPermissions voltam exatamente como codificados

Compare semântica normalizada em vez de bytes. Encriptação 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 checagens devem ser formuladas nesse nível. A etapa do QDF ganha seu lugar por causa da armadilha do offset: um QDF com duas páginas e sem conteúdo passa numa checagem de contagem de páginas e falha numa checagem de extração de texto, então cheque a segunda. Qualquer mudança que é no-op num compilador e mudança de comportamento no outro, o que descreve quatro dos cinco acima, precisa passar pelas mesmas checagens nos dois

A metade de link-time do mesmo port, fazer os objetos OMF do Delphi e as expectativas COFF do Free Pascal concordarem, é uma história própria em linking de objetos FPC Win32 OMF para COFF, e o endurecimento estrutural do mesmo leitor TIFF contra BigTIFF e arquivos tiled está nas notas do decoder TIFF embutido. Os decoders deste artigo vêm na PDF Library for Delphi para Delphi, C++Builder e Free Pascal, em que se espera que o mesmo source ganhe o mesmo resultado em todo compilador a que se destina, em vez de lhe ser concedido por um