diff --git a/scripts/validate-architectures.mjs b/scripts/validate-architectures.mjs
index cc4965de..f7eb9be4 100644
--- a/scripts/validate-architectures.mjs
+++ b/scripts/validate-architectures.mjs
@@ -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
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 || '', 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 || '', 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`);