Skip to main content

scientific_workflow/
artifact.rs

1//! Generic content-addressed input artifacts inside an execution scope.
2//!
3//! This module owns immutable artifact publication and integrity verification:
4//! deterministic naming, SHA-256 identity computation, deduplicating persistence,
5//! descriptor encoding, and strict load-time checks.
6//!
7//! # Boundary
8//!
9//! Artifact handling is intentionally limited to bytes. The module does not own
10//! execution policy, storage stream formats, task scheduling, or scientific
11//! semantics. Callers map descriptors into their own provenance domain and pass
12//! them through `ExecutionScope` paths.
13
14use std::fs::{self, File, OpenOptions};
15use std::io::Write;
16use std::path::{Component, Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use thiserror::Error;
22
23use crate::execution::ExecutionScope;
24
25static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
26
27/// Exact identity and execution-relative location of immutable bytes.
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
29#[serde(deny_unknown_fields)]
30pub struct ArtifactDescriptor {
31    sha256: String,
32    path: String,
33}
34
35impl ArtifactDescriptor {
36    /// Returns the lowercase SHA-256 identity of the exact bytes.
37    pub fn sha256(&self) -> &str {
38        &self.sha256
39    }
40
41    /// Returns the artifact path relative to its execution directory.
42    pub fn path(&self) -> &str {
43        &self.path
44    }
45}
46
47/// Whether publishing created new bytes or reused identical existing bytes.
48#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
49pub enum ArtifactDisposition {
50    /// This publication created the destination artifact.
51    Created,
52    /// Identical bytes already existed and were reused.
53    Reused,
54}
55
56/// Result of atomically publishing immutable content.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct PersistedArtifact {
59    descriptor: ArtifactDescriptor,
60    disposition: ArtifactDisposition,
61}
62
63/// Verified canonical path and exact immutable bytes.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct VerifiedArtifact {
66    path: PathBuf,
67    bytes: Vec<u8>,
68}
69
70impl VerifiedArtifact {
71    /// Returns the canonical verified filesystem path.
72    pub fn path(&self) -> &Path {
73        &self.path
74    }
75
76    /// Borrows the bytes whose digest has been verified.
77    pub fn bytes(&self) -> &[u8] {
78        &self.bytes
79    }
80
81    /// Transfers ownership of the verified bytes.
82    pub fn into_bytes(self) -> Vec<u8> {
83        self.bytes
84    }
85}
86
87impl PersistedArtifact {
88    /// Borrows the immutable identity and relative path.
89    pub const fn descriptor(&self) -> &ArtifactDescriptor {
90        &self.descriptor
91    }
92
93    /// Reports whether this call created or reused the destination.
94    pub const fn disposition(&self) -> ArtifactDisposition {
95        self.disposition
96    }
97
98    /// Transfers ownership of the descriptor.
99    pub fn into_descriptor(self) -> ArtifactDescriptor {
100        self.descriptor
101    }
102}
103
104/// Atomically publishes exact bytes beneath `scope/inputs` using their SHA-256.
105///
106/// `stem` and `extension` describe representation only; callers retain all
107/// domain interpretation. Both must be nonempty safe filename fragments.
108pub fn persist_artifact(
109    scope: &ExecutionScope,
110    stem: &str,
111    extension: &str,
112    bytes: &[u8],
113) -> Result<PersistedArtifact, ArtifactError> {
114    validate_fragment("stem", stem, true)?;
115    validate_fragment("extension", extension, false)?;
116    let digest = sha256_hex(bytes);
117    let file_name = format!("{stem}-{digest}.{extension}");
118    let relative_path = format!("inputs/{file_name}");
119    let inputs = scope.directory().join("inputs");
120    fs::create_dir_all(&inputs).map_err(|source| ArtifactError::Io {
121        operation: "create artifact input directory",
122        path: inputs.clone(),
123        source,
124    })?;
125    let destination = inputs.join(file_name);
126
127    if destination.exists() {
128        verify_existing(&destination, bytes, &digest)?;
129        return Ok(persisted(
130            digest,
131            relative_path,
132            ArtifactDisposition::Reused,
133        ));
134    }
135
136    let temporary = create_complete_temporary(&inputs, &digest, bytes)?;
137    let disposition = match fs::hard_link(&temporary, &destination) {
138        Ok(()) => ArtifactDisposition::Created,
139        Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
140            let verification = verify_existing(&destination, bytes, &digest);
141            remove_published_temporary(&temporary)?;
142            verification?;
143            ArtifactDisposition::Reused
144        }
145        Err(source) => {
146            remove_temporary(&temporary);
147            return Err(ArtifactError::Io {
148                operation: "publish artifact",
149                path: destination,
150                source,
151            });
152        }
153    };
154    if temporary.exists() {
155        remove_published_temporary(&temporary)?;
156    }
157    sync_directory(&inputs)?;
158    Ok(persisted(digest, relative_path, disposition))
159}
160
161/// Reads immutable bytes after path-containment and exact-digest verification.
162pub fn load_verified_artifact(
163    execution_directory: impl AsRef<Path>,
164    descriptor: &ArtifactDescriptor,
165) -> Result<VerifiedArtifact, ArtifactLoadError> {
166    validate_descriptor(descriptor)?;
167    let execution_directory =
168        fs::canonicalize(execution_directory.as_ref()).map_err(|source| ArtifactLoadError::Io {
169            operation: "resolve execution directory",
170            path: execution_directory.as_ref().to_path_buf(),
171            source,
172        })?;
173    let relative = Path::new(descriptor.path());
174    if relative.as_os_str().is_empty()
175        || relative
176            .components()
177            .any(|component| !matches!(component, Component::Normal(_)))
178    {
179        return Err(ArtifactLoadError::InvalidDescriptor {
180            reason: "artifact path must be a nonempty normalized relative path".to_owned(),
181        });
182    }
183    let unresolved = execution_directory.join(relative);
184    let path = fs::canonicalize(&unresolved).map_err(|source| ArtifactLoadError::Io {
185        operation: "resolve artifact",
186        path: unresolved,
187        source,
188    })?;
189    if !path.starts_with(&execution_directory) {
190        return Err(ArtifactLoadError::InvalidDescriptor {
191            reason: "artifact path resolves outside the execution directory".to_owned(),
192        });
193    }
194    let bytes = fs::read(&path).map_err(|source| ArtifactLoadError::Io {
195        operation: "read artifact",
196        path: path.clone(),
197        source,
198    })?;
199    let actual = sha256_hex(&bytes);
200    if actual != descriptor.sha256 {
201        return Err(ArtifactLoadError::DigestMismatch {
202            path,
203            expected: descriptor.sha256.clone(),
204            actual,
205        });
206    }
207    Ok(VerifiedArtifact { path, bytes })
208}
209
210fn persisted(sha256: String, path: String, disposition: ArtifactDisposition) -> PersistedArtifact {
211    PersistedArtifact {
212        descriptor: ArtifactDescriptor { sha256, path },
213        disposition,
214    }
215}
216
217fn validate_fragment(
218    kind: &'static str,
219    value: &str,
220    allow_hyphen: bool,
221) -> Result<(), ArtifactError> {
222    let valid = !value.is_empty()
223        && value.bytes().all(|byte| {
224            byte.is_ascii_alphanumeric() || byte == b'_' || (allow_hyphen && byte == b'-')
225        });
226    if valid {
227        Ok(())
228    } else {
229        Err(ArtifactError::InvalidFragment {
230            kind,
231            value: value.to_owned(),
232        })
233    }
234}
235
236fn validate_descriptor(descriptor: &ArtifactDescriptor) -> Result<(), ArtifactLoadError> {
237    if descriptor.sha256.len() != 64
238        || !descriptor
239            .sha256
240            .bytes()
241            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
242    {
243        return Err(ArtifactLoadError::InvalidDescriptor {
244            reason: "artifact SHA-256 must contain exactly 64 lowercase hexadecimal digits"
245                .to_owned(),
246        });
247    }
248    Ok(())
249}
250
251fn create_complete_temporary(
252    directory: &Path,
253    digest: &str,
254    bytes: &[u8],
255) -> Result<PathBuf, ArtifactError> {
256    for _ in 0..1024 {
257        let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
258        let path = directory.join(format!(
259            ".artifact-{digest}-{}-{sequence}.tmp",
260            std::process::id()
261        ));
262        let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) {
263            Ok(file) => file,
264            Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue,
265            Err(source) => {
266                return Err(ArtifactError::Io {
267                    operation: "create temporary artifact",
268                    path,
269                    source,
270                });
271            }
272        };
273        if let Err(source) = file.write_all(bytes).and_then(|()| file.sync_all()) {
274            drop(file);
275            remove_temporary(&path);
276            return Err(ArtifactError::Io {
277                operation: "write temporary artifact",
278                path,
279                source,
280            });
281        }
282        return Ok(path);
283    }
284    Err(ArtifactError::TemporaryIdentityExhausted {
285        directory: directory.to_path_buf(),
286    })
287}
288
289fn verify_existing(path: &Path, expected: &[u8], digest: &str) -> Result<(), ArtifactError> {
290    let actual = fs::read(path).map_err(|source| ArtifactError::Io {
291        operation: "read existing artifact",
292        path: path.to_path_buf(),
293        source,
294    })?;
295    if actual == expected {
296        Ok(())
297    } else {
298        Err(ArtifactError::DigestCollision {
299            digest: digest.to_owned(),
300            path: path.to_path_buf(),
301        })
302    }
303}
304
305fn sha256_hex(bytes: &[u8]) -> String {
306    let digest = Sha256::digest(bytes);
307    let mut encoded = String::with_capacity(digest.len() * 2);
308    for byte in digest {
309        use std::fmt::Write as _;
310        write!(encoded, "{byte:02x}").expect("writing into a String cannot fail");
311    }
312    encoded
313}
314
315fn sync_directory(path: &Path) -> Result<(), ArtifactError> {
316    File::open(path)
317        .and_then(|directory| directory.sync_all())
318        .map_err(|source| ArtifactError::Io {
319            operation: "synchronize artifact input directory",
320            path: path.to_path_buf(),
321            source,
322        })
323}
324
325fn remove_temporary(path: &Path) {
326    let _ = fs::remove_file(path);
327}
328
329fn remove_published_temporary(path: &Path) -> Result<(), ArtifactError> {
330    fs::remove_file(path).map_err(|source| ArtifactError::Io {
331        operation: "remove temporary artifact",
332        path: path.to_path_buf(),
333        source,
334    })
335}
336
337/// Failure while validating or publishing an immutable artifact.
338#[derive(Debug, Error)]
339#[non_exhaustive]
340pub enum ArtifactError {
341    /// A filename fragment is empty or contains unsafe characters.
342    #[error("invalid artifact {kind} `{value}`")]
343    InvalidFragment {
344        /// Kind of fragment, such as `stem` or `extension`.
345        kind: &'static str,
346        /// Rejected fragment value.
347        value: String,
348    },
349    /// A filesystem operation failed during publication.
350    #[error("failed to {operation} at `{path}`")]
351    Io {
352        /// Stable description of the attempted operation.
353        operation: &'static str,
354        /// Filesystem path affected by the operation.
355        path: PathBuf,
356        /// Underlying operating-system failure.
357        #[source]
358        source: std::io::Error,
359    },
360    /// Existing bytes at a digest-derived path do not match the digest input.
361    #[error("artifact digest collision for `{digest}` at `{path}`")]
362    DigestCollision {
363        /// SHA-256 identity whose destination was occupied.
364        digest: String,
365        /// Conflicting artifact path.
366        path: PathBuf,
367    },
368    /// All bounded attempts to allocate a unique temporary path collided.
369    #[error("could not allocate a temporary artifact beneath `{directory}`")]
370    TemporaryIdentityExhausted {
371        /// Directory in which allocation was attempted.
372        directory: PathBuf,
373    },
374}
375
376/// Failure while locating or verifying a persisted artifact.
377#[derive(Debug, Error)]
378#[non_exhaustive]
379pub enum ArtifactLoadError {
380    /// Descriptor contents are malformed or escape the execution directory.
381    #[error("invalid artifact descriptor: {reason}")]
382    InvalidDescriptor {
383        /// Contextual descriptor violation.
384        reason: String,
385    },
386    /// A filesystem operation failed during loading.
387    #[error("failed to {operation} at `{path}`")]
388    Io {
389        /// Stable description of the attempted operation.
390        operation: &'static str,
391        /// Filesystem path affected by the operation.
392        path: PathBuf,
393        /// Underlying operating-system failure.
394        #[source]
395        source: std::io::Error,
396    },
397    /// Loaded bytes do not have the descriptor's declared digest.
398    #[error("artifact `{path}` has SHA-256 `{actual}`, but metadata declares `{expected}`")]
399    DigestMismatch {
400        /// Canonical path of the loaded artifact.
401        path: PathBuf,
402        /// SHA-256 digest declared by the descriptor.
403        expected: String,
404        /// SHA-256 digest calculated from the loaded bytes.
405        actual: String,
406    },
407}