返回 CodeWhale
tools.rs
根目录 / crates / tui / src / tools / file / tests / tools.rs
1 use super::*;
2 use tempfile::tempdir;
3
4 async fn read_before_edit(ctx: &ToolContext, path: &str) {
5 ReadFileTool
6 .execute(json!({"path": path}), ctx)
7 .await
8 .expect("read before edit");
9 }
10
11 #[tokio::test]
12 async fn test_read_file_tool() {
13 let tmp = tempdir().expect("tempdir");
14 let ctx = ToolContext::new(tmp.path().to_path_buf());
15
16 // Create a test file
17 let test_file = tmp.path().join("test.txt");
18 fs::write(&test_file, "hello world").expect("write");
19
20 let tool = ReadFileTool;
21 let result = tool
22 .execute(json!({"path": "test.txt"}), &ctx)
23 .await
24 .expect("execute");
25
26 assert!(result.success);
27 // #3979: a small-file read now leads with the snapshot hash the edit
28 // guard verifies against, then the contents verbatim.
29 assert_eq!(
30 result.content,
31 format!(
32 "content_hash=\"{}\"\nhello world",
33 super::content_hash(b"hello world")
34 )
35 );
36 }
37
38 // This test deliberately serializes process-global environment changes
39 // while awaiting the tool path.
40 #[allow(clippy::await_holding_lock)]
41 #[tokio::test]
42 async fn read_file_denies_codewhale_config_backups_and_secret_store() {
43 let _env_lock = crate::test_support::lock_test_env();
44 let tmp = tempdir().expect("tempdir");
45 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
46 let _config_path = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
47 let _legacy_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
48
49 fs::write(tmp.path().join("config.toml"), "api_key = \"secret\"\n").expect("write config");
50 fs::write(
51 tmp.path().join("config.toml.bak"),
52 "api_key = \"old-secret\"\n",
53 )
54 .expect("write config backup");
55 fs::create_dir_all(tmp.path().join("secrets")).expect("create secrets dir");
56 fs::write(
57 tmp.path().join("secrets").join("secrets.json"),
58 r#"{"provider":"secret"}"#,
59 )
60 .expect("write file keyring");
61 fs::write(tmp.path().join("notes.txt"), "ordinary workspace data")
62 .expect("write ordinary file");
63
64 let ctx = ToolContext::new(tmp.path().to_path_buf());
65 for path in ["config.toml", "config.toml.bak", "secrets/secrets.json"] {
66 let err = ReadFileTool
67 .execute(json!({"path": path}), &ctx)
68 .await
69 .expect_err("credential-bearing CodeWhale file must be denied");
70 let message = err.to_string();
71 assert!(message.contains("cannot expose Codewhale"), "{message}");
72 assert!(message.contains("codewhale config list"), "{message}");
73 }
74
75 let ordinary = ReadFileTool
76 .execute(json!({"path": "notes.txt"}), &ctx)
77 .await
78 .expect("ordinary workspace file should remain readable");
79 assert!(
80 ordinary.content.ends_with("ordinary workspace data"),
81 "{}",
82 ordinary.content
83 );
84 }
85
86 #[tokio::test]
87 async fn read_file_ocr_extracts_text_from_image_when_backend_exists() {
88 if !crate::tools::image_ocr::ocr_available() {
89 return;
90 }
91 let fixture =
92 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ocr_hello.png");
93 if !fixture.exists() {
94 return;
95 }
96 let tmp = tempdir().expect("tempdir");
97 fs::copy(&fixture, tmp.path().join("ocr_hello.png")).expect("copy fixture");
98 let ctx = ToolContext::new(tmp.path().to_path_buf());
99
100 let result = match ReadFileTool
101 .execute(json!({"path": "ocr_hello.png"}), &ctx)
102 .await
103 {
104 Ok(result) => result,
105 Err(err) => {
106 // Name is when_backend_exists — skip if live OCR fails after
107 // the availability probe (restricted Vision, etc.).
108 let msg = err.to_string();
109 let _skip_reason = format!("OCR backend probe passed but read_file OCR failed: {msg}");
110 let _ = &_skip_reason;
111 return;
112 }
113 };
114
115 assert!(result.success);
116 assert!(result.content.contains("<image_ocr"));
117 let normalized = result.content.to_uppercase();
118 assert!(
119 normalized.contains("HELLO") && normalized.contains("OCR"),
120 "expected OCR text in read_file result, got {:?}",
121 result.content
122 );
123 }
124
125 #[test]
126 fn parse_pages_arg_accepts_single_page() {
127 assert_eq!(parse_pages_arg("3"), Some((3, 3)));
128 assert_eq!(parse_pages_arg(" 7 "), Some((7, 7)));
129 }
130
131 #[test]
132 fn parse_pages_arg_accepts_range() {
133 assert_eq!(parse_pages_arg("1-5"), Some((1, 5)));
134 assert_eq!(parse_pages_arg("10-20"), Some((10, 20)));
135 // Whitespace around either side of the dash is tolerated so
136 // hand-typed `pages: "1 - 5"` still works.
137 assert_eq!(parse_pages_arg(" 1 - 5 "), Some((1, 5)));
138 }
139
140 #[test]
141 fn parse_pages_arg_rejects_invalid_ranges() {
142 // Caller would otherwise feed `pdftotext -f 5 -l 1`, which
143 // prints nothing — fail loudly so the model can re-issue.
144 assert!(parse_pages_arg("5-1").is_none(), "end < start must reject");
145 // 0-indexed pages aren't a thing in pdftotext; reject so the
146 // caller doesn't get a confusing "no output" silent fail.
147 assert!(
148 parse_pages_arg("0").is_none(),
149 "zero single-page must reject"
150 );
151 assert!(parse_pages_arg("0-3").is_none(), "zero start must reject");
152 // Empty / whitespace-only / non-numeric inputs must reject.
153 assert!(parse_pages_arg("").is_none());
154 assert!(parse_pages_arg(" ").is_none());
155 assert!(parse_pages_arg("abc").is_none());
156 assert!(parse_pages_arg("3.5").is_none(), "floats must reject");
157 }
158
159 #[test]
160 fn parse_pages_arg_rejects_half_open_ranges() {
161 // Half-open ranges like `1-` or `-5` are almost certainly a
162 // typo for `1-N`/`N` rather than intentional input. Reject
163 // them rather than silently extending to u32::MAX or 0.
164 assert!(parse_pages_arg("1-").is_none());
165 assert!(parse_pages_arg("-5").is_none());
166 assert!(parse_pages_arg("-").is_none());
167 }
168
169 #[test]
170 fn parse_pages_arg_rejects_negative_numbers() {
171 // u32::parse on a negative literal returns Err, so the
172 // function reports `None` rather than wrapping into a giant
173 // positive number — defensive but worth pinning.
174 assert!(parse_pages_arg("-3-5").is_none());
175 }
176
177 #[tokio::test]
178 async fn test_read_file_not_found() {
179 let tmp = tempdir().expect("tempdir");
180 let ctx = ToolContext::new(tmp.path().to_path_buf());
181
182 let tool = ReadFileTool;
183 let result = tool.execute(json!({"path": "nonexistent.txt"}), &ctx).await;
184
185 assert!(result.is_err());
186 }
187
188 #[tokio::test]
189 async fn read_file_small_file_returns_unwrapped_contents() {
190 // Small files (≤ 200 lines AND ≤ 16KB, no explicit range) keep
191 // the historical "return contents unchanged" behavior so
192 // existing prompts don't suddenly see <file> tags appear.
193 // Harvested from #1451 — pin the fast-path contract.
194 //
195 // #3979 added one `content_hash="…"` header line ahead of the contents:
196 // the guard is useless if the common read path cannot report a hash, and
197 // this branch has no `<file>` envelope to carry it as an attribute. The
198 // contents themselves are still verbatim and still unwrapped.
199 let tmp = tempdir().expect("tempdir");
200 let ctx = ToolContext::new(tmp.path().to_path_buf());
201 let file = tmp.path().join("small.txt");
202 fs::write(&file, "line 1\nline 2\nline 3\n").expect("write");
203 let tool = ReadFileTool;
204 let result = tool
205 .execute(json!({ "path": "small.txt" }), &ctx)
206 .await
207 .expect("execute");
208 assert!(result.success);
209 assert_eq!(
210 result.content,
211 format!(
212 "content_hash=\"{}\"\nline 1\nline 2\nline 3\n",
213 super::content_hash(b"line 1\nline 2\nline 3\n")
214 )
215 );
216 assert!(
217 !result.content.contains("<file"),
218 "small-file fast path must not wrap output"
219 );
220 }
221
222 #[tokio::test]
223 async fn read_file_explicit_range_wraps_in_file_tag_with_one_based_lines() {
224 let tmp = tempdir().expect("tempdir");
225 let ctx = ToolContext::new(tmp.path().to_path_buf());
226 let file = tmp.path().join("ranged.txt");
227 let body: String = (1..=10).map(|n| format!("line {n}\n")).collect();
228 fs::write(&file, &body).expect("write");
229 let tool = ReadFileTool;
230 let result = tool
231 .execute(
232 json!({ "path": "ranged.txt", "start_line": 3, "max_lines": 4 }),
233 &ctx,
234 )
235 .await
236 .expect("execute");
237 assert!(result.success);
238 assert!(
239 result.content.contains("shown_lines=\"3-6\""),
240 "1-based inclusive range must be reflected in shown_lines: {}",
241 result.content
242 );
243 assert!(
244 result.content.contains("next_start_line=\"7\""),
245 "next_start_line must point one past the last shown line: {}",
246 result.content
247 );
248 assert!(
249 result.content.contains(" 3│ line 3"),
250 "rendered lines must start at the requested line number"
251 );
252 assert!(
253 result.content.contains(" 6│ line 6"),
254 "rendered lines must end at the last in-range line"
255 );
256 assert!(
257 !result.content.contains(" 7│ line 7"),
258 "lines past max_lines must be excluded"
259 );
260 assert!(result.content.contains("truncated=\"true\""));
261 }
262
263 #[tokio::test]
264 async fn read_file_range_beyond_total_returns_no_content_sentinel() {
265 let tmp = tempdir().expect("tempdir");
266 let ctx = ToolContext::new(tmp.path().to_path_buf());
267 let file = tmp.path().join("short.txt");
268 fs::write(&file, "only\nthree\nlines\n").expect("write");
269 let tool = ReadFileTool;
270 let result = tool
271 .execute(json!({ "path": "short.txt", "start_line": 99 }), &ctx)
272 .await
273 .expect("execute");
274 assert!(
275 result.success,
276 "out-of-range must not raise — it's a sentinel"
277 );
278 assert!(result.content.contains("[NO CONTENT]"));
279 assert!(result.content.contains("shown_lines=\"none\""));
280 assert!(result.content.contains("truncated=\"false\""));
281 }
282
283 /// 2026-08-04 review: a `start_line:"1200"` string (or any wrong type) used
284 /// to fall back SILENTLY to the defaults, returning lines 1-500 — the head
285 /// of the file dressed up as the window the model asked for. Wrong types
286 /// are errors, matching the shared `optional_u64` contract.
287 #[tokio::test]
288 async fn read_file_refuses_wrongly_typed_range_params_instead_of_defaulting() {
289 let tmp = tempdir().expect("tempdir");
290 let ctx = ToolContext::new(tmp.path().to_path_buf());
291 fs::write(tmp.path().join("any.txt"), "x\ny\nz\n").expect("write");
292 let tool = ReadFileTool;
293 for bad in [json!("1200"), json!(-5), json!(2.5), json!([1200])] {
294 let err = tool
295 .execute(json!({ "path": "any.txt", "start_line": bad }), &ctx)
296 .await
297 .expect_err("wrongly typed start_line must error, never default");
298 assert!(
299 err.to_string().contains("start_line"),
300 "error names the field: {err}"
301 );
302 let err = tool
303 .execute(json!({ "path": "any.txt", "max_lines": bad }), &ctx)
304 .await
305 .expect_err("wrongly typed max_lines must error, never default");
306 assert!(
307 err.to_string().contains("max_lines"),
308 "error names the field: {err}"
309 );
310 }
311 // Null still reads as absent, consistent with the strictness lane.
312 let ok = tool
313 .execute(json!({ "path": "any.txt", "start_line": null }), &ctx)
314 .await
315 .expect("null is absence, not a type error");
316 assert!(ok.success);
317 }
318
319 #[tokio::test]
320 async fn read_file_rejects_zero_start_line_and_zero_max_lines() {
321 let tmp = tempdir().expect("tempdir");
322 let ctx = ToolContext::new(tmp.path().to_path_buf());
323 fs::write(tmp.path().join("any.txt"), "x\n").expect("write");
324 let tool = ReadFileTool;
325 let zero_start = tool
326 .execute(json!({ "path": "any.txt", "start_line": 0 }), &ctx)
327 .await;
328 assert!(zero_start.is_err(), "start_line=0 must error (1-based)");
329 let zero_max = tool
330 .execute(json!({ "path": "any.txt", "max_lines": 0 }), &ctx)
331 .await;
332 assert!(zero_max.is_err(), "max_lines=0 must error");
333 }
334
335 #[tokio::test]
336 async fn read_file_byte_truncation_keeps_head_and_tail() {
337 // Long lines force the 16 KiB bound before the line cap. The model must
338 // see both ends of the window (qwen-style head = budget/5 + tail) and the
339 // recovery note must name the original path for a re-read.
340 let tmp = tempdir().expect("tempdir");
341 let ctx = ToolContext::new(tmp.path().to_path_buf());
342 let file = tmp.path().join("wide.txt");
343 let body: String = (1..=40)
344 .map(|n| format!("LINE{n}_START {} LINE{n}_END\n", "x".repeat(600)))
345 .collect();
346 assert!(body.len() > 16 * 1024, "fixture must exceed 16KB");
347 fs::write(&file, &body).expect("write");
348
349 let tool = ReadFileTool;
350 let result = tool
351 .execute(
352 json!({ "path": "wide.txt", "start_line": 1, "max_lines": 40 }),
353 &ctx,
354 )
355 .await
356 .expect("execute");
357
358 assert!(result.success);
359 assert!(result.content.contains("truncated=\"true\""));
360 assert!(
361 result.content.contains("LINE1_START"),
362 "head of the window must survive: {}",
363 &result.content[..result.content.len().min(400)]
364 );
365 assert!(
366 result.content.contains("LINE40_END") || result.content.contains("LINE40_START"),
367 "tail of the window must survive: {}",
368 &result.content[result.content.len().saturating_sub(400)..]
369 );
370 assert!(
371 result.content.contains("[CONTENT TRUNCATED]"),
372 "head/tail separator missing: {}",
373 result.content
374 );
375 assert!(
376 result.content.contains("path=\"wide.txt\""),
377 "recovery path must name the file: {}",
378 result.content
379 );
380 assert!(
381 result
382 .content
383 .contains("Re-read narrower windows to see the middle"),
384 "byte-truncation recovery note must give actionable advice: {}",
385 result.content
386 );
387 assert!(
388 result.content.contains("offset=1 limit=20"),
389 "the note names a concrete narrower window: {}",
390 result.content
391 );
392 // Middle of the window should be the part omitted under a head+tail budget.
393 assert!(
394 !result.content.contains("LINE20_START") || result.content.contains("[CONTENT TRUNCATED]"),
395 "expected truncation of the middle: {}",
396 result.content
397 );
398 }
399
400 #[tokio::test]
401 async fn read_file_clamps_max_lines_to_hard_cap() {
402 let tmp = tempdir().expect("tempdir");
403 let ctx = ToolContext::new(tmp.path().to_path_buf());
404 let file = tmp.path().join("bigish.txt");
405 let body: String = (1..=600).map(|n| format!("L{n}\n")).collect();
406 fs::write(&file, &body).expect("write");
407 let tool = ReadFileTool;
408 let result = tool
409 .execute(json!({ "path": "bigish.txt", "max_lines": 5000 }), &ctx)
410 .await
411 .expect("execute");
412 // Hard cap is 500 lines; line 500 must appear, line 501 must not.
413 assert!(
414 result.content.contains(" 500│ L500"),
415 "line 500 should be in the window (max_lines clamped to 500)"
416 );
417 assert!(
418 !result.content.contains(" 501│ L501"),
419 "line 501 must be outside the clamped window"
420 );
421 assert!(result.content.contains("next_start_line=\"501\""));
422 assert!(result.content.contains("truncated=\"true\""));
423 }
424
425 #[tokio::test]
426 async fn read_file_large_file_without_range_uses_default_window() {
427 // A file over 200 lines / 16KB with no explicit range still
428 // gets the default window, not the unbounded raw content —
429 // this is the entire point of the patch (token-budget control).
430 let tmp = tempdir().expect("tempdir");
431 let ctx = ToolContext::new(tmp.path().to_path_buf());
432 let file = tmp.path().join("big.txt");
433 let body: String = (1..=250).map(|n| format!("row {n}\n")).collect();
434 fs::write(&file, &body).expect("write");
435 let tool = ReadFileTool;
436 let result = tool
437 .execute(json!({ "path": "big.txt" }), &ctx)
438 .await
439 .expect("execute");
440 // 250 rows is ~1.7 KB — far inside the 16 KB byte budget — so it reads in
441 // ONE call. The old 200-line default truncated here and charged a second
442 // round trip to fetch 50 lines, which is what this change removes.
443 // No `<file …>` envelope: at 250 lines / ~1.7 KB it now takes the
444 // whole-file path and comes back as plain text, which is the point.
445 assert!(result.content.contains("row 1"));
446 assert!(result.content.contains("row 250"));
447 assert!(
448 !result.content.contains("next_start_line"),
449 "a 250-line, ~1.7 KB file must not window: {}",
450 result.content
451 );
452
453 // Past the line cap it still windows, because the cap is a real guard for
454 // pathologically short lines.
455 let many = tmp.path().join("many.txt");
456 let body: String = (1..=600).map(|n| format!("row {n}\n")).collect();
457 fs::write(&many, &body).expect("write");
458 let windowed = tool
459 .execute(json!({ "path": "many.txt" }), &ctx)
460 .await
461 .expect("execute");
462 assert!(windowed.content.contains("shown_lines=\"1-500\""));
463 assert!(windowed.content.contains("next_start_line=\"501\""));
464 }
465
466 #[tokio::test]
467 async fn read_file_streamed_range_on_large_file_matches_windowed_contract() {
468 // Over 16KB forces the streamed BufRead path even without an
469 // explicit range; assert the ranged output stays byte-compatible
470 // with the historical full-read implementation.
471 let tmp = tempdir().expect("tempdir");
472 let ctx = ToolContext::new(tmp.path().to_path_buf());
473 let file = tmp.path().join("large.txt");
474 let body: String = (1..=2000)
475 .map(|n| format!("line {n} {}\n", "x".repeat(20)))
476 .collect();
477 assert!(body.len() > 16 * 1024, "fixture must exceed 16KB");
478 fs::write(&file, &body).expect("write");
479
480 let tool = ReadFileTool;
481 let result = tool
482 .execute(
483 json!({ "path": "large.txt", "start_line": 1500, "max_lines": 10 }),
484 &ctx,
485 )
486 .await
487 .expect("execute");
488
489 assert!(result.success);
490 assert!(result.content.contains("total_lines=\"2000\""));
491 assert!(result.content.contains("shown_lines=\"1500-1509\""));
492 assert!(result.content.contains("next_start_line=\"1510\""));
493 assert!(result.content.contains(" 1500│ line 1500"));
494 assert!(result.content.contains(" 1509│ line 1509"));
495 assert!(!result.content.contains(" 1510│"));
496 assert!(result.content.contains(
497 "[TRUNCATED] Showing lines 1500-1509 of 2000. To continue, call read with path=\"large.txt\" offset=1510 limit=10"
498 ));
499 assert!(!result.content.contains("read_file"), "{}", result.content);
500
501 // Default window (no range) on the same large file starts at line 1.
502 let default_window = tool
503 .execute(json!({ "path": "large.txt" }), &ctx)
504 .await
505 .expect("execute");
506 assert!(default_window.content.contains("shown_lines=\"1-500\""));
507 assert!(default_window.content.contains("next_start_line=\"501\""));
508 assert!(default_window.content.contains(" 1│ line 1"));
509
510 // Paging past EOF returns the no-content sentinel, not an error.
511 let past_end = tool
512 .execute(json!({ "path": "large.txt", "start_line": 5000 }), &ctx)
513 .await
514 .expect("execute");
515 assert!(past_end.content.contains("[NO CONTENT]"));
516 assert!(past_end.content.contains("shown_lines=\"none\""));
517 }
518
519 #[tokio::test]
520 async fn read_file_streamed_range_rejects_invalid_utf8_like_full_read() {
521 let tmp = tempdir().expect("tempdir");
522 let ctx = ToolContext::new(tmp.path().to_path_buf());
523 let file = tmp.path().join("mixed.bin");
524 // Valid first lines, invalid bytes later: the streamed path must
525 // still fail the whole read like read_to_string did.
526 let mut bytes = b"good line\n".repeat(5);
527 bytes.extend_from_slice(&[0xFF, 0xFE, b'\n']);
528 fs::write(&file, &bytes).expect("write");
529
530 let err = ReadFileTool
531 .execute(
532 json!({ "path": "mixed.bin", "start_line": 1, "max_lines": 2 }),
533 &ctx,
534 )
535 .await
536 .expect_err("invalid UTF-8 must error");
537 let message = err.to_string();
538 assert!(message.contains("Failed to read"), "{message}");
539 assert!(message.contains("valid UTF-8"), "{message}");
540 }
541
542 #[tokio::test]
543 async fn test_read_file_missing_path() {
544 let tmp = tempdir().expect("tempdir");
545 let ctx = ToolContext::new(tmp.path().to_path_buf());
546
547 let tool = ReadFileTool;
548 let result = tool.execute(json!({}), &ctx).await;
549
550 assert!(result.is_err());
551 let err = result.unwrap_err();
552 assert!(
553 err.to_string()
554 .contains("Failed to validate input: missing required field 'path'")
555 );
556 }
557
558 #[tokio::test]
559 async fn pdf_detected_by_extension() {
560 let tmp = tempdir().expect("tempdir");
561 let path = tmp.path().join("paper.PDF");
562 fs::write(&path, b"not really a pdf, but extension says yes").unwrap();
563 assert!(is_pdf(&path).await.unwrap());
564 }
565
566 #[tokio::test]
567 async fn pdf_detected_by_magic_bytes_without_extension() {
568 let tmp = tempdir().expect("tempdir");
569 let path = tmp.path().join("blob");
570 fs::write(&path, b"%PDF-1.7\nrest of bytes").unwrap();
571 assert!(is_pdf(&path).await.unwrap());
572 }
573
574 #[tokio::test]
575 async fn non_pdf_not_detected() {
576 let tmp = tempdir().expect("tempdir");
577 let path = tmp.path().join("notes.txt");
578 fs::write(&path, "hello").unwrap();
579 assert!(!is_pdf(&path).await.unwrap());
580 }
581
582 #[test]
583 fn pages_arg_parses_single_and_range() {
584 assert_eq!(parse_pages_arg("5"), Some((5, 5)));
585 assert_eq!(parse_pages_arg("1-10"), Some((1, 10)));
586 assert_eq!(parse_pages_arg(" 3 - 7 "), Some((3, 7)));
587 assert_eq!(parse_pages_arg("0"), None);
588 assert_eq!(parse_pages_arg("10-3"), None);
589 assert_eq!(parse_pages_arg(""), None);
590 assert_eq!(parse_pages_arg("abc"), None);
591 }
592
593 /// Sample PDF shipped with the repo for parity tests against the
594 /// pure-Rust extractor. 38 pages, born-digital LaTeX (arXiv 2512.24601).
595 /// Path is workspace-root-relative because the fixture lives outside
596 /// the tui crate.
597 const SAMPLE_PDF_PATH: &str = "../../docs/2512.24601v2.pdf";
598
599 fn sample_pdf_present() -> bool {
600 std::path::Path::new(SAMPLE_PDF_PATH).exists()
601 }
602
603 #[test]
604 fn clean_pdf_text_collapses_consecutive_blank_lines() {
605 let raw = "line1\n\n\n\n\nline2\n\n\nline3";
606 let cleaned = super::clean_pdf_text(raw);
607 assert_eq!(cleaned, "line1\n\nline2\n\nline3");
608 }
609
610 #[test]
611 fn clean_pdf_text_replaces_nul_bytes_with_replacement_char() {
612 let raw = "hello\0world";
613 let cleaned = super::clean_pdf_text(raw);
614 assert!(!cleaned.contains('\0'));
615 assert!(cleaned.contains('\u{FFFD}'));
616 }
617
618 #[test]
619 fn clean_pdf_text_replaces_non_breaking_spaces() {
620 let raw = "hello\u{A0}world";
621 let cleaned = super::clean_pdf_text(raw);
622 assert!(!cleaned.contains('\u{A0}'));
623 assert_eq!(cleaned, "hello world");
624 }
625
626 #[test]
627 fn clean_pdf_text_trims_trailing_whitespace() {
628 let raw = "hello ";
629 let cleaned = super::clean_pdf_text(raw);
630 assert_eq!(cleaned, "hello");
631 }
632
633 #[test]
634 fn clean_pdf_text_preserves_leading_indentation() {
635 let raw = " indented line\nregular line";
636 let cleaned = super::clean_pdf_text(raw);
637 assert_eq!(cleaned, " indented line\nregular line");
638 }
639
640 #[tokio::test]
641 async fn read_file_pdf_path_uses_optional_pdftotext_adapter() {
642 if !sample_pdf_present() || crate::dependencies::resolve_pdftotext().is_none() {
643 return;
644 }
645 let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../");
646 let ctx = ToolContext::new(workspace);
647 let result = ReadFileTool
648 .execute(json!({"path": "docs/2512.24601v2.pdf", "pages": "1"}), &ctx)
649 .await
650 .expect("execute");
651 assert!(result.success);
652 assert!(
653 result.content.contains("Recursive Language Models"),
654 "page-1 extraction must surface the title"
655 );
656 }
657
658 #[tokio::test]
659 async fn test_write_file_tool() {
660 let tmp = tempdir().expect("tempdir");
661 let ctx = ToolContext::new(tmp.path().to_path_buf());
662
663 let tool = WriteFileTool;
664 let result = tool
665 .execute(
666 json!({"path": "output.txt", "content": "test content"}),
667 &ctx,
668 )
669 .await
670 .expect("execute");
671
672 assert!(result.success);
673 // New file → "Created …" summary; the unified diff above the summary
674 // primes the TUI's diff-aware renderer (#505).
675 assert!(result.content.contains("Created"), "{}", result.content);
676 assert!(result.content.contains("--- a/"), "{}", result.content);
677 assert!(
678 result.content.contains("+test content"),
679 "{}",
680 result.content
681 );
682 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
683 assert_eq!(
684 mutation["files"],
685 json!([{ "path": "output.txt", "outcome": "created" }])
686 );
687 assert!(
688 mutation["diff"]
689 .as_str()
690 .is_some_and(|diff| diff.contains("--- a/output.txt")),
691 "{mutation}"
692 );
693 assert!(
694 !mutation["diff"]
695 .as_str()
696 .unwrap_or_default()
697 .contains(&tmp.path().display().to_string()),
698 "receipt headers must not expose the resolved host path: {mutation}"
699 );
700
701 // Verify file was written
702 let written = fs::read_to_string(tmp.path().join("output.txt")).expect("read");
703 assert_eq!(written, "test content");
704 }
705
706 #[tokio::test]
707 async fn test_write_file_creates_dirs() {
708 let tmp = tempdir().expect("tempdir");
709 let ctx = ToolContext::new(tmp.path().to_path_buf());
710
711 let tool = WriteFileTool;
712 let result = tool
713 .execute(
714 json!({"path": "subdir/nested/file.txt", "content": "nested content"}),
715 &ctx,
716 )
717 .await
718 .expect("execute");
719
720 assert!(result.success);
721
722 // Verify nested file was created
723 let written = fs::read_to_string(tmp.path().join("subdir/nested/file.txt")).expect("read");
724 assert_eq!(written, "nested content");
725 }
726
727 #[cfg(unix)]
728 #[tokio::test]
729 async fn write_file_tool_new_file_matches_standard_creation_mode() {
730 use std::os::unix::fs::PermissionsExt;
731
732 let tmp = tempdir().expect("tempdir");
733 let ctx = ToolContext::new(tmp.path().to_path_buf());
734
735 let control = tmp.path().join("control.txt");
736 fs::write(&control, b"control").expect("write control");
737
738 WriteFileTool
739 .execute(
740 json!({"path": "created.txt", "content": "from write_file"}),
741 &ctx,
742 )
743 .await
744 .expect("execute");
745
746 let control_mode = fs::metadata(&control)
747 .expect("control metadata")
748 .permissions()
749 .mode()
750 & 0o777;
751 let created_mode = fs::metadata(tmp.path().join("created.txt"))
752 .expect("created metadata")
753 .permissions()
754 .mode()
755 & 0o777;
756 assert_eq!(created_mode, control_mode);
757 }
758
759 #[cfg(unix)]
760 #[tokio::test]
761 async fn write_file_tool_preserves_existing_mode() {
762 use std::os::unix::fs::PermissionsExt;
763
764 let tmp = tempdir().expect("tempdir");
765 let ctx = ToolContext::new(tmp.path().to_path_buf());
766 let path = tmp.path().join("shared.txt");
767 fs::write(&path, b"before").expect("initial write");
768 fs::set_permissions(&path, fs::Permissions::from_mode(0o664)).expect("set shared permissions");
769
770 WriteFileTool
771 .execute(json!({"path": "shared.txt", "content": "after"}), &ctx)
772 .await
773 .expect("execute");
774
775 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
776 assert_eq!(mode, 0o664);
777 assert_eq!(fs::read_to_string(&path).expect("read"), "after");
778 }
779
780 #[tokio::test]
781 async fn write_file_over_crlf_file_preserves_crlf_line_endings() {
782 let tmp = tempdir().expect("tempdir");
783 let ctx = ToolContext::new(tmp.path().to_path_buf());
784 let path = tmp.path().join("crlf.txt");
785 fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");
786
787 WriteFileTool
788 .execute(
789 json!({"path": "crlf.txt", "content": "gamma\ndelta\n"}),
790 &ctx,
791 )
792 .await
793 .expect("execute");
794
795 let written = fs::read(&path).expect("read");
796 assert_eq!(
797 written, b"gamma\r\ndelta\r\n",
798 "write_file must preserve the existing CRLF style, like edit_file"
799 );
800 }
801
802 #[tokio::test]
803 async fn contract_write_over_crlf_file_preserves_crlf_line_endings() {
804 // The contract `write` path (WriteFileTool::execute_contract_write) must
805 // honor the same line-ending policy as the full write_file tool.
806 let tmp = tempdir().expect("tempdir");
807 let ctx = ToolContext::new(tmp.path().to_path_buf());
808 let path = tmp.path().join("crlf.txt");
809 fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");
810
811 WriteFileTool::execute_contract_write(
812 json!({"path": "crlf.txt", "content": "gamma\ndelta\n"}),
813 &ctx,
814 )
815 .await
816 .expect("execute");
817
818 let written = fs::read(&path).expect("read");
819 assert_eq!(
820 written, b"gamma\r\ndelta\r\n",
821 "contract write must preserve the existing CRLF style, like edit_file"
822 );
823 }
824
825 #[test]
826 fn preserve_prior_line_endings_keeps_the_prior_style() {
827 // Existing CRLF file: incoming LF content is re-emitted as CRLF.
828 assert_eq!(
829 preserve_prior_line_endings("gamma\ndelta\n", "alpha\r\nbeta\r\n"),
830 "gamma\r\ndelta\r\n"
831 );
832 // Existing LF file: incoming CRLF content is re-emitted as LF.
833 assert_eq!(
834 preserve_prior_line_endings("gamma\r\ndelta\r\n", "alpha\nbeta\n"),
835 "gamma\ndelta\n"
836 );
837 // Brand-new file (no prior content): written verbatim, including CRLF.
838 assert_eq!(
839 preserve_prior_line_endings("gamma\r\ndelta\r\n", ""),
840 "gamma\r\ndelta\r\n"
841 );
842 assert_eq!(preserve_prior_line_endings("plain", ""), "plain");
843 // A lone CR in the incoming content is normalized like edit_file does: the
844 // bare \r becomes \n, then is re-emitted as CRLF when the prior is CRLF.
845 assert_eq!(
846 preserve_prior_line_endings("alpha\rbeta\n", "x\r\ny\r\n"),
847 "alpha\r\nbeta\r\n"
848 );
849 assert_eq!(
850 preserve_prior_line_endings("alpha\rbeta\n", "x\ny\n"),
851 "alpha\nbeta\n"
852 );
853 }
854
855 #[cfg(unix)]
856 #[tokio::test]
857 async fn edit_file_tool_preserves_executable_bits() {
858 use std::os::unix::fs::PermissionsExt;
859
860 let tmp = tempdir().expect("tempdir");
861 let ctx = ToolContext::new(tmp.path().to_path_buf());
862 let path = tmp.path().join("script.sh");
863 fs::write(&path, b"#!/bin/sh\nexit 0\n").expect("initial write");
864 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
865 .expect("set executable permissions");
866 read_before_edit(&ctx, "script.sh").await;
867
868 EditFileTool
869 .execute(
870 json!({
871 "path": "script.sh",
872 "search": "exit 0",
873 "replace": "exit 1"
874 }),
875 &ctx,
876 )
877 .await
878 .expect("execute");
879
880 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
881 assert_eq!(mode, 0o755);
882 assert_eq!(
883 fs::read_to_string(&path).expect("read"),
884 "#!/bin/sh\nexit 1\n"
885 );
886 }
887
888 /// #6205 — a sloppy edit to a rustfmt-clean file lands normalized, and the
889 /// tool result's returned diff matches the bytes on disk, so the model's next
890 /// anchor is the real text.
891 #[tokio::test]
892 async fn edit_file_normalizes_a_sloppy_edit_in_a_rustfmt_clean_file() {
893 let tmp = tempdir().expect("tempdir");
894 let ctx = ToolContext::new(tmp.path().to_path_buf());
895 let path = tmp.path().join("clean.rs");
896 fs::write(&path, "fn main() {\n let x = 1;\n}\n").expect("write");
897 read_before_edit(&ctx, "clean.rs").await;
898
899 let result = EditFileTool
900 .execute(
901 json!({
902 "path": "clean.rs",
903 "search": " let x = 1;",
904 "replace": " let x = 1;\n let y=2;",
905 }),
906 &ctx,
907 )
908 .await
909 .expect("execute");
910
911 // No skip-if-missing branch: rustfmt ships with the pinned toolchain, and a
912 // test that passes vacuously without it proves nothing.
913 assert_eq!(
914 fs::read_to_string(&path).expect("read"),
915 "fn main() {\n let x = 1;\n let y = 2;\n}\n"
916 );
917 assert!(
918 result.content.contains("rustfmt-normalized"),
919 "the result must say the content was normalized: {}",
920 result.content
921 );
922 let diff = result.metadata.as_ref().expect("metadata")["mutation"]["diff"]
923 .as_str()
924 .expect("diff")
925 .to_string();
926 assert!(
927 diff.contains("+ let y = 2;"),
928 "the returned diff must show the normalized text, not what was sent: {diff}"
929 );
930 assert!(!diff.contains("let y=2;"), "{diff}");
931 }
932
933 /// A file the author formats by hand is never reformatted wholesale.
934 #[tokio::test]
935 async fn edit_file_leaves_a_hand_formatted_file_alone() {
936 let tmp = tempdir().expect("tempdir");
937 let ctx = ToolContext::new(tmp.path().to_path_buf());
938 let path = tmp.path().join("handmade.rs");
939 // Two-space indentation: rustfmt would rewrite every line of this file.
940 fs::write(&path, "fn main() {\n let x = 1;\n}\n").expect("write");
941 read_before_edit(&ctx, "handmade.rs").await;
942
943 EditFileTool
944 .execute(
945 json!({
946 "path": "handmade.rs",
947 "search": " let x = 1;",
948 "replace": " let x = 1;\n let y = 2;",
949 }),
950 &ctx,
951 )
952 .await
953 .expect("execute");
954
955 assert_eq!(
956 fs::read_to_string(&path).expect("read"),
957 "fn main() {\n let x = 1;\n let y = 2;\n}\n",
958 "unrelated user formatting must survive the edit"
959 );
960 }
961
962 /// #6206 — a dependency bump that leaves `Cargo.toml` unparseable is refused
963 /// at edit time, not discovered by the next `cargo` invocation.
964 #[tokio::test]
965 async fn edit_file_refuses_an_edit_that_breaks_a_cargo_manifest() {
966 let tmp = tempdir().expect("tempdir");
967 let ctx = ToolContext::new(tmp.path().to_path_buf());
968 let path = tmp.path().join("Cargo.toml");
969 let original = "[dependencies]\nserde = \"1.0\"\n";
970 fs::write(&path, original).expect("write");
971 read_before_edit(&ctx, "Cargo.toml").await;
972
973 let error = EditFileTool
974 .execute(
975 json!({
976 "path": "Cargo.toml",
977 "search": "serde = \"1.0\"",
978 // Unterminated string: the classic half-finished version bump.
979 "replace": "serde = \"1.0",
980 }),
981 &ctx,
982 )
983 .await
984 .expect_err("an unparseable manifest must be refused");
985
986 let message = error.to_string();
987 assert!(message.contains("TOML syntax error at line"), "{message}");
988 assert_eq!(
989 fs::read_to_string(&path).expect("read"),
990 original,
991 "a refused edit must leave the manifest unchanged"
992 );
993 }
994
995 /// A valid structured-config edit is untouched by the gate.
996 #[tokio::test]
997 async fn edit_file_applies_a_valid_json_edit() {
998 let tmp = tempdir().expect("tempdir");
999 let ctx = ToolContext::new(tmp.path().to_path_buf());
1000 let path = tmp.path().join("data.json");
1001 fs::write(&path, "{\n \"port\": 8080\n}\n").expect("write");
1002 read_before_edit(&ctx, "data.json").await;
1003
1004 EditFileTool
1005 .execute(
1006 json!({
1007 "path": "data.json",
1008 "search": "8080",
1009 "replace": "9090",
1010 }),
1011 &ctx,
1012 )
1013 .await
1014 .expect("a valid JSON edit must proceed unchanged");
1015
1016 assert_eq!(
1017 fs::read_to_string(&path).expect("read"),
1018 "{\n \"port\": 9090\n}\n"
1019 );
1020 }
1021
1022 /// #6204 — an edit that takes a parseable Rust file to an unparseable one is
1023 /// refused before the write, with a `line:column` from `syn`.
1024 #[tokio::test]
1025 async fn edit_file_refuses_an_edit_that_breaks_rust_syntax() {
1026 let tmp = tempdir().expect("tempdir");
1027 let ctx = ToolContext::new(tmp.path().to_path_buf());
1028 let path = tmp.path().join("main.rs");
1029 let original = "fn main() {\n println!(\"hi\");\n}\n";
1030 fs::write(&path, original).expect("write");
1031 read_before_edit(&ctx, "main.rs").await;
1032
1033 let error = EditFileTool
1034 .execute(
1035 json!({
1036 "path": "main.rs",
1037 // Same brace balance, so the payload-corruption heuristic has
1038 // no objection; the parenthesis is what breaks the grammar.
1039 "search": "fn main() {",
1040 "replace": "fn main( {",
1041 }),
1042 &ctx,
1043 )
1044 .await
1045 .expect_err("an edit that breaks Rust syntax must be refused");
1046
1047 let message = error.to_string();
1048 assert!(message.contains("Rust syntax error at line"), "{message}");
1049 assert!(message.contains("Nothing was written"), "{message}");
1050 assert_eq!(
1051 fs::read_to_string(&path).expect("read"),
1052 original,
1053 "a refused edit must leave the file byte-for-byte unchanged"
1054 );
1055 }
1056
1057 /// The gate catches the edit that *introduces* breakage, never the one that
1058 /// repairs it: a file that already fails to parse stays editable.
1059 #[tokio::test]
1060 async fn edit_file_still_repairs_an_already_broken_rust_file() {
1061 let tmp = tempdir().expect("tempdir");
1062 let ctx = ToolContext::new(tmp.path().to_path_buf());
1063 let path = tmp.path().join("broken.rs");
1064 fs::write(&path, "fn main( {\n println!(\"hi\");\n}\n").expect("write");
1065 read_before_edit(&ctx, "broken.rs").await;
1066
1067 EditFileTool
1068 .execute(
1069 json!({
1070 "path": "broken.rs",
1071 "search": "fn main( {",
1072 "replace": "fn main() {",
1073 }),
1074 &ctx,
1075 )
1076 .await
1077 .expect("repairing a broken file must not be gated");
1078
1079 assert_eq!(
1080 fs::read_to_string(&path).expect("read"),
1081 "fn main() {\n println!(\"hi\");\n}\n"
1082 );
1083 }
1084
1085 #[tokio::test]
1086 async fn edit_file_refuses_brace_collapsed_match_arm_payload() {
1087 let tmp = tempdir().expect("tempdir");
1088 let ctx = ToolContext::new(tmp.path().to_path_buf());
1089 let path = tmp.path().join("arm.rs");
1090 let original = r#"match outcome {
1091 SendMessageOutcome::Finished {
1092 status: TurnOutcomeStatus::Interrupted,
1093 ..
1094 } => self.pause_goal_after_interruption().await,
1095 SendMessageOutcome::Finished {
1096 status: TurnOutcomeStatus::Completed,
1097 ..
1098 } => {}
1099 }
1100 "#;
1101 fs::write(&path, original).expect("write");
1102 read_before_edit(&ctx, "arm.rs").await;
1103
1104 let search = r#"SendMessageOutcome::Finished {
1105 status: TurnOutcomeStatus::Interrupted,
1106 ..
1107 } => self.pause_goal_after_interruption().await,"#;
1108 // Corrupted host payload: brace block collapsed to empty brackets.
1109 let replace = "[
1110
1111 ] => {},";
1112 let err = EditFileTool
1113 .execute(
1114 json!({
1115 "path": "arm.rs",
1116 "search": search,
1117 "replace": replace,
1118 }),
1119 &ctx,
1120 )
1121 .await
1122 .expect_err("corrupted brace collapse must fail closed");
1123 let msg = err.to_string();
1124 assert!(
1125 msg.contains("corrupted") || msg.contains("collapsed") || msg.contains("unbalanced"),
1126 "unexpected error: {msg}"
1127 );
1128 assert_eq!(fs::read_to_string(&path).expect("read"), original);
1129 }
1130
1131 #[tokio::test]
1132 async fn edit_file_preserves_rust_match_arm_braces() {
1133 let tmp = tempdir().expect("tempdir");
1134 let ctx = ToolContext::new(tmp.path().to_path_buf());
1135 let path = tmp.path().join("arm.rs");
1136 let original = r#"match outcome {
1137 SendMessageOutcome::Finished {
1138 status: TurnOutcomeStatus::Interrupted,
1139 ..
1140 } => self.pause_goal_after_interruption().await,
1141 other => {}
1142 }
1143 "#;
1144 fs::write(&path, original).expect("write");
1145 read_before_edit(&ctx, "arm.rs").await;
1146
1147 let search = r#"SendMessageOutcome::Finished {
1148 status: TurnOutcomeStatus::Interrupted,
1149 ..
1150 } => self.pause_goal_after_interruption().await,"#;
1151 let replace = r#"SendMessageOutcome::Finished {
1152 status: TurnOutcomeStatus::Interrupted,
1153 ..
1154 } => {
1155 // stay active
1156 let _ = self.tx_event.send(Event::status("ok".into())).await;
1157 }"#;
1158 EditFileTool
1159 .execute(
1160 json!({
1161 "path": "arm.rs",
1162 "search": search,
1163 "replace": replace,
1164 }),
1165 &ctx,
1166 )
1167 .await
1168 .expect("brace-heavy replace must apply");
1169 let updated = fs::read_to_string(&path).expect("read");
1170 assert!(updated.contains("stay active"), "{updated}");
1171 assert!(
1172 updated.contains("SendMessageOutcome::Finished"),
1173 "{updated}"
1174 );
1175 assert!(
1176 !updated.contains("pause_goal_after_interruption"),
1177 "{updated}"
1178 );
1179 }
1180
1181 #[tokio::test]
1182 async fn test_edit_file_tool() {
1183 let tmp = tempdir().expect("tempdir");
1184 let ctx = ToolContext::new(tmp.path().to_path_buf());
1185
1186 // Create a file to edit
1187 let test_file = tmp.path().join("edit_me.txt");
1188 fs::write(&test_file, "hello world").expect("write");
1189 read_before_edit(&ctx, "edit_me.txt").await;
1190
1191 let tool = EditFileTool;
1192 let result = tool
1193 .execute(
1194 json!({"path": "edit_me.txt", "search": "hello", "replace": "hi"}),
1195 &ctx,
1196 )
1197 .await
1198 .expect("execute");
1199
1200 assert!(result.success);
1201 assert!(result.content.contains("Replaced 1 occurrence"));
1202 // Inline diff (#505) — the unified diff lands above the summary
1203 // line so the TUI's diff-aware renderer kicks in.
1204 assert!(result.content.contains("--- a/"), "{}", result.content);
1205 assert!(
1206 result.content.contains("-hello world"),
1207 "{}",
1208 result.content
1209 );
1210 assert!(result.content.contains("+hi world"), "{}", result.content);
1211 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
1212 assert_eq!(
1213 mutation["files"],
1214 json!([{ "path": "edit_me.txt", "outcome": "updated" }])
1215 );
1216 let receipt_diff = mutation["diff"].as_str().expect("receipt diff");
1217 assert!(receipt_diff.contains("--- a/edit_me.txt"), "{receipt_diff}");
1218 assert!(receipt_diff.contains("-hello world"), "{receipt_diff}");
1219 assert!(receipt_diff.contains("+hi world"), "{receipt_diff}");
1220 assert!(
1221 !receipt_diff.contains(&tmp.path().display().to_string()),
1222 "receipt headers must not expose the resolved host path: {receipt_diff}"
1223 );
1224
1225 // Verify edit was applied
1226 let edited = fs::read_to_string(&test_file).expect("read");
1227 assert_eq!(edited, "hi world");
1228 }
1229
1230 #[tokio::test]
1231 async fn edit_file_matches_lf_search_in_crlf_file_and_preserves_crlf() {
1232 let tmp = tempdir().expect("tempdir");
1233 let ctx = ToolContext::new(tmp.path().to_path_buf());
1234 let test_file = tmp.path().join("crlf.py");
1235 fs::write(
1236 &test_file,
1237 b"def greet(name):\r\n print(name)\r\n\r\ndef add(a, b):\r\n return a + b\r\n",
1238 )
1239 .expect("write");
1240 read_before_edit(&ctx, "crlf.py").await;
1241
1242 let result = EditFileTool
1243 .execute(
1244 json!({
1245 "path": "crlf.py",
1246 "search": "def add(a, b):\n return a + b",
1247 "replace": "def add(a, b):\n return a * b",
1248 }),
1249 &ctx,
1250 )
1251 .await
1252 .expect("LF model input should edit a CRLF file");
1253
1254 assert!(result.success, "{}", result.content);
1255 assert_eq!(
1256 fs::read(&test_file).expect("read"),
1257 b"def greet(name):\r\n print(name)\r\n\r\ndef add(a, b):\r\n return a * b\r\n",
1258 );
1259 }
1260
1261 #[test]
1262 fn edit_file_sparse_crlf_positions_map_utf8_range_through_eof() {
1263 let original = "前\r\n尾";
1264 let (normalized, crlf_positions) = normalize_crlf_with_positions(original);
1265
1266 assert_eq!(normalized, "前\n尾");
1267 assert_eq!(crlf_positions.as_deref(), Some(&[3][..]));
1268 assert_eq!(
1269 map_normalized_range((0, normalized.len()), crlf_positions.as_deref()),
1270 (0, original.len()),
1271 );
1272 }
1273
1274 #[tokio::test]
1275 async fn edit_file_maps_utf8_crlf_match_ending_at_eof() {
1276 let tmp = tempdir().expect("tempdir");
1277 let ctx = ToolContext::new(tmp.path().to_path_buf());
1278 let test_file = tmp.path().join("utf8-eof-crlf.txt");
1279 fs::write(&test_file, "前\r\n尾").expect("write");
1280 read_before_edit(&ctx, "utf8-eof-crlf.txt").await;
1281
1282 EditFileTool
1283 .execute(
1284 json!({
1285 "path": "utf8-eof-crlf.txt",
1286 "search": "前\n尾",
1287 "replace": "始\n终",
1288 }),
1289 &ctx,
1290 )
1291 .await
1292 .expect("UTF-8 CRLF match should map through EOF");
1293
1294 assert_eq!(fs::read(&test_file).expect("read"), "始\r\n终".as_bytes(),);
1295 }
1296
1297 #[tokio::test]
1298 async fn edit_file_normalizes_multiline_replacement_for_single_line_crlf_match() {
1299 let tmp = tempdir().expect("tempdir");
1300 let ctx = ToolContext::new(tmp.path().to_path_buf());
1301 let test_file = tmp.path().join("single-line-crlf.txt");
1302 fs::write(&test_file, b"alpha\r\nomega\r\n").expect("write");
1303 read_before_edit(&ctx, "single-line-crlf.txt").await;
1304
1305 EditFileTool
1306 .execute(
1307 json!({
1308 "path": "single-line-crlf.txt",
1309 "search": "omega",
1310 "replace": "beta\ngamma",
1311 }),
1312 &ctx,
1313 )
1314 .await
1315 .expect("replacement should follow the file's CRLF style");
1316
1317 assert_eq!(
1318 fs::read(&test_file).expect("read"),
1319 b"alpha\r\nbeta\r\ngamma\r\n",
1320 );
1321 }
1322
1323 #[tokio::test]
1324 async fn edit_file_normalizes_crlf_and_mixed_replacement_for_lf_file() {
1325 let tmp = tempdir().expect("tempdir");
1326 let ctx = ToolContext::new(tmp.path().to_path_buf());
1327 let test_file = tmp.path().join("lf.txt");
1328 fs::write(&test_file, b"alpha\nomega\n").expect("write");
1329 read_before_edit(&ctx, "lf.txt").await;
1330
1331 EditFileTool
1332 .execute(
1333 json!({
1334 "path": "lf.txt",
1335 "search": "omega",
1336 "replace": "beta\r\ngamma\nfinal",
1337 }),
1338 &ctx,
1339 )
1340 .await
1341 .expect("replacement should follow the file's LF style");
1342
1343 assert_eq!(
1344 fs::read(&test_file).expect("read"),
1345 b"alpha\nbeta\ngamma\nfinal\n",
1346 );
1347 }
1348
1349 #[tokio::test]
1350 async fn edit_file_rejects_logical_duplicate_across_lf_and_crlf() {
1351 let tmp = tempdir().expect("tempdir");
1352 let ctx = ToolContext::new(tmp.path().to_path_buf());
1353 let test_file = tmp.path().join("mixed.txt");
1354 let original = b"same\nblock\r\nsame\r\nblock\r\n";
1355 fs::write(&test_file, original).expect("write");
1356 read_before_edit(&ctx, "mixed.txt").await;
1357
1358 let error = EditFileTool
1359 .execute(
1360 json!({
1361 "path": "mixed.txt",
1362 "search": "same\nblock",
1363 "replace": "changed",
1364 }),
1365 &ctx,
1366 )
1367 .await
1368 .expect_err("logical duplicates must remain non-unique");
1369
1370 assert!(error.to_string().contains("matched 2"), "{error}");
1371 assert_eq!(fs::read(&test_file).expect("read"), original);
1372 }
1373
1374 #[tokio::test]
1375 async fn edit_file_combines_crlf_and_indentation_fuzzy_matching() {
1376 let tmp = tempdir().expect("tempdir");
1377 let ctx = ToolContext::new(tmp.path().to_path_buf());
1378 let test_file = tmp.path().join("fuzzy-crlf.txt");
1379 fs::write(&test_file, "前言\r\n 数据 = 1\r\n").expect("write");
1380 read_before_edit(&ctx, "fuzzy-crlf.txt").await;
1381
1382 let result = EditFileTool
1383 .execute(
1384 json!({
1385 "path": "fuzzy-crlf.txt",
1386 "search": "前言\n 数据 = 1",
1387 "replace": "前言\n 数据 = 2",
1388 }),
1389 &ctx,
1390 )
1391 .await
1392 .expect("indentation fallback should compose with CRLF normalization");
1393
1394 assert!(
1395 result.content.contains("fuzzy indentation match"),
1396 "{}",
1397 result.content
1398 );
1399 assert_eq!(
1400 fs::read(&test_file).expect("read"),
1401 "前言\r\n 数据 = 2\r\n".as_bytes(),
1402 );
1403 }
1404
1405 #[tokio::test]
1406 async fn edit_file_combines_crlf_and_punctuation_fuzzy_matching() {
1407 let tmp = tempdir().expect("tempdir");
1408 let ctx = ToolContext::new(tmp.path().to_path_buf());
1409 let test_file = tmp.path().join("punctuation-crlf.txt");
1410 fs::write(&test_file, "前言\r\n数据 \"x\"\r\n").expect("write");
1411 read_before_edit(&ctx, "punctuation-crlf.txt").await;
1412
1413 let result = EditFileTool
1414 .execute(
1415 json!({
1416 "path": "punctuation-crlf.txt",
1417 "search": "前言\n数据 \u{201C}x\u{201D}",
1418 "replace": "前言\r\n数据 y\n下一行",
1419 }),
1420 &ctx,
1421 )
1422 .await
1423 .expect("punctuation fallback should compose with CRLF normalization");
1424
1425 assert!(
1426 result.content.contains("fuzzy punctuation match"),
1427 "{}",
1428 result.content
1429 );
1430 assert_eq!(
1431 fs::read(&test_file).expect("read"),
1432 "前言\r\n数据 y\r\n下一行\r\n".as_bytes(),
1433 );
1434 }
1435
1436 #[tokio::test]
1437 async fn edit_file_rejects_line_ending_normalized_noop() {
1438 let tmp = tempdir().expect("tempdir");
1439 let ctx = ToolContext::new(tmp.path().to_path_buf());
1440 let test_file = tmp.path().join("noop-crlf.txt");
1441 let original = b"alpha\r\nbeta\r\n";
1442 fs::write(&test_file, original).expect("write");
1443 read_before_edit(&ctx, "noop-crlf.txt").await;
1444
1445 let error = EditFileTool
1446 .execute(
1447 json!({
1448 "path": "noop-crlf.txt",
1449 "search": "alpha\nbeta",
1450 "replace": "alpha\r\nbeta",
1451 }),
1452 &ctx,
1453 )
1454 .await
1455 .expect_err("normalized no-op should be rejected");
1456
1457 assert!(error.to_string().contains("no change intended"), "{error}");
1458 assert_eq!(fs::read(&test_file).expect("read"), original);
1459 }
1460
1461 #[tokio::test]
1462 async fn edit_file_requires_prior_read() {
1463 let tmp = tempdir().expect("tempdir");
1464 let ctx = ToolContext::new(tmp.path().to_path_buf());
1465
1466 let test_file = tmp.path().join("blind.txt");
1467 fs::write(&test_file, "hello world").expect("write");
1468
1469 let err = EditFileTool
1470 .execute(
1471 json!({"path": "blind.txt", "search": "hello", "replace": "hi"}),
1472 &ctx,
1473 )
1474 .await
1475 .expect_err("edit without read should fail");
1476 let message = err.to_string();
1477 assert!(message.contains("not been read"), "{message}");
1478 // The recovery has to be spelled as a call the model can make: `read_file`
1479 // was retired in v0.9.3 and the registry has no fuzzy resolve step.
1480 assert!(message.contains(r#"File with action="read""#), "{message}");
1481 assert!(!message.contains("read_file"), "{message}");
1482
1483 let unchanged = fs::read_to_string(&test_file).expect("read");
1484 assert_eq!(unchanged, "hello world");
1485 }
1486
1487 #[tokio::test]
1488 async fn edit_file_rejects_stale_prior_read() {
1489 let tmp = tempdir().expect("tempdir");
1490 let ctx = ToolContext::new(tmp.path().to_path_buf());
1491
1492 let test_file = tmp.path().join("stale.txt");
1493 fs::write(&test_file, "alpha beta").expect("write");
1494 read_before_edit(&ctx, "stale.txt").await;
1495 fs::write(&test_file, "alpha beta gamma").expect("external write");
1496
1497 let err = EditFileTool
1498 .execute(
1499 json!({"path": "stale.txt", "search": "alpha", "replace": "omega"}),
1500 &ctx,
1501 )
1502 .await
1503 .expect_err("stale read should fail");
1504 let message = err.to_string();
1505 assert!(message.contains("changed since"), "{message}");
1506 assert!(message.contains(r#"File with action="read""#), "{message}");
1507 assert!(!message.contains("read_file"), "{message}");
1508
1509 let unchanged = fs::read_to_string(&test_file).expect("read");
1510 assert_eq!(unchanged, "alpha beta gamma");
1511 }
1512
1513 #[tokio::test]
1514 async fn edit_file_rejects_non_unique_exact_match() {
1515 let tmp = tempdir().expect("tempdir");
1516 let ctx = ToolContext::new(tmp.path().to_path_buf());
1517
1518 let test_file = tmp.path().join("multi.txt");
1519 fs::write(&test_file, "hello world hello").expect("write");
1520 read_before_edit(&ctx, "multi.txt").await;
1521
1522 let err = EditFileTool
1523 .execute(
1524 json!({"path": "multi.txt", "search": "hello", "replace": "hi"}),
1525 &ctx,
1526 )
1527 .await
1528 .expect_err("non-unique exact match should fail");
1529 let message = err.to_string();
1530 assert!(message.contains("non-unique"), "{message}");
1531 assert!(message.contains("matched 2"), "{message}");
1532 // Recovery text must name the live surface. `read_file` is retired and
1533 // cannot dispatch (crates/tui/src/tools/registry.rs:2067).
1534 assert!(
1535 message.contains("call File with action=\"read\""),
1536 "{message}"
1537 );
1538 assert!(!message.contains("read_file"), "{message}");
1539
1540 let unchanged = fs::read_to_string(&test_file).expect("read");
1541 assert_eq!(unchanged, "hello world hello");
1542 }
1543
1544 /// `fuzz` on `edit` was an advertised parameter with no implementation: it
1545 /// was parsed into `let _fuzz` and thrown away, and a live model read the
1546 /// schema as offering "an optional fuzzy-matching flag for the search". The
1547 /// advertisement is gone, so the name now means nothing to `edit` and is
1548 /// refused like any other name with no known meaning — the fuzzy fallbacks it
1549 /// appeared to control run unconditionally either way.
1550 #[tokio::test]
1551 async fn edit_file_refuses_the_retired_fuzz_parameter() {
1552 let tmp = tempdir().expect("tempdir");
1553 let ctx = ToolContext::new(tmp.path().to_path_buf());
1554 let test_file = tmp.path().join("fuzz_retired.txt");
1555 fs::write(&test_file, "hello world").expect("write");
1556 read_before_edit(&ctx, "fuzz_retired.txt").await;
1557
1558 let err = EditFileTool
1559 .execute(
1560 json!({
1561 "path": "fuzz_retired.txt",
1562 "search": "hello",
1563 "replace": "hi",
1564 "fuzz": true,
1565 }),
1566 &ctx,
1567 )
1568 .await
1569 .expect_err("a parameter edit does not implement must be refused");
1570 let msg = err.to_string();
1571 assert!(msg.contains("fuzz"), "must name the parameter: {msg}");
1572 assert!(
1573 msg.contains("was not performed"),
1574 "must deny having edited: {msg}"
1575 );
1576 assert_eq!(
1577 fs::read_to_string(&test_file).expect("read"),
1578 "hello world",
1579 "a refused edit must not touch the file"
1580 );
1581 }
1582
1583 #[tokio::test]
1584 async fn test_edit_file_single_match_has_no_multi_match_warning() {
1585 let tmp = tempdir().expect("tempdir");
1586 let ctx = ToolContext::new(tmp.path().to_path_buf());
1587
1588 let test_file = tmp.path().join("single.txt");
1589 fs::write(&test_file, "hello world").expect("write");
1590 read_before_edit(&ctx, "single.txt").await;
1591
1592 let tool = EditFileTool;
1593 let result = tool
1594 .execute(
1595 json!({"path": "single.txt", "search": "hello", "replace": "hi"}),
1596 &ctx,
1597 )
1598 .await
1599 .expect("execute");
1600
1601 assert!(result.success);
1602 assert!(result.content.contains("Replaced 1 occurrence"));
1603 assert!(!result.content.contains("multiple matches were replaced"));
1604 }
1605
1606 #[tokio::test]
1607 async fn test_edit_file_fuzz_tolerates_leading_whitespace() {
1608 let tmp = tempdir().expect("tempdir");
1609 let ctx = ToolContext::new(tmp.path().to_path_buf());
1610
1611 let test_file = tmp.path().join("fuzzy.txt");
1612 fs::write(
1613 &test_file,
1614 "fn main() {\n if true {\n let value = 1;\n }\n}\n",
1615 )
1616 .expect("write");
1617 read_before_edit(&ctx, "fuzzy.txt").await;
1618
1619 let tool = EditFileTool;
1620 let result = tool
1621 .execute(
1622 json!({
1623 "path": "fuzzy.txt",
1624 "search": "if true {\n let value = 1;\n}",
1625 "replace": " if true {\n let value = 2;\n }"
1626 }),
1627 &ctx,
1628 )
1629 .await
1630 .expect("execute");
1631
1632 assert!(result.success);
1633 assert!(result.content.contains("fuzzy indentation match"));
1634 let edited = fs::read_to_string(&test_file).expect("read");
1635 assert_eq!(
1636 edited,
1637 "fn main() {\n if true {\n let value = 2;\n }\n}\n"
1638 );
1639 }
1640
1641 #[tokio::test]
1642 async fn test_edit_file_fuzz_tolerates_leading_whitespace_after_multibyte_start() {
1643 let tmp = tempdir().expect("tempdir");
1644 let ctx = ToolContext::new(tmp.path().to_path_buf());
1645
1646 let test_file = tmp.path().join("fuzzy_cjk.txt");
1647 fs::write(&test_file, "数据\n").expect("write");
1648 read_before_edit(&ctx, "fuzzy_cjk.txt").await;
1649
1650 let tool = EditFileTool;
1651 let result = tool
1652 .execute(
1653 json!({
1654 "path": "fuzzy_cjk.txt",
1655 "search": " 数据",
1656 "replace": "记录"
1657 }),
1658 &ctx,
1659 )
1660 .await
1661 .expect("execute");
1662
1663 assert!(result.success, "{}", result.content);
1664 assert!(result.content.contains("fuzzy indentation match"));
1665 let edited = fs::read_to_string(&test_file).expect("read");
1666 assert_eq!(edited, "记录\n");
1667 }
1668
1669 #[tokio::test]
1670 async fn test_edit_file_fuzz_tolerates_smart_quote_substitution() {
1671 // The file on disk has ASCII quotes. The search comes from a
1672 // browser paste with curly quotes. Exact match fails; the
1673 // punctuation-normalized fallback should still land the edit.
1674 let tmp = tempdir().expect("tempdir");
1675 let ctx = ToolContext::new(tmp.path().to_path_buf());
1676
1677 let test_file = tmp.path().join("smart.rs");
1678 fs::write(&test_file, "let s = \"hello world\";\n").expect("write");
1679 read_before_edit(&ctx, "smart.rs").await;
1680
1681 let tool = EditFileTool;
1682 let result = tool
1683 .execute(
1684 json!({
1685 "path": "smart.rs",
1686 // \u{201C} \u{201D} are the curly double-quote pair.
1687 "search": "let s = \u{201C}hello world\u{201D};",
1688 "replace": "let s = \"hello universe\";"
1689 }),
1690 &ctx,
1691 )
1692 .await
1693 .expect("execute");
1694
1695 assert!(result.success, "fuzzy punctuation edit should succeed");
1696 assert!(
1697 result.content.contains("fuzzy punctuation match"),
1698 "expected punctuation-fuzz note, got: {}",
1699 result.content
1700 );
1701 let edited = fs::read_to_string(&test_file).expect("read");
1702 assert_eq!(edited, "let s = \"hello universe\";\n");
1703 }
1704
1705 #[tokio::test]
1706 async fn test_edit_file_fuzz_tolerates_smart_quote_after_multibyte_start() {
1707 let tmp = tempdir().expect("tempdir");
1708 let ctx = ToolContext::new(tmp.path().to_path_buf());
1709
1710 let test_file = tmp.path().join("smart_cjk.md");
1711 fs::write(&test_file, "数据 \"x\"\n").expect("write");
1712 read_before_edit(&ctx, "smart_cjk.md").await;
1713
1714 let tool = EditFileTool;
1715 let result = tool
1716 .execute(
1717 json!({
1718 "path": "smart_cjk.md",
1719 "search": "数据 \u{201C}x\u{201D}",
1720 "replace": "数据 y"
1721 }),
1722 &ctx,
1723 )
1724 .await
1725 .expect("execute");
1726
1727 assert!(result.success, "{}", result.content);
1728 assert!(result.content.contains("fuzzy punctuation match"));
1729 let edited = fs::read_to_string(&test_file).expect("read");
1730 assert_eq!(edited, "数据 y\n");
1731 }
1732
1733 #[tokio::test]
1734 async fn test_edit_file_fuzz_tolerates_em_dash_and_nbsp() {
1735 let tmp = tempdir().expect("tempdir");
1736 let ctx = ToolContext::new(tmp.path().to_path_buf());
1737
1738 let test_file = tmp.path().join("dash.md");
1739 // File has an ASCII hyphen and ASCII space.
1740 fs::write(&test_file, "alpha - beta\n").expect("write");
1741 read_before_edit(&ctx, "dash.md").await;
1742
1743 let tool = EditFileTool;
1744 let result = tool
1745 .execute(
1746 json!({
1747 "path": "dash.md",
1748 // Search uses em-dash + NBSP, common after a copy-paste
1749 // from a styled document.
1750 "search": "alpha\u{00A0}\u{2014}\u{00A0}beta",
1751 "replace": "alpha - gamma"
1752 }),
1753 &ctx,
1754 )
1755 .await
1756 .expect("execute");
1757
1758 assert!(result.success);
1759 let edited = fs::read_to_string(&test_file).expect("read");
1760 assert_eq!(edited, "alpha - gamma\n");
1761 }
1762
1763 #[tokio::test]
1764 async fn test_edit_file_not_found() {
1765 let tmp = tempdir().expect("tempdir");
1766 let ctx = ToolContext::new(tmp.path().to_path_buf());
1767
1768 // Create a file without the search string
1769 let test_file = tmp.path().join("no_match.txt");
1770 fs::write(&test_file, "foo bar baz").expect("write");
1771 read_before_edit(&ctx, "no_match.txt").await;
1772
1773 let tool = EditFileTool;
1774 let result = tool
1775 .execute(
1776 json!({"path": "no_match.txt", "search": "hello", "replace": "hi"}),
1777 &ctx,
1778 )
1779 .await;
1780
1781 assert!(result.is_err());
1782 let err = result.unwrap_err();
1783 assert!(err.to_string().contains("not found"));
1784 assert!(err.to_string().contains("call File with action=\"read\""));
1785 assert!(!err.to_string().contains("read_file"));
1786 }
1787
1788 #[tokio::test]
1789 async fn test_edit_file_rejects_identical_search_and_replace() {
1790 let tmp = tempdir().expect("tempdir");
1791 let ctx = ToolContext::new(tmp.path().to_path_buf());
1792
1793 let test_file = tmp.path().join("same.txt");
1794 fs::write(&test_file, "a := \"foo\"").expect("write");
1795
1796 let tool = EditFileTool;
1797 let result = tool
1798 .execute(
1799 json!({
1800 "path": "same.txt",
1801 "search": "a := \"foo\"",
1802 "replace": "a := \"foo\""
1803 }),
1804 &ctx,
1805 )
1806 .await;
1807
1808 assert!(result.is_err());
1809 let err = result.unwrap_err().to_string();
1810 assert!(
1811 err.contains("search and replace are identical"),
1812 "error must explain the no-op input: {err}"
1813 );
1814 // #5003 - the diagnostic must help the model self-correct: it should
1815 // size the payload and point at the root cause instead of a bare
1816 // "no change intended".
1817 assert!(
1818 err.contains("10 chars"),
1819 "error should size the payload: {err}"
1820 );
1821 assert!(
1822 err.contains("Recovery"),
1823 "error should offer recovery: {err}"
1824 );
1825 let unchanged = fs::read_to_string(&test_file).expect("read");
1826 assert_eq!(unchanged, "a := \"foo\"");
1827 }
1828
1829 #[test]
1830 fn test_c_preprocessor_rejects_missing_close() {
1831 let before = "#if FEATURE\nold code\n#endif\n";
1832 let after = "#if FEATURE\nnew code\n";
1833 assert_eq!(
1834 invalid_preprocessor_edit(Path::new("source.c"), before, after),
1835 Some(PREPROCESSOR_CONDITIONAL_ERROR)
1836 );
1837 }
1838
1839 #[test]
1840 fn test_c_preprocessor_rejects_extra_close() {
1841 let before = "#if FEATURE\nold code\n#endif\n";
1842 let after = "#if FEATURE\nnew code\n#endif\n#endif\n";
1843 assert_eq!(
1844 invalid_preprocessor_edit(Path::new("source.hpp"), before, after),
1845 Some(PREPROCESSOR_CONDITIONAL_ERROR)
1846 );
1847 }
1848
1849 #[test]
1850 fn test_c_preprocessor_allows_balanced_block_removal_and_insertion() {
1851 let block = "#ifdef FEATURE\nfeature();\n#endif\n";
1852 assert!(invalid_preprocessor_edit(Path::new("source.cc"), block, "").is_none());
1853 assert!(invalid_preprocessor_edit(Path::new("source.cc"), "", block).is_none());
1854 }
1855
1856 #[test]
1857 fn test_c_preprocessor_allows_in_block_edit() {
1858 let before = "#if FEATURE\nold_call();\n#endif\n";
1859 let after = "#if FEATURE\nnew_call();\n#endif\n";
1860 assert!(invalid_preprocessor_edit(Path::new("source.cxx"), before, after).is_none());
1861 }
1862
1863 #[test]
1864 fn test_non_c_directive_prose_is_not_validated() {
1865 let before = "#if this example is enabled\nexplanation\n#endif\n";
1866 let after = "#if this example is enabled\nupdated explanation\n";
1867 assert!(invalid_preprocessor_edit(Path::new("guide.md"), before, after).is_none());
1868 }
1869
1870 #[test]
1871 fn test_preview_search_for_error_truncates() {
1872 let long_line = "x".repeat(200);
1873 let search = format!("{long_line}\nsecond line\nthird line\nfourth line\n");
1874 let preview = preview_search_for_error(&search);
1875 assert!(preview.lines().count() <= 3);
1876 assert!(preview.contains("..."));
1877 assert!(!preview.contains("fourth line"));
1878 }
1879
1880 #[tokio::test]
1881 async fn test_edit_file_not_found_shows_search_preview() {
1882 // #5003 - when search misses, the error should preview the search text
1883 // so the model can compare what it searched for against the file.
1884 let tmp = tempdir().expect("tempdir");
1885 let ctx = ToolContext::new(tmp.path().to_path_buf());
1886
1887 let test_file = tmp.path().join("preview.txt");
1888 fs::write(&test_file, "foo bar baz").expect("write");
1889 read_before_edit(&ctx, "preview.txt").await;
1890
1891 let tool = EditFileTool;
1892 let result = tool
1893 .execute(
1894 json!({
1895 "path": "preview.txt",
1896 "search": "first line\nsecond line\n",
1897 "replace": "changed"
1898 }),
1899 &ctx,
1900 )
1901 .await;
1902
1903 assert!(result.is_err());
1904 let err = result.unwrap_err().to_string();
1905 assert!(err.contains("Search string not found"));
1906 assert!(
1907 err.contains("first line"),
1908 "error should preview search text: {err}"
1909 );
1910 }
1911
1912 /// #157 / #5209 — `replacement` is an unambiguous synonym for `replace`, so
1913 /// the edit the model asked for is the edit that lands. The #5209 guarantee
1914 /// being protected is that the file and the receipt agree: a reported
1915 /// replacement must correspond to a real one.
1916 #[tokio::test]
1917 async fn edit_file_accepts_replacement_alias_and_applies_the_edit() {
1918 let tmp = tempdir().expect("tempdir");
1919 let ctx = ToolContext::new(tmp.path().to_path_buf());
1920
1921 let test_file = tmp.path().join("test.txt");
1922 fs::write(&test_file, "hello world").expect("write");
1923 read_before_edit(&ctx, "test.txt").await;
1924
1925 let result = EditFileTool
1926 .execute(
1927 json!({"path": "test.txt", "search": "hello", "replacement": "hi"}),
1928 &ctx,
1929 )
1930 .await
1931 .expect("replacement alias must be honored");
1932
1933 assert!(result.success);
1934 assert_eq!(
1935 fs::read_to_string(&test_file).expect("read"),
1936 "hi world",
1937 "the receipt claimed an edit, so the file must actually carry it"
1938 );
1939 }
1940
1941 /// Every cross-harness spelling of the two edit arguments resolves to the
1942 /// same applied edit. A model that guesses from a different harness's prior
1943 /// gets its work done instead of a rejection and a wasted turn (#5209).
1944 #[tokio::test]
1945 async fn edit_file_accepts_every_cross_harness_edit_alias() {
1946 for (search_key, replace_key) in [
1947 ("old_string", "new_string"),
1948 ("old_str", "new_str"),
1949 ("oldText", "newText"),
1950 ("old_text", "new_text"),
1951 ] {
1952 let tmp = tempdir().expect("tempdir");
1953 let ctx = ToolContext::new(tmp.path().to_path_buf());
1954 let path = tmp.path().join("doc.md");
1955 fs::write(&path, "old text line\n").expect("write");
1956 read_before_edit(&ctx, "doc.md").await;
1957
1958 let result = EditFileTool
1959 .execute(
1960 json!({
1961 "path": "doc.md",
1962 search_key: "old text line",
1963 replace_key: "new text line",
1964 }),
1965 &ctx,
1966 )
1967 .await
1968 .unwrap_or_else(|err| panic!("{search_key}/{replace_key} must apply: {err}"));
1969
1970 assert!(result.success, "{search_key}/{replace_key}");
1971 assert_eq!(
1972 fs::read_to_string(&path).expect("read"),
1973 "new text line\n",
1974 "{search_key}/{replace_key} must reach the file"
1975 );
1976 }
1977 }
1978
1979 /// The unified `File` tool takes the same alias path as the inner tool, so
1980 /// the model-facing surface and the dispatch target cannot disagree.
1981 #[tokio::test]
1982 async fn file_tool_action_edit_accepts_new_str_alias() {
1983 use crate::tools::file_tool::FileTool;
1984
1985 let tmp = tempdir().expect("tempdir");
1986 let ctx = ToolContext::new(tmp.path().to_path_buf());
1987 let path = tmp.path().join("doc.md");
1988 fs::write(&path, "old text line\n").expect("write");
1989 read_before_edit(&ctx, "doc.md").await;
1990
1991 let result = FileTool::with_patch("File")
1992 .execute(
1993 json!({
1994 "action": "edit",
1995 "path": "doc.md",
1996 "search": "old text line",
1997 "new_str": "new text line",
1998 }),
1999 &ctx,
2000 )
2001 .await
2002 .expect("File action=edit with new_str must apply");
2003
2004 assert!(result.success);
2005 assert_eq!(fs::read_to_string(&path).expect("read"), "new text line\n");
2006 }
2007
2008 /// An alias that contradicts an explicitly supplied canonical value is
2009 /// ambiguous. Picking one would be the guess this whole path exists to
2010 /// avoid, so it fails and changes nothing.
2011 #[tokio::test]
2012 async fn edit_file_rejects_alias_conflicting_with_canonical_name() {
2013 let tmp = tempdir().expect("tempdir");
2014 let ctx = ToolContext::new(tmp.path().to_path_buf());
2015 let path = tmp.path().join("doc.md");
2016 fs::write(&path, "old text line\n").expect("write");
2017 read_before_edit(&ctx, "doc.md").await;
2018
2019 let err = EditFileTool
2020 .execute(
2021 json!({
2022 "path": "doc.md",
2023 "search": "old text line",
2024 "replace": "one thing",
2025 "new_string": "a different thing",
2026 }),
2027 &ctx,
2028 )
2029 .await
2030 .expect_err("conflicting alias must not be silently resolved");
2031
2032 let msg = err.to_string();
2033 assert!(
2034 msg.contains("`replace`") && msg.contains("`new_string`"),
2035 "must name both spellings: {msg}"
2036 );
2037 assert_eq!(
2038 fs::read_to_string(&path).expect("read"),
2039 "old text line\n",
2040 "nothing may change on an ambiguous call"
2041 );
2042 }
2043
2044 /// An alias that merely repeats the canonical value is a harmless
2045 /// duplicate, not a conflict.
2046 #[tokio::test]
2047 async fn edit_file_accepts_alias_agreeing_with_canonical_name() {
2048 let tmp = tempdir().expect("tempdir");
2049 let ctx = ToolContext::new(tmp.path().to_path_buf());
2050 let path = tmp.path().join("doc.md");
2051 fs::write(&path, "old text line\n").expect("write");
2052 read_before_edit(&ctx, "doc.md").await;
2053
2054 EditFileTool
2055 .execute(
2056 json!({
2057 "path": "doc.md",
2058 "search": "old text line",
2059 "replace": "new text line",
2060 "new_string": "new text line",
2061 }),
2062 &ctx,
2063 )
2064 .await
2065 .expect("agreeing duplicate must be accepted");
2066
2067 assert_eq!(fs::read_to_string(&path).expect("read"), "new text line\n");
2068 }
2069
2070 /// `file_path` is the other widespread spelling of `path` and is accepted on
2071 /// every file action.
2072 #[tokio::test]
2073 async fn file_actions_accept_file_path_alias() {
2074 let tmp = tempdir().expect("tempdir");
2075 let ctx = ToolContext::new(tmp.path().to_path_buf());
2076
2077 WriteFileTool
2078 .execute(json!({"file_path": "note.txt", "content": "first\n"}), &ctx)
2079 .await
2080 .expect("write must accept file_path");
2081 assert_eq!(
2082 fs::read_to_string(tmp.path().join("note.txt")).expect("read"),
2083 "first\n"
2084 );
2085
2086 let read = ReadFileTool
2087 .execute(json!({"file_path": "note.txt"}), &ctx)
2088 .await
2089 .expect("read must accept file_path");
2090 assert!(read.content.contains("first"));
2091
2092 EditFileTool
2093 .execute(
2094 json!({"file_path": "note.txt", "search": "first", "replace": "second"}),
2095 &ctx,
2096 )
2097 .await
2098 .expect("edit must accept file_path");
2099 assert_eq!(
2100 fs::read_to_string(tmp.path().join("note.txt")).expect("read"),
2101 "second\n"
2102 );
2103 }
2104
2105 /// `offset`/`limit` name the same read window as `start_line`/`max_lines`.
2106 /// Before they were translated, a wrong guess was dropped and the model
2107 /// silently got the head of the file instead of the window it asked for.
2108 #[tokio::test]
2109 async fn read_file_accepts_offset_and_limit_aliases() {
2110 let tmp = tempdir().expect("tempdir");
2111 let ctx = ToolContext::new(tmp.path().to_path_buf());
2112 let body: String = (1..=20).map(|n| format!("line {n}\n")).collect();
2113 fs::write(tmp.path().join("many.txt"), &body).expect("write");
2114
2115 let aliased = ReadFileTool
2116 .execute(json!({"path": "many.txt", "offset": 5, "limit": 3}), &ctx)
2117 .await
2118 .expect("offset/limit must be honored");
2119 let canonical = ReadFileTool
2120 .execute(
2121 json!({"path": "many.txt", "start_line": 5, "max_lines": 3}),
2122 &ctx,
2123 )
2124 .await
2125 .expect("canonical read");
2126
2127 assert_eq!(
2128 aliased.content, canonical.content,
2129 "aliases must select the same window as the canonical names"
2130 );
2131 assert!(
2132 aliased.content.contains("line 5") && !aliased.content.contains("line 1\n"),
2133 "must start at the requested offset: {}",
2134 aliased.content
2135 );
2136 }
2137
2138 /// #5209 — unknown keys on edit hard-error even when required fields are present.
2139 #[tokio::test]
2140 async fn edit_file_rejects_unexpected_parameter_names() {
2141 let tmp = tempdir().expect("tempdir");
2142 let ctx = ToolContext::new(tmp.path().to_path_buf());
2143 let path = tmp.path().join("doc.md");
2144 fs::write(&path, "hello\n").expect("write");
2145 read_before_edit(&ctx, "doc.md").await;
2146
2147 let err = EditFileTool
2148 .execute(
2149 json!({
2150 "path": "doc.md",
2151 "search": "hello",
2152 "replace": "hi",
2153 "mystery": true,
2154 }),
2155 &ctx,
2156 )
2157 .await
2158 .expect_err("unexpected params must hard-error");
2159 let msg = err.to_string();
2160 assert!(
2161 msg.contains("unexpected") && msg.contains("mystery"),
2162 "must name unexpected key: {msg}"
2163 );
2164 assert_eq!(fs::read_to_string(&path).expect("read"), "hello\n");
2165 }
2166
2167 #[test]
2168 fn edit_payload_allows_same_brace_delta_unbalanced_fragment() {
2169 // Same-delta unbalanced fragment: both sides open one more brace than
2170 // they close (typical mid-block edit).
2171 let search = " handler({\n a: 1,\n";
2172 let replace = " handler({\n a: 1,\n b: 2,\n";
2173 assert!(
2174 edit_payload_looks_corrupted(search, replace).is_none(),
2175 "same brace delta unbalanced fragment must be allowed"
2176 );
2177
2178 // Unbalanced-to-unbalanced with the same closing delta (e.g. near `});`).
2179 let search = " done();\n });\n";
2180 let replace = " done();\n cleanup();\n });\n";
2181 assert!(
2182 edit_payload_looks_corrupted(search, replace).is_none(),
2183 "unbalanced-to-unbalanced with same delta (e.g. around `}});`) must be allowed"
2184 );
2185 }
2186
2187 #[test]
2188 fn edit_payload_rejects_divergent_brace_delta() {
2189 let search = "fn f() {\n body\n}\n";
2190 let replace = "fn f() {\n body\n"; // lost closing brace
2191 let reason =
2192 edit_payload_looks_corrupted(search, replace).expect("divergent brace delta must reject");
2193 assert!(
2194 reason.contains("brace balance") || reason.contains("unbalanced"),
2195 "reason should mention brace balance: {reason}"
2196 );
2197 }
2198
2199 #[test]
2200 fn edit_payload_still_rejects_empty_bracket_collapse() {
2201 let search = r#"SendMessageOutcome::Finished {
2202 status: TurnOutcomeStatus::Interrupted,
2203 ..
2204 } => self.pause_goal_after_interruption().await,"#;
2205 let replace = "[
2206
2207 ] => {},";
2208 assert!(
2209 edit_payload_looks_corrupted(search, replace).is_some(),
2210 "empty bracket collapse must still fail closed"
2211 );
2212 }
2213
2214 #[test]
2215 fn edit_payload_still_rejects_extreme_shrinkage() {
2216 // Many nested braces in search, collapsed to a tiny stub that lost opens.
2217 let search = "fn long_match_arm() {\n".to_string()
2218 + &" if cond { statement(); }\n".repeat(20)
2219 + "}\n";
2220 let replace = "fn long_match_arm() {}\n";
2221 assert!(
2222 search.len() >= 80,
2223 "fixture must be long enough for shrinkage guard"
2224 );
2225 assert!(
2226 edit_payload_looks_corrupted(&search, replace).is_some(),
2227 "extreme shrinkage with lost braces must still fail closed"
2228 );
2229 }
2230
2231 #[tokio::test]
2232 async fn test_list_dir_tool() {
2233 let tmp = tempdir().expect("tempdir");
2234 let ctx = ToolContext::new(tmp.path().to_path_buf());
2235
2236 // Create some files and directories
2237 fs::write(tmp.path().join("file1.txt"), "").expect("write");
2238 fs::write(tmp.path().join("file2.txt"), "").expect("write");
2239 fs::create_dir(tmp.path().join("subdir")).expect("mkdir");
2240
2241 let tool = ListDirTool;
2242 let result = tool.execute(json!({}), &ctx).await.expect("execute");
2243
2244 assert!(result.success);
2245 assert!(result.content.contains("file1.txt"));
2246 assert!(result.content.contains("file2.txt"));
2247 assert!(result.content.contains("subdir"));
2248 let entries: Value = serde_json::from_str(&result.content).expect("list_dir json");
2249 assert!(entries.as_array().expect("entries").iter().any(|entry| {
2250 entry.get("name").and_then(Value::as_str) == Some("subdir")
2251 && entry.get("is_dir").and_then(Value::as_bool) == Some(true)
2252 }));
2253 }
2254
2255 #[tokio::test]
2256 async fn test_list_dir_with_path() {
2257 let tmp = tempdir().expect("tempdir");
2258 let ctx = ToolContext::new(tmp.path().to_path_buf());
2259
2260 // Create a subdirectory with files
2261 let subdir = tmp.path().join("mydir");
2262 fs::create_dir(&subdir).expect("mkdir");
2263 fs::write(subdir.join("nested.txt"), "").expect("write");
2264
2265 let tool = ListDirTool;
2266 let result = tool
2267 .execute(json!({"path": "mydir"}), &ctx)
2268 .await
2269 .expect("execute");
2270
2271 assert!(result.success);
2272 assert!(result.content.contains("nested.txt"));
2273 }
2274
2275 #[tokio::test]
2276 async fn test_list_dir_small_dir_keeps_plain_array_response() {
2277 let tmp = tempdir().expect("tempdir");
2278 let ctx = ToolContext::new(tmp.path().to_path_buf());
2279 fs::write(tmp.path().join("only.txt"), "").expect("write");
2280
2281 let tool = ListDirTool;
2282 let result = tool.execute(json!({}), &ctx).await.expect("execute");
2283
2284 let parsed: Value = serde_json::from_str(&result.content).expect("json");
2285 assert!(
2286 parsed.is_array(),
2287 "small dirs must keep the historical array shape: {parsed}"
2288 );
2289 assert_eq!(parsed.as_array().unwrap().len(), 1);
2290 }
2291
2292 #[tokio::test]
2293 async fn test_list_dir_caps_entries_with_truncation_metadata() {
2294 let tmp = tempdir().expect("tempdir");
2295 let ctx = ToolContext::new(tmp.path().to_path_buf());
2296 let extra = 7;
2297 for i in 0..LIST_DIR_MAX_ENTRIES + extra {
2298 fs::write(tmp.path().join(format!("f{i:04}.txt")), "").expect("write");
2299 }
2300
2301 let tool = ListDirTool;
2302 let result = tool.execute(json!({}), &ctx).await.expect("execute");
2303
2304 let parsed: Value = serde_json::from_str(&result.content).expect("json");
2305 assert!(parsed.is_object(), "oversized dirs return an object");
2306 assert_eq!(parsed["truncated"], json!(true));
2307 assert_eq!(
2308 parsed["listed_entries"].as_u64().unwrap() as usize,
2309 LIST_DIR_MAX_ENTRIES
2310 );
2311 assert_eq!(
2312 parsed["total_entries"].as_u64().unwrap() as usize,
2313 LIST_DIR_MAX_ENTRIES + extra
2314 );
2315 assert_eq!(
2316 parsed["entries"].as_array().unwrap().len(),
2317 LIST_DIR_MAX_ENTRIES
2318 );
2319 }
2320
2321 #[tokio::test]
2322 async fn test_list_dir_respects_cancel_token() {
2323 let tmp = tempdir().expect("tempdir");
2324 fs::write(tmp.path().join("file.txt"), "").expect("write");
2325 let cancel_token = CancellationToken::new();
2326 cancel_token.cancel();
2327 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);
2328
2329 let tool = ListDirTool;
2330 let err = tool
2331 .execute(json!({}), &ctx)
2332 .await
2333 .expect_err("cancelled list_dir should return an error");
2334
2335 assert!(
2336 format!("{err:?}").contains("cancelled"),
2337 "unexpected error: {err:?}"
2338 );
2339 }
2340
2341 #[tokio::test]
2342 async fn test_list_dir_blocking_wrapper_reports_timeout() {
2343 let err = run_blocking_list_dir(Duration::from_millis(1), None, || {
2344 std::thread::sleep(Duration::from_millis(50));
2345 Ok(Value::Array(Vec::new()))
2346 })
2347 .await
2348 .expect_err("slow list_dir worker should time out");
2349
2350 assert!(
2351 matches!(err, ToolError::Timeout { seconds: 1 }),
2352 "unexpected error: {err:?}"
2353 );
2354 }
2355
2356 #[test]
2357 fn test_read_file_tool_properties() {
2358 let tool = ReadFileTool;
2359 assert_eq!(tool.name(), "read_file");
2360 assert!(tool.is_read_only());
2361 assert!(tool.is_sandboxable());
2362 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
2363 }
2364
2365 #[test]
2366 fn test_write_file_tool_properties() {
2367 let tool = WriteFileTool;
2368 assert_eq!(tool.name(), "write_file");
2369 assert!(!tool.is_read_only());
2370 assert!(tool.is_sandboxable());
2371 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
2372 }
2373
2374 #[test]
2375 fn test_edit_file_tool_properties() {
2376 let tool = EditFileTool;
2377 assert_eq!(tool.name(), "edit_file");
2378 assert!(!tool.is_read_only());
2379 assert!(tool.is_sandboxable());
2380 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
2381 assert!(tool.description().contains("exact search/replace"));
2382 assert!(tool.description().contains("structural"));
2383 }
2384
2385 #[test]
2386 fn test_list_dir_tool_properties() {
2387 let tool = ListDirTool;
2388 assert_eq!(tool.name(), "list_dir");
2389 assert!(tool.is_read_only());
2390 assert!(tool.is_sandboxable());
2391 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
2392 }
2393
2394 #[test]
2395 fn test_parallel_support_flags() {
2396 let read_tool = ReadFileTool;
2397 let list_tool = ListDirTool;
2398 let write_tool = WriteFileTool;
2399
2400 assert!(read_tool.supports_parallel());
2401 assert!(list_tool.supports_parallel());
2402 assert!(!write_tool.supports_parallel());
2403 }
2404
2405 #[test]
2406 fn test_input_schemas() {
2407 // Verify all tools have valid JSON schemas
2408 let read_schema = ReadFileTool.input_schema();
2409 assert!(read_schema.get("type").is_some());
2410 assert!(read_schema.get("properties").is_some());
2411
2412 let write_schema = WriteFileTool.input_schema();
2413 let required = write_schema
2414 .get("required")
2415 .and_then(|value| value.as_array())
2416 .expect("write schema should include required array");
2417 assert!(required.iter().any(|v| v.as_str() == Some("path")));
2418 assert!(required.iter().any(|v| v.as_str() == Some("content")));
2419
2420 let edit_schema = EditFileTool.input_schema();
2421 let required = edit_schema
2422 .get("required")
2423 .and_then(|value| value.as_array())
2424 .expect("edit schema should include required array");
2425 let required_fields: Vec<_> = required.iter().filter_map(|value| value.as_str()).collect();
2426 assert_eq!(required_fields, vec!["path", "search", "replace"]);
2427 assert!(!required_fields.contains(&"fuzz"));
2428 // `fuzz` was never read by `edit` — it was parsed into a discarded
2429 // binding while the schema advertised it. An unimplemented parameter has
2430 // no place in a schema the model is asked to trust.
2431 assert!(edit_schema["properties"].get("fuzz").is_none());
2432 let search_desc = edit_schema["properties"]["search"]["description"]
2433 .as_str()
2434 .expect("search description");
2435 assert!(search_desc.contains("Exact text"));
2436 assert!(search_desc.contains("whitespace"));
2437
2438 let list_schema = ListDirTool.input_schema();
2439 let required = list_schema
2440 .get("required")
2441 .and_then(|value| value.as_array())
2442 .expect("list schema should include required array");
2443 assert!(required.is_empty()); // path is optional
2444 }
2445
2446 // === Content-hash edit guards (#3979) ===
2447 //
2448 // The guard's whole value is that a stale hash stops the write *before* it
2449 // happens, so every rejection case asserts the file is byte-for-byte
2450 // unchanged — a clear error over a corrupted file is the point.
2451
2452 /// Read the hash the model would actually see, the way the model sees it:
2453 /// parsed out of the tool result's content, never out of its metadata.
2454 async fn reported_content_hash(ctx: &ToolContext, path: &str) -> String {
2455 let result = ReadFileTool
2456 .execute(json!({ "path": path }), ctx)
2457 .await
2458 .expect("read");
2459 let (_, rest) = result
2460 .content
2461 .split_once("content_hash=\"")
2462 .unwrap_or_else(|| panic!("read output carries no content_hash: {}", result.content));
2463 let (hash, _) = rest.split_once('"').expect("terminated content_hash");
2464 hash.to_string()
2465 }
2466
2467 #[tokio::test]
2468 async fn reported_hash_verifies_against_the_file_contents() {
2469 let tmp = tempdir().expect("tempdir");
2470 let ctx = ToolContext::new(tmp.path().to_path_buf());
2471 let body = "alpha\nbeta\ngamma\n";
2472 fs::write(tmp.path().join("doc.txt"), body).expect("write");
2473
2474 let reported = reported_content_hash(&ctx, "doc.txt").await;
2475 assert_eq!(reported, super::content_hash(body.as_bytes()));
2476 assert!(reported.starts_with("sha256:"), "{reported}");
2477 assert_eq!(reported.len(), "sha256:".len() + 64, "{reported}");
2478 }
2479
2480 #[tokio::test]
2481 async fn windowed_read_reports_the_whole_file_hash_not_the_window() {
2482 let tmp = tempdir().expect("tempdir");
2483 let ctx = ToolContext::new(tmp.path().to_path_buf());
2484 let body: String = (1..=40).map(|n| format!("line {n}\n")).collect();
2485 fs::write(tmp.path().join("many.txt"), &body).expect("write");
2486
2487 // A partial read must still hand back a guard for the *file*, or the
2488 // model could only ever guard edits to files it read in full.
2489 let result = ReadFileTool
2490 .execute(
2491 json!({ "path": "many.txt", "start_line": 5, "max_lines": 3 }),
2492 &ctx,
2493 )
2494 .await
2495 .expect("read");
2496 assert!(
2497 result.content.contains("shown_lines=\"5-7\""),
2498 "{}",
2499 result.content
2500 );
2501 assert!(
2502 result.content.contains(&format!(
2503 "content_hash=\"{}\"",
2504 super::content_hash(body.as_bytes())
2505 )),
2506 "{}",
2507 result.content
2508 );
2509 }
2510
2511 #[tokio::test]
2512 async fn edit_with_matching_expected_hash_proceeds() {
2513 let tmp = tempdir().expect("tempdir");
2514 let ctx = ToolContext::new(tmp.path().to_path_buf());
2515 let path = tmp.path().join("doc.txt");
2516 fs::write(&path, "alpha\nbeta\n").expect("write");
2517
2518 let hash = reported_content_hash(&ctx, "doc.txt").await;
2519 EditFileTool
2520 .execute(
2521 json!({
2522 "path": "doc.txt",
2523 "search": "alpha",
2524 "replace": "delta",
2525 "expected_hash": hash,
2526 }),
2527 &ctx,
2528 )
2529 .await
2530 .expect("matching hash must not block the edit");
2531
2532 assert_eq!(fs::read_to_string(&path).expect("read"), "delta\nbeta\n");
2533 }
2534
2535 #[tokio::test]
2536 async fn edit_with_stale_expected_hash_rejects_without_writing() {
2537 let tmp = tempdir().expect("tempdir");
2538 let ctx = ToolContext::new(tmp.path().to_path_buf());
2539 let path = tmp.path().join("doc.txt");
2540 fs::write(&path, "alpha\nbeta\n").expect("write");
2541
2542 let stale = reported_content_hash(&ctx, "doc.txt").await;
2543 // Someone else edits the file between the read and the edit.
2544 fs::write(&path, "alpha\nbeta\ngamma\n").expect("concurrent write");
2545 // Re-read so the *other* staleness gate (mtime/size) cannot be what
2546 // rejects this — the hash must be doing the work.
2547 read_before_edit(&ctx, "doc.txt").await;
2548
2549 let err = EditFileTool
2550 .execute(
2551 json!({
2552 "path": "doc.txt",
2553 "search": "alpha",
2554 "replace": "delta",
2555 "expected_hash": stale,
2556 }),
2557 &ctx,
2558 )
2559 .await
2560 .expect_err("stale hash must reject");
2561
2562 let message = err.to_string();
2563 assert!(message.contains("changed since it was read"), "{message}");
2564 assert!(
2565 message.contains("re-read") || message.contains("action=\"read\""),
2566 "{message}"
2567 );
2568 assert_eq!(
2569 fs::read_to_string(&path).expect("read"),
2570 "alpha\nbeta\ngamma\n",
2571 "a rejected edit must not modify the file"
2572 );
2573 }
2574
2575 #[tokio::test]
2576 async fn edit_without_expected_hash_is_unchanged() {
2577 let tmp = tempdir().expect("tempdir");
2578 let ctx = ToolContext::new(tmp.path().to_path_buf());
2579 let path = tmp.path().join("doc.txt");
2580 fs::write(&path, "alpha\nbeta\n").expect("write");
2581 read_before_edit(&ctx, "doc.txt").await;
2582
2583 EditFileTool
2584 .execute(
2585 json!({ "path": "doc.txt", "search": "alpha", "replace": "delta" }),
2586 &ctx,
2587 )
2588 .await
2589 .expect("absent expected_hash keeps the pre-#3979 behavior");
2590
2591 assert_eq!(fs::read_to_string(&path).expect("read"), "delta\nbeta\n");
2592 }
2593
2594 #[tokio::test]
2595 async fn write_with_stale_expected_hash_rejects_without_writing() {
2596 let tmp = tempdir().expect("tempdir");
2597 let ctx = ToolContext::new(tmp.path().to_path_buf());
2598 let path = tmp.path().join("doc.txt");
2599 fs::write(&path, "original\n").expect("write");
2600
2601 let stale = super::content_hash(b"something else entirely\n");
2602 let err = WriteFileTool
2603 .execute(
2604 json!({ "path": "doc.txt", "content": "clobbered\n", "expected_hash": stale }),
2605 &ctx,
2606 )
2607 .await
2608 .expect_err("stale hash must reject");
2609
2610 assert!(
2611 err.to_string().contains("changed since it was read"),
2612 "{err}"
2613 );
2614 assert_eq!(
2615 fs::read_to_string(&path).expect("read"),
2616 "original\n",
2617 "a rejected write must not modify the file"
2618 );
2619 }
2620
2621 #[tokio::test]
2622 async fn write_with_matching_expected_hash_proceeds() {
2623 let tmp = tempdir().expect("tempdir");
2624 let ctx = ToolContext::new(tmp.path().to_path_buf());
2625 let path = tmp.path().join("doc.txt");
2626 fs::write(&path, "original\n").expect("write");
2627
2628 let hash = reported_content_hash(&ctx, "doc.txt").await;
2629 WriteFileTool
2630 .execute(
2631 json!({ "path": "doc.txt", "content": "replaced\n", "expected_hash": hash }),
2632 &ctx,
2633 )
2634 .await
2635 .expect("matching hash must not block the write");
2636
2637 assert_eq!(fs::read_to_string(&path).expect("read"), "replaced\n");
2638 }
2639
2640 #[tokio::test]
2641 async fn write_with_expected_hash_on_a_missing_file_fails_closed() {
2642 let tmp = tempdir().expect("tempdir");
2643 let ctx = ToolContext::new(tmp.path().to_path_buf());
2644
2645 // There is no snapshot to verify, so honoring the guard is impossible.
2646 // Creating the file anyway would silently give back less safety than the
2647 // caller asked for.
2648 let err = WriteFileTool
2649 .execute(
2650 json!({
2651 "path": "new.txt",
2652 "content": "x\n",
2653 "expected_hash": super::content_hash(b"anything"),
2654 }),
2655 &ctx,
2656 )
2657 .await
2658 .expect_err("guarded write to a missing file must fail closed");
2659
2660 assert!(err.to_string().contains("does not exist"), "{err}");
2661 assert!(
2662 !tmp.path().join("new.txt").exists(),
2663 "a rejected write must not create the file"
2664 );
2665 }
2666
2667 #[tokio::test]
2668 async fn write_without_expected_hash_still_creates_files() {
2669 let tmp = tempdir().expect("tempdir");
2670 let ctx = ToolContext::new(tmp.path().to_path_buf());
2671
2672 WriteFileTool
2673 .execute(json!({ "path": "new.txt", "content": "x\n" }), &ctx)
2674 .await
2675 .expect("absent expected_hash keeps the pre-#3979 behavior");
2676
2677 assert_eq!(
2678 fs::read_to_string(tmp.path().join("new.txt")).expect("read"),
2679 "x\n"
2680 );
2681 }
2682
2683 #[tokio::test]
2684 async fn expected_hash_is_advertised_on_every_mutating_action() {
2685 for schema in [
2686 WriteFileTool.input_schema(),
2687 EditFileTool.input_schema(),
2688 crate::tools::apply_patch::ApplyPatchTool.input_schema(),
2689 ] {
2690 let description = schema["properties"]["expected_hash"]["description"]
2691 .as_str()
2692 .expect("expected_hash must be advertised");
2693 assert!(description.contains("content_hash"), "{description}");
2694 }
2695 }
2696
2697 /// S1: the in-process read tools are the *only* enforcement point for
2698 /// `read_file`/`read`/`read_media` — they call `std::fs` inside the harness
2699 /// process, so `sandbox-exec` and `bwrap` never see them. This asserts the
2700 /// refusal is an explicit permission error, not an empty result, and that it
2701 /// applies to the built-in defaults with no config required.
2702 #[test]
2703 fn read_tools_refuse_paths_under_the_default_sandbox_read_denylist() {
2704 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2705 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
2706 // snapshots the home directory the first time it is built, so pointing
2707 // `HOME` at a fixture here could never match the cached table. What this
2708 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
2709 // not a home of its own.
2710 let _env_lock = crate::test_support::lock_test_env();
2711 let Some(home) = dirs::home_dir() else {
2712 // No home directory: only machine-wide rules exist and the assertion
2713 // below would be vacuous. Skip rather than pretend to have evidence.
2714 return;
2715 };
2716
2717 let error = enforce_read_denylist(&home.join(".ssh").join("id_ed25519"), "read_file")
2718 .expect_err("~/.ssh must be denied by the built-in defaults");
2719 assert!(
2720 matches!(error, ToolError::PermissionDenied { .. }),
2721 "a denied read must be an explicit refusal, never an empty or missing-file result: {error:?}"
2722 );
2723 let message = error.to_string();
2724 assert!(message.contains("read deny-list"), "{message}");
2725 assert!(
2726 message.contains("sandbox_read_denylist_exempt"),
2727 "{message}"
2728 );
2729
2730 // Ordinary source files stay readable — a coding agent must still be able
2731 // to read the user's tree, which is the whole point of the tool.
2732 let temporary = tempfile::tempdir().expect("tempdir");
2733 let source = temporary.path().join("main.rs");
2734 std::fs::write(&source, "fn main() {}\n").expect("fixture");
2735 assert!(enforce_read_denylist(&source, "read_file").is_ok());
2736 }
2737
2738 /// A symlink whose own name is innocuous but whose target is a credential
2739 /// store must be refused by the target. A deny-list a symlink walks around is
2740 /// theater, and `resolve_path` deliberately *permits* a workspace symlink that
2741 /// resolves outside the workspace.
2742 #[cfg(unix)]
2743 #[allow(clippy::await_holding_lock)]
2744 #[tokio::test]
2745 async fn read_file_refuses_a_workspace_symlink_pointing_at_a_denied_tree() {
2746 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2747 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
2748 // snapshots the home directory the first time it is built, so pointing
2749 // `HOME` at a fixture here could never match the cached table. What this
2750 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
2751 // not a home of its own.
2752 let _env_lock = crate::test_support::lock_test_env();
2753 let Some(home) = dirs::home_dir() else {
2754 return;
2755 };
2756 let ssh = home.join(".ssh");
2757 if !ssh.is_dir() {
2758 // Nothing to point at; a fabricated pass here would be worse than a skip.
2759 return;
2760 }
2761
2762 let workspace = tempfile::tempdir().expect("tempdir");
2763 let link = workspace.path().join("notes.txt");
2764 std::os::unix::fs::symlink(&ssh, &link).expect("symlink");
2765
2766 let error = enforce_read_denylist(&link, "read_file")
2767 .expect_err("a symlink into ~/.ssh must be refused by its target");
2768 let message = error.to_string();
2769 assert!(message.contains("symlink"), "{message}");
2770 // The rule's *label* ("SSH keys (~/.ssh)") is named on purpose — the user
2771 // needs to know which rule to exempt. What must never appear is the
2772 // resolved absolute path, which is the location the caller was fishing for.
2773 assert!(
2774 !message.contains(&ssh.display().to_string()),
2775 "the refusal must not hand back the secret's resolved location: {message}"
2776 );
2777 }
2778
2779 /// F1: `list_dir ~/.ssh` used to hand back the key file names — enumerating a
2780 /// denied directory is a read of it, exactly what Seatbelt's
2781 /// `deny file-read*` blocks at the OS layer.
2782 #[allow(clippy::await_holding_lock)]
2783 #[tokio::test]
2784 async fn list_dir_refuses_to_enumerate_a_denied_directory() {
2785 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2786 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
2787 // snapshots the home directory the first time it is built, so pointing
2788 // `HOME` at a fixture here could never match the cached table. What this
2789 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
2790 // not a home of its own.
2791 let _env_lock = crate::test_support::lock_test_env();
2792 let ctx = ToolContext::new(std::env::temp_dir());
2793
2794 // Deterministic anchor independent of the machine's home layout: the
2795 // `.env` filename rule denies any path whose file name is `.env`, so a
2796 // directory by that name is a refused listing too.
2797 let holder = tempfile::tempdir().expect("tempdir");
2798 let env_dir = holder.path().join("project");
2799 std::fs::create_dir_all(env_dir.join(".env")).expect("mkdir");
2800 let error = ListDirTool
2801 .execute(json!({ "path": env_dir.join(".env") }), &ctx)
2802 .await
2803 .expect_err("a directory named `.env` is denied by the filename rule");
2804 assert!(
2805 matches!(error, ToolError::PermissionDenied { .. }),
2806 "enumeration of a denied path must be an explicit refusal: {error:?}"
2807 );
2808
2809 let Some(home) = dirs::home_dir() else {
2810 return;
2811 };
2812 let ssh = home.join(".ssh");
2813 if !ssh.is_dir() {
2814 return;
2815 }
2816 let error = ListDirTool
2817 .execute(json!({ "path": ssh }), &ctx)
2818 .await
2819 .expect_err("`list_dir ~/.ssh` must not return the key file names");
2820 assert!(
2821 matches!(error, ToolError::PermissionDenied { .. }),
2822 "expected a permission refusal, got: {error:?}"
2823 );
2824 let message = error.to_string();
2825 assert!(message.contains("read deny-list"), "{message}");
2826 }
2827
2828 /// F2: the refusal must name the path as the caller spelled it. When a
2829 /// workspace symlink points into a denied tree, `resolve_path` hands the guard
2830 /// the secret's resolved absolute location first, and a denial raised on that
2831 /// resolved path answers the probe ("where does this link really go?") in the
2832 /// error text. The raw-spelling check runs before resolution, so it wins.
2833 #[cfg(unix)]
2834 #[allow(clippy::await_holding_lock)]
2835 #[tokio::test]
2836 async fn read_file_refusal_names_the_callers_spelling_not_the_symlink_target() {
2837 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2838 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
2839 // snapshots the home directory the first time it is built, so pointing
2840 // `HOME` at a fixture here could never match the cached table. What this
2841 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
2842 // not a home of its own.
2843 let _env_lock = crate::test_support::lock_test_env();
2844 let Some(home) = dirs::home_dir() else {
2845 return;
2846 };
2847 let ssh = home.join(".ssh");
2848 if !ssh.is_dir() {
2849 return;
2850 }
2851
2852 let workspace = tempfile::tempdir().expect("tempdir");
2853 let link = workspace.path().join("notes.txt");
2854 std::os::unix::fs::symlink(&ssh, &link).expect("symlink");
2855
2856 let ctx = ToolContext::new(workspace.path().to_path_buf());
2857 let error = ReadFileTool
2858 .execute(json!({ "path": link }), &ctx)
2859 .await
2860 .expect_err("a symlink into ~/.ssh must be refused");
2861 assert!(
2862 matches!(error, ToolError::PermissionDenied { .. }),
2863 "expected a permission refusal, got: {error:?}"
2864 );
2865 let message = error.to_string();
2866 assert!(
2867 message.contains("notes.txt"),
2868 "the refusal must name the caller's spelling: {message}"
2869 );
2870 assert!(
2871 !message.contains(&ssh.display().to_string()),
2872 "the refusal must not reveal the symlink target's location: {message}"
2873 );
2874 }
2875
2876 // Reads process-global `HOME` (via `effective_home_dir`) and then resolves `~`
2877 // again through the tool, so it must hold the env lock for the whole span: any
2878 // sibling that rebinds `HOME` between those two reads makes the fixture path
2879 // stop matching the tilde path.
2880 #[allow(clippy::await_holding_lock)]
2881 #[tokio::test]
2882 async fn read_and_write_file_home_path_in_allowed_real_home_fixture() {
2883 let _env_lock = crate::test_support::lock_test_env();
2884 let home = tempfile::tempdir().expect("home tempdir");
2885 let _home = crate::test_support::EnvVarGuard::set("HOME", home.path());
2886 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
2887 let real_home = crate::config::effective_home_dir().expect("test home must be available");
2888 let home_fixture = tempfile::Builder::new()
2889 .prefix("cw_home_tool_fixture_")
2890 .tempdir_in(&real_home)
2891 .expect("create fixture inside test home");
2892
2893 let test_file = home_fixture.path().join("home_note.txt");
2894 let rel = test_file
2895 .strip_prefix(&real_home)
2896 .expect("fixture is below test home");
2897 let tilde_path = format!("~/{}", rel.to_string_lossy());
2898
2899 let ctx = ToolContext::new(home_fixture.path().to_path_buf());
2900
2901 // 1. Write content to home-relative path
2902 let write_result = WriteFileTool
2903 .execute(
2904 json!({
2905 "path": &tilde_path,
2906 "content": "initial home content\n"
2907 }),
2908 &ctx,
2909 )
2910 .await
2911 .expect("write_file to home-relative path inside workspace should succeed");
2912 assert!(write_result.success);
2913
2914 // 2. Read content back and verify content and hash
2915 let read_result = ReadFileTool
2916 .execute(json!({ "path": &tilde_path }), &ctx)
2917 .await
2918 .expect("read_file from home-relative path should succeed");
2919 assert!(read_result.success);
2920 assert!(read_result.content.contains("initial home content\n"));
2921 let expected_hash = crate::tools::file::content_hash(b"initial home content\n");
2922 assert!(read_result.content.contains(&expected_hash));
2923
2924 // 3. Edit content with read-before-write consistency
2925 let edit_result = EditFileTool
2926 .execute(
2927 json!({
2928 "path": &tilde_path,
2929 "search": "initial home",
2930 "replace": "updated home"
2931 }),
2932 &ctx,
2933 )
2934 .await
2935 .expect("edit_file on home-relative path should succeed after read");
2936 assert!(edit_result.success);
2937
2938 // 4. Verify updated read
2939 let updated_read = ReadFileTool
2940 .execute(json!({ "path": &tilde_path }), &ctx)
2941 .await
2942 .expect("read_file after edit should succeed");
2943 assert!(updated_read.content.contains("updated home content\n"));
2944
2945 // 5. list_dir on home-relative directory
2946 let dir_rel = home_fixture.path().strip_prefix(&real_home).unwrap();
2947 let dir_tilde = format!("~/{}", dir_rel.to_string_lossy());
2948 let list_result = ListDirTool
2949 .execute(json!({ "path": &dir_tilde }), &ctx)
2950 .await
2951 .expect("list_dir on home-relative directory should succeed");
2952 assert!(list_result.success);
2953 assert!(list_result.content.contains("home_note.txt"));
2954 }
2955
2956 #[tokio::test]
2957 async fn read_file_home_path_restricted_refusal() {
2958 let workspace = tempfile::tempdir().expect("tempdir");
2959 let ctx = ToolContext::new(workspace.path().to_path_buf());
2960
2961 let error = ReadFileTool
2962 .execute(
2963 json!({ "path": "~/untrusted_outside_workspace_file_98765.txt" }),
2964 &ctx,
2965 )
2966 .await
2967 .expect_err("home path outside workspace without trust must be refused");
2968 assert!(
2969 matches!(
2970 error,
2971 ToolError::PathEscape { .. } | ToolError::ExecutionFailed { .. }
2972 ),
2973 "expected path escape or execution failed, got: {error:?}"
2974 );
2975
2976 let write_error = WriteFileTool
2977 .execute(
2978 json!({
2979 "path": "~/untrusted_outside_workspace_file_98765.txt",
2980 "content": "illegal write"
2981 }),
2982 &ctx,
2983 )
2984 .await
2985 .expect_err("write to untrusted home path must be refused");
2986 assert!(
2987 matches!(
2988 write_error,
2989 ToolError::PathEscape { .. } | ToolError::ExecutionFailed { .. }
2990 ),
2991 "expected path escape or execution failed, got: {write_error:?}"
2992 );
2993 }
2994
2995 #[allow(clippy::await_holding_lock)]
2996 #[tokio::test]
2997 async fn read_file_home_path_trusted_external_allowance() {
2998 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2999 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
3000 // snapshots the home directory the first time it is built, so pointing
3001 // `HOME` at a fixture here could never match the cached table. What this
3002 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
3003 // not a home of its own.
3004 let _env_lock = crate::test_support::lock_test_env();
3005 let real_home = crate::config::effective_home_dir().expect("test home must be available");
3006 let trusted_fixture = tempfile::Builder::new()
3007 .prefix("cw_home_trusted_fixture_")
3008 .tempdir_in(&real_home)
3009 .expect("create fixture inside test home");
3010
3011 let test_file = trusted_fixture.path().join("external.txt");
3012 std::fs::write(&test_file, "external trusted data\n").expect("write external");
3013 let rel = test_file
3014 .strip_prefix(&real_home)
3015 .expect("fixture is below test home");
3016 let tilde_path = format!("~/{}", rel.to_string_lossy());
3017
3018 let workspace = tempfile::tempdir().expect("tempdir");
3019 let canonical_trusted = trusted_fixture
3020 .path()
3021 .canonicalize()
3022 .unwrap_or_else(|_| trusted_fixture.path().to_path_buf());
3023 let ctx = ToolContext::new(workspace.path().to_path_buf())
3024 .with_trusted_external_paths(vec![canonical_trusted]);
3025
3026 let result = ReadFileTool
3027 .execute(json!({ "path": &tilde_path }), &ctx)
3028 .await
3029 .expect("read_file on trusted external home path should succeed");
3030 assert!(result.success);
3031 assert!(result.content.contains("external trusted data\n"));
3032 }
3033
3034 #[tokio::test]
3035 async fn read_file_literal_tilde_stays_literal() {
3036 let workspace = tempfile::tempdir().expect("tempdir");
3037 let literal_dir = workspace.path().join("~");
3038 std::fs::create_dir_all(&literal_dir).expect("create literal ~ dir");
3039 let literal_file = literal_dir.join("payload.txt");
3040 std::fs::write(&literal_file, "literal dir content").expect("write payload");
3041
3042 let ctx = ToolContext::new(workspace.path().to_path_buf());
3043 let result = ReadFileTool
3044 .execute(json!({ "path": "./~/payload.txt" }), &ctx)
3045 .await
3046 .expect("read_file on literal ./~/ path should read from workspace literal ~ directory");
3047 assert!(result.success);
3048 assert!(result.content.contains("literal dir content"));
3049 }
3050
3051 #[tokio::test]
3052 async fn read_file_no_shell_expansion() {
3053 let workspace = tempfile::tempdir().expect("tempdir");
3054 let home_var_dir = workspace.path().join("$HOME");
3055 std::fs::create_dir_all(&home_var_dir).expect("create literal $HOME dir");
3056 let var_file = home_var_dir.join("shell.txt");
3057 std::fs::write(&var_file, "literal $HOME file").expect("write var file");
3058
3059 let ctx = ToolContext::new(workspace.path().to_path_buf());
3060 let result = ReadFileTool
3061 .execute(json!({ "path": "$HOME/shell.txt" }), &ctx)
3062 .await
3063 .expect("read_file on $HOME/file should read from workspace literal $HOME directory without expanding env vars");
3064 assert!(result.success);
3065 assert!(result.content.contains("literal $HOME file"));
3066 }
3067
3068 // Seals `HOME` to a fixture: this test used to resolve `~` against the
3069 // developer's real home, so a sibling test setting `CODEWHALE_HOME` between the
3070 // tilde expansion and the guard's own lookup could flip it to a pass — and the
3071 // failure printed the developer's actual config file, credentials included.
3072 #[allow(clippy::await_holding_lock)]
3073 #[tokio::test]
3074 async fn read_file_denies_home_credential_path() {
3075 let _env_lock = crate::test_support::lock_test_env();
3076 let home = tempfile::tempdir().expect("home tempdir");
3077 let _home = crate::test_support::EnvVarGuard::set("HOME", home.path());
3078 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
3079 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
3080 let _config_path = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
3081 let _legacy_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
3082 fs::create_dir_all(home.path().join(".codewhale")).expect("create fixture home");
3083 fs::write(
3084 home.path().join(".codewhale").join("config.toml"),
3085 "api_key = \"fixture-secret\"\n",
3086 )
3087 .expect("write fixture config");
3088
3089 let workspace = tempfile::tempdir().expect("tempdir");
3090 // Even in trust mode, credential paths must be blocked
3091 let ctx = ToolContext::new(workspace.path().to_path_buf()).with_trust_mode(true);
3092
3093 let error = ReadFileTool
3094 .execute(json!({ "path": "~/.codewhale/config.toml" }), &ctx)
3095 .await
3096 .expect_err("reading ~/.codewhale/config.toml must be denied");
3097 assert!(
3098 matches!(error, ToolError::PermissionDenied { .. }),
3099 "expected permission denied, got: {error:?}"
3100 );
3101 let message = error.to_string();
3102 assert!(
3103 message.contains("cannot expose Codewhale configuration or credential-store files"),
3104 "error message must protect credentials: {message}"
3105 );
3106 }
3107
3108 /// `CODEWHALE_HOME` relocates the runtime home. It must not un-guard the user's
3109 /// real `~/.codewhale/config.toml`: the guard derived every root from
3110 /// `codewhale_home()`, which returns the override when set, so pointing that
3111 /// variable anywhere else left the ambient config (OAuth tokens included)
3112 /// readable in trust mode. `sandbox::read_guard` only covers
3113 /// `~/.codewhale/secrets`, so nothing else was denying this file.
3114 #[allow(clippy::await_holding_lock)]
3115 #[tokio::test]
3116 async fn read_file_denies_ambient_home_config_even_when_codewhale_home_is_relocated() {
3117 let _env_lock = crate::test_support::lock_test_env();
3118 let home = tempfile::tempdir().expect("home tempdir");
3119 let relocated = tempfile::tempdir().expect("relocated home tempdir");
3120 let _home = crate::test_support::EnvVarGuard::set("HOME", home.path());
3121 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
3122 // The override points somewhere else entirely — the ambient store must stay guarded.
3123 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", relocated.path());
3124 let _config_path = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
3125 let _legacy_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
3126
3127 let ambient = home.path().join(".codewhale");
3128 fs::create_dir_all(&ambient).expect("create ambient home");
3129 fs::write(
3130 ambient.join("config.toml"),
3131 "[providers.openai]\napi_key = \"sk-ambient-must-not-leak\"\n",
3132 )
3133 .expect("write ambient config");
3134 fs::write(
3135 ambient.join("config.toml.bak"),
3136 "[providers.openai]\napi_key = \"sk-ambient-backup-must-not-leak\"\n",
3137 )
3138 .expect("write ambient config backup");
3139
3140 let workspace = tempfile::tempdir().expect("tempdir");
3141 let ctx = ToolContext::new(workspace.path().to_path_buf()).with_trust_mode(true);
3142
3143 for name in ["config.toml", "config.toml.bak"] {
3144 let target = ambient.join(name);
3145 let error = ReadFileTool
3146 .execute(json!({ "path": target.to_string_lossy() }), &ctx)
3147 .await
3148 .err()
3149 .unwrap_or_else(|| {
3150 panic!("reading the ambient {name} must be denied despite CODEWHALE_HOME")
3151 });
3152 assert!(
3153 matches!(error, ToolError::PermissionDenied { .. }),
3154 "expected permission denied for {name}, got: {error:?}"
3155 );
3156 assert!(
3157 !error.to_string().contains("must-not-leak"),
3158 "the denial must not echo credential content for {name}"
3159 );
3160 }
3161 }
3162
3163 #[cfg(unix)]
3164 #[allow(clippy::await_holding_lock)]
3165 #[tokio::test]
3166 async fn read_file_refusal_names_home_spelling_not_denied_symlink_target() {
3167 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
3168 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
3169 // snapshots the home directory the first time it is built, so pointing
3170 // `HOME` at a fixture here could never match the cached table. What this
3171 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
3172 // not a home of its own.
3173 let _env_lock = crate::test_support::lock_test_env();
3174 let real_home = crate::config::effective_home_dir().expect("test home must be available");
3175 let home_fixture = tempfile::Builder::new()
3176 .prefix("cw_home_symlink_fixture_")
3177 .tempdir_in(&real_home)
3178 .expect("create fixture inside test home");
3179
3180 let denied_file = home_fixture.path().join(".env");
3181 std::fs::write(&denied_file, "SYNTHETIC_FIXTURE=not-a-secret").expect("create denied fixture");
3182 let link = home_fixture.path().join("ssh_probe");
3183 std::os::unix::fs::symlink(&denied_file, &link).expect("symlink to denied file");
3184
3185 let rel = link
3186 .strip_prefix(&real_home)
3187 .expect("fixture is below test home");
3188 let tilde_path = format!("~/{}", rel.to_string_lossy());
3189
3190 let ctx = ToolContext::new(home_fixture.path().to_path_buf());
3191 let error = ReadFileTool
3192 .execute(json!({ "path": &tilde_path }), &ctx)
3193 .await
3194 .expect_err("symlink pointing to a denied file must be refused");
3195 assert!(
3196 matches!(error, ToolError::PermissionDenied { .. }),
3197 "expected permission refusal, got: {error:?}"
3198 );
3199 let message = error.to_string();
3200 assert!(
3201 message.contains(&tilde_path),
3202 "refusal message must name caller's tilde path ({tilde_path}): {message}"
3203 );
3204 assert!(
3205 !message.contains(&denied_file.display().to_string()),
3206 "refusal message must NOT leak target path ({}): {message}",
3207 denied_file.display()
3208 );
3209 }
3210
3210 lines RUST