import { createHash } from 'node:crypto' import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' const formalManifestKinds = new Set(['runtime-asset-inventory']) const assertOnlyFields = (value, fields, label) => { for (const field of Object.keys(value)) { if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`) } } const resolveInsideWorkspace = (workspace, candidate, label) => { if (typeof candidate !== 'string' || candidate.trim() === '') { throw new Error(`${label} must be a non-empty string`) } const absolutePath = path.resolve(workspace, candidate) const relative = path.relative(workspace, absolutePath) if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`${label} escapes workspace: ${candidate}`) } return absolutePath } const readManifest = async (manifestPath) => { try { return JSON.parse(await readFile(manifestPath, 'utf8')) } catch (error) { if (error.code === 'ENOENT') throw new Error(`missing manifest: ${manifestPath}`) throw error } } const validateDirectAsset = (asset, workspace) => { if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('runtime asset must be an object') assertOnlyFields( asset, new Set(['id', 'output', 'width', 'height', 'alpha', 'bytes', 'sha256', 'provenance', 'rebuildable']), 'runtime asset', ) for (const key of ['id', 'output']) { if (typeof asset[key] !== 'string' || asset[key].trim() === '') { throw new Error(`runtime asset ${key} must be a non-empty string`) } } resolveInsideWorkspace(workspace, asset.output, `${asset.id}.output`) for (const key of ['width', 'height', 'bytes']) { if (!Number.isInteger(asset[key]) || asset[key] <= 0) throw new Error(`${asset.id}.${key} must be a positive integer`) } if (typeof asset.alpha !== 'boolean') throw new Error(`${asset.id}.alpha must be boolean`) if (asset.provenance !== 'committed-binary') { throw new Error(`${asset.id}.provenance must be committed-binary`) } if (asset.rebuildable !== false) throw new Error(`${asset.id}.rebuildable must be false`) if (!/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error(`${asset.id}.sha256 must be lowercase SHA256`) return asset } export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) => { const workspacePath = path.resolve(workspace) const loaded = new Set() const stack = [] const ids = new Map() const outputs = new Map() const manifests = [] const addAsset = (asset, owner) => { if (ids.has(asset.id)) { throw new Error(`duplicate asset id: ${asset.id} (${ids.get(asset.id)}, ${owner})`) } if (outputs.has(asset.output)) { throw new Error(`duplicate output: ${asset.output} (${outputs.get(asset.output).owner}, ${owner})`) } ids.set(asset.id, owner) outputs.set(asset.output, { ...asset, owner }) } // 递归展开只处理机器清单之间的依赖;页面消费者始终从源码扫描得到,避免双写。 const visit = async (manifestPath) => { const absolutePath = resolveInsideWorkspace(workspacePath, manifestPath, 'manifest') if (stack.includes(absolutePath)) { throw new Error(`import cycle: ${[...stack, absolutePath].join(' -> ')}`) } if (loaded.has(absolutePath)) throw new Error(`duplicate import: ${absolutePath}`) stack.push(absolutePath) loaded.add(absolutePath) manifests.push(absolutePath) const manifest = await readManifest(absolutePath) if (manifest.kind === 'runtime-asset-inventory') { assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'scope', 'imports', 'assets']), 'runtime manifest') if (manifest.schemaVersion !== 1) throw new Error('runtime inventory schemaVersion must be 1') if (typeof manifest.scope !== 'string' || manifest.scope.trim() === '') throw new Error('runtime inventory scope is required') if (!Array.isArray(manifest.imports) || !Array.isArray(manifest.assets)) { throw new Error('runtime inventory imports and assets must be arrays') } for (const asset of manifest.assets) addAsset(validateDirectAsset(asset, workspacePath), absolutePath) for (const imported of manifest.imports) await visit(imported) } else { throw new Error(`unsupported manifest kind: ${manifest.kind}`) } stack.pop() } await visit(path.relative(workspacePath, path.resolve(rootManifestPath))) return { manifests, assets: [...outputs.values()] } } export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace, manifestsDirectory) => { const workspacePath = path.resolve(workspace) const directory = resolveInsideWorkspace(workspacePath, manifestsDirectory, 'manifests directory') const rootManifest = await readManifest(path.resolve(rootManifestPath)) if (rootManifest.kind !== 'runtime-asset-inventory' || rootManifest.scope !== 'schema-v3') { throw new Error('registry root scope must be schema-v3') } const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath) const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest))) for (const asset of inventory.assets) { let binary try { binary = await readFile(resolveInsideWorkspace(workspacePath, asset.output, `${asset.id}.output`)) } catch (error) { if (error.code === 'ENOENT') throw new Error(`missing runtime asset: ${asset.output}`) throw error } if (binary.length !== asset.bytes) { throw new Error(`${asset.id}.bytes does not match ${asset.output}`) } const digest = createHash('sha256').update(binary).digest('hex') if (digest !== asset.sha256) { throw new Error(`${asset.id}.sha256 does not match ${asset.output}`) } } // 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败, // output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。 for (const entry of await readdir(directory, { withFileTypes: true })) { if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.json') continue const manifestPath = path.join(directory, entry.name) const manifest = await readManifest(manifestPath) if (formalManifestKinds.has(manifest.kind)) { if (!registered.has(path.resolve(manifestPath))) throw new Error(`unregistered manifest: ${manifestPath}`) continue } throw new Error(`undeclared legacy manifest: ${manifestPath}`) } return inventory }