Artículo técnico

Cinco bugs de FPC: código Delphi que funciona por accidente

PDF Library for Delphi encontró cinco defectos de decodificación al portar su código de CCITT, TIFF, PNG, Flate y búferes de stream a Free Pascal, y cada uno de ellos había pasado la suite de pruebas completa de Delphi durante años. Ninguno era un bug del compilador. Cada uno era Pascal que Delphi simplemente ejecutaba bien gracias a un detalle de implementación: un parámetro de resultado oculto que era alias del arreglo del llamador, una rama fuera de rango que nadie leía más allá, un búfer de longitud cero cuyo único resguardo era un switch de range checking, un offset 1-based que un solo camino de código pasaba como 1, y un contrato de TStream.Read que los streams en memoria nunca ejercitan. Cambie de compilador, o dele al mismo código un archivo malformado, y el accidente deja de sostenerse

Lo que sigue es la forma concreta de cada uno, el fix y la disciplina que salió de todo esto: el mismo fuente ahora tiene que producir la misma semántica de documento en ambos compiladores, y un include de pruebas verifica que así sea. El artículo hermano sobre endurecer un parser de PDF en Pascal contra archivos maliciosos cubrió el ancho de enteros, la profundidad de recursión y los búferes sin inicializar. Este trata una clase de falla distinta: código que estuvo mal todo el tiempo y tenía a un compilador tapándolo en silencio

¿Por qué una función que devuelve un arreglo dinámico funciona sin SetLength en Delphi?

Porque Delphi pasa la propia variable del llamador como el parámetro de resultado oculto, así que una función que nunca asigna su resultado igual puede escribir en un arreglo que el llamador asignó. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray es la búsqueda de la 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 los changing elements de la scanline anterior — el b1 y el b2 del esquema de codificación bidimensional ITU-T T.4 y T.6 — y los devuelve como un arreglo de dos ranuras. La función original escribía Result[0] y Result[1] y jamás llamaba a SetLength sobre Result

Eso debería fallar en la primera escritura, y en Free Pascal lo hace. En Delphi nunca lo hizo, porque ambos sitios de llamada del decoder se ven así: declare b: TCCITTIntegerArray, ejecute SetLength(b, 2) una vez antes del ciclo de scanlines, y luego dentro del ciclo asigne b := GetNextChangingElement(a0, IsWhite) y lea b[0] y b[1]. La guía del lenguaje Delphi dice que una función cuyo resultado es un long string, un arreglo dinámico 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. Así que Result dentro de la función es b mismo, con dos elementos ya asignados, y cada escritura cae en memoria que el llamador posee. Free Pascal le entrega a la función un arreglo nil recién creado y se 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 arreglo b del llamador como el Result var oculto de GetNextChangingElement, de modo que las escrituras caen en memoria que el llamador posee y una búsqueda fallida conserva los valores anteriores, mientras que Free Pascal le entrega a la función un arreglo nil nuevo que el guardia de Length debe dimensionar con SetLength antes de la primera escritura
Delphi usa el arreglo del llamador como alias del parámetro Result oculto, así que las escrituras sin resguardo igual caen en memoria propia, mientras que Free Pascal llega con nil y el guardia de una línea convierte la falla en el comportamiento esperado sin tocar la ruta de decodificación de Delphi

El aliasing también cargaba 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 un elemento después de él, así que ante un miss las ranuras conservan lo que la iteración anterior dejó en b. El fix obvio — asignar dos ranuras y ponerlas a cero en cada llamada — habría destruido ese acarreo y cambiado la salida decodificada en Delphi. El fix que se publicó es un guardia en lugar de un reseteo: en Delphi es código muerto y la ruta de decodificación queda byte a byte como era, y en Free Pascal convierte una falla en el comportamiento esperado. Esa asimetría es todo 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
  // Delphi llega acá con el arreglo de dos elementos del llamador
  // alias de 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] siguen escribiéndose solo en un hit, así que
  // un miss conserva los valores de la iteración anterior tal cual
End;

Un conteo que sobrevivió a sus datos: la entrada de directorio TIFF

Cuando invalida un arreglo, tiene que invalidar su conteo en la misma sentencia, o el conteo será creído por código que jamás ve el arreglo. Una entrada de image file directory de TIFF (TIFF 6.0 §2, el layout de 12 bytes de tag, tipo, conteo y valor-o-offset) carga un conteo 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 arreglos decodificados IntegerValues y DoubleValues. El código original verificaba si Offset + TypeSize * Length se pasaba del final del archivo, y si era así, ponía ambos arreglos a longitud cero. Dejaba Result.Length con el valor que venía del archivo

Desde ahí dos cosas salieron mal. La función termina con un fallback que dice «si Length es cero, dale a la entrada un elemento con valor cero» para que los llamadores siempre puedan leer el elemento cero. Como Length nunca se limpiaba en la ruta fuera de rango, ese fallback nunca se disparó para el único caso para el que existía. Y los llamadores 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 por cuatro bytes de un arreglo que no tiene ninguno. Un arreglo limpiado con un conteo vivo es estrictamente más peligroso que uno sin verificar, porque el que no se verifica al menos tiene los bytes que dice tener

El segundo problema era el orden. Las dos llamadas a SetLength corrían antes de la prueba de rango, dimensionadas con el conteo del archivo, así que una entrada hostil podía pedir una asignación de varios gigabytes antes de una sola verificación de validez. En Delphi la excepción resultante la atrapaba un handler más arriba en la ruta de carga de imágenes y el archivo simplemente fallaba al cargar, por eso nadie lo notó; lo que en realidad pasaba era un evento de memoria agotada que eligió el archivo. El fix mueve la asignación después de la prueba y hace que el conteo viaje con los datos

Endurecimiento de la entrada de directorio TIFF en PDFlibPas: la entrada de 12 bytes carga un conteo suministrado por el archivo, el orden roto asignaba arreglos con ese conteo antes de la prueba de rango y dejaba Result.Length vivo tras limpiarlos, y el orden corregido prueba primero la aritmética Int64 contra la longitud del archivo, de modo que el conteo se limpia junto con los arreglos
Asignar antes de la prueba de rango permitía que un conteo hostil pidiera gigabytes y dejaba un conteo vivo sobre un arreglo vaciado, así que el fix prueba el offset primero y limpia Result.Length en la misma sentencia que los arreglos
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // el conteo 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 existía:
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 lo que lo hace merecedor de estar en esta lista. El defecto estaba latente en Delphi por la misma razón que estaba latente en Free Pascal: ningún archivo de prueba tenía una entrada de directorio apuntando más allá del final del archivo. El port no lo expuso. Lo expuso leer el código con la pregunta «qué hace Delphi por mí acá que yo no estoy haciendo yo mismo»

¿Qué pasa cuando un IHDR de PNG declara 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 ciclos de unfiltering un búfer vacío. 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 de método de compresión y método de filtro del IHDR y dejaba pasar FColorType y la profundidad de bits sin tocar

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

El fix es la tabla de la especificación, aplicada donde los demás campos del IHDR ya se verificaban: 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, ya que el lector de DPI indexaba S[1] hasta S[8] de un string que el chunk corto había dejado vacío

Un offset 1-based tratado como puntero 0-based

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

Sobrevivió porque el único llamador al que llegan la mayoría de las pruebas es InflateStr, que pasa 0. Cero resulta ser el offset 0-based correcto, así que los dos builds coincidían en cada llamada simple a InflateStr y en cada prueba que pasaba por ahí. TPDFDocument.DecodeAllStreams, la rutina que SaveQDFToFile y ConvertFileToQDF usan para expandir streams con un único FlateDecode a forma legible, pasa 1. En el build de FPC el header zlib saltado hacía fallar el inflate, pero el stream zlib igual reportaba 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 con un string vacío. El QDF resultante tenía el conteo de páginas correcto, estructura válida y ningún contenido de página, que es un archivo que abre sin error en cualquier visor y no muestra nada

// Rama FPC de InflateStrFromPosition, después de v3.539.16.
// StartPos es 1-based como en la rama Delphi; hazle clamp, luego convierte
// 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 resguarda es la más pequeña posible: compreza un payload, inflelo desde la posición 0 y desde la posición 1, y verifique que ambos devuelven el mismo payload y que ambos reportan un Consumed igual a la longitud completa del stream. Un stream RFC 1950 tiene un header de dos bytes y un trailer de Adler-32 de cuatro bytes, así que un off-by-one en cualquiera de los dos extremos no es una corrupción sutil: es un stream que o no logra arrancar o no logra terminar. La lección es sobre la frontera, no sobre zlib: cuando el parámetro de una función está definido en una base de indexado y la implementación de abajo usa la otra, la conversión pertenece a exactamente una línea, y una prueba tiene que llamarla con el valor que distingue las dos bases

¿Por qué un TStream.Read corto 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 venga en gana, y solo un retorno de 0 significa que no hay nada más. TMemoryStream y TFileStream sobre un disco local casi siempre llenan el pedido, y por eso el código que trata «devolvió menos de lo que pedí» como fin de archivo pasa todas las pruebas que los usan. Los streams respaldados por red, los streams de descompresión y cualquier descendiente de TStream que un cliente escriba pueden devolver dos bytes cuando se les piden 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 arreglo 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 ciclo terminaba con Until ReadCount < BlockSize. Para las tres fuentes en memoria eso es correcto, porque ReadIntoBuffer siempre entrega el bloque completo salvo el último. Para la fuente stream significa que el escaneo se rinde ante la primera lectura corta, reporta el delimitador como ausente, y el tokenizer de arriba decide que el objeto termina donde no termina

Manejo de lecturas cortas en el búfer de stream de PDFlibPas: DistanceToByte escanea bloques de 64 KB, el ciclo viejo trataba Until ReadCount < BlockSize como fin de datos y se rendía ante la primera lectura corta, mientras que el ciclo corregido 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 se le piden 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 ciclo sale antes de tiempo
// TPLBuffer.DistanceToByte, el ciclo después de v3.539.6.
// Cero es la única señal de fin de datos 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;   // un peek no debe mover al lector
End;

La prueba que fija esto es un descendiente de TMemoryStream cuyo override de Read limita cada pedido a dos bytes. Envuelva el string aaaaaX en él, ponga la posición del búfer en 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 ciclo: un Exit desde dentro del escaneo es el camino normal de éxito, y la posición lógica tiene que restaurarse en ese camino también, no solo cuando el ciclo corre hasta el final

Un fuente, dos compiladores, un solo juego de aserciones

La disciplina que salió de estos cinco es que «el build de 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 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 vía SaveQDFToFile, repara el QDF con RepairQDFFile, cifra el archivo plano con AES-128 mediante EncryptFile y una máscara de permisos de EncodePermissions, y luego recarga cada artefacto y afirma lo mismo en ambos compiladores: el conteo 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 y no byte a byte. El cifrado sortea salts aleatorios 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 aserciones están redactadas a ese nivel. La pata del QDF está ahí específicamente por el bug del offset: un QDF con dos páginas y sin contenido pasa una verificación de conteo de páginas y falla una de extracción de texto, y la matriz afirma la segunda. Cualquier fix futuro que sea un no-op en un compilador y un cambio de comportamiento en el otro — que describe cuatro de los cinco de arriba — ahora tiene que pasar las mismas aserciones dos veces antes de publicarse

La mitad de enlazado 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 OMF a COFF en FPC Win32, y el endurecimiento estructural del mismo lector TIFF contra archivos BigTIFF y tiled está en las notas del decoder TIFF integrado. Los decoders de este artículo, y la prueba cruzada de compiladores que ahora les queda debajo, se envían con 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