import { mkdtemp, mkdir, writeFile, symlink, readFile, lstat, } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { ersetzeUrlPlatzhalter, sichererDateiname, uebernehmeRegulaereDatei, } from './pfad-sicherheit.util'; describe('sichererDateiname', () => { it('lässt normale Namen unverändert', () => { expect(sichererDateiname('2026-0042_Rechnung Müller.pdf')).toBe( '2026-0042_Rechnung Müller.pdf', ); }); it.each([ ['../../etc/cron.d/x.pdf', '.._.._etc_cron.d_x.pdf'], ['a/b\\c.pdf', 'a_b_c.pdf'], ['..', '_'], ['.', '_'], ['', '_'], ['x\u0000y\r\n.pdf', 'x_y__.pdf'], ])('macht %p zu einem einzelnen Pfadsegment', (eingabe, erwartet) => { expect(sichererDateiname(eingabe)).toBe(erwartet); }); }); describe('ersetzeUrlPlatzhalter', () => { it('setzt Werte URL-kodiert ein', () => { expect( ersetzeUrlPlatzhalter('https://beleg.example/{Jahr}/set/{Nummer}', { Jahr: '2026', Nummer: '42', }), ).toBe('https://beleg.example/2026/set/42'); }); it('verhindert Pfad- und Query-Manipulation', () => { expect( ersetzeUrlPlatzhalter('https://beleg.example/set/{Nummer}?k=1', { Nummer: '../../admin/delete?all=1#', }), ).toBe( 'https://beleg.example/set/..%2F..%2Fadmin%2Fdelete%3Fall%3D1%23?k=1', ); }); it('ersetzt jedes Vorkommen', () => { expect(ersetzeUrlPlatzhalter('/{Nummer}/{Nummer}', { Nummer: 'a b' })).toBe( '/a%20b/a%20b', ); }); }); describe('uebernehmeRegulaereDatei', () => { let wurzel: string; let quelle: string; let ziel: string; beforeEach(async () => { wurzel = await mkdtemp(join(tmpdir(), 'scan-')); await mkdir(join(wurzel, 'scans', 'bob'), { recursive: true }); await mkdir(join(wurzel, 'data')); await writeFile(join(wurzel, 'geheim.txt'), 'GEHEIM'); quelle = join(wurzel, 'scans', 'bob', 'scan.pdf'); ziel = join(wurzel, 'data', 'document.pdf'); }); // Wie in Produktion: /mnt/scans und /mnt/data sind verschiedene Mounts. const crossDevice = () => Promise.reject(Object.assign(new Error('EXDEV'), { code: 'EXDEV' })); it.each([ ['rename', undefined], ['copy (EXDEV)', crossDevice], ])('übernimmt reguläre Dateien per %s', async (_n, rename) => { await writeFile(quelle, '%PDF-1.7'); await uebernehmeRegulaereDatei(quelle, ziel, { rename }); expect(await readFile(ziel, 'utf8')).toBe('%PDF-1.7'); await expect(lstat(quelle)).rejects.toThrow(); }); it.each([ ['rename', undefined], ['copy (EXDEV)', crossDevice], ])('lehnt Symlinks ab (%s)', async (_n, rename) => { await symlink(join(wurzel, 'geheim.txt'), quelle); await expect( uebernehmeRegulaereDatei(quelle, ziel, { rename }), ).rejects.toThrow(/keine reguläre Datei/); await expect(lstat(ziel)).rejects.toThrow(); }); it('lehnt Dateien in symverlinkten Verzeichnissen ab', async () => { await mkdir(join(wurzel, 'fremd')); await writeFile(join(wurzel, 'fremd', 'x.pdf'), 'FREMD'); await symlink(join(wurzel, 'fremd'), join(wurzel, 'scans', 'eve')); await expect( uebernehmeRegulaereDatei(join(wurzel, 'scans', 'eve', 'x.pdf'), ziel, { rename: crossDevice, }), ).rejects.toThrow(/Verzeichnis/); }); it('überschreibt beim Kopieren keine vorhandene Zieldatei', async () => { await writeFile(quelle, 'NEU'); await writeFile(ziel, 'ALT'); await expect( uebernehmeRegulaereDatei(quelle, ziel, { rename: crossDevice }), ).rejects.toThrow(); expect(await readFile(ziel, 'utf8')).toBe('ALT'); }); });