Skip to main content

eggup_core/
lib.rs

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        // Post-handoff replacement: the recorded name now resolves to foreign
289        // bytes while the open object still proves the owned bytes.
290        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        // The recorded advisory path is stale from here on; staging must still
329        // resolve the owned object, never the renamed-away name.
330        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    // ---- M005 corrective coverage ----
1228
1229    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        // Re-prepare after the symlink exists would still stage fine; commit must fail.
1352        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        // Mutate the staged copy after validation.
1363        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        // Finalize fault retains the real backup root and reports finalize phase.
1404        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        // Lock file, when preserved by a finalize fault, must be 0600.
1444        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        // After drop with cleanup, available again.
1479        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        // Inspect must never delete.
1489        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        // Authenticity support does not exist: every commit requires a verified
1550        // SHA-256 digest, and there is no public authenticity type. This test
1551        // guards the API surface by asserting the commit path rejects missing
1552        // integrity evidence (see unverified_integrity_none_cannot_commit) and
1553        // documents the checksum-only contract here.
1554        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}