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