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