Artículo técnico

Cinco bugs de porting a FPC que Delphi tapaba por accidente

PDF Library for Delphi encontró cinco defectos de decodificación al portar su código de CCITT, TIFF, PNG, Flate y buffers de stream a Free Pascal, y todos llevaban años pasando la suite de tests completa de Delphi. Ninguno era un bug del compilador. Cada uno era Pascal que Delphi ejecutaba correctamente de pura suerte gracias a un detalle de implementación: un parámetro de resultado oculto que aliasaba el array del caller, una rama fuera de rango que nadie llegó a leer nunca, un buffer de longitud cero cuya única protección era un switch de range checking, un offset 1-based que un único camino de código pasaba como 1, y un contrato de TStream.Read que los streams en memoria nunca ejercitan. Cambia de compilador, o dale al mismo código un archivo malformado, y el accidente deja de sostenerse

A continuación va la forma concreta de cada uno, el fix y la disciplina que salió de todo ello: el mismo fuente tiene que producir ahora la misma semántica de documento en ambos compiladores, y un include de tests comprueba que así sea. El artículo hermano sobre endurecer un parser PDF en Pascal frente a archivos maliciosos cubría el ancho de enteros, la profundidad de recursión y los buffers sin inicializar. Este va de otra clase de fallo: código que estuvo mal desde el principio y tenía a un compilador tapándole las espaldas en silencio

¿Por qué una función que devuelve un dynamic array funciona sin SetLength en Delphi?

Porque Delphi pasa la propia variable del caller como parámetro de resultado oculto, así que una función que nunca reserva su resultado puede escribir igualmente en un array que reservó el caller. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray es la búsqueda de línea de referencia en el corazón de la decodificación bidimensional Group 3 y Group 4: dada la posición actual a0 y el color de la run actual, busca entre los elementos de cambio de la línea de escaneo anterior, los b1 y b2 del esquema de codificación bidimensional ITU-T T.4 y T.6, y los devuelve como un array de dos huecos. La función original escribía Result[0] y Result[1] y jamás llamó a SetLength sobre Result

Eso debería petar en la primera escritura, y en Free Pascal peta. En Delphi nunca lo hizo, porque ambos puntos de llamada del decoder tienen esta pinta: declaras b: TCCITTIntegerArray, ejecutas SetLength(b, 2) una vez antes del bucle de la scanline, y dentro del bucle asignas b := GetNextChangingElement(a0, IsWhite) y lees b[0] y b[1]. La guía del lenguaje Delphi dice que una función cuyo resultado es un long string, un dynamic array u otro tipo gestionado recibe ese resultado como un parámetro var adicional, y en la práctica el compilador pasa la dirección del destino de la asignación. O sea que Result dentro de la función es b mismo, ya con dos elementos, y cada escritura cae en memoria que es del caller. Free Pascal le entrega a la función un array nil fresco y lo asigna a b después, que es la lectura del contrato contra la que el código debió escribirse desde el principio

Divergencia de decodificación CCITT en PDFlibPas: Delphi pasa el array b del caller como Result var oculto de GetNextChangingElement, así que las escrituras caen en memoria del caller y una lookup fallida conserva los valores anteriores, mientras Free Pascal entrega a la función un array nil fresco que el guardia de Length debe dimensionar con SetLength antes de la primera escritura
Delphi aliasa el array del caller como parámetro Result oculto, así que las escrituras sin guardia caen igualmente en memoria propia, mientras Free Pascal llega con nil y el guardia de una línea convierte el fallo en el comportamiento previsto sin tocar el camino de decodificación de Delphi

El aliasing además cargaba con una semántica de la que el decoder depende. Result[0] solo se asigna cuando el escaneo encuentra un elemento mayor que a0, y Result[1] solo cuando hay otro después, así que en un miss los huecos conservan lo que la iteración anterior dejó en b. El fix obvio, reservar dos huecos y ponerlos a cero en cada llamada, habría destruido ese arrastre y cambiado la salida decodificada en Delphi. El fix que se envió es un guardia en lugar de un reset: en Delphi es código muerto y el camino de decodificación se queda byte a byte como estaba, y en Free Pascal convierte un fallo en el comportamiento previsto. Esa asimetría es justo el punto, porque el fix tenía que ser un no-op en el compilador donde el código ya producía salida verificada

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Aquí Delphi llega con el array de dos elementos del caller aliado
  // como Result, así que esto es un no-op allí. FPC llega con nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] se siguen escribiendo solo en un hit, así que
  // un miss conserva los valores de la iteración anterior tal cual
End;

Un count que sobrevivió a sus datos: la entrada del directorio TIFF

Cuando invalidas un array, tienes que invalidar su count en la misma sentencia, o el count será creído por código que nunca ve el array. Una entrada del directorio de imagen TIFF (TIFF 6.0 §2, el layout de 12 bytes de tag, type, count y valor-o-offset) trae un count de 32 bits directo del archivo, y PDF Library for Delphi lee cada una a través de PopDE: TTIFFEntry, un record con Tag, TagType, Length, Offset y los arrays decodificados IntegerValues y DoubleValues. El código original comprobaba si Offset + TypeSize * Length se pasaba del final del archivo, y si se pasaba, ponía ambos arrays a longitud cero. Dejó Result.Length con el valor del archivo

Desde ahí fallaron dos cosas. La función termina con un fallback que dice «si Length es cero, dale a la entrada un elemento a cero» para que los callers siempre puedan leer el elemento cero. Como Length nunca se limpiaba en el camino fuera de rango, ese fallback no saltaba jamás para justo el caso para el que existía. Y los callers sí leen el elemento cero, incondicionalmente: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip y una docena más toman E.IntegerValues[0], y las tablas de strips hacen Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), copiando Length veces cuatro bytes de un array que no tiene ninguno. Un array limpiado con un count vivo es estrictamente más peligroso que uno sin comprobar, porque el sin comprobar al menos guarda los bytes que dice tener

El segundo problema era el orden. Las dos llamadas SetLength corrían antes del test de rango, dimensionadas desde el count del archivo, así que una entrada hostil podía pedir una reserva de varios gigabytes antes de una sola comprobación de validez. En Delphi la excepción resultante la cazaba un handler más arriba en el camino de carga de imágenes y el archivo simplemente no cargaba, por eso nadie lo notó; lo que de verdad pasaba era un evento de out-of-memory elegido por el archivo. El fix mueve la reserva después del test y hace que el count viaje con los datos

Endurecimiento de la entrada del directorio TIFF en PDFlibPas: la entrada de 12 bytes trae un count suministrado por el archivo, el orden roto reservaba arrays desde ese count antes del test de rango y dejaba Result.Length vivo tras limpiarlos, y el orden arreglado prueba antes la aritmética Int64 contra la longitud del archivo, así el count se limpia junto con los arrays
Reservar antes del test de rango dejaba que un count hostil pidiera gigabytes y abandonaba un count vivo sobre un array vaciado, así que el fix prueba primero el offset y limpia Result.Length en la misma sentencia que los arrays
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // el count se va con los valores
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // solo ahora
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... más adelante, el fallback existente por fin alcanza el caso para el que estaba:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Nada de este fix es específico del compilador, y eso es justo lo que le da sitio en esta lista. El defecto estaba latente en Delphi por la misma razón que en Free Pascal: ningún archivo de test tenía una entrada de directorio apuntando más allá del final del archivo. El port no lo destapó. Lo destapó leer el código con la pregunta «qué hace Delphi por mí aquí que yo no estoy haciendo yo mismo»

¿Qué pasa cuando un IHDR de PNG reclama un color type que el formato no define?

PDF Library for Delphi ahora rechaza la imagen antes de que corran los filtros de fila; antes de v3.539.2 calculaba una scanline de cero bytes y le entregaba a los bucles de unfiltering un buffer vacío. La ISO 15948 §11.2.2 define el chunk IHDR y la Tabla 11.1 lista las seis combinaciones legales de color type y bit depth: escala de grises a 1, 2, 4, 8 o 16 bits, color indexado a 1, 2, 4 u 8, y truecolor, escala de grises con alpha y truecolor con alpha a 8 o 16. TPNGReader validaba los campos compression method y filter method del IHDR y dejaba pasar FColorType y el bit depth sin tocar

El código de filtros de fila dimensiona todo desde un Case FColorType Of que mapea cada color type a un número de componentes. Un color type fuera de los seis cae en la rama Else, donde SourceComponents es 0, así que ScanlineByteCount es 0, así que a SetLength(PreviousScanline, 0) le sigue inmediatamente FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indexar el elemento cero de un dynamic array vacío es una dirección calculada desde nil. Con range checking apagado, un fill de cero bytes por esa dirección es un no-op silencioso y el decoder sigue avanzando por filas que no existen; con range checking encendido es un ERangeError en la primera imagen; y las llamadas Move que vienen después están a un paso de un access violation. Cuál de los tres te toca depende del compilador y de los switches de build, no de nada que el decoder decidiera, y ese es el síntoma de que el decoder nunca decidió nada

El fix es la tabla de la especificación, aplicada donde ya se comprobaban los demás campos del IHDR: COLOR_GRAYSCALE acepta FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE acepta [1, 2, 4, 8], y COLOR_RGB, COLOR_GRAYSCALEALPHA y COLOR_RGBALPHA aceptan [8, 16]; cualquier otra cosa limpia ValidImage y la imagen se rechaza con su ancho y alto intactos para diagnóstico. Un chunk pHYs más corto que sus nueve bytes se cerró en la misma pasada, porque el lector de DPI indexaba S[1] a S[8] de una cadena que el chunk corto había dejado vacía

Un offset 1-based tratado como puntero 0-based

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString toma un StartPos 1-based, porque su entrada es un AnsiString y la implementación Delphi direcciona la entrada zlib como @Input[StartPos]. La implementación Free Pascal, escrita contra paszlib para que ambos targets de Windows enlacen la compresión estáticamente, ponía next_in a PAnsiChar(Input) + StartPos y avail_in a Length(Input) - StartPos. Eso es aritmética de punteros, y es 0-based. Pasa 1, que es lo que «empezar por el principio» significa para esta función, y el build FPC empieza a inflar en el segundo byte y se para un byte antes del final

La razón por la que sobrevivió es que el único caller al que llegan la mayoría de los tests es InflateStr, que pasa 0. El cero resulta ser el offset 0-based correcto, así que los dos builds coincidían en cada llamada simple a InflateStr y en cada test que pasaba por ella. TPDFDocument.DecodeAllStreams, la rutina que SaveQDFToFile y ConvertFileToQDF usan para expandir streams de un solo FlateDecode a forma legible, pasa 1. En el build FPC la cabecera zlib saltada hacía fallar el inflate, pero el stream zlib seguía reportando un Consumed distinto de cero por los bytes que había examinado, así que DecodeAllStreams tomaba el payload vacío como una decodificación exitosa y reemplazaba cada content stream por una cadena vacía. El QDF resultante tenía el número de páginas correcto, estructura válida y ningún contenido de página, que es un archivo que abre sin error en todos los visores y no muestra nada

// Rama FPC de InflateStrFromPosition, tras v3.539.16.
// StartPos es 1-based como en la rama Delphi; acótala y conviértela
// a un offset de puntero 0-based exactamente una vez, en la frontera.
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;

La regresión que lo deja clavado es la más pequeña posible: desinfla un payload, infla desde la posición 0 y desde la posición 1, y afirma que ambos devuelven el mismo payload y que ambos reportan un Consumed igual a la longitud completa del stream. Un stream RFC 1950 lleva una cabecera de dos bytes y un trailer Adler-32 de cuatro, así que un off-by-one en cualquiera de los dos extremos no es una corrupción sutil: es un stream que o no arranca o no termina. La lección va de la frontera, no de zlib: cuando el parámetro de una función está definido en una base de índices y la implementación de debajo usa la otra, la conversión pertenece a una única línea, y un test tiene que llamarla con el valor que distingue ambas bases

¿Por qué una lectura corta de TStream.Read no es el fin del stream?

Porque a TStream.Read se le permite devolver menos bytes de los pedidos por cualquier razón que le apetezca, y solo un retorno de 0 significa que no hay más. TMemoryStream y TFileStream sobre disco local casi siempre llenan la petición, y por eso el código que trata «devolvió menos de lo que pedí» como fin de archivo pasa todos los tests que los usan. Los streams respaldados por red, los streams de descompresión y cualquier descendiente de TStream que escriba un cliente pueden devolver dos bytes cuando les pides sesenta y cuatro mil y seguir teniendo gigabytes detrás

TPLBuffer es el lector por el que pasa cada parser de PDF Library for Delphi, y puede envolver un AnsiString, un puntero, un array de bytes o un TStream. Sus cuatro consultas de escaneo, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte y DistanceToOtherBytes, todas devolviendo Int64, leen la fuente en bloques de 64 KB buscando un delimitador y reportan a qué distancia está sin mover la posición lógica. Cada bucle acababa en Until ReadCount < BlockSize. Para las tres fuentes en memoria eso es correcto, porque ReadIntoBuffer entrega siempre el bloque completo salvo el último. Para la fuente de stream significa que el escaneo se rinde en la primera lectura corta, reporta el delimitador como ausente, y el tokenizer de encima decide que el objeto acaba donde no acaba

Gestión de lecturas cortas en el buffer de stream de PDFlibPas: DistanceToByte escanea bloques de 64 KB, el bucle viejo trataba Until ReadCount < BlockSize como fin de datos y se rendía en la primera lectura corta, mientras el bucle arreglado corre hasta que ReadCount llega a cero, encuentra el delimitador y restaura la posición en un bloque finally
Un stream puede devolver dos bytes cuando le pides sesenta y cuatro mil, así que cero es la única señal de fin de datos en la que el escaneo puede confiar, y la cláusula finally restaura la posición lógica cuando se encuentra el delimitador y el bucle sale antes de tiempo
// TPLBuffer.DistanceToByte, el bucle tras v3.539.6.
// Cero es la única señal de fin de datos que define TStream.Read.
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;   // un peek no debe mover al lector
End;

El test que lo fija es un descendiente de TMemoryStream cuyo override de Read topa cada petición a dos bytes. Envuelve en él la cadena aaaaaX, pon la posición del buffer a 1, y las cuatro consultas deben reportar una distancia de 4 hasta la X, dejar la posición en 1 después, y reportar -1 para un byte que no está. Antes del fix la primera consulta veía dos bytes, concluía que el stream estaba agotado y devolvía -1. El finally importa tanto como la condición del bucle: un Exit desde dentro del escaneo es el camino normal de éxito, y la posición lógica tiene que restaurarse también en ese camino, no solo cuando el bucle corre hasta el final

Un fuente, dos compiladores, un solo juego de assertions

La disciplina que salió de estos cinco es que «el build Delphi pasa» es evidencia sobre Delphi, no sobre el fuente. Desde v3.539.16 la suite DUnitX de Delphi y la suite de consola de Free Pascal incluyen ambas el mismo Tests\CrossCompilerSemantics.inc, una única rutina, RunCrossCompilerFileSemantics, que construye un documento de dos páginas con contenido comprimido a través de TPDFlib, lo guarda, lo guarda otra vez como QDF mediante SaveQDFToFile, repara el QDF con RepairQDFFile, cifra el archivo plano con AES-128 a través de EncryptFile y una máscara de permisos de EncodePermissions, y luego recarga cada artefacto y afirma lo mismo en ambos compiladores: el número de páginas es 2, el título sobrevive, el texto de la página dos se extrae intacto de los archivos plano, reparado y cifrado, la contraseña equivocada se rechaza con un LastErrorCode distinto de cero, EncryptionStrength es 128, EncryptionAlgorithm es 2, y los bits de permiso individuales de GetUserPermissions vuelven exactamente como se codificaron

La comparación es deliberadamente normalizada en lugar de byte a byte. El cifrado sortea sales aleatorias y el writer asigna identificadores de documento, así que no se espera que los dos builds emitan archivos idénticos; se espera que emitan archivos que significan lo mismo, y las assertions están redactadas a ese nivel. La pata QDF está ahí precisamente por el bug del offset: un QDF con dos páginas y sin contenido pasa un chequeo de número de páginas y suspende un chequeo de extracción de texto, y la matriz afirma el segundo. Cualquier fix futuro que sea un no-op en un compilador y un cambio de comportamiento en el otro, que describe a cuatro de los cinco de arriba, tiene ahora que superar las mismas assertions dos veces antes de enviarse

La mitad de link-time del mismo port, hacer que los objetos OMF de Delphi y las expectativas COFF de Free Pascal se entiendan, es historia aparte en el enlazado de objetos FPC Win32 de OMF a COFF, y el endurecimiento estructural del mismo lector TIFF frente a BigTIFF y archivos teselados está en las notas del decoder TIFF integrado. Los decoders de este artículo, y el test cross-compiler que ahora se sienta debajo, se envían en la PDF Library for Delphi para Delphi, C++Builder y Free Pascal, donde se espera que el mismo fuente gane el mismo resultado en cada compilador al que apunta en vez de que un compilador se lo regale