import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import fs from 'node:fs' import path from 'node:path' interface Asset { path: string, sha256: string, mode: string } interface Notice { source: string, target: string, sha256: string } interface Component { name: string version: string tarball: string assets: string[] notices: string[] } interface Group { name: string, version: string, tarball: string, review?: string, roots: string[], files: Asset[], notices: Notice[], components?: Component[] } export interface VendorManifest { schemaVersion: number, scope: string, groups: Group[] } const hash = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') function resolve(root: string, relative: string) { assert(relative && !relative.includes('\\') && !path.isAbsolute(relative), 'Expected a repository-relative vendor path') const file = path.resolve(root, relative) const actual = path.relative(root, file) assert(actual && !actual.startsWith('..') && !path.isAbsolute(actual), 'Vendor path escapes the repository') return file } function list(root: string, relative: string): string[] { const file = resolve(root, relative) const stat = fs.lstatSync(file) assert(!stat.isSymbolicLink(), `Redirected vendor path: ${relative}`) if (stat.isDirectory()) return fs.readdirSync(file).sort().flatMap(name => list(root, `${relative}/${name}`)) assert(stat.isFile(), `Invalid vendor file: ${relative}`) return [relative] } export function generateNotices(root: string, manifest: VendorManifest): Map { assert.equal(manifest.schemaVersion, 1) const outputs = new Map() const summary = ['# Verified site component notices', '', manifest.scope, '', 'Generated by yarn build:site-notices. Runtime asset URLs are retained; reviewed local patches are identified below.', ''] for (const group of manifest.groups) { assert(group.notices.length > 0, `Missing upstream notice sources: ${group.name}`) const actual = group.roots.flatMap(relative => list(root, relative)).sort() assert.deepEqual(actual, group.files.map(file => file.path).sort(), `Vendor inventory drift: ${group.name}`) for (const asset of group.files) { const bytes = fs.readFileSync(resolve(root, asset.path)) assert(['binary', 'lf-text'].includes(asset.mode), 'Unknown asset normalization') const normalized = asset.mode === 'binary' ? bytes : new TextEncoder().encode(bytes.toString('utf8').replaceAll('\r\n', '\n')) assert.equal(hash(normalized), asset.sha256, `Vendor bytes changed: ${asset.path}`) } summary.push(`## ${group.name} ${group.version}`, '', `Source: ${group.tarball}`, '') if (group.review) summary.push(group.review, '') for (const component of group.components || []) { assert(component.assets.length > 0 && component.notices.length > 0, `Missing component evidence: ${component.name}`) for (const asset of component.assets) assert(group.files.some(file => file.path === asset), `Missing component asset: ${asset}`) for (const target of component.notices) assert(group.notices.some(notice => notice.target === target), `Missing component notice: ${target}`) summary.push(`Included component: ${component.name} ${component.version}`, '', `Source: ${component.tarball}`, '') } for (const notice of group.notices) { const bytes = fs.readFileSync(resolve(root, notice.source)) assert.equal(hash(bytes), notice.sha256, `Upstream notice changed: ${notice.source}`) resolve(root, notice.target) assert(notice.target.startsWith(`docs/licenses/${group.name}/`), 'Notice output must stay in its component directory') assert(!outputs.has(notice.target), 'Duplicate notice output') outputs.set(notice.target, bytes) summary.push(`- ${notice.target.slice('docs/'.length)}`) } summary.push('') } outputs.set('docs/THIRD_PARTY_NOTICES.md', new TextEncoder().encode(`${summary.join('\n').trimEnd()}\n`)) return outputs } export function writeOrCheckNotices(root: string, manifest: VendorManifest, check: boolean) { // Resolve and verify every source before writing the first output. const outputs = generateNotices(root, manifest) for (const [relative, bytes] of outputs) { const file = resolve(root, relative) if (check) { assert(fs.readFileSync(file).equals(bytes), `Generated vendor notice drift: ${relative}`) } else { fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, bytes) } } return outputs.size }