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