Artículo técnico

Código Delphi que funciona por accidente: cinco trampas FPC

Portar a Free Pascal el código de CCITT, TIFF, PNG, Flate y buffers de stream es una forma fiable de encontrar Pascal que solo funciona en Delphi de pura suerte. Ninguno de los cinco patrones de abajo implica un bug del compilador. Cada uno es código que Delphi ejecuta correctamente gracias a un detalle de implementación: un parámetro de resultado oculto que aliasa el array del caller, una rama fuera de rango que nadie lee nunca más allá, un buffer de longitud cero cuya única protección es un switch de range checking, un offset 1-based que un único camino de código pasa 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

Lo que sigue es la forma concreta de cada trampa, cómo la maneja PDF Library for Delphi y la disciplina que hay detrás: el mismo fuente tiene que producir la misma semántica de documento en ambos compiladores, y la única forma de saberlo es comprobar en ambos. El artículo hermano sobre endurecer un parser PDF en Pascal frente a archivos maliciosos cubre 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 mientras un compilador le tapaba 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 implementación tentadora escribe Result[0] y Result[1] y no llama a SetLength sobre Result en absoluto

Eso debería petar en la primera escritura, y en Free Pascal peta. En Delphi no, mientras los puntos de llamada tengan 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 carga 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, destruye ese arrastre y cambia la salida decodificada en Delphi. PDF Library for Delphi usa un guardia en lugar de un reset: en Delphi es código muerto y el camino de decodificación se queda byte a byte igual, y en Free Pascal convierte un fallo en el comportamiento previsto. Esa asimetría es justo el punto, porque el guardia tiene que ser un no-op en el compilador donde el código ya produce 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 range check ingenuo comprueba si Offset + TypeSize * Length se pasa del final del archivo y, si se pasa, pone ambos arrays a longitud cero mientras deja Result.Length con el valor del archivo

Desde ahí fallan 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 limpia en el camino fuera de rango, ese fallback no salta jamás para justo el caso para el que existe. 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

La segunda trampa es el orden. Ejecuta las dos llamadas SetLength antes del test de rango, dimensionadas desde el count del archivo, y una entrada hostil puede pedir una reserva de varios gigabytes antes de una sola comprobación de validez. En Delphi la excepción resultante la caza típicamente un handler más arriba en el camino de carga de imágenes y el archivo simplemente no carga, por eso el problema es fácil de perder; lo que de verdad pasa es un evento de out-of-memory elegido por el archivo. El orden correcto reserva después del test y hace que el count viaje con los datos

Endurecimiento de entradas de directorio TIFF en PDFlibPas: la entrada de 12 bytes trae un count suministrado por el archivo, el orden ingenuo reserva los arrays desde ese count antes del test de rango y deja un Result.Length vivo tras limpiarlos, y el orden seguro comprueba antes la aritmética Int64 contra la longitud del archivo para que el count se limpie junto con los arrays
Reservar antes del test de rango deja que un count hostil pida gigabytes y mantiene un count vivo en un array vaciado, así que el orden seguro comprueba 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 alcanza por fin el caso para el que existe:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Nada de este es específico del compilador, y eso es justo lo que le da sitio en esta lista. La trampa sigue latente en Delphi por la misma razón que en Free Pascal: hace falta una entrada de directorio apuntando más allá del final del archivo, y los archivos de test ordinarios no tienen ninguna. El port no la destapa. La destapa 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 rechaza la imagen antes de que corran los filtros de fila. 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. La trampa es un lector que valida los campos compression method y filter method del IHDR y deja pasar FColorType y el bit depth sin tocar: un color type ilegal produce entonces una scanline de cero bytes, y los bucles de unfiltering reciben un buffer vacío

El código de filtros de fila típicamente 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

PDF Library for Delphi aplica la tabla de la especificación donde ya se comprueban 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 comprueba en el mismo sitio, así que el lector de DPI nunca indexa S[1] a S[8] de una cadena que el chunk corto dejó 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, tiene que direccionar la misma entrada mediante aritmética de punteros, y la aritmética de punteros es 0-based. Pon next_in a PAnsiChar(Input) + StartPos y avail_in a Length(Input) - StartPos, 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

Esta se esconde con facilidad porque el 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 coinciden en cada llamada simple a InflateStr y en cada test que pasa por ella. TPDFDocument.DecodeAllStreams, la rutina que SaveQDFToFile y ConvertFileToQDF usan para expandir streams de un solo FlateDecode a forma legible, pasa 1. Con el off-by-one en marcha, la cabecera zlib saltada hace fallar el inflate, pero el stream zlib sigue reportando un Consumed distinto de cero por los bytes que había examinado, así que un caller que confía en Consumed toma el payload vacío como una decodificación exitosa y reemplaza cada content stream por una cadena vacía. El QDF resultante tiene 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.
// StartPos is 1-based like the Delphi branch; clamp it, then convert
// to a 0-based pointer offset exactly once, at the boundary.
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 comprobación que lo caza es la más pequeña posible: desinfla un payload, infla desde la posición 0 y desde la posición 1, y confirma 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. La forma natural de acabar ese tipo de bucle es 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, que es por lo que TPLBuffer itera hasta que una lectura devuelve 0

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, bucle de escaneo.
// 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;

La forma de clavarlo 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á. Un escaneo que se para en la primera lectura corta ve dos bytes, concluye que el stream está agotado y devuelve -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 detrás de estos cinco es que «el build Delphi pasa» es evidencia sobre Delphi, no sobre el fuente. Una comprobación cross-compiler que merece la pena corre una rutina bajo ambos compiladores: 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 comprueba 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

Compara semántica normalizada en lugar de bytes. 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 comprobaciones deben redactarse a ese nivel. La pata QDF se gana su sitio por la trampa 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, así que comprueba el segundo. Cualquier cambio 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 que superar las mismas comprobaciones en ambos

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 se envían en la PDF Library for Delphi para Delphi, C++Builder y Free Pascal, donde se espera que el mismo fuente se gane el mismo resultado en cada compilador al que apunta en vez de que un compilador se lo regale