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