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