85 lines
3.5 KiB
JavaScript
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;
|
|
});
|
|
}
|