eggup_core/domain.rs
1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::fs::{self, File};
4use std::path::{Component, Path, PathBuf};
5
6use crate::current_exe::CurrentExecutable;
7use crate::error::{Error, Result};
8use crate::stage::{PreparedTransaction, Stage};
9
10/// A validated opaque product identifier owned by the consumer.
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct ProductId(String);
13
14impl ProductId {
15 /// Creates a product identifier after rejecting empty or unsafe control-character input.
16 ///
17 /// # Errors
18 ///
19 /// Returns [`Error::InvalidInput`] when the identifier is empty or
20 /// contains a control character.
21 pub fn new(value: impl Into<String>) -> Result<Self> {
22 Ok(Self(validate_identifier("product", value.into())?))
23 }
24
25 /// Returns the identifier's stable string representation.
26 pub fn as_str(&self) -> &str {
27 &self.0
28 }
29}
30
31impl fmt::Display for ProductId {
32 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33 formatter.write_str(&self.0)
34 }
35}
36
37/// A validated opaque release identifier owned by the consumer.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct ReleaseId(String);
40
41impl ReleaseId {
42 /// Creates a release identifier after rejecting empty or unsafe control-character input.
43 ///
44 /// # Errors
45 ///
46 /// Returns [`Error::InvalidInput`] when the identifier is empty or
47 /// contains a control character.
48 pub fn new(value: impl Into<String>) -> Result<Self> {
49 Ok(Self(validate_identifier("release", value.into())?))
50 }
51
52 /// Returns the identifier's stable string representation.
53 pub fn as_str(&self) -> &str {
54 &self.0
55 }
56}
57
58impl fmt::Display for ReleaseId {
59 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60 formatter.write_str(&self.0)
61 }
62}
63
64/// A stable identity for one member of a deployment bundle.
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub struct MemberId(String);
67
68impl MemberId {
69 /// Creates a member identifier after rejecting empty or unsafe control-character input.
70 ///
71 /// # Errors
72 ///
73 /// Returns [`Error::InvalidInput`] when the identifier is empty or
74 /// contains a control character.
75 pub fn new(value: impl Into<String>) -> Result<Self> {
76 Ok(Self(validate_identifier("member", value.into())?))
77 }
78
79 /// Returns the identifier's stable string representation.
80 pub fn as_str(&self) -> &str {
81 &self.0
82 }
83}
84
85impl fmt::Display for MemberId {
86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87 formatter.write_str(&self.0)
88 }
89}
90
91fn validate_identifier(kind: &str, value: String) -> Result<String> {
92 if value.is_empty() || value.chars().any(char::is_control) {
93 return Err(Error::invalid(format!(
94 "{kind} identifier is empty or contains control characters"
95 )));
96 }
97 Ok(value)
98}
99
100/// The stable member identity used by [`InstallPlan::for_current_executable`].
101const CURRENT_EXECUTABLE_MEMBER: &str = "executable";
102
103/// The file kind expected for an artifact member.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum FileKind {
107 /// A regular file; directories, links, devices, sockets, and FIFOs are rejected.
108 Regular,
109}
110
111/// The permission intent to apply to a staged member.
112///
113/// Staged output is always owner-private (`0600`, or `0700` when executable).
114/// `Preserve` maps an executable source to `0700` and anything else to
115/// `0600`; broad source modes are never inherited.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum PermissionsIntent {
119 /// Retain executable intent privately where the platform exposes it.
120 Preserve,
121 /// Mark the staged member executable where the platform exposes executable bits.
122 Executable,
123}
124
125/// The canonical ownership classification for a live destination.
126///
127/// `Owned` is never inferred from an [`InstallPlan`]. It is returned only by a
128/// consumer-supplied [`OwnershipVerifier`] that proves the destination belongs
129/// to the intended deployment. `Foreign` and `Unknown` always fail closed.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Ownership {
132 /// No filesystem object exists at the destination.
133 Absent,
134 /// The destination provably belongs to this deployment and may be replaced.
135 Owned,
136 /// The destination belongs to another deployment and must not be mutated.
137 Foreign,
138 /// Ownership cannot be proven; mutation is denied.
139 Unknown,
140}
141
142/// A declared integrity requirement. Computation is provided by the verification layer.
143///
144/// Only `Sha256` members are commit-capable. `None` is visible during
145/// preparation but can never reach candidate execution or commit. Checksum
146/// evidence only; not an authenticity or signature claim.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148#[non_exhaustive]
149pub enum IntegrityRequirement {
150 /// No integrity evidence is attached at this stage.
151 None,
152 /// The staged bytes must later match this SHA-256 digest.
153 Sha256([u8; 32]),
154}
155
156/// Consumer-supplied proof that a live destination belongs to this deployment.
157///
158/// The verifier is the *only* way a destination can be classified as
159/// [`Ownership::Owned`]: core never infers ownership from an
160/// [`InstallPlan`], from a member's presence, or from anything else. It is
161/// also infallible by design — [`verify`](OwnershipVerifier::verify) returns
162/// an [`Ownership`] value, never a `Result`, so a verifier cannot fail the
163/// transaction by erroring; it reports trouble as [`Ownership::Unknown`], and
164/// `Unknown` denies mutation.
165///
166/// Implementations MUST be synchronous, deterministic for a fixed filesystem
167/// state, free of application-specific version types, and treat verifier
168/// errors or ambiguity as [`Ownership::Unknown`] or a hard failure — never
169/// [`Ownership::Owned`].
170///
171/// The verifier runs again immediately before destructive mutation while the
172/// [`crate::MutationLock`] is held. A change between preflight and locked
173/// classification fails the transaction.
174pub trait OwnershipVerifier {
175 /// Classifies one live destination for one artifact member.
176 fn verify(&self, member: &MemberId, destination: &Path) -> Ownership;
177}
178
179/// Authorization to create a destination that is currently [`Ownership::Absent`].
180///
181/// This is distinct from authorization to replace an [`Ownership::Owned`]
182/// destination. Replacement of `Owned` never requires this flag; creation of
183/// `Absent` always does.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum AbsentPolicy {
186 /// Installing into an absent destination is permitted.
187 AllowCreate,
188 /// Only replacement of an already-`Owned` destination is permitted.
189 DenyCreate,
190}
191
192/// Commit-time ownership contract: who proves ownership and whether absent
193/// destinations may be created.
194#[derive(Clone, Copy)]
195pub struct CommitOwnership<'a> {
196 /// Consumer verifier consulted under lock before any live mutation.
197 pub verifier: &'a dyn OwnershipVerifier,
198 /// Whether `Absent` destinations may be created.
199 pub absent: AbsentPolicy,
200}
201
202impl<'a> CommitOwnership<'a> {
203 /// Creates an ownership contract from a verifier and an absent policy.
204 pub fn new(verifier: &'a dyn OwnershipVerifier, absent: AbsentPolicy) -> Self {
205 Self { verifier, absent }
206 }
207}
208
209impl std::fmt::Debug for CommitOwnership<'_> {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.debug_struct("CommitOwnership")
212 .field("absent", &self.absent)
213 .finish_non_exhaustive()
214 }
215}
216
217/// A verifier that permits creation only: `Absent` when missing, `Foreign`
218/// when anything exists. Useful for first-install flows that must never
219/// replace an existing file.
220#[derive(Debug, Clone, Copy, Default)]
221pub struct AbsentOnlyVerifier;
222
223impl OwnershipVerifier for AbsentOnlyVerifier {
224 fn verify(&self, _member: &MemberId, destination: &Path) -> Ownership {
225 match std::fs::symlink_metadata(destination) {
226 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
227 Ok(_) => Ownership::Foreign,
228 Err(_) => Ownership::Unknown,
229 }
230 }
231}
232
233/// A verifier that treats an existing regular file as owned.
234///
235/// This helper exists only for deterministic tests and examples that do not
236/// model real deployment identity. Production consumers SHOULD use
237/// [`ExactDigestVerifier`] or a deployment-specific identity check instead of
238/// this permissive mapping.
239#[derive(Debug, Clone, Copy, Default)]
240pub struct ExistingAsOwnedVerifier;
241
242impl OwnershipVerifier for ExistingAsOwnedVerifier {
243 fn verify(&self, _member: &MemberId, destination: &Path) -> Ownership {
244 match std::fs::symlink_metadata(destination) {
245 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
246 Ok(m) if m.is_file() => Ownership::Owned,
247 Ok(_) => Ownership::Foreign,
248 Err(_) => Ownership::Unknown,
249 }
250 }
251}
252
253/// A verifier that proves ownership by exact prior SHA-256 content.
254///
255/// Returns `Absent` when nothing exists, `Owned` when the live file hashes to
256/// the expected digest for that member, `Foreign` when content differs, and
257/// `Unknown` when the destination cannot be read unambiguously.
258#[derive(Debug, Clone)]
259pub struct ExactDigestVerifier {
260 expected: std::collections::HashMap<MemberId, [u8; 32]>,
261}
262
263impl ExactDigestVerifier {
264 /// Creates a verifier from `(member, expected_live_digest)` pairs.
265 pub fn new(expected: Vec<(MemberId, [u8; 32])>) -> Self {
266 Self {
267 expected: expected.into_iter().collect(),
268 }
269 }
270}
271
272impl OwnershipVerifier for ExactDigestVerifier {
273 fn verify(&self, member: &MemberId, destination: &Path) -> Ownership {
274 let expected = match self.expected.get(member) {
275 Some(d) => d,
276 None => return Ownership::Unknown,
277 };
278 match std::fs::symlink_metadata(destination) {
279 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
280 Err(_) => Ownership::Unknown,
281 Ok(m) => {
282 if !m.is_file() {
283 return Ownership::Foreign;
284 }
285 #[cfg(unix)]
286 {
287 use std::os::unix::fs::MetadataExt;
288 if m.nlink() != 1 {
289 return Ownership::Foreign;
290 }
291 }
292 match crate::integrity::hash_file(destination) {
293 Ok(d) if &d == expected => Ownership::Owned,
294 Ok(_) => Ownership::Foreign,
295 Err(_) => Ownership::Unknown,
296 }
297 }
298 }
299 }
300}
301
302/// One explicitly acquired local artifact and its intended destination.
303///
304/// The member declares where bytes come from and where they should be
305/// installed. It never authorizes replacement: destination ownership is proven
306/// separately at commit time by an [`OwnershipVerifier`].
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct ArtifactMember {
309 id: MemberId,
310 source: PathBuf,
311 destination: PathBuf,
312 file_kind: FileKind,
313 permissions: PermissionsIntent,
314 integrity: IntegrityRequirement,
315}
316
317impl ArtifactMember {
318 /// Creates a regular-file member from an acquired source and relative destination.
319 ///
320 /// # Errors
321 ///
322 /// Returns [`Error::InvalidInput`] when the destination is empty, is
323 /// absolute, contains a control character, or escapes the installation
324 /// root.
325 pub fn new(
326 id: MemberId,
327 source: impl Into<PathBuf>,
328 destination: impl Into<PathBuf>,
329 ) -> Result<Self> {
330 let destination = normalize_relative_path(&destination.into())?;
331 Ok(Self {
332 id,
333 source: source.into(),
334 destination,
335 file_kind: FileKind::Regular,
336 permissions: PermissionsIntent::Preserve,
337 integrity: IntegrityRequirement::None,
338 })
339 }
340
341 /// Sets the intended staged-file permissions.
342 pub fn with_permissions(mut self, permissions: PermissionsIntent) -> Self {
343 self.permissions = permissions;
344 self
345 }
346
347 /// Attaches a declared integrity requirement without computing it.
348 ///
349 /// Only `Sha256` members can be committed. `None` is permitted during
350 /// preparation so callers can observe the missing-evidence failure mode,
351 /// but verification and commit reject it.
352 pub fn with_integrity(mut self, integrity: IntegrityRequirement) -> Self {
353 self.integrity = integrity;
354 self
355 }
356
357 /// Returns the stable member identity.
358 pub fn id(&self) -> &MemberId {
359 &self.id
360 }
361
362 /// Returns the acquired local source path.
363 pub fn source(&self) -> &Path {
364 &self.source
365 }
366
367 /// Returns the normalized destination path relative to the installation root.
368 pub fn destination(&self) -> &Path {
369 &self.destination
370 }
371
372 /// Returns the expected file kind.
373 pub fn file_kind(&self) -> FileKind {
374 self.file_kind
375 }
376
377 /// Returns the permission intent.
378 pub fn permissions(&self) -> PermissionsIntent {
379 self.permissions
380 }
381
382 /// Returns the declared integrity requirement.
383 pub fn integrity(&self) -> IntegrityRequirement {
384 self.integrity
385 }
386}
387
388/// A non-empty set of artifact members with unique member identities.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct ArtifactSet(Vec<ArtifactMember>);
391
392impl ArtifactSet {
393 /// Creates a set and rejects an empty input or duplicate member identity.
394 ///
395 /// # Errors
396 ///
397 /// Returns [`Error::InvalidInput`] when the set is empty or contains two
398 /// members with the same identity.
399 pub fn new(members: Vec<ArtifactMember>) -> Result<Self> {
400 if members.is_empty() {
401 return Err(Error::invalid(
402 "artifact set must contain at least one member",
403 ));
404 }
405 let mut ids = HashSet::new();
406 for member in &members {
407 if !ids.insert(member.id.clone()) {
408 return Err(Error::invalid(format!(
409 "duplicate artifact member: {}",
410 member.id
411 )));
412 }
413 }
414 Ok(Self(members))
415 }
416
417 /// Creates a one-member set.
418 ///
419 /// # Errors
420 ///
421 /// Returns [`Error::InvalidInput`] when the member's destination is
422 /// empty, is absolute, contains a control character, or escapes the
423 /// installation root.
424 pub fn single(member: ArtifactMember) -> Result<Self> {
425 Self::new(vec![member])
426 }
427
428 /// Iterates over members in caller-provided order.
429 pub fn iter(&self) -> impl ExactSizeIterator<Item = &ArtifactMember> {
430 self.0.iter()
431 }
432
433 /// Returns the number of members in the set.
434 pub fn len(&self) -> usize {
435 self.0.len()
436 }
437
438 /// Returns whether the set contains no members. Always false for a valid set.
439 pub fn is_empty(&self) -> bool {
440 self.0.is_empty()
441 }
442
443 pub(crate) fn as_slice(&self) -> &[ArtifactMember] {
444 &self.0
445 }
446}
447
448/// Already-open member objects staged without pathname re-resolution.
449///
450/// A bound source proves which bytes to stage by object identity: the moved
451/// open [`File`] is the same object whose bytes were acquired and verified,
452/// so no directory-entry replacement after the handoff boundary can redirect
453/// staged bytes. Handles are single-owner and move-only. [`File`] cursors are
454/// shared across clones, so this type is deliberately not [`Clone`]: move the
455/// handle into [`InstallPlan::prepare_with_bound_sources`], which rewinds it
456/// to byte zero before staging. Do not [`try_clone`](File::try_clone) a bound
457/// handle and assume an independent offset.
458///
459/// The recorded [`ArtifactMember::source`] path of a bound member is advisory
460/// diagnostics only once a handle is supplied for its identity.
461#[derive(Debug, Default)]
462pub struct BoundSources {
463 handles: HashMap<MemberId, File>,
464}
465
466impl BoundSources {
467 /// Creates an empty bound-source map, equivalent to no bound members.
468 pub fn new() -> Self {
469 Self {
470 handles: HashMap::new(),
471 }
472 }
473
474 /// Registers the already-open object for `member`, returning any
475 /// previously registered handle for the same identity.
476 pub fn insert(&mut self, member: MemberId, handle: File) -> Option<File> {
477 self.handles.insert(member, handle)
478 }
479
480 pub(crate) fn is_empty(&self) -> bool {
481 self.handles.is_empty()
482 }
483
484 pub(crate) fn take(&mut self, member: &MemberId) -> Option<File> {
485 self.handles.remove(member)
486 }
487}
488
489/// Where transaction-owned staging state is created for a plan.
490///
491/// The distinction is an authority boundary, not a convenience: staging
492/// placement decides which directory a caller must be able to write in.
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494#[non_exhaustive]
495pub enum StagePlacement {
496 /// A private, unpredictable directory beside the installation root.
497 ///
498 /// Requires write authority in the installation root's **parent**.
499 SiblingOfInstallationRoot,
500 /// A private, unpredictable directory inside the installation root itself.
501 ///
502 /// Requires write authority in the installation root only. This is what
503 /// [`InstallPlan::for_current_executable`] uses so that replacing the
504 /// running executable never needs authority above its own directory.
505 InsideInstallationRoot,
506}
507
508/// An explicit installation root and a coherent artifact set ready for private preparation.
509#[derive(Debug, Clone, PartialEq, Eq)]
510pub struct InstallPlan {
511 product: ProductId,
512 release: ReleaseId,
513 installation_root: PathBuf,
514 artifacts: ArtifactSet,
515 placement: StagePlacement,
516 current_executable: Option<CurrentExecutable>,
517}
518
519impl InstallPlan {
520 /// Validates an installation root, destination containment, sources, and member uniqueness.
521 ///
522 /// # Errors
523 ///
524 /// Returns [`Error::InvalidInput`] when the installation root is not an
525 /// absolute directory, when two members share a destination, or when a
526 /// member's source is not an absolute regular file, lies inside the
527 /// installation root, or aliases its own destination; and [`Error::Io`]
528 /// when the installation root or a member source cannot be read or
529 /// canonicalized.
530 pub fn new(
531 product: ProductId,
532 release: ReleaseId,
533 installation_root: impl Into<PathBuf>,
534 artifacts: ArtifactSet,
535 ) -> Result<Self> {
536 Self::build(
537 product,
538 release,
539 installation_root.into(),
540 artifacts,
541 StagePlacement::SiblingOfInstallationRoot,
542 None,
543 )
544 }
545
546 fn build(
547 product: ProductId,
548 release: ReleaseId,
549 installation_root: PathBuf,
550 artifacts: ArtifactSet,
551 placement: StagePlacement,
552 current_executable: Option<CurrentExecutable>,
553 ) -> Result<Self> {
554 validate_installation_root(&installation_root)?;
555 let canonical_root = fs::canonicalize(&installation_root)
556 .map_err(|source| Error::io("canonicalizing installation root", source))?;
557 let mut destinations = HashSet::new();
558 for member in artifacts.as_slice() {
559 if !destinations.insert(member.destination.clone()) {
560 return Err(Error::invalid(format!(
561 "duplicate destination: {}",
562 member.destination.display()
563 )));
564 }
565 validate_source(member, &canonical_root, &installation_root)?;
566 }
567 Ok(Self {
568 product,
569 release,
570 installation_root,
571 artifacts,
572 placement,
573 current_executable,
574 })
575 }
576
577 /// Builds a one-member plan that replaces the executable currently running.
578 ///
579 /// This is an ordinary [`InstallPlan`], not a second transaction model: it
580 /// advances through exactly the same prepared/verified/validated/receipt
581 /// path as any other one-member plan, and its terminal contract is the same
582 /// [`crate::TransactionReceipt`]. The only differences are authority and
583 /// identity:
584 ///
585 /// - The installation root is the executable's own directory, and staging is
586 /// [`StagePlacement::InsideInstallationRoot`], so the transaction needs
587 /// write authority in that directory and nowhere above it.
588 /// - The single destination is the canonical live image, so an invocation
589 /// symlink is followed to its real target instead of being overwritten.
590 /// - The bound executable identity is re-proved immediately before mutation.
591 ///
592 /// The candidate must be a local path outside the executable's directory,
593 /// exactly as for any other member. Nothing here selects a release, infers a
594 /// version, or reads product identity from the executable: `product`,
595 /// `release`, and `integrity` are all caller-supplied.
596 ///
597 /// # Errors
598 ///
599 /// Returns [`Error::InvalidInput`] when the installation root is not an
600 /// absolute directory, when the candidate is not an absolute regular file,
601 /// when the candidate lies inside the executable's own directory, or when
602 /// the candidate aliases its destination; and [`Error::Io`] when the
603 /// installation root or the candidate cannot be read or canonicalized.
604 pub fn for_current_executable(
605 product: ProductId,
606 release: ReleaseId,
607 candidate: impl Into<PathBuf>,
608 current: &CurrentExecutable,
609 integrity: IntegrityRequirement,
610 ) -> Result<Self> {
611 let member = ArtifactMember::new(
612 MemberId::new(CURRENT_EXECUTABLE_MEMBER)?,
613 candidate,
614 current.destination_name(),
615 )?
616 .with_permissions(PermissionsIntent::Executable)
617 .with_integrity(integrity);
618 let artifacts = ArtifactSet::single(member)?;
619 Self::build(
620 product,
621 release,
622 current.installation_root().to_path_buf(),
623 artifacts,
624 StagePlacement::InsideInstallationRoot,
625 Some(current.clone()),
626 )
627 }
628
629 /// Returns where this plan creates its private staging state.
630 pub fn stage_placement(&self) -> StagePlacement {
631 self.placement
632 }
633
634 /// Returns the bound current executable when this plan replaces one.
635 pub fn current_executable(&self) -> Option<&CurrentExecutable> {
636 self.current_executable.as_ref()
637 }
638
639 pub(crate) fn revalidate_current_executable(&self) -> Result<()> {
640 match &self.current_executable {
641 Some(current) => current.revalidate(),
642 None => Ok(()),
643 }
644 }
645
646 /// Returns the consumer-owned product identity.
647 pub fn product(&self) -> &ProductId {
648 &self.product
649 }
650
651 /// Returns the consumer-owned release identity.
652 pub fn release(&self) -> &ReleaseId {
653 &self.release
654 }
655
656 /// Returns the exact installation root used for destination resolution.
657 pub fn installation_root(&self) -> &Path {
658 &self.installation_root
659 }
660
661 /// Returns the validated artifact set.
662 pub fn artifacts(&self) -> &ArtifactSet {
663 &self.artifacts
664 }
665
666 /// Resolves a member destination beneath the exact installation root.
667 ///
668 /// # Errors
669 ///
670 /// Returns [`Error::UnknownMember`] when `member` is not part of this
671 /// plan's artifact set.
672 pub fn destination(&self, member: &MemberId) -> Result<PathBuf> {
673 let artifact = self
674 .artifacts
675 .iter()
676 .find(|candidate| candidate.id == *member)
677 .ok_or_else(|| Error::UnknownMember(member.to_string()))?;
678 Ok(self.installation_root.join(&artifact.destination))
679 }
680
681 /// Copies every member into private owned stage state without mutating a destination.
682 ///
683 /// # Errors
684 ///
685 /// Returns [`Error::Io`] when the private stage cannot be created or a
686 /// member cannot be copied into it. Preparation never mutates a live
687 /// destination and never creates a live destination parent: a missing
688 /// parent is a commit-time failure, not a preparation failure.
689 pub fn prepare(self) -> Result<PreparedTransaction> {
690 Stage::prepare(self)
691 }
692
693 /// Copies every member into private owned stage state, sourcing bound
694 /// members from their already-open objects without any pathname lookup.
695 ///
696 /// Each member identity present in `bound` is staged by reading the moved
697 /// open object from byte zero; its recorded source path is treated as
698 /// advisory diagnostics only and is never opened, even if it now names
699 /// foreign state. Members absent from `bound` stage from their recorded
700 /// source paths exactly as in [`prepare`](Self::prepare).
701 ///
702 /// Every bound identity must name a member of this plan: leftover handles
703 /// fail closed and no staged byte is ever obtained by reopening a member
704 /// name as fallback. A failed bound stage copy never falls back to the
705 /// recorded path.
706 ///
707 /// # Errors
708 ///
709 /// Returns [`Error::InvalidInput`] when a bound identity does not name a
710 /// member of this plan, and [`Error::Io`] when the private stage cannot
711 /// be created or a member cannot be copied into it.
712 pub fn prepare_with_bound_sources(self, bound: BoundSources) -> Result<PreparedTransaction> {
713 Stage::prepare_bound(self, bound)
714 }
715
716 #[cfg(test)]
717 pub(crate) fn prepare_with_failure(
718 self,
719 point: crate::test_support::FailurePoint,
720 ) -> Result<PreparedTransaction> {
721 Stage::prepare_with_failure(self, point)
722 }
723}
724
725fn validate_installation_root(root: &Path) -> Result<()> {
726 if !root.is_absolute() {
727 return Err(Error::invalid("installation root must be absolute"));
728 }
729 let metadata = fs::symlink_metadata(root)
730 .map_err(|source| Error::io("reading installation root", source))?;
731 if !metadata.is_dir() {
732 return Err(Error::invalid("installation root must be a directory"));
733 }
734 Ok(())
735}
736
737fn validate_source(
738 member: &ArtifactMember,
739 canonical_root: &Path,
740 installation_root: &Path,
741) -> Result<()> {
742 if !member.source.is_absolute() {
743 return Err(Error::invalid(format!(
744 "source for {} must be absolute",
745 member.id
746 )));
747 }
748 let metadata = fs::symlink_metadata(&member.source)
749 .map_err(|source| Error::io("reading artifact source", source))?;
750 if member.file_kind == FileKind::Regular && !metadata.is_file() {
751 return Err(Error::invalid(format!(
752 "source for {} is not a regular file",
753 member.id
754 )));
755 }
756 let canonical_source = fs::canonicalize(&member.source)
757 .map_err(|source| Error::io("canonicalizing artifact source", source))?;
758 if canonical_source.starts_with(canonical_root) {
759 return Err(Error::invalid(format!(
760 "source for {} is inside the installation root",
761 member.id
762 )));
763 }
764 let destination = installation_root.join(&member.destination);
765 if canonical_source == destination {
766 return Err(Error::invalid(format!(
767 "source for {} aliases its destination",
768 member.id
769 )));
770 }
771 Ok(())
772}
773
774pub(crate) fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
775 if path.as_os_str().is_empty() || path.is_absolute() {
776 return Err(Error::invalid(
777 "destination must be a non-empty relative path",
778 ));
779 }
780 let mut normalized = PathBuf::new();
781 for component in path.components() {
782 match component {
783 Component::Normal(value) => {
784 if value.to_string_lossy().chars().any(char::is_control) {
785 return Err(Error::invalid("destination contains control characters"));
786 }
787 normalized.push(value);
788 }
789 Component::CurDir => {}
790 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
791 return Err(Error::invalid("destination escapes the installation root"));
792 }
793 }
794 }
795 if normalized.as_os_str().is_empty() {
796 return Err(Error::invalid("destination must name a file"));
797 }
798 Ok(normalized)
799}