Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Each release of Research-Kit, newest first. The version is `KIT_VERSION` in

## Unreleased

- Invalid `--snapshot-max-age-ms` values are usage errors with exit 2. Library callers
receive `INPUT-SNAPSHOT-MAX-AGE` / `INCOMPLETE` rather than a false stale-snapshot
verdict; finite non-negative allowances, including zero, retain their meaning (#343).

- ZIP archives declare UTF-8 names in both headers, so ordinary extractors preserve non-ASCII paths declared by the package manifest (#345).

- Machine config saves preserve unpatched raw role and posture values, including typos
Expand Down
6 changes: 5 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,11 @@ environment variable at all**, so it cannot depend on a credential or a config.
maps to exit code — `PASS` 0, `FAIL`/`REOPEN` 1, `INCOMPLETE` 2, `BLOCKED` 3, unknown 4 —
and a **usage error exits 2 from every subcommand**, printing the usage block to stderr;
its argument parser names a value flag without a value before resolving any path,
including when the next token is another flag;
including when the next token is another flag. `validate` refuses blank, non-finite or
negative `--snapshot-max-age-ms` values as usage errors before judging evidence (#343).
The `validateRelease` and `validateSnapshotEvidence` library boundaries report an invalid
age allowance as `INPUT-SNAPSHOT-MAX-AGE` / `INCOMPLETE`; `SNAPSHOT-STALE` is reserved for
an actual snapshot older than a valid allowance;
`test/support-policy.test.mjs` pins that by running the CLI. It writes exactly one thing,
only when asked: `fi-validate --report <file>`),
`selftest.mjs` (every requested group must match a test file before any test starts; an unmatched group is named and refused with exit 2, including beside a valid group; #332. CLI regressions isolate child report paths from the parent suite; the whole suite, queued by the harness — which **awaits** every test,
Expand Down
2 changes: 1 addition & 1 deletion research-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ node research-kit/bin/selftest.mjs # all of it
node research-kit/bin/selftest.mjs gate hook # just these files
```

1845 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
1847 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
the assertions settled (ADR-0021), and each test is raced against a watchdog
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).

Expand Down
8 changes: 7 additions & 1 deletion research-kit/bin/researcher-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ function main(argv = process.argv.slice(2)) {
if (parsed.error) { console.error(`${parsed.error}\n${usage()}`); return 2; }
let result;
if (parsed.command === 'validate') {
const snapshotMaxAgeMs = parsed.snapshotmaxagems === undefined ? undefined : Number(parsed.snapshotmaxagems);
if (parsed.snapshotmaxagems !== undefined
&& (!parsed.snapshotmaxagems.trim() || !Number.isFinite(snapshotMaxAgeMs) || snapshotMaxAgeMs < 0)) {
console.error(`--snapshot-max-age-ms must be a finite non-negative number\n${usage()}`);
return 2;
}
if (!parsed.root || !parsed.package || !parsed.registry || !parsed.roles || !parsed.pointers) {
console.error(usage());
return 2;
Expand All @@ -78,7 +84,7 @@ function main(argv = process.argv.slice(2)) {
headPath: parsed.head ? path.resolve(parsed.head) : null,
genesisHash: parsed.genesishash ?? '0'.repeat(64),
publicKeys,
snapshotMaxAgeMs: parsed.snapshotmaxagems === undefined ? undefined : Number(parsed.snapshotmaxagems),
snapshotMaxAgeMs,
});
} else if (parsed.command === 'conform') {
if (!parsed.schema) { console.error(usage()); return 2; }
Expand Down
10 changes: 9 additions & 1 deletion research-kit/lib/release-validator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,10 @@ function snapshotRefValue(ref) {
*/
export function validateSnapshotEvidence(record, { root = null, now = Date.now(), maxAgeMs = SNAPSHOT_MAX_AGE_MS } = {}) {
const errors = [];
if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) {
add(errors, 'INPUT-SNAPSHOT-MAX-AGE', 'snapshot age allowance must be a finite non-negative number', '--snapshot-max-age-ms', 'INCOMPLETE');
return { status: resultStatus(errors), errors };
}
const meta = metaOf(record);
const refs = snapshotRefs(record);
if (!refs || typeof refs !== 'object' || Array.isArray(refs)) {
Expand All @@ -719,7 +723,7 @@ export function validateSnapshotEvidence(record, { root = null, now = Date.now()
const capturedMs = typeof capturedAt === 'string' && UTC.test(capturedAt) ? Date.parse(capturedAt) : NaN;
if (!Number.isFinite(capturedMs)) add(errors, 'SNAPSHOT-TIME', `${phase} capturedAt must be UTC ISO-8601 with Z`, `${at}.capturedAt`, 'INCOMPLETE');
else if (capturedMs > now) add(errors, 'SNAPSHOT-FUTURE', `${phase} snapshot is dated in the future`, `${at}.capturedAt`);
else if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0 || now - capturedMs > maxAgeMs) add(errors, 'SNAPSHOT-STALE', `${phase} path-authority snapshot is older than the allowed ${maxAgeMs}ms`, `${at}.capturedAt`);
else if (now - capturedMs > maxAgeMs) add(errors, 'SNAPSHOT-STALE', `${phase} path-authority snapshot is older than the allowed ${maxAgeMs}ms`, `${at}.capturedAt`);

const fileName = ref.path ?? ref.file ?? ref.evidencePath;
if (fileName === undefined) {
Expand Down Expand Up @@ -987,6 +991,10 @@ export function validateRelease({ root, package: packageName, registryPath, role
recoveryReceiptIds: [],
errors,
};
if (!Number.isFinite(snapshotMaxAgeMs) || snapshotMaxAgeMs < 0) {
add(errors, 'INPUT-SNAPSHOT-MAX-AGE', 'snapshot age allowance must be a finite non-negative number', '--snapshot-max-age-ms', 'INCOMPLETE');
return output;
}
if (!root || !fs.existsSync(root) || !inside(process.cwd(), root) && !path.isAbsolute(root)) add(errors, 'INPUT-ROOT', 'root must name an existing directory', '--root', 'INCOMPLETE');
if (!PACKAGES.has(packageName)) add(errors, 'INPUT-PACKAGE', 'package must be R28, R29, R30, R31, or R32', '--package', 'INCOMPLETE');
const registryLoaded = readJson(registryPath);
Expand Down
66 changes: 66 additions & 0 deletions research-kit/test/release-validator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,72 @@ test('researcher-release names a missing value before resolving paths', () => {
}
});

test('researcher-release refuses invalid snapshot ages as usage before judging a valid package', async () => {
const root = tempRoot();
try {
const registryPath = path.join(root, 'registry.json');
const rolesPath = path.join(root, 'roles.json');
const pointers = path.join(root, 'pointers');
const record = envelope();
record.payloadSha256 = await sha256(canonical(record.payload));
writeJson(path.join(root, 'records', 'R28-01.json'), record);
writeJson(registryPath, registryFor());
writeJson(rolesPath, roster());
fs.mkdirSync(pointers);
const args = ['research-kit/bin/researcher-release.mjs', 'validate', '--root', root,
'--package', 'R28', '--registry', registryPath, '--roles', rolesPath, '--pointers', pointers, '--json'];
for (const value of ['abc', '', ' ', 'NaN', 'Infinity', '1e309', '-1']) {
const run = spawnSync(process.execPath, [...args, '--snapshot-max-age-ms', value], { encoding: 'utf8' });
assertEqual(run.status, 2, run.stdout || run.stderr);
assert(run.stderr.includes('--snapshot-max-age-ms'), run.stderr);
assert(run.stderr.includes('Usage:'), run.stderr);
assertEqual(run.stdout, '', 'usage must not produce an evidence verdict');
assert(!/SNAPSHOT-STALE|allowed .*ms/.test(run.stderr), run.stderr);
}
for (const value of ['0', '1', '1.5', '1e3']) {
const run = spawnSync(process.execPath, [...args, '--snapshot-max-age-ms', value], { encoding: 'utf8' });
assertEqual(run.status, 0, run.stdout || run.stderr);
assertEqual(JSON.parse(run.stdout).status, 'PASS');
}
} finally { cleanup(root); }
});

test('release and snapshot facades distinguish an invalid age allowance from stale evidence', async () => {
const { validateRelease, validateSnapshotEvidence } = await import('../lib/release-validator.mjs');
const root = tempRoot();
try {
const registryPath = path.join(root, 'registry.json');
const rolesPath = path.join(root, 'roles.json');
writeJson(registryPath, registryFor());
writeJson(rolesPath, roster());
const now = Date.parse('2026-01-01T00:00:00Z');
const baseRecord = envelope();
baseRecord.payloadSha256 = await sha256(canonical(baseRecord.payload));
writeJson(path.join(root, 'records', 'R28-01.json'), baseRecord);
const baseline = validateRelease({ root, package: 'R28', registryPath, rolesPath });
assertEqual(baseline.status, 'PASS', JSON.stringify(baseline));
const refs = Object.fromEntries(['before', 'after', 'recovery'].map((phase) => [phase, {
snapshotId: phase, capturedAt: '2026-01-01T00:00:00Z', status: 'PASS', envelopeCanonicalSha256: 'c'.repeat(64),
}]));
const record = envelope({ payload: { pathAuthoritySnapshots: refs } });
for (const value of [NaN, Infinity, -Infinity, -1, null, '0']) {
const results = [
validateRelease({ root, package: 'R28', registryPath, rolesPath, snapshotMaxAgeMs: value }),
validateSnapshotEvidence(record, { root, now, maxAgeMs: value }),
];
for (const result of results) {
assertEqual(result.status, 'INCOMPLETE', JSON.stringify(result));
assert(result.errors.some((error) => error.code === 'INPUT-SNAPSHOT-MAX-AGE'), JSON.stringify(result));
assert(!result.errors.some((error) => error.code === 'SNAPSHOT-STALE'), JSON.stringify(result));
}
}
const fresh = validateSnapshotEvidence(record, { root, now, maxAgeMs: 0 });
assert(!fresh.errors.some((error) => error.code === 'INPUT-SNAPSHOT-MAX-AGE' || error.code === 'SNAPSHOT-STALE'), JSON.stringify(fresh));
const stale = validateSnapshotEvidence(record, { root, now: now + 1, maxAgeMs: 0 });
assertEqual(stale.errors.filter((error) => error.code === 'SNAPSHOT-STALE').length, 3, JSON.stringify(stale));
} finally { cleanup(root); }
});

test('researcher-release conform validates explicit files and maps schema failure to exit 1', async () => {
const root = tempRoot();
try {
Expand Down
Loading