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(/ 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(/]*\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` );