1use std::path::{Path, PathBuf};
9
10use async_trait::async_trait;
11use gaze_mcp_core::{
12 Tool, ToolCtx, ToolDescriptor, ToolError, ToolRegistry, ToolRegistryError, ToolResponse,
13};
14use serde::Serialize;
15use serde_json::json;
16
17#[cfg(feature = "ocr-tesseract")]
18use crate::extract::InputKind;
19#[cfg(feature = "ocr-tesseract")]
20use crate::DocumentError;
21
22pub const DEFAULT_MAX_FILE_SIZE: u64 = 25 * 1024 * 1024;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct GazeReadOpts {
29 pub max_file_size: u64,
31}
32
33impl Default for GazeReadOpts {
34 fn default() -> Self {
35 Self {
36 max_file_size: DEFAULT_MAX_FILE_SIZE,
37 }
38 }
39}
40
41pub fn register_tools(
43 registry: &mut ToolRegistry,
44 opts: GazeReadOpts,
45) -> Result<(), ToolRegistryError> {
46 registry.register(GazeReadText::new())?;
47 registry.register(GazeReadFile::with_max_file_size(opts.max_file_size))?;
48 Ok(())
49}
50
51#[derive(Debug)]
53#[non_exhaustive]
54pub struct GazeReadText {
55 descriptor: ToolDescriptor,
56}
57
58impl GazeReadText {
59 pub fn new() -> Self {
61 Self {
62 descriptor: ToolDescriptor::agent(
63 "gaze_read_text",
64 json!({
65 "type": "object",
66 "properties": {
67 "text": {
68 "type": "string",
69 "description": "Already-extracted text to pseudonymize before model use."
70 }
71 },
72 "required": ["text"]
73 }),
74 )
75 .with_description("Pseudonymize already-extracted text before returning it to an MCP client.")
76 .with_output_schema(response_schema())
77 .with_carriers(gaze_mcp_core::CarrierDeclaration::text_fields(&["text"]), response_carriers()),
78 }
79 }
80}
81
82impl Default for GazeReadText {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88#[async_trait]
89impl Tool for GazeReadText {
90 fn descriptor(&self) -> &ToolDescriptor {
91 &self.descriptor
92 }
93
94 async fn invoke(&self, ctx: &ToolCtx<'_>) -> Result<ToolResponse, ToolError> {
95 let text = required_string(ctx.redacted_args(), "text")?;
96 let clean_text = redact_document_text(text, ctx)?;
97 Ok(ToolResponse::json(json!(DocumentToolResponse {
98 clean_markdown: format_text_markdown(&clean_text),
99 manifest_id: ctx.call_id().to_string(),
100 file_metadata: FileMetadata {
101 source_kind: "text".to_string(),
102 ocr_mean_confidence: None,
103 bundle_version: crate::BUNDLE_VERSION,
104 page_count: None,
105 },
106 })))
107 }
108}
109
110#[derive(Debug)]
112#[non_exhaustive]
113pub struct GazeReadFile {
114 descriptor: ToolDescriptor,
115 max_file_size: u64,
116}
117
118impl GazeReadFile {
119 pub fn new() -> Self {
121 Self::with_max_file_size(DEFAULT_MAX_FILE_SIZE)
122 }
123
124 pub fn with_max_file_size(max_file_size: u64) -> Self {
126 Self {
127 descriptor: ToolDescriptor::agent(
128 "gaze_read_file",
129 json!({
130 "type": "object",
131 "properties": {
132 "path": {
133 "type": "string",
134 "description": "Filesystem path to a PNG, JPG, or PDF document."
135 }
136 },
137 "required": ["path"]
138 }),
139 )
140 .with_description(
141 "Read an image or PDF through OCR and Gaze pseudonymization before MCP return.",
142 )
143 .with_output_schema(response_schema())
144 .with_carriers(
145 gaze_mcp_core::CarrierDeclaration::text_fields(&["path"]),
146 response_carriers(),
147 ),
148 max_file_size,
149 }
150 }
151}
152
153impl Default for GazeReadFile {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159#[async_trait]
160impl Tool for GazeReadFile {
161 fn descriptor(&self) -> &ToolDescriptor {
162 &self.descriptor
163 }
164
165 async fn invoke(&self, ctx: &ToolCtx<'_>) -> Result<ToolResponse, ToolError> {
166 let protected_path = required_string(ctx.redacted_args(), "path")?;
169 let raw_path = restore_path(ctx.resources().session(), protected_path)?;
170 let path = PathBuf::from(raw_path);
171 validate_file(&path, self.max_file_size)?;
172 read_file_response(&path, ctx).map(|response| ToolResponse::json(json!(response)))
173 }
174}
175
176#[derive(Serialize)]
177struct DocumentToolResponse {
178 clean_markdown: String,
179 manifest_id: String,
180 file_metadata: FileMetadata,
181}
182
183#[derive(Serialize)]
184struct FileMetadata {
185 source_kind: String,
186 ocr_mean_confidence: Option<f32>,
187 bundle_version: u32,
188 page_count: Option<u32>,
189}
190
191fn required_string<'a>(args: &'a serde_json::Value, field: &str) -> Result<&'a str, ToolError> {
192 args.get(field)
193 .and_then(|value| value.as_str())
194 .ok_or_else(|| ToolError::InvalidArgs(format!("missing required string field `{field}`")))
195}
196
197fn response_carriers() -> gaze_mcp_core::CarrierDeclaration {
198 use gaze_mcp_core::CarrierSegment::Member;
199 let path = |names: &[&str]| {
200 names
201 .iter()
202 .map(|name| Member((*name).into()))
203 .collect::<Vec<_>>()
204 };
205 let mut members = ["clean_markdown", "manifest_id", "file_metadata"]
206 .iter()
207 .map(|name| path(&[name]))
208 .collect::<Vec<_>>();
209 for name in [
210 "source_kind",
211 "ocr_mean_confidence",
212 "bundle_version",
213 "page_count",
214 ] {
215 members.push(path(&["file_metadata", name]));
216 }
217 let numbers = ["ocr_mean_confidence", "bundle_version", "page_count"]
218 .iter()
219 .map(|name| path(&["file_metadata", name]))
220 .collect();
221 gaze_mcp_core::CarrierDeclaration::new(members, numbers)
222}
223
224fn redact_document_text(text: &str, ctx: &ToolCtx<'_>) -> Result<String, ToolError> {
225 let pipeline = crate::bundle::build_document_pipeline().map_err(map_document_error)?;
226 let mut transaction = ctx.resources().session().begin_transaction();
229 let clean = pipeline
230 .protect_text_transaction(&mut transaction, text, ctx.resources().protection_context())
231 .map_err(ToolError::internal)?;
232 transaction.commit().map_err(ToolError::internal)?;
233 Ok(clean)
234}
235
236fn restore_path(session: &gaze::Session, protected_path: &str) -> Result<String, ToolError> {
256 gaze::token_shape::validate_restore_shapes(protected_path)
259 .map_err(|_| ToolError::InvalidArgs("path restoration failed".into()))?;
260
261 let assessment = session
265 .assess_restore_text(protected_path)
266 .map_err(|_| ToolError::InvalidArgs("path restoration failed".into()))?;
267 if !assessment.unknown_tokens().is_empty() {
268 return Err(ToolError::InvalidArgs("path restoration failed".into()));
269 }
270 Ok(assessment.into_restored().text)
271}
272
273fn validate_file(path: &Path, max_file_size: u64) -> Result<(), ToolError> {
274 let metadata = std::fs::metadata(path).map_err(|err| map_file_metadata_error(path, err))?;
275 if !metadata.is_file() {
276 return Err(ToolError::InvalidArgs(format!(
277 "path `{}` is not a regular file",
278 path.display()
279 )));
280 }
281 if metadata.len() > max_file_size {
282 return Err(ToolError::LimitExceeded(format!(
283 "file `{}` is {} bytes; configured cap is {} bytes",
284 path.display(),
285 metadata.len(),
286 max_file_size
287 )));
288 }
289 Ok(())
290}
291
292fn map_file_metadata_error(path: &Path, err: std::io::Error) -> ToolError {
293 if err.kind() == std::io::ErrorKind::NotFound {
294 ToolError::NotFound(format!("file `{}` not found", path.display()))
295 } else {
296 ToolError::internal(err)
297 }
298}
299
300#[cfg(feature = "ocr-tesseract")]
301fn read_file_response(path: &Path, ctx: &ToolCtx<'_>) -> Result<DocumentToolResponse, ToolError> {
302 let kind = InputKind::detect(path).map_err(map_document_error)?;
303 let backend = crate::ocr::TesseractBackend::new();
304 let (ocr_result, pdf_page_count, _) =
305 crate::bundle::run_ocr(path, kind, &backend).map_err(map_document_error)?;
306 let normalized = crate::ocr::normalize_ocr_artifacts(&ocr_result.text);
307 let clean_text = redact_document_text(&normalized, ctx)?;
308 Ok(DocumentToolResponse {
309 clean_markdown: crate::bundle::format_clean_markdown(&clean_text, kind),
310 manifest_id: ctx.call_id().to_string(),
311 file_metadata: FileMetadata {
312 source_kind: source_kind(kind).to_string(),
313 ocr_mean_confidence: ocr_result.mean_confidence,
314 bundle_version: crate::BUNDLE_VERSION,
315 page_count: pdf_page_count.and_then(|count| u32::try_from(count).ok()),
316 },
317 })
318}
319
320#[cfg(feature = "ocr-tesseract")]
321fn source_kind(kind: InputKind) -> &'static str {
322 match crate::bundle::kind_label(kind) {
323 "png" | "jpeg" => "image",
324 "pdf" => "pdf",
325 other => other,
326 }
327}
328
329#[cfg(feature = "ocr-tesseract")]
330fn map_document_error(err: DocumentError) -> ToolError {
331 match err {
332 DocumentError::TesseractNotFound(hint) | DocumentError::PdfiumNotFound(hint) => {
333 ToolError::BackendUnavailable(hint)
334 }
335 DocumentError::TesseractFailed { status, stderr } => {
336 ToolError::BackendFailure(format!("tesseract exited with status {status}: {stderr}"))
337 }
338 DocumentError::PdfRasterFailed(detail) => ToolError::BackendFailure(detail),
339 DocumentError::UnsupportedInput { path, reason } => {
340 ToolError::InvalidArgs(format!("unsupported input `{}`: {reason}", path.display()))
341 }
342 other => ToolError::internal(other),
343 }
344}
345
346fn format_text_markdown(text: &str) -> String {
347 let mut out = String::new();
348 out.push_str("# gaze-document safe text\n\n");
349 out.push_str("Source kind: `text`\n\n");
350 out.push_str("---\n\n");
351 out.push_str(text);
352 if !text.ends_with('\n') {
353 out.push('\n');
354 }
355 out
356}
357
358fn response_schema() -> serde_json::Value {
359 json!({
360 "type": "object",
361 "properties": {
362 "clean_markdown": { "type": "string" },
363 "manifest_id": { "type": "string" },
364 "file_metadata": {
365 "type": "object",
366 "properties": {
367 "source_kind": { "type": "string" },
368 "ocr_mean_confidence": { "type": ["number", "null"] },
369 "bundle_version": { "type": "integer" },
370 "page_count": { "type": ["integer", "null"] }
371 },
372 "required": [
373 "source_kind",
374 "ocr_mean_confidence",
375 "bundle_version",
376 "page_count"
377 ]
378 }
379 },
380 "required": ["clean_markdown", "manifest_id", "file_metadata"]
381 })
382}
383
384#[cfg(test)]
385mod tests {
386 use std::sync::atomic::{AtomicUsize, Ordering};
387 use std::sync::Arc;
388
389 use async_trait::async_trait;
390 use gaze_mcp_core::{
391 AuthError, AuthHook, DispatchError, ManifestStore, PiiEnvelope, Principal, SessionIdPolicy,
392 };
393 use gaze_mcp_core::{BeginCallContext, CallHandle, FailureReason, ManifestError, SnapshotRef};
394 use serde_json::json;
395
396 use super::*;
397
398 #[test]
399 fn w1_restore_path_rejects_malformed_session_placeholder() {
400 let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
401 assert!(
402 super::restore_path(&session, "directory/<deadbeef:Email_>/input.png").is_err(),
403 "malformed session placeholder reached filesystem path"
404 );
405 }
406
407 #[test]
408 fn r1_restore_path_preserves_owned_and_legacy_boundaries() {
409 let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
410 let token = session
411 .tokenize(&gaze::PiiClass::Email, "alice@example.invalid")
412 .unwrap();
413 assert_eq!(
414 super::restore_path(&session, &format!("directory/{token}/input.png")).unwrap(),
415 "directory/alice@example.invalid/input.png"
416 );
417 for path in [
418 format!("directory/<{token}>/input.png"),
419 "directory/email1@gaze-fake.invalid/input.png".to_owned(),
420 "directory/<Email_999>/input.png".to_owned(),
421 ] {
422 assert!(
423 super::restore_path(&session, &path).is_err(),
424 "unowned or nested token reached filesystem path: {path}"
425 );
426 }
427 }
428
429 #[test]
437 fn restore_path_passes_a_redaction_marker_through_verbatim() {
438 let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
439 let token = session
440 .tokenize(&gaze::PiiClass::Email, "alice@example.invalid")
441 .unwrap();
442 for class in [
443 gaze::PiiClass::Name,
444 gaze::PiiClass::custom("address_2").unwrap(),
445 ] {
446 let marker = gaze::redaction_marker(&class);
447 let path = format!("directory/{marker}/{token}/input.png");
448 assert_eq!(
449 super::restore_path(&session, &path).unwrap(),
450 format!("directory/{marker}/alice@example.invalid/input.png"),
451 "{marker} must pass through untouched while the owned token restores"
452 );
453 }
454 }
455
456 #[test]
457 fn r1_restore_path_retains_malformed_family_rejection() {
458 let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
459 for path in [
460 "directory/<Custom:family:tenant-document_>/input.png",
461 "directory/<deadbeef:Custom:family:tenant-document_>/input.png",
462 ] {
463 assert!(session.restore_strict_text(path).is_err());
464 assert!(
465 super::restore_path(&session, path).is_err(),
466 "malformed family token reached filesystem path: {path}"
467 );
468 }
469 }
470
471 struct AllowAllAuth;
472
473 #[async_trait]
474 impl AuthHook for AllowAllAuth {
475 async fn authorize_agent(
476 &self,
477 _principal: &Principal,
478 _tool_name: &str,
479 ) -> Result<(), AuthError> {
480 Ok(())
481 }
482
483 async fn authorize_operator(
484 &self,
485 _principal: &Principal,
486 _tool_name: &str,
487 ) -> Result<(), AuthError> {
488 Err(AuthError::Denied("operator tier disabled in test".into()))
489 }
490 }
491
492 struct RecordingManifest {
493 begins: AtomicUsize,
494 finishes: AtomicUsize,
495 failures: AtomicUsize,
496 args: std::sync::Mutex<Vec<serde_json::Value>>,
497 }
498
499 impl RecordingManifest {
500 fn new() -> Self {
501 Self {
502 begins: AtomicUsize::new(0),
503 finishes: AtomicUsize::new(0),
504 failures: AtomicUsize::new(0),
505 args: Default::default(),
506 }
507 }
508 }
509
510 #[async_trait]
511 impl ManifestStore for RecordingManifest {
512 async fn begin_call(&self, ctx: BeginCallContext<'_>) -> Result<CallHandle, ManifestError> {
513 self.begins.fetch_add(1, Ordering::SeqCst);
514 self.args.lock().unwrap().push(ctx.redacted_args.clone());
515 Ok(CallHandle::new(ctx.call_id))
516 }
517
518 async fn finish_call(
519 &self,
520 _handle: CallHandle,
521 _snapshot: SnapshotRef,
522 ) -> Result<(), ManifestError> {
523 self.finishes.fetch_add(1, Ordering::SeqCst);
524 Ok(())
525 }
526
527 async fn fail_call(
528 &self,
529 _handle: CallHandle,
530 _reason: FailureReason,
531 ) -> Result<(), ManifestError> {
532 self.failures.fetch_add(1, Ordering::SeqCst);
533 Ok(())
534 }
535 }
536
537 struct Harness {
538 registry: ToolRegistry,
539 auth: AllowAllAuth,
540 manifest: Arc<RecordingManifest>,
541 pipeline: gaze::Pipeline,
542 session: gaze::Session,
543 session_id_policy: SessionIdPolicy,
544 }
545
546 impl Harness {
547 fn new() -> Self {
548 let mut registry = ToolRegistry::new();
549 register_tools(&mut registry, GazeReadOpts::default()).expect("register tools");
550 Self {
551 registry,
552 auth: AllowAllAuth,
553 manifest: Arc::new(RecordingManifest::new()),
554 pipeline: crate::bundle::build_document_pipeline().expect("pipeline"),
555 session: gaze::Session::new(gaze::Scope::Ephemeral).expect("session"),
556 session_id_policy: SessionIdPolicy::default_strict(),
557 }
558 }
559
560 async fn dispatch(
561 &self,
562 tool_name: &str,
563 args: serde_json::Value,
564 ) -> Result<serde_json::Value, DispatchError> {
565 let envelope = PiiEnvelope::new(
566 &self.registry,
567 &self.auth,
568 self.manifest.as_ref(),
569 &self.pipeline,
570 &self.session,
571 &[gaze::LocaleTag::Global],
572 &self.session_id_policy,
573 );
574 envelope
575 .dispatch(&Principal::new("unit-test"), tool_name, args, None)
576 .await
577 .map(|response| response.payload)
578 }
579 }
580
581 fn assert_no_raw_fixture_values(clean_markdown: &str) {
582 assert!(!clean_markdown.contains("Jane Doe"), "{clean_markdown}");
583 assert!(
584 !clean_markdown.contains("@example.invalid"),
585 "{clean_markdown}"
586 );
587 assert!(!clean_markdown.contains("555-0142"), "{clean_markdown}");
588 }
589
590 #[tokio::test]
591 async fn read_text_dispatch_returns_clean_markdown_and_manifest_id() {
592 let harness = Harness::new();
593 let payload = harness
594 .dispatch(
595 "gaze_read_text",
596 json!({
597 "text": "Bill to: Jane Doe\nEmail: jane.doe@example.invalid\nPhone: +1-555-0142"
598 }),
599 )
600 .await
601 .expect("dispatch succeeds");
602
603 let clean_markdown = payload["clean_markdown"].as_str().expect("clean markdown");
604 assert!(clean_markdown.contains(":Email_"), "{clean_markdown}");
605 assert!(clean_markdown.contains(":Name_"), "{clean_markdown}");
606 assert!(
607 clean_markdown.contains(":Custom:phone_"),
608 "{clean_markdown}"
609 );
610 assert_no_raw_fixture_values(clean_markdown);
611 assert!(!payload["manifest_id"].as_str().unwrap().is_empty());
612 assert_eq!(payload["file_metadata"]["source_kind"], "text");
613 assert_eq!(
614 payload["file_metadata"]["ocr_mean_confidence"],
615 serde_json::Value::Null
616 );
617 assert_eq!(harness.manifest.begins.load(Ordering::SeqCst), 1);
618 assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 1);
619 }
620
621 #[tokio::test]
622 async fn read_file_missing_path_fails_closed_as_not_found() {
623 let harness = Harness::new();
624 let err = harness
625 .dispatch(
626 "gaze_read_file",
627 json!({ "path": "testdata/does-not-exist.png" }),
628 )
629 .await
630 .expect_err("missing file fails");
631
632 match err {
633 DispatchError::ToolError(ToolError::NotFound(message)) => {
634 assert!(message.contains("not found"));
635 }
636 other => panic!("unexpected error: {other:?}"),
637 }
638 assert_eq!(harness.manifest.failures.load(Ordering::SeqCst), 1);
639 }
640
641 #[tokio::test]
642 async fn read_file_limit_fails_closed_before_ocr() {
643 let mut registry = ToolRegistry::new();
644 registry
645 .register(GazeReadFile::with_max_file_size(1))
646 .expect("register file tool");
647 let harness = Harness {
648 registry,
649 auth: AllowAllAuth,
650 manifest: Arc::new(RecordingManifest::new()),
651 pipeline: crate::bundle::build_document_pipeline().expect("pipeline"),
652 session: gaze::Session::new(gaze::Scope::Ephemeral).expect("session"),
653 session_id_policy: SessionIdPolicy::default_strict(),
654 };
655 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
656 .join("testdata")
657 .join("synthetic_image.png");
658 let err = harness
659 .dispatch("gaze_read_file", json!({ "path": fixture }))
660 .await
661 .expect_err("oversized file fails");
662
663 match err {
664 DispatchError::ToolError(ToolError::LimitExceeded(message)) => {
665 assert!(message.contains("configured cap is 1 bytes"));
666 }
667 other => panic!("unexpected error: {other:?}"),
668 }
669 }
670
671 #[tokio::test]
672 async fn configured_dispatch_restores_owner_path_before_file_validation() {
673 let core = gaze_assembly::CorePipelineConfig::new().build().unwrap();
674 let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
675 let directory = tempfile::tempdir().unwrap();
676 let raw_path = directory
677 .path()
678 .join("alice@example.invalid")
679 .join("input.png");
680 std::fs::create_dir_all(raw_path.parent().unwrap()).unwrap();
681 std::fs::write(&raw_path, b"oversized").unwrap();
682 let gaze::CleanDocument::Text(protected_path) = core
683 .pseudonymize_text(&session, raw_path.to_str().unwrap())
684 .unwrap()
685 else {
686 panic!("text expected")
687 };
688 assert_ne!(protected_path, raw_path.to_str().unwrap());
689 let literal_path = PathBuf::from(&protected_path);
691 assert!(literal_path.starts_with(directory.path()));
692 std::fs::create_dir_all(literal_path.parent().unwrap()).unwrap();
693 std::fs::write(&literal_path, b"").unwrap();
694 let mut registry = ToolRegistry::new();
695 registry
696 .register(GazeReadFile::with_max_file_size(1))
697 .unwrap();
698 let manifest = RecordingManifest::new();
699 let policy = SessionIdPolicy::default_strict();
700 let envelope = PiiEnvelope::new(
701 ®istry,
702 &AllowAllAuth,
703 &manifest,
704 core.pipeline(),
705 &session,
706 core.locale_chain().as_slice(),
707 &policy,
708 );
709 for path in [raw_path.to_str().unwrap(), &protected_path] {
710 let err = envelope
711 .dispatch(
712 &Principal::new("unit-test"),
713 "gaze_read_file",
714 json!({"path": path}),
715 None,
716 )
717 .await
718 .unwrap_err();
719 assert!(
720 matches!(err, DispatchError::ToolError(ToolError::LimitExceeded(_))),
721 "{err:?}"
722 );
723 }
724 for args in manifest.args.lock().unwrap().iter() {
725 let path = args["path"].as_str().unwrap();
726 assert!(!path.contains("alice@example.invalid"));
727 assert_eq!(
728 session.restore_strict_text(path).unwrap(),
729 raw_path.to_str().unwrap()
730 );
731 }
732 let unknown_path = directory
733 .path()
734 .join("<deadbeef:Email_999>")
735 .join("input.png");
736 std::fs::create_dir_all(unknown_path.parent().unwrap()).unwrap();
737 std::fs::write(&unknown_path, b"oversized").unwrap();
738 let err = envelope
739 .dispatch(
740 &Principal::new("unit-test"),
741 "gaze_read_file",
742 json!({"path": unknown_path}),
743 None,
744 )
745 .await
746 .unwrap_err();
747 assert!(
748 matches!(err, DispatchError::ToolError(ToolError::InvalidArgs(_))),
749 "{err:?}"
750 );
751 for bare in ["scan_1.png", "Scan_1.png"] {
757 let path = directory.path().join(bare);
758 assert!(!path.exists(), "fixture `{bare}` must not exist");
759 let err = envelope
760 .dispatch(
761 &Principal::new("unit-test"),
762 "gaze_read_file",
763 json!({ "path": path }),
764 None,
765 )
766 .await
767 .unwrap_err();
768 assert!(
769 matches!(err, DispatchError::ToolError(ToolError::NotFound(_))),
770 "bare-shape `{bare}` must reach validate_file, got: {err:?}",
771 );
772 }
773 assert_eq!(manifest.finishes.load(Ordering::SeqCst), 0);
775 }
776
777 #[tokio::test]
778 async fn read_file_dispatch_rejects_unowned_session_prefixed_token_in_path() {
779 let harness = Harness::new();
780 let err = harness
784 .dispatch(
785 "gaze_read_file",
786 json!({ "path": "directory/<deadbeef:Email_999>/input.png" }),
787 )
788 .await
789 .expect_err("unowned session-prefixed token must fail at the restore gate");
790 match err {
791 DispatchError::ToolError(ToolError::InvalidArgs(message)) => {
792 assert_eq!(message, "path restoration failed");
793 }
794 other => panic!("unexpected error: {other:?}"),
795 }
796 assert_eq!(harness.manifest.failures.load(Ordering::SeqCst), 1);
797 assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
798 }
799
800 #[tokio::test]
801 async fn read_file_dispatch_passes_ordinary_bare_shape_filenames_to_validation() {
802 let harness = Harness::new();
803 let directory = tempfile::tempdir().unwrap();
804
805 for bare in [
812 "scan_1.png",
813 "Scan_1.png",
814 "invoice_20250111.pdf",
815 "doc_v2.png",
816 ] {
817 let path = directory.path().join(bare);
818 assert!(!path.exists(), "fixture `{bare}` must not exist");
819 let err = harness
820 .dispatch("gaze_read_file", json!({ "path": path }))
821 .await
822 .expect_err("path must reach validate_file, not the restore gate");
823 match err {
824 DispatchError::ToolError(ToolError::NotFound(message)) => {
825 assert!(
826 message.contains(bare),
827 "NotFound for `{bare}` should mention the filename: {message}"
828 );
829 }
830 other => panic!("unexpected error for `{bare}`: {other:?}"),
831 }
832 }
833
834 assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
837 }
838
839 #[tokio::test]
840 async fn read_file_dispatch_rejects_malformed_session_prefixed_token_in_path() {
841 let harness = Harness::new();
842 for malformed in [
846 "directory/<deadbeef:Email_>/input.png",
847 "path/<Email_>/file.pdf",
848 "/tmp/<deadbeef:Name_>/doc.png",
849 "directory/<Custom:family:tenant-document_>/input.png",
850 "directory/<deadbeef:Custom:family:tenant-document_>/input.png",
851 "directory/<custom:family:tenant-document_>/input.png",
852 ] {
853 let err = harness
854 .dispatch("gaze_read_file", json!({ "path": malformed }))
855 .await
856 .expect_err("malformed token in path must fail at the restore gate");
857 assert!(
858 matches!(
859 err,
860 DispatchError::ToolError(ToolError::InvalidArgs(ref msg))
861 if msg == "path restoration failed"
862 ),
863 "malformed `{malformed}` must return InvalidArgs, got: {err:?}",
864 );
865 }
866 assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
867 }
868
869 #[tokio::test]
870 async fn read_file_dispatch_rejects_nested_wrapper_token_in_path() {
871 let harness = Harness::new();
872 for nested in [
876 "directory/<<deadbeef:Email_1>>/input.png",
877 "path/<<Email_1>>/file.pdf",
878 ] {
879 let err = harness
880 .dispatch("gaze_read_file", json!({ "path": nested }))
881 .await
882 .expect_err("nested-wrapper token in path must fail at the restore gate");
883 assert!(
884 matches!(
885 err,
886 DispatchError::ToolError(ToolError::InvalidArgs(ref msg))
887 if msg == "path restoration failed"
888 ),
889 "nested `{nested}` must return InvalidArgs, got: {err:?}",
890 );
891 }
892 assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
893 }
894
895 #[tokio::test]
896 async fn read_file_dispatch_rejects_unowned_wrapped_and_legacy_placeholders() {
897 let harness = Harness::new();
898 for unowned in [
902 "<Email_1>/input.png",
903 "directory/<Email_1>/input.png",
904 "email_1/input.png",
905 ] {
906 let err = harness
907 .dispatch("gaze_read_file", json!({ "path": unowned }))
908 .await
909 .expect_err("unowned trap placeholder in path must fail at the restore gate");
910 assert!(
911 matches!(
912 err,
913 DispatchError::ToolError(ToolError::InvalidArgs(ref msg))
914 if msg == "path restoration failed"
915 ),
916 "unowned trap `{unowned}` must return InvalidArgs, got: {err:?}",
917 );
918 }
919 assert_eq!(harness.manifest.finishes.load(Ordering::SeqCst), 0);
920 }
921
922 #[tokio::test]
923 async fn read_file_dispatch_restores_owned_token_alongside_negative_cases() {
924 let core = gaze_assembly::CorePipelineConfig::new().build().unwrap();
928 let session = gaze::Session::new(gaze::Scope::Ephemeral).unwrap();
929 let directory = tempfile::tempdir().unwrap();
930 let raw_path = directory
931 .path()
932 .join("alice@example.invalid")
933 .join("report.pdf");
934 std::fs::create_dir_all(raw_path.parent().unwrap()).unwrap();
935 std::fs::write(&raw_path, b"dummy").unwrap();
936 let gaze::CleanDocument::Text(protected_path) = core
937 .pseudonymize_text(&session, raw_path.to_str().unwrap())
938 .unwrap()
939 else {
940 panic!("text expected")
941 };
942 assert_ne!(protected_path, raw_path.to_str().unwrap());
943
944 let mut registry = ToolRegistry::new();
945 registry
946 .register(GazeReadFile::with_max_file_size(1))
947 .unwrap();
948 let manifest = RecordingManifest::new();
949 let policy = SessionIdPolicy::default_strict();
950 let envelope = PiiEnvelope::new(
951 ®istry,
952 &AllowAllAuth,
953 &manifest,
954 core.pipeline(),
955 &session,
956 core.locale_chain().as_slice(),
957 &policy,
958 );
959
960 let err = envelope
962 .dispatch(
963 &Principal::new("unit-test"),
964 "gaze_read_file",
965 json!({"path": protected_path}),
966 None,
967 )
968 .await
969 .unwrap_err();
970 assert!(
971 matches!(err, DispatchError::ToolError(ToolError::LimitExceeded(_))),
972 "owned-token path must reach validate_file: {err:?}",
973 );
974
975 let malformed = format!(
977 "{}/file.pdf",
978 directory.path().join("<deadbeef:Email_>").display()
979 );
980 let err = envelope
981 .dispatch(
982 &Principal::new("unit-test"),
983 "gaze_read_file",
984 json!({"path": malformed}),
985 None,
986 )
987 .await
988 .unwrap_err();
989 assert!(
990 matches!(err, DispatchError::ToolError(ToolError::InvalidArgs(_))),
991 "malformed token in path must be rejected: {err:?}",
992 );
993
994 let unowned_wrapped = format!("{}/file.pdf", directory.path().join("<Email_1>").display());
996 let err = envelope
997 .dispatch(
998 &Principal::new("unit-test"),
999 "gaze_read_file",
1000 json!({"path": unowned_wrapped}),
1001 None,
1002 )
1003 .await
1004 .unwrap_err();
1005 assert!(
1006 matches!(err, DispatchError::ToolError(ToolError::InvalidArgs(_))),
1007 "unowned wrapped placeholder must be rejected: {err:?}",
1008 );
1009 }
1010
1011 #[cfg(feature = "ocr-tesseract")]
1012 #[tokio::test]
1013 async fn read_file_dispatch_returns_clean_markdown_for_fixture_when_backend_available() {
1014 let harness = Harness::new();
1015 let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1016 .join("testdata")
1017 .join("synthetic_image.png");
1018 let payload = match harness
1019 .dispatch("gaze_read_file", json!({ "path": fixture }))
1020 .await
1021 {
1022 Ok(payload) => payload,
1023 Err(DispatchError::ToolError(ToolError::BackendUnavailable(message))) => {
1024 eprintln!("SKIP: document backend unavailable: {message}");
1025 return;
1026 }
1027 Err(other) => panic!("unexpected dispatch error: {other:?}"),
1028 };
1029
1030 let clean_markdown = payload["clean_markdown"].as_str().expect("clean markdown");
1031 assert!(clean_markdown.contains(":Email_"), "{clean_markdown}");
1032 assert!(clean_markdown.contains(":Name_"), "{clean_markdown}");
1033 assert!(
1034 clean_markdown.contains(":Custom:phone_"),
1035 "{clean_markdown}"
1036 );
1037 assert_no_raw_fixture_values(clean_markdown);
1038 assert_eq!(payload["file_metadata"]["source_kind"], "image");
1039 assert!(!payload["manifest_id"].as_str().unwrap().is_empty());
1040 }
1041}