| 1 | //! Real pinned harness/Execution broker and local fake parser. Native framework |
| 2 | //! is replaced only at its private test port; OS Vision acceptance remains separate. |
| 3 | use super::*; |
| 4 | use crate::extension_host::tests::node_for_tests; |
| 5 | use crate::extension_host::{ExtensionHostManager, ExtensionHostOptions, TestManagerGuard}; |
| 6 | use crate::features::{Feature, Features}; |
| 7 | use crate::plugins::activation::TestPolicyGuard; |
| 8 | use std::os::unix::fs::PermissionsExt; |
| 9 | use std::sync::Arc; |
| 10 | |
| 11 | struct Overrides(Option<TestOverrides>); |
| 12 | impl Drop for Overrides { |
| 13 | fn drop(&mut self) { |
| 14 | TEST_OVERRIDES.with(|slot| *slot.borrow_mut() = self.0.take()); |
| 15 | } |
| 16 | } |
| 17 | fn overrides(native: Result<Option<String>, ToolError>, binary: Option<OsString>) -> Overrides { |
| 18 | let value = TestOverrides { |
| 19 | native: Arc::new(move |_| native.clone()), |
| 20 | tesseract: binary, |
| 21 | }; |
| 22 | Overrides(TEST_OVERRIDES.with(|slot| slot.borrow_mut().replace(value))) |
| 23 | } |
| 24 | fn context(path: &Path, host: bool) -> ToolContext { |
| 25 | let mut flags = Features::with_defaults(); |
| 26 | if host { |
| 27 | flags.enable(Feature::OcrHost); |
| 28 | } |
| 29 | ToolContext::new(path).with_features(flags) |
| 30 | } |
| 31 | fn new_manager(node: PathBuf, path: &Path) -> Arc<ExtensionHostManager> { |
| 32 | Arc::new(ExtensionHostManager::new(ExtensionHostOptions { |
| 33 | runtime: crate::config::ExtensionHostRuntime::Node, |
| 34 | node_override: Some(node), |
| 35 | root: Some(path.join("host")), |
| 36 | ..ExtensionHostOptions::default() |
| 37 | })) |
| 38 | } |
| 39 | #[cfg(test)] |
| 40 | fn binary(root: &Path, body: &str) -> PathBuf { |
| 41 | let path = root.join("fake-tesseract"); |
| 42 | std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); |
| 43 | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); |
| 44 | path |
| 45 | } |
| 46 | fn decision(code: &str, trim_end: bool, message: Option<&str>) -> ToolResult { |
| 47 | ToolResult::success("").with_metadata( |
| 48 | json!({"kind":"ocr_decision","code":code,"trim_end":trim_end,"message":message}), |
| 49 | ) |
| 50 | } |
| 51 | #[test] |
| 52 | fn ocr_host_is_independent_and_defaults_to_rust() { |
| 53 | let mut flags = Features::with_defaults(); |
| 54 | assert!(!flags.enabled(Feature::OcrHost)); |
| 55 | assert_eq!( |
| 56 | crate::features::feature_from_key("ocr_host"), |
| 57 | Some(Feature::OcrHost) |
| 58 | ); |
| 59 | flags.enable(Feature::OcrHost); |
| 60 | assert!(!flags.enabled(Feature::ExtensionHost)); |
| 61 | assert!(!flags.enabled(Feature::PdfHost)); |
| 62 | } |
| 63 | #[tokio::test(flavor = "current_thread")] |
| 64 | async fn real_host_ocr_and_read_file_preserve_native_first_and_tesseract_results() { |
| 65 | let _home = crate::test_support::SealedHome::new(); |
| 66 | let _policy = TestPolicyGuard::extension_host(false); |
| 67 | let Some(node) = node_for_tests("real_host_ocr_consumers") else { |
| 68 | return; |
| 69 | }; |
| 70 | let root = tempfile::tempdir().unwrap(); |
| 71 | let manager = new_manager(node, root.path()); |
| 72 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 73 | let image = root.path().canonicalize().unwrap().join("image.png"); |
| 74 | std::fs::write(&image, b"private-image-fixture").unwrap(); |
| 75 | let marker = root.path().join("tesseract-launched"); |
| 76 | let tess = binary( |
| 77 | root.path(), |
| 78 | &format!( |
| 79 | "printf x >> '{}'; printf ' page one\\npage two\\f\\n\\t\\302\\205'", |
| 80 | marker.display() |
| 81 | ), |
| 82 | ); |
| 83 | let cases = [ |
| 84 | Ok(Some(" Native output stays exact \n".into())), |
| 85 | Ok(None), |
| 86 | Err(ToolError::execution_failed("Native framework refusal")), |
| 87 | ]; |
| 88 | for native in cases { |
| 89 | let native_ok = matches!(&native, Ok(Some(_))); |
| 90 | let _override = overrides(native, Some(tess.clone().into_os_string())); |
| 91 | for host in [false, true] { |
| 92 | let ctx = context(root.path(), host); |
| 93 | let result = ImageOcrTool |
| 94 | .execute(json!({"path":"image.png"}), &ctx) |
| 95 | .await |
| 96 | .unwrap(); |
| 97 | let expected = if native_ok { |
| 98 | " Native output stays exact \n" |
| 99 | } else { |
| 100 | " page one\npage two" |
| 101 | }; |
| 102 | assert_eq!(result.content, expected); |
| 103 | let read = crate::tools::file::ReadFileTool |
| 104 | .execute(json!({"path":"image.png"}), &ctx) |
| 105 | .await |
| 106 | .unwrap(); |
| 107 | assert_eq!( |
| 108 | read.content, |
| 109 | format!("<image_ocr path=\"image.png\">\n{expected}\n</image_ocr>") |
| 110 | ); |
| 111 | } |
| 112 | if native_ok { |
| 113 | assert!( |
| 114 | !marker.exists(), |
| 115 | "successful Native must not launch/probe fallback" |
| 116 | ); |
| 117 | } |
| 118 | } |
| 119 | assert_eq!(std::fs::read(&marker).unwrap(), b"xxxxxxxx"); |
| 120 | assert!(!crate::plugins::activation::extension_host_policy_enabled()); |
| 121 | manager.shutdown().await; |
| 122 | } |
| 123 | #[tokio::test(flavor = "current_thread")] |
| 124 | async fn real_host_ocr_no_backend_native_failure_and_parser_errors_match_default() { |
| 125 | let _home = crate::test_support::SealedHome::new(); |
| 126 | let _policy = TestPolicyGuard::extension_host(false); |
| 127 | let Some(node) = node_for_tests("real_host_ocr_faults") else { |
| 128 | return; |
| 129 | }; |
| 130 | let root = tempfile::tempdir().unwrap(); |
| 131 | let manager = new_manager(node, root.path()); |
| 132 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 133 | let image = root.path().canonicalize().unwrap().join("image.png"); |
| 134 | std::fs::write(&image, b"fixture").unwrap(); |
| 135 | for native in [ |
| 136 | Ok(None), |
| 137 | Err(ToolError::execution_failed("Native framework refusal")), |
| 138 | ] { |
| 139 | let _override = overrides(native, None); |
| 140 | let rust = ocr_image_path(&image, &context(root.path(), false)) |
| 141 | .await |
| 142 | .unwrap_err(); |
| 143 | let host = ocr_image_path(&image, &context(root.path(), true)) |
| 144 | .await |
| 145 | .unwrap_err(); |
| 146 | assert_eq!(host.to_string(), rust.to_string()); |
| 147 | } |
| 148 | for body in ["printf ' parser diagnostic ' >&2; exit 2", "exit 3"] { |
| 149 | let tess = binary(root.path(), body); |
| 150 | let _override = overrides(Ok(None), Some(tess.into_os_string())); |
| 151 | let rust = ocr_image_path(&image, &context(root.path(), false)) |
| 152 | .await |
| 153 | .unwrap_err(); |
| 154 | let host = ocr_image_path(&image, &context(root.path(), true)) |
| 155 | .await |
| 156 | .unwrap_err(); |
| 157 | assert_eq!(host.to_string(), rust.to_string()); |
| 158 | } |
| 159 | manager.shutdown().await; |
| 160 | } |
| 161 | #[tokio::test(flavor = "current_thread")] |
| 162 | async fn ocr_guards_run_before_host_start_and_host_refusal_has_no_fallback() { |
| 163 | let _home = crate::test_support::SealedHome::new(); |
| 164 | let _policy = TestPolicyGuard::extension_host(false); |
| 165 | let root = tempfile::tempdir().unwrap(); |
| 166 | let manager = new_manager(root.path().join("missing-runtime"), root.path()); |
| 167 | let _manager = TestManagerGuard::install(manager); |
| 168 | let ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); |
| 169 | let mark = Arc::clone(&ran); |
| 170 | let value = TestOverrides { |
| 171 | native: Arc::new(move |_| { |
| 172 | mark.store(true, std::sync::atomic::Ordering::SeqCst); |
| 173 | Ok(Some("must not fallback".into())) |
| 174 | }), |
| 175 | tesseract: None, |
| 176 | }; |
| 177 | let _override = Overrides(TEST_OVERRIDES.with(|slot| slot.borrow_mut().replace(value))); |
| 178 | let ctx = context(root.path(), true); |
| 179 | let missing = ImageOcrTool |
| 180 | .execute(json!({"path":"missing.png"}), &ctx) |
| 181 | .await |
| 182 | .unwrap_err(); |
| 183 | assert!(missing.to_string().contains("does not exist")); |
| 184 | std::fs::write(root.path().join(".env"), b"guarded").unwrap(); |
| 185 | assert!(matches!( |
| 186 | ImageOcrTool |
| 187 | .execute(json!({"path":".env"}), &ctx) |
| 188 | .await |
| 189 | .unwrap_err(), |
| 190 | ToolError::PermissionDenied { .. } |
| 191 | )); |
| 192 | std::fs::write( |
| 193 | root.path().canonicalize().unwrap().join("image.png"), |
| 194 | b"fixture", |
| 195 | ) |
| 196 | .unwrap(); |
| 197 | let refused = ImageOcrTool |
| 198 | .execute(json!({"path":"image.png"}), &ctx) |
| 199 | .await |
| 200 | .unwrap_err(); |
| 201 | assert!(refused.to_string().contains("Builtin") || refused.to_string().contains("runtime")); |
| 202 | assert!(!ran.load(std::sync::atomic::Ordering::SeqCst)); |
| 203 | } |
| 204 | #[test] |
| 205 | fn ocr_private_text_and_terminal_guards_refuse_forged_host_decisions() { |
| 206 | use std::os::unix::process::ExitStatusExt; |
| 207 | let native = || OcrOutcome::Native(Ok(Some("private image text\n".into()))); |
| 208 | assert!( |
| 209 | native() |
| 210 | .into_host_text(decision("tesseract_success", true, None)) |
| 211 | .is_err() |
| 212 | ); |
| 213 | assert_eq!( |
| 214 | native() |
| 215 | .into_host_text(decision("native_success", false, None)) |
| 216 | .unwrap(), |
| 217 | "private image text\n" |
| 218 | ); |
| 219 | let tess = |success| { |
| 220 | OcrOutcome::Tesseract(Ok(std::process::Output { |
| 221 | status: std::process::ExitStatus::from_raw(if success { 0 } else { 2 << 8 }), |
| 222 | stdout: vec![b'x'; MAX_OCR_TEXT], |
| 223 | stderr: b"private input path /private/customer/receipt.png\n".to_vec(), |
| 224 | })) |
| 225 | }; |
| 226 | let projection = tess(true).projection(); |
| 227 | assert!(serde_json::to_vec(&projection).unwrap().len() < 512); |
| 228 | assert!(projection.get("stdout").is_none()); |
| 229 | assert!(projection.get("stderr").is_none()); |
| 230 | assert!(!projection.to_string().contains("receipt.png")); |
| 231 | let expected = |
| 232 | "tesseract failed (exit Some(2)): private input path /private/customer/receipt.png"; |
| 233 | assert_eq!( |
| 234 | tess(false) |
| 235 | .into_host_text(decision( |
| 236 | "execution", |
| 237 | false, |
| 238 | Some("tesseract failed (exit Some(2)): ") |
| 239 | )) |
| 240 | .unwrap_err() |
| 241 | .to_string(), |
| 242 | ToolError::execution_failed(expected).to_string() |
| 243 | ); |
| 244 | assert!( |
| 245 | tess(false) |
| 246 | .into_host_text(decision( |
| 247 | "execution", |
| 248 | false, |
| 249 | Some("forged private diagnostic") |
| 250 | )) |
| 251 | .is_err() |
| 252 | ); |
| 253 | assert_eq!( |
| 254 | tess(true) |
| 255 | .into_host_text(decision("tesseract_success", true, None)) |
| 256 | .unwrap() |
| 257 | .len(), |
| 258 | MAX_OCR_TEXT |
| 259 | ); |
| 260 | assert!( |
| 261 | tess(false) |
| 262 | .into_host_text(decision("tesseract_success", true, None)) |
| 263 | .is_err() |
| 264 | ); |
| 265 | assert!( |
| 266 | OcrOutcome::Native(Ok(None)) |
| 267 | .into_host_text(decision("native_success", false, None)) |
| 268 | .is_err() |
| 269 | ); |
| 270 | let cancel = OcrOutcome::Tesseract(Err(ToolError::cancelled("cancelled"))) |
| 271 | .into_host_text(decision("fault", false, None)) |
| 272 | .unwrap_err(); |
| 273 | assert!(matches!(cancel, ToolError::Cancelled { .. })); |
| 274 | } |
| 275 | #[tokio::test(flavor = "current_thread")] |
| 276 | async fn real_host_ocr_cancellation_bounds_parser_tree_and_never_replays() { |
| 277 | let _home = crate::test_support::SealedHome::new(); |
| 278 | let _policy = TestPolicyGuard::extension_host(false); |
| 279 | let Some(node) = node_for_tests("real_host_ocr_cancel") else { |
| 280 | return; |
| 281 | }; |
| 282 | let root = tempfile::tempdir().unwrap(); |
| 283 | let manager = new_manager(node, root.path()); |
| 284 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 285 | let image = root.path().canonicalize().unwrap().join("image.png"); |
| 286 | std::fs::write(&image, b"fixture").unwrap(); |
| 287 | let warm = overrides(Ok(Some("warm".into())), None); |
| 288 | ocr_image_path(&image, &context(root.path(), true)) |
| 289 | .await |
| 290 | .unwrap(); |
| 291 | drop(warm); |
| 292 | let marker = root.path().join("launched"); |
| 293 | let child = root.path().join("descendant-survived"); |
| 294 | let tess = binary( |
| 295 | root.path(), |
| 296 | &format!( |
| 297 | "printf x >> '{}'; (/bin/sleep 1; printf alive > '{}') &\n/bin/sleep 30", |
| 298 | marker.display(), |
| 299 | child.display() |
| 300 | ), |
| 301 | ); |
| 302 | let _override = overrides(Ok(None), Some(tess.into_os_string())); |
| 303 | let cancel = CancellationToken::new(); |
| 304 | let stop = cancel.clone(); |
| 305 | let check = marker.clone(); |
| 306 | let waiter = tokio::spawn(async move { |
| 307 | for _ in 0..1000 { |
| 308 | if check.exists() { |
| 309 | stop.cancel(); |
| 310 | return; |
| 311 | } |
| 312 | tokio::time::sleep(Duration::from_millis(5)).await; |
| 313 | } |
| 314 | panic!("OCR parser never launched"); |
| 315 | }); |
| 316 | let ctx = context(root.path(), true).with_cancel_token(cancel); |
| 317 | let err = ocr_image_path(&image, &ctx).await.unwrap_err(); |
| 318 | assert!(matches!(err, ToolError::Cancelled { .. })); |
| 319 | waiter.await.unwrap(); |
| 320 | tokio::time::sleep(Duration::from_millis(1100)).await; |
| 321 | assert_eq!(std::fs::read(marker).unwrap(), b"x"); |
| 322 | assert!(!child.exists()); |
| 323 | manager.shutdown().await; |
| 324 | } |
| 325 | |
| 326 | #[tokio::test(flavor = "current_thread")] |
| 327 | async fn real_host_ocr_retains_captured_bytes_after_original_path_replacement() { |
| 328 | let _home = crate::test_support::SealedHome::new(); |
| 329 | let _policy = TestPolicyGuard::extension_host(false); |
| 330 | let Some(node) = node_for_tests("ocr_captured_image") else { |
| 331 | return; |
| 332 | }; |
| 333 | let root = tempfile::tempdir().unwrap(); |
| 334 | let manager = new_manager(node, root.path()); |
| 335 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 336 | let image = root.path().canonicalize().unwrap().join("receipt.png"); |
| 337 | let tess = binary(root.path(), "/bin/cat \"$1\""); |
| 338 | for host in [false, true] { |
| 339 | std::fs::write(&image, b"captured original image").unwrap(); |
| 340 | let original = image.clone(); |
| 341 | let observed = Arc::new(std::sync::Mutex::new(None)); |
| 342 | let record = Arc::clone(&observed); |
| 343 | let value = TestOverrides { |
| 344 | native: Arc::new(move |staged| { |
| 345 | assert_ne!(staged, original.as_path()); |
| 346 | assert_eq!(std::fs::read(staged).unwrap(), b"captured original image"); |
| 347 | *record.lock().unwrap() = Some(staged.to_path_buf()); |
| 348 | std::fs::remove_file(&original).unwrap(); |
| 349 | std::fs::write(&original, b"replacement image must not be parsed").unwrap(); |
| 350 | Ok(None) |
| 351 | }), |
| 352 | tesseract: Some(tess.clone().into_os_string()), |
| 353 | }; |
| 354 | let _override = Overrides(TEST_OVERRIDES.with(|slot| slot.borrow_mut().replace(value))); |
| 355 | let result = ImageOcrTool |
| 356 | .execute(json!({"path":"receipt.png"}), &context(root.path(), host)) |
| 357 | .await |
| 358 | .unwrap(); |
| 359 | assert_eq!(result.content, "captured original image"); |
| 360 | assert_eq!( |
| 361 | std::fs::read(&image).unwrap(), |
| 362 | b"replacement image must not be parsed" |
| 363 | ); |
| 364 | assert!( |
| 365 | !observed.lock().unwrap().as_ref().unwrap().exists(), |
| 366 | "captured temporary survives work and is retired afterwards" |
| 367 | ); |
| 368 | } |
| 369 | manager.shutdown().await; |
| 370 | } |
| 371 | #[tokio::test(flavor = "current_thread")] |
| 372 | async fn ocr_capture_reuses_bounded_anchored_reader_and_content_digest() { |
| 373 | use std::os::unix::fs::symlink; |
| 374 | let _home = crate::test_support::SealedHome::new(); |
| 375 | let root = tempfile::tempdir().unwrap(); |
| 376 | let root_path = root.path().canonicalize().unwrap(); |
| 377 | let image = root_path.join("receipt.png"); |
| 378 | std::fs::write(&image, b"captured original image").unwrap(); |
| 379 | let ctx = context(&root_path, false); |
| 380 | let deadline = tokio::time::Instant::now() + Duration::from_secs(10); |
| 381 | let first = CapturedOcr::capture(&image, &ctx, deadline).await.unwrap(); |
| 382 | let second = CapturedOcr::capture(&image, &ctx, deadline).await.unwrap(); |
| 383 | assert_ne!(first.path, second.path); |
| 384 | assert_eq!( |
| 385 | first.digest(), |
| 386 | crate::hashing::sha256_hex(b"captured original image") |
| 387 | ); |
| 388 | assert_eq!(first.digest(), second.digest()); |
| 389 | let staged = first.path.clone(); |
| 390 | drop(first); |
| 391 | assert!(!staged.exists()); |
| 392 | let link = root_path.join("linked.png"); |
| 393 | symlink(&image, &link).unwrap(); |
| 394 | assert!(CapturedOcr::capture(&link, &ctx, deadline).await.is_err()); |
| 395 | let hard = root_path.join("hard.png"); |
| 396 | std::fs::hard_link(&image, &hard).unwrap(); |
| 397 | assert!(CapturedOcr::capture(&image, &ctx, deadline).await.is_err()); |
| 398 | std::fs::remove_file(hard).unwrap(); |
| 399 | let big = root_path.join("big.png"); |
| 400 | std::fs::File::create(&big) |
| 401 | .unwrap() |
| 402 | .set_len((16 * 1024 * 1024 + 1) as u64) |
| 403 | .unwrap(); |
| 404 | assert!( |
| 405 | CapturedOcr::capture(&big, &ctx, deadline) |
| 406 | .await |
| 407 | .err() |
| 408 | .unwrap() |
| 409 | .to_string() |
| 410 | .contains("16 MiB") |
| 411 | ); |
| 412 | } |
| 413 |