import { access, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import sharp from 'sharp'; import { fileTypeFromFile } from 'file-type'; import { createWorker, OEM } from 'tesseract.js'; import { BinaryBitmap, HybridBinarizer, MultiFormatReader, NotFoundException, RGBLuminanceSource, } from '@zxing/library'; const normalize = (value) => value.normalize('NFKC').toLocaleLowerCase('en-US'); const fail = (rule, location = '') => { throw new Error(location ? `${rule}:${location}` : rule); }; export const loadDenylist = async (path) => { let text; try { text = await readFile(path, 'utf8'); } catch { fail('PRIVACY-DENYLIST-001'); } const terms = [...new Set(text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map(normalize))]; if (terms.length === 0 || terms.some((term) => term.length < 3 || /[\u0000-\u001f\u007f]/u.test(term))) { fail('PRIVACY-DENYLIST-001'); } return terms; }; export const scanText = (bytesOrText, denylist, rule = 'PRIVACY-BYTES-001') => { const text = normalize(Buffer.isBuffer(bytesOrText) || bytesOrText instanceof Uint8Array ? Buffer.from(bytesOrText).toString('utf8') : String(bytesOrText)); if (denylist.some((term) => text.includes(normalize(term)))) fail(rule); }; const scanBarcode = async (path) => { let decoded; try { decoded = await sharp(path).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true }); } catch { fail('ASSET-DECODE-001'); } const { data, info } = decoded; const luminance = new RGBLuminanceSource(new Uint8ClampedArray(data), info.width, info.height); const bitmap = new BinaryBitmap(new HybridBinarizer(luminance)); try { new MultiFormatReader().decode(bitmap); fail('PRIVACY-BARCODE-001'); } catch (error) { if (error instanceof NotFoundException || error?.name === 'NotFoundException') return; if (error instanceof Error && error.message === 'PRIVACY-BARCODE-001') throw error; fail('PRIVACY-BARCODE-002'); } }; const scanOcr = async (path, denylist) => { const candidates = ['/usr/share/tessdata', '/usr/share/tesseract-ocr/5/tessdata']; let langPath; for (const candidate of candidates) { try { await access(join(candidate, 'eng.traineddata')); langPath = candidate; break; } catch {} } if (!langPath) fail('PRIVACY-OCR-002'); let worker; try { worker = await createWorker('eng', OEM.LSTM_ONLY, { langPath, gzip: false, cacheMethod: 'none' }); } catch { fail('PRIVACY-OCR-002'); } let timer; try { const result = await Promise.race([ worker.recognize(path), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 25_000); }), ]); scanText(result.data.text, denylist, 'PRIVACY-OCR-001'); } catch (error) { if (error instanceof Error && error.message === 'PRIVACY-OCR-001') throw error; fail('PRIVACY-OCR-002'); } finally { if (timer !== undefined) clearTimeout(timer); await worker.terminate().catch(() => {}); } }; export const scanImage = async (path, denylist, options = {}) => { let type; try { type = await fileTypeFromFile(path); } catch { fail('PRIVACY-TYPE-001'); } if (!type || !['image/png', 'image/webp', 'image/jpeg'].includes(type.mime)) fail('PRIVACY-TYPE-001'); let metadata; try { metadata = await sharp(path, { failOn: 'error', limitInputPixels: 50_000_000 }).metadata(); } catch { fail('ASSET-DECODE-001'); } if (metadata.exif || metadata.icc || metadata.iptc || metadata.xmp || metadata.orientation !== undefined) { fail('PRIVACY-METADATA-001'); } scanText(await readFile(path), denylist); await scanBarcode(path); if (options.runOcr !== false) await scanOcr(path, denylist); return { width: metadata.width, height: metadata.height, mediaType: type.mime }; }; export const scanBinary = async (path, denylist) => { let bytes; try { bytes = await readFile(path); } catch { fail('PRIVACY-TYPE-001'); } scanText(bytes, denylist); let type; try { type = await fileTypeFromFile(path); } catch { fail('PRIVACY-TYPE-001'); } if (!type || !['video/webm', 'video/mp4'].includes(type.mime)) fail('PRIVACY-TYPE-001'); return { mediaType: type.mime }; };