Skip to main content

eggup_core/
domain.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::fs;
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/// An explicit installation root and a coherent artifact set ready for private preparation.
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct InstallPlan {
407    product: ProductId,
408    release: ReleaseId,
409    installation_root: PathBuf,
410    artifacts: ArtifactSet,
411}
412
413impl InstallPlan {
414    /// Validates an installation root, destination containment, sources, and member uniqueness.
415    pub fn new(
416        product: ProductId,
417        release: ReleaseId,
418        installation_root: impl Into<PathBuf>,
419        artifacts: ArtifactSet,
420    ) -> Result<Self> {
421        let installation_root = installation_root.into();
422        validate_installation_root(&installation_root)?;
423        let canonical_root = fs::canonicalize(&installation_root)
424            .map_err(|source| Error::io("canonicalizing installation root", source))?;
425        let mut destinations = HashSet::new();
426        for member in artifacts.as_slice() {
427            if !destinations.insert(member.destination.clone()) {
428                return Err(Error::invalid(format!(
429                    "duplicate destination: {}",
430                    member.destination.display()
431                )));
432            }
433            validate_source(member, &canonical_root, &installation_root)?;
434        }
435        Ok(Self {
436            product,
437            release,
438            installation_root,
439            artifacts,
440        })
441    }
442
443    /// Returns the consumer-owned product identity.
444    pub fn product(&self) -> &ProductId {
445        &self.product
446    }
447
448    /// Returns the consumer-owned release identity.
449    pub fn release(&self) -> &ReleaseId {
450        &self.release
451    }
452
453    /// Returns the exact installation root used for destination resolution.
454    pub fn installation_root(&self) -> &Path {
455        &self.installation_root
456    }
457
458    /// Returns the validated artifact set.
459    pub fn artifacts(&self) -> &ArtifactSet {
460        &self.artifacts
461    }
462
463    /// Resolves a member destination beneath the exact installation root.
464    pub fn destination(&self, member: &MemberId) -> Result<PathBuf> {
465        let artifact = self
466            .artifacts
467            .iter()
468            .find(|candidate| candidate.id == *member)
469            .ok_or_else(|| Error::UnknownMember(member.to_string()))?;
470        Ok(self.installation_root.join(&artifact.destination))
471    }
472
473    /// Copies every member into private owned stage state without mutating a destination.
474    pub fn prepare(self) -> Result<PreparedTransaction> {
475        Stage::prepare(self)
476    }
477
478    #[cfg(test)]
479    pub(crate) fn prepare_with_failure(
480        self,
481        point: crate::test_support::FailurePoint,
482    ) -> Result<PreparedTransaction> {
483        Stage::prepare_with_failure(self, point)
484    }
485}
486
487fn validate_installation_root(root: &Path) -> Result<()> {
488    if !root.is_absolute() {
489        return Err(Error::invalid("installation root must be absolute"));
490    }
491    let metadata = fs::symlink_metadata(root)
492        .map_err(|source| Error::io("reading installation root", source))?;
493    if !metadata.is_dir() {
494        return Err(Error::invalid("installation root must be a directory"));
495    }
496    Ok(())
497}
498
499fn validate_source(
500    member: &ArtifactMember,
501    canonical_root: &Path,
502    installation_root: &Path,
503) -> Result<()> {
504    if !member.source.is_absolute() {
505        return Err(Error::invalid(format!(
506            "source for {} must be absolute",
507            member.id
508        )));
509    }
510    let metadata = fs::symlink_metadata(&member.source)
511        .map_err(|source| Error::io("reading artifact source", source))?;
512    if member.file_kind == FileKind::Regular && !metadata.is_file() {
513        return Err(Error::invalid(format!(
514            "source for {} is not a regular file",
515            member.id
516        )));
517    }
518    let canonical_source = fs::canonicalize(&member.source)
519        .map_err(|source| Error::io("canonicalizing artifact source", source))?;
520    if canonical_source.starts_with(canonical_root) {
521        return Err(Error::invalid(format!(
522            "source for {} is inside the installation root",
523            member.id
524        )));
525    }
526    let destination = installation_root.join(&member.destination);
527    if canonical_source == destination {
528        return Err(Error::invalid(format!(
529            "source for {} aliases its destination",
530            member.id
531        )));
532    }
533    Ok(())
534}
535
536pub(crate) fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
537    if path.as_os_str().is_empty() || path.is_absolute() {
538        return Err(Error::invalid(
539            "destination must be a non-empty relative path",
540        ));
541    }
542    let mut normalized = PathBuf::new();
543    for component in path.components() {
544        match component {
545            Component::Normal(value) => {
546                if value.to_string_lossy().chars().any(char::is_control) {
547                    return Err(Error::invalid("destination contains control characters"));
548                }
549                normalized.push(value);
550            }
551            Component::CurDir => {}
552            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
553                return Err(Error::invalid("destination escapes the installation root"));
554            }
555        }
556    }
557    if normalized.as_os_str().is_empty() {
558        return Err(Error::invalid("destination must name a file"));
559    }
560    Ok(normalized)
561}