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    path::{Path, PathBuf},
11    sync::atomic::{AtomicU64, Ordering},
12};
13
14use ic_host_fs::{durable::write_bytes, read::read_file};
15
16use super::cache_fs::remove_path_if_present;
17
18const STAGE_ATTEMPTS: usize = 64;
19static STAGE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
20
21#[derive(Debug)]
22pub(super) struct OwnedDirectory {
23    path: PathBuf,
24    armed: bool,
25}
26
27impl OwnedDirectory {
28    pub(super) const fn new(path: PathBuf) -> Self {
29        Self { path, armed: true }
30    }
31
32    pub(super) fn path(&self) -> &Path {
33        &self.path
34    }
35
36    pub(super) const fn is_armed(&self) -> bool {
37        self.armed
38    }
39
40    pub(super) const fn disarm(&mut self) {
41        self.armed = false;
42    }
43
44    pub(super) fn preserve(mut self) {
45        self.disarm();
46    }
47
48    pub(super) fn try_cleanup(&mut self) -> io::Result<()> {
49        remove_path_if_present(&self.path)?;
50        self.disarm();
51        Ok(())
52    }
53
54    pub(super) fn cleanup(mut self) -> io::Result<()> {
55        self.try_cleanup()
56    }
57}
58
59impl Drop for OwnedDirectory {
60    fn drop(&mut self) {
61        if self.armed {
62            let _ = remove_path_if_present(&self.path);
63            self.disarm();
64        }
65    }
66}
67
68/// Temporary artifact directory retained until its owner is closed or dropped.
69///
70/// The caller keeps custody of the parent and allocated child against unrelated
71/// writers. Drop performs best-effort cleanup of this invocation-owned child;
72/// use [`Self::close`] when cleanup failures must reach the caller. No shared
73/// cache entry is created, reused or removed.
74#[derive(Debug)]
75pub struct ArtifactStagingDirectory {
76    directory: OwnedDirectory,
77}
78
79impl ArtifactStagingDirectory {
80    /// Allocate a distinct staging child beneath an existing caller-owned directory.
81    ///
82    /// The parent is not created. Existing children are never reused or removed.
83    ///
84    /// # Errors
85    /// Returns the filesystem error for a missing or unwritable parent, or
86    /// `AlreadyExists` when every allocation attempt collides.
87    pub fn new(parent: &Path) -> io::Result<Self> {
88        for _ in 0..STAGE_ATTEMPTS {
89            let sequence = STAGE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
90            let root = parent.join(format!(
91                ".ic-build-artifact-stage-{}-{sequence}",
92                std::process::id()
93            ));
94            match fs::create_dir(&root) {
95                Ok(()) => {
96                    return Ok(Self {
97                        directory: OwnedDirectory::new(root),
98                    });
99                }
100                Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
101                Err(source) => return Err(source),
102            }
103        }
104        Err(io::Error::new(
105            io::ErrorKind::AlreadyExists,
106            format!(
107                "failed to allocate artifact staging beneath {}",
108                parent.display()
109            ),
110        ))
111    }
112
113    /// Borrow the owned staging directory through artifact production and consumption.
114    #[must_use]
115    pub fn path(&self) -> &Path {
116        self.directory.path()
117    }
118
119    /// Remove this owned staging child, reporting cleanup failure without a hidden retry.
120    ///
121    /// On failure, the caller owns recovery of the remaining staging child.
122    /// It does not remove the parent or any other artifact directory.
123    ///
124    /// # Errors
125    /// Returns the filesystem error if removing the owned child fails.
126    pub fn close(mut self) -> io::Result<()> {
127        self.directory.disarm();
128        remove_path_if_present(self.directory.path())
129    }
130}
131
132/// Owned copy of one completed producer output, independent of later source replacement.
133///
134/// Keep this record alive through every use of [`Self::path`]. The copy is
135/// temporary rather than a reusable cache entry; ordinary Drop removes it.
136/// [`Self::retain`] explicitly transfers cleanup to the caller.
137#[derive(Debug)]
138pub struct CapturedArtifact {
139    staging: ArtifactStagingDirectory,
140    path: PathBuf,
141}
142
143/// Finalization failed after capture custody was transferred to the caller.
144///
145/// The exact bytes remain at [`Self::retained_path`]. If rename succeeded but
146/// cleanup failed, that path is the destination; otherwise it is the staging file.
147#[derive(Debug)]
148pub struct CaptureRetentionError {
149    /// Filesystem operation that failed.
150    pub source: io::Error,
151    /// Actual retained file path after the failed operation.
152    pub retained_path: PathBuf,
153    /// Owned staging child still requiring caller disposition.
154    pub staging_directory: PathBuf,
155}
156
157impl fmt::Display for CaptureRetentionError {
158    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(
160            formatter,
161            "capture retained at {} (staging {}): {}",
162            self.retained_path.display(),
163            self.staging_directory.display(),
164            self.source
165        )
166    }
167}
168
169impl std::error::Error for CaptureRetentionError {
170    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
171        Some(&self.source)
172    }
173}
174
175impl CapturedArtifact {
176    /// Capture an output synchronously before another producer can overwrite it.
177    ///
178    /// The caller keeps the source stable during capture and selects the maximum
179    /// readable byte count. Read/publication failures retain their typed causes
180    /// inside the returned I/O error. Existing public destinations are untouched.
181    ///
182    /// # Errors
183    /// Returns the source read/byte-limit, staging allocation or durable-write
184    /// error. An unsuccessful capture performs best-effort staging cleanup.
185    pub fn capture(source: &Path, parent: &Path, maximum_bytes: usize) -> io::Result<Self> {
186        let bytes = read_file(source, maximum_bytes).map_err(io::Error::from)?;
187        let staging = ArtifactStagingDirectory::new(parent)?;
188        let path = staging.path().join("input.artifact");
189        write_bytes(&path, &bytes).map_err(io::Error::other)?;
190        Ok(Self { staging, path })
191    }
192
193    /// Borrow the exact captured bytes while this record owns their lifetime.
194    #[must_use]
195    pub fn path(&self) -> &Path {
196        &self.path
197    }
198
199    /// Transfer the captured file and its staging child to caller-owned retention.
200    ///
201    /// Unlike close or Drop, this keeps the exact captured bytes after this owner
202    /// is consumed. The caller owns later cleanup of the returned path's parent.
203    /// Keep custody of those paths; this grants no cache or release admission.
204    #[must_use]
205    pub fn retain(mut self) -> PathBuf {
206        self.staging.directory.disarm();
207        self.path
208    }
209
210    /// Retain the exact capture at a caller-selected destination.
211    ///
212    /// Uses [`fs::rename`] semantics, including replacement of an existing
213    /// destination when supported. The caller owns destination admission and
214    /// later custody. Only the empty owned staging child is removed on success.
215    ///
216    /// # Errors
217    /// Rename or cleanup failure reports the actual retained bytes and staging
218    /// child. Neither failure deletes evidence or silently retries publication.
219    pub fn retain_to(self, destination: &Path) -> Result<PathBuf, CaptureRetentionError> {
220        let staging = self.staging.path().to_owned();
221        let retained = self.retain();
222        fs::rename(&retained, destination).map_err(|source| CaptureRetentionError {
223            source,
224            retained_path: retained.clone(),
225            staging_directory: staging.clone(),
226        })?;
227        fs::remove_dir(&staging).map_err(|source| CaptureRetentionError {
228            source,
229            retained_path: destination.to_owned(),
230            staging_directory: staging,
231        })?;
232        Ok(destination.to_owned())
233    }
234
235    /// Remove the captured output and its owned staging child, reporting cleanup failure.
236    ///
237    /// # Errors
238    /// Returns the filesystem error if removing the owned child fails.
239    pub fn close(self) -> io::Result<()> {
240        self.staging.close()
241    }
242}
243
244#[cfg(test)]
245mod tests;