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