398 lines
14 KiB
JavaScript
398 lines
14 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const auditLabel = 'gift-challenge-aslan-asset-audit';
|
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(scriptDirectory, '..');
|
|
const pageDirectory = path.join(repoRoot, 'activity', 'gift-challenge');
|
|
const aslanDirectory = path.join(pageDirectory, 'aslan');
|
|
const layerDirectory = path.join(aslanDirectory, 'assets', 'layers');
|
|
const manifestPath = path.join(layerDirectory, 'manifest.json');
|
|
const localeCodes = ['en', 'ar', 'tr', 'es'];
|
|
|
|
// Keep the permanent red-line list in code as well as the manifest. Otherwise a
|
|
// later manifest edit could silently remove a root frame from its own denylist.
|
|
const canonicalForbiddenNodes = new Set([
|
|
'646:1997',
|
|
'918:6917',
|
|
'918:7827',
|
|
'918:8941',
|
|
'918:9851',
|
|
'918:10385',
|
|
]);
|
|
const forbiddenAssetName =
|
|
/(?:whole[-_ ]?page|full[-_ ]?page|page[-_ ]?shot|page[-_ ]?screenshot|root[-_ ]?frame|screenshot)/i;
|
|
|
|
function invariant(condition, message) {
|
|
if (!condition) throw new Error(`[${auditLabel}] ${message}`);
|
|
}
|
|
|
|
function pngDimensions(bytes, filename) {
|
|
invariant(
|
|
bytes.length >= 24 &&
|
|
bytes[0] === 0x89 &&
|
|
bytes.toString('ascii', 1, 4) === 'PNG',
|
|
`${filename}: expected PNG signature`
|
|
);
|
|
return {
|
|
width: bytes.readUInt32BE(16),
|
|
height: bytes.readUInt32BE(20),
|
|
};
|
|
}
|
|
|
|
function jpegDimensions(bytes, filename) {
|
|
invariant(
|
|
bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8,
|
|
`${filename}: expected JPEG signature`
|
|
);
|
|
const sizeMarkers = new Set([
|
|
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce,
|
|
0xcf,
|
|
]);
|
|
for (let offset = 2; offset + 8 < bytes.length; ) {
|
|
if (bytes[offset] !== 0xff) {
|
|
offset += 1;
|
|
continue;
|
|
}
|
|
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
|
|
const marker = bytes[offset];
|
|
offset += 1;
|
|
if (
|
|
marker === 0xd8 ||
|
|
marker === 0xd9 ||
|
|
marker === 0x01 ||
|
|
(marker >= 0xd0 && marker <= 0xd7)
|
|
) {
|
|
continue;
|
|
}
|
|
invariant(offset + 2 <= bytes.length, `${filename}: truncated JPEG`);
|
|
const segmentLength = bytes.readUInt16BE(offset);
|
|
invariant(segmentLength >= 2, `${filename}: invalid JPEG segment`);
|
|
if (sizeMarkers.has(marker)) {
|
|
invariant(
|
|
segmentLength >= 7 && offset + segmentLength <= bytes.length,
|
|
`${filename}: invalid JPEG size segment`
|
|
);
|
|
return {
|
|
height: bytes.readUInt16BE(offset + 3),
|
|
width: bytes.readUInt16BE(offset + 5),
|
|
};
|
|
}
|
|
offset += segmentLength;
|
|
}
|
|
throw new Error(`[${auditLabel}] ${filename}: JPEG dimensions not found`);
|
|
}
|
|
|
|
function svgDimensions(bytes, asset) {
|
|
const source = bytes.toString('utf8');
|
|
invariant(/<svg\b/i.test(source), `${asset.filename}: expected SVG root`);
|
|
const match = source.match(/viewBox=["']([^"']+)["']/i);
|
|
invariant(match, `${asset.filename}: SVG viewBox is required`);
|
|
const values = match[1]
|
|
.trim()
|
|
.split(/[\s,]+/)
|
|
.map(Number);
|
|
invariant(
|
|
values.length === 4 && values.every(Number.isFinite),
|
|
`${asset.filename}: SVG viewBox must contain four finite numbers`
|
|
);
|
|
const expected = asset.exportViewBox;
|
|
invariant(
|
|
expected,
|
|
`${asset.filename}: manifest exportViewBox is required`
|
|
);
|
|
const expectedValues = [
|
|
expected.minX,
|
|
expected.minY,
|
|
expected.width,
|
|
expected.height,
|
|
].map(Number);
|
|
invariant(
|
|
expectedValues.every(Number.isFinite) &&
|
|
values.every(
|
|
(value, index) =>
|
|
Math.abs(value - expectedValues[index]) <= 0.001
|
|
),
|
|
`${asset.filename}: SVG viewBox does not match the manifest`
|
|
);
|
|
invariant(
|
|
values[2] > 0 && values[3] > 0,
|
|
`${asset.filename}: SVG viewBox dimensions must be positive`
|
|
);
|
|
return { width: values[2], height: values[3] };
|
|
}
|
|
|
|
function fileDimensions(bytes, asset) {
|
|
if (asset.file.format === 'png') {
|
|
return pngDimensions(bytes, asset.filename);
|
|
}
|
|
if (asset.file.format === 'jpeg') {
|
|
return jpegDimensions(bytes, asset.filename);
|
|
}
|
|
if (asset.file.format === 'svg') {
|
|
return svgDimensions(bytes, asset);
|
|
}
|
|
throw new Error(
|
|
`[${auditLabel}] ${asset.filename}: unsupported file format`
|
|
);
|
|
}
|
|
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
invariant(manifest.schemaVersion === 1, 'unsupported manifest schema');
|
|
invariant(
|
|
manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl',
|
|
'unexpected Figma file key'
|
|
);
|
|
const manifestForbiddenNodes = new Set(
|
|
manifest.policy?.forbiddenSourceNodes || []
|
|
);
|
|
for (const node of canonicalForbiddenNodes) {
|
|
invariant(
|
|
manifestForbiddenNodes.has(node),
|
|
`${node}: permanent forbidden node is missing from manifest policy`
|
|
);
|
|
}
|
|
const maxLayerPixels = Number(manifest.policy?.maxDecorativeLayerPixels);
|
|
invariant(
|
|
Number.isFinite(maxLayerPixels) && maxLayerPixels > 0,
|
|
'maxDecorativeLayerPixels must be a positive number'
|
|
);
|
|
|
|
const assets = manifest.assets || [];
|
|
invariant(assets.length > 0, 'manifest must list exported layers');
|
|
const assetsByFile = new Map();
|
|
|
|
for (const asset of assets) {
|
|
// basename-only paths prevent a manifest entry from escaping the audited
|
|
// directory while still appearing to represent a local layer.
|
|
invariant(
|
|
typeof asset.filename === 'string' &&
|
|
path.basename(asset.filename) === asset.filename,
|
|
'every layer filename must be a basename'
|
|
);
|
|
invariant(
|
|
!assetsByFile.has(asset.filename),
|
|
`${asset.filename}: duplicate manifest entry`
|
|
);
|
|
assetsByFile.set(asset.filename, asset);
|
|
invariant(
|
|
!forbiddenAssetName.test(asset.filename),
|
|
`${asset.filename}: filename indicates a forbidden whole-page asset`
|
|
);
|
|
invariant(
|
|
/^\d+:\d+$/.test(asset.sourceNode || ''),
|
|
`${asset.filename}: source Figma node is required`
|
|
);
|
|
invariant(
|
|
!canonicalForbiddenNodes.has(asset.sourceNode) &&
|
|
!manifestForbiddenNodes.has(asset.sourceNode),
|
|
`${asset.filename}: root canvas/frame exports are forbidden`
|
|
);
|
|
invariant(
|
|
typeof asset.use === 'string' && asset.use.trim(),
|
|
`${asset.filename}: usage description is required`
|
|
);
|
|
invariant(
|
|
asset.scope === 'runtime',
|
|
`${asset.filename}: Gift Challenge layers must be runtime decoration`
|
|
);
|
|
invariant(
|
|
typeof asset.derived === 'boolean',
|
|
`${asset.filename}: derived must be explicit`
|
|
);
|
|
if (asset.derived) {
|
|
invariant(
|
|
typeof asset.derivation === 'string' && asset.derivation.trim(),
|
|
`${asset.filename}: derived assets must document their derivation`
|
|
);
|
|
}
|
|
invariant(
|
|
Number(asset.renderedDesignSize?.width) > 0 &&
|
|
Number(asset.renderedDesignSize?.height) > 0,
|
|
`${asset.filename}: positive Figma design size is required`
|
|
);
|
|
for (const field of [
|
|
'containsText',
|
|
'containsControls',
|
|
'containsDynamicBusinessMedia',
|
|
]) {
|
|
invariant(
|
|
typeof asset[field] === 'boolean',
|
|
`${asset.filename}: ${field} must be boolean`
|
|
);
|
|
invariant(
|
|
asset[field] === false,
|
|
`${asset.filename}: text, controls and dynamic business media must remain DOM/API content`
|
|
);
|
|
}
|
|
invariant(
|
|
/^[a-f0-9]{64}$/.test(asset.sha256 || ''),
|
|
`${asset.filename}: SHA-256 is required`
|
|
);
|
|
invariant(
|
|
Number.isInteger(asset.file?.bytes) && asset.file.bytes > 0,
|
|
`${asset.filename}: byte size must be a positive integer`
|
|
);
|
|
invariant(
|
|
Number(asset.file?.pixelWidth) > 0 &&
|
|
Number(asset.file?.pixelHeight) > 0,
|
|
`${asset.filename}: positive exported dimensions are required`
|
|
);
|
|
const extension = path.extname(asset.filename).toLowerCase();
|
|
invariant(
|
|
(asset.file.format === 'png' && extension === '.png') ||
|
|
(asset.file.format === 'jpeg' &&
|
|
['.jpg', '.jpeg'].includes(extension)) ||
|
|
(asset.file.format === 'svg' && extension === '.svg'),
|
|
`${asset.filename}: extension does not match manifest format`
|
|
);
|
|
|
|
const assetPath = path.join(layerDirectory, asset.filename);
|
|
const [bytes, fileInfo] = await Promise.all([
|
|
readFile(assetPath),
|
|
stat(assetPath),
|
|
]);
|
|
const dimensions = fileDimensions(bytes, asset);
|
|
invariant(
|
|
fileInfo.isFile(),
|
|
`${asset.filename}: manifest entry must point to a file`
|
|
);
|
|
invariant(
|
|
fileInfo.size === asset.file.bytes,
|
|
`${asset.filename}: byte size mismatch`
|
|
);
|
|
invariant(
|
|
Math.abs(dimensions.width - Number(asset.file.pixelWidth)) <= 0.001 &&
|
|
Math.abs(dimensions.height - Number(asset.file.pixelHeight)) <=
|
|
0.001,
|
|
`${asset.filename}: exported dimensions mismatch`
|
|
);
|
|
invariant(
|
|
dimensions.width * dimensions.height <= maxLayerPixels,
|
|
`${asset.filename}: layer exceeds the decorative pixel limit`
|
|
);
|
|
invariant(
|
|
createHash('sha256').update(bytes).digest('hex') === asset.sha256,
|
|
`${asset.filename}: SHA-256 mismatch`
|
|
);
|
|
}
|
|
|
|
const diskFiles = new Set(
|
|
(await readdir(layerDirectory)).filter(
|
|
(filename) => filename !== 'manifest.json'
|
|
)
|
|
);
|
|
invariant(
|
|
diskFiles.size === assetsByFile.size &&
|
|
[...diskFiles].every((filename) => assetsByFile.has(filename)),
|
|
'every layer file must have exactly one manifest record'
|
|
);
|
|
|
|
const [html, css, js, ...localeTexts] = await Promise.all([
|
|
readFile(path.join(pageDirectory, 'aslan.html'), 'utf8'),
|
|
readFile(path.join(aslanDirectory, 'aslan.css'), 'utf8'),
|
|
readFile(path.join(aslanDirectory, 'aslan.js'), 'utf8'),
|
|
...localeCodes.map((locale) =>
|
|
readFile(
|
|
path.join(repoRoot, 'common', 'locales', `${locale}.json`),
|
|
'utf8'
|
|
)
|
|
),
|
|
]);
|
|
const runtimeSource = `${html}\n${css}\n${js}`;
|
|
|
|
// Every runtime image reference must resolve back to this manifest. This checks
|
|
// the actual page path, not merely whether an approved collection exists on disk.
|
|
const runtimeLayerReferences = [
|
|
...runtimeSource.matchAll(
|
|
/(?:\.\/)?(?:aslan\/)?assets\/layers\/([^"')\s?#]+)/g
|
|
),
|
|
].map((match) => match[1]);
|
|
invariant(
|
|
runtimeLayerReferences.length > 0,
|
|
'page must reference audited layers'
|
|
);
|
|
for (const filename of runtimeLayerReferences) {
|
|
invariant(
|
|
assetsByFile.has(filename),
|
|
`${filename}: runtime layer reference is not present in the manifest`
|
|
);
|
|
invariant(
|
|
!forbiddenAssetName.test(filename),
|
|
`${filename}: aslan.html/runtime references a forbidden whole-page asset`
|
|
);
|
|
}
|
|
for (const asset of assets) {
|
|
invariant(
|
|
runtimeLayerReferences.includes(asset.filename),
|
|
`${asset.filename}: manifested layer is not used by the page`
|
|
);
|
|
}
|
|
|
|
const htmlImageSources = [
|
|
...html.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi),
|
|
].map((match) => match[1]);
|
|
for (const source of htmlImageSources) {
|
|
invariant(
|
|
!forbiddenAssetName.test(source),
|
|
`${source}: aslan.html references a forbidden whole-page image`
|
|
);
|
|
const layerMatch = source.match(/assets\/layers\/([^"')\s?#]+)/);
|
|
invariant(
|
|
layerMatch && assetsByFile.has(layerMatch[1]),
|
|
`${source}: every aslan.html image must be an audited layer`
|
|
);
|
|
invariant(
|
|
!canonicalForbiddenNodes.has(
|
|
assetsByFile.get(layerMatch[1]).sourceNode
|
|
),
|
|
`${source}: aslan.html image resolves to a forbidden root frame`
|
|
);
|
|
}
|
|
invariant(
|
|
html.includes('../../common/theme.css') &&
|
|
html.includes('../../common/i18n.js'),
|
|
'shared theme and i18n runtime are required'
|
|
);
|
|
|
|
const locales = localeTexts.map((source) => JSON.parse(source));
|
|
const baseKeys = Object.keys(locales[0])
|
|
.filter((key) => key.startsWith('giftChallenge.'))
|
|
.sort();
|
|
invariant(baseKeys.length >= 40, 'English Gift Challenge locale is incomplete');
|
|
const baseKeySet = new Set(baseKeys);
|
|
for (let index = 0; index < locales.length; index += 1) {
|
|
const localeCode = localeCodes[index];
|
|
const locale = locales[index];
|
|
const keys = Object.keys(locale)
|
|
.filter((key) => key.startsWith('giftChallenge.'))
|
|
.sort();
|
|
const missing = baseKeys.filter((key) => !(key in locale));
|
|
const extra = keys.filter((key) => !baseKeySet.has(key));
|
|
invariant(
|
|
missing.length === 0 && extra.length === 0,
|
|
`${localeCode}: Gift Challenge key set differs (missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'})`
|
|
);
|
|
for (const key of keys) {
|
|
invariant(
|
|
typeof locale[key] === 'string' && locale[key].trim(),
|
|
`${localeCode}:${key}: translation must be a non-empty string`
|
|
);
|
|
}
|
|
}
|
|
|
|
const runtimeI18nKeys = new Set(
|
|
[...runtimeSource.matchAll(/giftChallenge\.[A-Za-z0-9_.-]+/g)].map(
|
|
(match) => match[0]
|
|
)
|
|
);
|
|
for (const key of runtimeI18nKeys) {
|
|
invariant(baseKeySet.has(key), `${key}: runtime i18n key is missing`);
|
|
}
|
|
|
|
console.log(
|
|
`[${auditLabel}] passed: ${assets.length} traceable layers, ${baseKeys.length} aligned keys across ${localeCodes.length} locales`
|
|
);
|