Skip to main content

ic_build/artifacts/staging/
mod.rs

1//! Invocation-owned artifact staging without cache publication or reuse.
2//!
3//! Callers own the parent directory, recipes and publication boundary. This
4//! module allocates only its own child and removes it after the last owner is
5//! dropped. Explicit retention transfers cleanup to the caller.
6//! It never prunes existing artifacts or publishes a product release.
7
8use std::{
9    fmt, fs, io,
10    num::NonZeroUsize,
11    path::{Path, PathBuf},
12};
13
14use ic_host_artifacts::artifact::{ArtifactError, ArtifactIdentity, CopyError, copy_reader};
15use ic_host_fs::{
16    directory::create_unique_directory,
17    durable::{NamedWriteError, PublicationMode, WriteOptions, write_with},
18    owned_directory::OwnedDirectory,
19    read::open_regular_file,
20};
21
22mod produce;
23pub use produce::produce_and_retain_artifact;
24
25fn staging_directory(parent: &Path) -> io::Result<OwnedDirectory> {
26    create_unique_directory(
27        parent,
28        ".ic-build-artifact-stage",
29        0o777,
30        NonZeroUsize::new(64),
31    )
32    .map(OwnedDirectory::new)
33}
34
35/// Owned copy of one completed producer output, independent of later source replacement.
36///
37/// Keep this record alive through every use of [`Self::path`]. The copy is
38/// temporary rather than a reusable cache entry; ordinary Drop removes it.
39/// [`Self::retain`] explicitly transfers cleanup to the caller.
40#[derive(Debug)]
41pub struct CapturedArtifact {
42    staging: OwnedDirectory,
43    path: PathBuf,
44}
45
46/// Finalization failed after capture custody was transferred to the caller.
47///
48/// The exact bytes remain at [`Self::retained_path`]. If rename succeeded but
49/// cleanup failed, that path is the destination; otherwise it is the staging file.
50#[derive(Debug)]
51pub struct CaptureRetentionError {
52    /// Filesystem operation that failed.
53    pub source: io::Error,
54    /// Actual retained file path after the failed operation.
55    pub retained_path: PathBuf,
56    /// Owned staging child still requiring caller disposition.
57    pub staging_directory: PathBuf,
58}
59
60impl fmt::Display for CaptureRetentionError {
61    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62        write!(
63            formatter,
64            "capture retained at {} (staging {}): {}",
65            self.retained_path.display(),
66            self.staging_directory.display(),
67            self.source
68        )
69    }
70}
71
72impl std::error::Error for CaptureRetentionError {
73    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
74        Some(&self.source)
75    }
76}
77
78impl CapturedArtifact {
79    /// Capture an output synchronously before another producer can overwrite it.
80    ///
81    /// The caller keeps the source stable during capture and selects the maximum
82    /// readable byte count. Read/publication failures retain their typed causes
83    /// inside the returned I/O error. Existing public destinations are untouched.
84    /// Bytes stream through bounded scratch rather than a complete in-memory copy.
85    ///
86    /// # Errors
87    /// Returns the source read/byte-limit, staging allocation or durable-write
88    /// error. An unsuccessful capture performs best-effort staging cleanup.
89    pub fn capture(source: &Path, parent: &Path, maximum_bytes: usize) -> io::Result<Self> {
90        Self::capture_with_identity(source, parent, maximum_bytes).map(|(captured, _)| captured)
91    }
92
93    // Identity describes bytes accepted by the completed, synchronized copy.
94    // It is not a live observation after callers modify the captured pathname.
95    pub(super) fn capture_with_identity(
96        source: &Path,
97        parent: &Path,
98        maximum_bytes: usize,
99    ) -> io::Result<(Self, ArtifactIdentity)> {
100        let source = open_regular_file(source).map_err(io::Error::from)?;
101        let limit = maximum_bytes as u64;
102        if source.metadata()?.len() > limit {
103            return Err(ArtifactError::LimitExceeded { limit }.into());
104        }
105        let staging = staging_directory(parent)?;
106        let path = staging.path().join("input.artifact");
107        let identity = write_with(
108            &path,
109            WriteOptions {
110                mode: PublicationMode::Replace,
111                permissions: 0o666,
112            },
113            |file| copy_reader(source, file, limit),
114        )
115        .map_err(|error| match error {
116            NamedWriteError::Producer {
117                source: CopyError::Input(source),
118                cleanup_error: None,
119            } => io::Error::from(source),
120            other => io::Error::other(other),
121        })?;
122        Ok((Self { staging, path }, identity))
123    }
124
125    /// Borrow the exact captured bytes while this record owns their lifetime.
126    #[must_use]
127    pub fn path(&self) -> &Path {
128        &self.path
129    }
130
131    /// Transfer the captured file and its staging child to caller-owned retention.
132    ///
133    /// Unlike close or Drop, this keeps the exact captured bytes after this owner
134    /// is consumed. The caller owns later cleanup of the returned path's parent.
135    /// Keep custody of those paths; this grants no cache or release admission.
136    #[must_use]
137    pub fn retain(mut self) -> PathBuf {
138        self.staging.disarm();
139        self.path
140    }
141
142    /// Retain the exact capture at a caller-selected destination.
143    ///
144    /// Uses [`fs::rename`] semantics, including replacement of an existing
145    /// destination when supported. The caller owns destination admission and
146    /// later custody. Only the empty owned staging child is removed on success.
147    ///
148    /// # Errors
149    /// Rename or cleanup failure reports the actual retained bytes and staging
150    /// child. Neither failure deletes evidence or silently retries publication.
151    pub fn retain_to(self, destination: &Path) -> Result<PathBuf, CaptureRetentionError> {
152        let staging = self.staging.path().to_owned();
153        let retained = self.retain();
154        fs::rename(&retained, destination).map_err(|source| CaptureRetentionError {
155            source,
156            retained_path: retained.clone(),
157            staging_directory: staging.clone(),
158        })?;
159        fs::remove_dir(&staging).map_err(|source| CaptureRetentionError {
160            source,
161            retained_path: destination.to_owned(),
162            staging_directory: staging,
163        })?;
164        Ok(destination.to_owned())
165    }
166
167    /// Remove the captured output and its owned staging child, reporting cleanup failure.
168    ///
169    /// # Errors
170    /// Returns the filesystem error if removing the owned child fails.
171    pub fn close(self) -> io::Result<()> {
172        self.staging.cleanup()
173    }
174}
175
176#[cfg(test)]
177mod tests;