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, TransactionDisposition,
43    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        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 partial_commit_failure_restores_every_old_member() {
409        let (_inputs, install, prepared) = prepared_bundle(true);
410        let verifier = allow_create();
411
412        let receipt = commit_verified_with_fault(
413            prepared,
414            &verifier,
415            AbsentPolicy::AllowCreate,
416            CommitFault::Commit(MemberId::new("helper").unwrap()),
417        )
418        .expect("receipt");
419
420        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
421        assert!(receipt.rollback_performed());
422        assert!(receipt.rollback_verified());
423        let failure = receipt.failure().expect("failure report");
424        assert_eq!(failure.phase(), super::FailurePhase::Commit);
425        assert_eq!(failure.member(), Some(&MemberId::new("helper").unwrap()));
426        assert_eq!(
427            fs::read(install.path().join("bin/main")).unwrap(),
428            b"old-main"
429        );
430        assert_eq!(
431            fs::read(install.path().join("bin/helper")).unwrap(),
432            b"old-helper"
433        );
434    }
435
436    #[test]
437    fn rollback_removes_new_members_that_were_absent_before_commit() {
438        let inputs = InstallationRoot::new().unwrap();
439        let install = InstallationRoot::new().unwrap();
440        let main_src = inputs.write_file("main", b"new-main").unwrap();
441        let helper_src = inputs.write_file("helper", b"new-helper").unwrap();
442        let main_digest = super::hash_file(&main_src).unwrap();
443        let helper_digest = super::hash_file(&helper_src).unwrap();
444        install.write_file("bin/main", b"old-main").unwrap();
445        let prepared = InstallPlan::new(
446            ProductId::new("bundle").unwrap(),
447            ReleaseId::new("r1").unwrap(),
448            install.path(),
449            ArtifactSet::new(vec![
450                ArtifactMember::new(
451                    MemberId::new("main").unwrap(),
452                    inputs.path().join("main"),
453                    "bin/main",
454                )
455                .unwrap()
456                .with_integrity(super::IntegrityRequirement::Sha256(main_digest)),
457                ArtifactMember::new(
458                    MemberId::new("helper").unwrap(),
459                    inputs.path().join("helper"),
460                    "bin/helper",
461                )
462                .unwrap()
463                .with_integrity(super::IntegrityRequirement::Sha256(helper_digest)),
464            ])
465            .unwrap(),
466        )
467        .unwrap()
468        .prepare()
469        .unwrap();
470        let verifier = allow_create();
471
472        let receipt = commit_verified_with_fault(
473            prepared,
474            &verifier,
475            AbsentPolicy::AllowCreate,
476            CommitFault::Commit(MemberId::new("helper").unwrap()),
477        )
478        .unwrap();
479
480        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
481        assert_eq!(
482            fs::read(install.path().join("bin/main")).unwrap(),
483            b"old-main"
484        );
485        assert!(!install.path().join("bin/helper").exists());
486    }
487
488    #[test]
489    fn lock_contention_and_malformed_lock_fail_closed() {
490        let (_inputs, install, prepared) = prepared_bundle(false);
491        let product = ProductId::new("bundle").unwrap();
492        let release = ReleaseId::new("r1").unwrap();
493        let _lock = MutationLock::acquire(install.path(), &product, &release).unwrap();
494        let verifier = allow_create();
495
496        let error = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap_err();
497        assert!(matches!(error, Error::UpdateInProgress { .. }));
498        drop(_lock);
499
500        let (_inputs, install2, prepared) = prepared_bundle(false);
501        let lock = install2.path().join(".eggup-mutation.lock");
502        fs::write(&lock, b"malformed").unwrap();
503        let error = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap_err();
504        assert!(matches!(error, Error::UpdateInProgress { .. }));
505        assert!(lock.exists());
506    }
507
508    #[test]
509    fn rollback_failure_returns_recovery_required_and_retains_evidence() {
510        let (_inputs, install, prepared) = prepared_bundle(true);
511        let verifier = allow_create();
512
513        let receipt = commit_verified_with_fault(
514            prepared,
515            &verifier,
516            AbsentPolicy::AllowCreate,
517            CommitFault::CommitThenRollback(
518                MemberId::new("helper").unwrap(),
519                MemberId::new("main").unwrap(),
520            ),
521        )
522        .unwrap();
523
524        assert_eq!(
525            receipt.disposition(),
526            TransactionDisposition::RecoveryRequired
527        );
528        assert!(!receipt.rollback_verified());
529        assert!(receipt.recovery_path().is_some());
530        assert!(receipt.failure().is_some());
531        assert!(receipt.rollback_failure().is_some());
532        assert_eq!(
533            receipt.failure().unwrap().phase(),
534            super::FailurePhase::Commit
535        );
536        assert_eq!(
537            receipt.rollback_failure().unwrap().phase(),
538            super::FailurePhase::Rollback
539        );
540        assert!(install.path().join(".eggup-mutation.lock").exists());
541        let recovery = receipt.recovery_path().unwrap();
542        assert!(recovery.exists());
543        let _ = fs::remove_dir_all(recovery);
544        let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
545    }
546
547    #[test]
548    fn precommit_and_backup_failures_restore_old_state() {
549        let (_inputs, install, prepared) = prepared_bundle(true);
550        let verifier = allow_create();
551        let receipt = commit_verified_with_fault(
552            prepared,
553            &verifier,
554            AbsentPolicy::AllowCreate,
555            CommitFault::BeforeFirstCommit,
556        )
557        .unwrap();
558        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
559        assert!(receipt.failure().is_some());
560        assert_eq!(
561            fs::read(install.path().join("bin/main")).unwrap(),
562            b"old-main"
563        );
564
565        let (_inputs, install, prepared) = prepared_bundle(true);
566        let receipt = commit_verified_with_fault(
567            prepared,
568            &verifier,
569            AbsentPolicy::AllowCreate,
570            CommitFault::Backup(MemberId::new("helper").unwrap()),
571        )
572        .unwrap();
573        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
574        assert_eq!(
575            receipt.failure().unwrap().phase(),
576            super::FailurePhase::Backup
577        );
578        assert_eq!(
579            fs::read(install.path().join("bin/helper")).unwrap(),
580            b"old-helper"
581        );
582    }
583
584    #[test]
585    fn injected_lock_creation_failure_performs_no_mutation() {
586        let (_inputs, install, prepared) = prepared_bundle(true);
587        let verifier = allow_create();
588
589        let error = commit_verified_with_fault(
590            prepared,
591            &verifier,
592            AbsentPolicy::AllowCreate,
593            CommitFault::LockCreation,
594        )
595        .unwrap_err();
596
597        assert!(matches!(error, Error::InvalidInput(_)));
598        assert_eq!(
599            fs::read(install.path().join("bin/main")).unwrap(),
600            b"old-main"
601        );
602        assert!(!install.path().join(".eggup-mutation.lock").exists());
603    }
604
605    #[cfg(unix)]
606    #[test]
607    fn destination_links_fail_immediately_before_backup() {
608        use std::fs::hard_link;
609        use std::os::unix::fs::symlink;
610
611        let inputs = InstallationRoot::new().unwrap();
612        let install = InstallationRoot::new().unwrap();
613        let src = inputs.write_file("main", b"new-main").unwrap();
614        let digest = super::hash_file(&src).unwrap();
615        install.write_file("bin/real", b"old-main").unwrap();
616        symlink(install.path().join("real"), install.path().join("bin/main")).unwrap();
617        let prepared = InstallPlan::new(
618            ProductId::new("bundle").unwrap(),
619            ReleaseId::new("r1").unwrap(),
620            install.path(),
621            ArtifactSet::single(
622                ArtifactMember::new(
623                    MemberId::new("main").unwrap(),
624                    inputs.path().join("main"),
625                    "bin/main",
626                )
627                .unwrap()
628                .with_integrity(super::IntegrityRequirement::Sha256(digest)),
629            )
630            .unwrap(),
631        )
632        .unwrap()
633        .prepare()
634        .unwrap();
635        let verifier = allow_create();
636        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
637        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
638        assert_eq!(
639            receipt.failure().unwrap().phase(),
640            super::FailurePhase::Ownership
641        );
642        assert_eq!(
643            fs::read(install.path().join("bin/real")).unwrap(),
644            b"old-main"
645        );
646
647        let (_inputs, install, prepared) = prepared_bundle(true);
648        hard_link(
649            install.path().join("bin/main"),
650            install.path().join("bin/linked"),
651        )
652        .unwrap();
653        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
654        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
655        assert_eq!(
656            fs::read(install.path().join("bin/main")).unwrap(),
657            b"old-main"
658        );
659    }
660
661    #[test]
662    fn hashes_known_bytes_and_parses_strict_sidecars() {
663        let fixture = InstallationRoot::new().unwrap();
664        let path = fixture.write_file("abc", b"abc").unwrap();
665        let manifest = super::parse_sha256_sidecar(
666            "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD  abc\n",
667        )
668        .unwrap();
669        assert_eq!(manifest.filename(), Some("abc"));
670        assert_eq!(
671            super::verify_file(&path, &manifest).unwrap(),
672            *manifest.digest()
673        );
674        assert!(super::parse_sha256_sidecar("abc\ndef").is_err());
675        assert!(super::parse_sha256_sidecar(
676            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad  other"
677        )
678        .and_then(|manifest| super::verify_file(&path, &manifest))
679        .is_err());
680        assert!(super::parse_sha256_sidecar("not-a-digest  abc").is_err());
681    }
682
683    #[test]
684    fn integrity_verification_precedes_candidate_validation() {
685        let inputs = InstallationRoot::new().unwrap();
686        let install = InstallationRoot::new().unwrap();
687        let source = inputs.write_file("main", b"verified bytes").unwrap();
688        let digest = super::hash_file(&source).unwrap();
689        let member = ArtifactMember::new(MemberId::new("main").unwrap(), source, "bin/main")
690            .unwrap()
691            .with_integrity(super::IntegrityRequirement::Sha256(digest));
692        let verified = InstallPlan::new(
693            ProductId::new("eggup").unwrap(),
694            ReleaseId::new("r1").unwrap(),
695            install.path(),
696            ArtifactSet::single(member).unwrap(),
697        )
698        .unwrap()
699        .prepare()
700        .unwrap()
701        .verify_integrity()
702        .unwrap();
703        assert_eq!(
704            verified
705                .integrity(&MemberId::new("main").unwrap())
706                .unwrap()
707                .status(),
708            super::IntegrityStatus::Verified
709        );
710
711        let bad_source = inputs.write_file("bad", b"bad bytes").unwrap();
712        let bad_member = ArtifactMember::new(MemberId::new("bad").unwrap(), bad_source, "bin/bad")
713            .unwrap()
714            .with_integrity(super::IntegrityRequirement::Sha256(digest));
715        assert!(matches!(
716            InstallPlan::new(
717                ProductId::new("eggup").unwrap(),
718                ReleaseId::new("r1").unwrap(),
719                install.path(),
720                ArtifactSet::single(bad_member).unwrap(),
721            )
722            .unwrap()
723            .prepare()
724            .unwrap()
725            .verify_integrity(),
726            Err(Error::VerificationFailed(_))
727        ));
728
729        let no_requirement = ArtifactMember::new(
730            MemberId::new("none").unwrap(),
731            inputs.write_file("none", b"no declaration").unwrap(),
732            "bin/none",
733        )
734        .unwrap();
735        let verified = InstallPlan::new(
736            ProductId::new("eggup").unwrap(),
737            ReleaseId::new("r1").unwrap(),
738            install.path(),
739            ArtifactSet::single(no_requirement).unwrap(),
740        )
741        .unwrap()
742        .prepare()
743        .unwrap()
744        .verify_integrity()
745        .unwrap();
746        let validator = ExactIdentityValidator::new(MemberId::new("none").unwrap(), "anything");
747        assert!(matches!(
748            verified.validate(&validator),
749            Err(Error::VerificationFailed(_))
750        ));
751    }
752
753    #[cfg(unix)]
754    #[test]
755    fn bounded_candidates_are_exact_and_environment_is_cleared() {
756        use std::os::unix::fs::PermissionsExt;
757
758        let inputs = InstallationRoot::new().unwrap();
759        let install = InstallationRoot::new().unwrap();
760        let script = inputs
761            .write_file("main", b"#!/bin/sh\nprintf 'eggup 1.2.3\\n'\n")
762            .unwrap();
763        fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
764        let digest = super::hash_file(&script).unwrap();
765        let member = ArtifactMember::new(MemberId::new("main").unwrap(), script, "bin/main")
766            .unwrap()
767            .with_integrity(super::IntegrityRequirement::Sha256(digest));
768        fs::create_dir_all(install.path().join("bin")).unwrap();
769        let verified = InstallPlan::new(
770            ProductId::new("eggup").unwrap(),
771            ReleaseId::new("r1").unwrap(),
772            install.path(),
773            ArtifactSet::single(member).unwrap(),
774        )
775        .unwrap()
776        .prepare()
777        .unwrap()
778        .verify_integrity()
779        .unwrap();
780        let validator =
781            ExactIdentityValidator::new(MemberId::new("main").unwrap(), "eggup 1.2.3\n");
782        let validated = verified.validate(&validator).unwrap();
783        let verifier = allow_create();
784        assert_eq!(
785            validated
786                .commit(CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate))
787                .unwrap()
788                .disposition(),
789            TransactionDisposition::Committed
790        );
791
792        let secret_script = inputs
793            .write_file(
794                "secret",
795                b"#!/bin/sh\nprintf '%s' \"${EGGUP_SECRET-unset}\"\n",
796            )
797            .unwrap();
798        fs::set_permissions(&secret_script, fs::Permissions::from_mode(0o755)).unwrap();
799        let output = super::run_bounded(
800            &super::CommandSpec::new(secret_script).timeout(Duration::from_secs(1)),
801        )
802        .unwrap();
803        assert!(output.success());
804        assert_eq!(output.stdout(), b"unset");
805    }
806
807    #[cfg(unix)]
808    #[test]
809    fn bounded_runner_kills_timeouts_and_limits_output() {
810        use std::os::unix::fs::PermissionsExt;
811
812        let fixture = InstallationRoot::new().unwrap();
813        let timeout_script = fixture
814            .write_file("timeout", b"#!/bin/sh\nsleep 2\n")
815            .unwrap();
816        let noisy_script = fixture
817            .write_file("noisy", b"#!/bin/sh\nprintf '1234567890'\n")
818            .unwrap();
819        for path in [&timeout_script, &noisy_script] {
820            fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
821        }
822        let timed_out = super::run_bounded(
823            &super::CommandSpec::new(timeout_script).timeout(Duration::from_millis(20)),
824        )
825        .unwrap();
826        assert!(timed_out.timed_out());
827        assert!(!timed_out.success());
828        let limited =
829            super::run_bounded(&super::CommandSpec::new(noisy_script).max_output_bytes(4)).unwrap();
830        assert!(limited.output_limited());
831        assert!(!limited.success());
832    }
833
834    #[cfg(unix)]
835    #[test]
836    fn cross_member_identity_requires_bundle_agreement() {
837        use std::os::unix::fs::PermissionsExt;
838
839        let inputs = InstallationRoot::new().unwrap();
840        let install = InstallationRoot::new().unwrap();
841        let main = inputs
842            .write_file("main", b"#!/bin/sh\nprintf 'bundle 9\\n'\n")
843            .unwrap();
844        let helper = inputs
845            .write_file("helper", b"#!/bin/sh\nprintf 'bundle 9\\n'\n")
846            .unwrap();
847        for path in [&main, &helper] {
848            fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
849        }
850        let main_digest = super::hash_file(&main).unwrap();
851        let helper_digest = super::hash_file(&helper).unwrap();
852        let verified = InstallPlan::new(
853            ProductId::new("bundle").unwrap(),
854            ReleaseId::new("r9").unwrap(),
855            install.path(),
856            ArtifactSet::new(vec![
857                ArtifactMember::new(MemberId::new("main").unwrap(), main, "bin/main")
858                    .unwrap()
859                    .with_integrity(super::IntegrityRequirement::Sha256(main_digest)),
860                ArtifactMember::new(MemberId::new("helper").unwrap(), helper, "bin/helper")
861                    .unwrap()
862                    .with_integrity(super::IntegrityRequirement::Sha256(helper_digest)),
863            ])
864            .unwrap(),
865        )
866        .unwrap()
867        .prepare()
868        .unwrap()
869        .verify_integrity()
870        .unwrap();
871        let validator = super::CrossMemberAgreementValidator::new(
872            [
873                MemberId::new("main").unwrap(),
874                MemberId::new("helper").unwrap(),
875            ],
876            "bundle 9\n",
877        );
878        assert!(verified.validate(&validator).is_ok());
879    }
880
881    // ---- M005 corrective coverage ----
882
883    fn single_verified(
884        new_bytes: &[u8],
885        old_bytes: Option<&[u8]>,
886        with_bin_dir: bool,
887    ) -> (
888        InstallationRoot,
889        InstallationRoot,
890        super::PreparedTransaction,
891        [u8; 32],
892    ) {
893        let inputs = InstallationRoot::new().unwrap();
894        let install = InstallationRoot::new().unwrap();
895        let src = inputs.write_file("main", new_bytes).unwrap();
896        let digest = super::hash_file(&src).unwrap();
897        if let Some(old) = old_bytes {
898            install.write_file("bin/main", old).unwrap();
899        } else if with_bin_dir {
900            fs::create_dir_all(install.path().join("bin")).unwrap();
901        }
902        let member = ArtifactMember::new(MemberId::new("main").unwrap(), src, "bin/main")
903            .unwrap()
904            .with_integrity(super::IntegrityRequirement::Sha256(digest));
905        let prepared = InstallPlan::new(
906            ProductId::new("p").unwrap(),
907            ReleaseId::new("r1").unwrap(),
908            install.path(),
909            ArtifactSet::single(member).unwrap(),
910        )
911        .unwrap()
912        .prepare()
913        .unwrap();
914        (inputs, install, prepared, digest)
915    }
916
917    #[test]
918    fn ownership_owned_allows_replacement_and_reports_no_failure() {
919        let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
920        let old_digest = super::hash_file(&install.path().join("bin/main")).unwrap();
921        let verifier = ExactDigestVerifier::new(vec![(MemberId::new("main").unwrap(), old_digest)]);
922        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
923        assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
924        assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"new");
925    }
926
927    #[test]
928    fn ownership_foreign_and_unknown_fail_closed_with_zero_mutation() {
929        for fixed in [Ownership::Foreign, Ownership::Unknown] {
930            let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
931            let verifier = FixedVerifier(fixed);
932            let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
933            assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
934            assert_eq!(
935                receipt.failure().unwrap().phase(),
936                super::FailurePhase::Ownership
937            );
938            assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
939        }
940    }
941
942    #[test]
943    fn ownership_absent_create_policy_is_enforced() {
944        let (_i, install, prepared, _) = single_verified(b"new", None, true);
945        let verifier = AbsentOnlyVerifier;
946        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
947        assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
948        assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"new");
949
950        let (_i, install, prepared, _) = single_verified(b"new", None, true);
951        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
952        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
953        assert!(!install.path().join("bin/main").exists());
954    }
955
956    #[test]
957    fn ownership_flapping_between_preflight_and_lock_fails() {
958        use std::cell::Cell;
959        #[derive(Debug)]
960        struct Flap {
961            first: Ownership,
962            second: Ownership,
963            calls: Cell<usize>,
964        }
965        impl OwnershipVerifier for Flap {
966            fn verify(&self, _m: &MemberId, _d: &Path) -> Ownership {
967                let n = self.calls.get();
968                self.calls.set(n + 1);
969                if n == 0 {
970                    self.first
971                } else {
972                    self.second
973                }
974            }
975        }
976        let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
977        let verifier = Flap {
978            first: Ownership::Owned,
979            second: Ownership::Foreign,
980            calls: Cell::new(0),
981        };
982        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
983        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
984        assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
985    }
986
987    #[test]
988    fn missing_parent_fails_without_creating_directories() {
989        let (_i, install, prepared, _) = single_verified(b"new", None, false);
990        assert!(!install.path().join("bin").exists());
991        let verifier = allow_create();
992        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
993        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
994        assert!(!install.path().join("bin").exists());
995        assert!(!install.path().join("bin/main").exists());
996    }
997
998    #[cfg(unix)]
999    #[test]
1000    fn symlinked_parent_fails_closed() {
1001        use std::os::unix::fs::symlink;
1002        let (_i, install, prepared, _) = single_verified(b"new", None, false);
1003        fs::create_dir_all(install.path().join("real-bin")).unwrap();
1004        symlink(install.path().join("real-bin"), install.path().join("bin")).unwrap();
1005        // Re-prepare after the symlink exists would still stage fine; commit must fail.
1006        let verifier = allow_create();
1007        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1008        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1009    }
1010
1011    #[test]
1012    fn staged_mutation_after_validation_fails_before_live_mutation() {
1013        let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1014        let verified = prepared.verify_integrity().unwrap();
1015        let validated = verified.validate(&AllValidators::new()).unwrap();
1016        // Mutate the staged copy after validation.
1017        let staged = validated
1018            .staged_path(&MemberId::new("main").unwrap())
1019            .unwrap();
1020        fs::write(&staged, b"tampered").unwrap();
1021        let verifier = allow_create();
1022        let receipt = validated
1023            .commit(CommitOwnership::new(&verifier, AbsentPolicy::AllowCreate))
1024            .unwrap();
1025        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1026        assert_eq!(
1027            receipt.failure().unwrap().phase(),
1028            super::FailurePhase::StageRevalidation
1029        );
1030        assert_eq!(fs::read(install.path().join("bin/main")).unwrap(), b"old");
1031    }
1032
1033    #[test]
1034    fn unverified_integrity_none_cannot_commit() {
1035        let inputs = InstallationRoot::new().unwrap();
1036        let install = InstallationRoot::new().unwrap();
1037        fs::create_dir_all(install.path().join("bin")).unwrap();
1038        let src = inputs.write_file("main", b"bytes").unwrap();
1039        let member = ArtifactMember::new(MemberId::new("main").unwrap(), src, "bin/main").unwrap();
1040        let prepared = InstallPlan::new(
1041            ProductId::new("p").unwrap(),
1042            ReleaseId::new("r1").unwrap(),
1043            install.path(),
1044            ArtifactSet::single(member).unwrap(),
1045        )
1046        .unwrap()
1047        .prepare()
1048        .unwrap();
1049        let verified = prepared.verify_integrity().unwrap();
1050        assert!(verified.validate(&AllValidators::new()).is_err());
1051    }
1052
1053    #[test]
1054    fn cleanup_failure_reports_real_backup_root() {
1055        let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1056        let verifier = allow_create();
1057        // Finalize fault retains the real backup root and reports finalize phase.
1058        let receipt = commit_verified_with_fault(
1059            prepared,
1060            &verifier,
1061            AbsentPolicy::AllowCreate,
1062            CommitFault::Finalize,
1063        )
1064        .unwrap();
1065        assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1066        assert_eq!(receipt.cleanup(), CleanupDisposition::RetainedForRecovery);
1067        let path = receipt.recovery_path().unwrap();
1068        assert!(path.exists());
1069        assert!(!path.to_string_lossy().contains("cleanup-failed"));
1070        assert_eq!(
1071            receipt.failure().unwrap().phase(),
1072            super::FailurePhase::Finalize
1073        );
1074        let _ = fs::remove_dir_all(path);
1075        let _ = fs::remove_file(install.path().join(".eggup-mutation.lock"));
1076    }
1077
1078    #[cfg(unix)]
1079    #[test]
1080    fn transaction_owned_state_is_owner_private() {
1081        use std::os::unix::fs::PermissionsExt;
1082        let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1083        let stage_mode = fs::metadata(prepared.stage_root())
1084            .unwrap()
1085            .permissions()
1086            .mode()
1087            & 0o777;
1088        assert_eq!(stage_mode, 0o700);
1089        let staged = prepared
1090            .staged_path(&MemberId::new("main").unwrap())
1091            .unwrap();
1092        let file_mode = fs::metadata(&staged).unwrap().permissions().mode() & 0o777;
1093        assert_eq!(file_mode, 0o600);
1094        let verifier = allow_create();
1095        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1096        assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1097        // Lock file, when preserved by a finalize fault, must be 0600.
1098        let (_i2, install2, prepared2, _) = single_verified(b"n2", Some(b"o2"), false);
1099        let receipt = commit_verified_with_fault(
1100            prepared2,
1101            &verifier,
1102            AbsentPolicy::AllowCreate,
1103            CommitFault::Finalize,
1104        )
1105        .unwrap();
1106        let lock_mode = fs::metadata(install2.path().join(".eggup-mutation.lock"))
1107            .unwrap()
1108            .permissions()
1109            .mode()
1110            & 0o777;
1111        assert_eq!(lock_mode, 0o600);
1112        let _ = fs::remove_dir_all(receipt.recovery_path().unwrap());
1113        let _ = fs::remove_file(install2.path().join(".eggup-mutation.lock"));
1114        let _ = install;
1115    }
1116
1117    #[test]
1118    fn lock_inspect_never_deletes_and_reports_status() {
1119        let install = InstallationRoot::new().unwrap();
1120        assert_eq!(
1121            MutationLock::inspect(install.path()).unwrap(),
1122            super::LockStatus::Available
1123        );
1124        let product = ProductId::new("p").unwrap();
1125        let release = ReleaseId::new("r").unwrap();
1126        let lock = MutationLock::acquire(install.path(), &product, &release).unwrap();
1127        match MutationLock::inspect(install.path()).unwrap() {
1128            super::LockStatus::Held { contents, .. } => assert!(contents.is_some()),
1129            other => panic!("expected held, got {other:?}"),
1130        }
1131        drop(lock);
1132        // After drop with cleanup, available again.
1133        assert_eq!(
1134            MutationLock::inspect(install.path()).unwrap(),
1135            super::LockStatus::Available
1136        );
1137        fs::write(install.path().join(".eggup-mutation.lock"), b"malformed").unwrap();
1138        match MutationLock::inspect(install.path()).unwrap() {
1139            super::LockStatus::Held { .. } | super::LockStatus::Malformed { .. } => {}
1140            other => panic!("expected held/malformed, got {other:?}"),
1141        }
1142        // Inspect must never delete.
1143        assert!(install.path().join(".eggup-mutation.lock").exists());
1144    }
1145
1146    #[test]
1147    fn oversized_lock_record_is_malformed_not_deleted() {
1148        let install = InstallationRoot::new().unwrap();
1149        let big = vec![b'x'; 8192];
1150        fs::write(install.path().join(".eggup-mutation.lock"), &big).unwrap();
1151        match MutationLock::inspect(install.path()).unwrap() {
1152            super::LockStatus::Malformed { .. } => {}
1153            other => panic!("expected malformed, got {other:?}"),
1154        }
1155        assert!(install.path().join(".eggup-mutation.lock").exists());
1156    }
1157
1158    #[test]
1159    fn exact_digest_verifier_proves_ownership() {
1160        let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1161        let live_digest = super::hash_file(&install.path().join("bin/main")).unwrap();
1162        let verifier =
1163            ExactDigestVerifier::new(vec![(MemberId::new("main").unwrap(), live_digest)]);
1164        assert_eq!(
1165            verifier.verify(
1166                &MemberId::new("main").unwrap(),
1167                &install.path().join("bin/main")
1168            ),
1169            Ownership::Owned
1170        );
1171        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::DenyCreate).unwrap();
1172        assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
1173    }
1174
1175    #[test]
1176    fn absent_only_verifier_never_authorizes_replacement() {
1177        let (_i, install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1178        let verifier = AbsentOnlyVerifier;
1179        assert_eq!(
1180            verifier.verify(
1181                &MemberId::new("main").unwrap(),
1182                &install.path().join("bin/main")
1183            ),
1184            Ownership::Foreign
1185        );
1186        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1187        assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
1188    }
1189
1190    #[test]
1191    fn failure_reports_carry_phase_category_and_member() {
1192        let (_i, _install, prepared, _) = single_verified(b"new", Some(b"old"), false);
1193        let verifier = FixedVerifier(Ownership::Foreign);
1194        let receipt = commit_verified(prepared, &verifier, AbsentPolicy::AllowCreate).unwrap();
1195        let failure = receipt.failure().unwrap();
1196        assert_eq!(failure.phase(), super::FailurePhase::Ownership);
1197        assert!(!failure.detail().is_empty());
1198        assert!(failure.detail().len() <= 512);
1199    }
1200
1201    #[test]
1202    fn checksum_only_state_is_explicit_in_docs() {
1203        // Authenticity support does not exist: every commit requires a verified
1204        // SHA-256 digest, and there is no public authenticity type. This test
1205        // guards the API surface by asserting the commit path rejects missing
1206        // integrity evidence (see unverified_integrity_none_cannot_commit) and
1207        // documents the checksum-only contract here.
1208        let desc = env!("CARGO_PKG_DESCRIPTION");
1209        let _ = desc;
1210    }
1211
1212    #[allow(dead_code)]
1213    fn _unused() {
1214        let _m: HashMap<MemberId, [u8; 32]> = HashMap::new();
1215    }
1216}