| 1 | //! Optional companion delivery through the existing updater's locked source. |
| 2 | //! This is installation provenance, never a runtime or extension authority. |
| 3 | use super::*; |
| 4 | use codewhale_tui::delivery_files::GuardedFile; |
| 5 | use std::collections::HashSet; |
| 6 | |
| 7 | const CATALOG: &str = "codewhale-extension-hosts.json"; |
| 8 | const RECEIPT: &str = "codewhale-extension-host.release.json"; |
| 9 | |
| 10 | #[derive(serde::Deserialize)] |
| 11 | struct Catalog { |
| 12 | schema: u64, |
| 13 | version: String, |
| 14 | source_sha: String, |
| 15 | bundle_sha256: String, |
| 16 | hosts: Vec<Host>, |
| 17 | } |
| 18 | |
| 19 | #[derive(serde::Deserialize)] |
| 20 | struct Host { |
| 21 | target: String, |
| 22 | asset: String, |
| 23 | sha256: String, |
| 24 | notices_asset: String, |
| 25 | notices_sha256: String, |
| 26 | source_asset: String, |
| 27 | source_sha256: String, |
| 28 | runtime_version: String, |
| 29 | runtime_revision: String, |
| 30 | runtime_sha256: String, |
| 31 | webkit_revision: String, |
| 32 | bundle_sha256: String, |
| 33 | source_commit: String, |
| 34 | native_platform: String, |
| 35 | native_arch: String, |
| 36 | libc: String, |
| 37 | passed: u64, |
| 38 | failed: u64, |
| 39 | skipped: u64, |
| 40 | test_log_sha256: String, |
| 41 | native_passed: u64, |
| 42 | native_failed: u64, |
| 43 | native_skipped: u64, |
| 44 | native_log_sha256: String, |
| 45 | license_closure: String, |
| 46 | relink_source: String, |
| 47 | } |
| 48 | |
| 49 | fn hex(value: &str, length: usize) -> bool { |
| 50 | value.len() == length |
| 51 | && value |
| 52 | .bytes() |
| 53 | .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) |
| 54 | } |
| 55 | |
| 56 | fn version(value: &str) -> bool { |
| 57 | let (core, suffix) = value |
| 58 | .split_once('-') |
| 59 | .map_or((value, None), |(a, b)| (a, Some(b))); |
| 60 | let parts = core.split('.').collect::<Vec<_>>(); |
| 61 | parts.len() == 3 |
| 62 | && parts |
| 63 | .iter() |
| 64 | .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) |
| 65 | && suffix.is_none_or(|s| { |
| 66 | !s.is_empty() |
| 67 | && s.bytes() |
| 68 | .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') |
| 69 | }) |
| 70 | } |
| 71 | |
| 72 | fn native_target(target: &str) -> Option<(&'static str, &'static str)> { |
| 73 | Some(match target { |
| 74 | "linux-x64" => ("linux", "x64"), |
| 75 | "linux-arm64" => ("linux", "arm64"), |
| 76 | "macos-x64" => ("darwin", "x64"), |
| 77 | "macos-arm64" => ("darwin", "arm64"), |
| 78 | "windows-x64" => ("win32", "x64"), |
| 79 | "windows-arm64" => ("win32", "arm64"), |
| 80 | _ => return None, |
| 81 | }) |
| 82 | } |
| 83 | |
| 84 | fn parse(bytes: &[u8], expected_version: &str) -> Result<Catalog> { |
| 85 | if bytes.len() > 64 * 1024 { |
| 86 | bail!("compiled host catalog exceeds 64 KiB"); |
| 87 | } |
| 88 | let catalog: Catalog = |
| 89 | serde_json::from_slice(bytes).context("malformed compiled host catalog")?; |
| 90 | if catalog.schema != 1 |
| 91 | || !version(&catalog.version) |
| 92 | || catalog.version != expected_version.trim_start_matches('v') |
| 93 | || !hex(&catalog.source_sha, 40) |
| 94 | || !hex(&catalog.bundle_sha256, 64) |
| 95 | || catalog.hosts.len() > 6 |
| 96 | { |
| 97 | bail!("compiled host catalog does not identify this exact release"); |
| 98 | } |
| 99 | let mut targets = HashSet::new(); |
| 100 | for host in &catalog.hosts { |
| 101 | let (platform, arch) = native_target(&host.target) |
| 102 | .context("unsupported compiled host target; Android is not a qualified Codewhale delivery target")?; |
| 103 | let minimum_native = if platform == "win32" { 8 } else { 1 }; |
| 104 | let stem = format!("codewhale-extension-host-{}", host.target); |
| 105 | let binary = format!("{stem}{}", if platform == "win32" { ".exe" } else { "" }); |
| 106 | if !targets.insert(&host.target) |
| 107 | || host.asset != binary |
| 108 | || host.notices_asset != format!("{stem}-LICENSES.txt") |
| 109 | || host.source_asset != format!("{stem}-relink-source.tar.gz") |
| 110 | || [ |
| 111 | &host.sha256, |
| 112 | &host.notices_sha256, |
| 113 | &host.source_sha256, |
| 114 | &host.runtime_sha256, |
| 115 | &host.test_log_sha256, |
| 116 | &host.native_log_sha256, |
| 117 | ] |
| 118 | .iter() |
| 119 | .any(|digest| !hex(digest, 64)) |
| 120 | || !version(&host.runtime_version) |
| 121 | || !hex(&host.runtime_revision, 40) |
| 122 | || !hex(&host.webkit_revision, 40) |
| 123 | || host.bundle_sha256 != catalog.bundle_sha256 |
| 124 | || host.source_commit != catalog.source_sha |
| 125 | || host.native_platform != platform |
| 126 | || host.native_arch != arch |
| 127 | || !(6..=9_007_199_254_740_991).contains(&host.passed) |
| 128 | || host.failed != 0 |
| 129 | || host.skipped != 0 |
| 130 | || !(minimum_native..=9_007_199_254_740_991).contains(&host.native_passed) |
| 131 | || host.native_failed != 0 |
| 132 | || host.native_skipped != 0 |
| 133 | || host.license_closure != "complete" |
| 134 | || host.relink_source != "complete" |
| 135 | || if platform == "linux" { |
| 136 | !matches!(host.libc.as_str(), "glibc" | "musl") |
| 137 | } else { |
| 138 | host.libc != "none" |
| 139 | } |
| 140 | { |
| 141 | bail!("compiled host is not a complete matching-native qualified payload"); |
| 142 | } |
| 143 | } |
| 144 | Ok(catalog) |
| 145 | } |
| 146 | |
| 147 | struct Replacement { |
| 148 | name: String, |
| 149 | staged: GuardedFile, |
| 150 | old: Option<GuardedFile>, |
| 151 | backup_name: String, |
| 152 | retired: bool, |
| 153 | published: bool, |
| 154 | } |
| 155 | |
| 156 | pub(super) struct Prepared { |
| 157 | directory: PathBuf, |
| 158 | replacements: Vec<Replacement>, |
| 159 | } |
| 160 | |
| 161 | impl Prepared { |
| 162 | fn verify_target(directory: &Path, replacement: &mut Replacement) -> Result<()> { |
| 163 | if let Some(old) = &mut replacement.old { |
| 164 | old.verify()?; |
| 165 | } else if GuardedFile::open(directory, &replacement.name)?.is_some() { |
| 166 | bail!( |
| 167 | "compiled host destination appeared: {}", |
| 168 | directory.join(&replacement.name).display() |
| 169 | ); |
| 170 | } |
| 171 | replacement.staged.verify()?; |
| 172 | Ok(()) |
| 173 | } |
| 174 | |
| 175 | pub(super) fn recovery_paths(&self) -> Vec<PathBuf> { |
| 176 | self.replacements |
| 177 | .iter() |
| 178 | .filter(|replacement| replacement.retired) |
| 179 | .map(|replacement| self.directory.join(&replacement.backup_name)) |
| 180 | .collect() |
| 181 | } |
| 182 | |
| 183 | pub(super) fn publish(&mut self) -> Result<()> { |
| 184 | for replacement in &mut self.replacements { |
| 185 | Self::verify_target(&self.directory, replacement)?; |
| 186 | } |
| 187 | for replacement in &mut self.replacements { |
| 188 | Self::verify_target(&self.directory, replacement)?; |
| 189 | if let Some(old) = &mut replacement.old { |
| 190 | let result = old.move_to_vacant(&replacement.backup_name); |
| 191 | // A Unix name can change between comparison and rename. Record |
| 192 | // the move even when post-move identity validation rejects it. |
| 193 | replacement.retired = old.name() == replacement.backup_name; |
| 194 | result.with_context(|| { |
| 195 | format!( |
| 196 | "failed to retire {}; recovery at {}", |
| 197 | replacement.name, |
| 198 | self.directory.join(&replacement.backup_name).display() |
| 199 | ) |
| 200 | })?; |
| 201 | } |
| 202 | let result = replacement.staged.move_to_vacant(&replacement.name); |
| 203 | replacement.published = replacement.staged.name() == replacement.name; |
| 204 | result.with_context(|| { |
| 205 | format!( |
| 206 | "failed to publish {}; no concurrent destination was overwritten", |
| 207 | replacement.name |
| 208 | ) |
| 209 | })?; |
| 210 | } |
| 211 | Ok(()) |
| 212 | } |
| 213 | |
| 214 | pub(super) fn rollback(&mut self) -> Result<()> { |
| 215 | let mut failures = Vec::new(); |
| 216 | for replacement in self.replacements.iter_mut().rev() { |
| 217 | let restored = (|| { |
| 218 | if replacement.published { |
| 219 | // The retained staged handle, not equal bytes, proves this |
| 220 | // entry is the one we published. Preserve changed entries. |
| 221 | replacement.staged.verify()?; |
| 222 | let recovery = GuardedFile::recovery_name(); |
| 223 | let result = replacement.staged.move_to_vacant(&recovery); |
| 224 | replacement.published = replacement.staged.name() == replacement.name; |
| 225 | result?; |
| 226 | } |
| 227 | if replacement.retired { |
| 228 | // Restore the entry actually moved, even if a racing Unix |
| 229 | // replacement made retirement refuse its identity. A fresh |
| 230 | // protected handle retains that recovery object's identity. |
| 231 | let original = replacement.old.as_mut().context("retired handle missing")?; |
| 232 | if original.verify().is_ok() { |
| 233 | original.move_to_vacant(&replacement.name)?; |
| 234 | } else { |
| 235 | // On Unix the name moved may be a racing replacement; |
| 236 | // on either platform an observed writer may have changed |
| 237 | // the recovery bytes. Capture that actual recovery entry |
| 238 | // and restore it only if the old destination is vacant. |
| 239 | let mut recovery = |
| 240 | GuardedFile::open(&self.directory, &replacement.backup_name)? |
| 241 | .context("retired entry disappeared")?; |
| 242 | recovery.move_to_vacant(&replacement.name)?; |
| 243 | } |
| 244 | replacement.retired = false; |
| 245 | } |
| 246 | Ok::<_, anyhow::Error>(()) |
| 247 | })(); |
| 248 | if let Err(error) = restored { |
| 249 | failures.push(format!( |
| 250 | "{}: {} (retired entry {}; staged/published entry {})", |
| 251 | replacement.name, |
| 252 | error, |
| 253 | self.directory.join(&replacement.backup_name).display(), |
| 254 | self.directory.join(replacement.staged.name()).display() |
| 255 | )); |
| 256 | } |
| 257 | } |
| 258 | if !failures.is_empty() { |
| 259 | bail!( |
| 260 | "compiled host rollback needs attention; recovery bytes preserved: {}", |
| 261 | failures.join("; ") |
| 262 | ); |
| 263 | } |
| 264 | Ok(()) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | pub(super) fn required_for(current_exe: &Path) -> Result<bool> { |
| 269 | let directory = current_exe |
| 270 | .parent() |
| 271 | .context("updater executable has no parent")?; |
| 272 | Ok( |
| 273 | std::env::var("CODEWHALE_INSTALL_COMPILED_HOST").as_deref() == Ok("1") |
| 274 | || GuardedFile::open_bounded(directory, RECEIPT, 64 * 1024)?.is_some(), |
| 275 | ) |
| 276 | } |
| 277 | |
| 278 | pub(super) fn require_catalog_manifest(bytes: &[u8]) -> Result<()> { |
| 279 | let text = std::str::from_utf8(bytes).context("compiled host manifest is not UTF-8")?; |
| 280 | if !parse_checksum_manifest(text)?.contains_key(CATALOG) { |
| 281 | bail!("release source has no qualified compiled-host catalog"); |
| 282 | } |
| 283 | Ok(()) |
| 284 | } |
| 285 | |
| 286 | pub(super) fn prepare( |
| 287 | download: &DownloadPlan, |
| 288 | release_version: &str, |
| 289 | target: &str, |
| 290 | current_exe: &Path, |
| 291 | proxy: Option<&Proxy>, |
| 292 | ) -> Result<Option<Prepared>> { |
| 293 | let directory = current_exe |
| 294 | .parent() |
| 295 | .context("updater executable has no parent")?; |
| 296 | let host_path = directory.join(format!( |
| 297 | "codewhale-extension-host{}", |
| 298 | std::env::consts::EXE_SUFFIX |
| 299 | )); |
| 300 | let receipt_path = directory.join(RECEIPT); |
| 301 | let mut receipt_file = GuardedFile::open_bounded(directory, RECEIPT, 64 * 1024)?; |
| 302 | let requested = std::env::var("CODEWHALE_INSTALL_COMPILED_HOST").as_deref() == Ok("1"); |
| 303 | if receipt_file.is_none() && !requested { |
| 304 | return Ok(None); |
| 305 | } |
| 306 | let mut ownership = HashMap::new(); |
| 307 | if let Some(mut receipt) = receipt_file.take() { |
| 308 | let old = parse(&receipt.read_bounded(64 * 1024)?, env!("CARGO_PKG_VERSION"))?; |
| 309 | let entry = old |
| 310 | .hosts |
| 311 | .iter() |
| 312 | .find(|host| host.target == target) |
| 313 | .context("installed host receipt has no matching target")?; |
| 314 | for (name, expected) in [ |
| 315 | ( |
| 316 | host_path |
| 317 | .file_name() |
| 318 | .and_then(|name| name.to_str()) |
| 319 | .context("invalid host basename")?, |
| 320 | entry.sha256.as_str(), |
| 321 | ), |
| 322 | ( |
| 323 | "codewhale-extension-host.LICENSES.txt", |
| 324 | entry.notices_sha256.as_str(), |
| 325 | ), |
| 326 | ( |
| 327 | "codewhale-extension-host.relink-source.tar.gz", |
| 328 | entry.source_sha256.as_str(), |
| 329 | ), |
| 330 | ] { |
| 331 | let file = GuardedFile::open(directory, name)? |
| 332 | .with_context(|| format!("owned compiled host file missing: {name}"))?; |
| 333 | if file.sha256() != expected { |
| 334 | bail!("compiled host ownership changed: {name}"); |
| 335 | } |
| 336 | ownership.insert(directory.join(name), file); |
| 337 | } |
| 338 | ownership.insert(receipt_path.clone(), receipt); |
| 339 | } else if GuardedFile::open( |
| 340 | directory, |
| 341 | host_path |
| 342 | .file_name() |
| 343 | .and_then(|name| name.to_str()) |
| 344 | .context("invalid host basename")?, |
| 345 | )? |
| 346 | .is_some() |
| 347 | { |
| 348 | bail!( |
| 349 | "compiled host beside this CLI has no installation receipt; it was not replaced. Install into a fresh prefix or use Node" |
| 350 | ); |
| 351 | } |
| 352 | let fetch = |name: &str| -> Result<Vec<u8>> { |
| 353 | let url = reqwest::Url::parse(&download.binary_url)?.join(name)?; |
| 354 | let bytes = download_url(url.as_str(), proxy)?; |
| 355 | verify_manifest_asset(download, name, &bytes)?; |
| 356 | Ok(bytes) |
| 357 | }; |
| 358 | if !download.checksums.contains_key(CATALOG) { |
| 359 | bail!( |
| 360 | "compiled host requested/installed but the selected release source has no qualified catalog; no files were changed. Node remains available" |
| 361 | ); |
| 362 | } |
| 363 | let catalog_bytes = fetch(CATALOG)?; |
| 364 | let catalog = parse(&catalog_bytes, release_version)?; |
| 365 | let host = catalog |
| 366 | .hosts |
| 367 | .iter() |
| 368 | .find(|host| host.target == target) |
| 369 | .context("release has no qualified compiled image for this target; use Node")?; |
| 370 | let payloads = [ |
| 371 | (host.asset.as_str(), host_path, host.sha256.as_str(), true), |
| 372 | ( |
| 373 | host.notices_asset.as_str(), |
| 374 | directory.join("codewhale-extension-host.LICENSES.txt"), |
| 375 | host.notices_sha256.as_str(), |
| 376 | false, |
| 377 | ), |
| 378 | ( |
| 379 | host.source_asset.as_str(), |
| 380 | directory.join("codewhale-extension-host.relink-source.tar.gz"), |
| 381 | host.source_sha256.as_str(), |
| 382 | false, |
| 383 | ), |
| 384 | ( |
| 385 | CATALOG, |
| 386 | receipt_path, |
| 387 | download |
| 388 | .checksums |
| 389 | .get(CATALOG) |
| 390 | .context("catalog checksum missing")? |
| 391 | .as_str(), |
| 392 | false, |
| 393 | ), |
| 394 | ]; |
| 395 | let mut replacements = Vec::new(); |
| 396 | for (name, path, expected, executable) in payloads { |
| 397 | let bytes = if name == CATALOG { |
| 398 | catalog_bytes.clone() |
| 399 | } else { |
| 400 | fetch(name)? |
| 401 | }; |
| 402 | if sha256_hex(&bytes) != expected { |
| 403 | bail!("compiled host catalog and manifest disagree for {name}"); |
| 404 | } |
| 405 | if executable && cfg!(target_os = "linux") { |
| 406 | let required = highest_required_glibc(&bytes); |
| 407 | let available = detect_host_glibc(); |
| 408 | if (host.libc == "glibc" && available.is_none()) |
| 409 | || required |
| 410 | .is_some_and(|required| available.is_none_or(|available| available < required)) |
| 411 | || (host.libc == "musl" && required.is_some()) |
| 412 | { |
| 413 | bail!( |
| 414 | "optional compiled Bun image has an incompatible libc floor; the CLI remains static musl. Use Node" |
| 415 | ); |
| 416 | } |
| 417 | } |
| 418 | let old = ownership.remove(&path); |
| 419 | let filename = path |
| 420 | .file_name() |
| 421 | .and_then(|name| name.to_str()) |
| 422 | .context("invalid compiled payload basename")? |
| 423 | .to_owned(); |
| 424 | if old.is_none() && GuardedFile::open(directory, &filename)?.is_some() { |
| 425 | bail!("refusing unowned compiled host file {}", path.display()); |
| 426 | } |
| 427 | let staged = GuardedFile::stage(directory, &bytes, executable)?; |
| 428 | if staged.sha256() != expected { |
| 429 | bail!("compiled host stage changed: {}", staged.name()); |
| 430 | } |
| 431 | replacements.push(Replacement { |
| 432 | name: filename, |
| 433 | staged, |
| 434 | old, |
| 435 | backup_name: GuardedFile::recovery_name(), |
| 436 | retired: false, |
| 437 | published: false, |
| 438 | }); |
| 439 | } |
| 440 | Ok(Some(Prepared { |
| 441 | directory: directory.to_path_buf(), |
| 442 | replacements, |
| 443 | })) |
| 444 | } |
| 445 | |
| 446 | #[cfg(test)] |
| 447 | mod tests { |
| 448 | use super::*; |
| 449 | |
| 450 | fn receipt() -> serde_json::Value { |
| 451 | serde_json::json!({ |
| 452 | "schema":1,"version":"0.10.1","source_sha":"b".repeat(40),"bundle_sha256":"a".repeat(64), |
| 453 | "hosts":[{ |
| 454 | "target":"linux-x64","asset":"codewhale-extension-host-linux-x64","sha256":"c".repeat(64), |
| 455 | "notices_asset":"codewhale-extension-host-linux-x64-LICENSES.txt","notices_sha256":"d".repeat(64), |
| 456 | "source_asset":"codewhale-extension-host-linux-x64-relink-source.tar.gz","source_sha256":"e".repeat(64), |
| 457 | "runtime_version":"1.4.0","runtime_revision":"f".repeat(40),"runtime_sha256":"1".repeat(64),"webkit_revision":"2".repeat(40), |
| 458 | "bundle_sha256":"a".repeat(64),"source_commit":"b".repeat(40),"native_platform":"linux","native_arch":"x64","libc":"glibc", |
| 459 | "passed":6,"failed":0,"skipped":0,"test_log_sha256":"3".repeat(64),"native_passed":1,"native_failed":0,"native_skipped":0,"native_log_sha256":"4".repeat(64),"license_closure":"complete","relink_source":"complete" |
| 460 | }] |
| 461 | }) |
| 462 | } |
| 463 | |
| 464 | #[test] |
| 465 | fn catalog_refuses_cross_native_or_skipped_containment_and_mixed_release() { |
| 466 | let valid = receipt(); |
| 467 | assert!(parse(&serde_json::to_vec(&valid).unwrap(), "v0.10.1").is_ok()); |
| 468 | for (key, value) in [ |
| 469 | ("native_skipped", serde_json::json!(1)), |
| 470 | ("native_arch", serde_json::json!("arm64")), |
| 471 | ("native_passed", serde_json::json!(0)), |
| 472 | ("source_asset", serde_json::json!("../source.tar.gz")), |
| 473 | ("relink_source", serde_json::json!("pending")), |
| 474 | ] { |
| 475 | let mut invalid = valid.clone(); |
| 476 | invalid["hosts"][0][key] = value; |
| 477 | assert!( |
| 478 | parse(&serde_json::to_vec(&invalid).unwrap(), "0.10.1").is_err(), |
| 479 | "{key}" |
| 480 | ); |
| 481 | } |
| 482 | assert!(parse(&serde_json::to_vec(&valid).unwrap(), "0.10.2").is_err()); |
| 483 | } |
| 484 | |
| 485 | #[test] |
| 486 | fn catalog_requires_all_eight_current_windows_native_cases() { |
| 487 | let mut value = receipt(); |
| 488 | let host = &mut value["hosts"][0]; |
| 489 | for (key, field) in [ |
| 490 | ("target", "windows-x64"), |
| 491 | ("asset", "codewhale-extension-host-windows-x64.exe"), |
| 492 | ( |
| 493 | "notices_asset", |
| 494 | "codewhale-extension-host-windows-x64-LICENSES.txt", |
| 495 | ), |
| 496 | ( |
| 497 | "source_asset", |
| 498 | "codewhale-extension-host-windows-x64-relink-source.tar.gz", |
| 499 | ), |
| 500 | ("native_platform", "win32"), |
| 501 | ("libc", "none"), |
| 502 | ] { |
| 503 | host[key] = serde_json::json!(field); |
| 504 | } |
| 505 | host["native_passed"] = serde_json::json!(7); |
| 506 | assert!(parse(&serde_json::to_vec(&value).unwrap(), "0.10.1").is_err()); |
| 507 | value["hosts"][0]["native_passed"] = serde_json::json!(8); |
| 508 | assert!(parse(&serde_json::to_vec(&value).unwrap(), "0.10.1").is_ok()); |
| 509 | } |
| 510 | |
| 511 | fn prepared(directory: &Path, name: &str, old: Option<&[u8]>) -> Prepared { |
| 512 | if let Some(bytes) = old { |
| 513 | std::fs::write(directory.join(name), bytes).unwrap(); |
| 514 | } |
| 515 | Prepared { |
| 516 | directory: directory.to_path_buf(), |
| 517 | replacements: vec![Replacement { |
| 518 | name: name.into(), |
| 519 | staged: GuardedFile::stage(directory, b"qualified", true).unwrap(), |
| 520 | old: GuardedFile::open(directory, name).unwrap(), |
| 521 | backup_name: GuardedFile::recovery_name(), |
| 522 | retired: false, |
| 523 | published: false, |
| 524 | }], |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | #[test] |
| 529 | fn publish_retires_no_companion_if_a_destination_changed() { |
| 530 | let directory = tempfile::tempdir().unwrap(); |
| 531 | let mut prepared = prepared(directory.path(), "host", None); |
| 532 | std::fs::write(directory.path().join("host"), b"foreign").unwrap(); |
| 533 | assert!(prepared.publish().is_err()); |
| 534 | assert_eq!( |
| 535 | std::fs::read(directory.path().join("host")).unwrap(), |
| 536 | b"foreign" |
| 537 | ); |
| 538 | } |
| 539 | |
| 540 | #[cfg(unix)] |
| 541 | #[test] |
| 542 | fn same_byte_inode_swap_cannot_establish_ownership() { |
| 543 | let directory = tempfile::tempdir().unwrap(); |
| 544 | let mut prepared = prepared(directory.path(), "host", Some(b"old")); |
| 545 | std::fs::rename( |
| 546 | directory.path().join("host"), |
| 547 | directory.path().join("original"), |
| 548 | ) |
| 549 | .unwrap(); |
| 550 | std::fs::write(directory.path().join("host"), b"old").unwrap(); |
| 551 | assert!(prepared.publish().is_err()); |
| 552 | assert_eq!( |
| 553 | std::fs::read(directory.path().join("host")).unwrap(), |
| 554 | b"old" |
| 555 | ); |
| 556 | assert_eq!( |
| 557 | std::fs::read(directory.path().join("original")).unwrap(), |
| 558 | b"old" |
| 559 | ); |
| 560 | } |
| 561 | |
| 562 | #[cfg(unix)] |
| 563 | #[test] |
| 564 | fn changed_writer_refuses_retirement_and_preserves_original() { |
| 565 | let directory = tempfile::tempdir().unwrap(); |
| 566 | let mut prepared = prepared(directory.path(), "host", Some(b"old")); |
| 567 | std::fs::write(directory.path().join("host"), b"concurrent").unwrap(); |
| 568 | assert!(prepared.publish().is_err()); |
| 569 | assert_eq!( |
| 570 | std::fs::read(directory.path().join("host")).unwrap(), |
| 571 | b"concurrent" |
| 572 | ); |
| 573 | } |
| 574 | |
| 575 | #[test] |
| 576 | fn successful_publication_and_rollback_preserve_both_versions() { |
| 577 | let directory = tempfile::tempdir().unwrap(); |
| 578 | let mut prepared = prepared(directory.path(), "host", Some(b"old")); |
| 579 | prepared.publish().unwrap(); |
| 580 | assert_eq!( |
| 581 | std::fs::read(directory.path().join("host")).unwrap(), |
| 582 | b"qualified" |
| 583 | ); |
| 584 | prepared.rollback().unwrap(); |
| 585 | assert_eq!( |
| 586 | std::fs::read(directory.path().join("host")).unwrap(), |
| 587 | b"old" |
| 588 | ); |
| 589 | let recovered = directory |
| 590 | .path() |
| 591 | .join(prepared.replacements[0].staged.name()); |
| 592 | assert_eq!(std::fs::read(recovered).unwrap(), b"qualified"); |
| 593 | } |
| 594 | |
| 595 | #[cfg(unix)] |
| 596 | #[test] |
| 597 | fn partial_rollback_preserves_changed_published_entry_and_recovers_other_slot() { |
| 598 | let directory = tempfile::tempdir().unwrap(); |
| 599 | let mut first = prepared(directory.path(), "host", Some(b"old-host")); |
| 600 | let mut second = prepared(directory.path(), "notices", Some(b"old-notices")); |
| 601 | first.replacements.append(&mut second.replacements); |
| 602 | first.publish().unwrap(); |
| 603 | let backup = directory.path().join(&first.replacements[0].backup_name); |
| 604 | std::fs::rename( |
| 605 | directory.path().join("host"), |
| 606 | directory.path().join("published-original"), |
| 607 | ) |
| 608 | .unwrap(); |
| 609 | // Same bytes must still fail identity; a digest-only rollback deletes it. |
| 610 | std::fs::write(directory.path().join("host"), b"qualified").unwrap(); |
| 611 | let error = first.rollback().unwrap_err().to_string(); |
| 612 | assert!(error.contains("recovery bytes preserved")); |
| 613 | assert_eq!( |
| 614 | std::fs::read(directory.path().join("host")).unwrap(), |
| 615 | b"qualified" |
| 616 | ); |
| 617 | assert_eq!(std::fs::read(backup).unwrap(), b"old-host"); |
| 618 | assert_eq!( |
| 619 | std::fs::read(directory.path().join("notices")).unwrap(), |
| 620 | b"old-notices" |
| 621 | ); |
| 622 | } |
| 623 | } |
| 624 |