#!/usr/bin/env node import { createHash } from 'node:crypto'; import { readFile, stat } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; function parseInventory(tsv) { const [headerLine, ...lines] = tsv.trim().split(/\r?\n/); const expectedHeader = 'media_id\tpublic_url\tlocal_path\tsource_url\tbytes\tsha256'; if (headerLine !== expectedHeader) throw new Error(`unexpected PDF inventory header: ${headerLine}`); return lines.filter(Boolean).map((line, index) => { const values = line.split('\t'); if (values.length !== 6) throw new Error(`inventory row ${index + 2} must have 6 tab-separated fields`); const [mediaId, publicUrl, localPath, sourceUrl, bytes, sha256] = values; return { mediaId, publicUrl, localPath, sourceUrl, bytes: Number(bytes), sha256 }; }); } function resolvePublicPath(projectRoot, localPath) { if (!localPath.startsWith('public/media/legacy-dokuwiki/') || !localPath.endsWith('.pdf')) { throw new Error(`invalid public PDF path: ${localPath}`); } const root = resolve(projectRoot); const absolute = resolve(root, localPath); if (!absolute.startsWith(`${root}${sep}`)) throw new Error(`public PDF path escapes project root: ${localPath}`); return absolute; } export async function verifyPdfArchive({ inventoryPath, projectRoot, expectedCount = 332 }) { const entries = parseInventory(await readFile(inventoryPath, 'utf8')); if (entries.length !== expectedCount) { throw new Error(`expected ${expectedCount} inventory records, found ${entries.length}`); } const publicUrls = new Set(); const localPaths = new Set(); const hashes = new Set(); let totalBytes = 0; for (const entry of entries) { if (publicUrls.has(entry.publicUrl)) throw new Error(`duplicate public URL: ${entry.publicUrl}`); if (localPaths.has(entry.localPath)) throw new Error(`duplicate public path: ${entry.localPath}`); publicUrls.add(entry.publicUrl); localPaths.add(entry.localPath); const absolutePath = resolvePublicPath(projectRoot, entry.localPath); let metadata; try { metadata = await stat(absolutePath); } catch (error) { if (error?.code === 'ENOENT') throw new Error(`missing public PDF: ${entry.localPath}`); throw error; } if (!metadata.isFile()) throw new Error(`public PDF is not a regular file: ${entry.localPath}`); if (metadata.size !== entry.bytes) { throw new Error(`byte size mismatch for ${entry.localPath}: expected ${entry.bytes}, found ${metadata.size}`); } const data = await readFile(absolutePath); if (!data.subarray(0, 5).equals(Buffer.from('%PDF-'))) { throw new Error(`invalid PDF signature: ${entry.localPath}`); } const sha256 = createHash('sha256').update(data).digest('hex'); if (sha256 !== entry.sha256) throw new Error(`SHA-256 mismatch for ${entry.localPath}`); hashes.add(sha256); totalBytes += metadata.size; } return { fileCount: entries.length, totalBytes, uniqueHashes: hashes.size }; } async function main() { const result = await verifyPdfArchive({ inventoryPath: 'docs/pdf-inventory.tsv', projectRoot: '.', }); console.log(`PDF archive verified: ${result.fileCount} files, ${result.totalBytes} bytes, ${result.uniqueHashes} unique hashes.`); } if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); }