← Files PDF ParserARCHIVED FILE

scripts/inventory-pdfs.mjs

11.1 KB · Oct 3, 2026 · 06:36 UTC

↓ Download file

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {spawnSync} from 'node:child_process';

function usage(message) {
  if (message) console.error(`Error: ${message}`);
  console.error('Usage: inventory-pdfs.mjs <source-folder> [--json <report-path>]');
  process.exit(2);
}

const argv = process.argv.slice(2);
let rootArg = null;
let outPath = null;
for (let i = 0; i < argv.length; i += 1) {
  const arg = argv[i];
  if (arg === '--json') {
    const value = argv[++i];
    if (!value) usage('--json requires a path');
    outPath = path.resolve(value);
  } else if (arg === '--help') {
    usage();
  } else if (arg.startsWith('-')) {
    usage(`unknown option ${arg}`);
  } else if (rootArg === null) {
    rootArg = arg;
  } else {
    usage(`unexpected argument ${arg}`);
  }
}
const root = path.resolve(rootArg || '.');
function isWithin(base, target) { const relative = path.relative(base, target); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); }
function outputInsideInput(base, target) {
  if (isWithin(base, target)) return true;
  try { return isWithin(fs.realpathSync(base), path.join(fs.realpathSync(path.dirname(target)), path.basename(target))); }
  catch { return false; }
}
if (outPath && outputInsideInput(root, outPath)) usage('--json must be outside the source root');
if (outPath && fs.existsSync(outPath)) fs.unlinkSync(outPath);
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) usage('source folder does not exist or is not a directory');

const MAX_WALK_ENTRIES = 5000;
const MAX_PDF_COUNT = 500;
const MAX_PDF_BYTES = 1024 * 1024 * 1024;
const MAX_TOTAL_PDF_BYTES = 5 * 1024 * 1024 * 1024;
const MAX_PAGES_PER_PDF = 5000;
const MAX_TOTAL_PAGES = 20_000;
const env = {...process.env, LANG: 'C', LC_ALL: 'C'};
function command(name, args, timeout = 60_000) {
  const result = spawnSync(name, args, {encoding: 'utf8', env, timeout, maxBuffer: 64 * 1024 * 1024});
  if (result.error) return {ok: false, stdout: result.stdout || '', stderr: result.stderr || '', error: result.error.code || result.error.message, signal: result.signal || null};
  if (result.status !== 0) return {ok: false, stdout: result.stdout || '', stderr: result.stderr || '', error: `exit_${result.status}`, signal: result.signal || null};
  return {ok: true, stdout: result.stdout || '', stderr: result.stderr || '', error: null, signal: null};
}
function version(name) {
  const result = command(name, ['-v'], 10_000);
  const text = `${result.stdout}\n${result.stderr}`.trim().split('\n')[0] || null;
  return {available: result.ok, version: text, error: result.ok ? null : result.error};
}
const discoveryErrors = [];
function walk(directory, state = {entries: 0}) {
  const found = [];
  const handle = fs.opendirSync(directory);
  try {
    let entry;
    while ((entry = handle.readSync()) !== null) {
      state.entries += 1;
      if (state.entries > MAX_WALK_ENTRIES) throw new Error('walk entry limit exceeded');
      const full = path.join(directory, entry.name);
      if (entry.isSymbolicLink()) { discoveryErrors.push({code: 'SYMLINK_SKIPPED', file: path.relative(root, full).split(path.sep).join('/')}); continue; }
      if (entry.isDirectory()) found.push(...walk(full, state));
      else if (entry.isFile() && entry.name.toLowerCase().endsWith('.pdf')) found.push(full);
    }
  } finally { handle.closeSync(); }
  return found;
}
async function hashFile(file) {
  const handle = await fs.promises.open(file, 'r');
  const started = Date.now();
  try {
    const stat = await handle.stat();
    if (!stat.isFile() || stat.size > MAX_PDF_BYTES) throw new Error('hash size limit exceeded');
    const hash = crypto.createHash('sha256');
    const buffer = Buffer.allocUnsafe(1024 * 1024);
    let position = 0;
    while (position < stat.size) {
      if (Date.now() - started > 60_000) throw new Error('hash timeout');
      const length = Math.min(buffer.length, stat.size - position);
      const {bytesRead} = await handle.read(buffer, 0, length, position);
      if (bytesRead < 1) throw new Error('unexpected EOF while hashing');
      hash.update(buffer.subarray(0, bytesRead));
      position += bytesRead;
    }
    return hash.digest('hex');
  } finally { await handle.close(); }
}
function safeError(result) {
  const detail = `${result.error || ''}${result.signal ? `:${result.signal}` : ''}`;
  return detail || 'unknown_error';
}
function hasPdfHeader(file) {
  const handle = fs.openSync(file, 'r');
  try {
    const header = Buffer.alloc(5);
    const bytesRead = fs.readSync(handle, header, 0, header.length, 0);
    return bytesRead === 5 && header.toString('ascii') === '%PDF-';
  } finally {
    fs.closeSync(handle);
  }
}

const tools = {pdfinfo: version('pdfinfo'), pdftotext: version('pdftotext'), pdfimages: version('pdfimages')};
const report = {schema_version: 1, tool: {name: 'inventory-pdfs', version: '1.0.0', node: process.version}, generated_at: new Date().toISOString(), root: '.', limits: {max_walk_entries: MAX_WALK_ENTRIES, max_pdf_count: MAX_PDF_COUNT, max_pdf_bytes: MAX_PDF_BYTES, max_total_pdf_bytes: MAX_TOTAL_PDF_BYTES, max_pages_per_pdf: MAX_PAGES_PER_PDF, max_total_pages: MAX_TOTAL_PAGES}, tools, pdf_count: 0, pdfs: [], errors: []};
for (const [name, details] of Object.entries(tools)) {
  if (!details.available) report.errors.push({code: 'MISSING_OR_BROKEN_TOOL', tool: name, reason: details.error});
}
let pdfs = [];
try { pdfs = walk(root).sort(); }
catch { discoveryErrors.push({code: 'WALK_ENTRY_LIMIT_EXCEEDED', limit: MAX_WALK_ENTRIES}); }
report.errors.push(...discoveryErrors);
report.pdf_count = pdfs.length;
const totalPdfBytes = pdfs.reduce((sum, file) => sum + fs.statSync(file).size, 0);
if (pdfs.length === 0) report.errors.push({code: 'NO_PDFS_FOUND'});
if (pdfs.length > MAX_PDF_COUNT) report.errors.push({code: 'PDF_COUNT_LIMIT_EXCEEDED', actual: pdfs.length, limit: MAX_PDF_COUNT});
if (totalPdfBytes > MAX_TOTAL_PDF_BYTES) report.errors.push({code: 'PDF_TOTAL_BYTES_LIMIT_EXCEEDED', actual: totalPdfBytes, limit: MAX_TOTAL_PDF_BYTES});
let totalPages = 0;

for (const file of pdfs.length > MAX_PDF_COUNT || totalPdfBytes > MAX_TOTAL_PDF_BYTES ? [] : pdfs) {
    const relative = path.relative(root, file).split(path.sep).join('/');
    const statBefore = fs.statSync(file);
    if (statBefore.size > MAX_PDF_BYTES) {
      const error = {stage: 'limits', reason: 'pdf_size_limit_exceeded'};
      report.pdfs.push({file: relative, sha256: null, bytes: statBefore.size, mtime_ms: statBefore.mtimeMs, pdf_header_valid: null, pages: null, text_bytes: null, image_rows: null, raster_risk: 'unknown', high_risk_pages: null, page_evidence: [], errors: [error]});
      report.errors.push({code: 'PDF_LIMIT_EXCEEDED', file: relative, ...error});
      continue;
    }
    const hashBefore = await hashFile(file);
    const headerOk = hasPdfHeader(file);
    const info = headerOk && tools.pdfinfo.available ? command('pdfinfo', [file]) : {ok: false, stdout: '', stderr: '', error: headerOk ? 'tool_unavailable' : 'invalid_pdf_header', signal: null};
    const pages = info.ok ? Number(info.stdout.match(/^Pages:\s+(\d+)/m)?.[1] || 0) : 0;
    if (Number.isInteger(pages) && pages > 0) totalPages += pages;
    const pageLimitExceeded = pages > MAX_PAGES_PER_PDF || totalPages > MAX_TOTAL_PAGES;
    const text = headerOk && tools.pdftotext.available && !pageLimitExceeded ? command('pdftotext', [file, '-']) : {ok: false, stdout: '', stderr: '', error: pageLimitExceeded ? 'page_limit_exceeded' : headerOk ? 'tool_unavailable' : 'invalid_pdf_header', signal: null};
    const images = headerOk && tools.pdfimages.available && !pageLimitExceeded ? command('pdfimages', ['-list', file]) : {ok: false, stdout: '', stderr: '', error: pageLimitExceeded ? 'page_limit_exceeded' : headerOk ? 'tool_unavailable' : 'invalid_pdf_header', signal: null};
    const errors = [];
    if (!info.ok) errors.push({stage: 'pdfinfo', reason: safeError(info)});
    else if (!Number.isInteger(pages) || pages < 1) errors.push({stage: 'pdfinfo', reason: 'invalid_page_count'});
    else if (pages > MAX_PAGES_PER_PDF) errors.push({stage: 'limits', reason: 'page_limit_exceeded'});
    else if (totalPages > MAX_TOTAL_PAGES) errors.push({stage: 'limits', reason: 'total_page_limit_exceeded'});
    if (!text.ok) errors.push({stage: 'pdftotext', reason: safeError(text)});
    if (!images.ok) errors.push({stage: 'pdfimages', reason: safeError(images)});
    const statAfter = fs.statSync(file);
    const hashAfter = await hashFile(file);
    const sourceChanged = hashBefore !== hashAfter || statBefore.size !== statAfter.size || statBefore.mtimeMs !== statAfter.mtimeMs;
    if (sourceChanged) errors.push({stage: 'snapshot', reason: 'source_changed_during_inventory'});

    const textPages = text.ok ? text.stdout.split('\f') : [];
    if (textPages.length > pages && textPages.at(-1) === '') textPages.pop();
    const imageRowsByPage = new Map();
    if (images.ok) {
      for (const line of images.stdout.split('\n')) {
        const match = line.match(/^\s*(\d+)\s+\d+\s+/);
        if (!match) continue;
        const pageNumber = Number(match[1]);
        imageRowsByPage.set(pageNumber, (imageRowsByPage.get(pageNumber) || 0) + 1);
      }
    }
    const pageEvidence = [];
    if (Number.isInteger(pages) && pages > 0 && !pageLimitExceeded) {
      for (let pageNumber = 1; pageNumber <= pages; pageNumber += 1) {
        const pageText = textPages[pageNumber - 1] || '';
        const textBytes = Buffer.byteLength(pageText);
        pageEvidence.push({
          page_number: pageNumber,
          text_bytes: text.ok ? textBytes : null,
          image_rows: images.ok ? (imageRowsByPage.get(pageNumber) || 0) : null,
          raster_risk: !text.ok ? 'unknown' : textBytes < 50 ? 'high' : 'low',
        });
      }
    }
    const highRiskPages = pageEvidence.filter(page => page.raster_risk === 'high').length;
    const row = {
      file: relative,
      sha256: sourceChanged ? null : hashBefore,
      sha256_before: hashBefore,
      sha256_after: hashAfter,
      bytes: statAfter.size,
      mtime_ms: statAfter.mtimeMs,
      snapshot_stable: !sourceChanged,
      pdf_header_valid: headerOk,
      pages: pages || null,
      text_bytes: text.ok ? Buffer.byteLength(text.stdout) : null,
      image_rows: images.ok ? [...imageRowsByPage.values()].reduce((sum, count) => sum + count, 0) : null,
      raster_risk: !text.ok || pages < 1 ? 'unknown' : highRiskPages > 0 ? 'high' : 'low',
      high_risk_pages: !text.ok || pages < 1 ? null : highRiskPages,
      page_evidence: pageEvidence,
      errors,
    };
    report.pdfs.push(row);
    for (const error of errors) report.errors.push({code: 'PDF_INSPECTION_FAILED', file: relative, ...error});
}
report.inventory_complete = report.errors.length === 0 && report.pdfs.length === report.pdf_count;
const output = `${JSON.stringify(report, null, 2)}\n`;
if (outPath) {
  fs.mkdirSync(path.dirname(outPath), {recursive: true});
  const temp = `${outPath}.tmp-${process.pid}`;
  fs.writeFileSync(temp, output, {flag: 'wx'});
  fs.renameSync(temp, outPath);
}
process.stdout.write(output);
process.exitCode = report.inventory_complete ? 0 : 1;

SHA-256: 83ad8e445881aeebc46fb22ba190396bbf11120eb41c5fb3c61868a45237a23a