1use 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#[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 pub fn sha256(&self) -> &str {
38 &self.sha256
39 }
40
41 pub fn path(&self) -> &str {
43 &self.path
44 }
45}
46
47#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
49pub enum ArtifactDisposition {
50 Created,
52 Reused,
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct PersistedArtifact {
59 descriptor: ArtifactDescriptor,
60 disposition: ArtifactDisposition,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct VerifiedArtifact {
66 path: PathBuf,
67 bytes: Vec<u8>,
68}
69
70impl VerifiedArtifact {
71 pub fn path(&self) -> &Path {
73 &self.path
74 }
75
76 pub fn bytes(&self) -> &[u8] {
78 &self.bytes
79 }
80
81 pub fn into_bytes(self) -> Vec<u8> {
83 self.bytes
84 }
85}
86
87impl PersistedArtifact {
88 pub const fn descriptor(&self) -> &ArtifactDescriptor {
90 &self.descriptor
91 }
92
93 pub const fn disposition(&self) -> ArtifactDisposition {
95 self.disposition
96 }
97
98 pub fn into_descriptor(self) -> ArtifactDescriptor {
100 self.descriptor
101 }
102}
103
104pub 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
161pub 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#[derive(Debug, Error)]
339#[non_exhaustive]
340pub enum ArtifactError {
341 #[error("invalid artifact {kind} `{value}`")]
343 InvalidFragment {
344 kind: &'static str,
346 value: String,
348 },
349 #[error("failed to {operation} at `{path}`")]
351 Io {
352 operation: &'static str,
354 path: PathBuf,
356 #[source]
358 source: std::io::Error,
359 },
360 #[error("artifact digest collision for `{digest}` at `{path}`")]
362 DigestCollision {
363 digest: String,
365 path: PathBuf,
367 },
368 #[error("could not allocate a temporary artifact beneath `{directory}`")]
370 TemporaryIdentityExhausted {
371 directory: PathBuf,
373 },
374}
375
376#[derive(Debug, Error)]
378#[non_exhaustive]
379pub enum ArtifactLoadError {
380 #[error("invalid artifact descriptor: {reason}")]
382 InvalidDescriptor {
383 reason: String,
385 },
386 #[error("failed to {operation} at `{path}`")]
388 Io {
389 operation: &'static str,
391 path: PathBuf,
393 #[source]
395 source: std::io::Error,
396 },
397 #[error("artifact `{path}` has SHA-256 `{actual}`, but metadata declares `{expected}`")]
399 DigestMismatch {
400 path: PathBuf,
402 expected: String,
404 actual: String,
406 },
407}