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