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 domain;
19mod error;
20mod integrity;
21mod lock;
22mod stage;
23mod transaction;
24
25pub use candidate::{
26 run_bounded, AllValidators, CandidateValidator, CommandOutput, CommandSpec,
27 CrossMemberAgreementValidator, ExactIdentityValidator, ValidatedTransaction,
28};
29pub use domain::{
30 AbsentOnlyVerifier, AbsentPolicy, ArtifactMember, ArtifactSet, BoundSources, CommitOwnership,
31 ExactDigestVerifier, ExistingAsOwnedVerifier, FileKind, InstallPlan, IntegrityRequirement,
32 MemberId, Ownership, OwnershipVerifier, PermissionsIntent, ProductId, ReleaseId,
33};
34pub use error::{Error, Result};
35pub use integrity::{
36 hash_file, parse_sha256_sidecar, verify_file, IntegrityResult, IntegrityStatus, Sha256Manifest,
37 VerifiedTransaction,
38};
39pub use lock::{LockStatus, MutationLock};
40pub use stage::PreparedTransaction;
41pub use transaction::{
42 CleanupDisposition, FailureCategory, FailurePhase, FailureReport, PostCommitFailurePolicy,
43 TransactionDisposition, TransactionReceipt,
44};
45
46#[cfg(test)]
47mod test_support;
48
49#[cfg(test)]
50mod tests {
51 use super::test_support::{FailureInjector, FailurePoint, InstallationRoot};
52 use super::transaction::CommitFault;
53 use super::{
54 AbsentOnlyVerifier, AbsentPolicy, AllValidators, ArtifactMember, ArtifactSet, BoundSources,
55 CleanupDisposition, CommitOwnership, Error, ExactDigestVerifier, ExactIdentityValidator,
56 ExistingAsOwnedVerifier, InstallPlan, MemberId, MutationLock, Ownership, OwnershipVerifier,
57 PostCommitFailurePolicy, ProductId, ReleaseId, TransactionDisposition,
58 };
59 use std::collections::HashMap;
60 use std::fs;
61 use std::path::Path;
62 #[cfg(unix)]
63 use std::time::Duration;
64
65 struct FixedVerifier(Ownership);
66 impl std::fmt::Debug for FixedVerifier {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_tuple("FixedVerifier").field(&self.0).finish()
69 }
70 }
71 impl OwnershipVerifier for FixedVerifier {
72 fn verify(&self, _m: &MemberId, _d: &Path) -> Ownership {
73 self.0
74 }
75 }
76
77 fn allow_create() -> ExistingAsOwnedVerifier {
78 ExistingAsOwnedVerifier
79 }
80
81 fn commit_verified(
82 prepared: super::PreparedTransaction,
83 verifier: &dyn OwnershipVerifier,
84 absent: AbsentPolicy,
85 ) -> super::Result<super::TransactionReceipt> {
86 let verified = prepared.verify_integrity()?;
87 let validated = verified.validate(&AllValidators::new())?;
88 validated.commit(CommitOwnership::new(verifier, absent))
89 }
90
91 fn commit_verified_with_fault(
92 prepared: super::PreparedTransaction,
93 verifier: &dyn OwnershipVerifier,
94 absent: AbsentPolicy,
95 fault: CommitFault,
96 ) -> super::Result<super::TransactionReceipt> {
97 let verified = prepared.verify_integrity()?;
98 let validated = verified.validate(&AllValidators::new())?;
99 validated.commit_with_fault(CommitOwnership::new(verifier, absent), fault)
100 }
101
102 #[test]
103 fn fixture_writes_and_reads_exact_bytes() {
104 let fixture = InstallationRoot::new().expect("fixture");
105 fixture
106 .write_file("bin/eggup", b"exact bytes")
107 .expect("write");
108
109 assert_eq!(
110 fixture.read_file("bin/eggup").expect("read"),
111 b"exact bytes"
112 );
113 assert!(fixture.path().join("bin/eggup").is_file());
114 }
115
116 #[test]
117 fn fixture_rejects_escape_paths() {
118 let fixture = InstallationRoot::new().expect("fixture");
119
120 let error = fixture.write_file("../outside", b"nope").unwrap_err();
121
122 assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
123 assert!(!fixture.path().parent().unwrap().join("outside").exists());
124 }
125
126 #[test]
127 fn failure_injector_defaults_to_no_failure() {
128 let mut injector = FailureInjector::default();
129
130 assert!(injector.check(FailurePoint::Prepare).is_ok());
131 assert!(injector.check(FailurePoint::Commit).is_ok());
132 }
133
134 #[test]
135 fn prepares_a_multi_member_bundle_without_touching_destinations() {
136 let inputs = InstallationRoot::new().expect("inputs");
137 let install = InstallationRoot::new().expect("install");
138 inputs.write_file("egress", b"egress").expect("write");
139 inputs.write_file("helper", b"helper").expect("write");
140 let product = ProductId::new("egress").expect("product");
141 let release = ReleaseId::new("2026.09.22").expect("release");
142 let members = ArtifactSet::new(vec![
143 ArtifactMember::new(
144 MemberId::new("main").expect("member"),
145 inputs.path().join("egress"),
146 "bin/egress",
147 )
148 .expect("artifact"),
149 ArtifactMember::new(
150 MemberId::new("helper").expect("member"),
151 inputs.path().join("helper"),
152 "bin/helper",
153 )
154 .expect("artifact"),
155 ])
156 .expect("members");
157
158 let prepared = InstallPlan::new(product, release, install.path(), members)
159 .expect("plan")
160 .prepare()
161 .expect("prepare");
162
163 assert!(!install.path().join("bin/egress").exists());
164 assert_eq!(
165 fs::read(
166 prepared
167 .staged_path(&MemberId::new("main").unwrap())
168 .unwrap()
169 )
170 .unwrap(),
171 b"egress"
172 );
173 }
174
175 #[test]
176 fn preparation_failure_cleans_private_stage() {
177 let inputs = InstallationRoot::new().expect("inputs");
178 let install = InstallationRoot::new().expect("install");
179 inputs.write_file("eggup", b"bytes").expect("write");
180 let plan = InstallPlan::new(
181 ProductId::new("eggup").unwrap(),
182 ReleaseId::new("r1").unwrap(),
183 install.path(),
184 ArtifactSet::single(
185 ArtifactMember::new(
186 MemberId::new("main").unwrap(),
187 inputs.path().join("eggup"),
188 "eggup",
189 )
190 .unwrap(),
191 )
192 .unwrap(),
193 )
194 .unwrap();
195
196 assert!(plan
197 .clone()
198 .prepare_with_failure(FailurePoint::StageCreate)
199 .is_err());
200 let result = plan.prepare_with_failure(FailurePoint::StageCopy);
201
202 assert!(result.is_err());
203 assert!(!install.path().join("eggup").exists());
204 let stage_prefix = format!(
205 ".eggup-stage-{}-",
206 install.path().file_name().unwrap().to_string_lossy()
207 );
208 assert!(install
209 .path()
210 .parent()
211 .unwrap()
212 .read_dir()
213 .unwrap()
214 .filter_map(std::result::Result::ok)
215 .all(|entry| !entry
216 .file_name()
217 .to_string_lossy()
218 .starts_with(&stage_prefix)));
219 }
220
221 #[test]
222 fn rejects_duplicate_normalized_destinations() {
223 let inputs = InstallationRoot::new().expect("inputs");
224 let install = InstallationRoot::new().expect("install");
225 inputs.write_file("one", b"one").expect("write");
226 inputs.write_file("two", b"two").expect("write");
227 let members = ArtifactSet::new(vec![
228 ArtifactMember::new(
229 MemberId::new("one").unwrap(),
230 inputs.path().join("one"),
231 "bin/./app",
232 )
233 .unwrap(),
234 ArtifactMember::new(
235 MemberId::new("two").unwrap(),
236 inputs.path().join("two"),
237 "bin/app",
238 )
239 .unwrap(),
240 ])
241 .unwrap();
242
243 let result = InstallPlan::new(
244 ProductId::new("eggup").unwrap(),
245 ReleaseId::new("r1").unwrap(),
246 install.path(),
247 members,
248 );
249
250 assert!(result.is_err());
251 }
252
253 #[test]
254 fn rejects_empty_sets_and_escaping_destinations() {
255 assert!(ArtifactSet::new(Vec::new()).is_err());
256 assert!(ArtifactMember::new(
257 MemberId::new("main").unwrap(),
258 "/tmp/acquired",
259 "../outside",
260 )
261 .is_err());
262 assert!(ArtifactMember::new(
263 MemberId::new("main").unwrap(),
264 "/tmp/acquired",
265 "/absolute/path",
266 )
267 .is_err());
268 }
269
270 #[test]
271 fn bound_source_stages_open_object_despite_foreign_replacement() {
272 use std::fs::File;
273
274 let inputs = InstallationRoot::new().expect("inputs");
275 let install = InstallationRoot::new().expect("install");
276 let source = inputs.write_file("owned", b"owned-bytes").expect("write");
277 let handle = File::open(&source).expect("open");
278 let member =
279 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
280 .unwrap();
281 let plan = InstallPlan::new(
282 ProductId::new("eggup").unwrap(),
283 ReleaseId::new("r1").unwrap(),
284 install.path(),
285 ArtifactSet::single(member).unwrap(),
286 )
287 .unwrap();
288 fs::remove_file(&source).expect("remove");
291 fs::write(&source, b"foreign-bytes").expect("foreign");
292 let mut bound = BoundSources::new();
293 bound.insert(MemberId::new("main").unwrap(), handle);
294 let prepared = plan.prepare_with_bound_sources(bound).unwrap();
295 assert_eq!(
296 fs::read(
297 prepared
298 .staged_path(&MemberId::new("main").unwrap())
299 .unwrap()
300 )
301 .unwrap(),
302 b"owned-bytes"
303 );
304 assert_eq!(fs::read(&source).unwrap(), b"foreign-bytes");
305 }
306
307 #[test]
308 fn bound_source_stages_open_object_after_root_rename() {
309 use std::fs::File;
310
311 let inputs = InstallationRoot::new().expect("inputs");
312 let install = InstallationRoot::new().expect("install");
313 let subdir = inputs.path().join("owned-dir");
314 fs::create_dir(&subdir).expect("mkdir");
315 let source = subdir.join("member");
316 fs::write(&source, b"owned-bytes").expect("write");
317 let handle = File::open(&source).expect("open");
318 let member =
319 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
320 .unwrap();
321 let plan = InstallPlan::new(
322 ProductId::new("eggup").unwrap(),
323 ReleaseId::new("r1").unwrap(),
324 install.path(),
325 ArtifactSet::single(member).unwrap(),
326 )
327 .unwrap();
328 fs::rename(&subdir, inputs.path().join("renamed-dir")).expect("rename");
331 let mut bound = BoundSources::new();
332 bound.insert(MemberId::new("main").unwrap(), handle);
333 let prepared = plan.prepare_with_bound_sources(bound).unwrap();
334 assert_eq!(
335 fs::read(
336 prepared
337 .staged_path(&MemberId::new("main").unwrap())
338 .unwrap()
339 )
340 .unwrap(),
341 b"owned-bytes"
342 );
343 }
344
345 #[test]
346 fn bound_source_stages_full_bytes_from_nonzero_cursor() {
347 use std::fs::File;
348 use std::io::{Read, Seek, SeekFrom};
349
350 let inputs = InstallationRoot::new().expect("inputs");
351 let install = InstallationRoot::new().expect("install");
352 let source = inputs.write_file("owned", b"owned-bytes").expect("write");
353 let mut handle = File::open(&source).expect("open");
354 let mut prefix = [0u8; 5];
355 handle.read_exact(&mut prefix).expect("read");
356 assert_eq!(&prefix, b"owned");
357 handle.seek(SeekFrom::Start(2)).expect("seek");
358 let member =
359 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
360 .unwrap();
361 let plan = InstallPlan::new(
362 ProductId::new("eggup").unwrap(),
363 ReleaseId::new("r1").unwrap(),
364 install.path(),
365 ArtifactSet::single(member).unwrap(),
366 )
367 .unwrap();
368 let mut bound = BoundSources::new();
369 bound.insert(MemberId::new("main").unwrap(), handle);
370 let prepared = plan.prepare_with_bound_sources(bound).unwrap();
371 assert_eq!(
372 fs::read(
373 prepared
374 .staged_path(&MemberId::new("main").unwrap())
375 .unwrap()
376 )
377 .unwrap(),
378 b"owned-bytes"
379 );
380 }
381
382 #[test]
383 fn bound_source_for_unknown_member_fails_closed() {
384 use std::fs::File;
385
386 let inputs = InstallationRoot::new().expect("inputs");
387 let install = InstallationRoot::new().expect("install");
388 let source = inputs.write_file("owned", b"owned-bytes").expect("write");
389 let handle = File::open(&source).expect("open");
390 let member =
391 ArtifactMember::new(MemberId::new("main").unwrap(), source.clone(), "bin/main")
392 .unwrap();
393 let plan = InstallPlan::new(
394 ProductId::new("eggup").unwrap(),
395 ReleaseId::new("r1").unwrap(),
396 install.path(),
397 ArtifactSet::single(member).unwrap(),
398 )
399 .unwrap();
400 let mut bound = BoundSources::new();
401 bound.insert(MemberId::new("ghost").unwrap(), handle);
402 assert!(plan.prepare_with_bound_sources(bound).is_err());
403 assert!(!install.path().join("bin/main").exists());
404 }
405
406 #[cfg(unix)]
407 #[test]
408 fn rejects_symlink_sources_and_preserves_executable_intent_in_stage() {
409 use std::os::unix::fs::symlink;
410
411 let inputs = InstallationRoot::new().expect("inputs");
412 let install = InstallationRoot::new().expect("install");
413 inputs.write_file("real", b"real").expect("write");
414 symlink(inputs.path().join("real"), inputs.path().join("link")).expect("symlink");
415 let symlink_member = ArtifactMember::new(
416 MemberId::new("link").unwrap(),
417 inputs.path().join("link"),
418 "bin/link",
419 )
420 .unwrap();
421 assert!(InstallPlan::new(
422 ProductId::new("eggup").unwrap(),
423 ReleaseId::new("r1").unwrap(),
424 install.path(),
425 ArtifactSet::single(symlink_member).unwrap(),
426 )
427 .is_err());
428
429 let member = ArtifactMember::new(
430 MemberId::new("real").unwrap(),
431 inputs.path().join("real"),
432 "bin/real",
433 )
434 .unwrap()
435 .with_permissions(super::PermissionsIntent::Executable);
436 let prepared = InstallPlan::new(
437 ProductId::new("eggup").unwrap(),
438 ReleaseId::new("r1").unwrap(),
439 install.path(),
440 ArtifactSet::single(member).unwrap(),
441 )
442 .unwrap()
443 .prepare()
444 .unwrap();
445 #[cfg(unix)]
446 {
447 use std::os::unix::fs::PermissionsExt;
448 let mode = fs::metadata(
449 prepared
450 .staged_path(&MemberId::new("real").unwrap())
451 .unwrap(),
452 )
453 .unwrap()
454 .permissions()
455 .mode();
456 assert_ne!(mode & 0o111, 0);
457 assert_eq!(mode & 0o777, 0o700);
458 }
459 }
460
461 fn prepared_bundle(
462 existing: bool,
463 ) -> (
464 InstallationRoot,
465 InstallationRoot,
466 super::PreparedTransaction,
467 ) {
468 let inputs = InstallationRoot::new().expect("inputs");
469 let install = InstallationRoot::new().expect("install");
470 let main_src = inputs.write_file("main", b"new-main").expect("write");
471 let helper_src = inputs.write_file("helper", b"new-helper").expect("write");
472 let main_digest = super::hash_file(&main_src).unwrap();
473 let helper_digest = super::hash_file(&helper_src).unwrap();
474 if existing {
475 install
476 .write_file("bin/main", b"old-main")
477 .expect("old main");
478 install
479 .write_file("bin/helper", b"old-helper")
480 .expect("old helper");
481 } else {
482 fs::create_dir_all(install.path().join("bin")).unwrap();
483 }
484 let members = ArtifactSet::new(vec![
485 ArtifactMember::new(
486 MemberId::new("main").unwrap(),
487 inputs.path().join("main"),
488 "bin/main",
489 )
490 .unwrap()
491 .with_integrity(super::IntegrityRequirement::Sha256(main_digest)),
492 ArtifactMember::new(
493 MemberId::new("helper").unwrap(),
494 inputs.path().join("helper"),
495 "bin/helper",
496 )
497 .unwrap()
498 .with_integrity(super::IntegrityRequirement::Sha256(helper_digest)),
499 ])
500 .unwrap();
501 let prepared = InstallPlan::new(
502 ProductId::new("bundle").unwrap(),
503 ReleaseId::new("r1").unwrap(),
504 install.path(),
505 members,
506 )
507 .unwrap()
508 .prepare()
509 .unwrap();
510 (inputs, install, prepared)
511 }
512
513 #[test]
514 fn commits_a_complete_multi_member_generation_and_releases_lock() {
515 let (_inputs, install, prepared) = prepared_bundle(true);
516 let verifier = allow_create();
517
518 let receipt =
519 commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).expect("commit");
520
521 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
522 assert_eq!(receipt.cleanup(), CleanupDisposition::Cleaned);
523 assert!(receipt.failure().is_none());
524 assert_eq!(
525 fs::read(install.path().join("bin/main")).unwrap(),
526 b"new-main"
527 );
528 assert_eq!(
529 fs::read(install.path().join("bin/helper")).unwrap(),
530 b"new-helper"
531 );
532 assert!(!install.path().join(".eggup-mutation.lock").exists());
533 assert!(install
534 .path()
535 .read_dir()
536 .unwrap()
537 .filter_map(std::result::Result::ok)
538 .all(|entry| !entry
539 .file_name()
540 .to_string_lossy()
541 .starts_with(".eggup-backup-")));
542 }
543
544 #[test]
545 fn post_commit_check_sees_complete_generation_and_holds_mutation_lock() {
546 let (_inputs, install, prepared) = prepared_bundle(true);
547 let verifier = allow_create();
548 let validated = prepared
549 .verify_integrity()
550 .unwrap()
551 .validate(&AllValidators::new())
552 .unwrap();
553 let receipt = validated
554 .commit_with_post_commit(
555 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
556 PostCommitFailurePolicy::RollBack,
557 || {
558 assert_eq!(
559 fs::read(install.path().join("bin/main")).unwrap(),
560 b"new-main"
561 );
562 assert_eq!(
563 fs::read(install.path().join("bin/helper")).unwrap(),
564 b"new-helper"
565 );
566 assert!(MutationLock::acquire(
567 install.path(),
568 &ProductId::new("bundle").unwrap(),
569 &ReleaseId::new("r1").unwrap(),
570 )
571 .is_err());
572 Ok::<_, &str>(())
573 },
574 )
575 .unwrap();
576 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
577 assert!(receipt.post_commit_failure().is_none());
578 }
579
580 #[test]
581 fn post_commit_keep_installed_records_bounded_failure() {
582 let (_inputs, install, prepared) = prepared_bundle(true);
583 let verifier = allow_create();
584 let validated = prepared
585 .verify_integrity()
586 .unwrap()
587 .validate(&AllValidators::new())
588 .unwrap();
589 let receipt = validated
590 .commit_with_post_commit(
591 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
592 PostCommitFailurePolicy::KeepInstalled,
593 || Err("é".repeat(400)),
594 )
595 .unwrap();
596 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
597 let failure = receipt.post_commit_failure().unwrap();
598 assert_eq!(failure.phase(), super::FailurePhase::PostCommit);
599 assert!(failure.detail().len() <= 512);
600 assert_eq!(
601 fs::read(install.path().join("bin/main")).unwrap(),
602 b"new-main"
603 );
604 assert_eq!(
605 fs::read(install.path().join("bin/helper")).unwrap(),
606 b"new-helper"
607 );
608 }
609
610 #[test]
611 fn post_commit_rollback_restores_old_generation_and_absent_members() {
612 let inputs = InstallationRoot::new().unwrap();
613 let install = InstallationRoot::new().unwrap();
614 let main = inputs.write_file("main", b"new-main").unwrap();
615 let helper = inputs.write_file("helper", b"new-helper").unwrap();
616 install.write_file("bin/main", b"old-main").unwrap();
617 let artifacts = ArtifactSet::new(vec![
618 ArtifactMember::new(MemberId::new("main").unwrap(), main, "bin/main")
619 .unwrap()
620 .with_integrity(super::IntegrityRequirement::Sha256(
621 super::hash_file(&inputs.path().join("main")).unwrap(),
622 )),
623 ArtifactMember::new(MemberId::new("helper").unwrap(), helper, "bin/helper")
624 .unwrap()
625 .with_integrity(super::IntegrityRequirement::Sha256(
626 super::hash_file(&inputs.path().join("helper")).unwrap(),
627 )),
628 ])
629 .unwrap();
630 let prepared = InstallPlan::new(
631 ProductId::new("bundle").unwrap(),
632 ReleaseId::new("r1").unwrap(),
633 install.path(),
634 artifacts,
635 )
636 .unwrap()
637 .prepare()
638 .unwrap();
639 let verifier = allow_create();
640 let receipt = prepared
641 .verify_integrity()
642 .unwrap()
643 .validate(&AllValidators::new())
644 .unwrap()
645 .commit_with_post_commit(
646 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
647 PostCommitFailurePolicy::RollBack,
648 || Err("health check failed"),
649 )
650 .unwrap();
651 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
652 assert_eq!(
653 receipt.failure().unwrap().phase(),
654 super::FailurePhase::PostCommit
655 );
656 assert_eq!(
657 fs::read(install.path().join("bin/main")).unwrap(),
658 b"old-main"
659 );
660 assert!(!install.path().join("bin/helper").exists());
661 }
662
663 #[test]
664 fn post_commit_rollback_failure_retains_both_reports_and_evidence() {
665 let (_inputs, install, prepared) = prepared_bundle(true);
666 let verifier = allow_create();
667 let receipt = prepared
668 .verify_integrity()
669 .unwrap()
670 .validate(&AllValidators::new())
671 .unwrap()
672 .commit_with_post_commit_fault(
673 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
674 PostCommitFailurePolicy::RollBack,
675 CommitFault::PostCommitRollback,
676 )
677 .unwrap();
678 assert_eq!(
679 receipt.disposition(),
680 TransactionDisposition::RecoveryRequired
681 );
682 assert_eq!(
683 receipt.post_commit_failure().unwrap().phase(),
684 super::FailurePhase::PostCommit
685 );
686 assert_eq!(
687 receipt.rollback_failure().unwrap().phase(),
688 super::FailurePhase::Rollback
689 );
690 assert!(receipt.recovery_path().unwrap().exists());
691 let recovery = receipt.recovery_path().unwrap().to_path_buf();
692 let _ = fs::remove_dir_all(recovery);
693 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
694 }
695
696 #[test]
697 fn keep_installed_finalize_failure_preserves_both_failure_facts() {
698 let (_inputs, install, prepared) = prepared_bundle(true);
699 let verifier = allow_create();
700 let receipt = prepared
701 .verify_integrity()
702 .unwrap()
703 .validate(&AllValidators::new())
704 .unwrap()
705 .commit_with_post_commit_fault(
706 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
707 PostCommitFailurePolicy::KeepInstalled,
708 CommitFault::Finalize,
709 )
710 .unwrap();
711 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
712 assert_eq!(
713 receipt.failure().unwrap().phase(),
714 super::FailurePhase::Finalize
715 );
716 assert_eq!(
717 receipt.post_commit_failure().unwrap().phase(),
718 super::FailurePhase::PostCommit
719 );
720 let recovery = receipt.recovery_path().unwrap().to_path_buf();
721 assert!(recovery.exists());
722 assert_eq!(
723 fs::read(install.path().join("bin/main")).unwrap(),
724 b"new-main"
725 );
726 let _ = fs::remove_dir_all(recovery);
727 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
728 }
729
730 #[test]
731 fn post_commit_panic_is_reported_and_rolls_back() {
732 let (_inputs, install, prepared) = prepared_bundle(true);
733 let verifier = allow_create();
734 let receipt = prepared
735 .verify_integrity()
736 .unwrap()
737 .validate(&AllValidators::new())
738 .unwrap()
739 .commit_with_post_commit(
740 CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate),
741 PostCommitFailurePolicy::RollBack,
742 || -> Result<(), &str> { panic!("simulated check panic") },
743 )
744 .unwrap();
745 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
746 assert_eq!(
747 receipt.post_commit_failure().unwrap().detail(),
748 "post-commit check panicked"
749 );
750 assert_eq!(
751 fs::read(install.path().join("bin/main")).unwrap(),
752 b"old-main"
753 );
754 }
755
756 #[test]
757 fn partial_commit_failure_restores_every_old_member() {
758 let (_inputs, install, prepared) = prepared_bundle(true);
759 let verifier = allow_create();
760
761 let receipt = commit_verified_with_fault(
762 prepared,
763 &verifier,
764 AbsentPolicy::AllowCreate,
765 CommitFault::Commit(MemberId::new("helper").unwrap()),
766 )
767 .expect("receipt");
768
769 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
770 assert!(receipt.rollback_performed());
771 assert!(receipt.rollback_verified());
772 let failure = receipt.failure().expect("failure report");
773 assert_eq!(failure.phase(), super::FailurePhase::Commit);
774 assert_eq!(failure.member(), Some(&MemberId::new("helper").unwrap()));
775 assert_eq!(
776 fs::read(install.path().join("bin/main")).unwrap(),
777 b"old-main"
778 );
779 assert_eq!(
780 fs::read(install.path().join("bin/helper")).unwrap(),
781 b"old-helper"
782 );
783 }
784
785 #[test]
786 fn rollback_removes_new_members_that_were_absent_before_commit() {
787 let inputs = InstallationRoot::new().unwrap();
788 let install = InstallationRoot::new().unwrap();
789 let main_src = inputs.write_file("main", b"new-main").unwrap();
790 let helper_src = inputs.write_file("helper", b"new-helper").unwrap();
791 let main_digest = super::hash_file(&main_src).unwrap();
792 let helper_digest = super::hash_file(&helper_src).unwrap();
793 install.write_file("bin/main", b"old-main").unwrap();
794 let prepared = InstallPlan::new(
795 ProductId::new("bundle").unwrap(),
796 ReleaseId::new("r1").unwrap(),
797 install.path(),
798 ArtifactSet::new(vec![
799 ArtifactMember::new(
800 MemberId::new("main").unwrap(),
801 inputs.path().join("main"),
802 "bin/main",
803 )
804 .unwrap()
805 .with_integrity(super::IntegrityRequirement::Sha256(main_digest)),
806 ArtifactMember::new(
807 MemberId::new("helper").unwrap(),
808 inputs.path().join("helper"),
809 "bin/helper",
810 )
811 .unwrap()
812 .with_integrity(super::IntegrityRequirement::Sha256(helper_digest)),
813 ])
814 .unwrap(),
815 )
816 .unwrap()
817 .prepare()
818 .unwrap();
819 let verifier = allow_create();
820
821 let receipt = commit_verified_with_fault(
822 prepared,
823 &verifier,
824 AbsentPolicy::AllowCreate,
825 CommitFault::Commit(MemberId::new("helper").unwrap()),
826 )
827 .unwrap();
828
829 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
830 assert_eq!(
831 fs::read(install.path().join("bin/main")).unwrap(),
832 b"old-main"
833 );
834 assert!(!install.path().join("bin/helper").exists());
835 }
836
837 #[test]
838 fn lock_contention_and_malformed_lock_fail_closed() {
839 let (_inputs, install, prepared) = prepared_bundle(false);
840 let product = ProductId::new("bundle").unwrap();
841 let release = ReleaseId::new("r1").unwrap();
842 let _lock = MutationLock::acquire(install.path(), &product, &release).unwrap();
843 let verifier = allow_create();
844
845 let error = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap_err();
846 assert!(matches!(error, Error::UpdateInProgress { .. }));
847 drop(_lock);
848
849 let (_inputs, install2, prepared) = prepared_bundle(false);
850 let lock = install2.path().join(".eggup-mutation.lock");
851 fs::write(&lock, b"malformed").unwrap();
852 let error = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap_err();
853 assert!(matches!(error, Error::UpdateInProgress { .. }));
854 assert!(lock.exists());
855 }
856
857 #[test]
858 fn rollback_failure_returns_recovery_required_and_retains_evidence() {
859 let (_inputs, install, prepared) = prepared_bundle(true);
860 let verifier = allow_create();
861
862 let receipt = commit_verified_with_fault(
863 prepared,
864 &verifier,
865 AbsentPolicy::AllowCreate,
866 CommitFault::CommitThenRollback(
867 MemberId::new("helper").unwrap(),
868 MemberId::new("main").unwrap(),
869 ),
870 )
871 .unwrap();
872
873 assert_eq!(
874 receipt.disposition(),
875 TransactionDisposition::RecoveryRequired
876 );
877 assert!(!receipt.rollback_verified());
878 assert!(receipt.recovery_path().is_some());
879 assert!(receipt.failure().is_some());
880 assert!(receipt.rollback_failure().is_some());
881 assert_eq!(
882 receipt.failure().unwrap().phase(),
883 super::FailurePhase::Commit
884 );
885 assert_eq!(
886 receipt.rollback_failure().unwrap().phase(),
887 super::FailurePhase::Rollback
888 );
889 assert!(install.path().join(".eggup-mutation.lock").exists());
890 let recovery = receipt.recovery_path().unwrap();
891 assert!(recovery.exists());
892 let _ = fs::remove_dir_all(recovery);
893 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
894 }
895
896 #[test]
897 fn precommit_and_backup_failures_restore_old_state() {
898 let (_inputs, install, prepared) = prepared_bundle(true);
899 let verifier = allow_create();
900 let receipt = commit_verified_with_fault(
901 prepared,
902 &verifier,
903 AbsentPolicy::AllowCreate,
904 CommitFault::BeforeFirstCommit,
905 )
906 .unwrap();
907 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
908 assert!(receipt.failure().is_some());
909 assert_eq!(
910 fs::read(install.path().join("bin/main")).unwrap(),
911 b"old-main"
912 );
913
914 let (_inputs, install, prepared) = prepared_bundle(true);
915 let receipt = commit_verified_with_fault(
916 prepared,
917 &verifier,
918 AbsentPolicy::AllowCreate,
919 CommitFault::Backup(MemberId::new("helper").unwrap()),
920 )
921 .unwrap();
922 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
923 assert_eq!(
924 receipt.failure().unwrap().phase(),
925 super::FailurePhase::Backup
926 );
927 assert_eq!(
928 fs::read(install.path().join("bin/helper")).unwrap(),
929 b"old-helper"
930 );
931 }
932
933 #[test]
934 fn injected_lock_creation_failure_performs_no_mutation() {
935 let (_inputs, install, prepared) = prepared_bundle(true);
936 let verifier = allow_create();
937
938 let error = commit_verified_with_fault(
939 prepared,
940 &verifier,
941 AbsentPolicy::AllowCreate,
942 CommitFault::LockCreation,
943 )
944 .unwrap_err();
945
946 assert!(matches!(error, Error::InvalidInput(_)));
947 assert_eq!(
948 fs::read(install.path().join("bin/main")).unwrap(),
949 b"old-main"
950 );
951 assert!(!install.path().join(".eggup-mutation.lock").exists());
952 }
953
954 #[cfg(unix)]
955 #[test]
956 fn destination_links_fail_immediately_before_backup() {
957 use std::fs::hard_link;
958 use std::os::unix::fs::symlink;
959
960 let inputs = InstallationRoot::new().unwrap();
961 let install = InstallationRoot::new().unwrap();
962 let src = inputs.write_file("main", b"new-main").unwrap();
963 let digest = super::hash_file(&src).unwrap();
964 install.write_file("bin/real", b"old-main").unwrap();
965 symlink(install.path().join("real"), install.path().join("bin/main")).unwrap();
966 let prepared = InstallPlan::new(
967 ProductId::new("bundle").unwrap(),
968 ReleaseId::new("r1").unwrap(),
969 install.path(),
970 ArtifactSet::single(
971 ArtifactMember::new(
972 MemberId::new("main").unwrap(),
973 inputs.path().join("main"),
974 "bin/main",
975 )
976 .unwrap()
977 .with_integrity(super::IntegrityRequirement::Sha256(digest)),
978 )
979 .unwrap(),
980 )
981 .unwrap()
982 .prepare()
983 .unwrap();
984 let verifier = allow_create();
985 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
986 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
987 assert_eq!(
988 receipt.failure().unwrap().phase(),
989 super::FailurePhase::Ownership
990 );
991 assert_eq!(
992 fs::read(install.path().join("bin/real")).unwrap(),
993 b"old-main"
994 );
995
996 let (_inputs, install, prepared) = prepared_bundle(true);
997 hard_link(
998 install.path().join("bin/main"),
999 install.path().join("bin/linked"),
1000 )
1001 .unwrap();
1002 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1003 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1004 assert_eq!(
1005 fs::read(install.path().join("bin/main")).unwrap(),
1006 b"old-main"
1007 );
1008 }
1009
1010 #[test]
1011 fn hashes_known_bytes_and_parses_strict_sidecars() {
1012 let fixture = InstallationRoot::new().unwrap();
1013 let path = fixture.write_file("abc", b"abc").unwrap();
1014 let manifest = super::parse_sha256_sidecar(
1015 "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD abc\n",
1016 )
1017 .unwrap();
1018 assert_eq!(manifest.filename(), Some("abc"));
1019 assert_eq!(
1020 super::verify_file(&path, &manifest).unwrap(),
1021 *manifest.digest()
1022 );
1023 assert!(super::parse_sha256_sidecar("abc\ndef").is_err());
1024 assert!(super::parse_sha256_sidecar(
1025 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad other"
1026 )
1027 .and_then(|manifest| super::verify_file(&path, &manifest))
1028 .is_err());
1029 assert!(super::parse_sha256_sidecar("not-a-digest abc").is_err());
1030 }
1031
1032 #[test]
1033 fn integrity_verification_precedes_candidate_validation() {
1034 let inputs = InstallationRoot::new().unwrap();
1035 let install = InstallationRoot::new().unwrap();
1036 let source = inputs.write_file("main", b"verified bytes").unwrap();
1037 let digest = super::hash_file(&source).unwrap();
1038 let member = ArtifactMember::new(MemberId::new("main").unwrap(), source, "bin/main")
1039 .unwrap()
1040 .with_integrity(super::IntegrityRequirement::Sha256(digest));
1041 let verified = InstallPlan::new(
1042 ProductId::new("eggup").unwrap(),
1043 ReleaseId::new("r1").unwrap(),
1044 install.path(),
1045 ArtifactSet::single(member).unwrap(),
1046 )
1047 .unwrap()
1048 .prepare()
1049 .unwrap()
1050 .verify_integrity()
1051 .unwrap();
1052 assert_eq!(
1053 verified
1054 .integrity(&MemberId::new("main").unwrap())
1055 .unwrap()
1056 .status(),
1057 super::IntegrityStatus::Verified
1058 );
1059
1060 let bad_source = inputs.write_file("bad", b"bad bytes").unwrap();
1061 let bad_member = ArtifactMember::new(MemberId::new("bad").unwrap(), bad_source, "bin/bad")
1062 .unwrap()
1063 .with_integrity(super::IntegrityRequirement::Sha256(digest));
1064 assert!(matches!(
1065 InstallPlan::new(
1066 ProductId::new("eggup").unwrap(),
1067 ReleaseId::new("r1").unwrap(),
1068 install.path(),
1069 ArtifactSet::single(bad_member).unwrap(),
1070 )
1071 .unwrap()
1072 .prepare()
1073 .unwrap()
1074 .verify_integrity(),
1075 Err(Error::VerificationFailed(_))
1076 ));
1077
1078 let no_requirement = ArtifactMember::new(
1079 MemberId::new("none").unwrap(),
1080 inputs.write_file("none", b"no declaration").unwrap(),
1081 "bin/none",
1082 )
1083 .unwrap();
1084 let verified = InstallPlan::new(
1085 ProductId::new("eggup").unwrap(),
1086 ReleaseId::new("r1").unwrap(),
1087 install.path(),
1088 ArtifactSet::single(no_requirement).unwrap(),
1089 )
1090 .unwrap()
1091 .prepare()
1092 .unwrap()
1093 .verify_integrity()
1094 .unwrap();
1095 let validator = ExactIdentityValidator::new(MemberId::new("none").unwrap(), "anything");
1096 assert!(matches!(
1097 verified.validate(&validator),
1098 Err(Error::VerificationFailed(_))
1099 ));
1100 }
1101
1102 #[cfg(unix)]
1103 #[test]
1104 fn bounded_candidates_are_exact_and_environment_is_cleared() {
1105 use std::os::unix::fs::PermissionsExt;
1106
1107 let inputs = InstallationRoot::new().unwrap();
1108 let install = InstallationRoot::new().unwrap();
1109 let script = inputs
1110 .write_file("main", b"#!/bin/sh\nprintf 'eggup 1.2.3\\n'\n")
1111 .unwrap();
1112 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1113 let digest = super::hash_file(&script).unwrap();
1114 let member = ArtifactMember::new(MemberId::new("main").unwrap(), script, "bin/main")
1115 .unwrap()
1116 .with_integrity(super::IntegrityRequirement::Sha256(digest));
1117 fs::create_dir_all(install.path().join("bin")).unwrap();
1118 let verified = InstallPlan::new(
1119 ProductId::new("eggup").unwrap(),
1120 ReleaseId::new("r1").unwrap(),
1121 install.path(),
1122 ArtifactSet::single(member).unwrap(),
1123 )
1124 .unwrap()
1125 .prepare()
1126 .unwrap()
1127 .verify_integrity()
1128 .unwrap();
1129 let validator =
1130 ExactIdentityValidator::new(MemberId::new("main").unwrap(), "eggup 1.2.3\n");
1131 let validated = verified.validate(&validator).unwrap();
1132 let verifier = allow_create();
1133 assert_eq!(
1134 validated
1135 .commit(CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate))
1136 .unwrap()
1137 .disposition(),
1138 TransactionDisposition::Committed
1139 );
1140
1141 let secret_script = inputs
1142 .write_file(
1143 "secret",
1144 b"#!/bin/sh\nprintf '%s' \"${EGGUP_SECRET-unset}\"\n",
1145 )
1146 .unwrap();
1147 fs::set_permissions(&secret_script, fs::Permissions::from_mode(0o755)).unwrap();
1148 let output = super::run_bounded(&super::CommandSpec::new(secret_script)).unwrap();
1149 assert!(output.success(), "bounded candidate output: {output:?}");
1150 assert_eq!(output.stdout(), b"unset");
1151 }
1152
1153 #[cfg(unix)]
1154 #[test]
1155 fn bounded_runner_kills_timeouts_and_limits_output() {
1156 use std::os::unix::fs::PermissionsExt;
1157
1158 let fixture = InstallationRoot::new().unwrap();
1159 let timeout_script = fixture
1160 .write_file("timeout", b"#!/bin/sh\nsleep 2\n")
1161 .unwrap();
1162 let noisy_script = fixture
1163 .write_file("noisy", b"#!/bin/sh\nprintf '1234567890'\n")
1164 .unwrap();
1165 for path in [&timeout_script, &noisy_script] {
1166 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
1167 }
1168 let timed_out = super::run_bounded(
1169 &super::CommandSpec::new(timeout_script).timeout(Duration::from_millis(20)),
1170 )
1171 .unwrap();
1172 assert!(timed_out.timed_out());
1173 assert!(!timed_out.success());
1174 let limited =
1175 super::run_bounded(&super::CommandSpec::new(noisy_script).max_output_bytes(4)).unwrap();
1176 assert!(limited.output_limited());
1177 assert!(!limited.success());
1178 }
1179
1180 #[cfg(unix)]
1181 #[test]
1182 fn cross_member_identity_requires_bundle_agreement() {
1183 use std::os::unix::fs::PermissionsExt;
1184
1185 let inputs = InstallationRoot::new().unwrap();
1186 let install = InstallationRoot::new().unwrap();
1187 let main = inputs
1188 .write_file("main", b"#!/bin/sh\nprintf 'bundle 9\\n'\n")
1189 .unwrap();
1190 let helper = inputs
1191 .write_file("helper", b"#!/bin/sh\nprintf 'bundle 9\\n'\n")
1192 .unwrap();
1193 for path in [&main, &helper] {
1194 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
1195 }
1196 let main_digest = super::hash_file(&main).unwrap();
1197 let helper_digest = super::hash_file(&helper).unwrap();
1198 let verified = InstallPlan::new(
1199 ProductId::new("bundle").unwrap(),
1200 ReleaseId::new("r9").unwrap(),
1201 install.path(),
1202 ArtifactSet::new(vec![
1203 ArtifactMember::new(MemberId::new("main").unwrap(), main, "bin/main")
1204 .unwrap()
1205 .with_integrity(super::IntegrityRequirement::Sha256(main_digest)),
1206 ArtifactMember::new(MemberId::new("helper").unwrap(), helper, "bin/helper")
1207 .unwrap()
1208 .with_integrity(super::IntegrityRequirement::Sha256(helper_digest)),
1209 ])
1210 .unwrap(),
1211 )
1212 .unwrap()
1213 .prepare()
1214 .unwrap()
1215 .verify_integrity()
1216 .unwrap();
1217 let validator = super::CrossMemberAgreementValidator::new(
1218 [
1219 MemberId::new("main").unwrap(),
1220 MemberId::new("helper").unwrap(),
1221 ],
1222 "bundle 9\n",
1223 );
1224 assert!(verified.validate(&validator).is_ok());
1225 }
1226
1227 fn single_verified(
1230 new_bytes: &[u8],
1231 old_bytes: Option<&[u8]>,
1232 with_bin_dir: bool,
1233 ) -> (
1234 InstallationRoot,
1235 InstallationRoot,
1236 super::PreparedTransaction,
1237 [u8; 32],
1238 ) {
1239 let inputs = InstallationRoot::new().unwrap();
1240 let install = InstallationRoot::new().unwrap();
1241 let src = inputs.write_file("main", new_bytes).unwrap();
1242 let digest = super::hash_file(&src).unwrap();
1243 if let Some(old) = old_bytes {
1244 install.write_file("bin/main", old).unwrap();
1245 } else if with_bin_dir {
1246 fs::create_dir_all(install.path().join("bin")).unwrap();
1247 }
1248 let member = ArtifactMember::new(MemberId::new("main").unwrap(), src, "bin/main")
1249 .unwrap()
1250 .with_integrity(super::IntegrityRequirement::Sha256(digest));
1251 let prepared = InstallPlan::new(
1252 ProductId::new("p").unwrap(),
1253 ReleaseId::new("r1").unwrap(),
1254 install.path(),
1255 ArtifactSet::single(member).unwrap(),
1256 )
1257 .unwrap()
1258 .prepare()
1259 .unwrap();
1260 (inputs, install, prepared, digest)
1261 }
1262
1263 #[test]
1264 fn ownership_owned_allows_replacement_and_reports_no_failure() {
1265 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1266 let old_digest = super::hash_file(&install.path().join("bin/main")).unwrap();
1267 let verifier = ExactDigestVerifier::new(vec![(MemberId::new("main").unwrap(), old_digest)]);
1268 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
1269 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1270 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"new");
1271 }
1272
1273 #[test]
1274 fn ownership_foreign_and_unknown_fail_closed_with_zero_mutation() {
1275 for fixed in [Ownership::Foreign, Ownership::Unknown] {
1276 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1277 let verifier = FixedVerifier(fixed);
1278 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1279 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1280 assert_eq!(
1281 receipt.failure().unwrap().phase(),
1282 super::FailurePhase::Ownership
1283 );
1284 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
1285 }
1286 }
1287
1288 #[test]
1289 fn ownership_absent_create_policy_is_enforced() {
1290 let (_i, install, prepared, _) = single_verified(b"new", None, true);
1291 let verifier = AbsentOnlyVerifier;
1292 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1293 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1294 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"new");
1295
1296 let (_i, install, prepared, _) = single_verified(b"new", None, true);
1297 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
1298 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1299 assert!(!install.path().join("bin/main").exists());
1300 }
1301
1302 #[test]
1303 fn ownership_flapping_between_preflight_and_lock_fails() {
1304 use std::cell::Cell;
1305 #[derive(Debug)]
1306 struct Flap {
1307 first: Ownership,
1308 second: Ownership,
1309 calls: Cell<usize>,
1310 }
1311 impl OwnershipVerifier for Flap {
1312 fn verify(&self, _m: &MemberId, _d: &Path) -> Ownership {
1313 let n = self.calls.get();
1314 self.calls.set(n + 1);
1315 if n == 0 {
1316 self.first
1317 } else {
1318 self.second
1319 }
1320 }
1321 }
1322 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1323 let verifier = Flap {
1324 first: Ownership::Owned,
1325 second: Ownership::Foreign,
1326 calls: Cell::new(0),
1327 };
1328 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1329 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1330 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
1331 }
1332
1333 #[test]
1334 fn missing_parent_fails_without_creating_directories() {
1335 let (_i, install, prepared, _) = single_verified(b"new", None, false);
1336 assert!(!install.path().join("bin").exists());
1337 let verifier = allow_create();
1338 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1339 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1340 assert!(!install.path().join("bin").exists());
1341 assert!(!install.path().join("bin/main").exists());
1342 }
1343
1344 #[cfg(unix)]
1345 #[test]
1346 fn symlinked_parent_fails_closed() {
1347 use std::os::unix::fs::symlink;
1348 let (_i, install, prepared, _) = single_verified(b"new", None, false);
1349 fs::create_dir_all(install.path().join("real-bin")).unwrap();
1350 symlink(install.path().join("real-bin"), install.path().join("bin")).unwrap();
1351 let verifier = allow_create();
1353 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1354 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1355 }
1356
1357 #[test]
1358 fn staged_mutation_after_validation_fails_before_live_mutation() {
1359 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1360 let verified = prepared.verify_integrity().unwrap();
1361 let validated = verified.validate(&AllValidators::new()).unwrap();
1362 let staged = validated
1364 .staged_path(&MemberId::new("main").unwrap())
1365 .unwrap();
1366 fs::write(&staged, b"tampered").unwrap();
1367 let verifier = allow_create();
1368 let receipt = validated
1369 .commit(CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate))
1370 .unwrap();
1371 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1372 assert_eq!(
1373 receipt.failure().unwrap().phase(),
1374 super::FailurePhase::StageRevalidation
1375 );
1376 assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
1377 }
1378
1379 #[test]
1380 fn unverified_integrity_none_cannot_commit() {
1381 let inputs = InstallationRoot::new().unwrap();
1382 let install = InstallationRoot::new().unwrap();
1383 fs::create_dir_all(install.path().join("bin")).unwrap();
1384 let src = inputs.write_file("main", b"bytes").unwrap();
1385 let member = ArtifactMember::new(MemberId::new("main").unwrap(), src, "bin/main").unwrap();
1386 let prepared = InstallPlan::new(
1387 ProductId::new("p").unwrap(),
1388 ReleaseId::new("r1").unwrap(),
1389 install.path(),
1390 ArtifactSet::single(member).unwrap(),
1391 )
1392 .unwrap()
1393 .prepare()
1394 .unwrap();
1395 let verified = prepared.verify_integrity().unwrap();
1396 assert!(verified.validate(&AllValidators::new()).is_err());
1397 }
1398
1399 #[test]
1400 fn cleanup_failure_reports_real_backup_root() {
1401 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1402 let verifier = allow_create();
1403 let receipt = commit_verified_with_fault(
1405 prepared,
1406 &verifier,
1407 AbsentPolicy::AllowCreate,
1408 CommitFault::Finalize,
1409 )
1410 .unwrap();
1411 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1412 assert_eq!(receipt.cleanup(), CleanupDisposition::RetainedForRecovery);
1413 let path = receipt.recovery_path().unwrap();
1414 assert!(path.exists());
1415 assert!(!path.to_string_lossy().contains("cleanup-failed"));
1416 assert_eq!(
1417 receipt.failure().unwrap().phase(),
1418 super::FailurePhase::Finalize
1419 );
1420 let _ = fs::remove_dir_all(path);
1421 let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
1422 }
1423
1424 #[cfg(unix)]
1425 #[test]
1426 fn transaction_owned_state_is_owner_private() {
1427 use std::os::unix::fs::PermissionsExt;
1428 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1429 let stage_mode = fs::metadata(prepared.stage_root())
1430 .unwrap()
1431 .permissions()
1432 .mode()
1433 & 0o777;
1434 assert_eq!(stage_mode, 0o700);
1435 let staged = prepared
1436 .staged_path(&MemberId::new("main").unwrap())
1437 .unwrap();
1438 let file_mode = fs::metadata(&staged).unwrap().permissions().mode() & 0o777;
1439 assert_eq!(file_mode, 0o600);
1440 let verifier = allow_create();
1441 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1442 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1443 let (_i2, install2, prepared2, _) = single_verified(b"n2", Some(b"o2"), false);
1445 let receipt = commit_verified_with_fault(
1446 prepared2,
1447 &verifier,
1448 AbsentPolicy::AllowCreate,
1449 CommitFault::Finalize,
1450 )
1451 .unwrap();
1452 let lock_mode = fs::metadata(install2.path().join(".eggup-mutation.lock"))
1453 .unwrap()
1454 .permissions()
1455 .mode()
1456 & 0o777;
1457 assert_eq!(lock_mode, 0o600);
1458 let _ = fs::remove_dir_all(receipt.recovery_path().unwrap());
1459 let _ = fs::remove_file(install2.path().join(".eggup-mutation.lock"));
1460 let _ = install;
1461 }
1462
1463 #[test]
1464 fn lock_inspect_never_deletes_and_reports_status() {
1465 let install = InstallationRoot::new().unwrap();
1466 assert_eq!(
1467 MutationLock::inspect(install.path()).unwrap(),
1468 super::LockStatus::Available
1469 );
1470 let product = ProductId::new("p").unwrap();
1471 let release = ReleaseId::new("r").unwrap();
1472 let lock = MutationLock::acquire(install.path(), &product, &release).unwrap();
1473 match MutationLock::inspect(install.path()).unwrap() {
1474 super::LockStatus::Held { contents, .. } => assert!(contents.is_some()),
1475 other => panic!("expected held, got {other:?}"),
1476 }
1477 drop(lock);
1478 assert_eq!(
1480 MutationLock::inspect(install.path()).unwrap(),
1481 super::LockStatus::Available
1482 );
1483 fs::write(install.path().join(".eggup-mutation.lock"), b"malformed").unwrap();
1484 match MutationLock::inspect(install.path()).unwrap() {
1485 super::LockStatus::Held { .. } | super::LockStatus::Malformed { .. } => {}
1486 other => panic!("expected held/malformed, got {other:?}"),
1487 }
1488 assert!(install.path().join(".eggup-mutation.lock").exists());
1490 }
1491
1492 #[test]
1493 fn oversized_lock_record_is_malformed_not_deleted() {
1494 let install = InstallationRoot::new().unwrap();
1495 let big = vec![b'x'; 8192];
1496 fs::write(install.path().join(".eggup-mutation.lock"), &big).unwrap();
1497 match MutationLock::inspect(install.path()).unwrap() {
1498 super::LockStatus::Malformed { .. } => {}
1499 other => panic!("expected malformed, got {other:?}"),
1500 }
1501 assert!(install.path().join(".eggup-mutation.lock").exists());
1502 }
1503
1504 #[test]
1505 fn exact_digest_verifier_proves_ownership() {
1506 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1507 let live_digest = super::hash_file(&install.path().join("bin/main")).unwrap();
1508 let verifier =
1509 ExactDigestVerifier::new(vec![(MemberId::new("main").unwrap(), live_digest)]);
1510 assert_eq!(
1511 verifier.verify(
1512 &MemberId::new("main").unwrap(),
1513 &install.path().join("bin/main")
1514 ),
1515 Ownership::Owned
1516 );
1517 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
1518 assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1519 }
1520
1521 #[test]
1522 fn absent_only_verifier_never_authorizes_replacement() {
1523 let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1524 let verifier = AbsentOnlyVerifier;
1525 assert_eq!(
1526 verifier.verify(
1527 &MemberId::new("main").unwrap(),
1528 &install.path().join("bin/main")
1529 ),
1530 Ownership::Foreign
1531 );
1532 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1533 assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1534 }
1535
1536 #[test]
1537 fn failure_reports_carry_phase_category_and_member() {
1538 let (_i, _install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1539 let verifier = FixedVerifier(Ownership::Foreign);
1540 let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1541 let failure = receipt.failure().unwrap();
1542 assert_eq!(failure.phase(), super::FailurePhase::Ownership);
1543 assert!(!failure.detail().is_empty());
1544 assert!(failure.detail().len() <= 512);
1545 }
1546
1547 #[test]
1548 fn checksum_only_state_is_explicit_in_docs() {
1549 let desc = env!("CARGO_PKG_DESCRIPTION");
1555 let _ = desc;
1556 }
1557
1558 #[allow(dead_code)]
1559 fn _unused() {
1560 let _m: HashMap<MemberId, [u8; 32]> = HashMap::new();
1561 }
1562}