1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3#![warn(clippy::missing_errors_doc)]
9#![doc = "Policy-neutral local mechanics for verified multi-artifact updates."]
10#![doc = ""]
11#![doc = "The core crate deliberately does not fetch bytes, manage services, or choose release policy."]
12#![doc = "Callers acquire artifacts and prove destination ownership; the core"]
13#![doc = "provides validated preparation, SHA-256 integrity verification, bounded"]
14#![doc = "candidate validation, locked ownership revalidation, staged-digest"]
15#![doc = "revalidation, and atomic-feeling multi-artifact commit with rollback."]
16#![doc = ""]
17#![doc = "Integrity here is checksum evidence only. No authenticity or signature"]
18#![doc = "claim is made: there is no authenticity verifier in this crate."]
19#![doc = ""]
20#![doc = "Runnable flows live in `examples/`: one-member, multi-member, custom"]
21#![doc = "validator, ownership verifier, and receipt interpretation."]
22
23mod candidate;
24mod current_exe;
25mod domain;
26mod error;
27mod integrity;
28mod lock;
29mod stage;
30mod transaction;
31
32pub use candidate::{
33 run_bounded, AllValidators, CandidateValidator, CommandOutput, CommandSpec,
34 CrossMemberAgreementValidator, ExactIdentityValidator, ValidatedTransaction, MAX_STDIN_BYTES,
35};
36pub use current_exe::CurrentExecutable;
37pub use domain::{
38 AbsentOnlyVerifier, AbsentPolicy, ArtifactMember, ArtifactSet, BoundSources, CommitOwnership,
39 ExactDigestVerifier, ExistingAsOwnedVerifier, FileKind, InstallPlan, IntegrityRequirement,
40 MemberId, Ownership, OwnershipVerifier, PermissionsIntent, ProductId, ReleaseId,
41 StagePlacement,
42};
43pub use error::{Error, RecoveryError, RecoveryResult, Result};
44pub use integrity::{
45 hash_file, parse_sha256_sidecar, verify_file, IntegrityResult, IntegrityStatus, Sha256Manifest,
46 VerifiedTransaction,
47};
48pub use lock::{LockObservation, LockStatus, MutationLock, StaleLockDecision, StaleLockVerifier};
49pub use stage::PreparedTransaction;
50pub use transaction::{
51 CleanupDisposition, FailureCategory, FailurePhase, FailureReport, PostCommitFailurePolicy,
52 TransactionDisposition, TransactionReceipt,
53};
54
55#[cfg(test)]
56mod test_support;
57
58#[cfg(test)]
59mod failure_taxonomy_tests;
60
61#[cfg(test)]
62mod tests {
63 use super::test_support::{FailureInjector, FailurePoint, InstallationRoot};
64 use super::transaction::CommitFault;
65 use super::{
66 AbsentOnlyVerifier, AbsentPolicy, AllValidators, ArtifactMember, ArtifactSet, BoundSources,
67 CleanupDisposition, CommitOwnership, Error, ExactDigestVerifier, ExactIdentityValidator,
68 ExistingAsOwnedVerifier, InstallPlan, MemberId, MutationLock, Ownership, OwnershipVerifier,
69 PostCommitFailurePolicy, ProductId, ReleaseId, TransactionDisposition,
70 };
71 use std::collections::HashMap;
72 use std::fs;
73 use std::path::Path;
74 #[cfg(unix)]
75 use std::time::Duration;
76
77 struct FixedVerifier(Ownership);
78 impl std::fmt::Debug for FixedVerifier {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_tuple("FixedVerifier").field(&self.0).finish()
81 }
82 }
83 impl OwnershipVerifier for FixedVerifier {
84 fn verify(&self, _m: &MemberId, _d: &Path) -> Ownership {
85 self.0
86 }
87 }
88
89 fn allow_create() -> ExistingAsOwnedVerifier {
90 ExistingAsOwnedVerifier
91 }
92
93 fn commit_verified(
94 prepared: super::PreparedTransaction,
95 verifier: &dyn OwnershipVerifier,
96 absent: AbsentPolicy,
97 ) -> super::Result<super::TransactionReceipt> {
98 let verified = prepared.verify_integrity()?;
99 let validated = verified.validate(&AllValidators::new())?;
100 validated.commit(CommitOwnership::new(verifier, absent))
101 }
102
103 fn commit_verified_with_fault(
104 prepared: super::PreparedTransaction,
105 verifier: &dyn OwnershipVerifier,
106 absent: AbsentPolicy,
107 fault: CommitFault,
108 ) -> super::Result<super::TransactionReceipt> {
109 let verified = prepared.verify_integrity()?;
110 let validated = verified.validate(&AllValidators::new())?;
111 validated.commit_with_fault(CommitOwnership::new(verifier, absent), fault)
112 }
113
114 #[test]
115 fn fixture_writes_and_reads_exact_bytes() {
116 let fixture = InstallationRoot::new().expect("fixture");
117 fixture
118 .write_file("bin/eggup", b"exact bytes")
119 .expect("write");
120
121 assert_eq!(
122 fixture.read_file("bin/eggup").expect("read"),
123 b"exact bytes"
124 );
125 assert!(fixture.path().join("bin/eggup").is_file());
126 }
127
128 #[test]
129 fn fixture_rejects_escape_paths() {
130 let fixture = InstallationRoot::new().expect("fixture");
131
132 let error = fixture.write_file("../outside", b"nope").unwrap_err();
133
134 assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
135 assert!(!fixture.path().parent().unwrap().join("outside").exists());
136 }
137
138 #[test]
139 fn failure_injector_defaults_to_no_failure() {
140 let mut injector = FailureInjector::default();
141
142 assert!(injector.check(FailurePoint::Prepare).is_ok());
143 assert!(injector.check(FailurePoint::Commit).is_ok());
144 }
145
146 #[test]
147 fn prepares_a_multi_member_bundle_without_touching_destinations() {
148 let inputs = InstallationRoot::new().expect("inputs");
149 let install = InstallationRoot::new().expect("install");
150 inputs.write_file("egress", b"egress").expect("write");
151 inputs.write_file("helper", b"helper").expect("write");
152 let product = ProductId::new("egress").expect("product");
153 let release = ReleaseId::new("2026.09.22").expect("release");
154 let members = ArtifactSet::new(vec![
155 ArtifactMember::new(
156 MemberId::new("main").expect("member"),
157 inputs.path().join("egress"),
158 "bin/egress",
159 )
160 .expect("artifact"),
161 ArtifactMember::new(
162 MemberId::new("helper").expect("member"),
163 inputs.path().join("helper"),
164 "bin/helper",
165 )
166 .expect("artifact"),
167 ])
168 .expect("members");
169
170 let prepared = InstallPlan::new(product, release, install.path(), members)
171 .expect("plan")
172 .prepare()
173 .expect("prepare");
174
175 assert!(!install.path().join("bin/egress").exists());
176 assert_eq!(
177 fs::read(
178 prepared
179 .staged_path(&MemberId::new("main").unwrap())
180 .unwrap()
181 )
182 .unwrap(),
183 b"egress"
184 );
185 }
186
187 #[test]
188 fn preparation_failure_cleans_private_stage() {
189 let inputs = InstallationRoot::new().expect("inputs");
190 let install = InstallationRoot::new().expect("install");
191 inputs.write_file("eggup", b"bytes").expect("write");
192 let plan = InstallPlan::new(
193 ProductId::new("eggup").unwrap(),
194 ReleaseId::new("r1").unwrap(),
195 install.path(),
196 ArtifactSet::single(
197 ArtifactMember::new(
198 MemberId::new("main").unwrap(),
199 inputs.path().join("eggup"),
200 "eggup",
201 )
202 .unwrap(),
203 )
204 .unwrap(),
205 )
206 .unwrap();
207
208 assert!(plan
209 .clone()
210 .prepare_with_failure(FailurePoint::StageCreate)
211 .is_err());
212 let result = plan.prepare_with_failure(FailurePoint::StageCopy);
213
214 assert!(result.is_err());
215 assert!(!install.path().join("eggup").exists());
216 let stage_prefix = format!(
217 ".eggup-stage-{}-",
218 install.path().file_name().unwrap().to_string_lossy()
219 );
220 assert!(install
221 .path()
222 .parent()
223 .unwrap()
224 .read_dir()
225 .unwrap()
226 .filter_map(std::result::Result::ok)
227 .all(|entry| !entry
228 .file_name()
229 .to_string_lossy()
230 .starts_with(&stage_prefix)));
231 }
232
233 #[test]
234 fn rejects_duplicate_normalized_destinations() {
235 let inputs = InstallationRoot::new().expect("inputs");
236 let install = InstallationRoot::new().expect("install");
237 inputs.write_file("one", b"one").expect("write");
238 inputs.write_file("two", b"two").expect("write");
239 let members = ArtifactSet::new(vec![
240 ArtifactMember::new(
241 MemberId::new("one").unwrap(),
242 inputs.path().join("one"),
243 "bin/./app",
244 )
245 .unwrap(),
246 ArtifactMember::new(
247 MemberId::new("two").unwrap(),
248 inputs.path().join("two"),
249 "bin/app",
250 )
251 .unwrap(),
252 ])
253 .unwrap();
254
255 let result = InstallPlan::new(
256 ProductId::new("eggup").unwrap(),
257 ReleaseId::new("r1").unwrap(),
258 install.path(),
259 members,
260 );
261
262 assert!(result.is_err());
263 }
264
265 #[test]
266 fn rejects_empty_sets_and_escaping_destinations() {
267 assert!(ArtifactSet::new(Vec::new()).is_err());
268 assert!(ArtifactMember::new(
269 MemberId::new("main").unwrap(),
270 "/tmp/acquired",
271 "../outside",
272 )
273 .is_err());
274 assert!(ArtifactMember::new(
275 MemberId::new("main").unwrap(),
276 "/tmp/acquired",
277 "/absolute/path",
278 )
279 .is_err());
280 }
281
282 #[test]
283 fn bound_source_stages_open_object_despite_foreign_replacement() {
284 use std::fs::File;
285
286 let inputs = InstallationRoot::new().expect("inputs");
287 let install = InstallationRoot::new().expect("install");
288 let source = inputs.write_file("owned", b"owned-bytes").expect("write");
289 let handle = File::open(&source).expect("open");
290 let member =
291 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
292 .unwrap();
293 let plan = InstallPlan::new(
294 ProductId::new("eggup").unwrap(),
295 ReleaseId::new("r1").unwrap(),
296 install.path(),
297 ArtifactSet::single(member).unwrap(),
298 )
299 .unwrap();
300 fs::remove_file(&source).expect("remove");
303 fs::write(&source, b"foreign-bytes").expect("foreign");
304 let mut bound = BoundSources::new();
305 bound.insert(MemberId::new("main").unwrap(), handle);
306 let prepared = plan.prepare_with_bound_sources(bound).unwrap();
307 assert_eq!(
308 fs::read(
309 prepared
310 .staged_path(&MemberId::new("main").unwrap())
311 .unwrap()
312 )
313 .unwrap(),
314 b"owned-bytes"
315 );
316 assert_eq!(fs::read(&source).unwrap(), b"foreign-bytes");
317 }
318
319 #[test]
320 fn bound_source_stages_open_object_after_root_rename() {
321 use std::fs::File;
322
323 let inputs = InstallationRoot::new().expect("inputs");
324 let install = InstallationRoot::new().expect("install");
325 let subdir = inputs.path().join("owned-dir");
326 fs::create_dir(&subdir).expect("mkdir");
327 let source = subdir.join("member");
328 fs::write(&source, b"owned-bytes").expect("write");
329 let handle = File::open(&source).expect("open");
330 let member =
331 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
332 .unwrap();
333 let plan = InstallPlan::new(
334 ProductId::new("eggup").unwrap(),
335 ReleaseId::new("r1").unwrap(),
336 install.path(),
337 ArtifactSet::single(member).unwrap(),
338 )
339 .unwrap();
340 #[cfg(unix)]
350 fs::rename(&subdir, inputs.path().join("renamed-dir")).expect("rename");
351 #[cfg(windows)]
352 fs::rename(&source, subdir.join("renamed-member")).expect("rename");
353 let mut bound = BoundSources::new();
354 bound.insert(MemberId::new("main").unwrap(), handle);
355 let prepared = plan.prepare_with_bound_sources(bound).unwrap();
356 assert_eq!(
357 fs::read(
358 prepared
359 .staged_path(&MemberId::new("main").unwrap())
360 .unwrap()
361 )
362 .unwrap(),
363 b"owned-bytes"
364 );
365 }
366
367 #[test]
368 fn bound_source_stages_full_bytes_from_nonzero_cursor() {
369 use std::fs::File;
370 use std::io::{Read, Seek, SeekFrom};
371
372 let inputs = InstallationRoot::new().expect("inputs");
373 let install = InstallationRoot::new().expect("install");
374 let source = inputs.write_file("owned", b"owned-bytes").expect("write");
375 let mut handle = File::open(&source).expect("open");
376 let mut prefix = [0u8; 5];
377 handle.read_exact(&mut prefix).expect("read");
378 assert_eq!(&prefix, b"owned");
379 handle.seek(SeekFrom::Start(2)).expect("seek");
380 let member =
381 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
382 .unwrap();
383 let plan = InstallPlan::new(
384 ProductId::new("eggup").unwrap(),
385 ReleaseId::new("r1").unwrap(),
386 install.path(),
387 ArtifactSet::single(member).unwrap(),
388 )
389 .unwrap();
390 let mut bound = BoundSources::new();
391 bound.insert(MemberId::new("main").unwrap(), handle);
392 let prepared = plan.prepare_with_bound_sources(bound).unwrap();
393 assert_eq!(
394 fs::read(
395 prepared
396 .staged_path(&MemberId::new("main").unwrap())
397 .unwrap()
398 )
399 .unwrap(),
400 b"owned-bytes"
401 );
402 }
403
404 #[test]
405 fn bound_source_for_unknown_member_fails_closed() {
406 use std::fs::File;
407
408 let inputs = InstallationRoot::new().expect("inputs");
409 let install = InstallationRoot::new().expect("install");
410 let source = inputs.write_file("owned", b"owned-bytes").expect("write");
411 let handle = File::open(&source).expect("open");
412 let member =
413 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
414 .unwrap();
415 let plan = InstallPlan::new(
416 ProductId::new("eggup").unwrap(),
417 ReleaseId::new("r1").unwrap(),
418 install.path(),
419 ArtifactSet::single(member).unwrap(),
420 )
421 .unwrap();
422 let mut bound = BoundSources::new();
423 bound.insert(MemberId::new("ghost").unwrap(), handle);
424 assert!(plan.prepare_with_bound_sources(bound).is_err());
425 assert!(!install.path().join("bin/main").exists());
426 }
427
428 #[cfg(unix)]
429 #[test]
430 fn rejects_symlink_sources_and_preserves_executable_intent_in_stage() {
431 use std::os::unix::fs::symlink;
432
433 let inputs = InstallationRoot::new().expect("inputs");
434 let install = InstallationRoot::new().expect("install");
435 inputs.write_file("real", b"real").expect("write");
436 symlink(inputs.path().join("real"), inputs.path().join("link")).expect("symlink");
437 let symlink_member = ArtifactMember::new(
438 MemberId::new("link").unwrap(),
439 inputs.path().join("link"),
440 "bin/link",
441 )
442 .unwrap();
443 assert!(InstallPlan::new(
444 ProductId::new("eggup").unwrap(),
445 ReleaseId::new("r1").unwrap(),
446 install.path(),
447 ArtifactSet::single(symlink_member).unwrap(),
448 )
449 .is_err());
450
451 let member = ArtifactMember::new(
452 MemberId::new("real").unwrap(),
453 inputs.path().join("real"),
454 "bin/real",
455 )
456 .unwrap()
457 .with_permissions(super::PermissionsIntent::Executable);
458 let prepared = InstallPlan::new(
459 ProductId::new("eggup").unwrap(),
460 ReleaseId::new("r1").unwrap(),
461 install.path(),
462 ArtifactSet::single(member).unwrap(),
463 )
464 .unwrap()
465 .prepare()
466 .unwrap();
467 #[cfg(unix)]
468 {
469 use std::os::unix::fs::PermissionsExt;
470 let mode = fs::metadata(
471 prepared
472 .staged_path(&MemberId::new("real").unwrap())
473 .unwrap(),
474 )
475 .unwrap()
476 .permissions()
477 .mode();
478 assert_ne!(mode & 0o111, 0);
479 assert_eq!(mode & 0o777, 0o700);
480 }
481 }
482
483 #[cfg(unix)]
484 #[test]
485 fn bound_source_preserves_executable_intent_that_the_fresh_stage_masks() {
486 use std::fs::File;
487 use std::os::unix::fs::PermissionsExt;
488
489 let inputs = InstallationRoot::new().expect("inputs");
490 let install = InstallationRoot::new().expect("install");
491 let source = inputs.write_file("owned", b"owned-bytes").expect("write");
492 fs::set_permissions(&source, fs::Permissions::from_mode(0o755)).expect("chmod source");
493 let handle = File::open(&source).expect("open");
494 let digest = super::hash_file(&source).unwrap();
495 fs::create_dir_all(install.path().join("bin")).expect("bin");
496 let member =
498 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
499 .unwrap()
500 .with_integrity(super::IntegrityRequirement::Sha256(digest));
501 let plan = InstallPlan::new(
502 ProductId::new("eggup").unwrap(),
503 ReleaseId::new("r1").unwrap(),
504 install.path(),
505 ArtifactSet::single(member).unwrap(),
506 )
507 .unwrap();
508 let mut bound = BoundSources::new();
509 bound.insert(MemberId::new("main").unwrap(), handle);
510 let prepared = plan.prepare_with_bound_sources(bound).unwrap();
511
512 let staged = prepared
513 .staged_path(&MemberId::new("main").unwrap())
514 .unwrap();
515 let staged_mode = fs::metadata(&staged).unwrap().permissions().mode();
516 assert_ne!(
517 staged_mode & 0o111,
518 0,
519 "bound staging must not read the hardcoded 0600 back as source intent"
520 );
521 assert_eq!(staged_mode & 0o777, 0o700);
522
523 let receipt =
525 commit_verified(prepared, &allow_create(), AbsentPolicy::AllowCreate).unwrap();
526 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
527 let live_mode = fs::metadata(install.path().join("bin/main"))
528 .unwrap()
529 .permissions()
530 .mode();
531 assert_ne!(
532 live_mode & 0o111,
533 0,
534 "installed executable must land executable"
535 );
536 assert_eq!(live_mode & 0o777, 0o700);
537 }
538
539 #[cfg(unix)]
540 #[test]
541 fn bound_source_does_not_widen_a_non_executable_source() {
542 use std::fs::File;
543 use std::os::unix::fs::PermissionsExt;
544
545 let inputs = InstallationRoot::new().expect("inputs");
546 let install = InstallationRoot::new().expect("install");
547 let source = inputs.write_file("owned", b"owned-bytes").expect("write");
548 fs::set_permissions(&source, fs::Permissions::from_mode(0o644)).expect("chmod source");
549 let handle = File::open(&source).expect("open");
550 let member =
551 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
552 .unwrap();
553 let plan = InstallPlan::new(
554 ProductId::new("eggup").unwrap(),
555 ReleaseId::new("r1").unwrap(),
556 install.path(),
557 ArtifactSet::single(member).unwrap(),
558 )
559 .unwrap();
560 let mut bound = BoundSources::new();
561 bound.insert(MemberId::new("main").unwrap(), handle);
562 let prepared = plan.prepare_with_bound_sources(bound).unwrap();
563 let staged_mode = fs::metadata(
564 prepared
565 .staged_path(&MemberId::new("main").unwrap())
566 .unwrap(),
567 )
568 .unwrap()
569 .permissions()
570 .mode();
571 assert_eq!(staged_mode & 0o777, 0o600);
572 }
573
574 #[test]
575 fn fault_category_is_structural_not_message_substring() {
576 let report = super::transaction::report_for_error(
579 super::FailurePhase::Ownership,
580 None,
581 &Error::invalid("member name 'injected' is reserved"),
582 );
583 assert_eq!(report.category(), super::FailureCategory::InvalidInput);
584
585 let injected = super::transaction::report_for_error(
586 super::FailurePhase::Ownership,
587 None,
588 &Error::injected("injected preparation failure at Copy"),
589 );
590 assert_eq!(injected.category(), super::FailureCategory::Injected);
591 }
592
593 fn prepared_bundle(
594 existing: bool,
595 ) -> (
596 InstallationRoot,
597 InstallationRoot,
598 super::PreparedTransaction,
599 ) {
600 let inputs = InstallationRoot::new().expect("inputs");
601 let install = InstallationRoot::new().expect("install");
602 let main_src = inputs.write_file("main", b"new-main").expect("write");
603 let helper_src = inputs.write_file("helper", b"new-helper").expect("write");
604 let main_digest = super::hash_file(&main_src).unwrap();
605 let helper_digest = super::hash_file(&helper_src).unwrap();
606 if existing {
607 install
608 .write_file("bin/main", b"old-main")
609 .expect("old main");
610 install
611 .write_file("bin/helper", b"old-helper")
612 .expect("old helper");
613 } else {
614 fs::create_dir_all(install.path().join("bin")).unwrap();
615 }
616 let members = ArtifactSet::new(vec![
617 ArtifactMember::new(
618 MemberId::new("main").unwrap(),
619 inputs.path().join("main"),
620 "bin/main",
621 )
622 .unwrap()
623 .with_integrity(super::IntegrityRequirement::Sha256(main_digest)),
624 ArtifactMember::new(
625 MemberId::new("helper").unwrap(),
626 inputs.path().join("helper"),
627 "bin/helper",
628 )
629 .unwrap()
630 .with_integrity(super::IntegrityRequirement::Sha256(helper_digest)),
631 ])
632 .unwrap();
633 let prepared = InstallPlan::new(
634 ProductId::new("bundle").unwrap(),
635 ReleaseId::new("r1").unwrap(),
636 install.path(),
637 members,
638 )
639 .unwrap()
640 .prepare()
641 .unwrap();
642 (inputs, install, prepared)
643 }
644
645 #[test]
646 fn commits_a_complete_multi_member_generation_and_releases_lock() {
647 let (_inputs, install, prepared) = prepared_bundle(true);
648 let verifier = allow_create();
649
650 let receipt =
651 commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).expect("commit");
652
653 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
654 assert_eq!(receipt.cleanup(), CleanupDisposition::Cleaned);
655 assert!(receipt.failure().is_none());
656 assert_eq!(
657 fs::read(install.path().join("bin/main")).unwrap(),
658 b"new-main"
659 );
660 assert_eq!(
661 fs::read(install.path().join("bin/helper")).unwrap(),
662 b"new-helper"
663 );
664 assert!(!install.path().join(".eggup-mutation.lock").exists());
665 assert!(install
666 .path()
667 .read_dir()
668 .unwrap()
669 .filter_map(std::result::Result::ok)
670 .all(|entry| !entry
671 .file_name()
672 .to_string_lossy()
673 .starts_with(".eggup-backup-")));
674 }
675
676 #[test]
677 fn post_commit_check_sees_complete_generation_and_holds_mutation_lock() {
678 let (_inputs, install, prepared) = prepared_bundle(true);
679 let verifier = allow_create();
680 let validated = prepared
681 .verify_integrity()
682 .unwrap()
683 .validate(&AllValidators::new())
684 .unwrap();
685 let receipt = validated
686 .commit_with_post_commit(
687 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
688 PostCommitFailurePolicy::RollBack,
689 || {
690 assert_eq!(
691 fs::read(install.path().join("bin/main")).unwrap(),
692 b"new-main"
693 );
694 assert_eq!(
695 fs::read(install.path().join("bin/helper")).unwrap(),
696 b"new-helper"
697 );
698 assert!(MutationLock::acquire(
699 install.path(),
700 &ProductId::new("bundle").unwrap(),
701 &ReleaseId::new("r1").unwrap(),
702 )
703 .is_err());
704 Ok::<_, &str>(())
705 },
706 )
707 .unwrap();
708 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
709 assert!(receipt.post_commit_failure().is_none());
710 }
711
712 #[test]
713 fn post_commit_keep_installed_records_bounded_failure() {
714 let (_inputs, install, prepared) = prepared_bundle(true);
715 let verifier = allow_create();
716 let validated = prepared
717 .verify_integrity()
718 .unwrap()
719 .validate(&AllValidators::new())
720 .unwrap();
721 let receipt = validated
722 .commit_with_post_commit(
723 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
724 PostCommitFailurePolicy::KeepInstalled,
725 || Err("é".repeat(400)),
726 )
727 .unwrap();
728 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
729 let failure = receipt.post_commit_failure().unwrap();
730 assert_eq!(failure.phase(), super::FailurePhase::PostCommit);
731 assert!(failure.detail().len() <= 512);
732 assert_eq!(
733 fs::read(install.path().join("bin/main")).unwrap(),
734 b"new-main"
735 );
736 assert_eq!(
737 fs::read(install.path().join("bin/helper")).unwrap(),
738 b"new-helper"
739 );
740 }
741
742 #[test]
743 fn post_commit_rollback_restores_old_generation_and_absent_members() {
744 let inputs = InstallationRoot::new().unwrap();
745 let install = InstallationRoot::new().unwrap();
746 let main = inputs.write_file("main", b"new-main").unwrap();
747 let helper = inputs.write_file("helper", b"new-helper").unwrap();
748 install.write_file("bin/main", b"old-main").unwrap();
749 let artifacts = ArtifactSet::new(vec![
750 ArtifactMember::new(MemberId::new("main").unwrap(), main, "bin/main")
751 .unwrap()
752 .with_integrity(super::IntegrityRequirement::Sha256(
753 super::hash_file(&inputs.path().join("main")).unwrap(),
754 )),
755 ArtifactMember::new(MemberId::new("helper").unwrap(), helper, "bin/helper")
756 .unwrap()
757 .with_integrity(super::IntegrityRequirement::Sha256(
758 super::hash_file(&inputs.path().join("helper")).unwrap(),
759 )),
760 ])
761 .unwrap();
762 let prepared = InstallPlan::new(
763 ProductId::new("bundle").unwrap(),
764 ReleaseId::new("r1").unwrap(),
765 install.path(),
766 artifacts,
767 )
768 .unwrap()
769 .prepare()
770 .unwrap();
771 let verifier = allow_create();
772 let receipt = prepared
773 .verify_integrity()
774 .unwrap()
775 .validate(&AllValidators::new())
776 .unwrap()
777 .commit_with_post_commit(
778 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
779 PostCommitFailurePolicy::RollBack,
780 || Err("health check failed"),
781 )
782 .unwrap();
783 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
784 assert_eq!(
785 receipt.failure().unwrap().phase(),
786 super::FailurePhase::PostCommit
787 );
788 assert_eq!(
789 fs::read(install.path().join("bin/main")).unwrap(),
790 b"old-main"
791 );
792 assert!(!install.path().join("bin/helper").exists());
793 }
794
795 #[test]
796 fn post_commit_rollback_failure_retains_both_reports_and_evidence() {
797 let (_inputs, install, prepared) = prepared_bundle(true);
798 let verifier = allow_create();
799 let receipt = prepared
800 .verify_integrity()
801 .unwrap()
802 .validate(&AllValidators::new())
803 .unwrap()
804 .commit_with_post_commit_fault(
805 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
806 PostCommitFailurePolicy::RollBack,
807 CommitFault::PostCommitRollback,
808 )
809 .unwrap();
810 assert_eq!(
811 receipt.disposition(),
812 TransactionDisposition::RecoveryRequired
813 );
814 assert_eq!(
815 receipt.post_commit_failure().unwrap().phase(),
816 super::FailurePhase::PostCommit
817 );
818 assert_eq!(
819 receipt.rollback_failure().unwrap().phase(),
820 super::FailurePhase::Rollback
821 );
822 assert!(receipt.recovery_path().unwrap().exists());
823 let recovery = receipt.recovery_path().unwrap().to_path_buf();
824 let _ = fs::remove_dir_all(recovery);
825 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
826 }
827
828 #[test]
829 fn keep_installed_finalize_failure_preserves_both_failure_facts() {
830 let (_inputs, install, prepared) = prepared_bundle(true);
831 let verifier = allow_create();
832 let receipt = prepared
833 .verify_integrity()
834 .unwrap()
835 .validate(&AllValidators::new())
836 .unwrap()
837 .commit_with_post_commit_fault(
838 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
839 PostCommitFailurePolicy::KeepInstalled,
840 CommitFault::Finalize,
841 )
842 .unwrap();
843 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
844 assert_eq!(
845 receipt.failure().unwrap().phase(),
846 super::FailurePhase::Finalize
847 );
848 assert_eq!(
849 receipt.post_commit_failure().unwrap().phase(),
850 super::FailurePhase::PostCommit
851 );
852 let recovery = receipt.recovery_path().unwrap().to_path_buf();
853 assert!(recovery.exists());
854 assert_eq!(
855 fs::read(install.path().join("bin/main")).unwrap(),
856 b"new-main"
857 );
858 let _ = fs::remove_dir_all(recovery);
859 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
860 }
861
862 #[test]
863 fn post_commit_panic_is_reported_and_rolls_back() {
864 let (_inputs, install, prepared) = prepared_bundle(true);
865 let verifier = allow_create();
866 let receipt = prepared
867 .verify_integrity()
868 .unwrap()
869 .validate(&AllValidators::new())
870 .unwrap()
871 .commit_with_post_commit(
872 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
873 PostCommitFailurePolicy::RollBack,
874 || -> Result<(), &str> { panic!("simulated check panic") },
875 )
876 .unwrap();
877 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
878 assert_eq!(
879 receipt.post_commit_failure().unwrap().detail(),
880 "post-commit check panicked"
881 );
882 assert_eq!(
883 fs::read(install.path().join("bin/main")).unwrap(),
884 b"old-main"
885 );
886 }
887
888 #[test]
889 fn partial_commit_failure_restores_every_old_member() {
890 let (_inputs, install, prepared) = prepared_bundle(true);
891 let verifier = allow_create();
892
893 let receipt = commit_verified_with_fault(
894 prepared,
895 &verifier,
896 AbsentPolicy::AllowCreate,
897 CommitFault::Commit(MemberId::new("helper").unwrap()),
898 )
899 .expect("receipt");
900
901 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
902 assert!(receipt.rollback_performed());
903 assert!(receipt.rollback_verified());
904 let failure = receipt.failure().expect("failure report");
905 assert_eq!(failure.phase(), super::FailurePhase::Commit);
906 assert_eq!(failure.member(), Some(&MemberId::new("helper").unwrap()));
907 assert_eq!(
908 fs::read(install.path().join("bin/main")).unwrap(),
909 b"old-main"
910 );
911 assert_eq!(
912 fs::read(install.path().join("bin/helper")).unwrap(),
913 b"old-helper"
914 );
915 }
916
917 #[test]
918 fn rollback_removes_new_members_that_were_absent_before_commit() {
919 let inputs = InstallationRoot::new().unwrap();
920 let install = InstallationRoot::new().unwrap();
921 let main_src = inputs.write_file("main", b"new-main").unwrap();
922 let helper_src = inputs.write_file("helper", b"new-helper").unwrap();
923 let main_digest = super::hash_file(&main_src).unwrap();
924 let helper_digest = super::hash_file(&helper_src).unwrap();
925 install.write_file("bin/main", b"old-main").unwrap();
926 let prepared = InstallPlan::new(
927 ProductId::new("bundle").unwrap(),
928 ReleaseId::new("r1").unwrap(),
929 install.path(),
930 ArtifactSet::new(vec![
931 ArtifactMember::new(
932 MemberId::new("main").unwrap(),
933 inputs.path().join("main"),
934 "bin/main",
935 )
936 .unwrap()
937 .with_integrity(super::IntegrityRequirement::Sha256(main_digest)),
938 ArtifactMember::new(
939 MemberId::new("helper").unwrap(),
940 inputs.path().join("helper"),
941 "bin/helper",
942 )
943 .unwrap()
944 .with_integrity(super::IntegrityRequirement::Sha256(helper_digest)),
945 ])
946 .unwrap(),
947 )
948 .unwrap()
949 .prepare()
950 .unwrap();
951 let verifier = allow_create();
952
953 let receipt = commit_verified_with_fault(
954 prepared,
955 &verifier,
956 AbsentPolicy::AllowCreate,
957 CommitFault::Commit(MemberId::new("helper").unwrap()),
958 )
959 .unwrap();
960
961 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
962 assert_eq!(
963 fs::read(install.path().join("bin/main")).unwrap(),
964 b"old-main"
965 );
966 assert!(!install.path().join("bin/helper").exists());
967 }
968
969 #[test]
970 fn lock_contention_and_malformed_lock_fail_closed() {
971 let (_inputs, install, prepared) = prepared_bundle(false);
972 let product = ProductId::new("bundle").unwrap();
973 let release = ReleaseId::new("r1").unwrap();
974 let _lock = MutationLock::acquire(install.path(), &product, &release).unwrap();
975 let verifier = allow_create();
976
977 let error = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap_err();
978 assert!(matches!(error, Error::UpdateInProgress { .. }));
979 drop(_lock);
980
981 let (_inputs, install2, prepared) = prepared_bundle(false);
982 let lock = install2.path().join(".eggup-mutation.lock");
983 fs::write(&lock, b"malformed").unwrap();
984 let error = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap_err();
985 assert!(matches!(error, Error::UpdateInProgress { .. }));
986 assert!(lock.exists());
987 }
988
989 #[test]
990 fn rollback_failure_returns_recovery_required_and_retains_evidence() {
991 let (_inputs, install, prepared) = prepared_bundle(true);
992 let verifier = allow_create();
993
994 let receipt = commit_verified_with_fault(
995 prepared,
996 &verifier,
997 AbsentPolicy::AllowCreate,
998 CommitFault::CommitThenRollback(
999 MemberId::new("helper").unwrap(),
1000 MemberId::new("main").unwrap(),
1001 ),
1002 )
1003 .unwrap();
1004
1005 assert_eq!(
1006 receipt.disposition(),
1007 TransactionDisposition::RecoveryRequired
1008 );
1009 assert!(!receipt.rollback_verified());
1010 assert!(receipt.recovery_path().is_some());
1011 assert!(receipt.failure().is_some());
1012 assert!(receipt.rollback_failure().is_some());
1013 assert_eq!(
1014 receipt.failure().unwrap().phase(),
1015 super::FailurePhase::Commit
1016 );
1017 assert_eq!(
1018 receipt.rollback_failure().unwrap().phase(),
1019 super::FailurePhase::Rollback
1020 );
1021 assert!(install.path().join(".eggup-mutation.lock").exists());
1022 let recovery = receipt.recovery_path().unwrap();
1023 assert!(recovery.exists());
1024 let _ = fs::remove_dir_all(recovery);
1025 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
1026 }
1027
1028 #[test]
1032 fn self_update_rollback_failure_retains_the_real_old_image() {
1033 use super::{hash_file, CurrentExecutable, IntegrityRequirement, StagePlacement};
1034
1035 let inputs = InstallationRoot::new().expect("inputs");
1036 let install = InstallationRoot::new().expect("install");
1037 let installed = install.path().join("app");
1038 fs::write(&installed, b"old-generation").expect("install old");
1039 let candidate = inputs
1040 .write_file("candidate", b"new-generation")
1041 .expect("candidate");
1042
1043 let current = CurrentExecutable::bind(&installed).expect("bind current executable");
1044 let digest = hash_file(&candidate).expect("hash candidate");
1045 let plan = InstallPlan::for_current_executable(
1046 ProductId::new("app").unwrap(),
1047 ReleaseId::new("r1").unwrap(),
1048 &candidate,
1049 ¤t,
1050 IntegrityRequirement::Sha256(digest),
1051 )
1052 .expect("plan");
1053 assert_eq!(
1054 plan.stage_placement(),
1055 StagePlacement::InsideInstallationRoot
1056 );
1057
1058 let prepared = plan.prepare().expect("prepare");
1059 let receipt = prepared
1060 .verify_integrity()
1061 .expect("verify")
1062 .validate(&AllValidators::new())
1063 .expect("validate")
1064 .commit_with_post_commit_fault(
1065 CommitOwnership::new(&FixedVerifier(Ownership::Owned), AbsentPolicy::DenyCreate),
1066 PostCommitFailurePolicy::RollBack,
1067 CommitFault::PostCommitRollback,
1068 )
1069 .expect("receipt");
1070
1071 assert_eq!(
1072 receipt.disposition(),
1073 TransactionDisposition::RecoveryRequired,
1074 "an incomplete rollback is never reported as RolledBack"
1075 );
1076 assert!(!receipt.rollback_verified());
1077 assert!(receipt.rollback_failure().is_some());
1078 let recovery = receipt
1079 .recovery_path()
1080 .expect("the real retained path must be reported");
1081 assert!(
1082 recovery.exists(),
1083 "reported recovery path must really exist"
1084 );
1085 assert!(
1086 recovery.starts_with(fs::canonicalize(install.path()).expect("canonical install root")),
1087 "recovery evidence stays inside the executable's own directory: {}",
1088 recovery.display()
1089 );
1090 let _ = fs::remove_dir_all(recovery);
1091 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
1092 }
1093
1094 #[test]
1095 fn precommit_and_backup_failures_restore_old_state() {
1096 let (_inputs, install, prepared) = prepared_bundle(true);
1097 let verifier = allow_create();
1098 let receipt = commit_verified_with_fault(
1099 prepared,
1100 &verifier,
1101 AbsentPolicy::AllowCreate,
1102 CommitFault::BeforeFirstCommit,
1103 )
1104 .unwrap();
1105 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1106 assert!(receipt.failure().is_some());
1107 assert_eq!(
1108 fs::read(install.path().join("bin/main")).unwrap(),
1109 b"old-main"
1110 );
1111
1112 let (_inputs, install, prepared) = prepared_bundle(true);
1113 let receipt = commit_verified_with_fault(
1114 prepared,
1115 &verifier,
1116 AbsentPolicy::AllowCreate,
1117 CommitFault::Backup(MemberId::new("helper").unwrap()),
1118 )
1119 .unwrap();
1120 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1121 assert_eq!(
1122 receipt.failure().unwrap().phase(),
1123 super::FailurePhase::Backup
1124 );
1125 assert_eq!(
1126 fs::read(install.path().join("bin/helper")).unwrap(),
1127 b"old-helper"
1128 );
1129 }
1130
1131 #[test]
1132 fn injected_lock_creation_failure_performs_no_mutation() {
1133 let (_inputs, install, prepared) = prepared_bundle(true);
1134 let verifier = allow_create();
1135
1136 let error = commit_verified_with_fault(
1137 prepared,
1138 &verifier,
1139 AbsentPolicy::AllowCreate,
1140 CommitFault::LockCreation,
1141 )
1142 .unwrap_err();
1143
1144 assert!(matches!(error, Error::Injected(_)));
1145 assert_eq!(
1146 fs::read(install.path().join("bin/main")).unwrap(),
1147 b"old-main"
1148 );
1149 assert!(!install.path().join(".eggup-mutation.lock").exists());
1150 }
1151
1152 #[cfg(unix)]
1153 #[test]
1154 fn destination_links_fail_immediately_before_backup() {
1155 use std::fs::hard_link;
1156 use std::os::unix::fs::symlink;
1157
1158 let inputs = InstallationRoot::new().unwrap();
1159 let install = InstallationRoot::new().unwrap();
1160 let src = inputs.write_file("main", b"new-main").unwrap();
1161 let digest = super::hash_file(&src).unwrap();
1162 install.write_file("bin/real", b"old-main").unwrap();
1163 symlink(install.path().join("real"), install.path().join("bin/main")).unwrap();
1164 let prepared = InstallPlan::new(
1165 ProductId::new("bundle").unwrap(),
1166 ReleaseId::new("r1").unwrap(),
1167 install.path(),
1168 ArtifactSet::single(
1169 ArtifactMember::new(
1170 MemberId::new("main").unwrap(),
1171 inputs.path().join("main"),
1172 "bin/main",
1173 )
1174 .unwrap()
1175 .with_integrity(super::IntegrityRequirement::Sha256(digest)),
1176 )
1177 .unwrap(),
1178 )
1179 .unwrap()
1180 .prepare()
1181 .unwrap();
1182 let verifier = allow_create();
1183 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1184 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1185 assert_eq!(
1186 receipt.failure().unwrap().phase(),
1187 super::FailurePhase::Ownership
1188 );
1189 assert_eq!(
1190 fs::read(install.path().join("bin/real")).unwrap(),
1191 b"old-main"
1192 );
1193
1194 let (_inputs, install, prepared) = prepared_bundle(true);
1195 hard_link(
1196 install.path().join("bin/main"),
1197 install.path().join("bin/linked"),
1198 )
1199 .unwrap();
1200 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1201 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1202 assert_eq!(
1203 fs::read(install.path().join("bin/main")).unwrap(),
1204 b"old-main"
1205 );
1206 }
1207
1208 #[test]
1209 fn hashes_known_bytes_and_parses_strict_sidecars() {
1210 let fixture = InstallationRoot::new().unwrap();
1211 let path = fixture.write_file("abc", b"abc").unwrap();
1212 let manifest = super::parse_sha256_sidecar(
1213 "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD abc\n",
1214 )
1215 .unwrap();
1216 assert_eq!(manifest.filename(), Some("abc"));
1217 assert_eq!(
1218 super::verify_file(&path, &manifest).unwrap(),
1219 *manifest.digest()
1220 );
1221 assert!(super::parse_sha256_sidecar("abc\ndef").is_err());
1222 assert!(super::parse_sha256_sidecar(
1223 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad other"
1224 )
1225 .and_then(|manifest| super::verify_file(&path, &manifest))
1226 .is_err());
1227 assert!(super::parse_sha256_sidecar("not-a-digest abc").is_err());
1228 }
1229
1230 #[test]
1231 fn integrity_verification_precedes_candidate_validation() {
1232 let inputs = InstallationRoot::new().unwrap();
1233 let install = InstallationRoot::new().unwrap();
1234 let source = inputs.write_file("main", b"verified bytes").unwrap();
1235 let digest = super::hash_file(&source).unwrap();
1236 let member = ArtifactMember::new(MemberId::new("main").unwrap(), source, "bin/main")
1237 .unwrap()
1238 .with_integrity(super::IntegrityRequirement::Sha256(digest));
1239 let verified = InstallPlan::new(
1240 ProductId::new("eggup").unwrap(),
1241 ReleaseId::new("r1").unwrap(),
1242 install.path(),
1243 ArtifactSet::single(member).unwrap(),
1244 )
1245 .unwrap()
1246 .prepare()
1247 .unwrap()
1248 .verify_integrity()
1249 .unwrap();
1250 assert_eq!(
1251 verified
1252 .integrity(&MemberId::new("main").unwrap())
1253 .unwrap()
1254 .status(),
1255 super::IntegrityStatus::Verified
1256 );
1257
1258 let bad_source = inputs.write_file("bad", b"bad bytes").unwrap();
1259 let bad_member = ArtifactMember::new(MemberId::new("bad").unwrap(), bad_source, "bin/bad")
1260 .unwrap()
1261 .with_integrity(super::IntegrityRequirement::Sha256(digest));
1262 assert!(matches!(
1263 InstallPlan::new(
1264 ProductId::new("eggup").unwrap(),
1265 ReleaseId::new("r1").unwrap(),
1266 install.path(),
1267 ArtifactSet::single(bad_member).unwrap(),
1268 )
1269 .unwrap()
1270 .prepare()
1271 .unwrap()
1272 .verify_integrity(),
1273 Err(Error::VerificationFailed(_))
1274 ));
1275
1276 let no_requirement = ArtifactMember::new(
1277 MemberId::new("none").unwrap(),
1278 inputs.write_file("none", b"no declaration").unwrap(),
1279 "bin/none",
1280 )
1281 .unwrap();
1282 let verified = InstallPlan::new(
1283 ProductId::new("eggup").unwrap(),
1284 ReleaseId::new("r1").unwrap(),
1285 install.path(),
1286 ArtifactSet::single(no_requirement).unwrap(),
1287 )
1288 .unwrap()
1289 .prepare()
1290 .unwrap()
1291 .verify_integrity()
1292 .unwrap();
1293 let validator = ExactIdentityValidator::new(MemberId::new("none").unwrap(), "anything");
1294 assert!(matches!(
1295 verified.validate(&validator),
1296 Err(Error::VerificationFailed(_))
1297 ));
1298 }
1299
1300 #[cfg(unix)]
1301 #[test]
1302 fn bounded_candidates_are_exact_and_environment_is_cleared() {
1303 use std::os::unix::fs::PermissionsExt;
1304
1305 let inputs = InstallationRoot::new().unwrap();
1306 let install = InstallationRoot::new().unwrap();
1307 let script = inputs
1308 .write_file("main", b"#!/bin/sh\nprintf 'eggup 1.2.3\\n'\n")
1309 .unwrap();
1310 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1311 let digest = super::hash_file(&script).unwrap();
1312 let member = ArtifactMember::new(MemberId::new("main").unwrap(), script, "bin/main")
1313 .unwrap()
1314 .with_integrity(super::IntegrityRequirement::Sha256(digest));
1315 fs::create_dir_all(install.path().join("bin")).unwrap();
1316 let verified = InstallPlan::new(
1317 ProductId::new("eggup").unwrap(),
1318 ReleaseId::new("r1").unwrap(),
1319 install.path(),
1320 ArtifactSet::single(member).unwrap(),
1321 )
1322 .unwrap()
1323 .prepare()
1324 .unwrap()
1325 .verify_integrity()
1326 .unwrap();
1327 let validator =
1328 ExactIdentityValidator::new(MemberId::new("main").unwrap(), "eggup 1.2.3\n");
1329 let validated = verified.validate(&validator).unwrap();
1330 let verifier = allow_create();
1331 assert_eq!(
1332 validated
1333 .commit(CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate))
1334 .unwrap()
1335 .disposition(),
1336 TransactionDisposition::Committed
1337 );
1338
1339 let secret_script = inputs
1340 .write_file(
1341 "secret",
1342 b"#!/bin/sh\nprintf '%s' \"${EGGUP_SECRET-unset}\"\n",
1343 )
1344 .unwrap();
1345 fs::set_permissions(&secret_script, fs::Permissions::from_mode(0o755)).unwrap();
1346 let output = super::run_bounded(&super::CommandSpec::new(secret_script)).unwrap();
1347 assert!(output.success(), "bounded candidate output: {output:?}");
1348 assert_eq!(output.stdout(), b"unset");
1349 }
1350
1351 #[cfg(unix)]
1352 #[test]
1353 fn bounded_runner_kills_timeouts_and_limits_output() {
1354 use std::os::unix::fs::PermissionsExt;
1355
1356 let fixture = InstallationRoot::new().unwrap();
1357 let timeout_script = fixture
1358 .write_file("timeout", b"#!/bin/sh\nsleep 2\n")
1359 .unwrap();
1360 let noisy_script = fixture
1361 .write_file("noisy", b"#!/bin/sh\nprintf '1234567890'\n")
1362 .unwrap();
1363 for path in [&timeout_script, &noisy_script] {
1364 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
1365 }
1366 let timed_out = super::run_bounded(
1367 &super::CommandSpec::new(timeout_script).timeout(Duration::from_millis(20)),
1368 )
1369 .unwrap();
1370 assert!(timed_out.timed_out());
1371 assert!(!timed_out.success());
1372 let limited =
1373 super::run_bounded(&super::CommandSpec::new(noisy_script).max_output_bytes(4)).unwrap();
1374 assert!(limited.output_limited());
1375 assert!(!limited.success());
1376 }
1377
1378 #[cfg(unix)]
1379 #[test]
1380 fn cross_member_identity_requires_bundle_agreement() {
1381 use std::os::unix::fs::PermissionsExt;
1382
1383 let inputs = InstallationRoot::new().unwrap();
1384 let install = InstallationRoot::new().unwrap();
1385 let main = inputs
1386 .write_file("main", b"#!/bin/sh\nprintf 'bundle 9\\n'\n")
1387 .unwrap();
1388 let helper = inputs
1389 .write_file("helper", b"#!/bin/sh\nprintf 'bundle 9\\n'\n")
1390 .unwrap();
1391 for path in [&main, &helper] {
1392 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
1393 }
1394 let main_digest = super::hash_file(&main).unwrap();
1395 let helper_digest = super::hash_file(&helper).unwrap();
1396 let verified = InstallPlan::new(
1397 ProductId::new("bundle").unwrap(),
1398 ReleaseId::new("r9").unwrap(),
1399 install.path(),
1400 ArtifactSet::new(vec![
1401 ArtifactMember::new(MemberId::new("main").unwrap(), main, "bin/main")
1402 .unwrap()
1403 .with_integrity(super::IntegrityRequirement::Sha256(main_digest)),
1404 ArtifactMember::new(MemberId::new("helper").unwrap(), helper, "bin/helper")
1405 .unwrap()
1406 .with_integrity(super::IntegrityRequirement::Sha256(helper_digest)),
1407 ])
1408 .unwrap(),
1409 )
1410 .unwrap()
1411 .prepare()
1412 .unwrap()
1413 .verify_integrity()
1414 .unwrap();
1415 let validator = super::CrossMemberAgreementValidator::new(
1416 [
1417 MemberId::new("main").unwrap(),
1418 MemberId::new("helper").unwrap(),
1419 ],
1420 "bundle 9\n",
1421 );
1422 assert!(verified.validate(&validator).is_ok());
1423 }
1424
1425 fn single_verified(
1428 new_bytes: &[u8],
1429 old_bytes: Option<&[u8]>,
1430 with_bin_dir: bool,
1431 ) -> (
1432 InstallationRoot,
1433 InstallationRoot,
1434 super::PreparedTransaction,
1435 [u8; 32],
1436 ) {
1437 let inputs = InstallationRoot::new().unwrap();
1438 let install = InstallationRoot::new().unwrap();
1439 let src = inputs.write_file("main", new_bytes).unwrap();
1440 let digest = super::hash_file(&src).unwrap();
1441 if let Some(old) = old_bytes {
1442 install.write_file("bin/main", old).unwrap();
1443 } else if with_bin_dir {
1444 fs::create_dir_all(install.path().join("bin")).unwrap();
1445 }
1446 let member = ArtifactMember::new(MemberId::new("main").unwrap(), src, "bin/main")
1447 .unwrap()
1448 .with_integrity(super::IntegrityRequirement::Sha256(digest));
1449 let prepared = InstallPlan::new(
1450 ProductId::new("p").unwrap(),
1451 ReleaseId::new("r1").unwrap(),
1452 install.path(),
1453 ArtifactSet::single(member).unwrap(),
1454 )
1455 .unwrap()
1456 .prepare()
1457 .unwrap();
1458 (inputs, install, prepared, digest)
1459 }
1460
1461 #[test]
1462 fn ownership_owned_allows_replacement_and_reports_no_failure() {
1463 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1464 let old_digest = super::hash_file(&install.path().join("bin/main")).unwrap();
1465 let verifier = ExactDigestVerifier::new(vec![(MemberId::new("main").unwrap(), old_digest)]);
1466 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
1467 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1468 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"new");
1469 }
1470
1471 #[test]
1472 fn ownership_foreign_and_unknown_fail_closed_with_zero_mutation() {
1473 for fixed in [Ownership::Foreign, Ownership::Unknown] {
1474 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1475 let verifier = FixedVerifier(fixed);
1476 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1477 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1478 assert_eq!(
1479 receipt.failure().unwrap().phase(),
1480 super::FailurePhase::Ownership
1481 );
1482 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
1483 }
1484 }
1485
1486 #[test]
1487 fn ownership_absent_create_policy_is_enforced() {
1488 let (_i, install, prepared, _) = single_verified(b"new", None, true);
1489 let verifier = AbsentOnlyVerifier;
1490 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1491 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1492 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"new");
1493
1494 let (_i, install, prepared, _) = single_verified(b"new", None, true);
1495 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
1496 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1497 assert!(!install.path().join("bin/main").exists());
1498 }
1499
1500 #[test]
1501 fn ownership_flapping_between_preflight_and_lock_fails() {
1502 use std::cell::Cell;
1503 #[derive(Debug)]
1504 struct Flap {
1505 first: Ownership,
1506 second: Ownership,
1507 calls: Cell<usize>,
1508 }
1509 impl OwnershipVerifier for Flap {
1510 fn verify(&self, _m: &MemberId, _d: &Path) -> Ownership {
1511 let n = self.calls.get();
1512 self.calls.set(n + 1);
1513 if n == 0 {
1514 self.first
1515 } else {
1516 self.second
1517 }
1518 }
1519 }
1520 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1521 let verifier = Flap {
1522 first: Ownership::Owned,
1523 second: Ownership::Foreign,
1524 calls: Cell::new(0),
1525 };
1526 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1527 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1528 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
1529 }
1530
1531 #[test]
1532 fn missing_parent_fails_without_creating_directories() {
1533 let (_i, install, prepared, _) = single_verified(b"new", None, false);
1534 assert!(!install.path().join("bin").exists());
1535 let verifier = allow_create();
1536 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1537 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1538 assert!(!install.path().join("bin").exists());
1539 assert!(!install.path().join("bin/main").exists());
1540 }
1541
1542 #[cfg(unix)]
1543 #[test]
1544 fn symlinked_parent_fails_closed() {
1545 use std::os::unix::fs::symlink;
1546 let (_i, install, prepared, _) = single_verified(b"new", None, false);
1547 fs::create_dir_all(install.path().join("real-bin")).unwrap();
1548 symlink(install.path().join("real-bin"), install.path().join("bin")).unwrap();
1549 let verifier = allow_create();
1551 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1552 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1553 }
1554
1555 #[test]
1556 fn staged_mutation_after_validation_fails_before_live_mutation() {
1557 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1558 let verified = prepared.verify_integrity().unwrap();
1559 let validated = verified.validate(&AllValidators::new()).unwrap();
1560 let staged = validated
1562 .staged_path(&MemberId::new("main").unwrap())
1563 .unwrap();
1564 fs::write(&staged, b"tampered").unwrap();
1565 let verifier = allow_create();
1566 let receipt = validated
1567 .commit(CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate))
1568 .unwrap();
1569 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1570 assert_eq!(
1571 receipt.failure().unwrap().phase(),
1572 super::FailurePhase::StageRevalidation
1573 );
1574 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
1575 }
1576
1577 #[test]
1578 fn unverified_integrity_none_cannot_commit() {
1579 let inputs = InstallationRoot::new().unwrap();
1580 let install = InstallationRoot::new().unwrap();
1581 fs::create_dir_all(install.path().join("bin")).unwrap();
1582 let src = inputs.write_file("main", b"bytes").unwrap();
1583 let member = ArtifactMember::new(MemberId::new("main").unwrap(), src, "bin/main").unwrap();
1584 let prepared = InstallPlan::new(
1585 ProductId::new("p").unwrap(),
1586 ReleaseId::new("r1").unwrap(),
1587 install.path(),
1588 ArtifactSet::single(member).unwrap(),
1589 )
1590 .unwrap()
1591 .prepare()
1592 .unwrap();
1593 let verified = prepared.verify_integrity().unwrap();
1594 assert!(verified.validate(&AllValidators::new()).is_err());
1595 }
1596
1597 #[test]
1598 fn cleanup_failure_reports_real_backup_root() {
1599 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1600 let verifier = allow_create();
1601 let receipt = commit_verified_with_fault(
1603 prepared,
1604 &verifier,
1605 AbsentPolicy::AllowCreate,
1606 CommitFault::Finalize,
1607 )
1608 .unwrap();
1609 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1610 assert_eq!(receipt.cleanup(), CleanupDisposition::RetainedForRecovery);
1611 let path = receipt.recovery_path().unwrap();
1612 assert!(path.exists());
1613 assert!(!path.to_string_lossy().contains("cleanup-failed"));
1614 assert_eq!(
1615 receipt.failure().unwrap().phase(),
1616 super::FailurePhase::Finalize
1617 );
1618 let _ = fs::remove_dir_all(path);
1619 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
1620 }
1621
1622 #[cfg(unix)]
1623 #[test]
1624 fn transaction_owned_state_is_owner_private() {
1625 use std::os::unix::fs::PermissionsExt;
1626 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1627 let stage_mode = fs::metadata(prepared.stage_root())
1628 .unwrap()
1629 .permissions()
1630 .mode()
1631 & 0o777;
1632 assert_eq!(stage_mode, 0o700);
1633 let staged = prepared
1634 .staged_path(&MemberId::new("main").unwrap())
1635 .unwrap();
1636 let file_mode = fs::metadata(&staged).unwrap().permissions().mode() & 0o777;
1637 assert_eq!(file_mode, 0o600);
1638 let verifier = allow_create();
1639 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1640 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1641 let (_i2, install2, prepared2, _) = single_verified(b"n2", Some(b"o2"), false);
1643 let receipt = commit_verified_with_fault(
1644 prepared2,
1645 &verifier,
1646 AbsentPolicy::AllowCreate,
1647 CommitFault::Finalize,
1648 )
1649 .unwrap();
1650 let lock_mode = fs::metadata(install2.path().join(".eggup-mutation.lock"))
1651 .unwrap()
1652 .permissions()
1653 .mode()
1654 & 0o777;
1655 assert_eq!(lock_mode, 0o600);
1656 let _ = fs::remove_dir_all(receipt.recovery_path().unwrap());
1657 let _ = fs::remove_file(install2.path().join(".eggup-mutation.lock"));
1658 let _ = install;
1659 }
1660
1661 #[test]
1662 fn lock_inspect_never_deletes_and_reports_status() {
1663 let install = InstallationRoot::new().unwrap();
1664 assert_eq!(
1665 MutationLock::inspect(install.path()).unwrap(),
1666 super::LockStatus::Available
1667 );
1668 let product = ProductId::new("p").unwrap();
1669 let release = ReleaseId::new("r").unwrap();
1670 let lock = MutationLock::acquire(install.path(), &product, &release).unwrap();
1671 match MutationLock::inspect(install.path()).unwrap() {
1672 super::LockStatus::Held { contents, .. } => assert!(contents.is_some()),
1673 other => panic!("expected held, got {other:?}"),
1674 }
1675 drop(lock);
1676 assert_eq!(
1678 MutationLock::inspect(install.path()).unwrap(),
1679 super::LockStatus::Available
1680 );
1681 fs::write(install.path().join(".eggup-mutation.lock"), b"malformed").unwrap();
1682 match MutationLock::inspect(install.path()).unwrap() {
1683 super::LockStatus::Held { .. } | super::LockStatus::Malformed { .. } => {}
1684 other => panic!("expected held/malformed, got {other:?}"),
1685 }
1686 assert!(install.path().join(".eggup-mutation.lock").exists());
1688 }
1689
1690 #[test]
1691 fn oversized_lock_record_is_malformed_not_deleted() {
1692 let install = InstallationRoot::new().unwrap();
1693 let big = vec![b'x'; 8192];
1694 fs::write(install.path().join(".eggup-mutation.lock"), &big).unwrap();
1695 match MutationLock::inspect(install.path()).unwrap() {
1696 super::LockStatus::Malformed { .. } => {}
1697 other => panic!("expected malformed, got {other:?}"),
1698 }
1699 assert!(install.path().join(".eggup-mutation.lock").exists());
1700 }
1701
1702 #[test]
1703 fn exact_digest_verifier_proves_ownership() {
1704 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1705 let live_digest = super::hash_file(&install.path().join("bin/main")).unwrap();
1706 let verifier =
1707 ExactDigestVerifier::new(vec![(MemberId::new("main").unwrap(), live_digest)]);
1708 assert_eq!(
1709 verifier.verify(
1710 &MemberId::new("main").unwrap(),
1711 &install.path().join("bin/main")
1712 ),
1713 Ownership::Owned
1714 );
1715 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
1716 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1717 }
1718
1719 #[test]
1720 fn absent_only_verifier_never_authorizes_replacement() {
1721 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1722 let verifier = AbsentOnlyVerifier;
1723 assert_eq!(
1724 verifier.verify(
1725 &MemberId::new("main").unwrap(),
1726 &install.path().join("bin/main")
1727 ),
1728 Ownership::Foreign
1729 );
1730 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1731 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1732 }
1733
1734 #[test]
1735 fn failure_reports_carry_phase_category_and_member() {
1736 let (_i, _install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1737 let verifier = FixedVerifier(Ownership::Foreign);
1738 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1739 let failure = receipt.failure().unwrap();
1740 assert_eq!(failure.phase(), super::FailurePhase::Ownership);
1741 assert!(!failure.detail().is_empty());
1742 assert!(failure.detail().len() <= 512);
1743 }
1744
1745 #[test]
1746 fn checksum_only_state_is_explicit_in_docs() {
1747 let desc = env!("CARGO_PKG_DESCRIPTION");
1753 let _ = desc;
1754 }
1755
1756 #[allow(dead_code)]
1757 fn _unused() {
1758 let _m: HashMap<MemberId, [u8; 32]> = HashMap::new();
1759 }
1760
1761 #[test]
1770 fn competing_writer_after_the_claim_is_preserved() {
1771 use crate::lock::POST_CLAIM_HOOK;
1772
1773 let root = InstallationRoot::new().expect("root");
1774 let lock_path = root.path().join(".eggup-mutation.lock");
1775 fs::write(&lock_path, "pid=1 nonce=1 product=a release=b\n").expect("plant");
1776 let competing = "pid=2 nonce=2 product=a release=b\n".to_string();
1777
1778 struct Proven;
1779 impl std::fmt::Debug for Proven {
1780 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1781 f.write_str("Proven")
1782 }
1783 }
1784 impl crate::lock::StaleLockVerifier for Proven {
1785 fn classify(
1786 &self,
1787 _observed: &crate::lock::LockObservation,
1788 ) -> crate::lock::StaleLockDecision {
1789 crate::lock::StaleLockDecision::ProvenStale
1790 }
1791 }
1792
1793 let hook_ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1794 let hook_ran_in_hook = hook_ran.clone();
1795 *POST_CLAIM_HOOK.lock().expect("hook slot") = Some(Box::new(move || {
1796 fs::write(&lock_path, &competing).expect("competing writer");
1797 hook_ran_in_hook.store(true, std::sync::atomic::Ordering::SeqCst);
1798 }));
1799
1800 let error = MutationLock::acquire_with_recovery(
1801 root.path(),
1802 &ProductId::new("a").unwrap(),
1803 &ReleaseId::new("b").unwrap(),
1804 &Proven,
1805 )
1806 .expect_err("the competing writer owns the domain");
1807
1808 assert!(
1809 matches!(
1810 error,
1811 crate::error::RecoveryError::Core(Error::UpdateInProgress { .. })
1812 ),
1813 "expected typed contention, got {error:?}"
1814 );
1815 assert!(
1816 hook_ran.load(std::sync::atomic::Ordering::SeqCst),
1817 "the injected competing writer must actually have run, otherwise this \
1818 test is asserting the pre-claim rejection path and proves nothing \
1819 about the post-claim branch"
1820 );
1821 assert_eq!(
1822 fs::read_to_string(root.path().join(".eggup-mutation.lock")).expect("read"),
1823 "pid=2 nonce=2 product=a release=b\n",
1824 "the winning writer's record must survive byte-identical"
1825 );
1826 let leftovers: Vec<_> = fs::read_dir(root.path())
1827 .expect("read dir")
1828 .flatten()
1829 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1830 .filter(|name| name.starts_with(".eggup-stale-claim-"))
1831 .collect();
1832 assert!(
1833 leftovers.is_empty(),
1834 "our own displaced record must be cleaned up: {leftovers:?}"
1835 );
1836 }
1837
1838 #[test]
1844 fn restore_never_clobbers_a_lock_taken_in_the_meantime() {
1845 let root = InstallationRoot::new().expect("root");
1846 let lock_path = root.path().join(".eggup-mutation.lock");
1847 let claim = root.path().join(".eggup-stale-claim-test");
1848 fs::write(&claim, "pid=1 nonce=1 product=a release=b\n").expect("write claim");
1849
1850 let winner = "pid=9 nonce=9 product=other release=z\n";
1852 fs::write(&lock_path, winner).expect("write winner");
1853
1854 let error =
1855 crate::lock::restore_claim(&claim, &lock_path, "pid=1 nonce=1 product=a release=b\n");
1856 assert!(
1857 matches!(error, crate::error::RecoveryError::RecoveryRequired { .. }),
1858 "a re-taken lock path must retain the claim, got {error:?}"
1859 );
1860 assert_eq!(
1861 fs::read_to_string(&lock_path).expect("read"),
1862 winner,
1863 "the competing writer's record must be untouched"
1864 );
1865 assert!(
1866 claim.exists(),
1867 "the displaced record is retained and reported, not deleted"
1868 );
1869 }
1870}