Incognito-Wiki/scripts/verify-pdf-archive.mjs
msa46 c590f77d96
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
feat: publish legacy course archive
2026-08-03 15:12:11 +02:00

85 lines
3.5 KiB
JavaScript

#!/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;
});
}