import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import AdmZip from 'adm-zip'; import { describe, expect, it } from 'vitest'; import { expandZip, isZip, ZIP_LIMITS } from './zip-expand'; const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); function zipOf(entries: Record): Buffer { const zip = new AdmZip(); for (const [name, content] of Object.entries(entries)) { zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content)); } return zip.toBuffer(); } function vendorZip(): Buffer { const inner = zipOf({ 'x.txt': 'innen' }); const zip = new AdmZip(); zip.addFile('ServerCertificate.crt', fx('rsa-leaf.pem')); zip.addFile('Intermediate/CA.crt', fx('rsa-inter.pem')); zip.addFile('__MACOSX/._ServerCertificate.crt', Buffer.from('mac')); zip.addFile('.DS_Store', Buffer.from('ds')); zip.addFile('Thumbs.db', Buffer.from('thumbs')); zip.addFile('Intermediate/', Buffer.alloc(0)); zip.addFile('readme.txt', Buffer.from('Bitte lesen')); zip.addFile('inner.zip', inner); return zip.toBuffer(); } describe('isZip', () => { it('erkennt ZIP an den Anfangsbytes, nicht am Namen', () => { expect(isZip(vendorZip())).toBe(true); expect(isZip(Buffer.from('PK\x05\x06rest', 'binary'))).toBe(true); expect(isZip(fx('rsa-leaf.pem'))).toBe(false); expect(isZip(Buffer.alloc(0))).toBe(false); }); }); describe('expandZip: Hersteller-ZIP', () => { const result = expandZip(vendorZip(), 'vendor.zip', 3); it('liefert die Zertifikate und die Textdatei mit Pfad "zip/eintrag"', () => { expect(result.blobs.map((b) => b.path).sort()).toEqual([ 'vendor.zip/Intermediate/CA.crt', 'vendor.zip/ServerCertificate.crt', 'vendor.zip/readme.txt', ]); const server = result.blobs.find((b) => b.path === 'vendor.zip/ServerCertificate.crt'); expect(server?.buffer.equals(fx('rsa-leaf.pem'))).toBe(true); }); it('Muell (MACOSX, Punktdateien, Thumbs.db, Ordner) erzeugt nichts', () => { const all = JSON.stringify(result); expect(all).not.toContain('MACOSX'); expect(all).not.toContain('DS_Store'); expect(all).not.toContain('Thumbs'); }); it('ein ZIP im ZIP wird mit Grund gemeldet und nicht geoeffnet', () => { expect(result.ignored).toEqual([ { file: 3, path: 'vendor.zip/inner.zip', reason: 'nestedZip' }, ]); expect(result.blobs.some((b) => b.path.endsWith('inner.zip'))).toBe(false); }); it('dasselbe ZIP wird auch unter anderem Namen geoeffnet (Anfangsbytes)', () => { const renamed = expandZip(vendorZip(), 'bundle.dat', 0); expect(renamed.blobs).toHaveLength(3); expect(renamed.blobs[0].path.startsWith('bundle.dat/')).toBe(true); }); }); describe('expandZip: Grenzen und Fehler', () => { it('Zufallsbytes mit ZIP-Anfang ergeben brokenZip', () => { const junk = Buffer.concat([Buffer.from('PK\x03\x04', 'binary'), Buffer.alloc(200, 7)]); const r = expandZip(junk, 'kaputt.zip', 1); expect(r.blobs).toEqual([]); expect(r.ignored).toEqual([{ file: 1, path: 'kaputt.zip', reason: 'brokenZip' }]); }); it('ein verschluesselter Eintrag wird mit Grund gemeldet', () => { const r = expandZip(fx('encrypted-entry.zip'), 'enc.zip', 0); expect(r.blobs).toEqual([]); expect(r.ignored).toEqual([{ file: 0, path: 'enc.zip/rsa-leaf.pem', reason: 'encryptedZip' }]); }); it('mehr Eintraege als erlaubt: ein tooManyEntries fuer das ganze ZIP, keine Teile', () => { const zip = zipOf({ 'a.pem': 'a', 'b.pem': 'b', 'c.pem': 'c' }); const r = expandZip(zip, 'viele.zip', 2, { ...ZIP_LIMITS, maxEntries: 2 }); expect(r.blobs).toEqual([]); expect(r.ignored).toEqual([{ file: 2, path: 'viele.zip', reason: 'tooManyEntries' }]); }); it('Muell zaehlt nicht zu den erlaubten Eintraegen', () => { const zip = zipOf({ 'a.pem': 'a', '.hidden': 'x', '__MACOSX/b': 'y', 'Thumbs.db': 'z' }); const r = expandZip(zip, 'z.zip', 0, { ...ZIP_LIMITS, maxEntries: 1 }); expect(r.blobs).toHaveLength(1); expect(r.ignored).toEqual([]); }); it('ein zu grosser Eintrag wird mit tooLarge uebersprungen, der Rest bleibt', () => { const zip = zipOf({ 'gross.pem': Buffer.from('ab'.repeat(400)), 'klein.pem': 'k' }); const r = expandZip(zip, 'g.zip', 0, { ...ZIP_LIMITS, maxEntryBytes: 500 }); expect(r.ignored).toEqual([{ file: 0, path: 'g.zip/gross.pem', reason: 'tooLarge' }]); expect(r.blobs.map((b) => b.path)).toEqual(['g.zip/klein.pem']); }); it('600 kB Nullbytes (Verhaeltnis ueber 100) ergeben suspicious', () => { const zip = zipOf({ 'null.bin': Buffer.alloc(600 * 1024) }); const r = expandZip(zip, 'bombe.zip', 0); expect(r.blobs).toEqual([]); expect(r.ignored).toEqual([{ file: 0, path: 'bombe.zip/null.bin', reason: 'suspicious' }]); }); it('behaltene Eintraege ueber der Gesamtgrenze: ein zipTooLarge, keine Teile', () => { const zip = zipOf({ 'a.pem': Buffer.from('1234567890'), 'b.pem': Buffer.from('1234567890') }); const r = expandZip(zip, 'summe.zip', 4, { ...ZIP_LIMITS, maxTotalBytes: 15 }); expect(r.blobs).toEqual([]); expect(r.ignored).toEqual([{ file: 4, path: 'summe.zip', reason: 'zipTooLarge' }]); }); it('prueft die Grenzen vor dem Entpacken (kein Entpacken bei zipTooLarge)', () => { const zip = zipOf({ 'a.pem': Buffer.from('1234567890'), 'b.pem': Buffer.from('1234567890') }); const original = AdmZip.prototype.getEntries; let inflated = 0; AdmZip.prototype.getEntries = function patched(this: AdmZip) { const entries = original.call(this); for (const e of entries) { const get = e.getData.bind(e); e.getData = () => { inflated++; return get(); }; } return entries; }; try { expandZip(zip, 's.zip', 0, { ...ZIP_LIMITS, maxTotalBytes: 15 }); } finally { AdmZip.prototype.getEntries = original; } expect(inflated).toBe(0); }); it('Anzeigepfad ohne Steuerzeichen', () => { const zip = zipOf({ 'a\u0001b.pem': 'x' }); const r = expandZip(zip, 'c.zip', 0); expect(r.blobs[0].path).toBe('c.zip/ab.pem'); }); it('leeres ZIP ergibt keine Teile und keinen Fehler', () => { const r = expandZip(new AdmZip().toBuffer(), 'leer.zip', 0); expect(r.blobs).toEqual([]); }); });