chk_defaced 0.1.0

Detect 'defaced' documents: verify the coherence of the fonts used to render text (PDF, DOCX, HTML+CSS) and build a JSON index of the system fonts' cmaps.
Documentation
//! `chk_defaced` — verifies the coherence of the fonts used to render documents (PDF/DOCX/HTML) and
//! builds a JSON index of the system fonts' cmaps.
//!
//! Background and motivation: "What you see is not what your AI reads"
//! <https://dariofinardi.it/what-you-see-is-not-what-your-ai-reads-c3fed388d3bc>.
//!
//! This is the v1: a registry builder + deterministic font-coherence checks (no OCR/browser). The
//! authoritative, false-positive-free verdict (comparing the outline drawn for a codepoint against the
//! canonical outline for that codepoint) is the next phase — see the README.

use std::path::PathBuf;
use std::process::ExitCode;

use anyhow::Result;
use clap::{Parser, Subcommand};

use chk_defaced::finding::{Report, Severity};
use chk_defaced::registry::FontRegistry;
use chk_defaced::scan;

#[derive(Parser)]
#[command(name = "chk_defaced", version, about = "Detect 'defaced' documents and index the system fonts' cmaps")]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Scan a fonts directory (default: the system fonts) and emit a JSON index of the cmaps.
    BuildRegistry {
        /// Fonts directory to index (default: the system fonts directory).
        #[arg(long)]
        fonts_dir: Option<PathBuf>,
        /// Output JSON file.
        #[arg(short, long, default_value = "fonts-index.json")]
        out: PathBuf,
        /// Omit the full cmaps (identity + hashes only): a much smaller index.
        #[arg(long)]
        slim: bool,
        /// Pretty-printed JSON (larger, human-readable).
        #[arg(long)]
        pretty: bool,
    },
    /// Verify the font coherence of one or more documents (.pdf/.docx/.html).
    Scan {
        /// Files to analyze.
        paths: Vec<PathBuf>,
        /// Reference JSON font index (from `build-registry`) used to spot modified fonts.
        #[arg(long)]
        registry: Option<PathBuf>,
        /// Output format: human | json.
        #[arg(long, default_value = "human")]
        format_out: String,
        /// Escalate to specimen-OCR on documents where the deterministic pass finds no semantic
        /// replacement — catches custom fonts with no honest anchor. Needs the `ocr-specimen` build
        /// and a tessdata directory; degrades gracefully (warns and continues) if OCR can't initialize.
        #[arg(long)]
        ocr: bool,
        /// OCR language(s) for `--ocr` (Tesseract `+`-joined form).
        #[arg(long, default_value = "ita+eng")]
        ocr_lang: String,
    },
}

fn default_fonts_dir() -> PathBuf {
    if let Ok(windir) = std::env::var("WINDIR") {
        return PathBuf::from(windir).join("Fonts");
    }
    // Reasonable fallbacks on other platforms.
    for p in [r"C:\Windows\Fonts", "/usr/share/fonts", "/System/Library/Fonts"] {
        let pb = PathBuf::from(p);
        if pb.is_dir() {
            return pb;
        }
    }
    PathBuf::from(r"C:\Windows\Fonts")
}

fn run() -> Result<ExitCode> {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::BuildRegistry { fonts_dir, out, slim, pretty } => {
            let dir = fonts_dir.unwrap_or_else(default_fonts_dir);
            eprintln!("Indexing fonts in {} …", dir.display());
            let reg = FontRegistry::build_from_dir(&dir, !slim)?;
            reg.save_json(&out, pretty)?;
            eprintln!(
                "Indexed {} font faces ({} unparsable files skipped) → {}",
                reg.count,
                reg.skipped.len(),
                out.display()
            );
            Ok(ExitCode::SUCCESS)
        }
        Cmd::Scan { paths, registry, format_out, ocr, ocr_lang } => {
            if paths.is_empty() {
                anyhow::bail!("no files to analyze");
            }
            let reg = match registry {
                Some(p) => Some(FontRegistry::load_json(&p)?),
                None => None,
            };

            // Optional OCR escalation engine (one per run). Only with the `ocr-specimen` build.
            #[cfg(feature = "ocr-specimen")]
            let ocr_engine = if ocr {
                use chk_defaced::ocr::{OcrHint, TesseractOcr};
                match TesseractOcr::new(None, &ocr_lang, OcrHint::SingleLine) {
                    Ok(e) => Some(e),
                    Err(e) => {
                        eprintln!("[warn] --ocr disabled: could not initialize Tesseract ({e:#})");
                        None
                    }
                }
            } else {
                None
            };
            #[cfg(not(feature = "ocr-specimen"))]
            if ocr {
                eprintln!("[warn] --ocr ignored: rebuild with `--features ocr-specimen`");
            }
            let _ = &ocr_lang; // used only with the ocr-specimen feature

            let scan_one = |path: &std::path::Path| -> Result<Report> {
                #[cfg(feature = "ocr-specimen")]
                if let Some(engine) = ocr_engine.as_ref() {
                    return scan::scan_path_with_ocr(path, reg.as_ref(), engine);
                }
                scan::scan_path(path, reg.as_ref())
            };

            let mut reports = Vec::new();
            for path in &paths {
                match scan_one(path) {
                    Ok(r) => reports.push(r),
                    Err(e) => eprintln!("[error] {}: {e:#}", path.display()),
                }
            }
            if format_out == "json" {
                println!("{}", serde_json::to_string_pretty(&reports)?);
            } else {
                for r in &reports {
                    print_human(r);
                }
            }
            // CI-friendly exit code: non-zero if any finding is High or worse.
            let worst = reports.iter().filter_map(|r| r.max_severity()).max();
            Ok(if matches!(worst, Some(s) if s >= Severity::High) {
                ExitCode::from(1)
            } else {
                ExitCode::SUCCESS
            })
        }
    }
}

fn print_human(r: &Report) {
    println!("\n=== {} [{}] — {} font(s) examined ===", r.file, r.format, r.fonts_examined);
    if r.findings.is_empty() {
        println!("  no anomalies detected ✓");
        return;
    }
    let mut findings: Vec<_> = r.findings.iter().collect();
    findings.sort_by(|a, b| b.severity.cmp(&a.severity));
    for f in findings {
        println!(
            "  [{:?}] {} — {} ({}) conf={:.2}",
            f.severity, f.rule, f.message, f.location, f.confidence
        );
    }
}

fn main() -> ExitCode {
    match run() {
        Ok(code) => code,
        Err(e) => {
            eprintln!("error: {e:#}");
            ExitCode::from(2)
        }
    }
}