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,
51 Reused,
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct PersistedArtifact {
57 descriptor: ArtifactDescriptor,
58 disposition: ArtifactDisposition,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct VerifiedArtifact {
64 path: PathBuf,
65 bytes: Vec<u8>,
66}
67
68impl VerifiedArtifact {
69 pub fn path(&self) -> &Path {
71 &self.path
72 }
73
74 pub fn bytes(&self) -> &[u8] {
76 &self.bytes
77 }
78
79 pub fn into_bytes(self) -> Vec<u8> {
81 self.bytes
82 }
83}
84
85impl PersistedArtifact {
86 pub const fn descriptor(&self) -> &ArtifactDescriptor {
88 &self.descriptor
89 }
90
91 pub const fn disposition(&self) -> ArtifactDisposition {
93 self.disposition
94 }
95
96 pub fn into_descriptor(self) -> ArtifactDescriptor {
98 self.descriptor
99 }
100}
101
102pub 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
159pub 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}