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