返回 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 writable_roots: roots.iter().map(|value| (*value).to_string()).collect(),
191 writable_files: files.iter().map(|value| (*value).to_string()).collect(),
192 coordination_contracts: Vec::new(),
193 }
194 .normalized()
195 .expect("valid test authority")
196 }
197
198 #[test]
199 fn tool_authority_allows_normal_nonexistent_children_only_inside_scope() {
200 let tmp = tempdir().expect("tempdir");
201 std::fs::create_dir(tmp.path().join("src")).expect("src");
202 let context = ToolContext::new(tmp.path().to_path_buf());
203 let authority = scoped_authority(&["src"], &[]);
204
205 assert!(
206 authority
207 .permits_mutation_path(&context, "src/new/nested.rs")
208 .expect("normal nonexistent child")
209 );
210 assert!(
211 !authority
212 .permits_mutation_path(&context, "docs/outside.md")
213 .expect("ordinary out-of-scope path")
214 );
215 }
216
217 #[cfg(unix)]
218 #[test]
219 fn tool_authority_rejects_exact_file_symlink_aliases() {
220 let tmp = tempdir().expect("tempdir");
221 std::fs::create_dir(tmp.path().join("src")).expect("src");
222 std::fs::create_dir(tmp.path().join("other")).expect("other");
223 std::fs::write(tmp.path().join("other/target.rs"), "outside scope\n").expect("target");
224 symlink("../other/target.rs", tmp.path().join("src/alias.rs")).expect("alias");
225 let context = ToolContext::new(tmp.path().to_path_buf());
226 let authority = scoped_authority(&[], &["src/alias.rs"]);
227
228 let error = authority
229 .permits_mutation_path(&context, "src/alias.rs")
230 .expect_err("an exact-file claim must not authorize a symlink target")
231 .to_string();
232 assert!(error.contains("must not traverse symlinks"), "{error}");
233 }
234
235 #[cfg(unix)]
236 #[test]
237 fn tool_authority_rejects_claimed_root_and_child_symlink_aliases() {
238 let tmp = tempdir().expect("tempdir");
239 std::fs::create_dir(tmp.path().join("real")).expect("real");
240 symlink("real", tmp.path().join("linked")).expect("linked root");
241 let context = ToolContext::new(tmp.path().to_path_buf());
242 let claimed_alias = scoped_authority(&["linked"], &[]);
243 let claimed_real = scoped_authority(&["real"], &[]);
244
245 for (authority, path) in [
246 (&claimed_alias, "linked/new.rs"),
247 (&claimed_real, "linked/new.rs"),
248 ] {
249 let error = authority
250 .permits_mutation_path(&context, path)
251 .expect_err("symlinked roots and mutation paths must fail closed")
252 .to_string();
253 assert!(error.contains("must not traverse symlinks"), "{error}");
254 }
255 }
256
257 #[test]
258 fn nested_tool_authority_may_only_narrow_the_outer_cap() {
259 let tmp = tempdir().expect("tempdir");
260 let outer = scoped_authority(&["src"], &["Cargo.toml"]);
261 let narrower = scoped_authority(&["src/parser"], &[]);
262 let expansion = scoped_authority(&["docs"], &[]);
263 ToolContext::new(tmp.path().to_path_buf())
264 .with_tool_authority(outer.clone())
265 .unwrap()
266 .with_tool_authority(narrower)
267 .expect("nested scope may narrow");
268 let error = ToolContext::new(tmp.path().to_path_buf())
269 .with_tool_authority(outer.clone())
270 .unwrap()
271 .with_tool_authority(expansion)
272 .err()
273 .expect("nested scope expansion must fail closed");
274 assert!(error.contains("cannot expand"), "{error}");
275
276 let read_only = ToolAuthorityEnvelope {
277 schema_version: 1,
278 owner: "read-only-child".to_string(),
279 authority: ToolMutationAuthority::ReadOnly,
280 network_access: None,
281 writable_roots: Vec::new(),
282 writable_files: Vec::new(),
283 coordination_contracts: Vec::new(),
284 };
285 ToolContext::new(tmp.path().to_path_buf())
286 .with_tool_authority(outer)
287 .unwrap()
288 .with_tool_authority(read_only)
289 .expect("read-only always narrows a write cap");
290 }
291
292 #[test]
293 fn process_tool_authority_inherits_into_all_context_constructors() {
294 const CHILD_ENV: &str = "CODEWHALE_TEST_PROCESS_TOOL_AUTHORITY_CHILD";
295 if std::env::var_os(CHILD_ENV).is_some() {
296 let tmp = tempdir().expect("tempdir");
297 install_process_tool_authority(ToolAuthorityEnvelope {
298 schema_version: 1,
299 owner: "fleet-worker-child-process".to_string(),
300 authority: ToolMutationAuthority::ReadOnly,
301 network_access: None,
302 writable_roots: Vec::new(),
303 writable_files: Vec::new(),
304 coordination_contracts: Vec::new(),
305 })
306 .expect("install process authority once in isolated child");
307 let notes = tmp.path().join("notes.md");
308 let mcp = tmp.path().join("mcp.json");
309 let contexts = [
310 ToolContext::new(tmp.path().to_path_buf()),
311 ToolContext::with_options(tmp.path().to_path_buf(), false, notes.clone(), mcp.clone()),
312 ToolContext::with_auto_approve(tmp.path().to_path_buf(), false, notes, mcp, true),
313 ];
314 for context in contexts {
315 let authority = context
316 .tool_authority
317 .as_ref()
318 .expect("every constructor inherits process authority");
319 assert_eq!(authority.owner, "fleet-worker-child-process");
320 assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
321 }
322 return;
323 }
324
325 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
326 .arg("--exact")
327 .arg("tools::spec::tests::process_tool_authority_inherits_into_all_context_constructors")
328 .arg("--nocapture")
329 .env(CHILD_ENV, "1")
330 .output()
331 .expect("spawn isolated authority test child");
332 assert!(
333 output.status.success(),
334 "child failed:\nstdout:\n{}\nstderr:\n{}",
335 String::from_utf8_lossy(&output.stdout),
336 String::from_utf8_lossy(&output.stderr)
337 );
338 }
339
340 #[test]
341 fn test_required_str() {
342 let input = json!({"name": "test", "count": 42});
343 assert_eq!(required_str(&input, "name").unwrap(), "test");
344 assert!(required_str(&input, "missing").is_err());
345 assert!(required_str(&input, "count").is_err()); // not a string
346 }
347
348 #[test]
349 fn test_optional_str() {
350 let input = json!({"name": "test", "count": 7});
351 assert_eq!(optional_str(&input, "name").unwrap(), Some("test"));
352 assert_eq!(optional_str(&input, "missing").unwrap(), None);
353 // An explicit null is the wire spelling of "absent", not a type error.
354 assert_eq!(optional_str(&json!({"name": null}), "name").unwrap(), None);
355 let err = optional_str(&input, "count").expect_err("a number is not a string");
356 let err = err.to_string();
357 assert!(
358 err.contains("count") && err.contains("number") && err.contains("string"),
359 "{err}"
360 );
361 }
362
363 #[test]
364 fn test_required_u64() {
365 let input = json!({"count": 42});
366 assert_eq!(required_u64(&input, "count").unwrap(), 42);
367 assert!(required_u64(&input, "missing").is_err());
368 }
369
370 #[test]
371 fn test_optional_u64() {
372 let input = json!({"count": 42});
373 assert_eq!(optional_u64(&input, "count", 0).unwrap(), 42);
374 assert_eq!(optional_u64(&input, "missing", 100).unwrap(), 100);
375 assert_eq!(
376 optional_u64(&json!({"count": null}), "count", 9).unwrap(),
377 9
378 );
379 // A stringy number keeps its default today only because the harness
380 // never noticed; it must be an error instead.
381 for bad in [json!("42"), json!(-1), json!(2.5), json!([42])] {
382 let err = optional_u64(&json!({"count": bad}), "count", 100)
383 .expect_err("a non-integer must not fall back to the default")
384 .to_string();
385 assert!(
386 err.contains("count") && err.contains("non-negative integer"),
387 "{err}"
388 );
389 }
390 }
391
392 #[test]
393 fn test_optional_bool() {
394 let input = json!({"flag": true});
395 assert!(optional_bool(&input, "flag", false).unwrap());
396 assert!(!optional_bool(&input, "missing", false).unwrap());
397 assert!(optional_bool(&json!({"flag": null}), "flag", true).unwrap());
398 // The whole point: "true" must never become the default `false`.
399 for bad in [json!("true"), json!("false"), json!(1), json!(0), json!([])] {
400 let err = optional_bool(&json!({"flag": bad}), "flag", false)
401 .expect_err("a non-boolean must not fall back to the default")
402 .to_string();
403 assert!(err.contains("flag") && err.contains("boolean"), "{err}");
404 }
405 }
406
407 #[test]
408 fn test_tool_error_display() {
409 let err = ToolError::missing_field("path");
410 assert_eq!(
411 format!("{err}"),
412 "Failed to validate input: missing required field 'path'"
413 );
414
415 let err = ToolError::execution_failed("boom");
416 assert_eq!(format!("{err}"), "Failed to execute tool: boom");
417 }
418
419 #[test]
420 fn test_approval_requirement_default() {
421 let level = ApprovalRequirement::default();
422 assert_eq!(level, ApprovalRequirement::Auto);
423 }
424
424 lines RUST