From 898d5fcf64dbf636de7496222ebddd073e4ae3a0 Mon Sep 17 00:00:00 2001 From: athenanewsapi <192553512+athenanewsapi@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:41:29 -0400 Subject: [PATCH 1/2] ocr: local OCR tier for scanned PDFs and images (pure-Rust ocrs) ScannedPdf branch now OCRs embedded page images (lopdf walk, 50-page cap, method pdf-ocr with pages_ocred); new PNG/JPEG claims -> image-ocr; typed failures; models (~12MB) fetch-once to ~/.cache/ocrs (VERITY_OCR_MODEL_DIR override); default-ON cargo feature with clean opt-out; extraction moved to spawn_blocking; png/jpeg mimes in the connector gate + MCP image lane; HONESTY.md: local, printed-text-grade. Real-engine e2e (VERITY_OCR_E2E=1) recognizes rendered text from PNG and scanned PDF. 33 extract tests green. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 311 ++++++++++ Cargo.toml | 38 ++ HONESTY.md | 13 + crates/verity-mcp/src/main.rs | 60 +- crates/verity-server/Cargo.toml | 21 +- crates/verity-server/src/extract.rs | 545 +++++++++++++++++- crates/verity-server/src/main.rs | 21 +- crates/verity-server/src/media.rs | 37 +- crates/verity-server/src/ocr.rs | 396 +++++++++++++ .../verity-server/src/testdata/ocr-sample.jpg | Bin 0 -> 78168 bytes .../verity-server/src/testdata/ocr-sample.png | Bin 0 -> 64321 bytes ingest/tests/test_gdrive.py | 16 +- ingest/verity_ingest/connectors/gdrive.py | 26 +- 13 files changed, 1407 insertions(+), 77 deletions(-) create mode 100644 crates/verity-server/src/ocr.rs create mode 100644 crates/verity-server/src/testdata/ocr-sample.jpg create mode 100644 crates/verity-server/src/testdata/ocr-sample.png diff --git a/Cargo.lock b/Cargo.lock index 100f107..e283122 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,12 +405,24 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -922,6 +934,37 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.8.1" @@ -1150,6 +1193,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "file-id" version = "0.2.3" @@ -1175,6 +1227,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flatbuffers" +version = "24.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +dependencies = [ + "bitflags 1.3.2", + "rustc_version", +] + [[package]] name = "flate2" version = "1.1.9" @@ -1831,6 +1893,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1930,6 +2007,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2099,20 +2229,24 @@ dependencies = [ "aes", "bitflags 2.13.0", "cbc", + "chrono", "ecb", "encoding_rs", "flate2", "getrandom 0.4.3", "indexmap 2.14.0", "itoa", + "jiff", "log", "md-5 0.10.6", "nom 8.0.0", "rand 0.10.2", "rangemap", + "rayon", "sha2 0.10.9", "stringprep", "thiserror 2.0.18", + "time", "ttf-parser", "weezl", ] @@ -2296,6 +2430,16 @@ dependencies = [ "syn", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -2504,6 +2648,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ocrs" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5379fdd3f11522b5a2ff53017a189463dabf5d0a9c915cb3eb97fabec4ea11c" +dependencies = [ + "anyhow", + "rayon", + "rten", + "rten-imageproc", + "rten-tensor", + "thiserror 2.0.18", + "wasm-bindgen", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2731,6 +2890,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2835,6 +3007,12 @@ dependencies = [ "prost", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "qdrant-client" version = "1.18.0" @@ -3309,6 +3487,113 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rten" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c230fa4ade87c913f61dbd911b7eb0d49460ceff3f1e4fabc837fac191137c" +dependencies = [ + "flatbuffers", + "num_cpus", + "rayon", + "rten-base", + "rten-gemm", + "rten-model-file", + "rten-onnx", + "rten-shape-inference", + "rten-simd", + "rten-tensor", + "rten-vecmath", + "rustc-hash", + "smallvec", + "typeid", + "wasm-bindgen", +] + +[[package]] +name = "rten-base" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2738cf8bb4c27f828ac788d01ccf4e367e8e773cfec6851f81851b5211de6a79" +dependencies = [ + "rayon", +] + +[[package]] +name = "rten-gemm" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a81a0ca209fb5ce21bd17efa0bd287d5881c6cebfbff0b21c4294a1a14a9e" +dependencies = [ + "rayon", + "rten-base", + "rten-simd", + "rten-tensor", +] + +[[package]] +name = "rten-imageproc" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f148e7e941fb5727b9046a5fa1b45525543d5105f14b384fd9261df0ee49bc" +dependencies = [ + "rten-tensor", +] + +[[package]] +name = "rten-model-file" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2f8d270f07ab1bbfff47250c6039f6caa5da59d6da7d74f66aa48559aa6fea" +dependencies = [ + "flatbuffers", + "rten-base", +] + +[[package]] +name = "rten-onnx" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23086eef75bfb55278cb0b45cf9f5a877d466d914914aafebee4ffca9b24d20c" + +[[package]] +name = "rten-shape-inference" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8a913c7ca40e2bfbb2a0cd447cce56b33ab19435f56693271a2ef37cf58984" +dependencies = [ + "rten-tensor", + "smallvec", +] + +[[package]] +name = "rten-simd" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19a0032dfcb70dd20960c1c51a37674b237586cbc1ce586f45b46605d108e82" + +[[package]] +name = "rten-tensor" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05dc744a270aa32d154f1a3df8e48740ccc1be9dfbcf23295ada66d83aa98de6" +dependencies = [ + "rayon", + "rten-base", + "smallvec", + "typeid", +] + +[[package]] +name = "rten-vecmath" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9574ddebf5671bc08ceb76e2e1638fadc57fdeff318634eab2c29e9a803cff64" +dependencies = [ + "rten-base", + "rten-simd", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -4505,6 +4790,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" @@ -4797,15 +5088,19 @@ dependencies = [ "clap", "futures-util", "hmac 0.12.1", + "image", + "lopdf", "moka", "notify", "notify-debouncer-full", "object_store", + "ocrs", "pdf-extract", "pgvector", "quick-xml 0.41.0", "rand_core 0.6.4", "reqwest 0.12.28", + "rten", "serde", "serde_json", "serde_yaml_ng", @@ -4815,6 +5110,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "ureq", "uuid", "verity-core", "verity-encoder", @@ -5535,3 +5831,18 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 6500a48..2fd564b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,23 @@ cfb = "0.10" # lopdf — the part we refuse to reimplement. Known to panic on hostile files; # every call is wrapped in catch_unwind (see extract.rs). pdf-extract = "0.12" +# OCR tier (ocr.rs): ocrs + rten — pure-Rust OCR. No tesseract or any system +# dependency (that would break the clean-VM stranger gate), no cloud OCR. +# Models (~12 MB total) download once from the ocrs project's canonical bucket +# into ~/.cache/ocrs — the same fetch-once-then-cache lane as the MiniLM +# encoder (which uses the hf-hub default cache). rten must track the exact +# version the ocrs release depends on, or Model types won't line up. +ocrs = "0.12" +rten = "0.24" +# image: PNG/JPEG decode for standalone image OCR and embedded PDF images, +# plus JPEG *encoding* in test fixtures. Decode-only features, pure Rust. +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +# lopdf: pinned to the same version pdf-extract already compiles, so walking a +# scanned PDF's page/image tree reuses the one PDF parser in the tree. +lopdf = "0.42" +# ureq: blocking OCR-model download (extraction is synchronous code); already +# in the tree transitively via hf-hub. +ureq = "3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] } schemars = { version = "1", features = ["chrono04"] } # Local-folder watching (folder_watch.rs): cross-platform FS events @@ -78,3 +95,24 @@ schemars = { version = "1", features = ["chrono04"] } # write bursts and mid-write partials fire once, after the file is quiescent. notify = "6" notify-debouncer-full = "0.3" + +# OCR inference is numeric hot-loop work; unoptimized rten is ~50x slower, +# which makes dev-profile scanned-PDF ingestion (and the VERITY_OCR_E2E test) +# crawl. Optimizing JUST the rten family in dev keeps `cargo build` fast for +# our own code while making local OCR usable. Release builds are unaffected. +[profile.dev.package.rten] +opt-level = 3 +[profile.dev.package.rten-base] +opt-level = 3 +[profile.dev.package.rten-gemm] +opt-level = 3 +[profile.dev.package.rten-imageproc] +opt-level = 3 +[profile.dev.package.rten-simd] +opt-level = 3 +[profile.dev.package.rten-tensor] +opt-level = 3 +[profile.dev.package.rten-vecmath] +opt-level = 3 +[profile.dev.package.ocrs] +opt-level = 3 diff --git a/HONESTY.md b/HONESTY.md index ffab946..c994fb8 100644 --- a/HONESTY.md +++ b/HONESTY.md @@ -98,6 +98,19 @@ source that has **never** heartbeated fails closed while the gate is on (never-s indistinguishable from stalled). With the gate off — the default — assume a stalled connector serves ACLs as stale as the stall is long, and monitor the heartbeats. +## OCR is local and printed-text-grade, not a document-AI service + +Scanned PDFs and PNG/JPEG images are extracted by a fully local, pure-Rust OCR engine +([ocrs](https://github.com/robertknight/ocrs) on rten; ~12 MB of models fetched once into +`~/.cache/ocrs`, no cloud calls, no system dependencies). It reads printed type well and is +**best-effort beyond that**: expect it to miss handwriting, low-resolution scans, stylized +layouts, and non-Latin scripts. Every extraction receipt discloses the method — `pdf-ocr` +(with `pages_ocred`, capped at 50 pages) or `image-ocr` — so OCR-derived text is never +passed off as a document's own text layer, and a file where OCR finds nothing lands +metadata-only with a typed reason rather than silently indexing empty. Encrypted PDFs are +still declined outright. Unsupported embedded encodings (CCITT/JBIG2/JPEG 2000) are +skipped, not guessed at. + ## No users, no SOC 2, no hosted cloud There are no production users yet. There is no SOC 2 (or any) compliance attestation. There diff --git a/crates/verity-mcp/src/main.rs b/crates/verity-mcp/src/main.rs index 516134d..3695f5d 100644 --- a/crates/verity-mcp/src/main.rs +++ b/crates/verity-mcp/src/main.rs @@ -234,8 +234,9 @@ struct IngestTextParams { struct IngestFileParams { /// Scope handle from memory_open_scope. scope_handle: String, - /// Path to a LOCAL UTF-8 text file. Allowed extensions: .txt, .md, - /// .json, .csv, .html. Maximum size: 512 KB. + /// Path to a LOCAL file. Allowed extensions: .txt, .md, .json, .csv, + /// .html (UTF-8 text) and .png, .jpg, .jpeg (server-side local OCR). + /// Maximum size: 512 KB. path: String, /// Entity tags, e.g. ["account:acme-corp"]. Must be inside the scope's /// entity_scope; omit to inherit the whole scope. @@ -363,22 +364,20 @@ impl VerityMcp { } /// Multipart POST /v1/files: fields `scope_handle`, `entities` - /// (comma-separated, only when tags were given), and `file`. + /// (comma-separated, only when tags were given), and `file`. The part is + /// caller-built: text content for the UTF-8 lane, raw bytes + image mime + /// for the OCR lane (the server extracts by magic either way). async fn post_file( &self, scope_handle: String, entities: Option>, - file_name: String, - content: String, + part: reqwest::multipart::Part, ) -> Result { let mut form = reqwest::multipart::Form::new().text("scope_handle", scope_handle); if let Some(entities) = entities.filter(|e| !e.is_empty()) { form = form.text("entities", entities.join(",")); } - form = form.part( - "file", - reqwest::multipart::Part::text(content).file_name(file_name), - ); + form = form.part("file", part); self.proxy(self.http.post(self.endpoint("/v1/files")).multipart(form)) .await } @@ -392,8 +391,12 @@ fn tool_error(msg: impl Into) -> Result { // ---------- local helpers for the ingest tools ---------- -/// Extensions memory_ingest_file accepts (UTF-8 text-like content only). +/// Extensions memory_ingest_file accepts as UTF-8 text. const INGEST_FILE_EXTENSIONS: [&str; 5] = ["txt", "md", "json", "csv", "html"]; +/// Extensions memory_ingest_file accepts as raw image bytes: the server's +/// local OCR tier (extract.rs + ocr.rs) extracts printed text best-effort, +/// disclosed as method "image-ocr" on the receipt. +const INGEST_IMAGE_EXTENSIONS: [&str; 3] = ["png", "jpg", "jpeg"]; /// memory_ingest_file size cap. const MAX_FILE_BYTES: u64 = 512 * 1024; /// memory_ingest_url download cap. @@ -837,7 +840,7 @@ impl VerityMcp { #[tool( name = "memory_ingest_file", - description = "Read a LOCAL text file and ingest its contents into shared memory, so it becomes searchable via memory_recall. Use when the knowledge lives in a file on this machine rather than in-context. Accepts UTF-8 .txt/.md/.json/.csv/.html up to 512 KB; anything else is rejected with an error." + description = "Read a LOCAL file and ingest its contents into shared memory, so it becomes searchable via memory_recall. Use when the knowledge lives in a file on this machine rather than in-context. Accepts UTF-8 text (.txt/.md/.json/.csv/.html) and images (.png/.jpg/.jpeg — printed text is extracted by the server's local OCR, best-effort, disclosed as method image-ocr) up to 512 KB; anything else is rejected with an error." )] async fn memory_ingest_file( &self, @@ -849,9 +852,10 @@ impl VerityMcp { .and_then(|e| e.to_str()) .map(str::to_ascii_lowercase) .unwrap_or_default(); - if !INGEST_FILE_EXTENSIONS.contains(&ext.as_str()) { + let is_image = INGEST_IMAGE_EXTENSIONS.contains(&ext.as_str()); + if !is_image && !INGEST_FILE_EXTENSIONS.contains(&ext.as_str()) { return tool_error(format!( - "unsupported file type {:?}: memory_ingest_file accepts only UTF-8 text files with extension .txt, .md, .json, .csv, or .html", + "unsupported file type {:?}: memory_ingest_file accepts UTF-8 text files (.txt, .md, .json, .csv, .html) and images (.png, .jpg, .jpeg)", p.path )); } @@ -873,6 +877,28 @@ impl VerityMcp { Ok(bytes) => bytes, Err(e) => return tool_error(format!("cannot read file {:?}: {e}", p.path)), }; + if is_image { + // Raw bytes to the server's OCR lane; the server sniffs magic and + // returns a typed, disclosed failure if OCR finds nothing. + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file.png") + .to_owned(); + let mime = if ext == "png" { + "image/png" + } else { + "image/jpeg" + }; + let part = match reqwest::multipart::Part::bytes(bytes) + .file_name(file_name) + .mime_str(mime) + { + Ok(part) => part, + Err(e) => return tool_error(format!("building upload part: {e}")), + }; + return self.post_file(p.scope_handle, p.entities, part).await; + } let content = match String::from_utf8(bytes) { Ok(content) => content, Err(_) => { @@ -887,8 +913,8 @@ impl VerityMcp { .and_then(|n| n.to_str()) .unwrap_or("file.txt") .to_owned(); - self.post_file(p.scope_handle, p.entities, file_name, content) - .await + let part = reqwest::multipart::Part::text(content).file_name(file_name); + self.post_file(p.scope_handle, p.entities, part).await } #[tool( @@ -968,8 +994,8 @@ impl VerityMcp { return tool_error(format!("no textual content extracted from {url}")); } let file_name = file_name_from_url(&url); - self.post_file(p.scope_handle, p.entities, file_name, content) - .await + let part = reqwest::multipart::Part::text(content).file_name(file_name); + self.post_file(p.scope_handle, p.entities, part).await } #[tool( diff --git a/crates/verity-server/Cargo.toml b/crates/verity-server/Cargo.toml index 8669640..3f6efb3 100644 --- a/crates/verity-server/Cargo.toml +++ b/crates/verity-server/Cargo.toml @@ -42,16 +42,33 @@ moka = { workspace = true } # Media object-store seam (task 47): S3/MinIO blob tier behind object_store. object_store = { workspace = true } bytes = "1" -# Tier-1 file extraction (extract.rs): PDF/PPTX/XLS(X) → text, deterministic, -# no OCR. Dep choices justified at the workspace root Cargo.toml. +# Tier-1 file extraction (extract.rs): PDF/PPTX/XLS(X)/DOC(X) text layers, +# deterministic. Dep choices justified at the workspace root Cargo.toml. calamine = { workspace = true } zip = { workspace = true } cfb = { workspace = true } quick-xml = { workspace = true } pdf-extract = { workspace = true } +# OCR tier (ocr.rs): image decode + scanned-PDF page walking are always built +# (typed failures either way); the ocrs/rten engine itself sits behind the +# default-ON `ocr` feature so `--no-default-features` can shave its compile +# cost — extraction then fails typed ("built without the 'ocr' feature"). +image = { workspace = true } +lopdf = { workspace = true } +ocrs = { workspace = true, optional = true } +rten = { workspace = true, optional = true } +ureq = { workspace = true, optional = true } # SSE subscriptions (task 21): poll-loop event streams. async-stream = "0.3" futures-util = { version = "0.3", default-features = false, features = ["alloc"] } # Local-folder watching (folder_watch.rs): server-side FS watcher + debounce. notify = { workspace = true } notify-debouncer-full = { workspace = true } + +[features] +default = ["ocr"] +# Local OCR engine (ocrs + rten). ON by default — sovereignty-first, still +# pure Rust, still zero system deps. Building with --no-default-features +# drops the engine (and its compile time); scanned-PDF/image extraction then +# returns the typed "built without the 'ocr' feature" failure instead. +ocr = ["dep:ocrs", "dep:rten", "dep:ureq"] diff --git a/crates/verity-server/src/extract.rs b/crates/verity-server/src/extract.rs index 18c0446..daf2ffd 100644 --- a/crates/verity-server/src/extract.rs +++ b/crates/verity-server/src/extract.rs @@ -1,19 +1,26 @@ -//! Tier-1 binary file extraction: PDF, PPTX, XLS(X) → plain text. +//! Tier-1 binary file extraction: PDF, PPTX, XLS(X), DOC(X) → plain text, +//! plus the local OCR tier for scanned PDFs and PNG/JPEG images (ocr.rs). //! //! The honesty rules here are load-bearing (founder directive, Tier 1): //! -//! * **Rust-native and deterministic.** No LLM, no OCR, no external process. -//! The same bytes always yield the same text. +//! * **Rust-native, local, no external process.** No LLM, no cloud OCR, no +//! system dependency. Text-layer extraction is fully deterministic; the OCR +//! paths ("pdf-ocr" / "image-ocr") are printed-text-grade and BEST-EFFORT — +//! the receipt always discloses the method so a consumer can weigh +//! OCR-derived text accordingly (same bytes + same cached models still +//! yield the same text; a model update may change it). //! * **Never silently empty.** Every call returns either extracted text with //! its method + truncation flag, a *typed* failure reason (encrypted PDF, -//! scanned/image PDF, parse failure, unrecognized format), or an explicit -//! `NotHandled` so the caller can run its existing text-like/store-only -//! logic. A caller can always disclose exactly what happened. -//! * **A hostile file never kills the server.** The PDF path is wrapped in +//! scanned PDF where OCR found nothing, OCR engine unavailable, parse +//! failure, unrecognized format), or an explicit `NotHandled` so the caller +//! can run its existing text-like/store-only logic. A caller can always +//! disclose exactly what happened. +//! * **A hostile file never kills the server.** The PDF paths are wrapped in //! `catch_unwind` because pdf crates are known to panic on malformed input. //! * **Honest limits.** Extraction is capped at [`MAX_EXTRACT_CHARS`] with a -//! disclosed `truncated` flag; scanned PDFs are declined with an explicit -//! "OCR is a later tier" reason, not returned as empty text. +//! disclosed `truncated` flag; scanned-PDF OCR is capped at +//! `ocr::MAX_OCR_PAGES` pages with the page count disclosed via +//! `pages_ocred`; encrypted PDFs are still declined outright. //! //! Dependency choices (also noted in the workspace Cargo.toml): //! @@ -33,6 +40,8 @@ use std::io::{Cursor, Read}; use calamine::Reader as _; +use crate::ocr::{self, OcrBackend}; + /// Hard cap on extracted text, in chars (~200 KB). Disclosed via /// `Extraction::truncated` — a capped extraction is never passed off as the /// whole document. @@ -43,9 +52,30 @@ pub(crate) const MAX_EXTRACT_CHARS: usize = 200_000; #[derive(Debug)] pub(crate) struct Extraction { pub(crate) text: String, - /// "calamine" | "pptx-xml" | "pdf-text" — recorded into provenance. + /// "calamine" | "pptx-xml" | "docx-xml" | "doc-piecetable" | "pdf-text" | + /// "pdf-ocr" | "image-ocr" — recorded into provenance. The two `-ocr` + /// methods mark best-effort recognized text, not a deterministic text + /// layer. pub(crate) method: &'static str, pub(crate) truncated: bool, + /// For "pdf-ocr" only: how many pages were actually OCRed (engine + /// consulted), capped at [`ocr::MAX_OCR_PAGES`] — a partial pass is + /// visible on the receipt. + pub(crate) pages_ocred: Option, +} + +/// The disclosed extraction receipt, embedded verbatim in episode payloads +/// and HTTP responses. ONE builder so no call site forgets `pages_ocred`. +pub(crate) fn receipt_json( + method: &str, + truncated: bool, + pages_ocred: Option, +) -> serde_json::Value { + let mut v = serde_json::json!({ "method": method, "truncated": truncated }); + if let Some(pages) = pages_ocred { + v["pages_ocred"] = pages.into(); + } + v } /// Typed failure reasons. `reason()` strings are part of the disclosed API @@ -53,14 +83,24 @@ pub(crate) struct Extraction { /// them deliberately. #[derive(Debug, PartialEq, Eq)] pub(crate) enum ExtractFailure { + /// Encrypted PDFs are declined outright — no decryption attempts, no OCR. EncryptedPdf, - /// Parsed fine but produced (approximately) no text: an image-only scan. + /// No text layer AND the OCR pass recognized nothing (or found no + /// decodable raster images at all). ScannedPdf, PdfParse(String), SheetParse(String), PptxParse(String), DocxParse(String), DocParse(String), + /// A PNG/JPEG whose bytes would not decode. + ImageParse(String), + /// An image parsed fine but OCR recognized no text in it. + ImageNoText, + /// The local OCR engine could not run (model download/init/inference + /// failed, or the server was built without the `ocr` feature). Typed and + /// disclosed — never a panic, never silent empty. + OcrUnavailable(String), /// The filename claimed one of our formats but the bytes don't match /// (magic wins), or the connector sent bytes we have no extractor for. UnrecognizedFormat, @@ -73,12 +113,15 @@ impl ExtractFailure { pub(crate) fn reason(&self) -> String { match self { Self::EncryptedPdf => "encrypted PDF".into(), - Self::ScannedPdf => "scanned/image PDF — no text layer (OCR is a later tier)".into(), + Self::ScannedPdf => "scanned/image PDF — no text layer and OCR found none".into(), Self::PdfParse(e) => format!("PDF parse failure: {e}"), Self::SheetParse(e) => format!("spreadsheet parse failure: {e}"), Self::PptxParse(e) => format!("PPTX parse failure: {e}"), Self::DocxParse(e) => format!("DOCX parse failure: {e}"), Self::DocParse(e) => format!("legacy .doc parse failure: {e}"), + Self::ImageParse(e) => format!("image decode failure: {e}"), + Self::ImageNoText => "image parsed but OCR found no text".into(), + Self::OcrUnavailable(e) => format!("OCR unavailable: {e}"), Self::UnrecognizedFormat => "unrecognized format".into(), Self::NoText => "file parsed but contains no extractable text".into(), } @@ -110,6 +153,8 @@ enum Claim { Xls, Docx, Doc, + Png, + Jpeg, } fn claim_from_name(filename: Option<&str>) -> Option { @@ -126,6 +171,10 @@ fn claim_from_name(filename: Option<&str>) -> Option { Some(Claim::Docx) } else if lower.ends_with(".doc") { Some(Claim::Doc) + } else if lower.ends_with(".png") { + Some(Claim::Png) + } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { + Some(Claim::Jpeg) } else { None } @@ -133,6 +182,9 @@ fn claim_from_name(filename: Option<&str>) -> Option { const ZIP_MAGIC: &[u8] = b"PK\x03\x04"; const OLE2_MAGIC: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; +const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n"; +/// JPEG/JFIF/EXIF all open with the SOI marker followed by another marker. +const JPEG_MAGIC: &[u8] = &[0xFF, 0xD8, 0xFF]; /// The PDF spec allows up to 1024 bytes of junk before `%PDF-`. fn looks_like_pdf(bytes: &[u8]) -> bool { @@ -154,9 +206,24 @@ pub(crate) fn extract(bytes: &[u8], filename: Option<&str>) -> ExtractOutcome { /// Cap-parameterized worker so tests can exercise truncation without /// megabyte fixtures. Production callers use [`extract`]. fn extract_with_cap(bytes: &[u8], filename: Option<&str>, cap: usize) -> ExtractOutcome { + extract_with_ocr(bytes, filename, cap, ocr::default_backend()) +} + +/// Backend-parameterized worker: tests inject fake OCR backends here so the +/// OCR plumbing (routing, page walking, caps, failure taxonomy) is provable +/// hermetically — no model downloads in unit tests. +fn extract_with_ocr( + bytes: &[u8], + filename: Option<&str>, + cap: usize, + ocr: &dyn OcrBackend, +) -> ExtractOutcome { let claim = claim_from_name(filename); if looks_like_pdf(bytes) { - return finish(extract_pdf(bytes, cap)); + return finish(extract_pdf(bytes, cap, ocr)); + } + if bytes.starts_with(PNG_MAGIC) || bytes.starts_with(JPEG_MAGIC) { + return finish(extract_image(bytes, cap, ocr)); } if bytes.starts_with(ZIP_MAGIC) { // Zip container: xlsx and pptx are both zips — tell them apart by the @@ -168,10 +235,7 @@ fn extract_with_cap(bytes: &[u8], filename: Option<&str>, cap: usize) -> Extract // A zip that isn't an office package: ours only if the name // claimed so (then the claim is wrong — typed, magic wins). Ok(_) => match claim { - Some(Claim::Xlsx) | Some(Claim::Pptx) | Some(Claim::Docx) | Some(Claim::Xls) - | Some(Claim::Doc) | Some(Claim::Pdf) => { - ExtractOutcome::Failed(ExtractFailure::UnrecognizedFormat) - } + Some(_) => ExtractOutcome::Failed(ExtractFailure::UnrecognizedFormat), None => ExtractOutcome::NotHandled, }, Err(e) => match claim { @@ -180,7 +244,7 @@ fn extract_with_cap(bytes: &[u8], filename: Option<&str>, cap: usize) -> Extract Some(Claim::Xlsx) | Some(Claim::Xls) => { ExtractOutcome::Failed(ExtractFailure::SheetParse(e)) } - Some(Claim::Pdf) | Some(Claim::Doc) => { + Some(Claim::Pdf) | Some(Claim::Doc) | Some(Claim::Png) | Some(Claim::Jpeg) => { ExtractOutcome::Failed(ExtractFailure::UnrecognizedFormat) } None => ExtractOutcome::NotHandled, @@ -249,7 +313,10 @@ fn zip_office_kind(bytes: &[u8]) -> Result, String> { // Char-budgeted output assembly (shared truncation semantics) // --------------------------------------------------------------------------- -struct Budget { +/// pub(crate): ocr.rs pushes OCRed page blocks through the same budget so +/// pdf-ocr shares the exact truncation semantics (and stops OCRing early once +/// the cap is hit — recognition is the expensive part). +pub(crate) struct Budget { out: String, remaining: usize, truncated: bool, @@ -267,7 +334,7 @@ impl Budget { /// Append `s`; if the budget runs out mid-string, cut at a char boundary /// and mark truncated. Returns false once the budget is exhausted so /// producers can stop early instead of materializing unbounded text. - fn push(&mut self, s: &str) -> bool { + pub(crate) fn push(&mut self, s: &str) -> bool { if self.truncated { return false; } @@ -294,6 +361,7 @@ impl Budget { text: self.out, method, truncated: self.truncated, + pages_ocred: None, } } } @@ -350,6 +418,7 @@ fn extract_sheet(bytes: &[u8], cap: usize) -> Result text: String::new(), // folded to ExtractFailure::NoText by finish() method: "calamine", truncated: false, + pages_ocred: None, }); } Ok(budget.into_extraction("calamine")) @@ -427,6 +496,7 @@ fn extract_pptx(bytes: &[u8], cap: usize) -> Result text: String::new(), // folded to ExtractFailure::NoText by finish() method: "pptx-xml", truncated: false, + pages_ocred: None, }); } Ok(budget.into_extraction("pptx-xml")) @@ -496,6 +566,7 @@ fn extract_docx(bytes: &[u8], cap: usize) -> Result text: budget.out, method: "docx-xml", truncated: budget.truncated, + pages_ocred: None, }) } @@ -653,6 +724,7 @@ fn extract_doc(bytes: &[u8], cap: usize) -> Result { text: budget.out.trim().to_string(), method: "doc-piecetable", truncated: budget.truncated, + pages_ocred: None, }) } @@ -698,10 +770,15 @@ fn notes_target(rels_xml: &str) -> Option { } // --------------------------------------------------------------------------- -// PDF via pdf-extract (text layer only — no OCR in Tier 1) +// PDF via pdf-extract (text layer), falling back to local OCR (ocr.rs) when +// the document has none // --------------------------------------------------------------------------- -fn extract_pdf(bytes: &[u8], cap: usize) -> Result { +fn extract_pdf( + bytes: &[u8], + cap: usize, + ocr: &dyn OcrBackend, +) -> Result { // Encryption check FIRST, on the raw bytes: the `/Encrypt` key legitimately // appears only in the trailer dictionary. A false positive would require // the literal token in an *uncompressed* content stream — vanishingly rare @@ -726,15 +803,73 @@ fn extract_pdf(bytes: &[u8], cap: usize) -> Result { } }; if text.trim().is_empty() { - // Parsed fine, ~no text: an image-only/scanned PDF. Tier 1 has no OCR - // — disclose that instead of indexing nothing silently. - return Err(ExtractFailure::ScannedPdf); + // Parsed fine, ~no text layer: a scanned/image PDF. Run the local OCR + // pass (ocr.rs) over its embedded page images — best-effort, + // disclosed as "pdf-ocr" with pages_ocred, and still a typed failure + // when OCR finds nothing or cannot run. Fenced like the text pass: + // lopdf must never unwind into the handler either. + return ocr_scanned_pdf(bytes, cap, ocr); } let mut budget = Budget::new(cap); budget.push(text.trim()); Ok(budget.into_extraction("pdf-text")) } +/// The scanned-PDF OCR pass. The engine is only consulted when a page image +/// actually decodes, so a blank or vector-only PDF fails fast as ScannedPdf +/// without touching (or downloading) any model. +fn ocr_scanned_pdf( + bytes: &[u8], + cap: usize, + ocr: &dyn OcrBackend, +) -> Result { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut budget = Budget::new(cap); + let pages = ocr::ocr_pdf_pages(bytes, &mut budget, ocr::MAX_OCR_PAGES, ocr)?; + Ok((budget, pages)) + })); + let (budget, pages_ocred) = match outcome { + Ok(Ok(ok)) => ok, + Ok(Err(ocr::PdfOcrError::Parse(e))) => { + return Err(ExtractFailure::PdfParse(format!("OCR pass: {e}"))) + } + Ok(Err(ocr::PdfOcrError::Engine(e))) => return Err(ExtractFailure::OcrUnavailable(e)), + Err(_) => { + return Err(ExtractFailure::PdfParse( + "OCR pass: parser panicked on malformed input (contained)".into(), + )) + } + }; + if budget.out.trim().is_empty() { + return Err(ExtractFailure::ScannedPdf); + } + let mut ex = budget.into_extraction("pdf-ocr"); + ex.pages_ocred = Some(pages_ocred); + Ok(ex) +} + +// --------------------------------------------------------------------------- +// Standalone PNG/JPEG via local OCR (ocr.rs) +// --------------------------------------------------------------------------- + +fn extract_image( + bytes: &[u8], + cap: usize, + ocr: &dyn OcrBackend, +) -> Result { + let img = ocr::decode_rgb(bytes).map_err(ExtractFailure::ImageParse)?; + let text = ocr + .recognize_rgb(img.width(), img.height(), img.as_raw()) + .map_err(ExtractFailure::OcrUnavailable)?; + let text = text.trim(); + if text.is_empty() { + return Err(ExtractFailure::ImageNoText); + } + let mut budget = Budget::new(cap); + budget.push(text); + Ok(budget.into_extraction("image-ocr")) +} + // --------------------------------------------------------------------------- // Programmatic fixtures (tests only): tiny valid files, no binaries in-repo. // --------------------------------------------------------------------------- @@ -959,11 +1094,106 @@ pub(crate) mod fixtures { pdf.into_bytes() } - /// A valid PDF with one empty page — parses fine, zero text ops. The - /// stand-in for a scanned/image-only document. + /// A valid PDF with one empty page — parses fine, zero text ops, zero + /// images. The stand-in for a blank/vector-only document: the OCR pass + /// finds nothing to even attempt. pub(crate) fn image_only_pdf() -> Vec { text_pdf(&[]) } + + /// Flat-color JPEG bytes (a valid, decodable page image; the injected OCR + /// fakes don't look at the pixels). + pub(crate) fn jpeg_bytes(w: u32, h: u32) -> Vec { + let img = image::RgbImage::from_pixel(w, h, image::Rgb([180, 180, 180])); + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut out) + .encode(img.as_raw(), w, h, image::ExtendedColorType::Rgb8) + .expect("fixture jpeg encodes"); + out + } + + /// Flat-color PNG bytes. + pub(crate) fn png_bytes(w: u32, h: u32) -> Vec { + let img = image::RgbImage::from_pixel(w, h, image::Rgb([64, 64, 64])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png) + .expect("fixture png encodes"); + out.into_inner() + } + + /// A minimal scanned-style PDF: one page per JPEG, each page's content + /// stream drawing its image XObject (DCTDecode — the stream body IS the + /// JPEG). Exactly the shape scanner exports take: valid structure, zero + /// text operators. Authored programmatically with correct xref offsets. + pub(crate) fn scanned_pdf_with_jpegs(jpegs: &[&[u8]]) -> Vec { + let n_pages = jpegs.len(); + let kids: Vec = (0..n_pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect(); + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!( + "<< /Type /Pages /Kids [{}] /Count {n_pages} >>", + kids.join(" ") + ) + .into_bytes(), + ]; + for (i, jpeg) in jpegs.iter().enumerate() { + use image::GenericImageView as _; + // Page object ids run 3, 6, 9, … (matching `kids` above), each + // followed by its contents and image objects. + let (contents_obj, image_obj) = (4 + i * 3, 5 + i * 3); + let img = image::load_from_memory(jpeg).expect("fixture jpeg decodes"); + let (w, h) = img.dimensions(); + let content = format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q\n"); + objects.push( + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \ + /Contents {contents_obj} 0 R \ + /Resources << /XObject << /Im0 {image_obj} 0 R >> >> >>" + ) + .into_bytes(), + ); + objects.push( + format!( + "<< /Length {} >>\nstream\n{content}endstream", + content.len() + ) + .into_bytes(), + ); + let mut image_object = format!( + "<< /Type /XObject /Subtype /Image /Width {w} /Height {h} \ + /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode \ + /Length {} >>\nstream\n", + jpeg.len() + ) + .into_bytes(); + image_object.extend_from_slice(jpeg); + image_object.extend_from_slice(b"\nendstream"); + objects.push(image_object); + } + + let mut pdf: Vec = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(obj); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref_at = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes()); + pdf.extend_from_slice(b"0000000000 65535 f \n"); + for off in offsets { + pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n", + objects.len() + 1 + ) + .as_bytes(), + ); + pdf + } } // --------------------------------------------------------------------------- @@ -974,6 +1204,32 @@ pub(crate) mod fixtures { mod tests { use super::*; + /// Injected OCR fakes: the plumbing (routing, page walk, caps, failure + /// taxonomy) is proven hermetically — no model downloads in unit tests. + /// Real-engine coverage is the VERITY_OCR_E2E=1-gated test below. + struct FakeOcr(&'static str); + impl OcrBackend for FakeOcr { + fn recognize_rgb(&self, _w: u32, _h: u32, _rgb: &[u8]) -> Result { + Ok(self.0.to_string()) + } + } + + /// The model-unavailable path (download/init failed). + struct DownOcr; + impl OcrBackend for DownOcr { + fn recognize_rgb(&self, _w: u32, _h: u32, _rgb: &[u8]) -> Result { + Err("models unavailable (test)".into()) + } + } + + /// Proves a code path never consults the engine at all. + struct PanicOcr; + impl OcrBackend for PanicOcr { + fn recognize_rgb(&self, _w: u32, _h: u32, _rgb: &[u8]) -> Result { + panic!("OCR backend must not be consulted on this path") + } + } + fn expect_extracted(outcome: ExtractOutcome) -> Extraction { match outcome { ExtractOutcome::Extracted(ex) => ex, @@ -1147,12 +1403,230 @@ mod tests { } #[test] - fn image_only_pdf_is_declined_as_scanned_no_ocr() { - let f = expect_failed(extract(&fixtures::image_only_pdf(), Some("scan.pdf"))); + fn imageless_scanned_pdf_fails_typed_without_consulting_the_engine() { + // No text layer AND no decodable page images: the honest failure, and + // the engine (PanicOcr) is provably never touched — no model download + // is ever triggered by a blank/vector-only PDF. + let f = expect_failed(extract_with_ocr( + &fixtures::image_only_pdf(), + Some("scan.pdf"), + MAX_EXTRACT_CHARS, + &PanicOcr, + )); assert_eq!(f, ExtractFailure::ScannedPdf); assert_eq!( f.reason(), - "scanned/image PDF — no text layer (OCR is a later tier)" + "scanned/image PDF — no text layer and OCR found none" + ); + } + + // ---------------- OCR tier (injected backends; see ocr.rs) ---------------- + + #[test] + fn scanned_pdf_ocrs_pages_in_order_with_pdf_ocr_receipt() { + let j1 = fixtures::jpeg_bytes(24, 16); + let j2 = fixtures::jpeg_bytes(16, 24); + let bytes = fixtures::scanned_pdf_with_jpegs(&[&j1, &j2]); + let ex = expect_extracted(extract_with_ocr( + &bytes, + Some("scan.pdf"), + MAX_EXTRACT_CHARS, + &FakeOcr("the falcon codeword is zanzibar"), + )); + assert_eq!(ex.method, "pdf-ocr"); + assert_eq!(ex.pages_ocred, Some(2)); + assert!(!ex.truncated); + assert!(ex.text.contains("Page 1:")); + assert!(ex.text.contains("Page 2:")); + assert!(ex.text.contains("the falcon codeword is zanzibar")); + let p1 = ex.text.find("Page 1:").unwrap(); + let p2 = ex.text.find("Page 2:").unwrap(); + assert!(p1 < p2, "pages must join in order"); + } + + #[test] + fn scanned_pdf_ocr_honors_the_page_cap() { + let jpeg = fixtures::jpeg_bytes(8, 8); + let pages: Vec<&[u8]> = std::iter::repeat(jpeg.as_slice()) + .take(crate::ocr::MAX_OCR_PAGES + 2) + .collect(); + let bytes = fixtures::scanned_pdf_with_jpegs(&pages); + let ex = expect_extracted(extract_with_ocr( + &bytes, + Some("big-scan.pdf"), + MAX_EXTRACT_CHARS, + &FakeOcr("line"), + )); + assert_eq!(ex.method, "pdf-ocr"); + assert_eq!(ex.pages_ocred, Some(crate::ocr::MAX_OCR_PAGES as u32)); + assert!(ex + .text + .contains(&format!("Page {}:", crate::ocr::MAX_OCR_PAGES))); + assert!(!ex + .text + .contains(&format!("Page {}:", crate::ocr::MAX_OCR_PAGES + 1))); + } + + #[test] + fn scanned_pdf_ocr_honors_the_char_cap_with_truncated_flag() { + let jpeg = fixtures::jpeg_bytes(8, 8); + let bytes = fixtures::scanned_pdf_with_jpegs(&[&jpeg, &jpeg, &jpeg]); + let ExtractOutcome::Extracted(ex) = extract_with_ocr( + &bytes, + Some("scan.pdf"), + 24, + &FakeOcr("0123456789abcdefghij"), + ) else { + panic!("expected Extracted"); + }; + assert_eq!(ex.method, "pdf-ocr"); + assert!( + ex.truncated, + "char-cap overflow must set the truncated flag" + ); + assert!(ex.text.chars().count() <= 24); + } + + #[test] + fn scanned_pdf_with_ocr_engine_down_is_a_typed_ocr_unavailable_failure() { + let jpeg = fixtures::jpeg_bytes(8, 8); + let bytes = fixtures::scanned_pdf_with_jpegs(&[&jpeg]); + let f = expect_failed(extract_with_ocr( + &bytes, + Some("scan.pdf"), + MAX_EXTRACT_CHARS, + &DownOcr, + )); + assert_eq!( + f, + ExtractFailure::OcrUnavailable("models unavailable (test)".into()) + ); + assert_eq!(f.reason(), "OCR unavailable: models unavailable (test)"); + } + + #[test] + fn scanned_pdf_whose_ocr_finds_nothing_fails_as_scanned_pdf() { + let jpeg = fixtures::jpeg_bytes(8, 8); + let bytes = fixtures::scanned_pdf_with_jpegs(&[&jpeg]); + let f = expect_failed(extract_with_ocr( + &bytes, + Some("scan.pdf"), + MAX_EXTRACT_CHARS, + &FakeOcr(" "), + )); + assert_eq!(f, ExtractFailure::ScannedPdf); + } + + #[test] + fn receipt_json_discloses_pages_ocred_only_when_present() { + let r = receipt_json("pdf-ocr", false, Some(3)); + assert_eq!(r["method"], "pdf-ocr"); + assert_eq!(r["pages_ocred"], 3); + let r = receipt_json("pdf-text", true, None); + assert_eq!(r["truncated"], true); + assert!( + r.get("pages_ocred").is_none(), + "non-OCR receipts must not carry pages_ocred" + ); + } + + #[test] + fn png_and_jpeg_extract_via_image_ocr() { + for (bytes, name) in [ + (fixtures::png_bytes(20, 12), "shot.png"), + (fixtures::jpeg_bytes(20, 12), "photo.jpg"), + ] { + let ex = expect_extracted(extract_with_ocr( + &bytes, + Some(name), + MAX_EXTRACT_CHARS, + &FakeOcr("renewal quote is 61000"), + )); + assert_eq!(ex.method, "image-ocr", "for {name}"); + assert_eq!(ex.pages_ocred, None); + assert!(ex.text.contains("renewal quote is 61000")); + } + } + + #[test] + fn image_detected_by_magic_even_with_misleading_name() { + // Magic wins: PNG bytes named .pdf still ride the image-ocr path. + let ex = expect_extracted(extract_with_ocr( + &fixtures::png_bytes(20, 12), + Some("report.pdf"), + MAX_EXTRACT_CHARS, + &FakeOcr("hello"), + )); + assert_eq!(ex.method, "image-ocr"); + } + + #[test] + fn corrupt_image_is_a_typed_image_parse_failure() { + // Valid PNG magic, hostile body. + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + bytes.extend_from_slice(&[0xAB; 128]); + let f = expect_failed(extract_with_ocr( + &bytes, + Some("bad.png"), + MAX_EXTRACT_CHARS, + &PanicOcr, // undecodable ⇒ the engine is never consulted + )); + assert!(matches!(f, ExtractFailure::ImageParse(_)), "got {f:?}"); + } + + #[test] + fn image_where_ocr_finds_nothing_is_a_typed_no_text_failure() { + let f = expect_failed(extract_with_ocr( + &fixtures::png_bytes(20, 12), + Some("blank.png"), + MAX_EXTRACT_CHARS, + &FakeOcr(""), + )); + assert_eq!(f, ExtractFailure::ImageNoText); + assert_eq!(f.reason(), "image parsed but OCR found no text"); + } + + #[test] + fn image_with_ocr_engine_down_is_a_typed_ocr_unavailable_failure() { + let f = expect_failed(extract_with_ocr( + &fixtures::png_bytes(20, 12), + Some("shot.png"), + MAX_EXTRACT_CHARS, + &DownOcr, + )); + assert!(matches!(f, ExtractFailure::OcrUnavailable(_)), "got {f:?}"); + } + + /// End-to-end with the REAL engine (downloads ~12 MB of models on first + /// run): gated behind VERITY_OCR_E2E=1. Proves the full lane — rendered + /// text PNG → image-ocr, and the same image embedded as a scanned PDF → + /// pdf-ocr — recognizes the known sentence. + #[test] + fn e2e_real_engine_reads_rendered_text_from_png_and_scanned_pdf() { + if std::env::var("VERITY_OCR_E2E").as_deref() != Ok("1") { + eprintln!("VERITY_OCR_E2E != 1; skipping"); + return; + } + let png = include_bytes!("testdata/ocr-sample.png"); + let ex = expect_extracted(extract(png, Some("ocr-sample.png"))); + assert_eq!(ex.method, "image-ocr"); + let lower = ex.text.to_lowercase(); + assert!( + lower.contains("quick brown fox"), + "OCR text was: {:?}", + ex.text + ); + + let jpeg = include_bytes!("testdata/ocr-sample.jpg"); + let pdf = fixtures::scanned_pdf_with_jpegs(&[jpeg.as_slice()]); + let ex = expect_extracted(extract(&pdf, Some("scan.pdf"))); + assert_eq!(ex.method, "pdf-ocr"); + assert_eq!(ex.pages_ocred, Some(1)); + let lower = ex.text.to_lowercase(); + assert!( + lower.contains("quick brown fox"), + "OCR text was: {:?}", + ex.text ); } @@ -1183,10 +1657,19 @@ mod tests { extract(b"hello world", Some("notes.txt")), ExtractOutcome::NotHandled )); + // Unknown binary with no format claim: not our job. assert!(matches!( - extract(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a], Some("img.png")), + extract(&[0x00, 0x01, 0x02, 0x03, 0x7f], Some("blob.bin")), ExtractOutcome::NotHandled )); + // A TRUNCATED png magic under a .png name: the bytes lie about the + // claim (magic wins), so this is now a typed refusal, not a silent + // store-only — PNG became one of our formats with the OCR tier. + let f = expect_failed(extract( + &[0x89, b'P', b'N', b'G', 0x0d, 0x0a], + Some("img.png"), + )); + assert_eq!(f, ExtractFailure::UnrecognizedFormat); } #[test] diff --git a/crates/verity-server/src/main.rs b/crates/verity-server/src/main.rs index afee1d4..6013d93 100644 --- a/crates/verity-server/src/main.rs +++ b/crates/verity-server/src/main.rs @@ -41,6 +41,7 @@ mod media; #[cfg(test)] mod media_tests; mod metrics; +mod ocr; mod playground; #[cfg(test)] mod principals_tests; @@ -3818,7 +3819,8 @@ struct IngestDocumentsRequest { #[serde(default)] content: Option, /// Binary path (Tier-1 extraction, extract.rs): raw file bytes, base64. - /// The SERVER extracts text (PDF/PPTX/XLS(X), deterministic, no OCR) so + /// The SERVER extracts text (PDF/PPTX/XLS(X)/DOC(X) text layers, plus + /// local best-effort OCR for scanned PDFs and PNG/JPEG — ocr.rs) so /// connectors stay extraction-free. Chosen over posting to /v1/files /// because /v1/files stamps the uploader scope's principals — it would /// REPLACE the connector's mirrored per-item ACL, which is the whole @@ -4262,11 +4264,20 @@ pub(crate) async fn ingest_document( (Some(c), hash) } DeliveredContent::Bytes { raw, hash_over } => { - let text = match extract::extract(&raw, req.filename.as_deref()) { + // Extraction runs on the blocking pool: the OCR paths (scanned + // PDFs, images) can take seconds and must not stall the runtime. + let fname = req.filename.clone(); + let outcome = + tokio::task::spawn_blocking(move || extract::extract(&raw, fname.as_deref())) + .await + .map_err(internal)?; + let text = match outcome { extract::ExtractOutcome::Extracted(ex) => { - extraction_receipt = Some(serde_json::json!({ - "method": ex.method, "truncated": ex.truncated, - })); + extraction_receipt = Some(extract::receipt_json( + ex.method, + ex.truncated, + ex.pages_ocred, + )); Some(ex.text) } extract::ExtractOutcome::Failed(f) => { diff --git a/crates/verity-server/src/media.rs b/crates/verity-server/src/media.rs index 6477552..a31947a 100644 --- a/crates/verity-server/src/media.rs +++ b/crates/verity-server/src/media.rs @@ -1,12 +1,13 @@ //! MediaObject + signed URIs (roadmap task 9): blobs live in the `media` //! table, addressed by uuid, served ONLY through HMAC-signed, expiring URLs //! minted under a scope handle. Text-like media additionally chunks into the -//! retrieval index under the uploader's scope; PDF / PPTX / XLS(X) go through -//! the Tier-1 extractor (extract.rs — deterministic, Rust-native, no OCR) and -//! index the extracted text, with the method + truncation recorded in -//! provenance and typed extraction failures stored metadata-only, disclosed -//! in both the response and the episode record. Other binary media is -//! store-only. +//! retrieval index under the uploader's scope; PDF / PPTX / XLS(X) / DOC(X) / +//! PNG / JPEG go through the Tier-1 extractor (extract.rs — Rust-native and +//! local; scanned PDFs and images ride the best-effort local OCR tier, ocr.rs) +//! and index the extracted text, with the method + truncation (+ pages_ocred +//! for OCRed PDFs) recorded in provenance and typed extraction failures stored +//! metadata-only, disclosed in both the response and the episode record. Other +//! binary media is store-only. //! //! v0.2 seam, stated honestly: the signed GET enforces signature + expiry //! (and the sign step enforces the tenant match), but per-principal media @@ -287,15 +288,28 @@ pub(crate) async fn ingest_file( text: String, method: &'static str, truncated: bool, + pages_ocred: Option, }, Refuse(crate::extract::ExtractFailure), StoreOnly, } - let plan = match crate::extract::extract(&bytes, filename.as_deref()) { + // Extraction runs on the blocking pool: the OCR paths can take seconds + // per page, which must never stall the async runtime. + let (outcome, bytes) = { + let fname = filename.clone(); + tokio::task::spawn_blocking(move || { + let outcome = crate::extract::extract(&bytes, fname.as_deref()); + (outcome, bytes) + }) + .await + .map_err(internal)? + }; + let plan = match outcome { crate::extract::ExtractOutcome::Extracted(ex) => Plan::Index { text: ex.text, method: ex.method, truncated: ex.truncated, + pages_ocred: ex.pages_ocred, }, crate::extract::ExtractOutcome::Failed(f) => Plan::Refuse(f), crate::extract::ExtractOutcome::NotHandled => { @@ -307,6 +321,7 @@ pub(crate) async fn ingest_file( text: s.to_string(), method: "utf-8", truncated: false, + pages_ocred: None, }, None => Plan::StoreOnly, } @@ -320,6 +335,7 @@ pub(crate) async fn ingest_file( text, method, truncated, + pages_ocred, } => { let episode_id = state .storage @@ -331,7 +347,7 @@ pub(crate) async fn ingest_file( payload: serde_json::json!({ "media_id": media_id, "filename": filename, "mime": mime, "sha256": sha256, "size_bytes": bytes.len(), - "extraction": { "method": method, "truncated": truncated }, + "extraction": crate::extract::receipt_json(method, truncated, pages_ocred), }), content_hash: sha256.clone(), trust_tier: TrustTier::Observation, @@ -373,10 +389,7 @@ pub(crate) async fn ingest_file( // emit after a write. Its absence here meant file content added via // `verity-cli add` (POST /v1/files) was never entity-resolved. state.resolution.mark_dirty(payload.tenant_id); - extraction_receipt = Some(serde_json::json!({ - "method": method, - "truncated": truncated, - })); + extraction_receipt = Some(crate::extract::receipt_json(method, truncated, pages_ocred)); } Plan::Refuse(failure) => { let reason = failure.reason(); diff --git a/crates/verity-server/src/ocr.rs b/crates/verity-server/src/ocr.rs new file mode 100644 index 0000000..9548c67 --- /dev/null +++ b/crates/verity-server/src/ocr.rs @@ -0,0 +1,396 @@ +//! Local OCR tier: scanned PDFs and standalone PNG/JPEG images → text. +//! +//! Sovereignty-first, same honesty rules as extract.rs (which owns the policy; +//! this module is the mechanism): +//! +//! * **Pure Rust, zero system dependencies.** The engine is the `ocrs` crate +//! on the `rten` runtime — no tesseract (a system dep would break the +//! clean-VM stranger gate), no cloud OCR, no Python. OCR quality is +//! printed-text-grade and best-effort: fine for scans and screenshots of +//! type, not handwriting, and receipts always disclose the method +//! ("pdf-ocr" / "image-ocr") so a consumer can weigh the text accordingly. +//! * **Models fetch once, then cache.** The two rten models (~2.5 MB +//! detection + ~9.7 MB recognition) download on FIRST USE from the ocrs +//! project's canonical bucket into `~/.cache/ocrs` — the same directory the +//! `ocrs` CLI uses, and the same fetch-once-then-cache lane as the MiniLM +//! query encoder (which caches via hf-hub). `VERITY_OCR_MODEL_DIR` +//! overrides the location for air-gapped deployments (drop the two `.rten` +//! files there by hand). The engine is only ever initialized when there is +//! actually an image to recognize — a text PDF never touches this module. +//! * **Failure is typed, never silent, never fatal.** Download/init/inference +//! failure surfaces as an `Err(String)` that extract.rs turns into the +//! disclosed `OCR unavailable: …` extraction failure. A failed init is NOT +//! cached — the next file retries (a transient network blip during the +//! first-ever scan must not poison the process). +//! * **Bounded work.** Scanned PDFs OCR at most [`MAX_OCR_PAGES`] pages; +//! individual images over [`MAX_OCR_PIXELS`] are refused (decompression- +//! bomb guard). Char caps are enforced by the caller's `Budget`. +//! +//! The `ocrs`/`rten` engine itself sits behind the default-ON `ocr` cargo +//! feature; built without it, [`default_backend`] returns a stub whose every +//! call fails typed ("built without the 'ocr' feature") — the decode and +//! PDF-walking plumbing stays compiled and tested either way. + +use std::io::Cursor; + +use crate::extract::Budget; + +/// Hard cap on pages OCRed per scanned PDF. Disclosed via the receipt's +/// `pages_ocred` (a 200-page scan reporting `pages_ocred: 50` is visibly +/// partial; the `truncated` flag additionally covers the char cap). +pub(crate) const MAX_OCR_PAGES: usize = 50; + +/// Refuse to decode images beyond this many pixels (~40 MP — comfortably +/// above any sane scan, small enough that a crafted PNG bomb cannot balloon +/// into gigabytes of raster). +pub(crate) const MAX_OCR_PIXELS: u64 = 40_000_000; + +/// The OCR seam. Implementations recognize printed text in one RGB8 bitmap +/// (`rgb.len() == 3 * width * height`). +/// +/// `Err` means the ENGINE failed (models unavailable, inference error) and is +/// disclosed as such; "ran fine, found no text" is `Ok` with an empty string. +/// Tests inject fakes here; production uses [`default_backend`]. +pub(crate) trait OcrBackend: Sync { + fn recognize_rgb(&self, width: u32, height: u32, rgb: &[u8]) -> Result; +} + +/// The production backend: lazily initialized global ocrs engine (or the +/// typed always-fails stub when built without the `ocr` feature). +pub(crate) fn default_backend() -> &'static dyn OcrBackend { + &engine::LazyOcrs +} + +// --------------------------------------------------------------------------- +// Standalone image decode (PNG/JPEG bytes → RGB8, bomb-guarded) +// --------------------------------------------------------------------------- + +/// Decode PNG/JPEG bytes to RGB8. Dimensions are read from the header FIRST +/// and checked against [`MAX_OCR_PIXELS`] before any pixel is materialized. +pub(crate) fn decode_rgb(bytes: &[u8]) -> Result { + let (w, h) = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| e.to_string())? + .into_dimensions() + .map_err(|e| e.to_string())?; + check_pixels(w, h)?; + let img = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| e.to_string())? + .decode() + .map_err(|e| e.to_string())?; + Ok(img.into_rgb8()) +} + +fn check_pixels(w: u32, h: u32) -> Result<(), String> { + if u64::from(w) * u64::from(h) > MAX_OCR_PIXELS { + return Err(format!( + "image is {w}x{h} pixels; OCR refuses images over {MAX_OCR_PIXELS} pixels" + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Scanned-PDF page walk: embedded image XObjects, in page order +// --------------------------------------------------------------------------- + +/// Why a scanned-PDF OCR pass failed. extract.rs maps these to its typed +/// `ExtractFailure` reasons. +#[derive(Debug)] +pub(crate) enum PdfOcrError { + /// lopdf could not re-parse the document for the image walk. + Parse(String), + /// The OCR engine itself failed (model download/init/inference). + Engine(String), +} + +/// Walk a (text-layer-less) PDF's pages in order, decode each page's embedded +/// raster image XObjects, OCR them, and push `Page N:` blocks into `budget`. +/// +/// Returns how many pages had at least one image actually OCRed. Undecodable +/// or unsupported-filter images (CCITT/JBIG2/JPX) are skipped — pages built +/// from them simply contribute nothing, and if NOTHING contributes, the +/// caller declares the honest "OCR found none" failure. The engine is never +/// consulted when no image decodes, so a blank-page PDF stays cheap and a +/// broken model cache is only ever reported on files that needed it. +pub(crate) fn ocr_pdf_pages( + bytes: &[u8], + budget: &mut Budget, + max_pages: usize, + ocr: &dyn OcrBackend, +) -> Result { + let doc = lopdf::Document::load_mem(bytes).map_err(|e| PdfOcrError::Parse(e.to_string()))?; + let mut pages_ocred = 0u32; + for (page_no, page_id) in doc.get_pages().into_iter().take(max_pages) { + let mut page_text = String::new(); + let mut page_had_image = false; + for img in page_images(&doc, page_id) { + page_had_image = true; + let text = ocr + .recognize_rgb(img.width(), img.height(), img.as_raw()) + .map_err(PdfOcrError::Engine)?; + let text = text.trim(); + if !text.is_empty() { + if !page_text.is_empty() { + page_text.push('\n'); + } + page_text.push_str(text); + } + } + if page_had_image { + pages_ocred += 1; + } + if !page_text.is_empty() && !budget.push(&format!("Page {page_no}:\n{page_text}\n\n")) { + break; // char cap reached — disclosed via `truncated` + } + } + Ok(pages_ocred) +} + +/// Decode the raster image XObjects a page references, in the order the +/// resource dictionary lists them. Only self-describing or raw formats we can +/// reconstruct deterministically are attempted: +/// +/// * `DCTDecode` — the stream IS a JPEG; hand it to the image crate. +/// * `FlateDecode` / unfiltered — raw samples; reconstructed for 8-bit +/// DeviceRGB and DeviceGray. +/// +/// Anything else (CCITT, JBIG2, JPX, indexed palettes, exotic bit depths) is +/// skipped, never guessed at. +fn page_images(doc: &lopdf::Document, page_id: lopdf::ObjectId) -> Vec { + let mut out = Vec::new(); + let Ok((inline_dict, resource_ids)) = doc.get_page_resources(page_id) else { + return out; + }; + let mut resource_dicts: Vec<&lopdf::Dictionary> = inline_dict.into_iter().collect(); + for id in resource_ids { + if let Ok(d) = doc.get_dictionary(id) { + resource_dicts.push(d); + } + } + for resources in resource_dicts { + let xobjects = match resources.get(b"XObject") { + Ok(lopdf::Object::Dictionary(d)) => d, + Ok(lopdf::Object::Reference(id)) => match doc.get_dictionary(*id) { + Ok(d) => d, + Err(_) => continue, + }, + _ => continue, + }; + for (_name, obj) in xobjects.iter() { + let stream = match obj { + lopdf::Object::Reference(id) => { + match doc.get_object(*id).and_then(lopdf::Object::as_stream) { + Ok(s) => s, + Err(_) => continue, + } + } + lopdf::Object::Stream(s) => s, + _ => continue, + }; + let subtype = stream.dict.get(b"Subtype").and_then(lopdf::Object::as_name); + if !matches!(subtype, Ok(b"Image")) { + continue; + } + if let Some(img) = decode_image_xobject(stream) { + out.push(img); + } + } + } + out +} + +fn decode_image_xobject(stream: &lopdf::Stream) -> Option { + let dict = &stream.dict; + let width = u32::try_from(dict.get(b"Width").and_then(lopdf::Object::as_i64).ok()?).ok()?; + let height = u32::try_from(dict.get(b"Height").and_then(lopdf::Object::as_i64).ok()?).ok()?; + check_pixels(width, height).ok()?; + + // Filter may be a single name, an array, or absent (raw samples). + let filters: Vec<&[u8]> = match dict.get(b"Filter") { + Ok(lopdf::Object::Name(n)) => vec![n.as_slice()], + Ok(lopdf::Object::Array(a)) => a.iter().filter_map(|o| o.as_name().ok()).collect(), + _ => vec![], + }; + + if filters.last() == Some(&b"DCTDecode".as_slice()) { + // The (possibly flate-wrapped) stream content is a JPEG file. + let jpeg = if filters.len() > 1 { + stream.decompressed_content().ok()? + } else { + stream.content.clone() + }; + let img = image::load_from_memory(&jpeg).ok()?; + let img = img.into_rgb8(); + check_pixels(img.width(), img.height()).ok()?; + return Some(img); + } + + // Raw samples (optionally flate-compressed): 8-bit DeviceRGB/DeviceGray. + let bpc = dict + .get(b"BitsPerComponent") + .and_then(lopdf::Object::as_i64); + if !matches!(bpc, Ok(8)) { + return None; + } + let colorspace = dict + .get(b"ColorSpace") + .and_then(lopdf::Object::as_name) + .ok()?; + let channels: usize = match colorspace { + b"DeviceRGB" => 3, + b"DeviceGray" => 1, + _ => return None, + }; + let raw = if filters.is_empty() { + stream.content.clone() + } else if filters == [b"FlateDecode".as_slice()] { + stream.decompressed_content().ok()? + } else { + return None; + }; + let expected = (width as usize) + .checked_mul(height as usize)? + .checked_mul(channels)?; + if raw.len() < expected { + return None; + } + match channels { + 3 => image::RgbImage::from_raw(width, height, raw[..expected].to_vec()), + 1 => { + let rgb: Vec = raw[..expected].iter().flat_map(|&g| [g, g, g]).collect(); + image::RgbImage::from_raw(width, height, rgb) + } + _ => unreachable!(), + } +} + +// --------------------------------------------------------------------------- +// The engine (feature "ocr"): lazy global ocrs instance + model fetch +// --------------------------------------------------------------------------- + +#[cfg(feature = "ocr")] +mod engine { + use std::path::{Path, PathBuf}; + use std::sync::OnceLock; + + use super::OcrBackend; + + /// Canonical distribution point for the ocrs models (the same URLs the + /// `ocrs` CLI fetches; the author's Hugging Face repo only carries the + /// pre-2024 model format, which rten ≥0.16 no longer loads). + const DETECTION_MODEL_URL: &str = + "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"; + const RECOGNITION_MODEL_URL: &str = + "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten"; + + /// `VERITY_OCR_MODEL_DIR` override, else `~/.cache/ocrs` (shared with the + /// ocrs CLI's own cache, so nothing downloads twice on a dev machine). + fn model_dir() -> Result { + if let Some(dir) = std::env::var_os("VERITY_OCR_MODEL_DIR") { + return Ok(PathBuf::from(dir)); + } + #[cfg(windows)] + let home = std::env::var_os("USERPROFILE"); + #[cfg(not(windows))] + let home = std::env::var_os("HOME"); + let home = home.ok_or("no home directory; set VERITY_OCR_MODEL_DIR")?; + Ok(PathBuf::from(home).join(".cache").join("ocrs")) + } + + /// Download `url` to `dest` once (no-op if present). Temp-file + rename so + /// a torn download never lands as a "cached" model. + fn fetch_once(url: &str, dest: &Path) -> Result<(), String> { + if dest.exists() { + return Ok(()); + } + let dir = dest + .parent() + .ok_or_else(|| format!("no parent dir for {}", dest.display()))?; + std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; + let mut resp = ureq::get(url) + .call() + .map_err(|e| format!("downloading {url}: {e}"))?; + let tmp = dest.with_extension(format!("part.{}", std::process::id())); + let result = (|| -> Result<(), String> { + let mut file = std::fs::File::create(&tmp) + .map_err(|e| format!("creating {}: {e}", tmp.display()))?; + std::io::copy(&mut resp.body_mut().as_reader(), &mut file) + .map_err(|e| format!("writing {}: {e}", tmp.display()))?; + std::fs::rename(&tmp, dest) + .map_err(|e| format!("moving model into place at {}: {e}", dest.display())) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result + } + + static ENGINE: OnceLock = OnceLock::new(); + + /// Fetch-if-needed, load, and cache the engine. Success is cached for the + /// process lifetime; failure is NOT (the next file retries, so a network + /// blip during the first scan doesn't poison every later one). Two racing + /// first calls may both build an engine; `get_or_init` keeps one. + fn global() -> Result<&'static ocrs::OcrEngine, String> { + if let Some(e) = ENGINE.get() { + return Ok(e); + } + let dir = model_dir()?; + let det_path = dir.join("text-detection.rten"); + let rec_path = dir.join("text-recognition.rten"); + fetch_once(DETECTION_MODEL_URL, &det_path)?; + fetch_once(RECOGNITION_MODEL_URL, &rec_path)?; + let detection = rten::Model::load_file(&det_path).map_err(|e| { + format!( + "loading {} (delete it to re-download): {e}", + det_path.display() + ) + })?; + let recognition = rten::Model::load_file(&rec_path).map_err(|e| { + format!( + "loading {} (delete it to re-download): {e}", + rec_path.display() + ) + })?; + let engine = ocrs::OcrEngine::new(ocrs::OcrEngineParams { + detection_model: Some(detection), + recognition_model: Some(recognition), + ..Default::default() + }) + .map_err(|e| format!("initializing OCR engine: {e}"))?; + Ok(ENGINE.get_or_init(|| engine)) + } + + pub(super) struct LazyOcrs; + + impl OcrBackend for LazyOcrs { + fn recognize_rgb(&self, width: u32, height: u32, rgb: &[u8]) -> Result { + let engine = global()?; + let source = ocrs::ImageSource::from_bytes(rgb, (width, height)) + .map_err(|e| format!("preparing image: {e}"))?; + let input = engine + .prepare_input(source) + .map_err(|e| format!("preparing OCR input: {e}"))?; + engine + .get_text(&input) + .map_err(|e| format!("recognizing text: {e}")) + } + } +} + +#[cfg(not(feature = "ocr"))] +mod engine { + use super::OcrBackend; + + /// Stub for `--no-default-features` builds: every OCR attempt fails typed + /// and disclosed — never silently empty. + pub(super) struct LazyOcrs; + + impl OcrBackend for LazyOcrs { + fn recognize_rgb(&self, _w: u32, _h: u32, _rgb: &[u8]) -> Result { + Err("verity-server was built without the 'ocr' cargo feature".into()) + } + } +} diff --git a/crates/verity-server/src/testdata/ocr-sample.jpg b/crates/verity-server/src/testdata/ocr-sample.jpg new file mode 100644 index 0000000000000000000000000000000000000000..52829c26d1aba7ffd4cfa023bb729dd3018ffc26 GIT binary patch literal 78168 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 icl1Eb%cfZH{nR-=^?>8nDLhRC!cx%J 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 EKTDf$DF6Tf literal 0 HcmV?d00001 diff --git a/ingest/tests/test_gdrive.py b/ingest/tests/test_gdrive.py index 3644cc2..eb5be30 100644 --- a/ingest/tests/test_gdrive.py +++ b/ingest/tests/test_gdrive.py @@ -1436,4 +1436,18 @@ def test_word_mimes_are_binary_extractable(): "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) assert is_binary_extractable("application/msword") - assert not is_binary_extractable("image/png") + + +def test_image_mimes_are_binary_extractable_png_jpeg_only(): + """The server's local OCR tier (extract.rs + ocr.rs) handles PNG and JPEG; + the mime gate must deliver exactly those. Formats the server would refuse + (GIF/TIFF/WebP/SVG) stay honestly metadata-only — pin both directions. + The sharepoint connector imports this gate, so this pins it there too.""" + from verity_ingest.connectors.gdrive import is_binary_extractable + + assert is_binary_extractable("image/png") + assert is_binary_extractable("image/jpeg") + assert not is_binary_extractable("image/gif") + assert not is_binary_extractable("image/tiff") + assert not is_binary_extractable("image/webp") + assert not is_binary_extractable("image/svg+xml") diff --git a/ingest/verity_ingest/connectors/gdrive.py b/ingest/verity_ingest/connectors/gdrive.py index 109aca9..3471d29 100644 --- a/ingest/verity_ingest/connectors/gdrive.py +++ b/ingest/verity_ingest/connectors/gdrive.py @@ -15,17 +15,20 @@ - Google Docs → ``files.export`` as ``text/plain`` - ``text/*`` and ``application/json`` → direct download (``alt=media``), delivered inline as ``content`` text -- PDF / PPTX / XLS(X) → direct download (``alt=media``), delivered as raw - bytes in ``content_base64`` (+ ``filename``); the SERVER runs the Tier-1 - extractor (verity-server extract.rs: Rust-native, deterministic, no OCR). +- PDF / Office formats / PNG / JPEG → direct download (``alt=media``), + delivered as raw bytes in ``content_base64`` (+ ``filename``); the SERVER + runs the Tier-1 extractor (verity-server extract.rs: Rust-native and local + — deterministic text layers, plus best-effort local OCR for scanned PDFs + and images, disclosed as ``pdf-ocr``/``image-ocr`` on the receipt). This was chosen over posting bytes to ``POST /v1/files`` because /v1/files writes under a scope handle whose principals would REPLACE the mirrored per-file ACL this connector computed — the whole point of a Tier-A connector. Riding the existing documents endpoint keeps one sink, the same visibility/entity mapping, and ACL-before-content ordering; the smallest - honest change. Typed extraction failures (encrypted PDF, scanned/image PDF - with no text layer, parse failure) land METADATA-ONLY server-side with the - reason disclosed on the stored record — never silently indexed as empty. + honest change. Typed extraction failures (encrypted PDF, scanned PDF where + OCR found nothing, OCR engine unavailable, parse failure) land + METADATA-ONLY server-side with the reason disclosed on the stored record — + never silently indexed as empty. - everything else → metadata + ACL only, no content bytes ACL mapping (fail-closed, §5e.6 / §6b): @@ -226,9 +229,9 @@ class GDriveDocumentEvent(DocumentEvent): # Binary formats the server's Tier-1 extractor handles (verity-server -# extract.rs): text-based PDF, PPTX, XLS(X). Deliberately NOT .doc/.docx or -# legacy .ppt — Google Docs already export as text, and anything else stays -# honestly metadata-only until a later tier. +# extract.rs): PDF (text layer, or the local OCR tier for scanned ones), +# Office formats, and PNG/JPEG images (local OCR). Deliberately NOT legacy +# .ppt, GIF/TIFF/WebP, etc. — anything else stays honestly metadata-only. BINARY_EXTRACTABLE_MIMES = frozenset( { "application/pdf", @@ -241,6 +244,11 @@ class GDriveDocumentEvent(DocumentEvent): "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel", + # Images: the server's local OCR tier (ocr.rs, ocrs+rten) extracts + # printed text best-effort, disclosed as method "image-ocr"; an image + # with no recognizable text lands metadata-only with a typed reason. + "image/png", + "image/jpeg", } ) From 644c77ad190331346831044b37777d948d3f2c08 Mon Sep 17 00:00:00 2001 From: athenanewsapi <192553512+athenanewsapi@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:31:26 -0400 Subject: [PATCH 2/2] ocr: security-review fixes (decompression bombs, model integrity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 launch-blocker fixed: Flate image streams inflate through a hard cap (W*H*channels + predictor slack; flate2 Read::take) — never lopdf's uncapped decompressed_content; a 100KB->100MB bomb now skips typed with bounded memory, probe-test included. R2: DCT dims verified from the codec header BEFORE pixel decode in both lanes. R3: model download gets timeouts, process-wide single-flight, unique temp names, and corrupt-cache self-heal (delete + refetch once). S1: SHA-256 pins for both model files, verified on download and cache load. C1: receipts carry pages_total + pages_skipped_unsupported, and an all-unsupported scan (CCITT/JBIG2/JPX) fails with its own honest reason instead of implying OCR looked. C2: flate-wrapped DCT implemented under the cap. Image lane catch_unwind fenced (typed, no JoinError 500). PNG predictors ported spec-correct (incl. lopdf's Avg precedence bug fixed). 42 extract + 15 engine tests green in BOTH feature configs; real-engine e2e passes; fmt/clippy clean. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + Cargo.toml | 5 + crates/verity-server/Cargo.toml | 4 + crates/verity-server/src/extract.rs | 462 +++++++++++++++++-- crates/verity-server/src/main.rs | 7 +- crates/verity-server/src/media.rs | 17 +- crates/verity-server/src/ocr.rs | 675 ++++++++++++++++++++++++---- 7 files changed, 1044 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e283122..616fef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5086,6 +5086,7 @@ dependencies = [ "cfb", "chrono", "clap", + "flate2", "futures-util", "hmac 0.12.1", "image", diff --git a/Cargo.toml b/Cargo.toml index 2fd564b..686dab4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,11 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # lopdf: pinned to the same version pdf-extract already compiles, so walking a # scanned PDF's page/image tree reuses the one PDF parser in the tree. lopdf = "0.42" +# flate2: capped FlateDecode inflation for embedded PDF images (ocr.rs). We +# deliberately do NOT use lopdf's decompressed_content() there — it inflates +# without any output bound, so a 100 KB stream declaring a 10x10 image can +# materialize gigabytes (decompression bomb). Already in the tree via zip. +flate2 = "1" # ureq: blocking OCR-model download (extraction is synchronous code); already # in the tree transitively via hf-hub. ureq = "3" diff --git a/crates/verity-server/Cargo.toml b/crates/verity-server/Cargo.toml index 3f6efb3..e8435b5 100644 --- a/crates/verity-server/Cargo.toml +++ b/crates/verity-server/Cargo.toml @@ -55,6 +55,10 @@ pdf-extract = { workspace = true } # cost — extraction then fails typed ("built without the 'ocr' feature"). image = { workspace = true } lopdf = { workspace = true } +# Bomb-guarded FlateDecode for embedded PDF images: lopdf's own +# decompressed_content() has no output cap, so ocr.rs inflates manually with +# flate2 + Read::take. Non-optional — the decode path compiles either way. +flate2 = { workspace = true } ocrs = { workspace = true, optional = true } rten = { workspace = true, optional = true } ureq = { workspace = true, optional = true } diff --git a/crates/verity-server/src/extract.rs b/crates/verity-server/src/extract.rs index daf2ffd..a8fbbf7 100644 --- a/crates/verity-server/src/extract.rs +++ b/crates/verity-server/src/extract.rs @@ -58,22 +58,26 @@ pub(crate) struct Extraction { /// layer. pub(crate) method: &'static str, pub(crate) truncated: bool, - /// For "pdf-ocr" only: how many pages were actually OCRed (engine - /// consulted), capped at [`ocr::MAX_OCR_PAGES`] — a partial pass is - /// visible on the receipt. - pub(crate) pages_ocred: Option, + /// For "pdf-ocr" only: the honest page accounting — pages OCRed (engine + /// consulted; capped at [`ocr::MAX_OCR_PAGES`]), total pages in the + /// document, and pages skipped because their images use encodings we + /// don't implement. A partial pass is visible on the receipt. + pub(crate) ocr_pages: Option, } /// The disclosed extraction receipt, embedded verbatim in episode payloads -/// and HTTP responses. ONE builder so no call site forgets `pages_ocred`. +/// and HTTP responses. ONE builder so no call site forgets the OCR page +/// accounting. pub(crate) fn receipt_json( method: &str, truncated: bool, - pages_ocred: Option, + ocr_pages: Option, ) -> serde_json::Value { let mut v = serde_json::json!({ "method": method, "truncated": truncated }); - if let Some(pages) = pages_ocred { - v["pages_ocred"] = pages.into(); + if let Some(pages) = ocr_pages { + v["pages_ocred"] = pages.ocred.into(); + v["pages_total"] = pages.total.into(); + v["pages_skipped_unsupported"] = pages.skipped_unsupported.into(); } v } @@ -88,6 +92,13 @@ pub(crate) enum ExtractFailure { /// No text layer AND the OCR pass recognized nothing (or found no /// decodable raster images at all). ScannedPdf, + /// No text layer, and every image-bearing page uses an encoding we don't + /// implement (CCITT/JBIG2/JPX, exotic colorspaces): OCR never got to + /// ATTEMPT recognition. Distinct from [`Self::ScannedPdf`], which means + /// OCR ran (or had nothing at all to run on) and found none — conflating + /// the two would pass off "we can't read this format" as "there was + /// nothing to read". + UnsupportedPdfImages, PdfParse(String), SheetParse(String), PptxParse(String), @@ -114,6 +125,9 @@ impl ExtractFailure { match self { Self::EncryptedPdf => "encrypted PDF".into(), Self::ScannedPdf => "scanned/image PDF — no text layer and OCR found none".into(), + Self::UnsupportedPdfImages => { + "scanned/image PDF — unsupported image encodings; OCR could not attempt".into() + } Self::PdfParse(e) => format!("PDF parse failure: {e}"), Self::SheetParse(e) => format!("spreadsheet parse failure: {e}"), Self::PptxParse(e) => format!("PPTX parse failure: {e}"), @@ -361,7 +375,7 @@ impl Budget { text: self.out, method, truncated: self.truncated, - pages_ocred: None, + ocr_pages: None, } } } @@ -418,7 +432,7 @@ fn extract_sheet(bytes: &[u8], cap: usize) -> Result text: String::new(), // folded to ExtractFailure::NoText by finish() method: "calamine", truncated: false, - pages_ocred: None, + ocr_pages: None, }); } Ok(budget.into_extraction("calamine")) @@ -496,7 +510,7 @@ fn extract_pptx(bytes: &[u8], cap: usize) -> Result text: String::new(), // folded to ExtractFailure::NoText by finish() method: "pptx-xml", truncated: false, - pages_ocred: None, + ocr_pages: None, }); } Ok(budget.into_extraction("pptx-xml")) @@ -566,7 +580,7 @@ fn extract_docx(bytes: &[u8], cap: usize) -> Result text: budget.out, method: "docx-xml", truncated: budget.truncated, - pages_ocred: None, + ocr_pages: None, }) } @@ -724,7 +738,7 @@ fn extract_doc(bytes: &[u8], cap: usize) -> Result { text: budget.out.trim().to_string(), method: "doc-piecetable", truncated: budget.truncated, - pages_ocred: None, + ocr_pages: None, }) } @@ -828,7 +842,7 @@ fn ocr_scanned_pdf( let pages = ocr::ocr_pdf_pages(bytes, &mut budget, ocr::MAX_OCR_PAGES, ocr)?; Ok((budget, pages)) })); - let (budget, pages_ocred) = match outcome { + let (budget, pages) = match outcome { Ok(Ok(ok)) => ok, Ok(Err(ocr::PdfOcrError::Parse(e))) => { return Err(ExtractFailure::PdfParse(format!("OCR pass: {e}"))) @@ -841,10 +855,16 @@ fn ocr_scanned_pdf( } }; if budget.out.trim().is_empty() { + // Honesty split: if OCR never got to attempt a single page because + // every image-bearing page uses an encoding we don't implement, say + // THAT — "OCR found none" would be a false claim of having looked. + if pages.ocred == 0 && pages.skipped_unsupported > 0 { + return Err(ExtractFailure::UnsupportedPdfImages); + } return Err(ExtractFailure::ScannedPdf); } let mut ex = budget.into_extraction("pdf-ocr"); - ex.pages_ocred = Some(pages_ocred); + ex.ocr_pages = Some(pages); Ok(ex) } @@ -857,17 +877,30 @@ fn extract_image( cap: usize, ocr: &dyn OcrBackend, ) -> Result { - let img = ocr::decode_rgb(bytes).map_err(ExtractFailure::ImageParse)?; - let text = ocr - .recognize_rgb(img.width(), img.height(), img.as_raw()) - .map_err(ExtractFailure::OcrUnavailable)?; - let text = text.trim(); - if text.is_empty() { - return Err(ExtractFailure::ImageNoText); + // Fenced like the PDF lanes: a decoder or engine panic on a hostile image + // must surface as a typed failure, never unwind into the handler (where + // it would become a JoinError 500 off the blocking pool). + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || -> Result { + let img = ocr::decode_rgb(bytes).map_err(ExtractFailure::ImageParse)?; + let text = ocr + .recognize_rgb(img.width(), img.height(), img.as_raw()) + .map_err(ExtractFailure::OcrUnavailable)?; + let text = text.trim(); + if text.is_empty() { + return Err(ExtractFailure::ImageNoText); + } + let mut budget = Budget::new(cap); + budget.push(text); + Ok(budget.into_extraction("image-ocr")) + }, + )); + match outcome { + Ok(res) => res, + Err(_) => Err(ExtractFailure::ImageParse( + "image decode/OCR panicked on malformed input (contained)".into(), + )), } - let mut budget = Budget::new(cap); - budget.push(text); - Ok(budget.into_extraction("image-ocr")) } // --------------------------------------------------------------------------- @@ -1194,6 +1227,99 @@ pub(crate) mod fixtures { ); pdf } + + /// The hostile-image harness: a one-page PDF embedding a single image + /// XObject with EXACTLY the dict entries given (everything but /Length, + /// which is derived) over an arbitrary stream body. Lets tests declare + /// lying dimensions, decompression bombs, and unsupported filters that no + /// honest encoder would produce. + pub(crate) fn pdf_with_image_xobject(image_dict: &str, stream: &[u8]) -> Vec { + let content = "q 100 0 0 100 0 0 cm /Im0 Do Q\n"; + let mut image_object = format!( + "<< /Type /XObject /Subtype /Image {image_dict} /Length {} >>\nstream\n", + stream.len() + ) + .into_bytes(); + image_object.extend_from_slice(stream); + image_object.extend_from_slice(b"\nendstream"); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \ + /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>" + .to_vec(), + format!( + "<< /Length {} >>\nstream\n{content}endstream", + content.len() + ) + .into_bytes(), + image_object, + ]; + + let mut pdf: Vec = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(obj); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref_at = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes()); + pdf.extend_from_slice(b"0000000000 65535 f \n"); + for off in offsets { + pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n", + objects.len() + 1 + ) + .as_bytes(), + ); + pdf + } + + /// zlib-compress bytes (FlateDecode test payloads). + pub(crate) fn zlib(bytes: &[u8]) -> Vec { + use std::io::Write as _; + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(bytes).expect("zlib write"); + enc.finish().expect("zlib finish") + } + + /// The review's probe, as a fixture: a small FlateDecode stream whose + /// dict declares a tiny image but whose payload inflates to ~100 MB. + pub(crate) fn flate_bomb_pdf() -> Vec { + use std::io::Write as _; + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + let zeros = [0u8; 65536]; + for _ in 0..1600 { + enc.write_all(&zeros).expect("zlib write"); // 1600 * 64 KiB = 100 MiB + } + let bomb = enc.finish().expect("zlib finish"); + assert!(bomb.len() < 256 * 1024, "bomb must be small on the wire"); + pdf_with_image_xobject( + "/Width 10 /Height 10 /ColorSpace /DeviceRGB /BitsPerComponent 8 \ + /Filter /FlateDecode", + &bomb, + ) + } + + /// Patch a fixture JPEG's SOF0 header to claim absurd dimensions — the + /// bytes still decode as a JPEG *header*, but any pixel decode would try + /// to materialize gigapixels. + pub(crate) fn jpeg_with_lying_dims(w: u32, h: u32, claim_w: u16, claim_h: u16) -> Vec { + let mut jpeg = jpeg_bytes(w, h); + let sof = jpeg + .windows(2) + .position(|m| m == [0xFF, 0xC0]) + .expect("baseline fixture JPEG has an SOF0 marker"); + // SOF0: FF C0 len(2) precision(1) height(2) width(2) … + jpeg[sof + 5..sof + 7].copy_from_slice(&claim_h.to_be_bytes()); + jpeg[sof + 7..sof + 9].copy_from_slice(&claim_w.to_be_bytes()); + jpeg + } } // --------------------------------------------------------------------------- @@ -1434,7 +1560,14 @@ mod tests { &FakeOcr("the falcon codeword is zanzibar"), )); assert_eq!(ex.method, "pdf-ocr"); - assert_eq!(ex.pages_ocred, Some(2)); + assert_eq!( + ex.ocr_pages, + Some(crate::ocr::OcrPages { + ocred: 2, + total: 2, + skipped_unsupported: 0 + }) + ); assert!(!ex.truncated); assert!(ex.text.contains("Page 1:")); assert!(ex.text.contains("Page 2:")); @@ -1458,7 +1591,13 @@ mod tests { &FakeOcr("line"), )); assert_eq!(ex.method, "pdf-ocr"); - assert_eq!(ex.pages_ocred, Some(crate::ocr::MAX_OCR_PAGES as u32)); + let pages = ex.ocr_pages.expect("pdf-ocr carries page accounting"); + assert_eq!(pages.ocred, crate::ocr::MAX_OCR_PAGES as u32); + assert_eq!( + pages.total, + crate::ocr::MAX_OCR_PAGES as u32 + 2, + "total must show the WHOLE document so the cap is visible" + ); assert!(ex .text .contains(&format!("Page {}:", crate::ocr::MAX_OCR_PAGES))); @@ -1517,17 +1656,263 @@ mod tests { assert_eq!(f, ExtractFailure::ScannedPdf); } + // ------- hostile embedded images: bombs, lying headers, unsupported ------- + + /// Records exactly what bitmaps the engine was shown (pixel-level proof + /// for the decode plumbing) and returns fixed text. + struct CaptureOcr(std::sync::Mutex)>>); + impl CaptureOcr { + fn new() -> Self { + Self(std::sync::Mutex::new(Vec::new())) + } + } + impl OcrBackend for CaptureOcr { + fn recognize_rgb(&self, w: u32, h: u32, rgb: &[u8]) -> Result { + self.0.lock().unwrap().push((w, h, rgb.to_vec())); + Ok("captured".into()) + } + } + + #[test] + fn extract_pdf_flate_bomb_is_skipped_typed_and_never_reaches_the_engine() { + // The review's probe: ~100 KB on the wire, declares 10x10, inflates + // to 100 MB. The capped inflate must skip it (typed ScannedPdf, since + // nothing else is on the page) without materializing the payload and + // without ever consulting the engine. + let bytes = fixtures::flate_bomb_pdf(); + let f = expect_failed(extract_with_ocr( + &bytes, + Some("bomb.pdf"), + MAX_EXTRACT_CHARS, + &PanicOcr, + )); + assert_eq!(f, ExtractFailure::ScannedPdf); + // And nothing was OCRed / nothing counted as "unsupported encoding": + // the bomb is bad DATA on a supported path, not an unsupported format. + let mut budget = Budget::new(MAX_EXTRACT_CHARS); + let pages = + crate::ocr::ocr_pdf_pages(&bytes, &mut budget, crate::ocr::MAX_OCR_PAGES, &PanicOcr) + .expect("walk parses"); + assert_eq!( + pages, + crate::ocr::OcrPages { + ocred: 0, + total: 1, + skipped_unsupported: 0 + } + ); + } + + #[test] + fn extract_inflate_capped_bounds_output_memory_at_the_cap() { + // Seam-level proof of the memory bound: a stream inflating to 10 MB + // against a 1000-byte cap is refused, and the refusal path can never + // have held more than cap+1 bytes of inflated output. + let payload = fixtures::zlib(&vec![0u8; 10 * 1024 * 1024]); + assert_eq!(crate::ocr::inflate_capped(&payload, 1000), None); + // At exactly the required cap the same stream inflates fine. + let ok = crate::ocr::inflate_capped(&payload, 10 * 1024 * 1024).expect("fits the cap"); + assert_eq!(ok.len(), 10 * 1024 * 1024); + } + + #[test] + fn extract_pdf_image_dict_declaring_oversize_dims_is_skipped_pre_decode() { + // MAX_OCR_PIXELS in the PDF lane: dict-declared 100k x 100k is + // refused before any sample is touched; the page contributes nothing. + let bytes = fixtures::pdf_with_image_xobject( + "/Width 100000 /Height 100000 /ColorSpace /DeviceRGB /BitsPerComponent 8", + b"tiny", + ); + let f = expect_failed(extract_with_ocr( + &bytes, + Some("huge.pdf"), + MAX_EXTRACT_CHARS, + &PanicOcr, + )); + assert_eq!(f, ExtractFailure::ScannedPdf); + } + + #[test] + fn extract_image_lane_rejects_lying_jpeg_dims_before_decode() { + // MAX_OCR_PIXELS in the standalone-image lane: the JPEG header claims + // 65500 x 65500 (~4.3 gigapixels); the header check must refuse it + // BEFORE any pixel decode, engine provably untouched. + let bytes = fixtures::jpeg_with_lying_dims(8, 8, 65500, 65500); + let f = expect_failed(extract_with_ocr( + &bytes, + Some("liar.jpg"), + MAX_EXTRACT_CHARS, + &PanicOcr, + )); + match f { + ExtractFailure::ImageParse(msg) => assert!( + msg.contains("refuses images over"), + "must be the pre-decode dimension refusal, got: {msg}" + ), + other => panic!("expected ImageParse, got {other:?}"), + } + } + + #[test] + fn extract_pdf_dct_image_with_lying_header_dims_is_rejected_pre_decode() { + // Same lie inside a PDF: the dict claims 8x8 (passes), but the JPEG's + // own header claims gigapixels — the header re-check must catch it + // before load, and the page then contributes nothing (typed). + let jpeg = fixtures::jpeg_with_lying_dims(8, 8, 65500, 65500); + let bytes = fixtures::pdf_with_image_xobject( + "/Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode", + &jpeg, + ); + let f = expect_failed(extract_with_ocr( + &bytes, + Some("liar.pdf"), + MAX_EXTRACT_CHARS, + &PanicOcr, + )); + assert_eq!(f, ExtractFailure::ScannedPdf); + } + + #[test] + fn extract_pdf_flate_wrapped_dct_image_decodes_and_ocrs() { + // [/FlateDecode /DCTDecode]: previously dead (lopdf errors + // Unimplemented on DCT); now the flate layer is stripped manually + // (capped) and the JPEG rides the normal bomb-checked decode. + let jpeg = fixtures::jpeg_bytes(24, 16); + let bytes = fixtures::pdf_with_image_xobject( + "/Width 24 /Height 16 /ColorSpace /DeviceRGB /BitsPerComponent 8 \ + /Filter [/FlateDecode /DCTDecode]", + &fixtures::zlib(&jpeg), + ); + let ex = expect_extracted(extract_with_ocr( + &bytes, + Some("wrapped.pdf"), + MAX_EXTRACT_CHARS, + &FakeOcr("the falcon codeword is zanzibar"), + )); + assert_eq!(ex.method, "pdf-ocr"); + assert_eq!( + ex.ocr_pages, + Some(crate::ocr::OcrPages { + ocred: 1, + total: 1, + skipped_unsupported: 0 + }) + ); + assert!(ex.text.contains("the falcon codeword is zanzibar")); + } + + #[test] + fn extract_pdf_all_pages_unsupported_encoding_fails_with_the_distinct_reason() { + // A CCITT-only scan: OCR never gets to ATTEMPT anything, and saying + // "OCR found none" would be a false claim of having looked (C1). + let bytes = fixtures::pdf_with_image_xobject( + "/Width 8 /Height 8 /ColorSpace /DeviceGray /BitsPerComponent 1 \ + /Filter /CCITTFaxDecode", + b"not really ccitt data", + ); + let f = expect_failed(extract_with_ocr( + &bytes, + Some("fax.pdf"), + MAX_EXTRACT_CHARS, + &PanicOcr, + )); + assert_eq!(f, ExtractFailure::UnsupportedPdfImages); + assert_eq!( + f.reason(), + "scanned/image PDF — unsupported image encodings; OCR could not attempt" + ); + // The accounting the receipt would carry on a partial success: + let mut budget = Budget::new(MAX_EXTRACT_CHARS); + let pages = + crate::ocr::ocr_pdf_pages(&bytes, &mut budget, crate::ocr::MAX_OCR_PAGES, &PanicOcr) + .expect("walk parses"); + assert_eq!( + pages, + crate::ocr::OcrPages { + ocred: 0, + total: 1, + skipped_unsupported: 1 + } + ); + } + #[test] - fn receipt_json_discloses_pages_ocred_only_when_present() { - let r = receipt_json("pdf-ocr", false, Some(3)); + fn extract_pdf_flate_image_with_png_predictor_reconstructs_exact_pixels() { + // The predictor handling lopdf used to apply inside its (uncapped) + // inflate, proven preserved on the capped path: Sub- and Up-filtered + // rows must reconstruct to the exact original samples. + let raw_rows: [[u8; 4]; 2] = [[10, 20, 30, 40], [50, 60, 70, 80]]; + let filtered = [ + [1u8, 10, 10, 10, 10], // Sub: first byte raw, then deltas of 10 + [2u8, 40, 40, 40, 40], // Up: deltas against the row above + ] + .concat(); + let bytes = fixtures::pdf_with_image_xobject( + "/Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 \ + /Filter /FlateDecode \ + /DecodeParms << /Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns 4 >>", + &fixtures::zlib(&filtered), + ); + let capture = CaptureOcr::new(); + let ex = expect_extracted(extract_with_ocr( + &bytes, + Some("predicted.pdf"), + MAX_EXTRACT_CHARS, + &capture, + )); + assert_eq!(ex.method, "pdf-ocr"); + let seen = capture.0.lock().unwrap(); + assert_eq!(seen.len(), 1, "exactly one bitmap must reach the engine"); + let (w, h, rgb) = &seen[0]; + assert_eq!((*w, *h), (4, 2)); + let expected: Vec = raw_rows.iter().flatten().flat_map(|&g| [g, g, g]).collect(); + assert_eq!(rgb, &expected, "unfiltered gray samples, replicated to RGB"); + } + + #[test] + fn extract_pdf_plain_flate_rgb_image_still_decodes() { + // The legit raw-samples lane must survive the bomb-guard rewrite. + let samples: Vec = (0..2u8 * 2 * 3).map(|i| i * 10).collect(); + let bytes = fixtures::pdf_with_image_xobject( + "/Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode", + &fixtures::zlib(&samples), + ); + let capture = CaptureOcr::new(); + let ex = expect_extracted(extract_with_ocr( + &bytes, + Some("raw.pdf"), + MAX_EXTRACT_CHARS, + &capture, + )); + assert_eq!(ex.method, "pdf-ocr"); + let seen = capture.0.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].2, samples); + } + + #[test] + fn receipt_json_discloses_ocr_page_accounting_only_when_present() { + let r = receipt_json( + "pdf-ocr", + false, + Some(crate::ocr::OcrPages { + ocred: 3, + total: 7, + skipped_unsupported: 2, + }), + ); assert_eq!(r["method"], "pdf-ocr"); assert_eq!(r["pages_ocred"], 3); + assert_eq!(r["pages_total"], 7); + assert_eq!(r["pages_skipped_unsupported"], 2); let r = receipt_json("pdf-text", true, None); assert_eq!(r["truncated"], true); - assert!( - r.get("pages_ocred").is_none(), - "non-OCR receipts must not carry pages_ocred" - ); + for key in ["pages_ocred", "pages_total", "pages_skipped_unsupported"] { + assert!( + r.get(key).is_none(), + "non-OCR receipts must not carry {key}" + ); + } } #[test] @@ -1543,7 +1928,7 @@ mod tests { &FakeOcr("renewal quote is 61000"), )); assert_eq!(ex.method, "image-ocr", "for {name}"); - assert_eq!(ex.pages_ocred, None); + assert_eq!(ex.ocr_pages, None); assert!(ex.text.contains("renewal quote is 61000")); } } @@ -1621,7 +2006,14 @@ mod tests { let pdf = fixtures::scanned_pdf_with_jpegs(&[jpeg.as_slice()]); let ex = expect_extracted(extract(&pdf, Some("scan.pdf"))); assert_eq!(ex.method, "pdf-ocr"); - assert_eq!(ex.pages_ocred, Some(1)); + assert_eq!( + ex.ocr_pages, + Some(crate::ocr::OcrPages { + ocred: 1, + total: 1, + skipped_unsupported: 0 + }) + ); let lower = ex.text.to_lowercase(); assert!( lower.contains("quick brown fox"), diff --git a/crates/verity-server/src/main.rs b/crates/verity-server/src/main.rs index 6013d93..1672de5 100644 --- a/crates/verity-server/src/main.rs +++ b/crates/verity-server/src/main.rs @@ -4273,11 +4273,8 @@ pub(crate) async fn ingest_document( .map_err(internal)?; let text = match outcome { extract::ExtractOutcome::Extracted(ex) => { - extraction_receipt = Some(extract::receipt_json( - ex.method, - ex.truncated, - ex.pages_ocred, - )); + extraction_receipt = + Some(extract::receipt_json(ex.method, ex.truncated, ex.ocr_pages)); Some(ex.text) } extract::ExtractOutcome::Failed(f) => { diff --git a/crates/verity-server/src/media.rs b/crates/verity-server/src/media.rs index a31947a..f09e01f 100644 --- a/crates/verity-server/src/media.rs +++ b/crates/verity-server/src/media.rs @@ -4,8 +4,9 @@ //! retrieval index under the uploader's scope; PDF / PPTX / XLS(X) / DOC(X) / //! PNG / JPEG go through the Tier-1 extractor (extract.rs — Rust-native and //! local; scanned PDFs and images ride the best-effort local OCR tier, ocr.rs) -//! and index the extracted text, with the method + truncation (+ pages_ocred -//! for OCRed PDFs) recorded in provenance and typed extraction failures stored +//! and index the extracted text, with the method + truncation (+ OCR page +//! accounting for OCRed PDFs) recorded in provenance and typed extraction +//! failures stored //! metadata-only, disclosed in both the response and the episode record. Other //! binary media is store-only. //! @@ -288,7 +289,7 @@ pub(crate) async fn ingest_file( text: String, method: &'static str, truncated: bool, - pages_ocred: Option, + ocr_pages: Option, }, Refuse(crate::extract::ExtractFailure), StoreOnly, @@ -309,7 +310,7 @@ pub(crate) async fn ingest_file( text: ex.text, method: ex.method, truncated: ex.truncated, - pages_ocred: ex.pages_ocred, + ocr_pages: ex.ocr_pages, }, crate::extract::ExtractOutcome::Failed(f) => Plan::Refuse(f), crate::extract::ExtractOutcome::NotHandled => { @@ -321,7 +322,7 @@ pub(crate) async fn ingest_file( text: s.to_string(), method: "utf-8", truncated: false, - pages_ocred: None, + ocr_pages: None, }, None => Plan::StoreOnly, } @@ -335,7 +336,7 @@ pub(crate) async fn ingest_file( text, method, truncated, - pages_ocred, + ocr_pages, } => { let episode_id = state .storage @@ -347,7 +348,7 @@ pub(crate) async fn ingest_file( payload: serde_json::json!({ "media_id": media_id, "filename": filename, "mime": mime, "sha256": sha256, "size_bytes": bytes.len(), - "extraction": crate::extract::receipt_json(method, truncated, pages_ocred), + "extraction": crate::extract::receipt_json(method, truncated, ocr_pages), }), content_hash: sha256.clone(), trust_tier: TrustTier::Observation, @@ -389,7 +390,7 @@ pub(crate) async fn ingest_file( // emit after a write. Its absence here meant file content added via // `verity-cli add` (POST /v1/files) was never entity-resolved. state.resolution.mark_dirty(payload.tenant_id); - extraction_receipt = Some(crate::extract::receipt_json(method, truncated, pages_ocred)); + extraction_receipt = Some(crate::extract::receipt_json(method, truncated, ocr_pages)); } Plan::Refuse(failure) => { let reason = failure.reason(); diff --git a/crates/verity-server/src/ocr.rs b/crates/verity-server/src/ocr.rs index 9548c67..bf8c45e 100644 --- a/crates/verity-server/src/ocr.rs +++ b/crates/verity-server/src/ocr.rs @@ -15,8 +15,12 @@ //! `ocrs` CLI uses, and the same fetch-once-then-cache lane as the MiniLM //! query encoder (which caches via hf-hub). `VERITY_OCR_MODEL_DIR` //! overrides the location for air-gapped deployments (drop the two `.rten` -//! files there by hand). The engine is only ever initialized when there is -//! actually an image to recognize — a text PDF never touches this module. +//! files there by hand). Downloads run under real connect/global timeouts, +//! are single-flighted process-wide, and every file — downloaded or cached — +//! must match a pinned SHA-256 before rten sees it (a corrupt cache +//! self-heals with one refetch). The engine is only ever initialized when +//! there is actually an image to recognize — a text PDF never touches this +//! module. //! * **Failure is typed, never silent, never fatal.** Download/init/inference //! failure surfaces as an `Err(String)` that extract.rs turns into the //! disclosed `OCR unavailable: …` extraction failure. A failed init is NOT @@ -105,28 +109,51 @@ pub(crate) enum PdfOcrError { Engine(String), } +/// The honest page accounting for a scanned-PDF OCR pass, disclosed verbatim +/// on the extraction receipt: `ocred < total` makes a partial pass visible; +/// `skipped_unsupported` makes "we never even attempted this page" visible. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OcrPages { + /// Pages where at least one image decoded and the engine was consulted. + pub(crate) ocred: u32, + /// Total pages in the document (NOT capped at [`MAX_OCR_PAGES`] — the + /// receipt must show how much of the document the pass could ever cover). + pub(crate) total: u32, + /// Walked pages that carried image XObjects, none of which we could + /// decode because every one used an encoding we don't implement + /// (CCITT/JBIG2/JPX, exotic colorspaces/bit depths). OCR never attempted + /// these pages; the caller reports that distinctly from "OCR found none". + pub(crate) skipped_unsupported: u32, +} + /// Walk a (text-layer-less) PDF's pages in order, decode each page's embedded /// raster image XObjects, OCR them, and push `Page N:` blocks into `budget`. /// -/// Returns how many pages had at least one image actually OCRed. Undecodable -/// or unsupported-filter images (CCITT/JBIG2/JPX) are skipped — pages built -/// from them simply contribute nothing, and if NOTHING contributes, the -/// caller declares the honest "OCR found none" failure. The engine is never -/// consulted when no image decodes, so a blank-page PDF stays cheap and a -/// broken model cache is only ever reported on files that needed it. +/// Returns the per-page accounting. Undecodable images (bombs, lying +/// dimensions, corrupt data) and unsupported-encoding images (CCITT/JBIG2/ +/// JPX) are skipped and counted — pages built from them contribute nothing, +/// and if NOTHING contributes, the caller declares the honest typed failure. +/// The engine is never consulted when no image decodes, so a blank-page PDF +/// stays cheap and a broken model cache is only ever reported on files that +/// needed it. pub(crate) fn ocr_pdf_pages( bytes: &[u8], budget: &mut Budget, max_pages: usize, ocr: &dyn OcrBackend, -) -> Result { +) -> Result { let doc = lopdf::Document::load_mem(bytes).map_err(|e| PdfOcrError::Parse(e.to_string()))?; - let mut pages_ocred = 0u32; - for (page_no, page_id) in doc.get_pages().into_iter().take(max_pages) { + let all_pages = doc.get_pages(); + let mut pages = OcrPages { + ocred: 0, + total: u32::try_from(all_pages.len()).unwrap_or(u32::MAX), + skipped_unsupported: 0, + }; + for (page_no, page_id) in all_pages.into_iter().take(max_pages) { + let scan = page_images(&doc, page_id); + let page_had_image = !scan.images.is_empty(); let mut page_text = String::new(); - let mut page_had_image = false; - for img in page_images(&doc, page_id) { - page_had_image = true; + for img in scan.images { let text = ocr .recognize_rgb(img.width(), img.height(), img.as_raw()) .map_err(PdfOcrError::Engine)?; @@ -139,27 +166,52 @@ pub(crate) fn ocr_pdf_pages( } } if page_had_image { - pages_ocred += 1; + pages.ocred += 1; + } else if scan.skipped_unsupported > 0 { + pages.skipped_unsupported += 1; } if !page_text.is_empty() && !budget.push(&format!("Page {page_no}:\n{page_text}\n\n")) { break; // char cap reached — disclosed via `truncated` } } - Ok(pages_ocred) + Ok(pages) +} + +/// One page's decode results: the images the engine will see, plus the counts +/// of what was skipped (and why) for the honest page accounting above. +struct PageScan { + images: Vec, + /// Encodings we don't implement (CCITT/JBIG2/JPX, exotic colorspaces…). + skipped_unsupported: u32, + /// Bad data: declared-oversize dims, flate payloads over the size cap + /// (bomb guard), truncated samples, corrupt JPEG bytes. + skipped_undecodable: u32, +} + +/// What [`decode_image_xobject`] decided about one image stream. +enum XObjectImage { + Decoded(image::RgbImage), + Unsupported, + Undecodable, } /// Decode the raster image XObjects a page references, in the order the /// resource dictionary lists them. Only self-describing or raw formats we can /// reconstruct deterministically are attempted: /// -/// * `DCTDecode` — the stream IS a JPEG; hand it to the image crate. +/// * `DCTDecode` — the stream IS a JPEG (possibly flate-wrapped); hand it to +/// the image crate after a header-dimensions bomb check. /// * `FlateDecode` / unfiltered — raw samples; reconstructed for 8-bit -/// DeviceRGB and DeviceGray. +/// DeviceRGB and DeviceGray, inflated under a hard cap. /// /// Anything else (CCITT, JBIG2, JPX, indexed palettes, exotic bit depths) is -/// skipped, never guessed at. -fn page_images(doc: &lopdf::Document, page_id: lopdf::ObjectId) -> Vec { - let mut out = Vec::new(); +/// skipped and counted, never guessed at. +fn page_images(doc: &lopdf::Document, page_id: lopdf::ObjectId) -> PageScan { + let mut out = PageScan { + images: Vec::new(), + skipped_unsupported: 0, + skipped_undecodable: 0, + }; let Ok((inline_dict, resource_ids)) = doc.get_page_resources(page_id) else { return out; }; @@ -193,19 +245,154 @@ fn page_images(doc: &lopdf::Document, page_id: lopdf::ObjectId) -> Vec out.images.push(img), + XObjectImage::Unsupported => out.skipped_unsupported += 1, + XObjectImage::Undecodable => out.skipped_undecodable += 1, } } } out } -fn decode_image_xobject(stream: &lopdf::Stream) -> Option { +/// Slack allowed on top of the exact expected sample size when inflating a +/// FlateDecode image stream: PNG predictors prepend 1 filter byte per row +/// (covered by `+ height` at the call sites) and this small fixed margin +/// absorbs encoder padding. Anything beyond the cap is a bomb — skipped. +const INFLATE_MARGIN: usize = 1024; + +/// Inflate a FlateDecode payload with a hard output cap. This exists because +/// lopdf's `decompressed_content()` inflates with NO output bound, so a +/// ~100 KB stream declaring a 10x10 image can materialize gigabytes and OOM +/// the server. Memory here is bounded at `cap + 1` bytes: the decoder is read +/// through `Read::take(cap + 1)`, and landing past `cap` means the stream +/// lied about its size → `None` (the caller skips the image, counted). +/// +/// Mirrors lopdf's decode quirks so behavior on legit files is unchanged: +/// a zlib failure that produced no output retries as raw deflate (corrupt +/// zlib headers/checksums in the wild), and a mid-stream error keeps the +/// partial output — the caller's expected-length check decides its fate. +pub(crate) fn inflate_capped(input: &[u8], cap: usize) -> Option> { + use std::io::Read as _; + + let limit = cap as u64 + 1; // one probe byte past the cap detects overflow + let mut out = Vec::new(); + let result = flate2::read::ZlibDecoder::new(input) + .take(limit) + .read_to_end(&mut out); + if result.is_err() && out.is_empty() && input.len() > 2 { + let _ = flate2::read::DeflateDecoder::new(&input[2..]) + .take(limit) + .read_to_end(&mut out); + } + if out.len() > cap { + return None; + } + Some(out) +} + +/// Undo PNG predictors (10-15) per the stream's `/DecodeParms`, row by row — +/// ported from the lopdf path we no longer take (its `filters::png` unfilter +/// ran inside the uncapped `decompressed_content()`). Predictor 1/2 and +/// absent params pass the data through untouched, exactly as lopdf did. +/// `None` means malformed predictor data (bad filter tag, ragged final row). +pub(crate) fn png_unpredict(data: Vec, params: Option<&lopdf::Dictionary>) -> Option> { + let Some(params) = params else { + return Some(data); + }; + let get = |key: &[u8]| params.get(key).and_then(lopdf::Object::as_i64).ok(); + let predictor = get(b"Predictor").unwrap_or(1); + if !(10..=15).contains(&predictor) { + return Some(data); + } + let columns = get(b"Columns").unwrap_or(1).max(1) as usize; + let colors = get(b"Colors").unwrap_or(1).max(1) as usize; + let bits = get(b"BitsPerComponent").unwrap_or(8).max(8) as usize; + let bpp = colors * bits / 8; + let row_len = bpp.checked_mul(columns)?; + let stride = row_len.checked_add(1)?; // +1 leading filter-type byte + if row_len == 0 || !data.len().is_multiple_of(stride) { + return None; + } + let mut prev = vec![0u8; row_len]; + let mut out = Vec::with_capacity(data.len() / stride * row_len); + for chunk in data.chunks_exact(stride) { + let mut cur = chunk[1..].to_vec(); + png_unfilter_row(chunk[0], bpp, &prev, &mut cur)?; + out.extend_from_slice(&cur); + prev = cur; + } + Some(out) +} + +/// One row of PNG unfiltering (RFC 2083 §6): None/Sub/Up/Average/Paeth. +fn png_unfilter_row(filter: u8, bpp: usize, prev: &[u8], cur: &mut [u8]) -> Option<()> { + let bpp = bpp.min(cur.len()); + match filter { + 0 => {} + 1 => { + for i in bpp..cur.len() { + cur[i] = cur[i].wrapping_add(cur[i - bpp]); + } + } + 2 => { + for i in 0..cur.len() { + cur[i] = cur[i].wrapping_add(prev[i]); + } + } + 3 => { + for i in 0..bpp { + cur[i] = cur[i].wrapping_add(prev[i] / 2); + } + for i in bpp..cur.len() { + let avg = (u16::from(cur[i - bpp]) + u16::from(prev[i])) / 2; + cur[i] = cur[i].wrapping_add(avg as u8); + } + } + 4 => { + for i in 0..bpp { + cur[i] = cur[i].wrapping_add(paeth_predict(0, prev[i], 0)); + } + for i in bpp..cur.len() { + cur[i] = cur[i].wrapping_add(paeth_predict(cur[i - bpp], prev[i], prev[i - bpp])); + } + } + _ => return None, + } + Some(()) +} + +fn paeth_predict(left: u8, above: u8, upper_left: u8) -> u8 { + let (l, a, ul) = (i16::from(left), i16::from(above), i16::from(upper_left)); + let estimate = l + a - ul; + let (dl, da, dul) = ( + (estimate - l).abs(), + (estimate - a).abs(), + (estimate - ul).abs(), + ); + if dl <= da && dl <= dul { + left + } else if da <= dul { + above + } else { + upper_left + } +} + +fn decode_image_xobject(stream: &lopdf::Stream) -> XObjectImage { let dict = &stream.dict; - let width = u32::try_from(dict.get(b"Width").and_then(lopdf::Object::as_i64).ok()?).ok()?; - let height = u32::try_from(dict.get(b"Height").and_then(lopdf::Object::as_i64).ok()?).ok()?; - check_pixels(width, height).ok()?; + let dim = |key: &[u8]| { + dict.get(key) + .and_then(lopdf::Object::as_i64) + .ok() + .and_then(|v| u32::try_from(v).ok()) + }; + let (Some(width), Some(height)) = (dim(b"Width"), dim(b"Height")) else { + return XObjectImage::Undecodable; + }; + if check_pixels(width, height).is_err() { + return XObjectImage::Undecodable; + } // Filter may be a single name, an array, or absent (raw samples). let filters: Vec<&[u8]> = match dict.get(b"Filter") { @@ -213,18 +400,41 @@ fn decode_image_xobject(stream: &lopdf::Stream) -> Option { Ok(lopdf::Object::Array(a)) => a.iter().filter_map(|o| o.as_name().ok()).collect(), _ => vec![], }; + let params = dict + .get(b"DecodeParms") + .and_then(lopdf::Object::as_dict) + .ok(); if filters.last() == Some(&b"DCTDecode".as_slice()) { - // The (possibly flate-wrapped) stream content is a JPEG file. - let jpeg = if filters.len() > 1 { - stream.decompressed_content().ok()? - } else { - stream.content.clone() + // The stream content is a JPEG file, possibly flate-wrapped. lopdf's + // decompressed_content() errors Unimplemented on DCT streams, so the + // flate layer is stripped manually — capped: a real JPEG payload for + // a dict-declared-legal image fits well under raw RGB size. + let jpeg: std::borrow::Cow<'_, [u8]> = match filters.as_slice() { + [_] => std::borrow::Cow::Borrowed(&stream.content), + [f, _] if *f == b"FlateDecode" => { + let cap = match (width as usize) + .checked_mul(height as usize) + .and_then(|p| p.checked_mul(3)) + .and_then(|p| p.checked_add(INFLATE_MARGIN)) + { + Some(cap) => cap, + None => return XObjectImage::Undecodable, + }; + match inflate_capped(&stream.content, cap) { + Some(jpeg) => std::borrow::Cow::Owned(jpeg), + None => return XObjectImage::Undecodable, + } + } + _ => return XObjectImage::Unsupported, + }; + // decode_rgb reads the JPEG's OWN header dimensions and enforces + // MAX_OCR_PIXELS BEFORE any pixel decodes — the dict check above only + // covered the *claimed* size, and headers can lie. + return match decode_rgb(&jpeg) { + Ok(img) => XObjectImage::Decoded(img), + Err(_) => XObjectImage::Undecodable, }; - let img = image::load_from_memory(&jpeg).ok()?; - let img = img.into_rgb8(); - check_pixels(img.width(), img.height()).ok()?; - return Some(img); } // Raw samples (optionally flate-compressed): 8-bit DeviceRGB/DeviceGray. @@ -232,37 +442,54 @@ fn decode_image_xobject(stream: &lopdf::Stream) -> Option { .get(b"BitsPerComponent") .and_then(lopdf::Object::as_i64); if !matches!(bpc, Ok(8)) { - return None; + return XObjectImage::Unsupported; } - let colorspace = dict - .get(b"ColorSpace") - .and_then(lopdf::Object::as_name) - .ok()?; - let channels: usize = match colorspace { - b"DeviceRGB" => 3, - b"DeviceGray" => 1, - _ => return None, + let channels: usize = match dict.get(b"ColorSpace").and_then(lopdf::Object::as_name) { + Ok(b"DeviceRGB") => 3, + Ok(b"DeviceGray") => 1, + _ => return XObjectImage::Unsupported, + }; + let expected = match (width as usize) + .checked_mul(height as usize) + .and_then(|p| p.checked_mul(channels)) + { + Some(e) => e, + None => return XObjectImage::Undecodable, }; let raw = if filters.is_empty() { stream.content.clone() } else if filters == [b"FlateDecode".as_slice()] { - stream.decompressed_content().ok()? + // Capped inflation (never lopdf's uncapped decompressed_content): + // expected samples + 1 predictor filter byte per row + fixed margin. + // A stream inflating past that declared-size envelope is a bomb. + let cap = expected + .saturating_add(height as usize) + .saturating_add(INFLATE_MARGIN); + let inflated = match inflate_capped(&stream.content, cap) { + Some(data) => data, + None => return XObjectImage::Undecodable, + }; + match png_unpredict(inflated, params) { + Some(data) => data, + None => return XObjectImage::Undecodable, + } } else { - return None; + return XObjectImage::Unsupported; }; - let expected = (width as usize) - .checked_mul(height as usize)? - .checked_mul(channels)?; if raw.len() < expected { - return None; + return XObjectImage::Undecodable; } - match channels { + let img = match channels { 3 => image::RgbImage::from_raw(width, height, raw[..expected].to_vec()), 1 => { let rgb: Vec = raw[..expected].iter().flat_map(|&g| [g, g, g]).collect(); image::RgbImage::from_raw(width, height, rgb) } _ => unreachable!(), + }; + match img { + Some(img) => XObjectImage::Decoded(img), + None => XObjectImage::Undecodable, } } @@ -273,7 +500,9 @@ fn decode_image_xobject(stream: &lopdf::Stream) -> Option { #[cfg(feature = "ocr")] mod engine { use std::path::{Path, PathBuf}; - use std::sync::OnceLock; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Mutex, OnceLock}; + use std::time::Duration; use super::OcrBackend; @@ -285,6 +514,35 @@ mod engine { const RECOGNITION_MODEL_URL: &str = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten"; + /// Pinned SHA-256 of the canonical model artifacts above (computed from + /// the upstream files; the bucket serves stable, versioned bytes). + /// Verified after every download AND on first cache load each process — + /// a corrupt, truncated, or tampered file never reaches rten, and a bad + /// cached copy self-heals (delete + one refetch) instead of failing OCR + /// until someone clears ~/.cache/ocrs by hand. + const DETECTION_MODEL_SHA256: &str = + "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca"; + const RECOGNITION_MODEL_SHA256: &str = + "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"; + + /// Real network bounds on the model download: without them a hung + /// connection parks the blocking extraction thread indefinitely. + const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + const GLOBAL_TIMEOUT: Duration = Duration::from_secs(120); + + /// Process-wide single-flight for the model fetch: N concurrent first + /// scans must produce ONE download, not N racing ones. + static FETCH_LOCK: Mutex<()> = Mutex::new(()); + /// Uniquifies temp download names within the process (the name also + /// carries the pid), so two healing threads can never tear each other's + /// partial files. + static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + + /// Injectable fetch seam: production passes [`download`]; tests inject + /// counting/faulty fetchers to prove single-flight and self-heal without + /// touching the network. + type FetchFn<'a> = &'a (dyn Fn(&str, &Path) -> Result<(), String> + Sync); + /// `VERITY_OCR_MODEL_DIR` override, else `~/.cache/ocrs` (shared with the /// ocrs CLI's own cache, so nothing downloads twice on a dev machine). fn model_dir() -> Result { @@ -299,20 +557,28 @@ mod engine { Ok(PathBuf::from(home).join(".cache").join("ocrs")) } - /// Download `url` to `dest` once (no-op if present). Temp-file + rename so - /// a torn download never lands as a "cached" model. - fn fetch_once(url: &str, dest: &Path) -> Result<(), String> { - if dest.exists() { - return Ok(()); - } + /// Download `url` to `dest` with real timeouts. Unique temp-file + rename + /// so a torn download never lands as a "cached" model. + fn download(url: &str, dest: &Path) -> Result<(), String> { let dir = dest .parent() .ok_or_else(|| format!("no parent dir for {}", dest.display()))?; std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; - let mut resp = ureq::get(url) + let agent = ureq::Agent::new_with_config( + ureq::Agent::config_builder() + .timeout_connect(Some(CONNECT_TIMEOUT)) + .timeout_global(Some(GLOBAL_TIMEOUT)) + .build(), + ); + let mut resp = agent + .get(url) .call() .map_err(|e| format!("downloading {url}: {e}"))?; - let tmp = dest.with_extension(format!("part.{}", std::process::id())); + let tmp = dest.with_extension(format!( + "part.{}.{}", + std::process::id(), + TMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); let result = (|| -> Result<(), String> { let mut file = std::fs::File::create(&tmp) .map_err(|e| format!("creating {}: {e}", tmp.display()))?; @@ -327,12 +593,102 @@ mod engine { result } + fn sha256_hex(path: &Path) -> Result { + use sha2::Digest as _; + let mut file = + std::fs::File::open(path).map_err(|e| format!("opening {}: {e}", path.display()))?; + let mut hasher = sha2::Sha256::new(); + std::io::copy(&mut file, &mut hasher) + .map_err(|e| format!("hashing {}: {e}", path.display()))?; + Ok(format!("{:x}", hasher.finalize())) + } + + fn verify_sha256(path: &Path, expected: &str) -> Result<(), String> { + let got = sha256_hex(path)?; + if got != expected { + return Err(format!( + "model checksum mismatch at {}: expected sha256 {expected}, got {got}", + path.display() + )); + } + Ok(()) + } + + /// Make `dest` present AND checksum-valid, fetching or self-healing as + /// needed. Fail-closed at every exit: a file that doesn't hash to the + /// pinned value is deleted, refetched at most ONCE, and if still wrong, + /// deleted again and reported typed — never handed to rten. + fn ensure_model_file( + url: &str, + dest: &Path, + expected_sha256: &str, + fetch: FetchFn<'_>, + ) -> Result<(), String> { + if dest.exists() { + match verify_sha256(dest, expected_sha256) { + Ok(()) => return Ok(()), + Err(_) => { + // Corrupt cache (torn pre-hardening download, disk rot, + // tampering): self-heal with one refetch. + std::fs::remove_file(dest) + .map_err(|e| format!("removing corrupt model {}: {e}", dest.display()))?; + } + } + } + fetch(url, dest)?; + verify_sha256(dest, expected_sha256).inspect_err(|_| { + let _ = std::fs::remove_file(dest); + }) + } + + /// Fetch/verify both model files under the process-wide single-flight + /// lock. Split from [`global`] so tests can prove that two racing threads + /// produce exactly one fetch. + fn ensure_models_locked( + specs: &[(&str, &Path, &str)], + fetch: FetchFn<'_>, + ) -> Result<(), String> { + let _guard = FETCH_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for (url, dest, sha) in specs { + ensure_model_file(url, dest, sha, fetch)?; + } + Ok(()) + } + + /// Load a (present, checksum-valid) model file; if loading fails anyway + /// (an artifact rten can't parse), delete it, refetch ONCE, and retry — + /// then fail typed. Generic over the loader so tests can prove the heal + /// without real rten models. + fn load_model_healing( + url: &str, + dest: &Path, + expected_sha256: &str, + fetch: FetchFn<'_>, + load: &dyn Fn(&Path) -> Result, + ) -> Result { + match load(dest) { + Ok(model) => Ok(model), + Err(first) => { + let _ = std::fs::remove_file(dest); + ensure_model_file(url, dest, expected_sha256, fetch)?; + load(dest).map_err(|e| { + format!( + "loading {} failed even after refetch: {e} (first attempt: {first})", + dest.display() + ) + }) + } + } + } + static ENGINE: OnceLock = OnceLock::new(); - /// Fetch-if-needed, load, and cache the engine. Success is cached for the - /// process lifetime; failure is NOT (the next file retries, so a network - /// blip during the first scan doesn't poison every later one). Two racing - /// first calls may both build an engine; `get_or_init` keeps one. + /// Fetch-if-needed, verify, load, and cache the engine. Success is cached + /// for the process lifetime; failure is NOT (the next file retries, so a + /// transient blip during the first-ever scan must not poison the + /// process). Fetches are single-flighted via [`ensure_models_locked`]; + /// two racing first calls may still both LOAD an engine from the verified + /// files (CPU-only) — `get_or_init` keeps one. fn global() -> Result<&'static ocrs::OcrEngine, String> { if let Some(e) = ENGINE.get() { return Ok(e); @@ -340,20 +696,30 @@ mod engine { let dir = model_dir()?; let det_path = dir.join("text-detection.rten"); let rec_path = dir.join("text-recognition.rten"); - fetch_once(DETECTION_MODEL_URL, &det_path)?; - fetch_once(RECOGNITION_MODEL_URL, &rec_path)?; - let detection = rten::Model::load_file(&det_path).map_err(|e| { - format!( - "loading {} (delete it to re-download): {e}", - det_path.display() - ) - })?; - let recognition = rten::Model::load_file(&rec_path).map_err(|e| { - format!( - "loading {} (delete it to re-download): {e}", - rec_path.display() - ) - })?; + ensure_models_locked( + &[ + (DETECTION_MODEL_URL, &det_path, DETECTION_MODEL_SHA256), + (RECOGNITION_MODEL_URL, &rec_path, RECOGNITION_MODEL_SHA256), + ], + &download, + )?; + let load = |p: &Path| { + rten::Model::load_file(p).map_err(|e| format!("loading {}: {e}", p.display())) + }; + let detection = load_model_healing( + DETECTION_MODEL_URL, + &det_path, + DETECTION_MODEL_SHA256, + &download, + &load, + )?; + let recognition = load_model_healing( + RECOGNITION_MODEL_URL, + &rec_path, + RECOGNITION_MODEL_SHA256, + &download, + &load, + )?; let engine = ocrs::OcrEngine::new(ocrs::OcrEngineParams { detection_model: Some(detection), recognition_model: Some(recognition), @@ -378,6 +744,157 @@ mod engine { .map_err(|e| format!("recognizing text: {e}")) } } + + /// Hermetic model-plumbing tests: fetch fns are injected, nothing touches + /// the network or rten. Named ocr_model_* so they ride the `ocr` filter. + #[cfg(test)] + mod tests { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use super::*; + + const GOOD: &[u8] = b"pretend this is an rten model"; + /// sha256 of GOOD, computed in-test (no second pinning to drift). + fn good_sha() -> String { + use sha2::Digest as _; + format!("{:x}", sha2::Sha256::digest(GOOD)) + } + + /// Fresh scratch dir per test (std-only; no tempfile dev-dep). + struct Scratch(PathBuf); + impl Scratch { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "verity-ocr-model-tests-{}-{}-{tag}", + std::process::id(), + TMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).expect("scratch dir"); + Scratch(dir) + } + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn counting_fetch( + counter: Arc, + body: &'static [u8], + ) -> impl Fn(&str, &Path) -> Result<(), String> + Sync { + move |_url: &str, dest: &Path| { + counter.fetch_add(1, Ordering::SeqCst); + std::fs::write(dest, body).map_err(|e| e.to_string()) + } + } + + #[test] + fn ocr_model_fetch_is_single_flight_two_threads_one_download() { + let scratch = Scratch::new("single-flight"); + let dest = scratch.path("model.rten"); + let sha = good_sha(); + let count = Arc::new(AtomicUsize::new(0)); + std::thread::scope(|s| { + for _ in 0..2 { + let fetch = counting_fetch(count.clone(), GOOD); + let (dest, sha) = (dest.clone(), sha.clone()); + s.spawn(move || { + ensure_models_locked(&[("mem://model", &dest, &sha)], &fetch) + .expect("fetch succeeds"); + }); + } + }); + assert_eq!( + count.load(Ordering::SeqCst), + 1, + "two racing first scans must produce exactly one download" + ); + assert_eq!(std::fs::read(&dest).unwrap(), GOOD); + } + + #[test] + fn ocr_model_corrupt_cache_self_heals_with_one_refetch() { + let scratch = Scratch::new("self-heal"); + let dest = scratch.path("model.rten"); + std::fs::write(&dest, b"bitrot").unwrap(); + let count = Arc::new(AtomicUsize::new(0)); + let fetch = counting_fetch(count.clone(), GOOD); + ensure_model_file("mem://model", &dest, &good_sha(), &fetch) + .expect("corrupt cache heals"); + assert_eq!(count.load(Ordering::SeqCst), 1, "exactly one refetch"); + assert_eq!( + std::fs::read(&dest).unwrap(), + GOOD, + "healed to canonical bytes" + ); + } + + #[test] + fn ocr_model_download_hash_mismatch_is_rejected_and_deleted() { + let scratch = Scratch::new("bad-download"); + let dest = scratch.path("model.rten"); + let count = Arc::new(AtomicUsize::new(0)); + let fetch = counting_fetch(count.clone(), b"not the pinned artifact"); + let err = ensure_model_file("mem://model", &dest, &good_sha(), &fetch) + .expect_err("mismatched download must be rejected"); + assert!(err.contains("checksum mismatch"), "typed reason: {err}"); + assert_eq!( + count.load(Ordering::SeqCst), + 1, + "refetched ONCE, then failed" + ); + assert!( + !dest.exists(), + "a mismatching file must never linger as cache" + ); + } + + #[test] + fn ocr_model_unloadable_file_is_deleted_refetched_once_then_loads() { + let scratch = Scratch::new("heal-load"); + let dest = scratch.path("model.rten"); + std::fs::write(&dest, GOOD).unwrap(); // checksum-valid but "unloadable" + let fetches = Arc::new(AtomicUsize::new(0)); + let fetch = counting_fetch(fetches.clone(), GOOD); + let loads = AtomicUsize::new(0); + let load = |p: &Path| -> Result, String> { + if loads.fetch_add(1, Ordering::SeqCst) == 0 { + Err("rten refused (test)".into()) + } else { + std::fs::read(p).map_err(|e| e.to_string()) + } + }; + let model = load_model_healing("mem://model", &dest, &good_sha(), &fetch, &load) + .expect("second load succeeds after heal"); + assert_eq!(model, GOOD); + assert_eq!(fetches.load(Ordering::SeqCst), 1, "healed with ONE refetch"); + assert_eq!(loads.load(Ordering::SeqCst), 2); + } + + #[test] + fn ocr_model_unloadable_after_refetch_fails_typed() { + let scratch = Scratch::new("heal-load-fails"); + let dest = scratch.path("model.rten"); + std::fs::write(&dest, GOOD).unwrap(); + let fetches = Arc::new(AtomicUsize::new(0)); + let fetch = counting_fetch(fetches.clone(), GOOD); + let load = |_: &Path| -> Result<(), String> { Err("rten refused (test)".into()) }; + let err = load_model_healing("mem://model", &dest, &good_sha(), &fetch, &load) + .expect_err("still-unloadable model fails typed"); + assert!(err.contains("after refetch"), "typed reason: {err}"); + assert_eq!( + fetches.load(Ordering::SeqCst), + 1, + "refetched ONCE, not in a loop" + ); + } + } } #[cfg(not(feature = "ocr"))]