mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-12 14:47:58 +00:00
234 lines
8 KiB
JavaScript
234 lines
8 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { readFile, realpath } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileDigest } from '../native-ui/contract.mjs';
|
|
import {
|
|
maintainedAssets,
|
|
taskModeAssets,
|
|
focusedBoardAssets,
|
|
verifyMediaEvidence,
|
|
} from './verify.mjs';
|
|
|
|
export const publicationSchema = 'documentation-media-publication/v1';
|
|
const sha = (value) => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
|
|
const digest = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
|
|
function git(root, ...args) {
|
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
assert.equal(result.status, 0, `Cannot verify publication history: ${result.stderr.trim()}`);
|
|
return result.stdout;
|
|
}
|
|
|
|
function publicationPath(file, version) {
|
|
return (
|
|
file === 'README.md' ||
|
|
/^docs\/(?:[^/]+\/)*[^/]+\.md$/.test(file) ||
|
|
file === 'docs/index.json' ||
|
|
file === 'docs/index.html' ||
|
|
file === 'docs/demo/index.html' ||
|
|
['assets/demo-overview.mp4', 'docs/assets/demo-overview.mp4'].includes(file) ||
|
|
[...maintainedAssets, ...taskModeAssets, ...focusedBoardAssets].some(
|
|
(name) => file === `docs/assets/v${version}/${name}`
|
|
)
|
|
);
|
|
}
|
|
|
|
function verifyCommittedFile(root, commit, file, expectedDigest) {
|
|
const tree = git(root, 'ls-tree', commit, '--', file).trim();
|
|
assert(tree.startsWith('100644 blob '), `${file}: media is not a committed regular file`);
|
|
const blob = spawnSync('git', ['cat-file', 'blob', `${commit}:${file}`], {
|
|
cwd: root,
|
|
maxBuffer: 256 * 1024 * 1024,
|
|
});
|
|
assert.equal(blob.status, 0, `${file}: cannot read committed media`);
|
|
assert.equal(
|
|
createHash('sha256').update(blob.stdout).digest('hex'),
|
|
expectedDigest,
|
|
`${file}: committed media differs from the original capture`
|
|
);
|
|
}
|
|
|
|
// A later documentation commit must not broaden which application build passed.
|
|
// Inspect Git objects, not a producer-supplied changed-files list.
|
|
export function verifyPublicationHistory({ root, buildCommit, publicationCommit, version }) {
|
|
assert(sha(buildCommit) && sha(publicationCommit), 'Invalid build/publication commit');
|
|
assert.equal(
|
|
git(root, 'rev-parse', 'HEAD').trim(),
|
|
publicationCommit,
|
|
'Stale publication commit'
|
|
);
|
|
assert.equal(git(root, 'status', '--porcelain').trim(), '', 'Publication checkout must be clean');
|
|
git(root, 'merge-base', '--is-ancestor', buildCommit, publicationCommit);
|
|
const commits = git(root, 'rev-list', `${buildCommit}..${publicationCommit}`)
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean);
|
|
for (const commit of commits) {
|
|
const records = git(
|
|
root,
|
|
'diff-tree',
|
|
'-m',
|
|
'--root',
|
|
'--no-commit-id',
|
|
'-r',
|
|
'--raw',
|
|
'--no-abbrev',
|
|
'--no-renames',
|
|
'--no-ext-diff',
|
|
'-z',
|
|
commit,
|
|
'--'
|
|
)
|
|
.split('\0')
|
|
.filter(Boolean);
|
|
for (let index = 0; index < records.length; index += 2) {
|
|
const [oldMode, newMode] = records[index].slice(1).split(' ');
|
|
const file = records[index + 1];
|
|
assert(
|
|
publicationPath(file, version) &&
|
|
['000000', '100644'].includes(oldMode) &&
|
|
['000000', '100644'].includes(newMode),
|
|
`Application or unsupported publication change requires a new build/capture: ${file}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sibling(evidencePath, name) {
|
|
assert(
|
|
typeof name === 'string' && name !== '.' && name !== '..' && path.basename(name) === name,
|
|
'Invalid capture artifact filename'
|
|
);
|
|
const directory = await realpath(path.dirname(evidencePath));
|
|
const intended = path.join(directory, name);
|
|
assert.equal(await realpath(intended), intended, 'Capture artifact traverses a symlink');
|
|
return intended;
|
|
}
|
|
|
|
export async function loadMediaPublication({ evidencePath, root, publicationCommit, publication }) {
|
|
publication ??= JSON.parse(await readFile(evidencePath, 'utf8'));
|
|
assert.equal(publication.schema, publicationSchema, 'Missing documentation publication manifest');
|
|
assert.equal(publication.status, 'verified', 'Documentation publication was not verified');
|
|
assert.equal(publication.publicationCommit, publicationCommit, 'Stale publication commit');
|
|
assert(digest(publication.captureManifest?.sha256), 'Missing original capture manifest digest');
|
|
const capturePath = await sibling(evidencePath, publication.captureManifest.path);
|
|
assert.equal(
|
|
await fileDigest(capturePath),
|
|
publication.captureManifest.sha256,
|
|
'Original capture manifest changed'
|
|
);
|
|
const capture = JSON.parse(await readFile(capturePath, 'utf8'));
|
|
const verified = Date.parse(publication.verifiedAt);
|
|
const completed = Date.parse(capture.completedAt);
|
|
assert(
|
|
Number.isFinite(verified) &&
|
|
Number.isFinite(completed) &&
|
|
verified >= completed &&
|
|
verified <= Date.now(),
|
|
'Invalid publication verification time'
|
|
);
|
|
assert.equal(
|
|
capture.mode,
|
|
'capture',
|
|
'Publication requires an original capture, not a preparation run'
|
|
);
|
|
assert.equal(capture.dirty, false, 'Capture checkout was dirty');
|
|
verifyPublicationHistory({
|
|
root,
|
|
buildCommit: capture.commit,
|
|
publicationCommit,
|
|
version: capture.version,
|
|
});
|
|
return { publication, capture, capturePath };
|
|
}
|
|
|
|
export async function verifyPublishedMedia({
|
|
evidencePath,
|
|
root,
|
|
expected,
|
|
maintainedContents,
|
|
publication,
|
|
}) {
|
|
try {
|
|
const { capture, capturePath } = await loadMediaPublication({
|
|
evidencePath,
|
|
root,
|
|
publicationCommit: expected.publicationCommit,
|
|
publication,
|
|
});
|
|
const errors = await verifyMediaEvidence({
|
|
evidencePath: capturePath,
|
|
root,
|
|
expected,
|
|
maintainedContents,
|
|
});
|
|
if (errors.length) return errors;
|
|
const committedTaskModes = taskModeAssets.filter((name) =>
|
|
git(
|
|
root,
|
|
'ls-tree',
|
|
expected.publicationCommit,
|
|
'--',
|
|
`docs/assets/v${expected.version}/${name}`
|
|
).trim()
|
|
);
|
|
if (committedTaskModes.length)
|
|
assert.deepEqual(
|
|
(capture.taskModeAssets ?? []).map((asset) => asset.name).sort(),
|
|
[...taskModeAssets].sort(),
|
|
'Published task mode media requires the complete original capture manifest'
|
|
);
|
|
for (const name of focusedBoardAssets) {
|
|
if (
|
|
git(
|
|
root,
|
|
'ls-tree',
|
|
expected.publicationCommit,
|
|
'--',
|
|
`docs/assets/v${expected.version}/${name}`
|
|
).trim()
|
|
)
|
|
assert.equal(
|
|
capture.focusedBoardAssets?.[0]?.name,
|
|
name,
|
|
'Published focused board media requires original capture evidence'
|
|
);
|
|
}
|
|
for (const asset of [
|
|
...capture.assets,
|
|
...(capture.taskModeAssets ?? []),
|
|
...(capture.focusedBoardAssets ?? []),
|
|
]) {
|
|
if (asset.decision === 'retire') continue;
|
|
verifyCommittedFile(root, expected.publicationCommit, asset.path, asset.sha256);
|
|
assert.equal(
|
|
await fileDigest(await sibling(capturePath, asset.name)),
|
|
asset.sha256,
|
|
`${asset.name}: original captured bytes changed`
|
|
);
|
|
}
|
|
assert.equal(capture.demoVideo?.name, 'demo-overview.mp4', 'Missing captured demo video');
|
|
assert(digest(capture.demoVideo.sha256), 'Missing demo video digest');
|
|
assert.equal(
|
|
await fileDigest(await sibling(capturePath, capture.demoVideo.name)),
|
|
capture.demoVideo.sha256,
|
|
'Original demo video changed'
|
|
);
|
|
const canonicalRoot = await realpath(root);
|
|
for (const file of ['assets/demo-overview.mp4', 'docs/assets/demo-overview.mp4']) {
|
|
verifyCommittedFile(root, expected.publicationCommit, file, capture.demoVideo.sha256);
|
|
const intended = path.join(canonicalRoot, file);
|
|
assert.equal(await realpath(intended), intended, 'Published demo video traverses a symlink');
|
|
assert.equal(
|
|
await fileDigest(intended),
|
|
capture.demoVideo.sha256,
|
|
'Published demo video changed'
|
|
);
|
|
}
|
|
return [];
|
|
} catch (error) {
|
|
return [error.message];
|
|
}
|
|
}
|