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    Created,
51    Reused,
52}
53
54/// Result of atomically publishing immutable content.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct PersistedArtifact {
57    descriptor: ArtifactDescriptor,
58    disposition: ArtifactDisposition,
59}
60
61/// Verified canonical path and exact immutable bytes.
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct VerifiedArtifact {
64    path: PathBuf,
65    bytes: Vec<u8>,
66}
67
68impl VerifiedArtifact {
69    /// Returns the canonical verified filesystem path.
70    pub fn path(&self) -> &Path {
71        &self.path
72    }
73
74    /// Borrows the bytes whose digest has been verified.
75    pub fn bytes(&self) -> &[u8] {
76        &self.bytes
77    }
78
79    /// Transfers ownership of the verified bytes.
80    pub fn into_bytes(self) -> Vec<u8> {
81        self.bytes
82    }
83}
84
85impl PersistedArtifact {
86    /// Borrows the immutable identity and relative path.
87    pub const fn descriptor(&self) -> &ArtifactDescriptor {
88        &self.descriptor
89    }
90
91    /// Reports whether this call created or reused the destination.
92    pub const fn disposition(&self) -> ArtifactDisposition {
93        self.disposition
94    }
95
96    /// Transfers ownership of the descriptor.
97    pub fn into_descriptor(self) -> ArtifactDescriptor {
98        self.descriptor
99    }
100}
101
102/// Atomically publishes exact bytes beneath `scope/inputs` using their SHA-256.
103///
104/// `stem` and `extension` describe representation only; callers retain all
105/// domain interpretation. Both must be nonempty safe filename fragments.
106pub fn persist_artifact(
107    scope: &ExecutionScope,
108    stem: &str,
109    extension: &str,
110    bytes: &[u8],
111) -> Result<PersistedArtifact, ArtifactError> {
112    validate_fragment("stem", stem, true)?;
113    validate_fragment("extension", extension, false)?;
114    let digest = sha256_hex(bytes);
115    let file_name = format!("{stem}-{digest}.{extension}");
116    let relative_path = format!("inputs/{file_name}");
117    let inputs = scope.directory().join("inputs");
118    fs::create_dir_all(&inputs).map_err(|source| ArtifactError::Io {
119        operation: "create artifact input directory",
120        path: inputs.clone(),
121        source,
122    })?;
123    let destination = inputs.join(file_name);
124
125    if destination.exists() {
126        verify_existing(&destination, bytes, &digest)?;
127        return Ok(persisted(
128            digest,
129            relative_path,
130            ArtifactDisposition::Reused,
131        ));
132    }
133
134    let temporary = create_complete_temporary(&inputs, &digest, bytes)?;
135    let disposition = match fs::hard_link(&temporary, &destination) {
136        Ok(()) => ArtifactDisposition::Created,
137        Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
138            let verification = verify_existing(&destination, bytes, &digest);
139            remove_published_temporary(&temporary)?;
140            verification?;
141            ArtifactDisposition::Reused
142        }
143        Err(source) => {
144            remove_temporary(&temporary);
145            return Err(ArtifactError::Io {
146                operation: "publish artifact",
147                path: destination,
148                source,
149            });
150        }
151    };
152    if temporary.exists() {
153        remove_published_temporary(&temporary)?;
154    }
155    sync_directory(&inputs)?;
156    Ok(persisted(digest, relative_path, disposition))
157}
158
159/// Reads immutable bytes after path-containment and exact-digest verification.
160pub fn load_verified_artifact(
161    execution_directory: impl AsRef<Path>,
162    descriptor: &ArtifactDescriptor,
163) -> Result<VerifiedArtifact, ArtifactLoadError> {
164    validate_descriptor(descriptor)?;
165    let execution_directory =
166        fs::canonicalize(execution_directory.as_ref()).map_err(|source| ArtifactLoadError::Io {
167            operation: "resolve execution directory",
168            path: execution_directory.as_ref().to_path_buf(),
169            source,
170        })?;
171    let relative = Path::new(descriptor.path());
172    if relative.as_os_str().is_empty()
173        || relative
174            .components()
175            .any(|component| !matches!(component, Component::Normal(_)))
176    {
177        return Err(ArtifactLoadError::InvalidDescriptor {
178            reason: "artifact path must be a nonempty normalized relative path".to_owned(),
179        });
180    }
181    let unresolved = execution_directory.join(relative);
182    let path = fs::canonicalize(&unresolved).map_err(|source| ArtifactLoadError::Io {
183        operation: "resolve artifact",
184        path: unresolved,
185        source,
186    })?;
187    if !path.starts_with(&execution_directory) {
188        return Err(ArtifactLoadError::InvalidDescriptor {
189            reason: "artifact path resolves outside the execution directory".to_owned(),
190        });
191    }
192    let bytes = fs::read(&path).map_err(|source| ArtifactLoadError::Io {
193        operation: "read artifact",
194        path: path.clone(),
195        source,
196    })?;
197    let actual = sha256_hex(&bytes);
198    if actual != descriptor.sha256 {
199        return Err(ArtifactLoadError::DigestMismatch {
200            path,
201            expected: descriptor.sha256.clone(),
202            actual,
203        });
204    }
205    Ok(VerifiedArtifact { path, bytes })
206}
207
208fn persisted(sha256: String, path: String, disposition: ArtifactDisposition) -> PersistedArtifact {
209    PersistedArtifact {
210        descriptor: ArtifactDescriptor { sha256, path },
211        disposition,
212    }
213}
214
215fn validate_fragment(
216    kind: &'static str,
217    value: &str,
218    allow_hyphen: bool,
219) -> Result<(), ArtifactError> {
220    let valid = !value.is_empty()
221        && value.bytes().all(|byte| {
222            byte.is_ascii_alphanumeric() || byte == b'_' || (allow_hyphen && byte == b'-')
223        });
224    if valid {
225        Ok(())
226    } else {
227        Err(ArtifactError::InvalidFragment {
228            kind,
229            value: value.to_owned(),
230        })
231    }
232}
233
234fn validate_descriptor(descriptor: &ArtifactDescriptor) -> Result<(), ArtifactLoadError> {
235    if descriptor.sha256.len() != 64
236        || !descriptor
237            .sha256
238            .bytes()
239            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
240    {
241        return Err(ArtifactLoadError::InvalidDescriptor {
242            reason: "artifact SHA-256 must contain exactly 64 lowercase hexadecimal digits"
243                .to_owned(),
244        });
245    }
246    Ok(())
247}
248
249fn create_complete_temporary(
250    directory: &Path,
251    digest: &str,
252    bytes: &[u8],
253) -> Result<PathBuf, ArtifactError> {
254    for _ in 0..1024 {
255        let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed);
256        let path = directory.join(format!(
257            ".artifact-{digest}-{}-{sequence}.tmp",
258            std::process::id()
259        ));
260        let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) {
261            Ok(file) => file,
262            Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue,
263            Err(source) => {
264                return Err(ArtifactError::Io {
265                    operation: "create temporary artifact",
266                    path,
267                    source,
268                });
269            }
270        };
271        if let Err(source) = file.write_all(bytes).and_then(|()| file.sync_all()) {
272            drop(file);
273            remove_temporary(&path);
274            return Err(ArtifactError::Io {
275                operation: "write temporary artifact",
276                path,
277                source,
278            });
279        }
280        return Ok(path);
281    }
282    Err(ArtifactError::TemporaryIdentityExhausted {
283        directory: directory.to_path_buf(),
284    })
285}
286
287fn verify_existing(path: &Path, expected: &[u8], digest: &str) -> Result<(), ArtifactError> {
288    let actual = fs::read(path).map_err(|source| ArtifactError::Io {
289        operation: "read existing artifact",
290        path: path.to_path_buf(),
291        source,
292    })?;
293    if actual == expected {
294        Ok(())
295    } else {
296        Err(ArtifactError::DigestCollision {
297            digest: digest.to_owned(),
298            path: path.to_path_buf(),
299        })
300    }
301}
302
303fn sha256_hex(bytes: &[u8]) -> String {
304    let digest = Sha256::digest(bytes);
305    let mut encoded = String::with_capacity(digest.len() * 2);
306    for byte in digest {
307        use std::fmt::Write as _;
308        write!(encoded, "{byte:02x}").expect("writing into a String cannot fail");
309    }
310    encoded
311}
312
313fn sync_directory(path: &Path) -> Result<(), ArtifactError> {
314    File::open(path)
315        .and_then(|directory| directory.sync_all())
316        .map_err(|source| ArtifactError::Io {
317            operation: "synchronize artifact input directory",
318            path: path.to_path_buf(),
319            source,
320        })
321}
322
323fn remove_temporary(path: &Path) {
324    let _ = fs::remove_file(path);
325}
326
327fn remove_published_temporary(path: &Path) -> Result<(), ArtifactError> {
328    fs::remove_file(path).map_err(|source| ArtifactError::Io {
329        operation: "remove temporary artifact",
330        path: path.to_path_buf(),
331        source,
332    })
333}
334
335#[derive(Debug, Error)]
336#[non_exhaustive]
337pub enum ArtifactError {
338    #[error("invalid artifact {kind} `{value}`")]
339    InvalidFragment { kind: &'static str, value: String },
340    #[error("failed to {operation} at `{path}`")]
341    Io {
342        operation: &'static str,
343        path: PathBuf,
344        #[source]
345        source: std::io::Error,
346    },
347    #[error("artifact digest collision for `{digest}` at `{path}`")]
348    DigestCollision { digest: String, path: PathBuf },
349    #[error("could not allocate a temporary artifact beneath `{directory}`")]
350    TemporaryIdentityExhausted { directory: PathBuf },
351}
352
353#[derive(Debug, Error)]
354#[non_exhaustive]
355pub enum ArtifactLoadError {
356    #[error("invalid artifact descriptor: {reason}")]
357    InvalidDescriptor { reason: String },
358    #[error("failed to {operation} at `{path}`")]
359    Io {
360        operation: &'static str,
361        path: PathBuf,
362        #[source]
363        source: std::io::Error,
364    },
365    #[error("artifact `{path}` has SHA-256 `{actual}`, but metadata declares `{expected}`")]
366    DigestMismatch {
367        path: PathBuf,
368        expected: String,
369        actual: String,
370    },
371}