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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ Validation uses a local snapshot of the stable OGraf Graphics v1 specification:
- Local files: [`packages/validator-core/spec/ebu-ograf-v1-d42afced`](packages/validator-core/spec/ebu-ograf-v1-d42afced)

The app never downloads schemas at runtime. Spec updates are reviewed and added
manually. `npm run spec:check` verifies the stored hashes and generated
standalone validator.
manually. `npm run spec:check` verifies that the documented EBU commit,
snapshot metadata, stored hashes, and generated standalone validator agree.

## Local development

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/validator-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to `@streamshapers/ograf-validator-core` are documented here.

## 0.2.1 - 2026-08-12

### Added

- Machine-readable metadata for the vendored EBU OGraf specification snapshot.
- A release check that keeps the snapshot directory, upstream commit, documentation, checksums, generated validator, and packaged files in sync.
- Regression tests for malformed snapshot metadata, stale documentation, and unsafe or duplicate checksum paths.

### Changed

- Validator generation, snapshot tests, and the npm tarball smoke test now discover the active snapshot from its metadata instead of using a hardcoded directory.

## 0.2.0 - 2026-08-10

### Added
Expand Down
5 changes: 3 additions & 2 deletions packages/validator-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,9 @@ npm run generate:validator
npm run spec:check
```

`spec:check` verifies `SHA256SUMS` and fails when regenerated standalone code
differs from the checked-in artifact.
`spec:check` verifies the snapshot metadata, current documentation,
`SHA256SUMS`, and generated standalone code. It fails if any of them refers to
a different EBU commit.

## Compatibility

Expand Down
2 changes: 1 addition & 1 deletion packages/validator-core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@streamshapers/ograf-validator-core",
"version": "0.2.0",
"version": "0.2.1",
"description": "Validate OGraf v1 Graphics Packages in Node.js and browsers with zero runtime dependencies",
"keywords": ["ograf", "broadcast", "graphics", "validator", "ebu"],
"repository": {
Expand Down
154 changes: 116 additions & 38 deletions packages/validator-core/scripts/check-spec.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
import { posix, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateValidatorSource, generatedValidatorPath } from './generate-standalone-validator.mjs';
import {
assertCurrentSnapshotReferences,
assertSnapshotReferences,
packageRoot,
snapshotMetadata,
snapshotRoot,
validateSnapshotMarkdown,
} from './spec-snapshot.mjs';

const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const snapshotRoot = resolve(packageRoot, 'spec/ebu-ograf-v1-d42afced');
const repositoryRoot = resolve(packageRoot, '../..');
const checksumPath = resolve(snapshotRoot, 'SHA256SUMS');

function normalizedRelativePath(path) {
Expand All @@ -15,50 +22,121 @@ function normalizedRelativePath(path) {
function collectFiles(directory) {
return readdirSync(directory)
.map((name) => resolve(directory, name))
.flatMap((path) => statSync(path).isDirectory() ? collectFiles(path) : [path]);
.flatMap((path) => {
const details = lstatSync(path);
if (details.isSymbolicLink()) {
throw new Error(`Snapshot must not contain symbolic links: ${normalizedRelativePath(path)}`);
}
if (details.isDirectory()) return collectFiles(path);
if (!details.isFile()) {
throw new Error(`Snapshot contains a non-regular file: ${normalizedRelativePath(path)}`);
}
return [path];
});
}

function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}

if (!existsSync(checksumPath)) {
throw new Error(`Missing snapshot checksum file: ${checksumPath}`);
function isCanonicalChecksumPath(path) {
if (path === '' || path === 'SHA256SUMS') return false;
if (path.includes('\\') || path.startsWith('/') || path.startsWith('./')) return false;
if (path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) {
return false;
}
return posix.normalize(path) === path;
}

const expected = new Map(
readFileSync(checksumPath, 'utf8')
.trim()
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => {
const match = /^([a-f0-9]{64}) {2}(.+)$/u.exec(line);
if (match === null) throw new Error(`Malformed SHA256SUMS line: ${line}`);
return [match[2], match[1]];
}),
);

const actualFiles = collectFiles(snapshotRoot)
.map(normalizedRelativePath)
.filter((path) => path !== 'SHA256SUMS')
.sort();

for (const path of actualFiles) {
const expectedHash = expected.get(path);
if (expectedHash === undefined) throw new Error(`Snapshot file is not pinned in SHA256SUMS: ${path}`);
const actualHash = sha256(resolve(snapshotRoot, path));
if (actualHash !== expectedHash) throw new Error(`Snapshot checksum mismatch: ${path}`);
expected.delete(path);
}
if (expected.size > 0) {
throw new Error(`SHA256SUMS references missing files: ${[...expected.keys()].join(', ')}`);
export function parseChecksumManifest(content) {
const expected = new Map();
for (const line of content.split(/\r?\n/u).filter(Boolean)) {
const match = /^([a-f0-9]{64}) {2}(.+)$/u.exec(line);
if (match === null) throw new Error(`Malformed SHA256SUMS line: ${line}`);
const path = match[2];
const hash = match[1];
if (path === undefined || hash === undefined || !isCanonicalChecksumPath(path)) {
throw new Error(`Unsafe or non-canonical SHA256SUMS path: ${String(path)}`);
}
if (expected.has(path)) throw new Error(`Duplicate SHA256SUMS path: ${path}`);
expected.set(path, hash);
}
if (expected.size === 0) throw new Error('SHA256SUMS must contain at least one file.');
return expected;
}

if (!existsSync(generatedValidatorPath)) {
throw new Error(`Missing generated validator: ${generatedValidatorPath}`);
function readUtf8(path) {
return readFileSync(path, 'utf8');
}
if (readFileSync(generatedValidatorPath, 'utf8') !== generateValidatorSource()) {
throw new Error('Generated validator drift detected. Run npm run generate:validator.');

export function runSpecCheck() {
if (!existsSync(checksumPath)) {
throw new Error(`Missing snapshot checksum file: ${checksumPath}`);
}

const snapshotMarkdown = readUtf8(resolve(snapshotRoot, 'SNAPSHOT.md'));
validateSnapshotMarkdown(snapshotMetadata, snapshotMarkdown);

assertCurrentSnapshotReferences(snapshotMetadata, [
{
label: 'README.md',
content: readUtf8(resolve(repositoryRoot, 'README.md')),
tokens: [
snapshotMetadata.commit,
snapshotMetadata.shortCommit,
`packages/validator-core/spec/${snapshotMetadata.directory}`,
snapshotMetadata.sourceDateDisplay,
],
},
{
label: 'packages/validator-core/README.md',
content: readUtf8(resolve(packageRoot, 'README.md')),
tokens: [
snapshotMetadata.commit,
snapshotMetadata.shortCommit,
`spec/${snapshotMetadata.directory}`,
snapshotMetadata.sourceDateDisplay,
],
},
]);
assertSnapshotReferences([{
label: 'packages/validator-core/CHANGELOG.md',
content: readUtf8(resolve(packageRoot, 'CHANGELOG.md')),
tokens: [snapshotMetadata.shortCommit],
}]);

const expected = parseChecksumManifest(readUtf8(checksumPath));
const actualFiles = collectFiles(snapshotRoot)
.map(normalizedRelativePath)
.filter((path) => path !== 'SHA256SUMS')
.sort();

for (const path of actualFiles) {
const expectedHash = expected.get(path);
if (expectedHash === undefined) {
throw new Error(`Snapshot file is not pinned in SHA256SUMS: ${path}`);
}
const actualHash = sha256(resolve(snapshotRoot, path));
if (actualHash !== expectedHash) throw new Error(`Snapshot checksum mismatch: ${path}`);
expected.delete(path);
}
if (expected.size > 0) {
throw new Error(`SHA256SUMS references missing files: ${[...expected.keys()].join(', ')}`);
}

if (!existsSync(generatedValidatorPath)) {
throw new Error(`Missing generated validator: ${generatedValidatorPath}`);
}
if (readUtf8(generatedValidatorPath) !== generateValidatorSource()) {
throw new Error('Generated validator drift detected. Run npm run generate:validator.');
}

console.log(
`Pinned OGraf snapshot ${snapshotMetadata.shortCommit}, documentation, checksums, ` +
'and generated validator are up to date.',
);
}

console.log('Pinned OGraf snapshot checksums and generated validator are up to date.');
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
runSpecCheck();
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { packageRoot, snapshotMetadata, snapshotRoot } from './spec-snapshot.mjs';

const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const snapshotRoot = resolve(packageRoot, 'spec/ebu-ograf-v1-d42afced');
export const generatedValidatorPath = resolve(packageRoot, 'src/generated/ograf-manifest-validator.ts');
const require = createRequire(import.meta.url);
const dependencySearchPaths = process.env['OGRAF_AJV_NODE_MODULES'] === undefined
Expand Down Expand Up @@ -69,7 +68,7 @@ export function generateValidatorSource() {
'// @ts-nocheck',
'/**',
' * Generated by scripts/generate-standalone-validator.mjs.',
' * Source: EBU OGraf v1 snapshot d42afcedf9348e05e35b2009b04fb9552785e35b.',
` * Source: EBU OGraf v1 snapshot ${snapshotMetadata.commit}.`,
' * Do not edit manually; run npm run generate:validator.',
' */',
runtimeHelpers,
Expand Down
Loading
Loading