1use sha2::{Digest, Sha256};
2use std::{
3 collections::BTreeSet,
4 ffi::OsStr,
5 fmt::Write as _,
6 fs::{self, File, OpenOptions},
7 io::{self, Read as _, Write as _},
8 path::{Path, PathBuf},
9 sync::atomic::{AtomicU64, Ordering},
10};
11
12static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
13
14#[derive(Debug)]
15struct AtomicCopyErrorContext {
16 source_path: PathBuf,
17 destination_path: PathBuf,
18 source: io::Error,
19}
20
21impl std::fmt::Display for AtomicCopyErrorContext {
22 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 write!(
24 formatter,
25 "failed to atomically copy {} to {}: {}",
26 self.source_path.display(),
27 self.destination_path.display(),
28 self.source
29 )
30 }
31}
32
33impl std::error::Error for AtomicCopyErrorContext {
34 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
35 Some(&self.source)
36 }
37}
38
39#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
41pub struct InputDigest([u8; 32]);
42
43impl InputDigest {
44 #[must_use]
46 pub const fn as_bytes(&self) -> &[u8; 32] {
47 &self.0
48 }
49
50 #[must_use]
52 pub fn to_hex(self) -> String {
53 let mut hex = String::with_capacity(64);
54 for byte in self.0 {
55 write!(hex, "{byte:02x}").expect("writing to a String cannot fail");
56 }
57 hex
58 }
59}
60
61impl std::fmt::Display for InputDigest {
62 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 formatter.write_str(&self.to_hex())
64 }
65}
66
67pub(super) struct InputHasher(Sha256);
68
69impl InputHasher {
70 pub(super) fn new(domain: &str) -> Self {
71 let mut hasher = Self(Sha256::new());
72 hasher.field("domain", domain.as_bytes());
73 hasher
74 }
75
76 pub(super) fn field(&mut self, label: &str, value: &[u8]) {
77 self.field_header(
78 label,
79 u64::try_from(value.len()).expect("input value length must fit in u64"),
80 );
81 self.0.update(value);
82 }
83
84 fn field_header(&mut self, label: &str, value_len: u64) {
85 self.0.update(
86 u64::try_from(label.len())
87 .expect("input label length must fit in u64")
88 .to_le_bytes(),
89 );
90 self.0.update(label.as_bytes());
91 self.0.update(value_len.to_le_bytes());
92 }
93
94 fn file_field(&mut self, label: &str, path: &Path) -> io::Result<u64> {
95 let mut file = File::open(path)?;
96 let expected_len = file.metadata()?.len();
97 self.field_header(label, expected_len);
98
99 let mut actual_len = 0_u64;
100 let mut buffer = [0_u8; 16 * 1024];
101 loop {
102 let read = file.read(&mut buffer)?;
103 if read == 0 {
104 break;
105 }
106 actual_len = actual_len
107 .saturating_add(u64::try_from(read).expect("artifact read length must fit in u64"));
108 self.0.update(&buffer[..read]);
109 }
110 if actual_len != expected_len {
111 return Err(io::Error::new(
112 io::ErrorKind::InvalidData,
113 format!(
114 "file changed size while hashing: expected {expected_len} bytes, read {actual_len}"
115 ),
116 ));
117 }
118 Ok(actual_len)
119 }
120
121 pub(super) fn finish(self) -> InputDigest {
122 InputDigest(self.0.finalize().into())
123 }
124}
125
126pub(super) fn digest_bytes(domain: &str, value: &[u8]) -> InputDigest {
127 let mut hasher = InputHasher::new(domain);
128 hasher.field("content", value);
129 hasher.finish()
130}
131
132pub(super) fn digest_file(domain: &str, path: &Path) -> io::Result<(u64, InputDigest)> {
133 let mut hasher = InputHasher::new(domain);
134 let bytes = hasher.file_field("content", path)?;
135 Ok((bytes, hasher.finish()))
136}
137
138pub(super) fn digest_labeled_paths(
139 domain: &str,
140 paths: &[(PathBuf, PathBuf)],
141 excluded_roots: &[PathBuf],
142) -> io::Result<InputDigest> {
143 let mut paths = paths.to_vec();
144 paths.sort_by(|(left, _), (right, _)| {
145 os_bytes(left.as_os_str()).cmp(&os_bytes(right.as_os_str()))
146 });
147
148 let excluded_roots = excluded_roots
149 .iter()
150 .filter_map(|path| path.canonicalize().ok())
151 .collect::<Vec<_>>();
152 let mut visited_directories = BTreeSet::new();
153 let mut hasher = InputHasher::new(domain);
154 for (label, path) in paths {
155 hash_path(
156 &mut hasher,
157 &label,
158 &path,
159 &excluded_roots,
160 &mut visited_directories,
161 true,
162 None,
163 )?;
164 }
165 Ok(hasher.finish())
166}
167
168#[derive(Default)]
169pub(super) struct LabeledPathDigestCache {
170 entries: Vec<LabeledPathDigestCacheEntry>,
171}
172
173struct LabeledPathDigestCacheEntry {
174 domain: String,
175 label: PathBuf,
176 path: PathBuf,
177 canonical_root: PathBuf,
178 excluded_roots: Vec<PathBuf>,
179 traversed_external_path: bool,
180 digest: InputDigest,
181}
182
183struct HashPathTrace {
184 canonical_root: PathBuf,
185 traversed_external_path: bool,
186}
187
188pub(super) fn digest_labeled_paths_composable(
189 domain: &str,
190 paths: &[(PathBuf, PathBuf)],
191 excluded_roots: &[PathBuf],
192 cache: &mut LabeledPathDigestCache,
193) -> io::Result<InputDigest> {
194 let mut paths = paths.to_vec();
195 paths.sort_by(|(left, _), (right, _)| {
196 os_bytes(left.as_os_str()).cmp(&os_bytes(right.as_os_str()))
197 });
198 let excluded_roots = excluded_roots
199 .iter()
200 .filter_map(|path| path.canonicalize().ok())
201 .collect::<Vec<_>>();
202 let mut hasher = InputHasher::new(&format!("{domain}/composable-v1"));
203 for (label, path) in paths {
204 let digest = cache.digest_root(domain, &label, &path, &excluded_roots)?;
205 hasher.field("input-label", &os_bytes(label.as_os_str()));
206 hasher.field("input-digest", digest.as_bytes());
207 }
208 Ok(hasher.finish())
209}
210
211impl LabeledPathDigestCache {
212 fn digest_root(
213 &mut self,
214 domain: &str,
215 label: &Path,
216 path: &Path,
217 excluded_roots: &[PathBuf],
218 ) -> io::Result<InputDigest> {
219 let canonical_root = path.canonicalize()?;
220 if let Some(entry) = self.entries.iter().find(|entry| {
221 entry.domain == domain
222 && entry.label == label
223 && entry.path == path
224 && entry.excluded_roots
225 == effective_root_exclusions(
226 &entry.canonical_root,
227 excluded_roots,
228 entry.traversed_external_path,
229 )
230 }) {
231 return Ok(entry.digest);
232 }
233 let mut hasher = InputHasher::new(&format!("{domain}/root-v1"));
234 let mut trace = HashPathTrace {
235 canonical_root: canonical_root.clone(),
236 traversed_external_path: false,
237 };
238 hash_path(
239 &mut hasher,
240 label,
241 path,
242 excluded_roots,
243 &mut BTreeSet::new(),
244 true,
245 Some(&mut trace),
246 )?;
247 let digest = hasher.finish();
248 self.entries.push(LabeledPathDigestCacheEntry {
249 domain: domain.to_owned(),
250 label: label.to_owned(),
251 path: path.to_owned(),
252 canonical_root,
253 excluded_roots: effective_root_exclusions(
254 &trace.canonical_root,
255 excluded_roots,
256 trace.traversed_external_path,
257 ),
258 traversed_external_path: trace.traversed_external_path,
259 digest,
260 });
261 Ok(digest)
262 }
263}
264
265fn effective_root_exclusions(
266 canonical_root: &Path,
267 excluded_roots: &[PathBuf],
268 traversed_external_path: bool,
269) -> Vec<PathBuf> {
270 if traversed_external_path {
271 return excluded_roots.to_vec();
272 }
273 excluded_roots
274 .iter()
275 .filter(|excluded| {
276 excluded.starts_with(canonical_root) || canonical_root.starts_with(excluded)
277 })
278 .cloned()
279 .collect()
280}
281
282fn hash_path(
283 hasher: &mut InputHasher,
284 label: &Path,
285 path: &Path,
286 excluded_roots: &[PathBuf],
287 visited_directories: &mut BTreeSet<PathBuf>,
288 declared_root: bool,
289 mut trace: Option<&mut HashPathTrace>,
290) -> io::Result<()> {
291 let context =
292 |error: io::Error| io::Error::new(error.kind(), format!("{}: {error}", path.display()));
293 let canonical = path.canonicalize().map_err(context)?;
294 if let Some(trace) = &mut trace
295 && !canonical.starts_with(&trace.canonical_root)
296 {
297 trace.traversed_external_path = true;
298 }
299 if excluded_roots
300 .iter()
301 .any(|excluded| canonical.starts_with(excluded))
302 {
303 if declared_root {
304 return Err(io::Error::new(
305 io::ErrorKind::InvalidInput,
306 format!(
307 "declared input is located inside an excluded cache root: {}",
308 path.display()
309 ),
310 ));
311 }
312 return Ok(());
313 }
314
315 let metadata = fs::metadata(path).map_err(context)?;
316 let label_bytes = os_bytes(label.as_os_str());
317 if metadata.is_file() {
318 hasher.field("file-path", &label_bytes);
319 hasher.file_field("file-content", path).map_err(context)?;
320 return Ok(());
321 }
322 if !metadata.is_dir() {
323 return Err(io::Error::new(
324 io::ErrorKind::InvalidInput,
325 format!(
326 "watched input is not a regular file or directory: {}",
327 path.display()
328 ),
329 ));
330 }
331
332 hasher.field("directory", &label_bytes);
333 if !visited_directories.insert(canonical) {
334 hasher.field("directory-already-visited", &label_bytes);
335 return Ok(());
336 }
337
338 let mut entries = fs::read_dir(path)
339 .map_err(context)?
340 .collect::<Result<Vec<_>, _>>()
341 .map_err(context)?;
342 entries.sort_by_key(|entry| os_bytes(&entry.file_name()));
343 for entry in entries {
344 hash_path(
345 hasher,
346 &label.join(entry.file_name()),
347 &entry.path(),
348 excluded_roots,
349 visited_directories,
350 false,
351 trace.as_deref_mut(),
352 )?;
353 }
354 Ok(())
355}
356
357pub(super) fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
358 write_file_atomic(path, |file| file.write_all(contents))
359}
360
361pub(super) fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<u64> {
362 let result = (|| {
363 let mut source_file = File::open(source)?;
364 write_file_atomic(destination, |destination_file| {
365 io::copy(&mut source_file, destination_file)
366 })
367 })();
368 result.map_err(|source_error| {
369 io::Error::new(
370 source_error.kind(),
371 AtomicCopyErrorContext {
372 source_path: source.to_owned(),
373 destination_path: destination.to_owned(),
374 source: source_error,
375 },
376 )
377 })
378}
379
380fn write_file_atomic<T>(
381 path: &Path,
382 write: impl FnOnce(&mut File) -> io::Result<T>,
383) -> io::Result<T> {
384 let parent = path.parent().ok_or_else(|| {
385 io::Error::new(
386 io::ErrorKind::InvalidInput,
387 format!("atomic output path has no parent: {}", path.display()),
388 )
389 })?;
390 fs::create_dir_all(parent)?;
391
392 let file_name = path.file_name().ok_or_else(|| {
393 io::Error::new(
394 io::ErrorKind::InvalidInput,
395 format!("atomic output path has no file name: {}", path.display()),
396 )
397 })?;
398 let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
399 let mut temp_name = file_name.to_os_string();
400 temp_name.push(format!(".tmp-{}-{sequence}", std::process::id()));
401 let temp_path = parent.join(temp_name);
402
403 let result = (|| {
404 let mut file = OpenOptions::new()
405 .create_new(true)
406 .write(true)
407 .open(&temp_path)?;
408 let value = write(&mut file)?;
409 file.sync_all()?;
410 fs::rename(&temp_path, path)?;
411 Ok(value)
412 })();
413 if result.is_err() {
414 let _ = fs::remove_file(&temp_path);
415 }
416 result
417}
418
419#[cfg(unix)]
420pub(super) fn os_bytes(value: &OsStr) -> Vec<u8> {
421 use std::os::unix::ffi::OsStrExt as _;
422 value.as_bytes().to_vec()
423}
424
425#[cfg(windows)]
426pub(super) fn os_bytes(value: &OsStr) -> Vec<u8> {
427 use std::os::windows::ffi::OsStrExt as _;
428 value
429 .encode_wide()
430 .flat_map(u16::to_le_bytes)
431 .collect::<Vec<_>>()
432}
433
434#[cfg(not(any(unix, windows)))]
435pub(super) fn os_bytes(value: &OsStr) -> Vec<u8> {
436 value.to_string_lossy().as_bytes().to_vec()
437}
438
439#[cfg(test)]
440mod tests {
441 use super::{
442 LabeledPathDigestCache, copy_file_atomic, digest_bytes, digest_file,
443 digest_labeled_paths_composable, write_atomic,
444 };
445 use crate::artifacts::test_support::unique_temp_directory;
446 use std::{fs, path::PathBuf};
447
448 #[test]
449 fn streaming_digest_and_atomic_copy_preserve_exact_bytes() {
450 let root = unique_temp_directory("streaming-digest");
451 let source = root.join("source");
452 let destination = root.join("destination");
453 let mut contents = vec![0_u8; 192 * 1024];
454 for (index, byte) in contents.iter_mut().enumerate() {
455 *byte = u8::try_from(index % 251).expect("test byte must fit");
456 }
457 fs::write(&source, &contents).expect("write source");
458
459 let (bytes, streamed) = digest_file("streaming-test-v1", &source).expect("digest file");
460 assert_eq!(
461 bytes,
462 u64::try_from(contents.len()).expect("fixture length must fit in u64")
463 );
464 assert_eq!(streamed, digest_bytes("streaming-test-v1", &contents));
465
466 write_atomic(&destination, b"old").expect("write original destination");
467 assert_eq!(
468 copy_file_atomic(&source, &destination).expect("copy source atomically"),
469 bytes
470 );
471 assert_eq!(
472 fs::read(&destination).expect("read copied destination"),
473 contents
474 );
475
476 let missing = root.join("missing");
477 let error = copy_file_atomic(&missing, &destination).expect_err("missing source must fail");
478 let message = error.to_string();
479 assert!(message.contains(&missing.display().to_string()));
480 assert!(message.contains(&destination.display().to_string()));
481 fs::remove_dir_all(root).expect("remove streaming-digest test directory");
482 }
483
484 #[test]
485 fn composable_digest_reuses_roots_across_irrelevant_exclusion_changes() {
486 let root = unique_temp_directory("composable-digest-cache");
487 let input = root.join("input");
488 fs::create_dir_all(&input).expect("create composable input");
489 fs::create_dir_all(root.join("generated-a")).expect("create first generated root");
490 fs::create_dir_all(root.join("generated-b")).expect("create second generated root");
491 fs::write(input.join("source"), b"source").expect("write composable input");
492 let paths = [(PathBuf::from("shared"), input)];
493 let mut cache = LabeledPathDigestCache::default();
494
495 let first = digest_labeled_paths_composable(
496 "composable-test-v1",
497 &paths,
498 &[root.join("generated-a")],
499 &mut cache,
500 )
501 .expect("hash first composable input");
502 let second = digest_labeled_paths_composable(
503 "composable-test-v1",
504 &paths,
505 &[root.join("generated-b")],
506 &mut cache,
507 )
508 .expect("reuse composable input root");
509
510 assert_eq!(first, second);
511 assert_eq!(cache.entries.len(), 1);
512 fs::remove_dir_all(root).expect("remove composable digest fixture");
513 }
514}