返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tools / spec / tests.rs
1 use super::*;
2 use serde_json::json;
3 #[cfg(unix)]
4 use std::os::unix::fs::symlink;
5 use tempfile::tempdir;
6
7 #[test]
8 fn test_tool_result_success() {
9 let result = ToolResult::success("hello");
10 assert!(result.success);
11 assert_eq!(result.content, "hello");
12 assert!(result.metadata.is_none());
13 }
14
15 #[test]
16 fn test_tool_result_error() {
17 let result = ToolResult::error("something failed");
18 assert!(!result.success);
19 assert_eq!(result.content, "something failed");
20 }
21
22 #[test]
23 fn test_tool_result_json() {
24 let data = json!({"key": "value"});
25 let result = ToolResult::json(&data).unwrap();
26 assert!(result.success);
27 assert!(result.content.contains("key"));
28 }
29
30 #[test]
31 fn test_tool_result_with_metadata() {
32 let result = ToolResult::success("content").with_metadata(json!({"extra": true}));
33 assert!(result.metadata.is_some());
34 }
35
36 #[test]
37 fn test_tool_context_resolve_path_relative() {
38 let tmp = tempdir().expect("tempdir");
39 let ctx = ToolContext::new(tmp.path().to_path_buf());
40
41 // Create a test file
42 let test_file = tmp.path().join("test.txt");
43 std::fs::write(&test_file, "test").expect("write");
44
45 let resolved = ctx.resolve_path("test.txt").expect("resolve");
46 assert!(resolved.ends_with("test.txt"));
47 }
48
49 #[test]
50 fn test_tool_context_resolve_path_escape() {
51 let tmp = tempdir().expect("tempdir");
52 let ctx = ToolContext::new(tmp.path().to_path_buf());
53
54 // Try to escape workspace
55 let result = ctx.resolve_path("/etc/passwd");
56 assert!(result.is_err());
57 }
58
59 #[test]
60 fn test_tool_context_resolve_path_parent_traversal() {
61 let tmp = tempdir().expect("tempdir");
62 let ctx = ToolContext::new(tmp.path().to_path_buf());
63
64 let result = ctx.resolve_path("../escape.txt");
65 assert!(result.is_err());
66 }
67
68 #[test]
69 fn test_tool_context_resolve_path_normalizes_parent() {
70 let tmp = tempdir().expect("tempdir");
71 let ctx = ToolContext::new(tmp.path().to_path_buf());
72
73 let result = ctx.resolve_path("new/../safe.txt");
74 assert!(result.is_ok());
75 }
76
77 #[test]
78 fn test_tool_context_trust_mode() {
79 let tmp = tempdir().expect("tempdir");
80 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_trust_mode(true);
81
82 // In trust mode, absolute paths should work
83 let result = ctx.resolve_path("/tmp");
84 assert!(result.is_ok());
85 }
86
87 #[test]
88 fn tool_context_keeps_execution_state_grouped_and_value_cloned() {
89 let mut context = ToolContext::new(".");
90 context.auto_approve = true;
91 context.state_namespace = "session-a".to_string();
92
93 assert!(context.execution.auto_approve);
94 assert_eq!(context.execution.state_namespace, "session-a");
95
96 let mut cloned = context.clone();
97 cloned.state_namespace = "session-b".to_string();
98 assert_eq!(context.state_namespace, "session-a");
99 assert_eq!(cloned.execution.state_namespace, "session-b");
100 }
101
102 #[test]
103 fn tool_context_top_level_stays_slim_as_services_grow() {
104 assert!(
105 std::mem::size_of::<ToolContext>()
106 <= std::mem::size_of::<PathBuf>() + 2 * std::mem::size_of::<usize>(),
107 "ToolContext should contain only the workspace and boxed execution group"
108 );
109 }
110
111 /// Issue #29: paths under a user-trusted external directory resolve
112 /// successfully even though they fall outside the workspace, while
113 /// untrusted external paths still error with `PathEscape`.
114 #[test]
115 fn test_tool_context_trusted_external_path_allows_escape() {
116 let workspace = tempdir().expect("workspace tempdir");
117 let trusted_root = tempdir().expect("trusted tempdir");
118 let trusted_file = trusted_root.path().join("notes.md");
119 std::fs::write(&trusted_file, "shared notes").unwrap();
120
121 let ctx = ToolContext::new(workspace.path().to_path_buf()).with_trusted_external_paths(vec![
122 trusted_root
123 .path()
124 .canonicalize()
125 .unwrap_or_else(|_| trusted_root.path().to_path_buf()),
126 ]);
127
128 let resolved = ctx
129 .resolve_path(trusted_file.to_str().unwrap())
130 .expect("trusted path should resolve");
131 assert!(resolved.ends_with("notes.md"));
132
133 // Path outside workspace AND outside the trust list should still fail.
134 let other = tempdir().expect("untrusted tempdir");
135 let other_file = other.path().join("secret.md");
136 std::fs::write(&other_file, "x").unwrap();
137 let err = ctx
138 .resolve_path(other_file.to_str().unwrap())
139 .expect_err("untrusted path must error");
140 assert!(matches!(err, ToolError::PathEscape { .. }));
141 }
142
143 #[test]
144 #[cfg(unix)]
145 fn test_tool_context_follow_symlinks_allows_nonexistent_path_under_workspace_symlink() {
146 let tmp = tempdir().expect("tempdir");
147 let workspace = tmp.path().join("workspace");
148 let outside = tmp.path().join("outside");
149 std::fs::create_dir_all(&workspace).expect("mkdir workspace");
150 std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
151 symlink(outside.join("target"), workspace.join("linked")).expect("symlink");
152
153 let ctx = ToolContext::new(workspace).with_follow_symlinks(true);
154 let resolved = ctx
155 .resolve_path("linked/new.txt")
156 .expect("path under workspace symlink should resolve");
157
158 let expected = outside
159 .join("target")
160 .canonicalize()
161 .expect("canonical target")
162 .join("new.txt");
163 assert_eq!(resolved, normalize_path(&expected));
164 }
165
166 #[test]
167 #[cfg(unix)]
168 fn test_tool_context_default_mode_rejects_nonexistent_path_under_workspace_symlink() {
169 let tmp = tempdir().expect("tempdir");
170 let workspace = tmp.path().join("workspace");
171 let outside = tmp.path().join("outside");
172 std::fs::create_dir_all(&workspace).expect("mkdir workspace");
173 std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
174 symlink(outside.join("target"), workspace.join("linked")).expect("symlink");
175
176 let ctx = ToolContext::new(workspace);
177 let err = ctx
178 .resolve_path("linked/new.txt")
179 .expect_err("default mode should still reject workspace symlink escapes");
180
181 assert!(matches!(err, ToolError::PathEscape { .. }));
182 }
183
184 fn scoped_authority(roots: &[&str], files: &[&str]) -> ToolAuthorityEnvelope {
185 ToolAuthorityEnvelope {
186 schema_version: 1,
187 owner: "fleet-worker-1".to_string(),
188 authority: ToolMutationAuthority::ScopedWrite,
189 network_access: None,
190 shell: ToolShellAuthority::None,
191 verification: ToolVerificationAuthority::None,
192 writable_roots: roots.iter().map(|value| (*value).to_string()).collect(),
193 writable_files: files.iter().map(|value| (*value).to_string()).collect(),
194 coordination_contracts: Vec::new(),
195 }
196 .normalized()
197 .expect("valid test authority")
198 }
199
200 #[test]
201 fn tool_authority_allows_normal_nonexistent_children_only_inside_scope() {
202 let tmp = tempdir().expect("tempdir");
203 std::fs::create_dir(tmp.path().join("src")).expect("src");
204 let context = ToolContext::new(tmp.path().to_path_buf());
205 let authority = scoped_authority(&["src"], &[]);
206
207 assert!(
208 authority
209 .permits_mutation_path(&context, "src/new/nested.rs")
210 .expect("normal nonexistent child")
211 );
212 assert!(
213 !authority
214 .permits_mutation_path(&context, "docs/outside.md")
215 .expect("ordinary out-of-scope path")
216 );
217 }
218
219 #[cfg(unix)]
220 #[test]
221 fn tool_authority_rejects_exact_file_symlink_aliases() {
222 let tmp = tempdir().expect("tempdir");
223 std::fs::create_dir(tmp.path().join("src")).expect("src");
224 std::fs::create_dir(tmp.path().join("other")).expect("other");
225 std::fs::write(tmp.path().join("other/target.rs"), "outside scope\n").expect("target");
226 symlink("../other/target.rs", tmp.path().join("src/alias.rs")).expect("alias");
227 let context = ToolContext::new(tmp.path().to_path_buf());
228 let authority = scoped_authority(&[], &["src/alias.rs"]);
229
230 let error = authority
231 .permits_mutation_path(&context, "src/alias.rs")
232 .expect_err("an exact-file claim must not authorize a symlink target")
233 .to_string();
234 assert!(error.contains("must not traverse symlinks"), "{error}");
235 }
236
237 #[cfg(unix)]
238 #[test]
239 fn tool_authority_rejects_claimed_root_and_child_symlink_aliases() {
240 let tmp = tempdir().expect("tempdir");
241 std::fs::create_dir(tmp.path().join("real")).expect("real");
242 symlink("real", tmp.path().join("linked")).expect("linked root");
243 let context = ToolContext::new(tmp.path().to_path_buf());
244 let claimed_alias = scoped_authority(&["linked"], &[]);
245 let claimed_real = scoped_authority(&["real"], &[]);
246
247 for (authority, path) in [
248 (&claimed_alias, "linked/new.rs"),
249 (&claimed_real, "linked/new.rs"),
250 ] {
251 let error = authority
252 .permits_mutation_path(&context, path)
253 .expect_err("symlinked roots and mutation paths must fail closed")
254 .to_string();
255 assert!(error.contains("must not traverse symlinks"), "{error}");
256 }
257 }
258
259 #[test]
260 fn nested_tool_authority_may_only_narrow_the_outer_cap() {
261 let tmp = tempdir().expect("tempdir");
262 let outer = scoped_authority(&["src"], &["Cargo.toml"]);
263 let narrower = scoped_authority(&["src/parser"], &[]);
264 let expansion = scoped_authority(&["docs"], &[]);
265 ToolContext::new(tmp.path().to_path_buf())
266 .with_tool_authority(outer.clone())
267 .unwrap()
268 .with_tool_authority(narrower)
269 .expect("nested scope may narrow");
270 let error = ToolContext::new(tmp.path().to_path_buf())
271 .with_tool_authority(outer.clone())
272 .unwrap()
273 .with_tool_authority(expansion)
274 .err()
275 .expect("nested scope expansion must fail closed");
276 assert!(error.contains("cannot expand"), "{error}");
277
278 let read_only = ToolAuthorityEnvelope {
279 schema_version: 1,
280 owner: "read-only-child".to_string(),
281 authority: ToolMutationAuthority::ReadOnly,
282 network_access: None,
283 shell: ToolShellAuthority::None,
284 verification: ToolVerificationAuthority::None,
285 writable_roots: Vec::new(),
286 writable_files: Vec::new(),
287 coordination_contracts: Vec::new(),
288 };
289 ToolContext::new(tmp.path().to_path_buf())
290 .with_tool_authority(outer.clone())
291 .unwrap()
292 .with_tool_authority(read_only)
293 .expect("read-only always narrows a write cap");
294
295 let shell_expansion = ToolAuthorityEnvelope {
296 schema_version: 1,
297 owner: "shell-expansion".to_string(),
298 authority: ToolMutationAuthority::ReadOnly,
299 network_access: None,
300 shell: ToolShellAuthority::ReadOnly,
301 verification: ToolVerificationAuthority::None,
302 writable_roots: Vec::new(),
303 writable_files: Vec::new(),
304 coordination_contracts: Vec::new(),
305 };
306 ToolContext::new(tmp.path().to_path_buf())
307 .with_tool_authority(outer)
308 .unwrap()
309 .with_tool_authority(shell_expansion)
310 .err()
311 .expect("nested authority cannot add a shell cap the outer process lacks");
312 }
313
314 #[test]
315 fn legacy_v1_authority_envelopes_default_to_shell_none() {
316 let authority = ToolAuthorityEnvelope::from_json(
317 r#"{"schema_version":1,"owner":"legacy-worker","authority":"read_only"}"#,
318 )
319 .expect("pre-shell v1 envelope remains readable");
320 assert_eq!(authority.shell, ToolShellAuthority::None);
321 assert_eq!(authority.verification, ToolVerificationAuthority::None);
322 }
323
324 #[test]
325 fn headless_fleet_registers_bash_only_when_the_clamped_ceiling_keeps_it() {
326 assert!(fleet_exec_shell_enabled(
327 true,
328 ToolShellAuthority::ReadOnly,
329 None
330 ));
331 assert!(!fleet_exec_shell_enabled(
332 true,
333 ToolShellAuthority::ReadOnly,
334 Some(&["ba*".into()])
335 ));
336 assert!(!fleet_exec_shell_enabled(
337 true,
338 ToolShellAuthority::None,
339 None
340 ));
341 }
342
343 #[test]
344 fn bounded_verification_is_typed_and_cannot_smuggle_bash_authority() {
345 let bounded = ToolAuthorityEnvelope::from_json(
346 r#"{"schema_version":1,"owner":"verifier","authority":"read_only","verification":"bounded"}"#,
347 )
348 .expect("bounded verifier authority");
349 assert_eq!(bounded.verification, ToolVerificationAuthority::Bounded);
350
351 let widened = ToolAuthorityEnvelope {
352 shell: ToolShellAuthority::ReadOnly,
353 ..bounded
354 };
355 assert!(
356 widened.normalized().is_err(),
357 "bounded verification and Bash authority are separate, non-composable caps"
358 );
359 }
360
361 #[test]
362 fn read_only_machine_authority_clamps_live_shell_policy() {
363 let tmp = tempdir().expect("tempdir");
364 let read_only = ToolAuthorityEnvelope {
365 schema_version: 1,
366 owner: "scout".to_string(),
367 authority: ToolMutationAuthority::ReadOnly,
368 network_access: Some(true),
369 shell: ToolShellAuthority::ReadOnly,
370 verification: ToolVerificationAuthority::None,
371 writable_roots: Vec::new(),
372 writable_files: Vec::new(),
373 coordination_contracts: Vec::new(),
374 };
375 let mut context = ToolContext::new(tmp.path().to_path_buf())
376 .with_tool_authority(read_only)
377 .expect("read-only authority");
378
379 assert_eq!(context.shell_policy, ShellPolicy::ReadOnly);
380 context.set_shell_policy(ShellPolicy::Full);
381 assert_eq!(
382 context.shell_policy,
383 ShellPolicy::ReadOnly,
384 "a live mode refresh must not widen the process authority cap"
385 );
386
387 let scoped = ToolContext::new(tmp.path().to_path_buf())
388 .with_tool_authority(scoped_authority(&["src"], &[]))
389 .expect("scoped authority")
390 .with_shell_policy(ShellPolicy::Full);
391 assert_eq!(scoped.shell_policy, ShellPolicy::None);
392 }
393
394 #[test]
395 fn process_tool_authority_inherits_into_all_context_constructors() {
396 const CHILD_ENV: &str = "CODEWHALE_TEST_PROCESS_TOOL_AUTHORITY_CHILD";
397 if std::env::var_os(CHILD_ENV).is_some() {
398 let tmp = tempdir().expect("tempdir");
399 install_process_tool_authority(ToolAuthorityEnvelope {
400 schema_version: 1,
401 owner: "fleet-worker-child-process".to_string(),
402 authority: ToolMutationAuthority::ReadOnly,
403 network_access: None,
404 shell: ToolShellAuthority::ReadOnly,
405 verification: ToolVerificationAuthority::None,
406 writable_roots: Vec::new(),
407 writable_files: Vec::new(),
408 coordination_contracts: Vec::new(),
409 })
410 .expect("install process authority once in isolated child");
411 let notes = tmp.path().join("notes.md");
412 let mcp = tmp.path().join("mcp.json");
413 let contexts = [
414 ToolContext::new(tmp.path().to_path_buf()),
415 ToolContext::with_options(tmp.path().to_path_buf(), false, notes.clone(), mcp.clone()),
416 ToolContext::with_auto_approve(tmp.path().to_path_buf(), false, notes, mcp, true),
417 ];
418 for context in contexts {
419 let authority = context
420 .tool_authority
421 .as_ref()
422 .expect("every constructor inherits process authority");
423 assert_eq!(authority.owner, "fleet-worker-child-process");
424 assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
425 assert_eq!(context.shell_policy, ShellPolicy::ReadOnly);
426 }
427 return;
428 }
429
430 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
431 .arg("--exact")
432 .arg("tools::spec::tests::process_tool_authority_inherits_into_all_context_constructors")
433 .arg("--nocapture")
434 .env(CHILD_ENV, "1")
435 .output()
436 .expect("spawn isolated authority test child");
437 assert!(
438 output.status.success(),
439 "child failed:\nstdout:\n{}\nstderr:\n{}",
440 String::from_utf8_lossy(&output.stdout),
441 String::from_utf8_lossy(&output.stderr)
442 );
443 }
444
445 #[test]
446 fn test_required_str() {
447 let input = json!({"name": "test", "count": 42});
448 assert_eq!(required_str(&input, "name").unwrap(), "test");
449 assert!(required_str(&input, "missing").is_err());
450 assert!(required_str(&input, "count").is_err()); // not a string
451 }
452
453 #[test]
454 fn test_optional_str() {
455 let input = json!({"name": "test", "count": 7});
456 assert_eq!(optional_str(&input, "name").unwrap(), Some("test"));
457 assert_eq!(optional_str(&input, "missing").unwrap(), None);
458 // An explicit null is the wire spelling of "absent", not a type error.
459 assert_eq!(optional_str(&json!({"name": null}), "name").unwrap(), None);
460 let err = optional_str(&input, "count").expect_err("a number is not a string");
461 let err = err.to_string();
462 assert!(
463 err.contains("count") && err.contains("number") && err.contains("string"),
464 "{err}"
465 );
466 }
467
468 #[test]
469 fn test_required_u64() {
470 let input = json!({"count": 42});
471 assert_eq!(required_u64(&input, "count").unwrap(), 42);
472 assert!(required_u64(&input, "missing").is_err());
473 }
474
475 #[test]
476 fn test_optional_u64() {
477 let input = json!({"count": 42});
478 assert_eq!(optional_u64(&input, "count", 0).unwrap(), 42);
479 assert_eq!(optional_u64(&input, "missing", 100).unwrap(), 100);
480 assert_eq!(
481 optional_u64(&json!({"count": null}), "count", 9).unwrap(),
482 9
483 );
484 // A stringy number keeps its default today only because the harness
485 // never noticed; it must be an error instead.
486 for bad in [json!("42"), json!(-1), json!(2.5), json!([42])] {
487 let err = optional_u64(&json!({"count": bad}), "count", 100)
488 .expect_err("a non-integer must not fall back to the default")
489 .to_string();
490 assert!(
491 err.contains("count") && err.contains("non-negative integer"),
492 "{err}"
493 );
494 }
495 }
496
497 #[test]
498 fn test_optional_bool() {
499 let input = json!({"flag": true});
500 assert!(optional_bool(&input, "flag", false).unwrap());
501 assert!(!optional_bool(&input, "missing", false).unwrap());
502 assert!(optional_bool(&json!({"flag": null}), "flag", true).unwrap());
503 // The whole point: "true" must never become the default `false`.
504 for bad in [json!("true"), json!("false"), json!(1), json!(0), json!([])] {
505 let err = optional_bool(&json!({"flag": bad}), "flag", false)
506 .expect_err("a non-boolean must not fall back to the default")
507 .to_string();
508 assert!(err.contains("flag") && err.contains("boolean"), "{err}");
509 }
510 }
511
512 #[test]
513 fn test_tool_error_display() {
514 let err = ToolError::missing_field("path");
515 assert_eq!(
516 format!("{err}"),
517 "Failed to validate input: missing required field 'path'"
518 );
519
520 let err = ToolError::execution_failed("boom");
521 assert_eq!(format!("{err}"), "Failed to execute tool: boom");
522 }
523
524 #[test]
525 fn test_approval_requirement_default() {
526 let level = ApprovalRequirement::default();
527 assert_eq!(level, ApprovalRequirement::Auto);
528 }
529
530 #[test]
531 fn test_resolve_home_path_exact_prefixes() {
532 let fake_home = PathBuf::from("/fake/user/home");
533
534 // Exact ~ and ~/ prefixes resolve
535 assert_eq!(
536 resolve_home_path_with("~", || Some(fake_home.clone())).unwrap(),
537 Some(fake_home.clone())
538 );
539 assert_eq!(
540 resolve_home_path_with("~/", || Some(fake_home.clone())).unwrap(),
541 Some(fake_home.clone())
542 );
543 assert_eq!(
544 resolve_home_path_with("~//", || Some(fake_home.clone())).unwrap(),
545 Some(fake_home.clone())
546 );
547 assert_eq!(
548 resolve_home_path_with("~/file.txt", || Some(fake_home.clone())).unwrap(),
549 Some(fake_home.join("file.txt"))
550 );
551 assert_eq!(
552 resolve_home_path_with("~/a/b/c.md", || Some(fake_home.clone())).unwrap(),
553 Some(fake_home.join("a/b/c.md"))
554 );
555
556 #[cfg(windows)]
557 {
558 assert_eq!(
559 resolve_home_path_with(r"~\", || Some(fake_home.clone())).unwrap(),
560 Some(fake_home.clone())
561 );
562 assert_eq!(
563 resolve_home_path_with(r"~\file.txt", || Some(fake_home.clone())).unwrap(),
564 Some(fake_home.join("file.txt"))
565 );
566 }
567
568 // Must NOT expand ~otheruser, shell variables, command substitutions, globs, or literals
569 assert_eq!(
570 resolve_home_path_with("~otheruser", || Some(fake_home.clone())).unwrap(),
571 None
572 );
573 assert_eq!(
574 resolve_home_path_with("~otheruser/file", || Some(fake_home.clone())).unwrap(),
575 None
576 );
577 assert_eq!(
578 resolve_home_path_with("./~/file", || Some(fake_home.clone())).unwrap(),
579 None
580 );
581 assert_eq!(
582 resolve_home_path_with("$HOME/file", || Some(fake_home.clone())).unwrap(),
583 None
584 );
585 assert_eq!(
586 resolve_home_path_with("`whoami`/file", || Some(fake_home.clone())).unwrap(),
587 None
588 );
589 assert_eq!(
590 resolve_home_path_with("~*", || Some(fake_home.clone())).unwrap(),
591 None
592 );
593 assert_eq!(
594 resolve_home_path_with("regular/path", || Some(fake_home.clone())).unwrap(),
595 None
596 );
597 assert_eq!(
598 resolve_home_path_with("/absolute/path", || Some(fake_home.clone())).unwrap(),
599 None
600 );
601 }
602
603 #[test]
604 fn test_resolve_home_path_unknown_home_fails_explicitly_without_cwd_guessing() {
605 let err = resolve_home_path_with("~", || None).expect_err("unknown home must fail explicitly");
606 let msg = err.to_string();
607 assert!(
608 msg.contains("user home directory could not be determined"),
609 "error message must be explicit: {msg}"
610 );
611
612 let err = resolve_home_path_with("~/nested/file.txt", || None)
613 .expect_err("unknown home must fail explicitly");
614 let msg = err.to_string();
615 assert!(
616 msg.contains("user home directory could not be determined"),
617 "error message must be explicit: {msg}"
618 );
619 }
620
621 #[test]
622 fn test_tool_context_resolve_path_home_prefix_inside_workspace() {
623 let real_home = crate::config::effective_home_dir().expect("test home must be available");
624 let home_temp = tempfile::Builder::new()
625 .prefix("cw_spec_test_home_")
626 .tempdir_in(&real_home)
627 .expect("create fixture inside test home");
628
629 let test_file = home_temp.path().join("inside.txt");
630 std::fs::write(&test_file, "inside workspace").expect("write test file");
631
632 let rel = test_file
633 .strip_prefix(&real_home)
634 .expect("fixture is below test home");
635 let tilde_path = format!("~/{}", rel.to_string_lossy());
636
637 let ctx = ToolContext::new(home_temp.path().to_path_buf());
638 let resolved = ctx
639 .resolve_path(&tilde_path)
640 .expect("home path inside workspace should resolve");
641
642 let expected = test_file
643 .canonicalize()
644 .unwrap_or_else(|_| normalize_path(&test_file));
645 assert_eq!(resolved, expected);
646 }
647
648 #[test]
649 fn test_tool_context_resolve_path_home_prefix_restricted_refusal() {
650 let workspace = tempdir().expect("workspace tempdir");
651 let ctx = ToolContext::new(workspace.path().to_path_buf());
652
653 // A home path outside workspace without trusted external path or trust mode must be refused
654 let err = ctx
655 .resolve_path("~/some_untrusted_file_never_present_12345.txt")
656 .expect_err("home path outside workspace must error");
657 assert!(matches!(err, ToolError::PathEscape { .. }));
658 }
659
660 #[test]
661 fn test_tool_context_resolve_path_home_prefix_trusted_external_path() {
662 let real_home = crate::config::effective_home_dir().expect("test home must be available");
663 let trusted_dir = tempfile::Builder::new()
664 .prefix("cw_spec_trusted_home_")
665 .tempdir_in(&real_home)
666 .expect("create fixture inside test home");
667 let trusted_file = trusted_dir.path().join("shared.md");
668 std::fs::write(&trusted_file, "shared content").expect("write trusted file");
669
670 let rel = trusted_file
671 .strip_prefix(&real_home)
672 .expect("fixture is below test home");
673 let tilde_path = format!("~/{}", rel.to_string_lossy());
674
675 let workspace = tempdir().expect("workspace tempdir");
676 let canonical_trusted = trusted_dir
677 .path()
678 .canonicalize()
679 .unwrap_or_else(|_| trusted_dir.path().to_path_buf());
680 let ctx = ToolContext::new(workspace.path().to_path_buf())
681 .with_trusted_external_paths(vec![canonical_trusted]);
682
683 let resolved = ctx
684 .resolve_path(&tilde_path)
685 .expect("trusted external home path should resolve");
686 assert_eq!(resolved, trusted_file.canonicalize().unwrap());
687 }
688
689 #[test]
690 fn test_tool_context_resolve_path_literal_tilde_in_workspace() {
691 let workspace = tempdir().expect("workspace tempdir");
692 let literal_tilde = workspace.path().join("~");
693 std::fs::create_dir_all(&literal_tilde).expect("create literal ~ dir");
694 let literal_file = literal_tilde.join("nested.txt");
695 std::fs::write(&literal_file, "literal tilde data").expect("write literal");
696
697 let ctx = ToolContext::new(workspace.path().to_path_buf());
698 let resolved = ctx
699 .resolve_path("./~/nested.txt")
700 .expect("literal ./~/ path must resolve inside workspace");
701 assert_eq!(resolved, literal_file.canonicalize().unwrap());
702 }
703
704 #[test]
705 fn test_tool_context_resolve_path_no_shell_expansion() {
706 let workspace = tempdir().expect("workspace tempdir");
707 let ctx = ToolContext::new(workspace.path().to_path_buf());
708
709 // $HOME/file should NOT expand shell env var, but be treated relative to workspace
710 let resolved = ctx
711 .resolve_path("$HOME/file.txt")
712 .expect("should treat as workspace child");
713 assert!(
714 resolved.starts_with(
715 workspace
716 .path()
717 .canonicalize()
718 .unwrap_or_else(|_| workspace.path().to_path_buf())
719 )
720 );
721 assert!(resolved.to_string_lossy().contains("$HOME"));
722
723 // ~otheruser should NOT expand other user home, but be treated relative to workspace
724 let resolved_other = ctx
725 .resolve_path("~otheruser/file.txt")
726 .expect("should treat as workspace child");
727 assert!(resolved_other.to_string_lossy().contains("~otheruser"));
728 }
729
729 lines RUST