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