返回 CodeWhale
host_tests.rs
根目录 / crates / tui / src / tools / image_ocr / host_tests.rs
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
413 lines RUST