1use sha2::{Digest, Sha256};
2use std::{
3 borrow::Cow,
4 collections::BTreeSet,
5 ffi::OsStr,
6 fmt::Write as _,
7 fs::{self, File, OpenOptions},
8 io::{self, Read as _, Write as _},
9 path::{Path, PathBuf},
10 sync::atomic::{AtomicU64, Ordering},
11};
12
13static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
14
15#[derive(Debug)]
16struct AtomicCopyErrorContext {
17 source_path: PathBuf,
18 destination_path: PathBuf,
19 source: io::Error,
20}
21
22impl std::fmt::Display for AtomicCopyErrorContext {
23 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 write!(
25 formatter,
26 "failed to atomically copy {} to {}: {}",
27 self.source_path.display(),
28 self.destination_path.display(),
29 self.source
30 )
31 }
32}
33
34impl std::error::Error for AtomicCopyErrorContext {
35 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
36 Some(&self.source)
37 }
38}
39
40#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
42pub struct InputDigest([u8; 32]);
43
44impl InputDigest {
45 #[must_use]
47 pub const fn as_bytes(&self) -> &[u8; 32] {
48 &self.0
49 }
50
51 #[must_use]
53 pub fn to_hex(self) -> String {
54 let mut hex = String::with_capacity(64);
55 write!(hex, "{self}").expect("writing to a String cannot fail");
56 hex
57 }
58}
59
60impl std::fmt::Display for InputDigest {
61 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 for byte in self.0 {
63 write!(formatter, "{byte:02x}")?;
64 }
65 Ok(())
66 }
67}
68
69pub(super) struct InputHasher(Sha256);
70
71impl InputHasher {
72 pub(super) fn new(domain: &str) -> Self {
73 let mut hasher = Self(Sha256::new());
74 hasher.field("domain", domain.as_bytes());
75 hasher
76 }
77
78 pub(super) fn field(&mut self, label: &str, value: &[u8]) {
79 self.field_header(
80 label,
81 u64::try_from(value.len()).expect("input value length must fit in u64"),
82 );
83 self.0.update(value);
84 }
85
86 fn field_header(&mut self, label: &str, value_len: u64) {
87 self.0.update(
88 u64::try_from(label.len())
89 .expect("input label length must fit in u64")
90 .to_le_bytes(),
91 );
92 self.0.update(label.as_bytes());
93 self.0.update(value_len.to_le_bytes());
94 }
95
96 fn file_field(&mut self, label: &str, path: &Path) -> io::Result<u64> {
97 let mut file = File::open(path)?;
98 let expected_len = file.metadata()?.len();
99 self.field_header(label, expected_len);
100
101 let mut actual_len = 0_u64;
102 let buffer_len = usize::try_from(expected_len.clamp(1, 64 * 1024))
105 .expect("bounded artifact buffer length must fit in usize");
106 let mut buffer = vec![0_u8; buffer_len];
107 loop {
108 let read = file.read(&mut buffer)?;
109 if read == 0 {
110 break;
111 }
112 actual_len = actual_len
113 .saturating_add(u64::try_from(read).expect("artifact read length must fit in u64"));
114 if actual_len > expected_len {
115 break;
116 }
117 self.0.update(&buffer[..read]);
118 }
119 if actual_len != expected_len {
120 return Err(io::Error::new(
121 io::ErrorKind::InvalidData,
122 format!(
123 "file changed size while hashing: expected {expected_len} bytes, read {actual_len}"
124 ),
125 ));
126 }
127 Ok(actual_len)
128 }
129
130 pub(super) fn finish(self) -> InputDigest {
131 InputDigest(self.0.finalize().into())
132 }
133}
134
135pub(super) fn digest_bytes(domain: &str, value: &[u8]) -> InputDigest {
136 let mut hasher = InputHasher::new(domain);
137 hasher.field("content", value);
138 hasher.finish()
139}
140
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub(super) struct FileDigest {
143 pub(super) bytes: u64,
144 pub(super) digest: InputDigest,
145}
146
147pub(super) fn digest_file(domain: &str, path: &Path) -> io::Result<FileDigest> {
148 let mut hasher = InputHasher::new(domain);
149 let bytes = hasher.file_field("content", path)?;
150 Ok(FileDigest {
151 bytes,
152 digest: hasher.finish(),
153 })
154}
155
156pub(super) fn destination_matches_digest(
159 domain: &str,
160 destination: &Path,
161 expected: &FileDigest,
162) -> bool {
163 destination_is_reusable(destination, expected.bytes)
164 && digest_file(domain, destination).is_ok_and(|actual| actual == *expected)
165}
166
167pub(super) fn destination_matches_bytes(destination: &Path, expected: &[u8]) -> bool {
168 destination_is_reusable(
169 destination,
170 u64::try_from(expected.len()).expect("artifact byte length must fit in u64"),
171 ) && fs::read(destination).is_ok_and(|actual| actual == expected)
172}
173
174fn destination_is_reusable(destination: &Path, expected_bytes: u64) -> bool {
175 #[cfg(unix)]
176 {
177 use std::os::unix::fs::MetadataExt as _;
178
179 let Ok(metadata) = fs::symlink_metadata(destination) else {
180 return false;
181 };
182 let effective_uid = unsafe { libc::geteuid() };
184 if !metadata.file_type().is_file()
187 || metadata.nlink() != 1
188 || metadata.uid() != effective_uid
189 || metadata.mode() & 0o600 != 0o600
190 || metadata.mode() & 0o7111 != 0
191 || metadata.len() != expected_bytes
192 {
193 return false;
194 }
195 true
196 }
197 #[cfg(not(unix))]
198 {
199 let _ = (destination, expected_bytes);
201 false
202 }
203}
204
205pub(super) fn digest_labeled_paths<L: AsRef<Path>, P: AsRef<Path>>(
206 domain: &str,
207 paths: impl IntoIterator<Item = (L, P)>,
208 excluded_roots: &[PathBuf],
209) -> io::Result<InputDigest> {
210 let mut paths = paths.into_iter().collect::<Vec<_>>();
211 paths.sort_by(|(left, _), (right, _)| {
212 os_bytes(left.as_ref().as_os_str()).cmp(&os_bytes(right.as_ref().as_os_str()))
213 });
214
215 let excluded_roots = excluded_roots
216 .iter()
217 .filter_map(|path| path.canonicalize().ok())
218 .collect::<Vec<_>>();
219 let mut visited_directories = BTreeSet::new();
220 let mut hasher = InputHasher::new(domain);
221 for (label, path) in paths {
222 hash_path(
223 &mut hasher,
224 label.as_ref(),
225 path.as_ref(),
226 &excluded_roots,
227 &mut visited_directories,
228 true,
229 None,
230 )?;
231 }
232 Ok(hasher.finish())
233}
234
235#[derive(Default)]
236pub(super) struct LabeledPathDigestCache {
237 entries: Vec<LabeledPathDigestCacheEntry>,
238}
239
240struct LabeledPathDigestCacheEntry {
241 domain: String,
242 label: PathBuf,
243 path: PathBuf,
244 canonical_root: PathBuf,
245 excluded_roots: Vec<PathBuf>,
246 traversed_external_path: bool,
247 digest: InputDigest,
248}
249
250struct HashPathTrace {
251 canonical_root: PathBuf,
252 traversed_external_path: bool,
253}
254
255pub(super) fn digest_labeled_paths_composable<'a>(
256 domain: &str,
257 paths: impl IntoIterator<Item = (&'a Path, &'a Path)>,
258 excluded_roots: &[PathBuf],
259 cache: &mut LabeledPathDigestCache,
260) -> io::Result<InputDigest> {
261 let mut paths = paths.into_iter().collect::<Vec<_>>();
262 paths.sort_by(|(left, _), (right, _)| {
263 os_bytes(left.as_os_str()).cmp(&os_bytes(right.as_os_str()))
264 });
265 let excluded_roots = excluded_roots
266 .iter()
267 .filter_map(|path| path.canonicalize().ok())
268 .collect::<Vec<_>>();
269 let mut hasher = InputHasher::new(&format!("{domain}/composable-v1"));
270 for (label, path) in paths {
271 let digest = cache.digest_root(domain, label, path, &excluded_roots)?;
272 hasher.field("input-label", &os_bytes(label.as_os_str()));
273 hasher.field("input-digest", digest.as_bytes());
274 }
275 Ok(hasher.finish())
276}
277
278impl LabeledPathDigestCache {
279 fn digest_root(
280 &mut self,
281 domain: &str,
282 label: &Path,
283 path: &Path,
284 excluded_roots: &[PathBuf],
285 ) -> io::Result<InputDigest> {
286 let canonical_root = path.canonicalize()?;
287 if let Some(entry) = self.entries.iter().find(|entry| {
288 entry.domain == domain
289 && entry.label == label
290 && entry.path == path
291 && entry.excluded_roots.iter().eq(effective_root_exclusions(
292 &entry.canonical_root,
293 excluded_roots,
294 entry.traversed_external_path,
295 ))
296 }) {
297 return Ok(entry.digest);
298 }
299 let mut hasher = InputHasher::new(&format!("{domain}/root-v1"));
300 let mut trace = HashPathTrace {
301 canonical_root: canonical_root.clone(),
302 traversed_external_path: false,
303 };
304 hash_path(
305 &mut hasher,
306 label,
307 path,
308 excluded_roots,
309 &mut BTreeSet::new(),
310 true,
311 Some(&mut trace),
312 )?;
313 let digest = hasher.finish();
314 self.entries.push(LabeledPathDigestCacheEntry {
315 domain: domain.to_owned(),
316 label: label.to_owned(),
317 path: path.to_owned(),
318 canonical_root,
319 excluded_roots: effective_root_exclusions(
320 &trace.canonical_root,
321 excluded_roots,
322 trace.traversed_external_path,
323 )
324 .cloned()
325 .collect(),
326 traversed_external_path: trace.traversed_external_path,
327 digest,
328 });
329 Ok(digest)
330 }
331}
332
333fn effective_root_exclusions<'a>(
334 canonical_root: &'a Path,
335 excluded_roots: &'a [PathBuf],
336 traversed_external_path: bool,
337) -> impl Iterator<Item = &'a PathBuf> {
338 excluded_roots.iter().filter(move |excluded| {
339 traversed_external_path
340 || excluded.starts_with(canonical_root)
341 || canonical_root.starts_with(excluded)
342 })
343}
344
345fn hash_path(
346 hasher: &mut InputHasher,
347 label: &Path,
348 path: &Path,
349 excluded_roots: &[PathBuf],
350 visited_directories: &mut BTreeSet<PathBuf>,
351 declared_root: bool,
352 mut trace: Option<&mut HashPathTrace>,
353) -> io::Result<()> {
354 let context =
355 |error: io::Error| io::Error::new(error.kind(), format!("{}: {error}", path.display()));
356 let canonical = path.canonicalize().map_err(context)?;
357 if let Some(trace) = &mut trace
358 && !canonical.starts_with(&trace.canonical_root)
359 {
360 trace.traversed_external_path = true;
361 }
362 if excluded_roots
363 .iter()
364 .any(|excluded| canonical.starts_with(excluded))
365 {
366 if declared_root {
367 return Err(io::Error::new(
368 io::ErrorKind::InvalidInput,
369 format!(
370 "declared input is located inside an excluded cache root: {}",
371 path.display()
372 ),
373 ));
374 }
375 return Ok(());
376 }
377
378 let metadata = fs::metadata(path).map_err(context)?;
379 let label_bytes = os_bytes(label.as_os_str());
380 if metadata.is_file() {
381 hasher.field("file-path", &label_bytes);
382 hasher.file_field("file-content", path).map_err(context)?;
383 return Ok(());
384 }
385 if !metadata.is_dir() {
386 return Err(io::Error::new(
387 io::ErrorKind::InvalidInput,
388 format!(
389 "watched input is not a regular file or directory: {}",
390 path.display()
391 ),
392 ));
393 }
394
395 hasher.field("directory", &label_bytes);
396 if !visited_directories.insert(canonical) {
397 hasher.field("directory-already-visited", &label_bytes);
398 return Ok(());
399 }
400
401 let mut entries = fs::read_dir(path)
402 .map_err(context)?
403 .collect::<Result<Vec<_>, _>>()
404 .map_err(context)?;
405 entries.sort_by_cached_key(|entry| os_bytes(&entry.file_name()).into_owned());
406 for entry in entries {
407 hash_path(
408 hasher,
409 &label.join(entry.file_name()),
410 &entry.path(),
411 excluded_roots,
412 visited_directories,
413 false,
414 trace.as_deref_mut(),
415 )?;
416 }
417 Ok(())
418}
419
420pub(super) fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
421 write_file_atomic(path, |file| file.write_all(contents))
422}
423
424pub(super) fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<u64> {
425 let result = (|| {
426 let mut source_file = File::open(source)?;
427 write_file_atomic(destination, |destination_file| {
428 io::copy(&mut source_file, destination_file)
429 })
430 })();
431 result.map_err(|source_error| {
432 io::Error::new(
433 source_error.kind(),
434 AtomicCopyErrorContext {
435 source_path: source.to_owned(),
436 destination_path: destination.to_owned(),
437 source: source_error,
438 },
439 )
440 })
441}
442
443fn write_file_atomic<T>(
444 path: &Path,
445 write: impl FnOnce(&mut File) -> io::Result<T>,
446) -> io::Result<T> {
447 let parent = path.parent().ok_or_else(|| {
448 io::Error::new(
449 io::ErrorKind::InvalidInput,
450 format!("atomic output path has no parent: {}", path.display()),
451 )
452 })?;
453 fs::create_dir_all(parent)?;
454
455 let file_name = path.file_name().ok_or_else(|| {
456 io::Error::new(
457 io::ErrorKind::InvalidInput,
458 format!("atomic output path has no file name: {}", path.display()),
459 )
460 })?;
461 let temp_path = loop {
462 let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
463 let temp_name = format!(".ic-testkit-tmp-{}-{sequence}", std::process::id());
464 if !file_name
467 .as_encoded_bytes()
468 .eq_ignore_ascii_case(temp_name.as_bytes())
469 {
470 break parent.join(temp_name);
471 }
472 };
473
474 let mut file = OpenOptions::new()
476 .create_new(true)
477 .write(true)
478 .open(&temp_path)?;
479 let result = (|| {
480 let value = write(&mut file)?;
481 file.sync_all()?;
482 fs::rename(&temp_path, path)?;
483 Ok(value)
484 })();
485 drop(file);
486 if result.is_err() {
487 let _ = fs::remove_file(&temp_path);
488 }
489 result
490}
491
492#[cfg(unix)]
493pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
494 use std::os::unix::ffi::OsStrExt as _;
495 Cow::Borrowed(value.as_bytes())
496}
497
498#[cfg(windows)]
499pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
500 use std::os::windows::ffi::OsStrExt as _;
501 Cow::Owned(value.encode_wide().flat_map(u16::to_le_bytes).collect())
502}
503
504#[cfg(not(any(unix, windows)))]
505pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
506 Cow::Owned(value.to_string_lossy().as_bytes().to_vec())
507}
508
509#[cfg(test)]
510mod tests {
511 use super::{
512 LabeledPathDigestCache, copy_file_atomic, digest_bytes, digest_file,
513 digest_labeled_paths_composable, write_atomic,
514 };
515 use crate::artifacts::test_support::unique_temp_directory;
516 use std::{fs, path::PathBuf};
517
518 #[test]
519 fn digest_text_preserves_lowercase_hex_and_leading_zeroes() {
520 let digest = super::InputDigest(std::array::from_fn(|index| {
521 u8::try_from(index).expect("digest byte index must fit")
522 }));
523 let expected = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
524 assert_eq!(digest.to_hex(), expected);
525 assert_eq!(digest.to_string(), expected);
526 assert_eq!(super::InputDigest([0xff; 32]).to_string(), "ff".repeat(32));
527 }
528
529 #[test]
530 #[cfg(unix)]
531 fn labeled_path_digests_preserve_native_names_and_sorted_order() {
532 use super::{InputHasher, digest_labeled_paths};
533 use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
534
535 let root = unique_temp_directory("native-path-digest");
536 let tree = root.join("tree");
537 fs::create_dir_all(tree.join("nested")).unwrap();
538 fs::write(tree.join(OsStr::from_bytes(b"\xff")), b"native").unwrap();
539 fs::write(tree.join("nested/z"), b"last").unwrap();
540 fs::write(tree.join("a"), b"first").unwrap();
541 fs::write(root.join("top"), b"top").unwrap();
542 let mut paths = [
543 (PathBuf::from("tree"), tree),
544 (PathBuf::from("aaa"), root.join("top")),
545 ];
546
547 let tree_fields = |hasher: &mut InputHasher| {
548 hasher.field("directory", b"tree");
549 hasher.field("file-path", b"tree/a");
550 hasher.field("file-content", b"first");
551 hasher.field("directory", b"tree/nested");
552 hasher.field("file-path", b"tree/nested/z");
553 hasher.field("file-content", b"last");
554 hasher.field("file-path", b"tree/\xff");
555 hasher.field("file-content", b"native");
556 };
557 let mut expected = InputHasher::new("native-path-test-v1");
558 expected.field("file-path", b"aaa");
559 expected.field("file-content", b"top");
560 tree_fields(&mut expected);
561 let expected = expected.finish();
562
563 let mut top = InputHasher::new("native-path-test-v1/root-v1");
564 top.field("file-path", b"aaa");
565 top.field("file-content", b"top");
566 let mut tree = InputHasher::new("native-path-test-v1/root-v1");
567 tree_fields(&mut tree);
568 let mut composable = InputHasher::new("native-path-test-v1/composable-v1");
569 composable.field("input-label", b"aaa");
570 composable.field("input-digest", top.finish().as_bytes());
571 composable.field("input-label", b"tree");
572 composable.field("input-digest", tree.finish().as_bytes());
573 let composable = composable.finish();
574
575 for _ in 0..2 {
576 assert_eq!(
577 digest_labeled_paths(
578 "native-path-test-v1",
579 paths.iter().map(|(label, path)| (label, path)),
580 &[],
581 )
582 .unwrap(),
583 expected,
584 );
585 assert_eq!(
586 digest_labeled_paths_composable(
587 "native-path-test-v1",
588 paths
589 .iter()
590 .map(|(label, path)| (label.as_path(), path.as_path())),
591 &[],
592 &mut LabeledPathDigestCache::default(),
593 )
594 .unwrap(),
595 composable,
596 );
597 paths.reverse();
598 }
599 fs::remove_dir_all(root).unwrap();
600 }
601
602 #[test]
603 #[cfg(windows)]
604 fn native_names_preserve_utf16_little_endian_encoding() {
605 use std::{ffi::OsString, os::windows::ffi::OsStringExt as _};
606 let value = OsString::from_wide(&[0x0061, 0xd800, 0x0100]);
607 assert_eq!(super::os_bytes(&value).as_ref(), &[0x61, 0, 0, 0xd8, 0, 1]);
608 }
609
610 #[test]
611 fn streaming_digest_and_atomic_copy_preserve_exact_bytes() {
612 let root = unique_temp_directory("streaming-digest");
613 let source = root.join("source");
614 let destination = root.join("destination");
615 let mut contents = vec![0_u8; 192 * 1024 + 37];
616 for (index, byte) in contents.iter_mut().enumerate() {
617 *byte = u8::try_from(index % 251).expect("test byte must fit");
618 }
619 for length in [
620 0,
621 1,
622 1024,
623 16 * 1024,
624 64 * 1024 - 1,
625 64 * 1024,
626 64 * 1024 + 1,
627 contents.len(),
628 ] {
629 let data = &contents[..length];
630 fs::write(&source, data).expect("write source");
631 let streamed = digest_file("streaming-test-v1", &source).expect("digest file");
632 assert_eq!(
633 streamed.bytes,
634 u64::try_from(length).expect("fixture length must fit in u64")
635 );
636 assert_eq!(streamed.digest, digest_bytes("streaming-test-v1", data));
637 }
638
639 write_atomic(&destination, b"old").expect("write original destination");
640 assert_eq!(
641 copy_file_atomic(&source, &destination).expect("copy source atomically"),
642 u64::try_from(contents.len()).expect("fixture length must fit in u64")
643 );
644 assert_eq!(
645 fs::read(&destination).expect("read copied destination"),
646 contents
647 );
648
649 let missing = root.join("missing");
650 let error = copy_file_atomic(&missing, &destination).expect_err("missing source must fail");
651 let message = error.to_string();
652 assert!(message.contains(&missing.display().to_string()));
653 assert!(message.contains(&destination.display().to_string()));
654 fs::remove_dir_all(root).expect("remove streaming-digest test directory");
655 }
656
657 #[test]
658 fn atomic_creation_failure_preserves_existing_files() {
659 const CHILD_ENV: &str = "IC_TESTKIT_ATOMIC_CREATION_COLLISION_CHILD";
660 if std::env::var_os(CHILD_ENV).is_none() {
661 let child = std::process::Command::new(std::env::current_exe().unwrap())
663 .args([
664 "--exact",
665 "artifacts::digest::tests::atomic_creation_failure_preserves_existing_files",
666 "--test-threads=1",
667 ])
668 .env(CHILD_ENV, "1")
669 .output()
670 .unwrap();
671 assert!(
672 child.status.success(),
673 "collision regression failed: {}{}",
674 String::from_utf8_lossy(&child.stdout),
675 String::from_utf8_lossy(&child.stderr)
676 );
677 return;
678 }
679
680 let root = unique_temp_directory("atomic-creation-collision");
681 let destination = root.join("output");
682 fs::write(&destination, b"original output").unwrap();
683 let sequence = super::TEMP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
684 let existing = root.join(format!(".ic-testkit-tmp-{}-{sequence}", std::process::id()));
685 fs::write(&existing, b"existing temporary file").unwrap();
686
687 let error = write_atomic(&destination, b"replacement").unwrap_err();
688 assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
689 assert_eq!(fs::read(&destination).unwrap(), b"original output");
690 assert_eq!(fs::read(&existing).unwrap(), b"existing temporary file");
691
692 write_atomic(&destination, b"replacement").unwrap();
694 assert_eq!(fs::read(&destination).unwrap(), b"replacement");
695 assert_eq!(fs::read(&existing).unwrap(), b"existing temporary file");
696
697 let sequence = super::TEMP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
700 let destination = root.join(format!(".ic-testkit-tmp-{}-{sequence}", std::process::id()));
701 super::write_file_atomic(&destination, |file| {
702 assert!(!destination.exists());
703 std::io::Write::write_all(file, b"separate temporary file")
704 })
705 .unwrap();
706 assert_eq!(fs::read(&destination).unwrap(), b"separate temporary file");
707 fs::remove_dir_all(root).unwrap();
708 }
709
710 #[test]
711 fn atomic_publication_failures_remove_only_the_owned_temporary_file() {
712 use std::io::{self, Write as _};
713
714 let root = unique_temp_directory("atomic-publication-failure");
715 let destination = root.join("output");
716 fs::write(&destination, b"original output").unwrap();
717 let error = super::write_file_atomic(&destination, |file| {
718 file.write_all(b"partial output")?;
719 Err::<(), _>(io::Error::other("synthetic write failure"))
720 })
721 .unwrap_err();
722 assert_eq!(error.to_string(), "synthetic write failure");
723 assert_eq!(fs::read(&destination).unwrap(), b"original output");
724 assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
725
726 fs::remove_file(&destination).unwrap();
728 fs::create_dir(&destination).unwrap();
729 fs::write(destination.join("child"), b"original child").unwrap();
730 assert!(write_atomic(&destination, b"replacement").is_err());
731 assert_eq!(
732 fs::read(destination.join("child")).unwrap(),
733 b"original child"
734 );
735 assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
736 fs::remove_dir_all(root).unwrap();
737 }
738
739 #[test]
740 #[cfg(unix)]
741 fn atomic_publication_supports_long_destination_names() {
742 let root = unique_temp_directory("atomic-long-destination");
743 let destination = root.join("a".repeat(255));
744 fs::write(&destination, b"original output").unwrap();
746 write_atomic(&destination, b"replacement").unwrap();
747 assert_eq!(fs::read(&destination).unwrap(), b"replacement");
748
749 let source = root.join("source");
750 fs::write(&source, b"copied output").unwrap();
751 assert_eq!(copy_file_atomic(&source, &destination).unwrap(), 13);
752 assert_eq!(fs::read(&destination).unwrap(), b"copied output");
753 assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
754 fs::remove_dir_all(root).unwrap();
755 }
756
757 #[test]
758 fn composable_digest_reuses_roots_across_irrelevant_exclusion_changes() {
759 let root = unique_temp_directory("composable-digest-cache");
760 let input = root.join("input");
761 fs::create_dir_all(&input).expect("create composable input");
762 fs::create_dir_all(root.join("generated-a")).expect("create first generated root");
763 fs::create_dir_all(root.join("generated-b")).expect("create second generated root");
764 fs::write(input.join("source"), b"source").expect("write composable input");
765 let paths = [(PathBuf::from("shared"), input)];
766 let mut cache = LabeledPathDigestCache::default();
767
768 let first = digest_labeled_paths_composable(
769 "composable-test-v1",
770 paths
771 .iter()
772 .map(|(label, path)| (label.as_path(), path.as_path())),
773 &[root.join("generated-a")],
774 &mut cache,
775 )
776 .expect("hash first composable input");
777 let second = digest_labeled_paths_composable(
778 "composable-test-v1",
779 paths
780 .iter()
781 .map(|(label, path)| (label.as_path(), path.as_path())),
782 &[root.join("generated-b")],
783 &mut cache,
784 )
785 .expect("reuse composable input root");
786
787 assert_eq!(first, second);
788 assert_eq!(cache.entries.len(), 1);
789 fs::remove_dir_all(root).expect("remove composable digest fixture");
790 }
791
792 #[test]
793 fn composable_digest_rehashes_changed_descendant_exclusions_and_rejects_ancestors() {
794 let root = unique_temp_directory("composable-relevant-exclusions");
795 let input = root.join("input");
796 let generated = input.join("generated");
797 fs::create_dir_all(&generated).unwrap();
798 fs::write(input.join("source"), b"source").unwrap();
799 fs::write(generated.join("artifact"), b"generated").unwrap();
800 let paths = [(PathBuf::from("input"), input.clone())];
801 let digest = |exclusions: &[PathBuf], cache: &mut LabeledPathDigestCache| {
802 digest_labeled_paths_composable(
803 "exclusions-test-v1",
804 paths
805 .iter()
806 .map(|(label, path)| (label.as_path(), path.as_path())),
807 exclusions,
808 cache,
809 )
810 };
811 let mut cache = LabeledPathDigestCache::default();
812 let excluded = digest(std::slice::from_ref(&generated), &mut cache).unwrap();
813 let included = digest(&[], &mut cache).unwrap();
814 assert_ne!(included, excluded);
815 assert_eq!(
816 included,
817 digest(&[], &mut LabeledPathDigestCache::default()).unwrap(),
818 );
819 for ancestor in [&input, &root] {
820 assert_eq!(
821 digest(std::slice::from_ref(ancestor), &mut cache)
822 .unwrap_err()
823 .kind(),
824 std::io::ErrorKind::InvalidInput,
825 );
826 }
827 assert_eq!(
828 digest(std::slice::from_ref(&generated), &mut cache).unwrap(),
829 excluded,
830 );
831 fs::remove_dir_all(root).unwrap();
832 }
833
834 #[test]
835 #[cfg(unix)]
836 fn composable_digest_tracks_exclusions_beyond_an_external_symlink() {
837 let root = unique_temp_directory("composable-external-exclusions");
838 let input = root.join("input");
839 let external = root.join("external");
840 fs::create_dir_all(&input).unwrap();
841 fs::create_dir_all(external.join("first")).unwrap();
842 fs::create_dir_all(external.join("second")).unwrap();
843 fs::write(input.join("source"), b"source").unwrap();
844 fs::write(external.join("first/file"), b"first").unwrap();
845 fs::write(external.join("second/file"), b"second").unwrap();
846 std::os::unix::fs::symlink(&external, input.join("linked")).unwrap();
847 let paths = [(PathBuf::from("input"), input)];
848 let digest = |exclusion: &PathBuf, cache: &mut LabeledPathDigestCache| {
849 digest_labeled_paths_composable(
850 "external-exclusions-test-v1",
851 paths
852 .iter()
853 .map(|(label, path)| (label.as_path(), path.as_path())),
854 std::slice::from_ref(exclusion),
855 cache,
856 )
857 };
858 let mut cache = LabeledPathDigestCache::default();
859 let first = digest(&external.join("first"), &mut cache).unwrap();
860 let second = digest(&external.join("second"), &mut cache).unwrap();
861 assert_ne!(first, second);
862 assert_eq!(
863 second,
864 digest(
865 &external.join("second"),
866 &mut LabeledPathDigestCache::default(),
867 )
868 .unwrap(),
869 );
870 assert_eq!(digest(&external.join("first"), &mut cache).unwrap(), first);
871 fs::remove_dir_all(root).unwrap();
872 }
873}