返回 CodeWhale
builtin_tests.rs
根目录 / crates / tui / src / plugins / builtin_tests.rs
1 //! Filesystem-only publication regressions. No plugin or native helper executes.
2
3 use super::*;
4 use std::process::{Child, Command, Stdio};
5 use std::sync::{Arc, Barrier};
6 use std::time::{Duration, Instant};
7
8 const NAME: &str = "fixture";
9 const FIRST: &[(&str, &[u8])] = &[
10 ("plugin.json", br#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"fixture","version":"1.0.0"}"#),
11 ("nested/body.txt", b"first bundle"),
12 ];
13 const SECOND: &[(&str, &[u8])] = &[
14 ("plugin.json", br#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"fixture","version":"1.0.0"}"#),
15 ("nested/body.txt", b"other bundle"),
16 ];
17
18 fn assert_contents(root: &Path, files: &[(&str, &[u8])]) {
19 for (path, contents) in files {
20 assert_eq!(fs::read(root.join(NAME).join(path)).unwrap(), *contents);
21 }
22 assert_eq!(
23 fs::read(root.join(STAMP_NAME)).unwrap(),
24 digest(files).as_bytes()
25 );
26 }
27
28 #[test]
29 fn concurrent_first_startups_share_one_complete_embedded_snapshot() {
30 let temp = tempfile::tempdir().unwrap();
31 let home = temp.path().join("home");
32 fs::create_dir(&home).unwrap();
33 let barrier = Arc::new(Barrier::new(4));
34 let workers: Vec<_> = (0..4)
35 .map(|_| {
36 let home = home.clone();
37 let barrier = Arc::clone(&barrier);
38 std::thread::spawn(move || {
39 barrier.wait();
40 materialize_at_home(&home).unwrap().unwrap()
41 })
42 })
43 .collect();
44 let roots: BTreeSet<_> = workers
45 .into_iter()
46 .map(|worker| worker.join().unwrap())
47 .collect();
48 assert_eq!(roots.len(), 1);
49 assert_eq!(
50 fs::read_dir(home.join(BUILTIN_DIR_NAME).join(SNAPSHOTS_DIR_NAME))
51 .unwrap()
52 .count(),
53 1
54 );
55 let root = roots.into_iter().next().unwrap();
56 for (path, contents) in COMPUTER_USE_FILES {
57 assert_eq!(
58 fs::read(root.join(COMPUTER_USE).join(path)).unwrap(),
59 *contents
60 );
61 }
62 assert!(!home.join("plugins/state.json").exists());
63 }
64
65 #[test]
66 fn concurrent_threads_keep_both_versions_complete() {
67 let temp = tempfile::tempdir().unwrap();
68 let barrier = Arc::new(Barrier::new(12));
69 let workers: Vec<_> = (0..12)
70 .map(|index| {
71 let root = temp.path().to_path_buf();
72 let barrier = Arc::clone(&barrier);
73 std::thread::spawn(move || {
74 let files = if index % 2 == 0 { FIRST } else { SECOND };
75 barrier.wait();
76 let published = write_bundle(&root, NAME, files).unwrap();
77 for _ in 0..8 {
78 assert_contents(&published, files);
79 assert_eq!(write_bundle(&root, NAME, files).unwrap(), published);
80 }
81 published
82 })
83 })
84 .collect();
85 let roots: BTreeSet<_> = workers
86 .into_iter()
87 .map(|worker| worker.join().unwrap())
88 .collect();
89 assert_eq!(roots.len(), 2);
90 assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 2);
91 }
92
93 // Kill/wait even if a parent assertion fails: fixtures must not leave processes.
94 struct ChildGuard(Child);
95 impl Drop for ChildGuard {
96 fn drop(&mut self) {
97 let _ = self.0.kill();
98 let _ = self.0.wait();
99 }
100 }
101
102 fn spawn_child(root: &Path, index: usize, orphan: bool) -> ChildGuard {
103 let test_name = format!(
104 "{}::publication_child",
105 module_path!().split_once("::").unwrap().1
106 );
107 let mut command = Command::new(std::env::current_exe().unwrap());
108 command
109 .args(["--exact", &test_name, "--ignored", "--nocapture"])
110 .env_clear()
111 .env("HOME", root)
112 .env("USERPROFILE", root)
113 .env("CODEWHALE_HOME", root.join("absent-home"))
114 .env("BUILTIN_TEST_ROOT", root)
115 .env("BUILTIN_TEST_INDEX", index.to_string())
116 .env("BUILTIN_TEST_ORPHAN", if orphan { "yes" } else { "no" })
117 .stdout(Stdio::null())
118 .stderr(Stdio::inherit());
119 #[cfg(windows)]
120 if let Some(system_root) = std::env::var_os("SystemRoot") {
121 command.env("SystemRoot", system_root);
122 }
123 ChildGuard(command.spawn().unwrap())
124 }
125
126 fn await_file(path: &Path) {
127 let deadline = Instant::now() + Duration::from_secs(15);
128 while !path.exists() {
129 assert!(
130 Instant::now() < deadline,
131 "child did not reach {}",
132 path.display()
133 );
134 std::thread::sleep(Duration::from_millis(5));
135 }
136 }
137
138 #[test]
139 #[ignore = "subprocess entry point, exercised by the two process tests"]
140 fn publication_child() {
141 let root = PathBuf::from(std::env::var_os("BUILTIN_TEST_ROOT").unwrap());
142 let index: usize = std::env::var("BUILTIN_TEST_INDEX")
143 .unwrap()
144 .parse()
145 .unwrap();
146 if std::env::var("BUILTIN_TEST_ORPHAN").unwrap() == "yes" {
147 let stage = tempfile::Builder::new()
148 .prefix(".staging-fixture-")
149 .tempdir_in(root.join("cache"))
150 .unwrap();
151 fs::write(stage.path().join("partial"), b"interrupted writer").unwrap();
152 fs::write(root.join("orphan-path"), stage.path().to_str().unwrap()).unwrap();
153 fs::write(root.join("ready-0"), b"ready").unwrap();
154 // The parent kills this writer before it can publish or run TempDir cleanup.
155 std::thread::sleep(Duration::from_secs(30));
156 panic!("parent did not terminate interrupted publisher");
157 }
158 fs::write(root.join(format!("ready-{index}")), b"ready").unwrap();
159 await_file(&root.join("start"));
160 let files = if index.is_multiple_of(2) {
161 FIRST
162 } else {
163 SECOND
164 };
165 for _ in 0..8 {
166 let published = write_bundle(&root.join("cache"), NAME, files).unwrap();
167 assert_contents(&published, files);
168 }
169 fs::write(root.join(format!("done-{index}")), b"done").unwrap();
170 }
171
172 #[test]
173 fn concurrent_processes_converge_without_replacing_other_versions() {
174 let temp = tempfile::tempdir().unwrap();
175 fs::create_dir(temp.path().join("cache")).unwrap();
176 let mut children: Vec<_> = (0..4)
177 .map(|index| spawn_child(temp.path(), index, false))
178 .collect();
179 for index in 0..4 {
180 await_file(&temp.path().join(format!("ready-{index}")));
181 }
182 fs::write(temp.path().join("start"), b"go").unwrap();
183 for (index, child) in children.iter_mut().enumerate() {
184 await_file(&temp.path().join(format!("done-{index}")));
185 assert!(child.0.wait().unwrap().success());
186 }
187 let cache = temp.path().join("cache");
188 assert_eq!(fs::read_dir(&cache).unwrap().count(), 2);
189 for files in [FIRST, SECOND] {
190 assert_contents(&write_bundle(&cache, NAME, files).unwrap(), files);
191 }
192 assert!(!temp.path().join("absent-home").exists());
193 }
194
195 #[test]
196 fn killed_writer_leaves_only_its_unexposed_stage() {
197 let temp = tempfile::tempdir().unwrap();
198 let cache = temp.path().join("cache");
199 fs::create_dir(&cache).unwrap();
200 let mut child = spawn_child(temp.path(), 0, true);
201 await_file(&temp.path().join("ready-0"));
202 child.0.kill().unwrap();
203 child.0.wait().unwrap();
204 let orphan = PathBuf::from(fs::read_to_string(temp.path().join("orphan-path")).unwrap());
205 let published = write_bundle(&cache, NAME, FIRST).unwrap();
206 assert_contents(&published, FIRST);
207 assert_eq!(
208 fs::read(orphan.join("partial")).unwrap(),
209 b"interrupted writer"
210 );
211 assert_eq!(fs::read_dir(&cache).unwrap().count(), 2);
212 assert_ne!(published, orphan);
213 }
214
215 #[test]
216 fn exclusive_publication_preserves_even_an_empty_destination() {
217 let temp = tempfile::tempdir().unwrap();
218 let stage = temp.path().join("stage");
219 let destination = temp.path().join("destination");
220 fs::create_dir(&stage).unwrap();
221 fs::write(stage.join("complete"), b"complete").unwrap();
222 fs::create_dir(&destination).unwrap();
223 assert_eq!(
224 publish_snapshot(&stage, &destination).unwrap_err().kind(),
225 io::ErrorKind::AlreadyExists
226 );
227 assert_eq!(fs::read_dir(destination).unwrap().count(), 0);
228 assert!(stage.join("complete").is_file());
229 }
230
231 #[test]
232 fn partial_published_destination_is_never_repaired_or_replaced() {
233 let temp = tempfile::tempdir().unwrap();
234 let destination = temp.path().join(format!("{NAME}-{}", digest(FIRST)));
235 fs::create_dir(&destination).unwrap();
236 fs::write(destination.join(STAMP_NAME), digest(FIRST)).unwrap();
237 assert!(write_bundle(temp.path(), NAME, FIRST).is_err());
238 assert_eq!(fs::read_dir(destination).unwrap().count(), 1);
239 assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 1);
240 }
241
242 #[test]
243 fn matching_stamp_does_not_bless_changed_missing_or_extra_content() {
244 for mutation in ["bytes", "missing", "extra-file", "extra-directory", "stamp"] {
245 let temp = tempfile::tempdir().unwrap();
246 let published = write_bundle(temp.path(), NAME, FIRST).unwrap();
247 let body = published.join(NAME).join("nested/body.txt");
248 match mutation {
249 "bytes" => fs::write(&body, b"other bundle").unwrap(), // Same length.
250 "missing" => fs::remove_file(&body).unwrap(),
251 "extra-file" => fs::write(published.join(NAME).join("extra"), b"extra").unwrap(),
252 "extra-directory" => fs::create_dir(published.join(NAME).join("extra")).unwrap(),
253 "stamp" => fs::write(published.join(STAMP_NAME), digest(SECOND)).unwrap(),
254 _ => unreachable!(),
255 }
256 assert!(
257 write_bundle(temp.path(), NAME, FIRST).is_err(),
258 "{mutation}"
259 );
260 assert!(published.exists());
261 assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 1);
262 if mutation == "bytes" {
263 assert_eq!(fs::read(&body).unwrap(), b"other bundle");
264 }
265 }
266 }
267
268 #[cfg(unix)]
269 #[test]
270 fn executable_mode_changes_and_hardlinks_fail_closed() {
271 use std::os::unix::fs::PermissionsExt as _;
272 for mode in [0o644, 0o700] {
273 let temp = tempfile::tempdir().unwrap();
274 let files: &[(&str, &[u8])] = &[("bin/darwin/accessibility", b"fixture only")];
275 let published = write_bundle(temp.path(), NAME, files).unwrap();
276 let file = published.join(NAME).join("bin/darwin/accessibility");
277 if mode == 0o644 {
278 fs::set_permissions(&file, fs::Permissions::from_mode(mode)).unwrap();
279 } else {
280 fs::hard_link(&file, temp.path().join("linked-helper")).unwrap();
281 }
282 assert!(write_bundle(temp.path(), NAME, files).is_err());
283 }
284 let temp = tempfile::tempdir().unwrap();
285 let published = write_bundle(temp.path(), NAME, FIRST).unwrap();
286 fs::set_permissions(
287 published.join(NAME).join("plugin.json"),
288 fs::Permissions::from_mode(0o700),
289 )
290 .unwrap();
291 assert!(write_bundle(temp.path(), NAME, FIRST).is_err());
292 }
293
294 #[cfg(unix)]
295 #[test]
296 fn linked_cache_snapshot_directory_and_file_are_rejected() {
297 use std::os::unix::fs::symlink;
298 for level in ["cache", "snapshot", "directory", "file"] {
299 let temp = tempfile::tempdir().unwrap();
300 let cache = temp.path().join("cache");
301 fs::create_dir(&cache).unwrap();
302 let published = write_bundle(&cache, NAME, FIRST).unwrap();
303 let original = match level {
304 "cache" => cache.clone(),
305 "snapshot" => published.clone(),
306 "directory" => published.join(NAME).join("nested"),
307 "file" => published.join(NAME).join("nested/body.txt"),
308 _ => unreachable!(),
309 };
310 let moved = temp.path().join("moved");
311 fs::rename(&original, &moved).unwrap();
312 symlink(&moved, &original).unwrap();
313 assert!(write_bundle(&cache, NAME, FIRST).is_err(), "{level}");
314 assert!(
315 fs::symlink_metadata(&original)
316 .unwrap()
317 .file_type()
318 .is_symlink()
319 );
320 assert!(moved.exists());
321 }
322 }
323
324 #[cfg(unix)]
325 #[test]
326 fn linked_home_pins_its_selected_directory_without_creating_missing_targets() {
327 use std::os::unix::fs::symlink;
328
329 let temp = tempfile::tempdir().unwrap();
330 let first_home = temp.path().join("first-home");
331 let second_home = temp.path().join("second-home");
332 let selected = temp.path().join("selected-home");
333 fs::create_dir(&first_home).unwrap();
334 fs::create_dir(&second_home).unwrap();
335 symlink(&first_home, &selected).unwrap();
336
337 let first = materialize_at_home(&selected).unwrap().unwrap();
338 assert!(first.starts_with(first_home.canonicalize().unwrap()));
339 assert_eq!(materialize_at_home(&first_home).unwrap().unwrap(), first);
340 let manifest = first.join(COMPUTER_USE).join("plugin.json");
341 let original = fs::read(&manifest).unwrap();
342
343 // Retargeting the user's alias cannot redirect an already captured root.
344 fs::remove_file(&selected).unwrap();
345 symlink(&second_home, &selected).unwrap();
346 let second = materialize_at_home(&selected).unwrap().unwrap();
347 assert!(second.starts_with(second_home.canonicalize().unwrap()));
348 assert_ne!(second, first);
349 assert_eq!(fs::read(&manifest).unwrap(), original);
350 assert_eq!(materialize_at_home(&first_home).unwrap().unwrap(), first);
351
352 fs::remove_file(&selected).unwrap();
353 let missing = temp.path().join("missing-home");
354 symlink(&missing, &selected).unwrap();
355 assert!(materialize_at_home(&selected).unwrap().is_none());
356 assert!(!missing.exists());
357 }
358
359 #[cfg(unix)]
360 #[test]
361 fn linked_builtin_descendants_of_an_aliased_home_are_rejected() {
362 use std::os::unix::fs::symlink;
363 for level in ["builtin", "snapshots"] {
364 let temp = tempfile::tempdir().unwrap();
365 let home = temp.path().join("home");
366 let selected = temp.path().join("selected-home");
367 let target = temp.path().join("target");
368 fs::create_dir(&target).unwrap();
369 let linked = match level {
370 "builtin" => {
371 fs::create_dir(&home).unwrap();
372 home.join(BUILTIN_DIR_NAME)
373 }
374 "snapshots" => {
375 fs::create_dir_all(home.join(BUILTIN_DIR_NAME)).unwrap();
376 home.join(BUILTIN_DIR_NAME).join(SNAPSHOTS_DIR_NAME)
377 }
378 _ => unreachable!(),
379 };
380 symlink(&home, &selected).unwrap();
381 symlink(&target, &linked).unwrap();
382 assert!(materialize_at_home(&selected).is_err(), "{level}");
383 assert_eq!(fs::read_dir(target).unwrap().count(), 0);
384 }
385 }
386
387 #[cfg(windows)]
388 #[test]
389 fn junction_cache_snapshot_and_nested_directory_are_rejected() {
390 for level in ["cache", "snapshot", "directory"] {
391 let temp = tempfile::tempdir().unwrap();
392 let cache = temp.path().join("cache");
393 fs::create_dir(&cache).unwrap();
394 let published = write_bundle(&cache, NAME, FIRST).unwrap();
395 let original = match level {
396 "cache" => cache.clone(),
397 "snapshot" => published.clone(),
398 "directory" => published.join(NAME).join("nested"),
399 _ => unreachable!(),
400 };
401 let moved = temp.path().join("moved");
402 fs::rename(&original, &moved).unwrap();
403 let result = Command::new("cmd")
404 .args(["/C", "mklink", "/J"])
405 .arg(&original)
406 .arg(&moved)
407 .output()
408 .unwrap();
409 assert!(
410 result.status.success(),
411 "{}",
412 String::from_utf8_lossy(&result.stderr)
413 );
414 assert!(write_bundle(&cache, NAME, FIRST).is_err(), "{level}");
415 assert!(metadata_is_link_or_reparse(
416 &fs::symlink_metadata(&original).unwrap()
417 ));
418 assert!(moved.exists());
419 }
420 }
421
422 #[test]
423 fn embedded_paths_cannot_escape_or_alias_the_inventory() {
424 for path in ["../outside", "/absolute", "", "./dot"] {
425 let temp = tempfile::tempdir().unwrap();
426 assert!(
427 write_bundle(temp.path(), NAME, &[(path, b"bad")]).is_err(),
428 "{path}"
429 );
430 assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 0);
431 }
432 let temp = tempfile::tempdir().unwrap();
433 assert!(write_bundle(temp.path(), NAME, &[("same", b"a"), ("same", b"b")]).is_err());
434 assert!(write_bundle(temp.path(), "../outside", FIRST).is_err());
435 assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 0);
436 }
437
438 #[test]
439 fn materialization_preserves_legacy_tree_receipts_and_missing_home() {
440 let temp = tempfile::tempdir().unwrap();
441 let home = temp.path().join("home");
442 assert!(materialize_at_home(&home).unwrap().is_none());
443 assert!(!home.exists());
444 let legacy = home.join(BUILTIN_DIR_NAME).join(COMPUTER_USE);
445 fs::create_dir_all(&legacy).unwrap();
446 fs::write(legacy.join("legacy"), b"old live bundle").unwrap();
447 fs::create_dir(home.join("plugins")).unwrap();
448 let state = home.join("plugins/state.json");
449 fs::write(&state, b"existing receipts").unwrap();
450 let published = materialize_at_home(&home).unwrap().unwrap();
451 let stamp_time = fs::metadata(published.join(STAMP_NAME))
452 .unwrap()
453 .modified()
454 .unwrap();
455 assert_eq!(materialize_at_home(&home).unwrap().unwrap(), published);
456 assert_eq!(
457 fs::metadata(published.join(STAMP_NAME))
458 .unwrap()
459 .modified()
460 .unwrap(),
461 stamp_time
462 );
463 assert!(published.join(COMPUTER_USE).join("plugin.json").is_file());
464 assert_eq!(fs::read(legacy.join("legacy")).unwrap(), b"old live bundle");
465 assert_eq!(fs::read(state).unwrap(), b"existing receipts");
466 }
467
467 lines RUST