feat(okr): path-confined bundle-traversering for arkivklar
This commit is contained in:
parent
892acf1d87
commit
582fae3212
2 changed files with 307 additions and 0 deletions
115
lib/arkivklar.mjs
Normal file
115
lib/arkivklar.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// arkivklar.mjs
|
||||
// D7 steg 17: traverser en OKF-bundle og klassifiser innholdet mot
|
||||
// bevaringsforskrifta (FOR-2025-12-19-2729). REN modul -- den leser filer den
|
||||
// faar rota til, men tar ingen beslutning og skriver ingenting.
|
||||
//
|
||||
// Disklesing hoerer til scripts/, ikke lib/ (D6-beslutning 1). Denne modulen er
|
||||
// grensetilfellet: den MAA lese for aa klassifisere, men den bestemmer ikke hvor
|
||||
// rota ligger og formaterer ingen rapport. Den tar rota som argument, akkurat som
|
||||
// arsrapportDelIII tar {historikk} som data.
|
||||
//
|
||||
// PATH-CONFINEMENT SOM LESEGRENSE (NFR med navngitt suksesskriterium):
|
||||
// hvert lesemaal realpath-bekreftes under bundle-rota, via den DELTE regelen i
|
||||
// lib/path-confinement.mjs -- samme kode som ingestion bruker paa skrivesiden.
|
||||
// Tolags fordi de to lagene fanger ulike former; se path-confinement.mjs.
|
||||
//
|
||||
// MODULEN RETURNERER VURDERINGSGRUNNLAG, ALDRI EN AVGJOERELSE. Den sier hvilken
|
||||
// kategori noe TROLIG faller under, aldri hva som skal skje med det. Kassasjon
|
||||
// krever Nasjonalarkivets hjemmel (arkivlova § 13) etter en vurdering loven
|
||||
// legger til virksomhetens dokumentasjonsplan (arkivlova § 8, arkivforskrifta
|
||||
// § 12 b) -- ingen av delene kan avledes fra en filtype.
|
||||
|
||||
import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { parseFrontmatter } from './frontmatter.mjs';
|
||||
import { resolveUnderBundle, assertRealUnderBundle } from './path-confinement.mjs';
|
||||
|
||||
const HVEM = 'arkivklar';
|
||||
|
||||
// Bevaringsforskrifta § 7 (statleg sektor) navngir «tildelingsbrev, rapportar,
|
||||
// etatsstyringsmoeter og evalueringa»; § 30 (kommunal sektor) navngir
|
||||
// «handlingsprogram, tertialrapportering, aarsmelding».
|
||||
// https://lovdata.no/dokument/SF/forskrift/2025-12-19-2729
|
||||
//
|
||||
// Laast arkitektur-beslutning 8: linjene er PARALLELLE, ikke alternative. En type
|
||||
// kan treffe en kategori i begge, og da rapporteres begge -- modulen velger ikke
|
||||
// styringslinje paa virksomhetens vegne.
|
||||
const KATEGORI_BY_TYPE = {
|
||||
Tildelingsbrev: { stat: 'tildelingsbrev', kommune: null },
|
||||
Virksomhetsplan: { stat: null, kommune: 'handlingsprogram' },
|
||||
Status: { stat: 'rapportar', kommune: 'tertialrapportering' },
|
||||
Retrospektiv: { stat: 'evalueringa', kommune: 'aarsmelding' },
|
||||
};
|
||||
|
||||
export const PARAGRAF = {
|
||||
stat: '§ 7',
|
||||
kommune: '§ 30',
|
||||
sikring: '§ 3',
|
||||
};
|
||||
|
||||
// Klassifiser en OKF-type mot begge styringslinjer.
|
||||
//
|
||||
// Sikringsparagrafen (§ 3) lukker resten: «All dokumentasjon som gjeld nye
|
||||
// oppgaaver [...] skal takast vare paa inntil Nasjonalarkivet har fastsett
|
||||
// reglar», og det samme for oppgaver som «av andre aarsaker ikkje er nemnde i
|
||||
// forskrifta». Arkivfaglig oppsummert: det som ikke er nevnt, skal bevares.
|
||||
// Ukjent type betyr derfor ALDRI «kan slettes» -- den er den mest forsiktige
|
||||
// kategorien, ikke den minst.
|
||||
export function klassifiser(type) {
|
||||
const treff = KATEGORI_BY_TYPE[type];
|
||||
if (!treff) return { stat: null, kommune: null, sikring: true };
|
||||
return { stat: treff.stat, kommune: treff.kommune, sikring: false };
|
||||
}
|
||||
|
||||
// Resolver et bundle-relativt lesemaal og bekreft at det faktisk ligger under
|
||||
// rota. Kaster ved confinement-brudd; lar ENOENT boble uendret (de to feilene
|
||||
// krever ulik handling hos kalleren -- se (17d)).
|
||||
export function lesemaal(bundleRoot, rel) {
|
||||
const resolvedBundle = path.resolve(bundleRoot);
|
||||
const realBundle = realpathSync(resolvedBundle);
|
||||
const maal = resolveUnderBundle(resolvedBundle, rel, HVEM);
|
||||
return assertRealUnderBundle(realBundle, maal, `lesemaal ${rel}`, HVEM);
|
||||
}
|
||||
|
||||
// index.md er navigasjon (OKF-indeksformatet), ikke et konsept. Den skal ikke
|
||||
// klassifiseres -- ellers ville hver katalog produsert en falsk «Dokument»-rad.
|
||||
const ER_INDEKS = (navn) => navn === 'index.md';
|
||||
|
||||
function lesType(absolutt) {
|
||||
const { get } = parseFrontmatter(readFileSync(absolutt, 'utf8'));
|
||||
return get('type') ?? null;
|
||||
}
|
||||
|
||||
// Traverser bundlen og returner vurderingsgrunnlag per konseptfil.
|
||||
//
|
||||
// readdirSync sorteres eksplisitt: traverseringsrekkefoelgen er en del av
|
||||
// kontrakten, siden rapporten nedstroems skal vaere byte-identisk mellom to
|
||||
// kjoeringer (samme determinisme-krav som historikk-lesingen i D6).
|
||||
export function traverserBundle(bundleRoot) {
|
||||
const resolvedBundle = path.resolve(bundleRoot);
|
||||
const realBundle = realpathSync(resolvedBundle);
|
||||
const filer = [];
|
||||
|
||||
function gaa(relDir) {
|
||||
const absDir = relDir === '' ? resolvedBundle : resolveUnderBundle(resolvedBundle, relDir, HVEM);
|
||||
assertRealUnderBundle(realBundle, absDir, `katalog ${relDir || '.'}`, HVEM);
|
||||
|
||||
for (const entry of readdirSync(absDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const rel = relDir === '' ? entry.name : `${relDir}/${entry.name}`;
|
||||
// Symlenker foelges ikke blindt: lesemaal realpath-bekrefter hver node,
|
||||
// saa en symlenket katalog som peker ut stopper traverseringen.
|
||||
const abs = lesemaal(resolvedBundle, rel);
|
||||
if (entry.isDirectory() || (entry.isSymbolicLink() && statSync(abs).isDirectory())) {
|
||||
gaa(rel);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith('.md') || ER_INDEKS(entry.name)) continue;
|
||||
const type = lesType(abs);
|
||||
filer.push({ rel, type, ...klassifiser(type) });
|
||||
}
|
||||
}
|
||||
|
||||
gaa('');
|
||||
return { rot: realBundle, filer };
|
||||
}
|
||||
192
tests/arkivklar.test.mjs
Normal file
192
tests/arkivklar.test.mjs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue