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 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 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 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 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 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 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 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 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}