// arkivklar.test.mjs // D7 steg 17: path-confinement som LESEGRENSE + klassifisering mot // bevaringsforskrifta (FOR-2025-12-19-2729) §§ 7 og 30. // // Path-confinement er kravets kjerne, og de tre angrepsformene fanges av ULIKE // lag. Derfor er de tre SEPARATE cases, ikke en felles «avvis ugyldig sti»: // (17a) ../-traversering -> leksikalsk lag // (17b) symlenke ut -> realpath-lag (path.resolve loeser IKKE symlenker) // (17c) soesken-katalog -> path.sep i prefikssjekken (/uploads vs /uploads-other) // En felles case ville bestaatt paa ett bein og skjult at de to andre var borte. // Mutasjons-verifisert per bein -- se sesjonsloggen (D7/S49). // // (17d) skiller ENOENT fra confinement-brudd: realpathSync kaster paa en sti som // ikke finnes, og de to feilene krever ulik handling hos kalleren. Maskeres den // ene som den andre, blir «fila mangler» rapportert som «angrepsforsoek». // // Zero npm deps. Moenster: tests/innboks-write.test.mjs (mkdtemp + symlinkSync). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { lesemaal, klassifiser, traverserBundle } from '../lib/arkivklar.mjs'; const FIXTURE = fileURLToPath(new URL('./fixtures/okf-realistic', import.meta.url)); function withTmp(fn) { const tmp = mkdtempSync(join(tmpdir(), 'arkivklar-')); try { fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); } } // ==================== (17a-c) path-confinement som lesegrense ==================== test('(17a) lesemaal avviser ../-traversering ut av bundle-rota', () => { withTmp((tmp) => { const rot = join(tmp, 'bundle'); mkdirSync(rot, { recursive: true }); writeFileSync(join(tmp, 'hemmelig.md'), '# utenfor\n'); // Assertionen pinner det LEKSIKALSKE laget («maal-sti ... avvist»), ikke // bare «avvist». Med en loesere regex bestod denne casen paa // realpath-lagets melding -- som ogsaa inneholder «utenfor bundle-rot» -- // og det leksikalske laget kunne fjernes uten at noen case roednet // (mutasjons-verifisert: den gjorde nettopp det). assert.throws( () => lesemaal(rot, '../hemmelig.md'), /maal-sti utenfor bundle-rot avvist/, '../-escape skal avvises av det leksikalske laget', ); }); }); test('(17b) lesemaal avviser symlenke som peker UT av bundle-rota', () => { withTmp((tmp) => { const rot = join(tmp, 'bundle'); const utenfor = join(tmp, 'utenfor'); mkdirSync(rot, { recursive: true }); mkdirSync(utenfor, { recursive: true }); writeFileSync(join(utenfor, 'hemmelig.md'), '# utenfor\n'); // Symlenken ligger INNE i bundlen og er leksikalsk uskyldig: den // resolverer under rota. Kun realpath avsloerer at den peker ut. symlinkSync(utenfor, join(rot, 'lenke')); assert.throws( () => lesemaal(rot, 'lenke/hemmelig.md'), /symlink-escape/, 'symlenke ut av bundlen skal avvises av realpath-laget', ); }); }); test('(17c) lesemaal avviser soesken-katalog med samme prefiks', () => { withTmp((tmp) => { // Klassisk prefiks-felle: basen /uploads slipper /uploads-other gjennom // dersom prefikssjekken mangler path.sep. const rot = join(tmp, 'uploads'); const soesken = join(tmp, 'uploads-other'); mkdirSync(rot, { recursive: true }); mkdirSync(soesken, { recursive: true }); writeFileSync(join(soesken, 'secret.txt'), 'hemmelig\n'); // Som (17a): pinner det leksikalske laget, ellers er det path.sep-sjekken // denne casen finnes for som blir utestet. assert.throws( () => lesemaal(rot, '../uploads-other/secret.txt'), /maal-sti utenfor bundle-rot avvist/, 'soesken-katalog med delt prefiks skal avvises av path.sep-sjekken', ); }); }); test('(17d) lesemaal skiller ENOENT fra confinement-brudd', () => { withTmp((tmp) => { const rot = join(tmp, 'bundle'); mkdirSync(rot, { recursive: true }); // En sti som er LOVLIG innenfor rota, men ikke finnes, skal gi ENOENT -- // ikke en confinement-feil. Kalleren maa kunne skille de to. assert.throws( () => lesemaal(rot, 'finnes-ikke.md'), (err) => err.code === 'ENOENT', 'manglende fil innenfor rota skal gi ENOENT, ikke confinement-brudd', ); }); }); // ==================== klassifisering mot §§ 7 og 30 ==================== test('klassifiser: navngitte kategorier i § 7 (stat) og § 30 (kommune)', () => { // Laast arkitektur-beslutning 8: styringslinjene er PARALLELLE. En type kan // treffe en kategori i begge, og da skal BEGGE rapporteres -- modulen velger // ikke linje for virksomheten. const tildeling = klassifiser('Tildelingsbrev'); assert.equal(tildeling.stat, 'tildelingsbrev'); assert.equal(tildeling.kommune, null); assert.equal(tildeling.sikring, false); const status = klassifiser('Status'); assert.equal(status.stat, 'rapportar'); assert.equal(status.kommune, 'tertialrapportering'); const virksomhetsplan = klassifiser('Virksomhetsplan'); assert.equal(virksomhetsplan.stat, null); assert.equal(virksomhetsplan.kommune, 'handlingsprogram'); const retro = klassifiser('Retrospektiv'); assert.equal(retro.stat, 'evalueringa'); assert.equal(retro.kommune, 'aarsmelding'); }); test('klassifiser: ikke-navngitt type faller til sikringsparagrafen § 3', () => { // Bevaringsforskrifta § 3: det som ikke er nevnt, skal bevares inntil // Nasjonalarkivet har fastsett reglar. Ukjent er derfor ALDRI «kan slettes». for (const type of ['OKR', 'Overordnede OKR', 'Notat', 'Dokument', 'Heltukjent']) { const k = klassifiser(type); assert.equal(k.sikring, true, `${type} skal falle til § 3`); assert.equal(k.stat, null, `${type} skal ikke paastaas navngitt i § 7`); assert.equal(k.kommune, null, `${type} skal ikke paastaas navngitt i § 30`); } }); // ==================== traversering av en ekte bundle ==================== test('traverserBundle: klassifiserer fixturens filer og er deterministisk', () => { const a = traverserBundle(FIXTURE); const b = traverserBundle(FIXTURE); assert.deepEqual(a, b, 'to kjoeringer skal gi identisk resultat (sortert traversering)'); assert.ok(a.filer.length >= 8, `forventet >= 8 konseptfiler, fant ${a.filer.length}`); // index.md er navigasjon, ikke konsept -- den skal ikke klassifiseres. assert.equal( a.filer.filter((f) => f.rel.endsWith('index.md')).length, 0, 'index.md skal holdes utenfor klassifiseringen', ); const tildeling = a.filer.find((f) => f.rel === 'strategisk-kontekst/tildelingsbrev-2026.md'); assert.ok(tildeling, 'fixturens tildelingsbrev skal finnes'); assert.equal(tildeling.type, 'Tildelingsbrev'); assert.equal(tildeling.stat, 'tildelingsbrev'); const retro = a.filer.find((f) => f.rel === 'historikk/retrospektiv-T3-2025.md'); assert.ok(retro, 'fixturens retrospektiv skal finnes'); assert.equal(retro.kommune, 'aarsmelding'); }); test('traverserBundle: symlenket underkatalog som peker ut avvises', () => { withTmp((tmp) => { const rot = join(tmp, 'bundle'); const utenfor = join(tmp, 'utenfor'); mkdirSync(join(rot, 'dokumenter'), { recursive: true }); mkdirSync(utenfor, { recursive: true }); writeFileSync(join(utenfor, 'hemmelig.md'), '---\ntype: Notat\n---\n\n# ute\n'); writeFileSync(join(rot, 'dokumenter', 'ok.md'), '---\ntype: Notat\n---\n\n# inne\n'); symlinkSync(utenfor, join(rot, 'lekkasje')); assert.throws( () => traverserBundle(rot), /symlink-escape/, 'traverseringen skal stoppe paa en symlenket katalog som peker ut', ); }); });