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 búferes de stream es una forma confiable de encontrar Pascal que solo funciona en Delphi por accidente. Ninguno de los cinco patrones de abajo involucra un bug del compilador. Cada uno es código que Delphi ejecuta bien gracias a un detalle de implementación: un parámetro de resultado oculto que es alias del arreglo del llamador, una rama fuera de rango que nadie lee más allá, un búfer de longitud cero cuyo único resguardo es un switch de range checking, un offset 1-based que un solo camino de código pasa 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 trampa, la manera en que PDF Library for Delphi la maneja y la disciplina detrás: el mismo fuente tiene que producir la misma semántica de documento en ambos compiladores, y la única forma de saberlo es verificar en ambos. El artículo hermano sobre endurecer un parser de PDF en Pascal contra archivos maliciosos cubre 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 mientras un compilador lo tapaba 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, 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 implementación tentadora escribe Result[0] y Result[1] y jamás llama a SetLength sobre Result

Eso debería fallar en la primera escritura, y en Free Pascal lo hace. En Delphi no, mientras los sitios de llamada se vean 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 carga 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 — destruiría ese acarreo y cambiaría la salida decodificada en Delphi. PDF Library for Delphi usa 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 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
  // 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. La verificación de rango ingenua prueba si Offset + TypeSize * Length se pasa del final del archivo y, si es así, pone ambos arreglos a longitud cero mientras deja Result.Length con el valor que viene del archivo

Desde ahí dos cosas salen 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 limpia en la ruta fuera de rango, ese fallback nunca se dispara para el único caso para el que existe. 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 es el orden. Si las dos llamadas a SetLength corren antes de la prueba de rango, dimensionadas con el conteo del archivo, una entrada hostil puede pedir una asignación de varios gigabytes antes de una sola verificación de validez. En Delphi la excepción resultante típicamente la atrapa un handler más arriba en la ruta de carga de imágenes y el archivo simplemente falla al cargar, por eso el problema es fácil de pasar por alto; lo que en realidad pasa es un evento de memoria agotada que eligió el archivo. El orden correcto asigna 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 ingenuo asigna arreglos con ese conteo antes de la prueba de rango y deja Result.Length vivo tras limpiarlos, y el orden seguro 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 permite que un conteo hostil pida gigabytes y deja un conteo vivo sobre un arreglo vaciado, así que el orden seguro 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 alcanza el caso para el que existe:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Nada de esto es específico del compilador, y eso es lo que lo hace merecedor de estar en esta lista. La trampa sigue latente en Delphi por la misma razón que sigue latente en Free Pascal: hace falta una entrada de directorio apuntando más allá del final del archivo, y los archivos de prueba ordinarios no tienen una. Un port no la expone. La expone 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 rechaza la imagen antes de que corran los filtros de fila. 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 de método de compresión y método de filtro del IHDR y deja pasar FColorType y la profundidad de bits sin tocar: un color type ilegal produce entonces una scanline de cero bytes, y los ciclos de unfiltering reciben un búfer 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 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

PDF Library for Delphi aplica la tabla de la especificación donde los demás campos del IHDR ya se verifican: 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 verifica en el mismo lugar, así que el lector de DPI nunca indexa S[1] hasta S[8] de un string que el chunk corto dejó 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, tiene que direccionar la misma entrada mediante aritmética de punteros, y la aritmética de punteros es 0-based. Ponga next_in en PAnsiChar(Input) + StartPos y avail_in en Length(Input) - StartPos, 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

Esta se esconde con facilidad porque el 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 coinciden en cada llamada simple a InflateStr y en cada prueba que pasa por ahí. TPDFDocument.DecodeAllStreams, la rutina que SaveQDFToFile y ConvertFileToQDF usan para expandir streams con un único FlateDecode a forma legible, pasa 1. Con el off-by-one presente, el header zlib saltado hace fallar el inflate, pero el stream zlib igual reporta un Consumed distinto de cero por los bytes que examinó, así que un llamador que confía en Consumed toma el payload vacío como una decodificación exitosa y reemplaza cada content stream con un string vacío. El QDF resultante tiene 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.
// 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 prueba que lo atrapa 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. La forma natural de terminar ese ciclo es 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, que es por lo que TPLBuffer hace ciclo hasta que una lectura devuelve 0

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
// Ciclo de escaneo de TPLBuffer.DistanceToByte.
// 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 manera de fijar 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á. Un escaneo que se detiene 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 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 detrás de estos cinco es que «el build de Delphi pasa» es evidencia sobre Delphi, no sobre el fuente. Una verificación cruzada de compiladores que vale la pena corre una rutina bajo ambos compiladores: construya un documento de dos páginas con contenido comprimido a través de TPDFlib, guárdelo, guárdelo otra vez como QDF vía SaveQDFToFile, repare el QDF con RepairQDFFile, cifre el archivo plano con AES-128 mediante EncryptFile y una máscara de permisos de EncodePermissions, y luego recargue cada artefacto y afirme 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

Compare semántica normalizada y no bytes. 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 verificaciones deben redactarse a ese nivel. La pata del QDF se gana su lugar por la trampa 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, así que verifique la segunda. Cualquier cambio que sea un no-op en un compilador y un cambio de comportamiento en el otro — que describe cuatro de los cinco de arriba — tiene que pasar las mismas verificaciones en ambos

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 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