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