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