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