1use sha2::{Digest, Sha256};
2use std::{
3 borrow::Cow,
4 collections::BTreeSet,
5 ffi::{OsStr, OsString},
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 pub(super) const fn from_bytes(bytes: [u8; 32]) -> Self {
48 Self(bytes)
49 }
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_artifacts::artifact::ArtifactError;
189 use ic_host_fs::read::read_file;
190
191 match read_file(path, maximum_len) {
194 Ok(contents) => Ok(Some(contents)),
195 Err(ArtifactError::LimitExceeded { .. }) => Ok(None),
196 Err(ArtifactError::NotRegularFile) => Err(io::Error::new(
197 io::ErrorKind::InvalidData,
198 ArtifactError::NotRegularFile,
199 )),
200 Err(error) => Err(error.into()),
201 }
202}
203
204fn destination_matches_digest(domain: &str, destination: &Path, expected: &FileDigest) -> bool {
207 destination_is_reusable(destination, expected.bytes)
208 && digest_file(domain, destination).is_ok_and(|actual| actual == *expected)
209}
210
211pub(super) fn materialize_verified_output(
214 domain: &str,
215 source: &Path,
216 destination: &Path,
217 verified: Option<&FileDigest>,
218) -> io::Result<()> {
219 if verified.is_some_and(|info| destination_matches_digest(domain, destination, info)) {
220 return Ok(());
221 }
222 copy_file_atomic(source, destination).map(|_| ())
223}
224
225pub(super) fn destination_matches_bytes(destination: &Path, expected: &[u8]) -> bool {
226 destination_is_reusable(
227 destination,
228 u64::try_from(expected.len()).expect("artifact byte length must fit in u64"),
229 ) && read_file_with_limit(destination, expected.len())
230 .is_ok_and(|actual| actual.as_deref() == Some(expected))
231}
232
233fn destination_is_reusable(destination: &Path, expected_bytes: u64) -> bool {
234 #[cfg(unix)]
235 {
236 let Ok(metadata) = fs::symlink_metadata(destination) else {
237 return false;
238 };
239 #[expect(unsafe_code, reason = "geteuid has no pointers and no failure case")]
241 let effective_uid = unsafe { libc::geteuid() };
242 if !metadata.file_type().is_file()
245 || metadata.nlink() != 1
246 || metadata.uid() != effective_uid
247 || metadata.mode() & 0o600 != 0o600
248 || metadata.mode() & 0o7111 != 0
249 || metadata.len() != expected_bytes
250 {
251 return false;
252 }
253 true
254 }
255 #[cfg(not(unix))]
256 {
257 let _ = (destination, expected_bytes);
259 false
260 }
261}
262
263fn resolve_excluded_roots(paths: &[PathBuf]) -> io::Result<Vec<PathBuf>> {
266 paths
267 .iter()
268 .filter_map(|path| match path.canonicalize() {
269 Ok(path) => Some(Ok(path)),
270 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
271 Err(error) => Some(Err(io::Error::new(
272 error.kind(),
273 format!(
274 "failed to resolve excluded root {}: {error}",
275 path.display()
276 ),
277 ))),
278 })
279 .collect()
280}
281
282pub(super) fn digest_labeled_paths<L: AsRef<Path>, P: AsRef<Path>>(
283 domain: &str,
284 paths: impl IntoIterator<Item = (L, P)>,
285 excluded_roots: &[PathBuf],
286) -> io::Result<InputDigest> {
287 let mut paths = paths.into_iter().collect::<Vec<_>>();
288 paths.sort_by(|(left, _), (right, _)| {
289 os_bytes(left.as_ref().as_os_str()).cmp(&os_bytes(right.as_ref().as_os_str()))
290 });
291
292 let excluded_roots = resolve_excluded_roots(excluded_roots)?;
293 let mut visited_directories = BTreeSet::new();
294 let mut hasher = InputHasher::new(domain);
295 for (label, path) in paths {
296 hash_path(
297 &mut hasher,
298 label.as_ref(),
299 path.as_ref(),
300 &excluded_roots,
301 &mut visited_directories,
302 true,
303 None,
304 )?;
305 }
306 Ok(hasher.finish())
307}
308
309#[derive(Default)]
310pub(super) struct LabeledPathDigestCache {
311 entries: Vec<LabeledPathDigestCacheEntry>,
312}
313
314struct LabeledPathDigestCacheEntry {
315 domain: String,
316 label: PathBuf,
317 path: PathBuf,
318 canonical_root: PathBuf,
319 excluded_roots: Vec<PathBuf>,
320 traversed_external_path: bool,
321 digest: InputDigest,
322}
323
324struct HashPathTrace {
325 canonical_root: PathBuf,
326 traversed_external_path: bool,
327}
328
329pub(super) fn digest_labeled_paths_composable<'a>(
330 domain: &str,
331 paths: impl IntoIterator<Item = (&'a Path, &'a Path)>,
332 excluded_roots: &[PathBuf],
333 cache: &mut LabeledPathDigestCache,
334) -> io::Result<InputDigest> {
335 let mut paths = paths.into_iter().collect::<Vec<_>>();
336 paths.sort_by(|(left, _), (right, _)| {
337 os_bytes(left.as_os_str()).cmp(&os_bytes(right.as_os_str()))
338 });
339 let excluded_roots = resolve_excluded_roots(excluded_roots)?;
340 let mut hasher = InputHasher::new(&format!("{domain}/composable-v1"));
341 for (label, path) in paths {
342 let digest = cache.digest_root(domain, label, path, &excluded_roots)?;
343 hasher.field("input-label", &os_bytes(label.as_os_str()));
344 hasher.field("input-digest", digest.as_bytes());
345 }
346 Ok(hasher.finish())
347}
348
349impl LabeledPathDigestCache {
350 fn digest_root(
351 &mut self,
352 domain: &str,
353 label: &Path,
354 path: &Path,
355 excluded_roots: &[PathBuf],
356 ) -> io::Result<InputDigest> {
357 let canonical_root = path.canonicalize()?;
358 if let Some(entry) = self.entries.iter().find(|entry| {
359 entry.domain == domain
360 && entry.label == label
361 && entry.path == path
362 && entry.excluded_roots.iter().eq(effective_root_exclusions(
363 &entry.canonical_root,
364 excluded_roots,
365 entry.traversed_external_path,
366 ))
367 }) {
368 return Ok(entry.digest);
369 }
370 let mut hasher = InputHasher::new(&format!("{domain}/root-v1"));
371 let mut trace = HashPathTrace {
372 canonical_root: canonical_root.clone(),
373 traversed_external_path: false,
374 };
375 hash_path(
376 &mut hasher,
377 label,
378 path,
379 excluded_roots,
380 &mut BTreeSet::new(),
381 true,
382 Some(&mut trace),
383 )?;
384 let digest = hasher.finish();
385 self.entries.push(LabeledPathDigestCacheEntry {
386 domain: domain.to_owned(),
387 label: label.to_owned(),
388 path: path.to_owned(),
389 canonical_root,
390 excluded_roots: effective_root_exclusions(
391 &trace.canonical_root,
392 excluded_roots,
393 trace.traversed_external_path,
394 )
395 .cloned()
396 .collect(),
397 traversed_external_path: trace.traversed_external_path,
398 digest,
399 });
400 Ok(digest)
401 }
402}
403
404fn effective_root_exclusions<'a>(
405 canonical_root: &'a Path,
406 excluded_roots: &'a [PathBuf],
407 traversed_external_path: bool,
408) -> impl Iterator<Item = &'a PathBuf> {
409 excluded_roots.iter().filter(move |excluded| {
410 traversed_external_path
411 || excluded.starts_with(canonical_root)
412 || canonical_root.starts_with(excluded)
413 })
414}
415
416fn hash_path(
417 hasher: &mut InputHasher,
418 label: &Path,
419 path: &Path,
420 excluded_roots: &[PathBuf],
421 visited_directories: &mut BTreeSet<PathBuf>,
422 declared_root: bool,
423 trace: Option<&mut HashPathTrace>,
424) -> io::Result<()> {
425 let mut traversal = InputTreeWalk {
426 excluded_roots,
427 excluded_names: &[],
428 visited_directories,
429 trace,
430 observe_missing: false,
431 reject_links: false,
432 };
433 traversal.visit(label, path, declared_root, &mut |entry| {
434 match entry {
435 InputTreeEntry::File { label, path } => {
436 hasher.field("file-path", &os_bytes(label.as_os_str()));
437 hasher.file_field("file-content", path)?;
438 }
439 InputTreeEntry::Directory {
440 label, repeated, ..
441 } => {
442 hasher.field("directory", &os_bytes(label.as_os_str()));
443 if repeated {
444 hasher.field("directory-already-visited", &os_bytes(label.as_os_str()));
445 }
446 }
447 InputTreeEntry::Missing { path } => {
448 return Err(io::Error::new(
449 io::ErrorKind::NotFound,
450 path.display().to_string(),
451 ));
452 }
453 }
454 Ok(())
455 })
456}
457
458pub(super) enum InputTreeEntry<'a> {
460 File {
461 label: &'a Path,
462 path: &'a Path,
463 },
464 Directory {
465 label: &'a Path,
466 path: &'a Path,
467 repeated: bool,
468 },
469 Missing {
470 path: &'a Path,
471 },
472}
473
474#[derive(Debug)]
476pub(super) struct InputPathError {
477 pub(super) path: PathBuf,
478 pub(super) source: io::Error,
479}
480
481impl std::fmt::Display for InputPathError {
482 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483 write!(formatter, "{}: {}", self.path.display(), self.source)
484 }
485}
486
487impl std::error::Error for InputPathError {
488 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
489 Some(&self.source)
490 }
491}
492
493pub(super) struct InputTreeWalk<'a> {
495 excluded_roots: &'a [PathBuf],
496 excluded_names: &'a [OsString],
497 visited_directories: &'a mut BTreeSet<PathBuf>,
498 trace: Option<&'a mut HashPathTrace>,
499 observe_missing: bool,
500 reject_links: bool,
501}
502
503impl<'a> InputTreeWalk<'a> {
504 pub(super) const fn evidence(
505 excluded_roots: &'a [PathBuf],
506 excluded_names: &'a [OsString],
507 visited_directories: &'a mut BTreeSet<PathBuf>,
508 ) -> Self {
509 Self {
510 excluded_roots,
511 excluded_names,
512 visited_directories,
513 trace: None,
514 observe_missing: true,
515 reject_links: true,
516 }
517 }
518
519 pub(super) fn visit(
520 &mut self,
521 label: &Path,
522 path: &Path,
523 declared_root: bool,
524 visitor: &mut impl FnMut(InputTreeEntry<'_>) -> io::Result<()>,
525 ) -> io::Result<()> {
526 let context = |source: io::Error| {
527 io::Error::new(
528 source.kind(),
529 InputPathError {
530 path: path.to_owned(),
531 source,
532 },
533 )
534 };
535 let inspected = if self.reject_links {
538 match fs::symlink_metadata(path) {
539 Ok(metadata) if metadata.file_type().is_symlink() => {
540 return Err(context(io::Error::new(
541 io::ErrorKind::InvalidInput,
542 "input is a symbolic link",
543 )));
544 }
545 Ok(metadata) => Some(metadata),
546 Err(error) if self.observe_missing && error.kind() == io::ErrorKind::NotFound => {
547 let resolved =
548 super::cache_fs::canonicalize_allow_missing(path).map_err(context)?;
549 if self
550 .excluded_roots
551 .iter()
552 .any(|root| resolved.starts_with(root))
553 {
554 if declared_root {
555 return Err(context(io::Error::new(
556 io::ErrorKind::InvalidInput,
557 "declared missing input is inside an excluded root",
558 )));
559 }
560 return Ok(());
561 }
562 return visitor(InputTreeEntry::Missing { path }).map_err(context);
563 }
564 Err(error) => return Err(context(error)),
565 }
566 } else {
567 None
568 };
569 let canonical = path.canonicalize().map_err(context)?;
570 if let Some(trace) = &mut self.trace
571 && !canonical.starts_with(&trace.canonical_root)
572 {
573 trace.traversed_external_path = true;
574 }
575 if self
576 .excluded_roots
577 .iter()
578 .any(|root| canonical.starts_with(root))
579 {
580 if declared_root {
581 return Err(context(io::Error::new(
582 io::ErrorKind::InvalidInput,
583 "declared input is inside an excluded root",
584 )));
585 }
586 return Ok(());
587 }
588 let metadata = match inspected {
589 Some(metadata) => metadata,
590 None => fs::metadata(path).map_err(context)?,
591 };
592 if metadata.is_file() {
593 return visitor(InputTreeEntry::File { label, path }).map_err(context);
594 }
595 if !metadata.is_dir() {
596 return Err(context(io::Error::new(
597 io::ErrorKind::InvalidInput,
598 "input is not a regular file or directory",
599 )));
600 }
601 let repeated = !self.visited_directories.insert(canonical) && !self.reject_links;
605 visitor(InputTreeEntry::Directory {
606 label,
607 path,
608 repeated,
609 })
610 .map_err(context)?;
611 if repeated {
612 return Ok(());
613 }
614 let mut entries = fs::read_dir(path)
615 .map_err(context)?
616 .map(|entry| entry.map(|entry| entry.file_name()))
617 .collect::<Result<Vec<_>, _>>()
618 .map_err(context)?;
619 #[cfg(unix)]
620 entries.sort_unstable_by(|left, right| os_bytes(left).cmp(&os_bytes(right)));
621 #[cfg(not(unix))]
622 entries.sort_by_cached_key(|name| os_bytes(name).into_owned());
623 for name in entries {
624 if self.excluded_names.contains(&name) {
625 continue;
626 }
627 self.visit(&label.join(&name), &path.join(&name), false, visitor)?;
628 }
629 Ok(())
630 }
631}
632
633pub(super) fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<u64> {
634 let result = (|| {
635 let mut source_file = File::open(source)?;
636 ic_host_fs::durable::write_with(
637 destination,
638 ic_host_fs::durable::WriteOptions {
639 mode: ic_host_fs::durable::PublicationMode::Replace,
640 permissions: 0o666,
641 },
642 |destination_file| io::copy(&mut source_file, destination_file),
643 )
644 .map_err(io::Error::other)
645 })();
646 result.map_err(|source_error| {
647 io::Error::new(
648 source_error.kind(),
649 AtomicCopyErrorContext {
650 source_path: source.to_owned(),
651 destination_path: destination.to_owned(),
652 source: source_error,
653 },
654 )
655 })
656}
657
658#[cfg(unix)]
659pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
660 Cow::Borrowed(value.as_bytes())
661}
662
663#[cfg(windows)]
664pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
665 Cow::Owned(value.encode_wide().flat_map(u16::to_le_bytes).collect())
666}
667
668#[cfg(not(any(unix, windows)))]
669pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
670 Cow::Owned(value.to_string_lossy().as_bytes().to_vec())
671}
672
673#[cfg(test)]
674mod tests {
675 use super::{
676 LabeledPathDigestCache, copy_file_atomic, digest_bytes, digest_file,
677 digest_labeled_paths_composable,
678 };
679 use crate::artifacts::test_support::unique_temp_directory;
680 use std::{fs, path::PathBuf};
681
682 #[cfg(unix)]
683 use super::{InputHasher, digest_labeled_paths};
684 #[cfg(unix)]
685 use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
686 #[cfg(windows)]
687 use std::{ffi::OsString, os::windows::ffi::OsStringExt as _};
688
689 #[test]
690 #[cfg(unix)]
691 fn exclusion_resolution_errors_stop_both_fingerprint_paths() {
692 use std::os::unix::fs::symlink;
693 let root = unique_temp_directory("invalid-digest-exclusions");
694 let input = root.join("input");
695 fs::write(&input, b"source").unwrap();
696 let cycle = root.join("cycle");
697 symlink("cycle", &cycle).unwrap();
698 let paths = [(std::path::Path::new("input"), input.as_path())];
699 assert!(
700 digest_labeled_paths("exclusions-v1", paths, std::slice::from_ref(&cycle)).is_err()
701 );
702 let mut cache = LabeledPathDigestCache::default();
703 let expected =
704 digest_labeled_paths_composable("exclusions-v1", paths, &[], &mut cache).unwrap();
705 assert!(
706 digest_labeled_paths_composable("exclusions-v1", paths, &[cycle], &mut cache).is_err()
707 );
708 assert_eq!(
711 digest_labeled_paths_composable(
712 "exclusions-v1",
713 paths,
714 &[root.join("missing")],
715 &mut cache,
716 )
717 .unwrap(),
718 expected
719 );
720 assert_eq!(
721 digest_labeled_paths("exclusions-v1", paths, &[]).unwrap(),
722 digest_labeled_paths("exclusions-v1", paths, &[root.join("missing")]).unwrap()
723 );
724 fs::remove_dir_all(root).unwrap();
725 }
726
727 #[test]
728 fn digest_text_preserves_lowercase_hex_and_leading_zeroes() {
729 let digest = super::InputDigest(std::array::from_fn(|index| {
730 u8::try_from(index).expect("digest byte index must fit")
731 }));
732 let expected = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
733 assert_eq!(digest.to_hex(), expected);
734 assert_eq!(digest.to_string(), expected);
735 assert_eq!(super::InputDigest([0xff; 32]).to_string(), "ff".repeat(32));
736 }
737
738 #[test]
739 #[cfg(unix)]
740 fn labeled_path_digests_preserve_native_names_and_sorted_order() {
741 let names: &[&[u8]] = &[
742 b"\xce\xbb",
743 #[cfg(target_os = "linux")]
744 b"\xff",
745 ];
746 for &name in names {
747 let root = unique_temp_directory("native-path-digest");
748 let tree = root.join("tree");
749 fs::create_dir_all(tree.join("nested")).unwrap();
750 fs::write(tree.join(OsStr::from_bytes(name)), b"native").unwrap();
751 fs::write(tree.join("nested/z"), b"last").unwrap();
752 fs::write(tree.join("a"), b"first").unwrap();
753 fs::write(root.join("top"), b"top").unwrap();
754 let mut paths = [
755 (PathBuf::from("tree"), tree),
756 (PathBuf::from("aaa"), root.join("top")),
757 ];
758
759 let tree_fields = |hasher: &mut InputHasher| {
760 hasher.field("directory", b"tree");
761 hasher.field("file-path", b"tree/a");
762 hasher.field("file-content", b"first");
763 hasher.field("directory", b"tree/nested");
764 hasher.field("file-path", b"tree/nested/z");
765 hasher.field("file-content", b"last");
766 hasher.field("file-path", &[b"tree/".as_slice(), name].concat());
767 hasher.field("file-content", b"native");
768 };
769 let mut expected = InputHasher::new("native-path-test-v1");
770 expected.field("file-path", b"aaa");
771 expected.field("file-content", b"top");
772 tree_fields(&mut expected);
773 let expected = expected.finish();
774
775 let mut top = InputHasher::new("native-path-test-v1/root-v1");
776 top.field("file-path", b"aaa");
777 top.field("file-content", b"top");
778 let mut tree = InputHasher::new("native-path-test-v1/root-v1");
779 tree_fields(&mut tree);
780 let mut composable = InputHasher::new("native-path-test-v1/composable-v1");
781 composable.field("input-label", b"aaa");
782 composable.field("input-digest", top.finish().as_bytes());
783 composable.field("input-label", b"tree");
784 composable.field("input-digest", tree.finish().as_bytes());
785 let composable = composable.finish();
786
787 for _ in 0..2 {
788 assert_eq!(
789 digest_labeled_paths(
790 "native-path-test-v1",
791 paths.iter().map(|(label, path)| (label, path)),
792 &[],
793 )
794 .unwrap(),
795 expected,
796 );
797 assert_eq!(
798 digest_labeled_paths_composable(
799 "native-path-test-v1",
800 paths
801 .iter()
802 .map(|(label, path)| (label.as_path(), path.as_path())),
803 &[],
804 &mut LabeledPathDigestCache::default(),
805 )
806 .unwrap(),
807 composable,
808 );
809 paths.reverse();
810 }
811 fs::remove_dir_all(root).unwrap();
812 }
813 }
814
815 #[test]
816 #[cfg(unix)]
817 fn native_bytes_preserve_non_utf8_without_a_filesystem_roundtrip() {
818 assert_eq!(
819 super::os_bytes(OsStr::from_bytes(b"name\xff")).as_ref(),
820 b"name\xff"
821 );
822 }
823
824 #[test]
825 #[cfg(windows)]
826 fn native_names_preserve_utf16_little_endian_encoding() {
827 let value = OsString::from_wide(&[0x0061, 0xd800, 0x0100]);
828 assert_eq!(super::os_bytes(&value).as_ref(), &[0x61, 0, 0, 0xd8, 0, 1]);
829 }
830
831 #[test]
832 fn streamed_fields_preserve_bytes_across_different_file_sizes() {
833 let root = unique_temp_directory("streamed-field-sizes");
834 let source = root.join("source");
835 let contents = (0..192 * 1024 + 37)
836 .map(|index| u8::try_from(index % 251).unwrap())
837 .collect::<Vec<_>>();
838 let mut streamed = super::InputHasher::new("streamed-fields-v1");
839 let mut expected = super::InputHasher::new("streamed-fields-v1");
840 for length in [1, 64 * 1024 - 1, contents.len(), 0, 7, 1024, 64 * 1024 + 1] {
841 let bytes = &contents[..length];
842 fs::write(&source, bytes).unwrap();
843 assert_eq!(streamed.file_field("part", &source).unwrap(), length as u64);
844 expected.field("part", bytes);
845 }
846 assert_eq!(streamed.finish(), expected.finish());
847 fs::remove_dir_all(root).unwrap();
848 }
849
850 #[test]
851 fn streaming_digest_and_atomic_copy_preserve_exact_bytes() {
852 let root = unique_temp_directory("streaming-digest");
853 let source = root.join("source");
854 let destination = root.join("destination");
855 let mut contents = vec![0_u8; 192 * 1024 + 37];
856 for (index, byte) in contents.iter_mut().enumerate() {
857 *byte = u8::try_from(index % 251).expect("test byte must fit");
858 }
859 for length in [
860 0,
861 1,
862 1024,
863 16 * 1024,
864 64 * 1024 - 1,
865 64 * 1024,
866 64 * 1024 + 1,
867 contents.len(),
868 ] {
869 let data = &contents[..length];
870 fs::write(&source, data).expect("write source");
871 let streamed = digest_file("streaming-test-v1", &source).expect("digest file");
872 assert_eq!(
873 streamed.bytes,
874 u64::try_from(length).expect("fixture length must fit in u64")
875 );
876 assert_eq!(streamed.digest, digest_bytes("streaming-test-v1", data));
877 }
878
879 ic_host_fs::durable::write_bytes(&destination, b"old").expect("write original destination");
880 assert_eq!(
881 copy_file_atomic(&source, &destination).expect("copy source atomically"),
882 u64::try_from(contents.len()).expect("fixture length must fit in u64")
883 );
884 assert_eq!(
885 fs::read(&destination).expect("read copied destination"),
886 contents
887 );
888
889 let missing = root.join("missing");
890 let error = copy_file_atomic(&missing, &destination).expect_err("missing source must fail");
891 let message = error.to_string();
892 assert!(message.contains(&missing.display().to_string()));
893 assert!(message.contains(&destination.display().to_string()));
894 fs::remove_dir_all(root).expect("remove streaming-digest test directory");
895 }
896
897 #[test]
898 #[cfg(unix)]
899 fn atomic_copy_retains_publication_phase_and_original_output_on_failure() {
900 use ic_host_fs::durable::NamedWriteError;
901
902 let root = unique_temp_directory("atomic-copy-publication-errors");
903 let destination = root.join("destination");
904 fs::write(&destination, b"retained").unwrap();
905 let error = copy_file_atomic(&root, &destination).unwrap_err();
908 let context = error
909 .get_ref()
910 .unwrap()
911 .downcast_ref::<super::AtomicCopyErrorContext>()
912 .unwrap();
913 let publication = context
914 .source
915 .get_ref()
916 .unwrap()
917 .downcast_ref::<NamedWriteError<std::io::Error>>()
918 .unwrap();
919 assert!(matches!(
920 publication,
921 NamedWriteError::Producer {
922 cleanup_error: None,
923 ..
924 }
925 ));
926 assert_eq!(fs::read(&destination).unwrap(), b"retained");
927 assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
928 fs::remove_dir_all(root).unwrap();
929 }
930
931 #[test]
932 #[cfg(unix)]
933 fn atomic_copy_rejects_invalid_destinations_without_publication() {
934 use ic_host_fs::durable::NamedWriteError;
935
936 let root = unique_temp_directory("atomic-copy-directory-suffix");
937 let source = root.join("source");
938 let destination = root.join("destination");
939 fs::write(&source, b"replacement").unwrap();
940 fs::write(&destination, b"retained").unwrap();
941 for (name, suffix) in [
942 ("destination", "/"),
943 ("destination", "/."),
944 ("missing/child", "/"),
945 ("missing/child", "/."),
946 ("missing/invalid\0name", ""),
947 ("invalid\0parent/child", ""),
948 ] {
949 let mut raw = root.join(name).into_os_string();
950 raw.push(suffix);
951 let target = PathBuf::from(raw);
952 let error = copy_file_atomic(&source, &target).unwrap_err();
953 let context = error
954 .get_ref()
955 .unwrap()
956 .downcast_ref::<super::AtomicCopyErrorContext>()
957 .unwrap();
958 assert_eq!(context.source_path, source);
959 assert_eq!(context.destination_path, target);
960 let publication = context
961 .source
962 .get_ref()
963 .unwrap()
964 .downcast_ref::<NamedWriteError<std::io::Error>>()
965 .unwrap();
966 assert!(matches!(
967 publication,
968 NamedWriteError::BeforePublication { source, cleanup_error: None }
969 if source.kind() == std::io::ErrorKind::InvalidInput
970 ));
971 assert_eq!(fs::read(&destination).unwrap(), b"retained");
972 assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
973 }
974 fs::remove_dir_all(root).unwrap();
975 }
976
977 #[test]
978 #[cfg(unix)]
979 fn atomic_publication_supports_long_destination_names() {
980 let root = unique_temp_directory("atomic-long-destination");
981 let destination = root.join("a".repeat(255));
982 fs::write(&destination, b"original output").unwrap();
984 let source = root.join("source");
985 fs::write(&source, b"copied output").unwrap();
986 assert_eq!(copy_file_atomic(&source, &destination).unwrap(), 13);
987 assert_eq!(fs::read(&destination).unwrap(), b"copied output");
988 assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
989 fs::remove_dir_all(root).unwrap();
990 }
991
992 #[test]
993 fn composable_digest_reuses_roots_across_irrelevant_exclusion_changes() {
994 let root = unique_temp_directory("composable-digest-cache");
995 let input = root.join("input");
996 fs::create_dir_all(&input).expect("create composable input");
997 fs::create_dir_all(root.join("generated-a")).expect("create first generated root");
998 fs::create_dir_all(root.join("generated-b")).expect("create second generated root");
999 fs::write(input.join("source"), b"source").expect("write composable input");
1000 let paths = [(PathBuf::from("shared"), input)];
1001 let mut cache = LabeledPathDigestCache::default();
1002
1003 let first = digest_labeled_paths_composable(
1004 "composable-test-v1",
1005 paths
1006 .iter()
1007 .map(|(label, path)| (label.as_path(), path.as_path())),
1008 &[root.join("generated-a")],
1009 &mut cache,
1010 )
1011 .expect("hash first composable input");
1012 let second = digest_labeled_paths_composable(
1013 "composable-test-v1",
1014 paths
1015 .iter()
1016 .map(|(label, path)| (label.as_path(), path.as_path())),
1017 &[root.join("generated-b")],
1018 &mut cache,
1019 )
1020 .expect("reuse composable input root");
1021
1022 assert_eq!(first, second);
1023 assert_eq!(cache.entries.len(), 1);
1024 fs::remove_dir_all(root).expect("remove composable digest fixture");
1025 }
1026
1027 #[test]
1028 fn composable_digest_rehashes_changed_descendant_exclusions_and_rejects_ancestors() {
1029 let root = unique_temp_directory("composable-relevant-exclusions");
1030 let input = root.join("input");
1031 let generated = input.join("generated");
1032 fs::create_dir_all(&generated).unwrap();
1033 fs::write(input.join("source"), b"source").unwrap();
1034 fs::write(generated.join("artifact"), b"generated").unwrap();
1035 let paths = [(PathBuf::from("input"), input.clone())];
1036 let digest = |exclusions: &[PathBuf], cache: &mut LabeledPathDigestCache| {
1037 digest_labeled_paths_composable(
1038 "exclusions-test-v1",
1039 paths
1040 .iter()
1041 .map(|(label, path)| (label.as_path(), path.as_path())),
1042 exclusions,
1043 cache,
1044 )
1045 };
1046 let mut cache = LabeledPathDigestCache::default();
1047 let excluded = digest(std::slice::from_ref(&generated), &mut cache).unwrap();
1048 let included = digest(&[], &mut cache).unwrap();
1049 assert_ne!(included, excluded);
1050 assert_eq!(
1051 included,
1052 digest(&[], &mut LabeledPathDigestCache::default()).unwrap(),
1053 );
1054 for ancestor in [&input, &root] {
1055 assert_eq!(
1056 digest(std::slice::from_ref(ancestor), &mut cache)
1057 .unwrap_err()
1058 .kind(),
1059 std::io::ErrorKind::InvalidInput,
1060 );
1061 }
1062 assert_eq!(
1063 digest(std::slice::from_ref(&generated), &mut cache).unwrap(),
1064 excluded,
1065 );
1066 fs::remove_dir_all(root).unwrap();
1067 }
1068
1069 #[test]
1070 #[cfg(unix)]
1071 fn composable_digest_tracks_exclusions_beyond_an_external_symlink() {
1072 let root = unique_temp_directory("composable-external-exclusions");
1073 let input = root.join("input");
1074 let external = root.join("external");
1075 fs::create_dir_all(&input).unwrap();
1076 fs::create_dir_all(external.join("first")).unwrap();
1077 fs::create_dir_all(external.join("second")).unwrap();
1078 fs::write(input.join("source"), b"source").unwrap();
1079 fs::write(external.join("first/file"), b"first").unwrap();
1080 fs::write(external.join("second/file"), b"second").unwrap();
1081 std::os::unix::fs::symlink(&external, input.join("linked")).unwrap();
1082 let paths = [(PathBuf::from("input"), input)];
1083 let digest = |exclusion: &PathBuf, cache: &mut LabeledPathDigestCache| {
1084 digest_labeled_paths_composable(
1085 "external-exclusions-test-v1",
1086 paths
1087 .iter()
1088 .map(|(label, path)| (label.as_path(), path.as_path())),
1089 std::slice::from_ref(exclusion),
1090 cache,
1091 )
1092 };
1093 let mut cache = LabeledPathDigestCache::default();
1094 let first = digest(&external.join("first"), &mut cache).unwrap();
1095 let second = digest(&external.join("second"), &mut cache).unwrap();
1096 assert_ne!(first, second);
1097 assert_eq!(
1098 second,
1099 digest(
1100 &external.join("second"),
1101 &mut LabeledPathDigestCache::default(),
1102 )
1103 .unwrap(),
1104 );
1105 assert_eq!(digest(&external.join("first"), &mut cache).unwrap(), first);
1106 fs::remove_dir_all(root).unwrap();
1107 }
1108}