返回 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!([{
686 "path": "output.txt",
687 "outcome": "created",
688 "size": "test content".len(),
689 "sha256": crate::hashing::sha256_hex(b"test content"),
690 }])
691 );
692 assert!(
693 mutation["diff"]
694 .as_str()
695 .is_some_and(|diff| diff.contains("--- a/output.txt")),
696 "{mutation}"
697 );
698 assert!(
699 !mutation["diff"]
700 .as_str()
701 .unwrap_or_default()
702 .contains(&tmp.path().display().to_string()),
703 "receipt headers must not expose the resolved host path: {mutation}"
704 );
705
706 // Verify file was written
707 let written = fs::read_to_string(tmp.path().join("output.txt")).expect("read");
708 assert_eq!(written, "test content");
709 }
710
711 #[tokio::test]
712 async fn test_write_file_creates_dirs() {
713 let tmp = tempdir().expect("tempdir");
714 let ctx = ToolContext::new(tmp.path().to_path_buf());
715
716 let tool = WriteFileTool;
717 let result = tool
718 .execute(
719 json!({"path": "subdir/nested/file.txt", "content": "nested content"}),
720 &ctx,
721 )
722 .await
723 .expect("execute");
724
725 assert!(result.success);
726
727 // Verify nested file was created
728 let written = fs::read_to_string(tmp.path().join("subdir/nested/file.txt")).expect("read");
729 assert_eq!(written, "nested content");
730 }
731
732 #[cfg(unix)]
733 #[tokio::test]
734 async fn write_file_tool_new_file_matches_standard_creation_mode() {
735 use std::os::unix::fs::PermissionsExt;
736
737 let tmp = tempdir().expect("tempdir");
738 let ctx = ToolContext::new(tmp.path().to_path_buf());
739
740 let control = tmp.path().join("control.txt");
741 fs::write(&control, b"control").expect("write control");
742
743 WriteFileTool
744 .execute(
745 json!({"path": "created.txt", "content": "from write_file"}),
746 &ctx,
747 )
748 .await
749 .expect("execute");
750
751 let control_mode = fs::metadata(&control)
752 .expect("control metadata")
753 .permissions()
754 .mode()
755 & 0o777;
756 let created_mode = fs::metadata(tmp.path().join("created.txt"))
757 .expect("created metadata")
758 .permissions()
759 .mode()
760 & 0o777;
761 assert_eq!(created_mode, control_mode);
762 }
763
764 #[cfg(unix)]
765 #[tokio::test]
766 async fn write_file_tool_preserves_existing_mode() {
767 use std::os::unix::fs::PermissionsExt;
768
769 let tmp = tempdir().expect("tempdir");
770 let ctx = ToolContext::new(tmp.path().to_path_buf());
771 let path = tmp.path().join("shared.txt");
772 fs::write(&path, b"before").expect("initial write");
773 fs::set_permissions(&path, fs::Permissions::from_mode(0o664)).expect("set shared permissions");
774
775 WriteFileTool
776 .execute(json!({"path": "shared.txt", "content": "after"}), &ctx)
777 .await
778 .expect("execute");
779
780 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
781 assert_eq!(mode, 0o664);
782 assert_eq!(fs::read_to_string(&path).expect("read"), "after");
783 }
784
785 #[tokio::test]
786 async fn write_file_over_crlf_file_preserves_crlf_line_endings() {
787 let tmp = tempdir().expect("tempdir");
788 let ctx = ToolContext::new(tmp.path().to_path_buf());
789 let path = tmp.path().join("crlf.txt");
790 fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");
791
792 WriteFileTool
793 .execute(
794 json!({"path": "crlf.txt", "content": "gamma\ndelta\n"}),
795 &ctx,
796 )
797 .await
798 .expect("execute");
799
800 let written = fs::read(&path).expect("read");
801 assert_eq!(
802 written, b"gamma\r\ndelta\r\n",
803 "write_file must preserve the existing CRLF style, like edit_file"
804 );
805 }
806
807 #[tokio::test]
808 async fn contract_write_over_crlf_file_preserves_crlf_line_endings() {
809 // The contract `write` path (WriteFileTool::execute_contract_write) must
810 // honor the same line-ending policy as the full write_file tool.
811 let tmp = tempdir().expect("tempdir");
812 let ctx = ToolContext::new(tmp.path().to_path_buf());
813 let path = tmp.path().join("crlf.txt");
814 fs::write(&path, b"alpha\r\nbeta\r\n").expect("initial CRLF write");
815
816 WriteFileTool::execute_contract_write(
817 json!({"path": "crlf.txt", "content": "gamma\ndelta\n"}),
818 &ctx,
819 )
820 .await
821 .expect("execute");
822
823 let written = fs::read(&path).expect("read");
824 assert_eq!(
825 written, b"gamma\r\ndelta\r\n",
826 "contract write must preserve the existing CRLF style, like edit_file"
827 );
828 }
829
830 #[test]
831 fn preserve_prior_line_endings_keeps_the_prior_style() {
832 // Existing CRLF file: incoming LF content is re-emitted as CRLF.
833 assert_eq!(
834 preserve_prior_line_endings("gamma\ndelta\n", "alpha\r\nbeta\r\n"),
835 "gamma\r\ndelta\r\n"
836 );
837 // Existing LF file: incoming CRLF content is re-emitted as LF.
838 assert_eq!(
839 preserve_prior_line_endings("gamma\r\ndelta\r\n", "alpha\nbeta\n"),
840 "gamma\ndelta\n"
841 );
842 // Brand-new file (no prior content): written verbatim, including CRLF.
843 assert_eq!(
844 preserve_prior_line_endings("gamma\r\ndelta\r\n", ""),
845 "gamma\r\ndelta\r\n"
846 );
847 assert_eq!(preserve_prior_line_endings("plain", ""), "plain");
848 // A lone CR in the incoming content is normalized like edit_file does: the
849 // bare \r becomes \n, then is re-emitted as CRLF when the prior is CRLF.
850 assert_eq!(
851 preserve_prior_line_endings("alpha\rbeta\n", "x\r\ny\r\n"),
852 "alpha\r\nbeta\r\n"
853 );
854 assert_eq!(
855 preserve_prior_line_endings("alpha\rbeta\n", "x\ny\n"),
856 "alpha\nbeta\n"
857 );
858 }
859
860 #[cfg(unix)]
861 #[tokio::test]
862 async fn edit_file_tool_preserves_executable_bits() {
863 use std::os::unix::fs::PermissionsExt;
864
865 let tmp = tempdir().expect("tempdir");
866 let ctx = ToolContext::new(tmp.path().to_path_buf());
867 let path = tmp.path().join("script.sh");
868 fs::write(&path, b"#!/bin/sh\nexit 0\n").expect("initial write");
869 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
870 .expect("set executable permissions");
871 read_before_edit(&ctx, "script.sh").await;
872
873 EditFileTool
874 .execute(
875 json!({
876 "path": "script.sh",
877 "search": "exit 0",
878 "replace": "exit 1"
879 }),
880 &ctx,
881 )
882 .await
883 .expect("execute");
884
885 let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
886 assert_eq!(mode, 0o755);
887 assert_eq!(
888 fs::read_to_string(&path).expect("read"),
889 "#!/bin/sh\nexit 1\n"
890 );
891 }
892
893 /// #6205 — a sloppy edit to a rustfmt-clean file lands normalized, and the
894 /// tool result's returned diff matches the bytes on disk, so the model's next
895 /// anchor is the real text.
896 #[tokio::test]
897 async fn edit_file_normalizes_a_sloppy_edit_in_a_rustfmt_clean_file() {
898 let tmp = tempdir().expect("tempdir");
899 let ctx = ToolContext::new(tmp.path().to_path_buf());
900 let path = tmp.path().join("clean.rs");
901 fs::write(&path, "fn main() {\n let x = 1;\n}\n").expect("write");
902 read_before_edit(&ctx, "clean.rs").await;
903
904 let result = EditFileTool
905 .execute(
906 json!({
907 "path": "clean.rs",
908 "search": " let x = 1;",
909 "replace": " let x = 1;\n let y=2;",
910 }),
911 &ctx,
912 )
913 .await
914 .expect("execute");
915
916 // No skip-if-missing branch: rustfmt ships with the pinned toolchain, and a
917 // test that passes vacuously without it proves nothing.
918 assert_eq!(
919 fs::read_to_string(&path).expect("read"),
920 "fn main() {\n let x = 1;\n let y = 2;\n}\n"
921 );
922 assert!(
923 result.content.contains("rustfmt-normalized"),
924 "the result must say the content was normalized: {}",
925 result.content
926 );
927 let diff = result.metadata.as_ref().expect("metadata")["mutation"]["diff"]
928 .as_str()
929 .expect("diff")
930 .to_string();
931 assert!(
932 diff.contains("+ let y = 2;"),
933 "the returned diff must show the normalized text, not what was sent: {diff}"
934 );
935 assert!(!diff.contains("let y=2;"), "{diff}");
936 }
937
938 /// A file the author formats by hand is never reformatted wholesale.
939 #[tokio::test]
940 async fn edit_file_leaves_a_hand_formatted_file_alone() {
941 let tmp = tempdir().expect("tempdir");
942 let ctx = ToolContext::new(tmp.path().to_path_buf());
943 let path = tmp.path().join("handmade.rs");
944 // Two-space indentation: rustfmt would rewrite every line of this file.
945 fs::write(&path, "fn main() {\n let x = 1;\n}\n").expect("write");
946 read_before_edit(&ctx, "handmade.rs").await;
947
948 EditFileTool
949 .execute(
950 json!({
951 "path": "handmade.rs",
952 "search": " let x = 1;",
953 "replace": " let x = 1;\n let y = 2;",
954 }),
955 &ctx,
956 )
957 .await
958 .expect("execute");
959
960 assert_eq!(
961 fs::read_to_string(&path).expect("read"),
962 "fn main() {\n let x = 1;\n let y = 2;\n}\n",
963 "unrelated user formatting must survive the edit"
964 );
965 }
966
967 /// #6206 — a dependency bump that leaves `Cargo.toml` unparseable is refused
968 /// at edit time, not discovered by the next `cargo` invocation.
969 #[tokio::test]
970 async fn edit_file_refuses_an_edit_that_breaks_a_cargo_manifest() {
971 let tmp = tempdir().expect("tempdir");
972 let ctx = ToolContext::new(tmp.path().to_path_buf());
973 let path = tmp.path().join("Cargo.toml");
974 let original = "[dependencies]\nserde = \"1.0\"\n";
975 fs::write(&path, original).expect("write");
976 read_before_edit(&ctx, "Cargo.toml").await;
977
978 let error = EditFileTool
979 .execute(
980 json!({
981 "path": "Cargo.toml",
982 "search": "serde = \"1.0\"",
983 // Unterminated string: the classic half-finished version bump.
984 "replace": "serde = \"1.0",
985 }),
986 &ctx,
987 )
988 .await
989 .expect_err("an unparseable manifest must be refused");
990
991 let message = error.to_string();
992 assert!(message.contains("TOML syntax error at line"), "{message}");
993 assert_eq!(
994 fs::read_to_string(&path).expect("read"),
995 original,
996 "a refused edit must leave the manifest unchanged"
997 );
998 }
999
1000 /// A valid structured-config edit is untouched by the gate.
1001 #[tokio::test]
1002 async fn edit_file_applies_a_valid_json_edit() {
1003 let tmp = tempdir().expect("tempdir");
1004 let ctx = ToolContext::new(tmp.path().to_path_buf());
1005 let path = tmp.path().join("data.json");
1006 fs::write(&path, "{\n \"port\": 8080\n}\n").expect("write");
1007 read_before_edit(&ctx, "data.json").await;
1008
1009 EditFileTool
1010 .execute(
1011 json!({
1012 "path": "data.json",
1013 "search": "8080",
1014 "replace": "9090",
1015 }),
1016 &ctx,
1017 )
1018 .await
1019 .expect("a valid JSON edit must proceed unchanged");
1020
1021 assert_eq!(
1022 fs::read_to_string(&path).expect("read"),
1023 "{\n \"port\": 9090\n}\n"
1024 );
1025 }
1026
1027 /// #6204 — an edit that takes a parseable Rust file to an unparseable one is
1028 /// refused before the write, with a `line:column` from `syn`.
1029 #[tokio::test]
1030 async fn edit_file_refuses_an_edit_that_breaks_rust_syntax() {
1031 let tmp = tempdir().expect("tempdir");
1032 let ctx = ToolContext::new(tmp.path().to_path_buf());
1033 let path = tmp.path().join("main.rs");
1034 let original = "fn main() {\n println!(\"hi\");\n}\n";
1035 fs::write(&path, original).expect("write");
1036 read_before_edit(&ctx, "main.rs").await;
1037
1038 let error = EditFileTool
1039 .execute(
1040 json!({
1041 "path": "main.rs",
1042 // Same brace balance, so the payload-corruption heuristic has
1043 // no objection; the parenthesis is what breaks the grammar.
1044 "search": "fn main() {",
1045 "replace": "fn main( {",
1046 }),
1047 &ctx,
1048 )
1049 .await
1050 .expect_err("an edit that breaks Rust syntax must be refused");
1051
1052 let message = error.to_string();
1053 assert!(message.contains("Rust syntax error at line"), "{message}");
1054 assert!(message.contains("Nothing was written"), "{message}");
1055 assert_eq!(
1056 fs::read_to_string(&path).expect("read"),
1057 original,
1058 "a refused edit must leave the file byte-for-byte unchanged"
1059 );
1060 }
1061
1062 /// The gate catches the edit that *introduces* breakage, never the one that
1063 /// repairs it: a file that already fails to parse stays editable.
1064 #[tokio::test]
1065 async fn edit_file_still_repairs_an_already_broken_rust_file() {
1066 let tmp = tempdir().expect("tempdir");
1067 let ctx = ToolContext::new(tmp.path().to_path_buf());
1068 let path = tmp.path().join("broken.rs");
1069 fs::write(&path, "fn main( {\n println!(\"hi\");\n}\n").expect("write");
1070 read_before_edit(&ctx, "broken.rs").await;
1071
1072 EditFileTool
1073 .execute(
1074 json!({
1075 "path": "broken.rs",
1076 "search": "fn main( {",
1077 "replace": "fn main() {",
1078 }),
1079 &ctx,
1080 )
1081 .await
1082 .expect("repairing a broken file must not be gated");
1083
1084 assert_eq!(
1085 fs::read_to_string(&path).expect("read"),
1086 "fn main() {\n println!(\"hi\");\n}\n"
1087 );
1088 }
1089
1090 #[tokio::test]
1091 async fn edit_file_refuses_brace_collapsed_match_arm_payload() {
1092 let tmp = tempdir().expect("tempdir");
1093 let ctx = ToolContext::new(tmp.path().to_path_buf());
1094 let path = tmp.path().join("arm.rs");
1095 let original = r#"match outcome {
1096 SendMessageOutcome::Finished {
1097 status: TurnOutcomeStatus::Interrupted,
1098 ..
1099 } => self.pause_goal_after_interruption().await,
1100 SendMessageOutcome::Finished {
1101 status: TurnOutcomeStatus::Completed,
1102 ..
1103 } => {}
1104 }
1105 "#;
1106 fs::write(&path, original).expect("write");
1107 read_before_edit(&ctx, "arm.rs").await;
1108
1109 let search = r#"SendMessageOutcome::Finished {
1110 status: TurnOutcomeStatus::Interrupted,
1111 ..
1112 } => self.pause_goal_after_interruption().await,"#;
1113 // Corrupted host payload: brace block collapsed to empty brackets.
1114 let replace = "[
1115
1116 ] => {},";
1117 let err = EditFileTool
1118 .execute(
1119 json!({
1120 "path": "arm.rs",
1121 "search": search,
1122 "replace": replace,
1123 }),
1124 &ctx,
1125 )
1126 .await
1127 .expect_err("corrupted brace collapse must fail closed");
1128 let msg = err.to_string();
1129 assert!(
1130 msg.contains("corrupted") || msg.contains("collapsed") || msg.contains("unbalanced"),
1131 "unexpected error: {msg}"
1132 );
1133 assert_eq!(fs::read_to_string(&path).expect("read"), original);
1134 }
1135
1136 #[tokio::test]
1137 async fn edit_file_preserves_rust_match_arm_braces() {
1138 let tmp = tempdir().expect("tempdir");
1139 let ctx = ToolContext::new(tmp.path().to_path_buf());
1140 let path = tmp.path().join("arm.rs");
1141 let original = r#"match outcome {
1142 SendMessageOutcome::Finished {
1143 status: TurnOutcomeStatus::Interrupted,
1144 ..
1145 } => self.pause_goal_after_interruption().await,
1146 other => {}
1147 }
1148 "#;
1149 fs::write(&path, original).expect("write");
1150 read_before_edit(&ctx, "arm.rs").await;
1151
1152 let search = r#"SendMessageOutcome::Finished {
1153 status: TurnOutcomeStatus::Interrupted,
1154 ..
1155 } => self.pause_goal_after_interruption().await,"#;
1156 let replace = r#"SendMessageOutcome::Finished {
1157 status: TurnOutcomeStatus::Interrupted,
1158 ..
1159 } => {
1160 // stay active
1161 let _ = self.tx_event.send(Event::status("ok".into())).await;
1162 }"#;
1163 EditFileTool
1164 .execute(
1165 json!({
1166 "path": "arm.rs",
1167 "search": search,
1168 "replace": replace,
1169 }),
1170 &ctx,
1171 )
1172 .await
1173 .expect("brace-heavy replace must apply");
1174 let updated = fs::read_to_string(&path).expect("read");
1175 assert!(updated.contains("stay active"), "{updated}");
1176 assert!(
1177 updated.contains("SendMessageOutcome::Finished"),
1178 "{updated}"
1179 );
1180 assert!(
1181 !updated.contains("pause_goal_after_interruption"),
1182 "{updated}"
1183 );
1184 }
1185
1186 #[tokio::test]
1187 async fn test_edit_file_tool() {
1188 let tmp = tempdir().expect("tempdir");
1189 let ctx = ToolContext::new(tmp.path().to_path_buf());
1190
1191 // Create a file to edit
1192 let test_file = tmp.path().join("edit_me.txt");
1193 fs::write(&test_file, "hello world").expect("write");
1194 read_before_edit(&ctx, "edit_me.txt").await;
1195
1196 let tool = EditFileTool;
1197 let result = tool
1198 .execute(
1199 json!({"path": "edit_me.txt", "search": "hello", "replace": "hi"}),
1200 &ctx,
1201 )
1202 .await
1203 .expect("execute");
1204
1205 assert!(result.success);
1206 assert!(result.content.contains("Replaced 1 occurrence"));
1207 // Inline diff (#505) — the unified diff lands above the summary
1208 // line so the TUI's diff-aware renderer kicks in.
1209 assert!(result.content.contains("--- a/"), "{}", result.content);
1210 assert!(
1211 result.content.contains("-hello world"),
1212 "{}",
1213 result.content
1214 );
1215 assert!(result.content.contains("+hi world"), "{}", result.content);
1216 let mutation = &result.metadata.as_ref().expect("metadata")["mutation"];
1217 assert_eq!(
1218 mutation["files"],
1219 json!([{
1220 "path": "edit_me.txt",
1221 "outcome": "updated",
1222 "size": fs::read(tmp.path().join("edit_me.txt")).expect("edited").len(),
1223 "sha256": crate::hashing::sha256_hex(
1224 fs::read(tmp.path().join("edit_me.txt")).expect("edited")
1225 ),
1226 }])
1227 );
1228 let receipt_diff = mutation["diff"].as_str().expect("receipt diff");
1229 assert!(receipt_diff.contains("--- a/edit_me.txt"), "{receipt_diff}");
1230 assert!(receipt_diff.contains("-hello world"), "{receipt_diff}");
1231 assert!(receipt_diff.contains("+hi world"), "{receipt_diff}");
1232 assert!(
1233 !receipt_diff.contains(&tmp.path().display().to_string()),
1234 "receipt headers must not expose the resolved host path: {receipt_diff}"
1235 );
1236
1237 // Verify edit was applied
1238 let edited = fs::read_to_string(&test_file).expect("read");
1239 assert_eq!(edited, "hi world");
1240 }
1241
1242 #[tokio::test]
1243 async fn edit_file_matches_lf_search_in_crlf_file_and_preserves_crlf() {
1244 let tmp = tempdir().expect("tempdir");
1245 let ctx = ToolContext::new(tmp.path().to_path_buf());
1246 let test_file = tmp.path().join("crlf.py");
1247 fs::write(
1248 &test_file,
1249 b"def greet(name):\r\n print(name)\r\n\r\ndef add(a, b):\r\n return a + b\r\n",
1250 )
1251 .expect("write");
1252 read_before_edit(&ctx, "crlf.py").await;
1253
1254 let result = EditFileTool
1255 .execute(
1256 json!({
1257 "path": "crlf.py",
1258 "search": "def add(a, b):\n return a + b",
1259 "replace": "def add(a, b):\n return a * b",
1260 }),
1261 &ctx,
1262 )
1263 .await
1264 .expect("LF model input should edit a CRLF file");
1265
1266 assert!(result.success, "{}", result.content);
1267 assert_eq!(
1268 fs::read(&test_file).expect("read"),
1269 b"def greet(name):\r\n print(name)\r\n\r\ndef add(a, b):\r\n return a * b\r\n",
1270 );
1271 }
1272
1273 #[test]
1274 fn edit_file_sparse_crlf_positions_map_utf8_range_through_eof() {
1275 let original = "前\r\n尾";
1276 let (normalized, crlf_positions) = normalize_crlf_with_positions(original);
1277
1278 assert_eq!(normalized, "前\n尾");
1279 assert_eq!(crlf_positions.as_deref(), Some(&[3][..]));
1280 assert_eq!(
1281 map_normalized_range((0, normalized.len()), crlf_positions.as_deref()),
1282 (0, original.len()),
1283 );
1284 }
1285
1286 #[tokio::test]
1287 async fn edit_file_maps_utf8_crlf_match_ending_at_eof() {
1288 let tmp = tempdir().expect("tempdir");
1289 let ctx = ToolContext::new(tmp.path().to_path_buf());
1290 let test_file = tmp.path().join("utf8-eof-crlf.txt");
1291 fs::write(&test_file, "前\r\n尾").expect("write");
1292 read_before_edit(&ctx, "utf8-eof-crlf.txt").await;
1293
1294 EditFileTool
1295 .execute(
1296 json!({
1297 "path": "utf8-eof-crlf.txt",
1298 "search": "前\n尾",
1299 "replace": "始\n终",
1300 }),
1301 &ctx,
1302 )
1303 .await
1304 .expect("UTF-8 CRLF match should map through EOF");
1305
1306 assert_eq!(fs::read(&test_file).expect("read"), "始\r\n终".as_bytes(),);
1307 }
1308
1309 #[tokio::test]
1310 async fn edit_file_normalizes_multiline_replacement_for_single_line_crlf_match() {
1311 let tmp = tempdir().expect("tempdir");
1312 let ctx = ToolContext::new(tmp.path().to_path_buf());
1313 let test_file = tmp.path().join("single-line-crlf.txt");
1314 fs::write(&test_file, b"alpha\r\nomega\r\n").expect("write");
1315 read_before_edit(&ctx, "single-line-crlf.txt").await;
1316
1317 EditFileTool
1318 .execute(
1319 json!({
1320 "path": "single-line-crlf.txt",
1321 "search": "omega",
1322 "replace": "beta\ngamma",
1323 }),
1324 &ctx,
1325 )
1326 .await
1327 .expect("replacement should follow the file's CRLF style");
1328
1329 assert_eq!(
1330 fs::read(&test_file).expect("read"),
1331 b"alpha\r\nbeta\r\ngamma\r\n",
1332 );
1333 }
1334
1335 #[tokio::test]
1336 async fn edit_file_normalizes_crlf_and_mixed_replacement_for_lf_file() {
1337 let tmp = tempdir().expect("tempdir");
1338 let ctx = ToolContext::new(tmp.path().to_path_buf());
1339 let test_file = tmp.path().join("lf.txt");
1340 fs::write(&test_file, b"alpha\nomega\n").expect("write");
1341 read_before_edit(&ctx, "lf.txt").await;
1342
1343 EditFileTool
1344 .execute(
1345 json!({
1346 "path": "lf.txt",
1347 "search": "omega",
1348 "replace": "beta\r\ngamma\nfinal",
1349 }),
1350 &ctx,
1351 )
1352 .await
1353 .expect("replacement should follow the file's LF style");
1354
1355 assert_eq!(
1356 fs::read(&test_file).expect("read"),
1357 b"alpha\nbeta\ngamma\nfinal\n",
1358 );
1359 }
1360
1361 #[tokio::test]
1362 async fn edit_file_rejects_logical_duplicate_across_lf_and_crlf() {
1363 let tmp = tempdir().expect("tempdir");
1364 let ctx = ToolContext::new(tmp.path().to_path_buf());
1365 let test_file = tmp.path().join("mixed.txt");
1366 let original = b"same\nblock\r\nsame\r\nblock\r\n";
1367 fs::write(&test_file, original).expect("write");
1368 read_before_edit(&ctx, "mixed.txt").await;
1369
1370 let error = EditFileTool
1371 .execute(
1372 json!({
1373 "path": "mixed.txt",
1374 "search": "same\nblock",
1375 "replace": "changed",
1376 }),
1377 &ctx,
1378 )
1379 .await
1380 .expect_err("logical duplicates must remain non-unique");
1381
1382 assert!(error.to_string().contains("matched 2"), "{error}");
1383 assert_eq!(fs::read(&test_file).expect("read"), original);
1384 }
1385
1386 #[tokio::test]
1387 async fn edit_file_combines_crlf_and_indentation_fuzzy_matching() {
1388 let tmp = tempdir().expect("tempdir");
1389 let ctx = ToolContext::new(tmp.path().to_path_buf());
1390 let test_file = tmp.path().join("fuzzy-crlf.txt");
1391 fs::write(&test_file, "前言\r\n 数据 = 1\r\n").expect("write");
1392 read_before_edit(&ctx, "fuzzy-crlf.txt").await;
1393
1394 let result = EditFileTool
1395 .execute(
1396 json!({
1397 "path": "fuzzy-crlf.txt",
1398 "search": "前言\n 数据 = 1",
1399 "replace": "前言\n 数据 = 2",
1400 }),
1401 &ctx,
1402 )
1403 .await
1404 .expect("indentation fallback should compose with CRLF normalization");
1405
1406 assert!(
1407 result.content.contains("fuzzy indentation match"),
1408 "{}",
1409 result.content
1410 );
1411 assert_eq!(
1412 fs::read(&test_file).expect("read"),
1413 "前言\r\n 数据 = 2\r\n".as_bytes(),
1414 );
1415 }
1416
1417 #[tokio::test]
1418 async fn edit_file_combines_crlf_and_punctuation_fuzzy_matching() {
1419 let tmp = tempdir().expect("tempdir");
1420 let ctx = ToolContext::new(tmp.path().to_path_buf());
1421 let test_file = tmp.path().join("punctuation-crlf.txt");
1422 fs::write(&test_file, "前言\r\n数据 \"x\"\r\n").expect("write");
1423 read_before_edit(&ctx, "punctuation-crlf.txt").await;
1424
1425 let result = EditFileTool
1426 .execute(
1427 json!({
1428 "path": "punctuation-crlf.txt",
1429 "search": "前言\n数据 \u{201C}x\u{201D}",
1430 "replace": "前言\r\n数据 y\n下一行",
1431 }),
1432 &ctx,
1433 )
1434 .await
1435 .expect("punctuation fallback should compose with CRLF normalization");
1436
1437 assert!(
1438 result.content.contains("fuzzy punctuation match"),
1439 "{}",
1440 result.content
1441 );
1442 assert_eq!(
1443 fs::read(&test_file).expect("read"),
1444 "前言\r\n数据 y\r\n下一行\r\n".as_bytes(),
1445 );
1446 }
1447
1448 #[tokio::test]
1449 async fn edit_file_rejects_line_ending_normalized_noop() {
1450 let tmp = tempdir().expect("tempdir");
1451 let ctx = ToolContext::new(tmp.path().to_path_buf());
1452 let test_file = tmp.path().join("noop-crlf.txt");
1453 let original = b"alpha\r\nbeta\r\n";
1454 fs::write(&test_file, original).expect("write");
1455 read_before_edit(&ctx, "noop-crlf.txt").await;
1456
1457 let error = EditFileTool
1458 .execute(
1459 json!({
1460 "path": "noop-crlf.txt",
1461 "search": "alpha\nbeta",
1462 "replace": "alpha\r\nbeta",
1463 }),
1464 &ctx,
1465 )
1466 .await
1467 .expect_err("normalized no-op should be rejected");
1468
1469 assert!(error.to_string().contains("no change intended"), "{error}");
1470 assert_eq!(fs::read(&test_file).expect("read"), original);
1471 }
1472
1473 #[tokio::test]
1474 async fn edit_file_requires_prior_read() {
1475 let tmp = tempdir().expect("tempdir");
1476 let ctx = ToolContext::new(tmp.path().to_path_buf());
1477
1478 let test_file = tmp.path().join("blind.txt");
1479 fs::write(&test_file, "hello world").expect("write");
1480
1481 let err = EditFileTool
1482 .execute(
1483 json!({"path": "blind.txt", "search": "hello", "replace": "hi"}),
1484 &ctx,
1485 )
1486 .await
1487 .expect_err("edit without read should fail");
1488 let message = err.to_string();
1489 assert!(message.contains("not been read"), "{message}");
1490 // The recovery has to be spelled as a call the model can make: `read_file`
1491 // was retired in v0.9.3 and the registry has no fuzzy resolve step.
1492 assert!(message.contains(r#"File with action="read""#), "{message}");
1493 assert!(!message.contains("read_file"), "{message}");
1494
1495 let unchanged = fs::read_to_string(&test_file).expect("read");
1496 assert_eq!(unchanged, "hello world");
1497 }
1498
1499 #[tokio::test]
1500 async fn edit_file_rejects_stale_prior_read() {
1501 let tmp = tempdir().expect("tempdir");
1502 let ctx = ToolContext::new(tmp.path().to_path_buf());
1503
1504 let test_file = tmp.path().join("stale.txt");
1505 fs::write(&test_file, "alpha beta").expect("write");
1506 read_before_edit(&ctx, "stale.txt").await;
1507 fs::write(&test_file, "alpha beta gamma").expect("external write");
1508
1509 let err = EditFileTool
1510 .execute(
1511 json!({"path": "stale.txt", "search": "alpha", "replace": "omega"}),
1512 &ctx,
1513 )
1514 .await
1515 .expect_err("stale read should fail");
1516 let message = err.to_string();
1517 assert!(message.contains("changed since"), "{message}");
1518 assert!(message.contains(r#"File with action="read""#), "{message}");
1519 assert!(!message.contains("read_file"), "{message}");
1520
1521 let unchanged = fs::read_to_string(&test_file).expect("read");
1522 assert_eq!(unchanged, "alpha beta gamma");
1523 }
1524
1525 #[tokio::test]
1526 async fn edit_file_rejects_non_unique_exact_match() {
1527 let tmp = tempdir().expect("tempdir");
1528 let ctx = ToolContext::new(tmp.path().to_path_buf());
1529
1530 let test_file = tmp.path().join("multi.txt");
1531 fs::write(&test_file, "hello world hello").expect("write");
1532 read_before_edit(&ctx, "multi.txt").await;
1533
1534 let err = EditFileTool
1535 .execute(
1536 json!({"path": "multi.txt", "search": "hello", "replace": "hi"}),
1537 &ctx,
1538 )
1539 .await
1540 .expect_err("non-unique exact match should fail");
1541 let message = err.to_string();
1542 assert!(message.contains("non-unique"), "{message}");
1543 assert!(message.contains("matched 2"), "{message}");
1544 // Recovery text must name the live surface. `read_file` is retired and
1545 // cannot dispatch (crates/tui/src/tools/registry.rs:2067).
1546 assert!(
1547 message.contains("call File with action=\"read\""),
1548 "{message}"
1549 );
1550 assert!(!message.contains("read_file"), "{message}");
1551
1552 let unchanged = fs::read_to_string(&test_file).expect("read");
1553 assert_eq!(unchanged, "hello world hello");
1554 }
1555
1556 /// `fuzz` on `edit` was an advertised parameter with no implementation: it
1557 /// was parsed into `let _fuzz` and thrown away, and a live model read the
1558 /// schema as offering "an optional fuzzy-matching flag for the search". The
1559 /// advertisement is gone, so the name now means nothing to `edit` and is
1560 /// refused like any other name with no known meaning — the fuzzy fallbacks it
1561 /// appeared to control run unconditionally either way.
1562 #[tokio::test]
1563 async fn edit_file_refuses_the_retired_fuzz_parameter() {
1564 let tmp = tempdir().expect("tempdir");
1565 let ctx = ToolContext::new(tmp.path().to_path_buf());
1566 let test_file = tmp.path().join("fuzz_retired.txt");
1567 fs::write(&test_file, "hello world").expect("write");
1568 read_before_edit(&ctx, "fuzz_retired.txt").await;
1569
1570 let err = EditFileTool
1571 .execute(
1572 json!({
1573 "path": "fuzz_retired.txt",
1574 "search": "hello",
1575 "replace": "hi",
1576 "fuzz": true,
1577 }),
1578 &ctx,
1579 )
1580 .await
1581 .expect_err("a parameter edit does not implement must be refused");
1582 let msg = err.to_string();
1583 assert!(msg.contains("fuzz"), "must name the parameter: {msg}");
1584 assert!(
1585 msg.contains("was not performed"),
1586 "must deny having edited: {msg}"
1587 );
1588 assert_eq!(
1589 fs::read_to_string(&test_file).expect("read"),
1590 "hello world",
1591 "a refused edit must not touch the file"
1592 );
1593 }
1594
1595 #[tokio::test]
1596 async fn test_edit_file_single_match_has_no_multi_match_warning() {
1597 let tmp = tempdir().expect("tempdir");
1598 let ctx = ToolContext::new(tmp.path().to_path_buf());
1599
1600 let test_file = tmp.path().join("single.txt");
1601 fs::write(&test_file, "hello world").expect("write");
1602 read_before_edit(&ctx, "single.txt").await;
1603
1604 let tool = EditFileTool;
1605 let result = tool
1606 .execute(
1607 json!({"path": "single.txt", "search": "hello", "replace": "hi"}),
1608 &ctx,
1609 )
1610 .await
1611 .expect("execute");
1612
1613 assert!(result.success);
1614 assert!(result.content.contains("Replaced 1 occurrence"));
1615 assert!(!result.content.contains("multiple matches were replaced"));
1616 }
1617
1618 #[tokio::test]
1619 async fn test_edit_file_fuzz_tolerates_leading_whitespace() {
1620 let tmp = tempdir().expect("tempdir");
1621 let ctx = ToolContext::new(tmp.path().to_path_buf());
1622
1623 let test_file = tmp.path().join("fuzzy.txt");
1624 fs::write(
1625 &test_file,
1626 "fn main() {\n if true {\n let value = 1;\n }\n}\n",
1627 )
1628 .expect("write");
1629 read_before_edit(&ctx, "fuzzy.txt").await;
1630
1631 let tool = EditFileTool;
1632 let result = tool
1633 .execute(
1634 json!({
1635 "path": "fuzzy.txt",
1636 "search": "if true {\n let value = 1;\n}",
1637 "replace": " if true {\n let value = 2;\n }"
1638 }),
1639 &ctx,
1640 )
1641 .await
1642 .expect("execute");
1643
1644 assert!(result.success);
1645 assert!(result.content.contains("fuzzy indentation match"));
1646 let edited = fs::read_to_string(&test_file).expect("read");
1647 assert_eq!(
1648 edited,
1649 "fn main() {\n if true {\n let value = 2;\n }\n}\n"
1650 );
1651 }
1652
1653 #[tokio::test]
1654 async fn test_edit_file_fuzz_tolerates_leading_whitespace_after_multibyte_start() {
1655 let tmp = tempdir().expect("tempdir");
1656 let ctx = ToolContext::new(tmp.path().to_path_buf());
1657
1658 let test_file = tmp.path().join("fuzzy_cjk.txt");
1659 fs::write(&test_file, "数据\n").expect("write");
1660 read_before_edit(&ctx, "fuzzy_cjk.txt").await;
1661
1662 let tool = EditFileTool;
1663 let result = tool
1664 .execute(
1665 json!({
1666 "path": "fuzzy_cjk.txt",
1667 "search": " 数据",
1668 "replace": "记录"
1669 }),
1670 &ctx,
1671 )
1672 .await
1673 .expect("execute");
1674
1675 assert!(result.success, "{}", result.content);
1676 assert!(result.content.contains("fuzzy indentation match"));
1677 let edited = fs::read_to_string(&test_file).expect("read");
1678 assert_eq!(edited, "记录\n");
1679 }
1680
1681 #[tokio::test]
1682 async fn test_edit_file_fuzz_tolerates_smart_quote_substitution() {
1683 // The file on disk has ASCII quotes. The search comes from a
1684 // browser paste with curly quotes. Exact match fails; the
1685 // punctuation-normalized fallback should still land the edit.
1686 let tmp = tempdir().expect("tempdir");
1687 let ctx = ToolContext::new(tmp.path().to_path_buf());
1688
1689 let test_file = tmp.path().join("smart.rs");
1690 fs::write(&test_file, "let s = \"hello world\";\n").expect("write");
1691 read_before_edit(&ctx, "smart.rs").await;
1692
1693 let tool = EditFileTool;
1694 let result = tool
1695 .execute(
1696 json!({
1697 "path": "smart.rs",
1698 // \u{201C} \u{201D} are the curly double-quote pair.
1699 "search": "let s = \u{201C}hello world\u{201D};",
1700 "replace": "let s = \"hello universe\";"
1701 }),
1702 &ctx,
1703 )
1704 .await
1705 .expect("execute");
1706
1707 assert!(result.success, "fuzzy punctuation edit should succeed");
1708 assert!(
1709 result.content.contains("fuzzy punctuation match"),
1710 "expected punctuation-fuzz note, got: {}",
1711 result.content
1712 );
1713 let edited = fs::read_to_string(&test_file).expect("read");
1714 assert_eq!(edited, "let s = \"hello universe\";\n");
1715 }
1716
1717 #[tokio::test]
1718 async fn test_edit_file_fuzz_tolerates_smart_quote_after_multibyte_start() {
1719 let tmp = tempdir().expect("tempdir");
1720 let ctx = ToolContext::new(tmp.path().to_path_buf());
1721
1722 let test_file = tmp.path().join("smart_cjk.md");
1723 fs::write(&test_file, "数据 \"x\"\n").expect("write");
1724 read_before_edit(&ctx, "smart_cjk.md").await;
1725
1726 let tool = EditFileTool;
1727 let result = tool
1728 .execute(
1729 json!({
1730 "path": "smart_cjk.md",
1731 "search": "数据 \u{201C}x\u{201D}",
1732 "replace": "数据 y"
1733 }),
1734 &ctx,
1735 )
1736 .await
1737 .expect("execute");
1738
1739 assert!(result.success, "{}", result.content);
1740 assert!(result.content.contains("fuzzy punctuation match"));
1741 let edited = fs::read_to_string(&test_file).expect("read");
1742 assert_eq!(edited, "数据 y\n");
1743 }
1744
1745 #[tokio::test]
1746 async fn test_edit_file_fuzz_tolerates_em_dash_and_nbsp() {
1747 let tmp = tempdir().expect("tempdir");
1748 let ctx = ToolContext::new(tmp.path().to_path_buf());
1749
1750 let test_file = tmp.path().join("dash.md");
1751 // File has an ASCII hyphen and ASCII space.
1752 fs::write(&test_file, "alpha - beta\n").expect("write");
1753 read_before_edit(&ctx, "dash.md").await;
1754
1755 let tool = EditFileTool;
1756 let result = tool
1757 .execute(
1758 json!({
1759 "path": "dash.md",
1760 // Search uses em-dash + NBSP, common after a copy-paste
1761 // from a styled document.
1762 "search": "alpha\u{00A0}\u{2014}\u{00A0}beta",
1763 "replace": "alpha - gamma"
1764 }),
1765 &ctx,
1766 )
1767 .await
1768 .expect("execute");
1769
1770 assert!(result.success);
1771 let edited = fs::read_to_string(&test_file).expect("read");
1772 assert_eq!(edited, "alpha - gamma\n");
1773 }
1774
1775 #[tokio::test]
1776 async fn test_edit_file_not_found() {
1777 let tmp = tempdir().expect("tempdir");
1778 let ctx = ToolContext::new(tmp.path().to_path_buf());
1779
1780 // Create a file without the search string
1781 let test_file = tmp.path().join("no_match.txt");
1782 fs::write(&test_file, "foo bar baz").expect("write");
1783 read_before_edit(&ctx, "no_match.txt").await;
1784
1785 let tool = EditFileTool;
1786 let result = tool
1787 .execute(
1788 json!({"path": "no_match.txt", "search": "hello", "replace": "hi"}),
1789 &ctx,
1790 )
1791 .await;
1792
1793 assert!(result.is_err());
1794 let err = result.unwrap_err();
1795 assert!(err.to_string().contains("not found"));
1796 assert!(err.to_string().contains("call File with action=\"read\""));
1797 assert!(!err.to_string().contains("read_file"));
1798 }
1799
1800 #[tokio::test]
1801 async fn test_edit_file_rejects_identical_search_and_replace() {
1802 let tmp = tempdir().expect("tempdir");
1803 let ctx = ToolContext::new(tmp.path().to_path_buf());
1804
1805 let test_file = tmp.path().join("same.txt");
1806 fs::write(&test_file, "a := \"foo\"").expect("write");
1807
1808 let tool = EditFileTool;
1809 let result = tool
1810 .execute(
1811 json!({
1812 "path": "same.txt",
1813 "search": "a := \"foo\"",
1814 "replace": "a := \"foo\""
1815 }),
1816 &ctx,
1817 )
1818 .await;
1819
1820 assert!(result.is_err());
1821 let err = result.unwrap_err().to_string();
1822 assert!(
1823 err.contains("search and replace are identical"),
1824 "error must explain the no-op input: {err}"
1825 );
1826 // #5003 - the diagnostic must help the model self-correct: it should
1827 // size the payload and point at the root cause instead of a bare
1828 // "no change intended".
1829 assert!(
1830 err.contains("10 chars"),
1831 "error should size the payload: {err}"
1832 );
1833 assert!(
1834 err.contains("Recovery"),
1835 "error should offer recovery: {err}"
1836 );
1837 let unchanged = fs::read_to_string(&test_file).expect("read");
1838 assert_eq!(unchanged, "a := \"foo\"");
1839 }
1840
1841 #[test]
1842 fn test_c_preprocessor_rejects_missing_close() {
1843 let before = "#if FEATURE\nold code\n#endif\n";
1844 let after = "#if FEATURE\nnew code\n";
1845 assert_eq!(
1846 invalid_preprocessor_edit(Path::new("source.c"), before, after),
1847 Some(PREPROCESSOR_CONDITIONAL_ERROR)
1848 );
1849 }
1850
1851 #[test]
1852 fn test_c_preprocessor_rejects_extra_close() {
1853 let before = "#if FEATURE\nold code\n#endif\n";
1854 let after = "#if FEATURE\nnew code\n#endif\n#endif\n";
1855 assert_eq!(
1856 invalid_preprocessor_edit(Path::new("source.hpp"), before, after),
1857 Some(PREPROCESSOR_CONDITIONAL_ERROR)
1858 );
1859 }
1860
1861 #[test]
1862 fn test_c_preprocessor_allows_balanced_block_removal_and_insertion() {
1863 let block = "#ifdef FEATURE\nfeature();\n#endif\n";
1864 assert!(invalid_preprocessor_edit(Path::new("source.cc"), block, "").is_none());
1865 assert!(invalid_preprocessor_edit(Path::new("source.cc"), "", block).is_none());
1866 }
1867
1868 #[test]
1869 fn test_c_preprocessor_allows_in_block_edit() {
1870 let before = "#if FEATURE\nold_call();\n#endif\n";
1871 let after = "#if FEATURE\nnew_call();\n#endif\n";
1872 assert!(invalid_preprocessor_edit(Path::new("source.cxx"), before, after).is_none());
1873 }
1874
1875 #[test]
1876 fn test_non_c_directive_prose_is_not_validated() {
1877 let before = "#if this example is enabled\nexplanation\n#endif\n";
1878 let after = "#if this example is enabled\nupdated explanation\n";
1879 assert!(invalid_preprocessor_edit(Path::new("guide.md"), before, after).is_none());
1880 }
1881
1882 #[test]
1883 fn test_preview_search_for_error_truncates() {
1884 let long_line = "x".repeat(200);
1885 let search = format!("{long_line}\nsecond line\nthird line\nfourth line\n");
1886 let preview = preview_search_for_error(&search);
1887 assert!(preview.lines().count() <= 3);
1888 assert!(preview.contains("..."));
1889 assert!(!preview.contains("fourth line"));
1890 }
1891
1892 #[tokio::test]
1893 async fn test_edit_file_not_found_shows_search_preview() {
1894 // #5003 - when search misses, the error should preview the search text
1895 // so the model can compare what it searched for against the file.
1896 let tmp = tempdir().expect("tempdir");
1897 let ctx = ToolContext::new(tmp.path().to_path_buf());
1898
1899 let test_file = tmp.path().join("preview.txt");
1900 fs::write(&test_file, "foo bar baz").expect("write");
1901 read_before_edit(&ctx, "preview.txt").await;
1902
1903 let tool = EditFileTool;
1904 let result = tool
1905 .execute(
1906 json!({
1907 "path": "preview.txt",
1908 "search": "first line\nsecond line\n",
1909 "replace": "changed"
1910 }),
1911 &ctx,
1912 )
1913 .await;
1914
1915 assert!(result.is_err());
1916 let err = result.unwrap_err().to_string();
1917 assert!(err.contains("Search string not found"));
1918 assert!(
1919 err.contains("first line"),
1920 "error should preview search text: {err}"
1921 );
1922 }
1923
1924 /// #6542 — a missed search returns the nearest region with line numbers and
1925 /// names a whitespace-only difference, so the retry can copy the real text.
1926 #[tokio::test]
1927 async fn edit_miss_returns_nearest_excerpt_with_line_numbers_and_whitespace_note() {
1928 let tmp = tempdir().expect("tempdir");
1929 let ctx = ToolContext::new(tmp.path().to_path_buf());
1930 fs::write(
1931 tmp.path().join("near.rs"),
1932 "fn a() {}\n\nfn compute(x: u32) -> u32 {\n x + 1\n}\n",
1933 )
1934 .expect("write");
1935 read_before_edit(&ctx, "near.rs").await;
1936
1937 let err = EditFileTool
1938 .execute(
1939 json!({
1940 "path": "near.rs",
1941 "search": "fn compute(x: u32) -> u32 { \n x + 1\n}",
1942 "replace": "fn compute(x: u32) -> u32 {\n x + 2\n}"
1943 }),
1944 &ctx,
1945 )
1946 .await
1947 .expect_err("trailing whitespace keeps the search from matching")
1948 .to_string();
1949 assert!(err.contains("Closest match (lines 3-5"), "{err}");
1950 assert!(err.contains("3\tfn compute(x: u32) -> u32 {"), "{err}");
1951 assert!(err.contains("trailing whitespace"), "{err}");
1952
1953 let err = EditFileTool
1954 .execute(
1955 json!({
1956 "path": "near.rs",
1957 "search": "completely unrelated text\nnothing like it",
1958 "replace": "x"
1959 }),
1960 &ctx,
1961 )
1962 .await
1963 .expect_err("no match")
1964 .to_string();
1965 assert!(err.contains("No similar region"), "{err}");
1966 }
1967
1968 /// #157 / #5209 — `replacement` is an unambiguous synonym for `replace`, so
1969 /// the edit the model asked for is the edit that lands. The #5209 guarantee
1970 /// being protected is that the file and the receipt agree: a reported
1971 /// replacement must correspond to a real one.
1972 #[tokio::test]
1973 async fn edit_file_accepts_replacement_alias_and_applies_the_edit() {
1974 let tmp = tempdir().expect("tempdir");
1975 let ctx = ToolContext::new(tmp.path().to_path_buf());
1976
1977 let test_file = tmp.path().join("test.txt");
1978 fs::write(&test_file, "hello world").expect("write");
1979 read_before_edit(&ctx, "test.txt").await;
1980
1981 let result = EditFileTool
1982 .execute(
1983 json!({"path": "test.txt", "search": "hello", "replacement": "hi"}),
1984 &ctx,
1985 )
1986 .await
1987 .expect("replacement alias must be honored");
1988
1989 assert!(result.success);
1990 assert_eq!(
1991 fs::read_to_string(&test_file).expect("read"),
1992 "hi world",
1993 "the receipt claimed an edit, so the file must actually carry it"
1994 );
1995 }
1996
1997 /// Every cross-harness spelling of the two edit arguments resolves to the
1998 /// same applied edit. A model that guesses from a different harness's prior
1999 /// gets its work done instead of a rejection and a wasted turn (#5209).
2000 #[tokio::test]
2001 async fn edit_file_accepts_every_cross_harness_edit_alias() {
2002 for (search_key, replace_key) in [
2003 ("old_string", "new_string"),
2004 ("old_str", "new_str"),
2005 ("oldText", "newText"),
2006 ("old_text", "new_text"),
2007 ] {
2008 let tmp = tempdir().expect("tempdir");
2009 let ctx = ToolContext::new(tmp.path().to_path_buf());
2010 let path = tmp.path().join("doc.md");
2011 fs::write(&path, "old text line\n").expect("write");
2012 read_before_edit(&ctx, "doc.md").await;
2013
2014 let result = EditFileTool
2015 .execute(
2016 json!({
2017 "path": "doc.md",
2018 search_key: "old text line",
2019 replace_key: "new text line",
2020 }),
2021 &ctx,
2022 )
2023 .await
2024 .unwrap_or_else(|err| panic!("{search_key}/{replace_key} must apply: {err}"));
2025
2026 assert!(result.success, "{search_key}/{replace_key}");
2027 assert_eq!(
2028 fs::read_to_string(&path).expect("read"),
2029 "new text line\n",
2030 "{search_key}/{replace_key} must reach the file"
2031 );
2032 }
2033 }
2034
2035 /// The unified `File` tool takes the same alias path as the inner tool, so
2036 /// the model-facing surface and the dispatch target cannot disagree.
2037 #[tokio::test]
2038 async fn file_tool_action_edit_accepts_new_str_alias() {
2039 use crate::tools::file_tool::FileTool;
2040
2041 let tmp = tempdir().expect("tempdir");
2042 let ctx = ToolContext::new(tmp.path().to_path_buf());
2043 let path = tmp.path().join("doc.md");
2044 fs::write(&path, "old text line\n").expect("write");
2045 read_before_edit(&ctx, "doc.md").await;
2046
2047 let result = FileTool::with_patch("File")
2048 .execute(
2049 json!({
2050 "action": "edit",
2051 "path": "doc.md",
2052 "search": "old text line",
2053 "new_str": "new text line",
2054 }),
2055 &ctx,
2056 )
2057 .await
2058 .expect("File action=edit with new_str must apply");
2059
2060 assert!(result.success);
2061 assert_eq!(fs::read_to_string(&path).expect("read"), "new text line\n");
2062 }
2063
2064 /// An alias that contradicts an explicitly supplied canonical value is
2065 /// ambiguous. Picking one would be the guess this whole path exists to
2066 /// avoid, so it fails and changes nothing.
2067 #[tokio::test]
2068 async fn edit_file_rejects_alias_conflicting_with_canonical_name() {
2069 let tmp = tempdir().expect("tempdir");
2070 let ctx = ToolContext::new(tmp.path().to_path_buf());
2071 let path = tmp.path().join("doc.md");
2072 fs::write(&path, "old text line\n").expect("write");
2073 read_before_edit(&ctx, "doc.md").await;
2074
2075 let err = EditFileTool
2076 .execute(
2077 json!({
2078 "path": "doc.md",
2079 "search": "old text line",
2080 "replace": "one thing",
2081 "new_string": "a different thing",
2082 }),
2083 &ctx,
2084 )
2085 .await
2086 .expect_err("conflicting alias must not be silently resolved");
2087
2088 let msg = err.to_string();
2089 assert!(
2090 msg.contains("`replace`") && msg.contains("`new_string`"),
2091 "must name both spellings: {msg}"
2092 );
2093 assert_eq!(
2094 fs::read_to_string(&path).expect("read"),
2095 "old text line\n",
2096 "nothing may change on an ambiguous call"
2097 );
2098 }
2099
2100 /// An alias that merely repeats the canonical value is a harmless
2101 /// duplicate, not a conflict.
2102 #[tokio::test]
2103 async fn edit_file_accepts_alias_agreeing_with_canonical_name() {
2104 let tmp = tempdir().expect("tempdir");
2105 let ctx = ToolContext::new(tmp.path().to_path_buf());
2106 let path = tmp.path().join("doc.md");
2107 fs::write(&path, "old text line\n").expect("write");
2108 read_before_edit(&ctx, "doc.md").await;
2109
2110 EditFileTool
2111 .execute(
2112 json!({
2113 "path": "doc.md",
2114 "search": "old text line",
2115 "replace": "new text line",
2116 "new_string": "new text line",
2117 }),
2118 &ctx,
2119 )
2120 .await
2121 .expect("agreeing duplicate must be accepted");
2122
2123 assert_eq!(fs::read_to_string(&path).expect("read"), "new text line\n");
2124 }
2125
2126 /// `file_path` is the other widespread spelling of `path` and is accepted on
2127 /// every file action.
2128 #[tokio::test]
2129 async fn file_actions_accept_file_path_alias() {
2130 let tmp = tempdir().expect("tempdir");
2131 let ctx = ToolContext::new(tmp.path().to_path_buf());
2132
2133 WriteFileTool
2134 .execute(json!({"file_path": "note.txt", "content": "first\n"}), &ctx)
2135 .await
2136 .expect("write must accept file_path");
2137 assert_eq!(
2138 fs::read_to_string(tmp.path().join("note.txt")).expect("read"),
2139 "first\n"
2140 );
2141
2142 let read = ReadFileTool
2143 .execute(json!({"file_path": "note.txt"}), &ctx)
2144 .await
2145 .expect("read must accept file_path");
2146 assert!(read.content.contains("first"));
2147
2148 EditFileTool
2149 .execute(
2150 json!({"file_path": "note.txt", "search": "first", "replace": "second"}),
2151 &ctx,
2152 )
2153 .await
2154 .expect("edit must accept file_path");
2155 assert_eq!(
2156 fs::read_to_string(tmp.path().join("note.txt")).expect("read"),
2157 "second\n"
2158 );
2159 }
2160
2161 /// `offset`/`limit` name the same read window as `start_line`/`max_lines`.
2162 /// Before they were translated, a wrong guess was dropped and the model
2163 /// silently got the head of the file instead of the window it asked for.
2164 #[tokio::test]
2165 async fn read_file_accepts_offset_and_limit_aliases() {
2166 let tmp = tempdir().expect("tempdir");
2167 let ctx = ToolContext::new(tmp.path().to_path_buf());
2168 let body: String = (1..=20).map(|n| format!("line {n}\n")).collect();
2169 fs::write(tmp.path().join("many.txt"), &body).expect("write");
2170
2171 let aliased = ReadFileTool
2172 .execute(json!({"path": "many.txt", "offset": 5, "limit": 3}), &ctx)
2173 .await
2174 .expect("offset/limit must be honored");
2175 let canonical = ReadFileTool
2176 .execute(
2177 json!({"path": "many.txt", "start_line": 5, "max_lines": 3}),
2178 &ctx,
2179 )
2180 .await
2181 .expect("canonical read");
2182
2183 assert_eq!(
2184 aliased.content, canonical.content,
2185 "aliases must select the same window as the canonical names"
2186 );
2187 assert!(
2188 aliased.content.contains("line 5") && !aliased.content.contains("line 1\n"),
2189 "must start at the requested offset: {}",
2190 aliased.content
2191 );
2192 }
2193
2194 /// #5209 — unknown keys on edit hard-error even when required fields are present.
2195 #[tokio::test]
2196 async fn edit_file_rejects_unexpected_parameter_names() {
2197 let tmp = tempdir().expect("tempdir");
2198 let ctx = ToolContext::new(tmp.path().to_path_buf());
2199 let path = tmp.path().join("doc.md");
2200 fs::write(&path, "hello\n").expect("write");
2201 read_before_edit(&ctx, "doc.md").await;
2202
2203 let err = EditFileTool
2204 .execute(
2205 json!({
2206 "path": "doc.md",
2207 "search": "hello",
2208 "replace": "hi",
2209 "mystery": true,
2210 }),
2211 &ctx,
2212 )
2213 .await
2214 .expect_err("unexpected params must hard-error");
2215 let msg = err.to_string();
2216 assert!(
2217 msg.contains("unexpected") && msg.contains("mystery"),
2218 "must name unexpected key: {msg}"
2219 );
2220 assert_eq!(fs::read_to_string(&path).expect("read"), "hello\n");
2221 }
2222
2223 #[test]
2224 fn edit_payload_allows_same_brace_delta_unbalanced_fragment() {
2225 // Same-delta unbalanced fragment: both sides open one more brace than
2226 // they close (typical mid-block edit).
2227 let search = " handler({\n a: 1,\n";
2228 let replace = " handler({\n a: 1,\n b: 2,\n";
2229 assert!(
2230 edit_payload_looks_corrupted(search, replace).is_none(),
2231 "same brace delta unbalanced fragment must be allowed"
2232 );
2233
2234 // Unbalanced-to-unbalanced with the same closing delta (e.g. near `});`).
2235 let search = " done();\n });\n";
2236 let replace = " done();\n cleanup();\n });\n";
2237 assert!(
2238 edit_payload_looks_corrupted(search, replace).is_none(),
2239 "unbalanced-to-unbalanced with same delta (e.g. around `}});`) must be allowed"
2240 );
2241 }
2242
2243 #[test]
2244 fn edit_payload_rejects_divergent_brace_delta() {
2245 let search = "fn f() {\n body\n}\n";
2246 let replace = "fn f() {\n body\n"; // lost closing brace
2247 let reason =
2248 edit_payload_looks_corrupted(search, replace).expect("divergent brace delta must reject");
2249 assert!(
2250 reason.contains("brace balance") || reason.contains("unbalanced"),
2251 "reason should mention brace balance: {reason}"
2252 );
2253 }
2254
2255 #[test]
2256 fn edit_payload_still_rejects_empty_bracket_collapse() {
2257 let search = r#"SendMessageOutcome::Finished {
2258 status: TurnOutcomeStatus::Interrupted,
2259 ..
2260 } => self.pause_goal_after_interruption().await,"#;
2261 let replace = "[
2262
2263 ] => {},";
2264 assert!(
2265 edit_payload_looks_corrupted(search, replace).is_some(),
2266 "empty bracket collapse must still fail closed"
2267 );
2268 }
2269
2270 #[test]
2271 fn edit_payload_still_rejects_extreme_shrinkage() {
2272 // Many nested braces in search, collapsed to a tiny stub that lost opens.
2273 let search = "fn long_match_arm() {\n".to_string()
2274 + &" if cond { statement(); }\n".repeat(20)
2275 + "}\n";
2276 let replace = "fn long_match_arm() {}\n";
2277 assert!(
2278 search.len() >= 80,
2279 "fixture must be long enough for shrinkage guard"
2280 );
2281 assert!(
2282 edit_payload_looks_corrupted(&search, replace).is_some(),
2283 "extreme shrinkage with lost braces must still fail closed"
2284 );
2285 }
2286
2287 #[tokio::test]
2288 async fn test_list_dir_tool() {
2289 let tmp = tempdir().expect("tempdir");
2290 let ctx = ToolContext::new(tmp.path().to_path_buf());
2291
2292 // Create some files and directories
2293 fs::write(tmp.path().join("file1.txt"), "").expect("write");
2294 fs::write(tmp.path().join("file2.txt"), "").expect("write");
2295 fs::create_dir(tmp.path().join("subdir")).expect("mkdir");
2296
2297 let tool = ListDirTool;
2298 let result = tool.execute(json!({}), &ctx).await.expect("execute");
2299
2300 assert!(result.success);
2301 assert!(result.content.contains("file1.txt"));
2302 assert!(result.content.contains("file2.txt"));
2303 assert!(result.content.contains("subdir"));
2304 let entries: Value = serde_json::from_str(&result.content).expect("list_dir json");
2305 assert!(entries.as_array().expect("entries").iter().any(|entry| {
2306 entry.get("name").and_then(Value::as_str) == Some("subdir")
2307 && entry.get("is_dir").and_then(Value::as_bool) == Some(true)
2308 }));
2309 }
2310
2311 #[tokio::test]
2312 async fn test_list_dir_with_path() {
2313 let tmp = tempdir().expect("tempdir");
2314 let ctx = ToolContext::new(tmp.path().to_path_buf());
2315
2316 // Create a subdirectory with files
2317 let subdir = tmp.path().join("mydir");
2318 fs::create_dir(&subdir).expect("mkdir");
2319 fs::write(subdir.join("nested.txt"), "").expect("write");
2320
2321 let tool = ListDirTool;
2322 let result = tool
2323 .execute(json!({"path": "mydir"}), &ctx)
2324 .await
2325 .expect("execute");
2326
2327 assert!(result.success);
2328 assert!(result.content.contains("nested.txt"));
2329 }
2330
2331 #[tokio::test]
2332 async fn test_list_dir_small_dir_keeps_plain_array_response() {
2333 let tmp = tempdir().expect("tempdir");
2334 let ctx = ToolContext::new(tmp.path().to_path_buf());
2335 fs::write(tmp.path().join("only.txt"), "").expect("write");
2336
2337 let tool = ListDirTool;
2338 let result = tool.execute(json!({}), &ctx).await.expect("execute");
2339
2340 let parsed: Value = serde_json::from_str(&result.content).expect("json");
2341 assert!(
2342 parsed.is_array(),
2343 "small dirs must keep the historical array shape: {parsed}"
2344 );
2345 assert_eq!(parsed.as_array().unwrap().len(), 1);
2346 }
2347
2348 #[tokio::test]
2349 async fn test_list_dir_caps_entries_with_truncation_metadata() {
2350 let tmp = tempdir().expect("tempdir");
2351 let ctx = ToolContext::new(tmp.path().to_path_buf());
2352 let extra = 7;
2353 for i in 0..LIST_DIR_MAX_ENTRIES + extra {
2354 fs::write(tmp.path().join(format!("f{i:04}.txt")), "").expect("write");
2355 }
2356
2357 let tool = ListDirTool;
2358 let result = tool.execute(json!({}), &ctx).await.expect("execute");
2359
2360 let parsed: Value = serde_json::from_str(&result.content).expect("json");
2361 assert!(parsed.is_object(), "oversized dirs return an object");
2362 assert_eq!(parsed["truncated"], json!(true));
2363 assert_eq!(
2364 parsed["listed_entries"].as_u64().unwrap() as usize,
2365 LIST_DIR_MAX_ENTRIES
2366 );
2367 assert_eq!(
2368 parsed["total_entries"].as_u64().unwrap() as usize,
2369 LIST_DIR_MAX_ENTRIES + extra
2370 );
2371 assert_eq!(
2372 parsed["entries"].as_array().unwrap().len(),
2373 LIST_DIR_MAX_ENTRIES
2374 );
2375 }
2376
2377 #[tokio::test]
2378 async fn test_list_dir_respects_cancel_token() {
2379 let tmp = tempdir().expect("tempdir");
2380 fs::write(tmp.path().join("file.txt"), "").expect("write");
2381 let cancel_token = CancellationToken::new();
2382 cancel_token.cancel();
2383 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);
2384
2385 let tool = ListDirTool;
2386 let err = tool
2387 .execute(json!({}), &ctx)
2388 .await
2389 .expect_err("cancelled list_dir should return an error");
2390
2391 assert!(
2392 format!("{err:?}").contains("cancelled"),
2393 "unexpected error: {err:?}"
2394 );
2395 }
2396
2397 #[tokio::test]
2398 async fn test_list_dir_blocking_wrapper_reports_timeout() {
2399 let err = run_blocking_list_dir(Duration::from_millis(1), None, || {
2400 std::thread::sleep(Duration::from_millis(50));
2401 Ok(Value::Array(Vec::new()))
2402 })
2403 .await
2404 .expect_err("slow list_dir worker should time out");
2405
2406 assert!(
2407 matches!(err, ToolError::Timeout { seconds: 1 }),
2408 "unexpected error: {err:?}"
2409 );
2410 }
2411
2412 #[test]
2413 fn test_read_file_tool_properties() {
2414 let tool = ReadFileTool;
2415 assert_eq!(tool.name(), "read_file");
2416 assert!(tool.is_read_only());
2417 assert!(tool.is_sandboxable());
2418 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
2419 }
2420
2421 #[test]
2422 fn test_write_file_tool_properties() {
2423 let tool = WriteFileTool;
2424 assert_eq!(tool.name(), "write_file");
2425 assert!(!tool.is_read_only());
2426 assert!(tool.is_sandboxable());
2427 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
2428 }
2429
2430 #[test]
2431 fn test_edit_file_tool_properties() {
2432 let tool = EditFileTool;
2433 assert_eq!(tool.name(), "edit_file");
2434 assert!(!tool.is_read_only());
2435 assert!(tool.is_sandboxable());
2436 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Suggest);
2437 assert!(tool.description().contains("exact search/replace"));
2438 assert!(tool.description().contains("structural"));
2439 }
2440
2441 #[test]
2442 fn test_list_dir_tool_properties() {
2443 let tool = ListDirTool;
2444 assert_eq!(tool.name(), "list_dir");
2445 assert!(tool.is_read_only());
2446 assert!(tool.is_sandboxable());
2447 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
2448 }
2449
2450 #[test]
2451 fn test_parallel_support_flags() {
2452 let read_tool = ReadFileTool;
2453 let list_tool = ListDirTool;
2454 let write_tool = WriteFileTool;
2455
2456 assert!(read_tool.supports_parallel());
2457 assert!(list_tool.supports_parallel());
2458 assert!(!write_tool.supports_parallel());
2459 }
2460
2461 #[test]
2462 fn test_input_schemas() {
2463 // Verify all tools have valid JSON schemas
2464 let read_schema = ReadFileTool.input_schema();
2465 assert!(read_schema.get("type").is_some());
2466 assert!(read_schema.get("properties").is_some());
2467
2468 let write_schema = WriteFileTool.input_schema();
2469 let required = write_schema
2470 .get("required")
2471 .and_then(|value| value.as_array())
2472 .expect("write schema should include required array");
2473 assert!(required.iter().any(|v| v.as_str() == Some("path")));
2474 assert!(required.iter().any(|v| v.as_str() == Some("content")));
2475
2476 let edit_schema = EditFileTool.input_schema();
2477 let required = edit_schema
2478 .get("required")
2479 .and_then(|value| value.as_array())
2480 .expect("edit schema should include required array");
2481 let required_fields: Vec<_> = required.iter().filter_map(|value| value.as_str()).collect();
2482 assert_eq!(required_fields, vec!["path", "search", "replace"]);
2483 assert!(!required_fields.contains(&"fuzz"));
2484 // `fuzz` was never read by `edit` — it was parsed into a discarded
2485 // binding while the schema advertised it. An unimplemented parameter has
2486 // no place in a schema the model is asked to trust.
2487 assert!(edit_schema["properties"].get("fuzz").is_none());
2488 let search_desc = edit_schema["properties"]["search"]["description"]
2489 .as_str()
2490 .expect("search description");
2491 assert!(search_desc.contains("Exact text"));
2492 assert!(search_desc.contains("whitespace"));
2493
2494 let list_schema = ListDirTool.input_schema();
2495 let required = list_schema
2496 .get("required")
2497 .and_then(|value| value.as_array())
2498 .expect("list schema should include required array");
2499 assert!(required.is_empty()); // path is optional
2500 }
2501
2502 // === Content-hash edit guards (#3979) ===
2503 //
2504 // The guard's whole value is that a stale hash stops the write *before* it
2505 // happens, so every rejection case asserts the file is byte-for-byte
2506 // unchanged — a clear error over a corrupted file is the point.
2507
2508 /// Read the hash the model would actually see, the way the model sees it:
2509 /// parsed out of the tool result's content, never out of its metadata.
2510 async fn reported_content_hash(ctx: &ToolContext, path: &str) -> String {
2511 let result = ReadFileTool
2512 .execute(json!({ "path": path }), ctx)
2513 .await
2514 .expect("read");
2515 let (_, rest) = result
2516 .content
2517 .split_once("content_hash=\"")
2518 .unwrap_or_else(|| panic!("read output carries no content_hash: {}", result.content));
2519 let (hash, _) = rest.split_once('"').expect("terminated content_hash");
2520 hash.to_string()
2521 }
2522
2523 #[tokio::test]
2524 async fn reported_hash_verifies_against_the_file_contents() {
2525 let tmp = tempdir().expect("tempdir");
2526 let ctx = ToolContext::new(tmp.path().to_path_buf());
2527 let body = "alpha\nbeta\ngamma\n";
2528 fs::write(tmp.path().join("doc.txt"), body).expect("write");
2529
2530 let reported = reported_content_hash(&ctx, "doc.txt").await;
2531 assert_eq!(reported, super::content_hash(body.as_bytes()));
2532 assert!(reported.starts_with("sha256:"), "{reported}");
2533 assert_eq!(reported.len(), "sha256:".len() + 64, "{reported}");
2534 }
2535
2536 #[tokio::test]
2537 async fn windowed_read_reports_the_whole_file_hash_not_the_window() {
2538 let tmp = tempdir().expect("tempdir");
2539 let ctx = ToolContext::new(tmp.path().to_path_buf());
2540 let body: String = (1..=40).map(|n| format!("line {n}\n")).collect();
2541 fs::write(tmp.path().join("many.txt"), &body).expect("write");
2542
2543 // A partial read must still hand back a guard for the *file*, or the
2544 // model could only ever guard edits to files it read in full.
2545 let result = ReadFileTool
2546 .execute(
2547 json!({ "path": "many.txt", "start_line": 5, "max_lines": 3 }),
2548 &ctx,
2549 )
2550 .await
2551 .expect("read");
2552 assert!(
2553 result.content.contains("shown_lines=\"5-7\""),
2554 "{}",
2555 result.content
2556 );
2557 assert!(
2558 result.content.contains(&format!(
2559 "content_hash=\"{}\"",
2560 super::content_hash(body.as_bytes())
2561 )),
2562 "{}",
2563 result.content
2564 );
2565 }
2566
2567 #[tokio::test]
2568 async fn edit_with_matching_expected_hash_proceeds() {
2569 let tmp = tempdir().expect("tempdir");
2570 let ctx = ToolContext::new(tmp.path().to_path_buf());
2571 let path = tmp.path().join("doc.txt");
2572 fs::write(&path, "alpha\nbeta\n").expect("write");
2573
2574 let hash = reported_content_hash(&ctx, "doc.txt").await;
2575 EditFileTool
2576 .execute(
2577 json!({
2578 "path": "doc.txt",
2579 "search": "alpha",
2580 "replace": "delta",
2581 "expected_hash": hash,
2582 }),
2583 &ctx,
2584 )
2585 .await
2586 .expect("matching hash must not block the edit");
2587
2588 assert_eq!(fs::read_to_string(&path).expect("read"), "delta\nbeta\n");
2589 }
2590
2591 #[tokio::test]
2592 async fn edit_with_stale_expected_hash_rejects_without_writing() {
2593 let tmp = tempdir().expect("tempdir");
2594 let ctx = ToolContext::new(tmp.path().to_path_buf());
2595 let path = tmp.path().join("doc.txt");
2596 fs::write(&path, "alpha\nbeta\n").expect("write");
2597
2598 let stale = reported_content_hash(&ctx, "doc.txt").await;
2599 // Someone else edits the file between the read and the edit.
2600 fs::write(&path, "alpha\nbeta\ngamma\n").expect("concurrent write");
2601 // Re-read so the *other* staleness gate (mtime/size) cannot be what
2602 // rejects this — the hash must be doing the work.
2603 read_before_edit(&ctx, "doc.txt").await;
2604
2605 let err = EditFileTool
2606 .execute(
2607 json!({
2608 "path": "doc.txt",
2609 "search": "alpha",
2610 "replace": "delta",
2611 "expected_hash": stale,
2612 }),
2613 &ctx,
2614 )
2615 .await
2616 .expect_err("stale hash must reject");
2617
2618 let message = err.to_string();
2619 assert!(message.contains("changed since it was read"), "{message}");
2620 assert!(
2621 message.contains("re-read") || message.contains("action=\"read\""),
2622 "{message}"
2623 );
2624 assert_eq!(
2625 fs::read_to_string(&path).expect("read"),
2626 "alpha\nbeta\ngamma\n",
2627 "a rejected edit must not modify the file"
2628 );
2629 }
2630
2631 #[tokio::test]
2632 async fn edit_without_expected_hash_is_unchanged() {
2633 let tmp = tempdir().expect("tempdir");
2634 let ctx = ToolContext::new(tmp.path().to_path_buf());
2635 let path = tmp.path().join("doc.txt");
2636 fs::write(&path, "alpha\nbeta\n").expect("write");
2637 read_before_edit(&ctx, "doc.txt").await;
2638
2639 EditFileTool
2640 .execute(
2641 json!({ "path": "doc.txt", "search": "alpha", "replace": "delta" }),
2642 &ctx,
2643 )
2644 .await
2645 .expect("absent expected_hash keeps the pre-#3979 behavior");
2646
2647 assert_eq!(fs::read_to_string(&path).expect("read"), "delta\nbeta\n");
2648 }
2649
2650 #[tokio::test]
2651 async fn write_with_stale_expected_hash_rejects_without_writing() {
2652 let tmp = tempdir().expect("tempdir");
2653 let ctx = ToolContext::new(tmp.path().to_path_buf());
2654 let path = tmp.path().join("doc.txt");
2655 fs::write(&path, "original\n").expect("write");
2656
2657 let stale = super::content_hash(b"something else entirely\n");
2658 let err = WriteFileTool
2659 .execute(
2660 json!({ "path": "doc.txt", "content": "clobbered\n", "expected_hash": stale }),
2661 &ctx,
2662 )
2663 .await
2664 .expect_err("stale hash must reject");
2665
2666 assert!(
2667 err.to_string().contains("changed since it was read"),
2668 "{err}"
2669 );
2670 assert_eq!(
2671 fs::read_to_string(&path).expect("read"),
2672 "original\n",
2673 "a rejected write must not modify the file"
2674 );
2675 }
2676
2677 #[tokio::test]
2678 async fn write_with_matching_expected_hash_proceeds() {
2679 let tmp = tempdir().expect("tempdir");
2680 let ctx = ToolContext::new(tmp.path().to_path_buf());
2681 let path = tmp.path().join("doc.txt");
2682 fs::write(&path, "original\n").expect("write");
2683
2684 let hash = reported_content_hash(&ctx, "doc.txt").await;
2685 WriteFileTool
2686 .execute(
2687 json!({ "path": "doc.txt", "content": "replaced\n", "expected_hash": hash }),
2688 &ctx,
2689 )
2690 .await
2691 .expect("matching hash must not block the write");
2692
2693 assert_eq!(fs::read_to_string(&path).expect("read"), "replaced\n");
2694 }
2695
2696 #[tokio::test]
2697 async fn write_with_expected_hash_on_a_missing_file_fails_closed() {
2698 let tmp = tempdir().expect("tempdir");
2699 let ctx = ToolContext::new(tmp.path().to_path_buf());
2700
2701 // There is no snapshot to verify, so honoring the guard is impossible.
2702 // Creating the file anyway would silently give back less safety than the
2703 // caller asked for.
2704 let err = WriteFileTool
2705 .execute(
2706 json!({
2707 "path": "new.txt",
2708 "content": "x\n",
2709 "expected_hash": super::content_hash(b"anything"),
2710 }),
2711 &ctx,
2712 )
2713 .await
2714 .expect_err("guarded write to a missing file must fail closed");
2715
2716 assert!(err.to_string().contains("does not exist"), "{err}");
2717 assert!(
2718 !tmp.path().join("new.txt").exists(),
2719 "a rejected write must not create the file"
2720 );
2721 }
2722
2723 #[tokio::test]
2724 async fn write_without_expected_hash_still_creates_files() {
2725 let tmp = tempdir().expect("tempdir");
2726 let ctx = ToolContext::new(tmp.path().to_path_buf());
2727
2728 WriteFileTool
2729 .execute(json!({ "path": "new.txt", "content": "x\n" }), &ctx)
2730 .await
2731 .expect("absent expected_hash keeps the pre-#3979 behavior");
2732
2733 assert_eq!(
2734 fs::read_to_string(tmp.path().join("new.txt")).expect("read"),
2735 "x\n"
2736 );
2737 }
2738
2739 #[tokio::test]
2740 async fn expected_hash_is_advertised_on_every_mutating_action() {
2741 for schema in [
2742 WriteFileTool.input_schema(),
2743 EditFileTool.input_schema(),
2744 crate::tools::apply_patch::ApplyPatchTool.input_schema(),
2745 ] {
2746 let description = schema["properties"]["expected_hash"]["description"]
2747 .as_str()
2748 .expect("expected_hash must be advertised");
2749 assert!(description.contains("content_hash"), "{description}");
2750 }
2751 }
2752
2753 /// S1: the in-process read tools are the *only* enforcement point for
2754 /// `read_file`/`read`/`read_media` — they call `std::fs` inside the harness
2755 /// process, so `sandbox-exec` and `bwrap` never see them. This asserts the
2756 /// refusal is an explicit permission error, not an empty result, and that it
2757 /// applies to the built-in defaults with no config required.
2758 #[test]
2759 fn read_tools_refuse_paths_under_the_default_sandbox_read_denylist() {
2760 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2761 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
2762 // snapshots the home directory the first time it is built, so pointing
2763 // `HOME` at a fixture here could never match the cached table. What this
2764 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
2765 // not a home of its own.
2766 let _env_lock = crate::test_support::lock_test_env();
2767 let Some(home) = dirs::home_dir() else {
2768 // No home directory: only machine-wide rules exist and the assertion
2769 // below would be vacuous. Skip rather than pretend to have evidence.
2770 return;
2771 };
2772
2773 let error = enforce_read_denylist(&home.join(".ssh").join("id_ed25519"), "read_file")
2774 .expect_err("~/.ssh must be denied by the built-in defaults");
2775 assert!(
2776 matches!(error, ToolError::PermissionDenied { .. }),
2777 "a denied read must be an explicit refusal, never an empty or missing-file result: {error:?}"
2778 );
2779 let message = error.to_string();
2780 assert!(message.contains("read deny-list"), "{message}");
2781 assert!(
2782 message.contains("sandbox_read_denylist_exempt"),
2783 "{message}"
2784 );
2785
2786 // Ordinary source files stay readable — a coding agent must still be able
2787 // to read the user's tree, which is the whole point of the tool.
2788 let temporary = tempfile::tempdir().expect("tempdir");
2789 let source = temporary.path().join("main.rs");
2790 std::fs::write(&source, "fn main() {}\n").expect("fixture");
2791 assert!(enforce_read_denylist(&source, "read_file").is_ok());
2792 }
2793
2794 /// A symlink whose own name is innocuous but whose target is a credential
2795 /// store must be refused by the target. A deny-list a symlink walks around is
2796 /// theater, and `resolve_path` deliberately *permits* a workspace symlink that
2797 /// resolves outside the workspace.
2798 #[cfg(unix)]
2799 #[allow(clippy::await_holding_lock)]
2800 #[tokio::test]
2801 async fn read_file_refuses_a_workspace_symlink_pointing_at_a_denied_tree() {
2802 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2803 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
2804 // snapshots the home directory the first time it is built, so pointing
2805 // `HOME` at a fixture here could never match the cached table. What this
2806 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
2807 // not a home of its own.
2808 let _env_lock = crate::test_support::lock_test_env();
2809 let Some(home) = dirs::home_dir() else {
2810 return;
2811 };
2812 let ssh = home.join(".ssh");
2813 if !ssh.is_dir() {
2814 // Nothing to point at; a fabricated pass here would be worse than a skip.
2815 return;
2816 }
2817
2818 let workspace = tempfile::tempdir().expect("tempdir");
2819 let link = workspace.path().join("notes.txt");
2820 std::os::unix::fs::symlink(&ssh, &link).expect("symlink");
2821
2822 let error = enforce_read_denylist(&link, "read_file")
2823 .expect_err("a symlink into ~/.ssh must be refused by its target");
2824 let message = error.to_string();
2825 assert!(message.contains("symlink"), "{message}");
2826 // The rule's *label* ("SSH keys (~/.ssh)") is named on purpose — the user
2827 // needs to know which rule to exempt. What must never appear is the
2828 // resolved absolute path, which is the location the caller was fishing for.
2829 assert!(
2830 !message.contains(&ssh.display().to_string()),
2831 "the refusal must not hand back the secret's resolved location: {message}"
2832 );
2833 }
2834
2835 /// F1: `list_dir ~/.ssh` used to hand back the key file names — enumerating a
2836 /// denied directory is a read of it, exactly what Seatbelt's
2837 /// `deny file-read*` blocks at the OS layer.
2838 #[allow(clippy::await_holding_lock)]
2839 #[tokio::test]
2840 async fn list_dir_refuses_to_enumerate_a_denied_directory() {
2841 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2842 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
2843 // snapshots the home directory the first time it is built, so pointing
2844 // `HOME` at a fixture here could never match the cached table. What this
2845 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
2846 // not a home of its own.
2847 let _env_lock = crate::test_support::lock_test_env();
2848 let ctx = ToolContext::new(std::env::temp_dir());
2849
2850 // Deterministic anchor independent of the machine's home layout: the
2851 // `.env` filename rule denies any path whose file name is `.env`, so a
2852 // directory by that name is a refused listing too.
2853 let holder = tempfile::tempdir().expect("tempdir");
2854 let env_dir = holder.path().join("project");
2855 std::fs::create_dir_all(env_dir.join(".env")).expect("mkdir");
2856 let error = ListDirTool
2857 .execute(json!({ "path": env_dir.join(".env") }), &ctx)
2858 .await
2859 .expect_err("a directory named `.env` is denied by the filename rule");
2860 assert!(
2861 matches!(error, ToolError::PermissionDenied { .. }),
2862 "enumeration of a denied path must be an explicit refusal: {error:?}"
2863 );
2864
2865 let Some(home) = dirs::home_dir() else {
2866 return;
2867 };
2868 let ssh = home.join(".ssh");
2869 if !ssh.is_dir() {
2870 return;
2871 }
2872 let error = ListDirTool
2873 .execute(json!({ "path": ssh }), &ctx)
2874 .await
2875 .expect_err("`list_dir ~/.ssh` must not return the key file names");
2876 assert!(
2877 matches!(error, ToolError::PermissionDenied { .. }),
2878 "expected a permission refusal, got: {error:?}"
2879 );
2880 let message = error.to_string();
2881 assert!(message.contains("read deny-list"), "{message}");
2882 }
2883
2884 /// F2: the refusal must name the path as the caller spelled it. When a
2885 /// workspace symlink points into a denied tree, `resolve_path` hands the guard
2886 /// the secret's resolved absolute location first, and a denial raised on that
2887 /// resolved path answers the probe ("where does this link really go?") in the
2888 /// error text. The raw-spelling check runs before resolution, so it wins.
2889 #[cfg(unix)]
2890 #[allow(clippy::await_holding_lock)]
2891 #[tokio::test]
2892 async fn read_file_refusal_names_the_callers_spelling_not_the_symlink_target() {
2893 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
2894 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
2895 // snapshots the home directory the first time it is built, so pointing
2896 // `HOME` at a fixture here could never match the cached table. What this
2897 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
2898 // not a home of its own.
2899 let _env_lock = crate::test_support::lock_test_env();
2900 let Some(home) = dirs::home_dir() else {
2901 return;
2902 };
2903 let ssh = home.join(".ssh");
2904 if !ssh.is_dir() {
2905 return;
2906 }
2907
2908 let workspace = tempfile::tempdir().expect("tempdir");
2909 let link = workspace.path().join("notes.txt");
2910 std::os::unix::fs::symlink(&ssh, &link).expect("symlink");
2911
2912 let ctx = ToolContext::new(workspace.path().to_path_buf());
2913 let error = ReadFileTool
2914 .execute(json!({ "path": link }), &ctx)
2915 .await
2916 .expect_err("a symlink into ~/.ssh must be refused");
2917 assert!(
2918 matches!(error, ToolError::PermissionDenied { .. }),
2919 "expected a permission refusal, got: {error:?}"
2920 );
2921 let message = error.to_string();
2922 assert!(
2923 message.contains("notes.txt"),
2924 "the refusal must name the caller's spelling: {message}"
2925 );
2926 assert!(
2927 !message.contains(&ssh.display().to_string()),
2928 "the refusal must not reveal the symlink target's location: {message}"
2929 );
2930 }
2931
2932 // Reads process-global `HOME` (via `effective_home_dir`) and then resolves `~`
2933 // again through the tool, so it must hold the env lock for the whole span: any
2934 // sibling that rebinds `HOME` between those two reads makes the fixture path
2935 // stop matching the tilde path.
2936 #[allow(clippy::await_holding_lock)]
2937 #[tokio::test]
2938 async fn read_and_write_file_home_path_in_allowed_real_home_fixture() {
2939 let _env_lock = crate::test_support::lock_test_env();
2940 let home = tempfile::tempdir().expect("home tempdir");
2941 let _home = crate::test_support::EnvVarGuard::set("HOME", home.path());
2942 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
2943 let real_home = crate::config::effective_home_dir().expect("test home must be available");
2944 let home_fixture = tempfile::Builder::new()
2945 .prefix("cw_home_tool_fixture_")
2946 .tempdir_in(&real_home)
2947 .expect("create fixture inside test home");
2948
2949 let test_file = home_fixture.path().join("home_note.txt");
2950 let rel = test_file
2951 .strip_prefix(&real_home)
2952 .expect("fixture is below test home");
2953 let tilde_path = format!("~/{}", rel.to_string_lossy());
2954
2955 let ctx = ToolContext::new(home_fixture.path().to_path_buf());
2956
2957 // 1. Write content to home-relative path
2958 let write_result = WriteFileTool
2959 .execute(
2960 json!({
2961 "path": &tilde_path,
2962 "content": "initial home content\n"
2963 }),
2964 &ctx,
2965 )
2966 .await
2967 .expect("write_file to home-relative path inside workspace should succeed");
2968 assert!(write_result.success);
2969
2970 // 2. Read content back and verify content and hash
2971 let read_result = ReadFileTool
2972 .execute(json!({ "path": &tilde_path }), &ctx)
2973 .await
2974 .expect("read_file from home-relative path should succeed");
2975 assert!(read_result.success);
2976 assert!(read_result.content.contains("initial home content\n"));
2977 let expected_hash = crate::tools::file::content_hash(b"initial home content\n");
2978 assert!(read_result.content.contains(&expected_hash));
2979
2980 // 3. Edit content with read-before-write consistency
2981 let edit_result = EditFileTool
2982 .execute(
2983 json!({
2984 "path": &tilde_path,
2985 "search": "initial home",
2986 "replace": "updated home"
2987 }),
2988 &ctx,
2989 )
2990 .await
2991 .expect("edit_file on home-relative path should succeed after read");
2992 assert!(edit_result.success);
2993
2994 // 4. Verify updated read
2995 let updated_read = ReadFileTool
2996 .execute(json!({ "path": &tilde_path }), &ctx)
2997 .await
2998 .expect("read_file after edit should succeed");
2999 assert!(updated_read.content.contains("updated home content\n"));
3000
3001 // 5. list_dir on home-relative directory
3002 let dir_rel = home_fixture.path().strip_prefix(&real_home).unwrap();
3003 let dir_tilde = format!("~/{}", dir_rel.to_string_lossy());
3004 let list_result = ListDirTool
3005 .execute(json!({ "path": &dir_tilde }), &ctx)
3006 .await
3007 .expect("list_dir on home-relative directory should succeed");
3008 assert!(list_result.success);
3009 assert!(list_result.content.contains("home_note.txt"));
3010 }
3011
3012 #[tokio::test]
3013 async fn read_file_home_path_restricted_refusal() {
3014 let workspace = tempfile::tempdir().expect("tempdir");
3015 let ctx = ToolContext::new(workspace.path().to_path_buf());
3016
3017 let error = ReadFileTool
3018 .execute(
3019 json!({ "path": "~/untrusted_outside_workspace_file_98765.txt" }),
3020 &ctx,
3021 )
3022 .await
3023 .expect_err("home path outside workspace without trust must be refused");
3024 assert!(
3025 matches!(
3026 error,
3027 ToolError::PathEscape { .. } | ToolError::ExecutionFailed { .. }
3028 ),
3029 "expected path escape or execution failed, got: {error:?}"
3030 );
3031
3032 let write_error = WriteFileTool
3033 .execute(
3034 json!({
3035 "path": "~/untrusted_outside_workspace_file_98765.txt",
3036 "content": "illegal write"
3037 }),
3038 &ctx,
3039 )
3040 .await
3041 .expect_err("write to untrusted home path must be refused");
3042 assert!(
3043 matches!(
3044 write_error,
3045 ToolError::PathEscape { .. } | ToolError::ExecutionFailed { .. }
3046 ),
3047 "expected path escape or execution failed, got: {write_error:?}"
3048 );
3049 }
3050
3051 #[allow(clippy::await_holding_lock)]
3052 #[tokio::test]
3053 async fn read_file_home_path_trusted_external_allowance() {
3054 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
3055 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
3056 // snapshots the home directory the first time it is built, so pointing
3057 // `HOME` at a fixture here could never match the cached table. What this
3058 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
3059 // not a home of its own.
3060 let _env_lock = crate::test_support::lock_test_env();
3061 let real_home = crate::config::effective_home_dir().expect("test home must be available");
3062 let trusted_fixture = tempfile::Builder::new()
3063 .prefix("cw_home_trusted_fixture_")
3064 .tempdir_in(&real_home)
3065 .expect("create fixture inside test home");
3066
3067 let test_file = trusted_fixture.path().join("external.txt");
3068 std::fs::write(&test_file, "external trusted data\n").expect("write external");
3069 let rel = test_file
3070 .strip_prefix(&real_home)
3071 .expect("fixture is below test home");
3072 let tilde_path = format!("~/{}", rel.to_string_lossy());
3073
3074 let workspace = tempfile::tempdir().expect("tempdir");
3075 let canonical_trusted = trusted_fixture
3076 .path()
3077 .canonicalize()
3078 .unwrap_or_else(|_| trusted_fixture.path().to_path_buf());
3079 let ctx = ToolContext::new(workspace.path().to_path_buf())
3080 .with_trusted_external_paths(vec![canonical_trusted]);
3081
3082 let result = ReadFileTool
3083 .execute(json!({ "path": &tilde_path }), &ctx)
3084 .await
3085 .expect("read_file on trusted external home path should succeed");
3086 assert!(result.success);
3087 assert!(result.content.contains("external trusted data\n"));
3088 }
3089
3090 #[tokio::test]
3091 async fn read_file_literal_tilde_stays_literal() {
3092 let workspace = tempfile::tempdir().expect("tempdir");
3093 let literal_dir = workspace.path().join("~");
3094 std::fs::create_dir_all(&literal_dir).expect("create literal ~ dir");
3095 let literal_file = literal_dir.join("payload.txt");
3096 std::fs::write(&literal_file, "literal dir content").expect("write payload");
3097
3098 let ctx = ToolContext::new(workspace.path().to_path_buf());
3099 let result = ReadFileTool
3100 .execute(json!({ "path": "./~/payload.txt" }), &ctx)
3101 .await
3102 .expect("read_file on literal ./~/ path should read from workspace literal ~ directory");
3103 assert!(result.success);
3104 assert!(result.content.contains("literal dir content"));
3105 }
3106
3107 #[tokio::test]
3108 async fn read_file_no_shell_expansion() {
3109 let workspace = tempfile::tempdir().expect("tempdir");
3110 let home_var_dir = workspace.path().join("$HOME");
3111 std::fs::create_dir_all(&home_var_dir).expect("create literal $HOME dir");
3112 let var_file = home_var_dir.join("shell.txt");
3113 std::fs::write(&var_file, "literal $HOME file").expect("write var file");
3114
3115 let ctx = ToolContext::new(workspace.path().to_path_buf());
3116 let result = ReadFileTool
3117 .execute(json!({ "path": "$HOME/shell.txt" }), &ctx)
3118 .await
3119 .expect("read_file on $HOME/file should read from workspace literal $HOME directory without expanding env vars");
3120 assert!(result.success);
3121 assert!(result.content.contains("literal $HOME file"));
3122 }
3123
3124 // Seals `HOME` to a fixture: this test used to resolve `~` against the
3125 // developer's real home, so a sibling test setting `CODEWHALE_HOME` between the
3126 // tilde expansion and the guard's own lookup could flip it to a pass — and the
3127 // failure printed the developer's actual config file, credentials included.
3128 #[allow(clippy::await_holding_lock)]
3129 #[tokio::test]
3130 async fn read_file_denies_home_credential_path() {
3131 let _env_lock = crate::test_support::lock_test_env();
3132 let home = tempfile::tempdir().expect("home tempdir");
3133 let _home = crate::test_support::EnvVarGuard::set("HOME", home.path());
3134 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
3135 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
3136 let _config_path = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
3137 let _legacy_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
3138 fs::create_dir_all(home.path().join(".codewhale")).expect("create fixture home");
3139 fs::write(
3140 home.path().join(".codewhale").join("config.toml"),
3141 "api_key = \"fixture-secret\"\n",
3142 )
3143 .expect("write fixture config");
3144
3145 let workspace = tempfile::tempdir().expect("tempdir");
3146 // Even in trust mode, credential paths must be blocked
3147 let ctx = ToolContext::new(workspace.path().to_path_buf()).with_trust_mode(true);
3148
3149 let error = ReadFileTool
3150 .execute(json!({ "path": "~/.codewhale/config.toml" }), &ctx)
3151 .await
3152 .expect_err("reading ~/.codewhale/config.toml must be denied");
3153 assert!(
3154 matches!(error, ToolError::PermissionDenied { .. }),
3155 "expected permission denied, got: {error:?}"
3156 );
3157 let message = error.to_string();
3158 assert!(
3159 message.contains("cannot expose Codewhale configuration or credential-store files"),
3160 "error message must protect credentials: {message}"
3161 );
3162 }
3163
3164 /// `CODEWHALE_HOME` relocates the runtime home. It must not un-guard the user's
3165 /// real `~/.codewhale/config.toml`: the guard derived every root from
3166 /// `codewhale_home()`, which returns the override when set, so pointing that
3167 /// variable anywhere else left the ambient config (OAuth tokens included)
3168 /// readable in trust mode. `sandbox::read_guard` only covers
3169 /// `~/.codewhale/secrets`, so nothing else was denying this file.
3170 #[allow(clippy::await_holding_lock)]
3171 #[tokio::test]
3172 async fn read_file_denies_ambient_home_config_even_when_codewhale_home_is_relocated() {
3173 let _env_lock = crate::test_support::lock_test_env();
3174 let home = tempfile::tempdir().expect("home tempdir");
3175 let relocated = tempfile::tempdir().expect("relocated home tempdir");
3176 let _home = crate::test_support::EnvVarGuard::set("HOME", home.path());
3177 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
3178 // The override points somewhere else entirely — the ambient store must stay guarded.
3179 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", relocated.path());
3180 let _config_path = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
3181 let _legacy_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
3182
3183 let ambient = home.path().join(".codewhale");
3184 fs::create_dir_all(&ambient).expect("create ambient home");
3185 fs::write(
3186 ambient.join("config.toml"),
3187 "[providers.openai]\napi_key = \"sk-ambient-must-not-leak\"\n",
3188 )
3189 .expect("write ambient config");
3190 fs::write(
3191 ambient.join("config.toml.bak"),
3192 "[providers.openai]\napi_key = \"sk-ambient-backup-must-not-leak\"\n",
3193 )
3194 .expect("write ambient config backup");
3195
3196 let workspace = tempfile::tempdir().expect("tempdir");
3197 let ctx = ToolContext::new(workspace.path().to_path_buf()).with_trust_mode(true);
3198
3199 for name in ["config.toml", "config.toml.bak"] {
3200 let target = ambient.join(name);
3201 let error = ReadFileTool
3202 .execute(json!({ "path": target.to_string_lossy() }), &ctx)
3203 .await
3204 .err()
3205 .unwrap_or_else(|| {
3206 panic!("reading the ambient {name} must be denied despite CODEWHALE_HOME")
3207 });
3208 assert!(
3209 matches!(error, ToolError::PermissionDenied { .. }),
3210 "expected permission denied for {name}, got: {error:?}"
3211 );
3212 assert!(
3213 !error.to_string().contains("must-not-leak"),
3214 "the denial must not echo credential content for {name}"
3215 );
3216 }
3217 }
3218
3219 #[cfg(unix)]
3220 #[allow(clippy::await_holding_lock)]
3221 #[tokio::test]
3222 async fn read_file_refusal_names_home_spelling_not_denied_symlink_target() {
3223 // Take the env lock WITHOUT rebinding `HOME`. The sandbox denylist is a
3224 // process-wide `OnceLock` (sandbox/read_guard.rs:346-347, 411-416) that
3225 // snapshots the home directory the first time it is built, so pointing
3226 // `HOME` at a fixture here could never match the cached table. What this
3227 // test needs is mutual exclusion against siblings that DO rebind `HOME`,
3228 // not a home of its own.
3229 let _env_lock = crate::test_support::lock_test_env();
3230 let real_home = crate::config::effective_home_dir().expect("test home must be available");
3231 let home_fixture = tempfile::Builder::new()
3232 .prefix("cw_home_symlink_fixture_")
3233 .tempdir_in(&real_home)
3234 .expect("create fixture inside test home");
3235
3236 let denied_file = home_fixture.path().join(".env");
3237 std::fs::write(&denied_file, "SYNTHETIC_FIXTURE=not-a-secret").expect("create denied fixture");
3238 let link = home_fixture.path().join("ssh_probe");
3239 std::os::unix::fs::symlink(&denied_file, &link).expect("symlink to denied file");
3240
3241 let rel = link
3242 .strip_prefix(&real_home)
3243 .expect("fixture is below test home");
3244 let tilde_path = format!("~/{}", rel.to_string_lossy());
3245
3246 let ctx = ToolContext::new(home_fixture.path().to_path_buf());
3247 let error = ReadFileTool
3248 .execute(json!({ "path": &tilde_path }), &ctx)
3249 .await
3250 .expect_err("symlink pointing to a denied file must be refused");
3251 assert!(
3252 matches!(error, ToolError::PermissionDenied { .. }),
3253 "expected permission refusal, got: {error:?}"
3254 );
3255 let message = error.to_string();
3256 assert!(
3257 message.contains(&tilde_path),
3258 "refusal message must name caller's tilde path ({tilde_path}): {message}"
3259 );
3260 assert!(
3261 !message.contains(&denied_file.display().to_string()),
3262 "refusal message must NOT leak target path ({}): {message}",
3263 denied_file.display()
3264 );
3265 }
3266
3266 lines RUST