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