import { deflateSync } from 'node:zlib' import { describe, expect, it } from 'vitest' import { pdfTextLesen } from '@/lib/ocr/pdf-text' /** * Was passiert, wenn jemand ein praepariertes PDF in die Akte legt? * * Ein Flate-Strom sagt nicht, wie gross er ausgepackt wird. Ein paar * Kilobyte Nullen komprimieren auf fast nichts und blaehen sich beim * Auspacken auf Gigabyte auf - die klassische Dekompressionsbombe. Der * Textleser packte vorher ohne Obergrenze aus. * * Der Test rechnet nicht mit Gigabyte - das waere in einer Testsuite * unhoeflich. Er baut eine Bombe, die die eingebaute Grenze knapp * ueberschreitet, und besteht darauf, dass sie nicht ausgepackt wird. */ /** Baut ein minimales PDF mit einem einzigen Flate-Strom. */ function pdfMitStrom(nutzlast: Buffer, filter = '/FlateDecode'): Buffer { const kopf = Buffer.from( `%PDF-1.4\n1 0 obj\n<< /Length ${nutzlast.length} /Filter ${filter} >>\nstream\n`, 'latin1', ) const fuss = Buffer.from('\nendstream\nendobj\n%%EOF\n', 'latin1') return Buffer.concat([kopf, nutzlast, fuss]) } /** * Text, den der Extraktor tatsaechlich findet: Er springt nur an, wenn im * Strom PDF-Textoperatoren stehen. */ function inhaltsstrom(text: string): Buffer { return Buffer.from(`BT /F1 12 Tf (${text}) Tj ET\n`, 'latin1') } describe('PDF-Textleser gegen praeparierte Dateien', () => { it('liest einen gewoehnlichen Flate-Strom', () => { const pdf = pdfMitStrom(deflateSync(inhaltsstrom('Bescheid vom Sozialamt'))) expect(pdfTextLesen(pdf)).toContain('Bescheid vom Sozialamt') }) it('packt eine Bombe nicht aus, die die Stromgrenze ueberschreitet', () => { // Die Nutzlast traegt eine erkennbare Marke UND 9 MB Fuellung. Beides // ist wichtig: Ohne die Marke waere das Ergebnis auch ohne Grenze leer, // und der Test bewiese nichts. Die Fuellung komprimiert auf wenige // Kilobyte, die Grenze je Strom liegt bei 8 MB - zlib bricht ab, der // Strom wird verworfen, und die Marke erscheint eben nicht. const bombe = deflateSync( Buffer.concat([inhaltsstrom('BOMBE-MARKE'), Buffer.alloc(9 * 1024 * 1024, 0x20)]), ) const pdf = pdfMitStrom(bombe) // Kein Absturz, kein Aufblaehen: einfach kein Text. expect(pdfTextLesen(pdf)).toBe('') }) it('liest denselben Strom, wenn er unter der Grenze bleibt', () => { // Gegenprobe zum Test darueber: Dieselbe Bauform, nur 4 MB Fuellung. // Damit ist belegt, dass oben die Grenze greift und nicht die Bauform // des Testfalls. const knappDrunter = deflateSync( Buffer.concat([inhaltsstrom('BOMBE-MARKE'), Buffer.alloc(4 * 1024 * 1024, 0x20)]), ) expect(pdfTextLesen(pdfMitStrom(knappDrunter))).toContain('BOMBE-MARKE') }) it('bleibt handhabbar, wenn viele mittelgrosse Stroeme zusammenkommen', () => { // Zwoelf Stroeme mit je 6 MB Nutzlast liegen einzeln unter der // Stromgrenze, zusammen aber ueber der Gesamtgrenze von 64 MB. Ohne den // zweiten Deckel waeren das 72 MB im Speicher, und zwar je Dokument. const einer = deflateSync(Buffer.concat([inhaltsstrom('Seite'), Buffer.alloc(6 * 1024 * 1024, 0x20)])) const teile: Buffer[] = [Buffer.from('%PDF-1.4\n', 'latin1')] for (let i = 0; i < 12; i += 1) { teile.push( Buffer.from(`${i + 1} 0 obj\n<< /Length ${einer.length} /Filter /FlateDecode >>\nstream\n`, 'latin1'), einer, Buffer.from('\nendstream\nendobj\n', 'latin1'), ) } teile.push(Buffer.from('%%EOF\n', 'latin1')) const text = pdfTextLesen(Buffer.concat(teile)) // Etwas wird gelesen - aber nicht alle zwoelf Stroeme. Waere der // Gesamtdeckel weg, stuende "Seite" zwoelfmal darin. const treffer = text.split('Seite').length - 1 expect(treffer).toBeGreaterThan(0) expect(treffer).toBeLessThan(12) }) it('kommt mit einem kaputten Strom zurecht, ohne zu werfen', () => { const pdf = pdfMitStrom(Buffer.from('das ist kein zlib-strom', 'latin1')) expect(() => pdfTextLesen(pdf)).not.toThrow() }) it('ignoriert Bilddaten, statt sie auszupacken zu versuchen', () => { const pdf = pdfMitStrom(deflateSync(inhaltsstrom('unsichtbar')), '/DCTDecode') expect(pdfTextLesen(pdf)).toBe('') }) })