| 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 |