Skip to content
Merged
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
38 changes: 36 additions & 2 deletions scripts/validate-architectures.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,54 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { join, resolve, sep } from 'node:path';
import { reportAndExit } from './lib/validate-utils.mjs';

const root = new URL('..', import.meta.url).pathname;
const staticRoot = resolve(join(root, 'static'));
const assetPrefix = '/img/architectures/';
const assetRoot = resolve(join(staticRoot, assetPrefix.slice(1)));
const idPattern = /^[a-z0-9][a-z0-9-]*$/;

const catalogPath = join(root, 'data/architectures/catalog.json');
if (!existsSync(catalogPath)) throw new Error('Missing data/architectures/catalog.json; run npm run import:architectures');

// The catalog is regenerated from the third-party cncf/architecture repository,
// so every field that reaches an href or an <img src> is treated as untrusted.
function isHttpsUrl(value) {
if (typeof value !== 'string') return false;
try {
return new URL(value).protocol === 'https:';
} catch {
return false;
}
}

// Resolve first, then assert containment: '..' segments are normalised away by
// join()/resolve(), so testing the raw value would let the guard be bypassed.
function resolveContainedAsset(asset) {
if (typeof asset !== 'string' || !asset.startsWith(assetPrefix)) return null;
const resolved = resolve(join(staticRoot, asset.slice(1)));
if (resolved !== assetRoot && !resolved.startsWith(assetRoot + sep)) return null;
return resolved;
}

const records = JSON.parse(readFileSync(catalogPath, 'utf8'));
const ids = new Set();
const errors = [];
for (const record of records) {
if (!record.id || !record.title || !record.organization) errors.push({ path: record.id || '<unknown>', severity: 'error', message: 'missing id, title, or organization' });
if (record.id && !idPattern.test(record.id)) errors.push({ path: record.id, severity: 'error', message: 'id must be a lowercase slug matching /^[a-z0-9][a-z0-9-]*$/; it is used as a route segment and as a filesystem path component' });
if (ids.has(record.id)) errors.push({ path: record.id, severity: 'error', message: 'duplicate id' });
ids.add(record.id);
for (const asset of record.assets ?? []) if (!existsSync(join(root, 'static', asset.replace(/^\//, '')))) errors.push({ path: record.id, severity: 'error', message: `missing asset ${asset}` });
if (!isHttpsUrl(record.sourceUrl)) errors.push({ path: record.id || '<unknown>', severity: 'error', message: 'sourceUrl must be an https URL; it is rendered as an href in the member directory' });
for (const asset of record.assets ?? []) {
const file = resolveContainedAsset(asset);
if (!file) {
errors.push({ path: record.id, severity: 'error', message: `asset ${asset} must be a site-absolute path contained in ${assetPrefix}` });
continue;
}
if (!existsSync(file)) errors.push({ path: record.id, severity: 'error', message: `missing asset ${asset}` });
}
}
reportAndExit(errors, 'architecture catalog');
console.log(`Validated ${records.length} architecture records`);
Loading