Skip to main content

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}