/** * Data integrity audit — compares current data/ against a git reference. * Detects: missing files, truncated content, degraded JSON arrays/objects, invalid JSON. * * CLI: bun run src/server/integrity-audit.ts [git-ref] [data-dir] * API: import { audit } from './integrity-audit.ts' (async — never blocks the event loop) * Remote: run `bun run src/server/integrity-audit.ts` from $APP_DIR on the target host. * * When no ref is given, picks the most recent "auto: sync" commit as baseline. * Exit code 1 if issues found. * * Proportional by design: only files that DIFFER between ref and HEAD are examined (an unchanged * file can't be missing, truncated or degraded, and its JSON validity was judged when it changed). * Work is ≤3 git processes total — `diff --raw`, one `cat-file --batch-check` (byte sizes for the * truncation check) and one `cat-file --batch` for changed .json only (≤16MB each) — regardless of * repo size. Non-JSON blobs (media/binaries) are never loaded into memory. * The old version ran `git show` via execSync for every tracked file (x2, plus every .json): on a * 4.4k-file prod data repo that froze the server's event loop for ~5 min after every restart. */ import { resolve } from 'node:path'; import { readdirSync } from 'node:fs'; // Self-contained: resolve DATA_DIR without importing paths.ts so the script works standalone. // Guard (mirrors paths.ts): never silently fall back to bare ./data when named env dirs (data-*) // exist — that targets a stale, env-less data/ folder. An explicit [data-dir]/DATA_DIR overrides. function resolveDataDir(): string { if (process.env.DATA_DIR) return process.env.DATA_DIR; const root = process.cwd(); if (readdirSync(root).some((f) => f.startsWith('data-'))) throw new Error('DATA_DIR not set but named data dirs exist (data-*). Pass [data-dir] or set DATA_DIR.'); return resolve(root, 'data'); } const SIZE_RATIO = 0.5; const COUNT_SLACK = 2; const STRUCTURAL_JSON = ['schedules.json', 'contacts.json', 'skills-defaults.json', 'api-keys.json']; const MAX_JSON_BYTES = 16 * 1024 * 1024; // larger .json isn't read/parsed (bounds memory) const GITLINK = '160000'; // submodule entry — not a blob const ABSENT = '000000'; // side of an add/delete async function git(dir: string, args: string[], input?: string): Promise { const proc = Bun.spawn(['git', '-C', dir, ...args], { stdin: input === undefined ? 'ignore' : new Blob([input]), stdout: 'pipe', stderr: 'pipe', }); const [out, err] = await Promise.all([ new Response(proc.stdout).arrayBuffer(), new Response(proc.stderr).text(), ]); const code = await proc.exited; if (code !== 0) throw new Error(`git ${args[0]} failed (${code}): ${err.trim()}`); return new Uint8Array(out); } // ignoreBOM keeps a leading BOM in the text, matching the old execSync decode (a BOM'd .json is invalid). const decoder = new TextDecoder('utf-8', { ignoreBOM: true }); /** Byte sizes of many objects through ONE `git cat-file --batch-check` (no content read). Missing omitted. */ async function blobSizes(dir: string, shas: string[]): Promise> { const sizes = new Map(); if (!shas.length) return sizes; for (const line of decoder.decode(await git(dir, ['cat-file', '--batch-check'], shas.join('\n') + '\n')).split('\n')) { const [sha, type, size] = line.split(' '); if (type && type !== 'missing' && size !== undefined) sizes.set(sha, Number(size)); } return sizes; } /** Read many blobs through ONE `git cat-file --batch` process. Missing objects are omitted. */ async function readBlobs(dir: string, shas: string[]): Promise> { const blobs = new Map(); if (!shas.length) return blobs; const buf = await git(dir, ['cat-file', '--batch'], shas.join('\n') + '\n'); let pos = 0; while (pos < buf.length) { const nl = buf.indexOf(10, pos); if (nl < 0) break; const [sha, type, size] = decoder.decode(buf.subarray(pos, nl)).split(' '); pos = nl + 1; if (type === 'missing' || size === undefined) continue; const n = Number(size); blobs.set(sha, decoder.decode(buf.subarray(pos, pos + n))); pos += n + 1; // content + trailing LF } return blobs; } function jsonEntryCount(text: string): number | null { try { const v = JSON.parse(text); if (Array.isArray(v)) return v.length; if (v && typeof v === 'object') return Object.keys(v).length; return null; } catch { return null; } } export interface Issue { file: string; kind: 'missing' | 'truncated' | 'degraded' | 'invalid-json'; detail: string; } export async function audit(ref: string, dataDir = resolveDataDir()): Promise { // `:oldmode newmode oldsha newsha status\0path\0` per changed path (renames split into D + A). // --relative: scope to dataDir and report paths relative to it (dataDir may be a subfolder of the repo). const tokens = decoder.decode(await git(dataDir, ['diff', '--raw', '-z', '--no-renames', '--no-abbrev', '--relative', ref, 'HEAD'])).split('\0'); const changes: { file: string; oldMode: string; newMode: string; oldSha: string; newSha: string }[] = []; for (let i = 0; i + 1 < tokens.length; i += 2) { const [oldMode, newMode, oldSha, newSha] = tokens[i].slice(1).split(' '); changes.push({ file: tokens[i + 1], oldMode, newMode, oldSha, newSha }); } const isBlob = (mode: string) => mode !== GITLINK && mode !== ABSENT; // Sizes for every changed blob (cheap: headers only); content ONLY for small .json that needs parsing. // Loading every changed blob (media/binaries) into memory spiked RSS by GBs on large syncs. const sizes = await blobSizes(dataDir, [...new Set(changes.flatMap(c => [ ...(isBlob(c.oldMode) ? [c.oldSha] : []), ...(isBlob(c.newMode) ? [c.newSha] : []), ]))]); const wanted = new Set(); const small = (sha: string) => (sizes.get(sha) ?? Infinity) <= MAX_JSON_BYTES; const skipped = (file: string, sha: string) => console.warn(`[integrity-audit] skipped ${file}: ${sizes.get(sha)}B exceeds the ${MAX_JSON_BYTES}B JSON cap — not validated`); for (const c of changes) { if (!c.file.endsWith('.json') || !isBlob(c.newMode)) continue; if (!small(c.newSha)) { skipped(c.file, c.newSha); continue; } wanted.add(c.newSha); if (!isBlob(c.oldMode) || !STRUCTURAL_JSON.includes(c.file)) continue; if (small(c.oldSha)) wanted.add(c.oldSha); else skipped(c.file, c.oldSha); } const blobs = await readBlobs(dataDir, [...wanted]); const issues: Issue[] = []; const jsonIssues: Issue[] = []; for (const { file: f, oldMode, newMode, oldSha, newSha } of changes) { if (isBlob(oldMode) && !isBlob(newMode)) { issues.push({ file: f, kind: 'missing', detail: 'in reference but not HEAD' }); continue; } const refSize = isBlob(oldMode) ? sizes.get(oldSha) : undefined; const curSize = isBlob(newMode) ? sizes.get(newSha) : undefined; const curContent = isBlob(newMode) ? blobs.get(newSha) : undefined; const refContent = isBlob(oldMode) ? blobs.get(oldSha) : undefined; // Size check (empty blobs are skipped, as before) if (refSize && curSize && refSize > 200 && curSize < refSize * SIZE_RATIO) { issues.push({ file: f, kind: 'truncated', detail: `${refSize}B → ${curSize}B (${Math.round(curSize / refSize * 100)}%)` }); } if (refContent && curContent) { // Structural JSON check if (STRUCTURAL_JSON.includes(f)) { const refCount = jsonEntryCount(refContent); const curCount = jsonEntryCount(curContent); if (refCount !== null && curCount !== null && curCount < refCount - COUNT_SLACK) { issues.push({ file: f, kind: 'degraded', detail: `${refCount} → ${curCount} entries` }); } } } // Invalid JSON check on added/modified .json files if (curContent && f.endsWith('.json')) { try { JSON.parse(curContent); } catch { jsonIssues.push({ file: f, kind: 'invalid-json', detail: 'parse error' }); } } } return [...issues, ...jsonIssues]; } export async function findBaselineRef(dataDir = resolveDataDir()): Promise { try { const log = decoder.decode(await git(dataDir, ['log', '-50', '--format=%H %s'])); for (const line of log.split('\n')) { const [hash, ...rest] = line.split(' '); const msg = rest.join(' '); if (msg.startsWith('auto: sync')) return hash; } } catch { /* no git history */ } return 'HEAD~1'; } // ── CLI ──────────────────────────────────────────────────────────────────── if (import.meta.main) { const dataDir = process.argv[3] || resolveDataDir(); const ref = process.argv[2] || await findBaselineRef(dataDir); console.log(`data-audit: HEAD vs ${ref.slice(0, 8)} (${dataDir})\n`); const issues = await audit(ref, dataDir); if (!issues.length) { console.log('✓ No issues found'); process.exit(0); } console.log(`⚠ ${issues.length} issue(s):\n`); for (const { kind, file, detail } of issues) { console.log(` ${kind.padEnd(12)} ${file} — ${detail}`); } process.exit(1); }