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