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