1use sha2::{Digest, Sha256};
2use std::{
3 borrow::Cow,
4 collections::BTreeSet,
5 ffi::OsStr,
6 fmt::Write as _,
7 fs::{self, File},
8 io::{self, Read as _},
9 path::{Path, PathBuf},
10};
11
12#[cfg(unix)]
13use std::os::unix::{ffi::OsStrExt as _, fs::MetadataExt as _};
14#[cfg(windows)]
15use std::os::windows::ffi::OsStrExt as _;
16
17#[derive(Debug)]
18struct AtomicCopyErrorContext {
19 source_path: PathBuf,
20 destination_path: PathBuf,
21 source: io::Error,
22}
23
24impl std::fmt::Display for AtomicCopyErrorContext {
25 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 write!(
27 formatter,
28 "failed to atomically copy {} to {}: {}",
29 self.source_path.display(),
30 self.destination_path.display(),
31 self.source
32 )
33 }
34}
35
36impl std::error::Error for AtomicCopyErrorContext {
37 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
38 Some(&self.source)
39 }
40}
41
42#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44pub struct InputDigest([u8; 32]);
45
46impl InputDigest {
47 #[must_use]
49 pub const fn as_bytes(&self) -> &[u8; 32] {
50 &self.0
51 }
52
53 #[must_use]
55 pub fn to_hex(self) -> String {
56 let mut hex = String::with_capacity(64);
57 write!(hex, "{self}").expect("writing to a String cannot fail");
58 hex
59 }
60}
61
62impl std::fmt::Display for InputDigest {
63 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 for byte in self.0 {
65 write!(formatter, "{byte:02x}")?;
66 }
67 Ok(())
68 }
69}
70
71pub(super) struct InputHasher {
72 state: Sha256,
73 read_buffer: Vec<u8>,
74}
75
76impl InputHasher {
77 pub(super) fn new(domain: &str) -> Self {
78 let mut hasher = Self {
79 state: Sha256::new(),
80 read_buffer: Vec::new(),
81 };
82 hasher.field("domain", domain.as_bytes());
83 hasher
84 }
85
86 pub(super) fn field(&mut self, label: &str, value: &[u8]) {
87 self.field_header(
88 label,
89 u64::try_from(value.len()).expect("input value length must fit in u64"),
90 );
91 self.state.update(value);
92 }
93
94 fn field_header(&mut self, label: &str, value_len: u64) {
95 self.state.update(
96 u64::try_from(label.len())
97 .expect("input label length must fit in u64")
98 .to_le_bytes(),
99 );
100 self.state.update(label.as_bytes());
101 self.state.update(value_len.to_le_bytes());
102 }
103
104 fn file_field(&mut self, label: &str, path: &Path) -> io::Result<u64> {
105 let mut file = File::open(path)?;
106 let expected_len = file.metadata()?.len();
107 self.field_header(label, expected_len);
108
109 let mut actual_len = 0_u64;
110 let buffer_len = usize::try_from(expected_len.clamp(1, 64 * 1024))
113 .expect("bounded artifact buffer length must fit in usize");
114 if self.read_buffer.len() < buffer_len {
117 self.read_buffer
120 .reserve_exact(buffer_len - self.read_buffer.len());
121 self.read_buffer.resize(buffer_len, 0);
122 }
123 loop {
124 let read = file.read(&mut self.read_buffer[..buffer_len])?;
125 if read == 0 {
126 break;
127 }
128 actual_len = actual_len
129 .saturating_add(u64::try_from(read).expect("artifact read length must fit in u64"));
130 if actual_len > expected_len {
131 break;
132 }
133 self.state.update(&self.read_buffer[..read]);
134 }
135 if actual_len != expected_len {
136 return Err(io::Error::new(
137 io::ErrorKind::InvalidData,
138 format!(
139 "file changed size while hashing: expected {expected_len} bytes, read {actual_len}"
140 ),
141 ));
142 }
143 Ok(actual_len)
144 }
145
146 pub(super) fn finish(self) -> InputDigest {
147 InputDigest(self.state.finalize().into())
148 }
149}
150
151pub(super) fn digest_bytes(domain: &str, value: &[u8]) -> InputDigest {
152 let mut hasher = InputHasher::new(domain);
153 hasher.field("content", value);
154 hasher.finish()
155}
156
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub(super) struct FileDigest {
159 pub(super) bytes: u64,
160 pub(super) digest: InputDigest,
161}
162
163pub(super) fn digest_file(domain: &str, path: &Path) -> io::Result<FileDigest> {
164 let mut hasher = InputHasher::new(domain);
165 let bytes = hasher.file_field("content", path)?;
166 Ok(FileDigest {
167 bytes,
168 digest: hasher.finish(),
169 })
170}
171
172pub(super) fn read_stamp_with_limit(path: &Path, maximum_len: usize) -> io::Result<Option<String>> {
175 read_file_with_limit(path, maximum_len)?
176 .map(|contents| {
177 String::from_utf8(contents)
178 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
179 })
180 .transpose()
181}
182
183pub(super) fn read_file_with_limit(path: &Path, maximum_len: usize) -> io::Result<Option<Vec<u8>>> {
185 use ic_host_artifacts::artifact::ArtifactError;
186 use ic_host_fs::read::read_file;
187
188 match read_file(path, maximum_len) {
191 Ok(contents) => Ok(Some(contents)),
192 Err(ArtifactError::LimitExceeded { .. }) => Ok(None),
193 Err(ArtifactError::Io(source)) => Err(source),
194 Err(ArtifactError::NotRegularFile) => Err(io::Error::new(
195 io::ErrorKind::InvalidData,
196 ArtifactError::NotRegularFile,
197 )),
198 Err(error) => Err(io::Error::other(error)),
199 }
200}
201
202pub(super) fn destination_matches_digest(
205 domain: &str,
206 destination: &Path,
207 expected: &FileDigest,
208) -> bool {
209 destination_is_reusable(destination, expected.bytes)
210 && digest_file(domain, destination).is_ok_and(|actual| actual == *expected)
211}
212
213pub(super) fn destination_matches_bytes(destination: &Path, expected: &[u8]) -> bool {
214 destination_is_reusable(
215 destination,
216 u64::try_from(expected.len()).expect("artifact byte length must fit in u64"),
217 ) && read_file_with_limit(destination, expected.len())
218 .is_ok_and(|actual| actual.as_deref() == Some(expected))
219}
220
221fn destination_is_reusable(destination: &Path, expected_bytes: u64) -> bool {
222 #[cfg(unix)]
223 {
224 let Ok(metadata) = fs::symlink_metadata(destination) else {
225 return false;
226 };
227 let effective_uid = unsafe { libc::geteuid() };
229 if !metadata.file_type().is_file()
232 || metadata.nlink() != 1
233 || metadata.uid() != effective_uid
234 || metadata.mode() & 0o600 != 0o600
235 || metadata.mode() & 0o7111 != 0
236 || metadata.len() != expected_bytes
237 {
238 return false;
239 }
240 true
241 }
242 #[cfg(not(unix))]
243 {
244 let _ = (destination, expected_bytes);
246 false
247 }
248}
249
250pub(super) fn digest_labeled_paths<L: AsRef<Path>, P: AsRef<Path>>(
251 domain: &str,
252 paths: impl IntoIterator<Item = (L, P)>,
253 excluded_roots: &[PathBuf],
254) -> io::Result<InputDigest> {
255 let mut paths = paths.into_iter().collect::<Vec<_>>();
256 paths.sort_by(|(left, _), (right, _)| {
257 os_bytes(left.as_ref().as_os_str()).cmp(&os_bytes(right.as_ref().as_os_str()))
258 });
259
260 let excluded_roots = excluded_roots
261 .iter()
262 .filter_map(|path| path.canonicalize().ok())
263 .collect::<Vec<_>>();
264 let mut visited_directories = BTreeSet::new();
265 let mut hasher = InputHasher::new(domain);
266 for (label, path) in paths {
267 hash_path(
268 &mut hasher,
269 label.as_ref(),
270 path.as_ref(),
271 &excluded_roots,
272 &mut visited_directories,
273 true,
274 None,
275 )?;
276 }
277 Ok(hasher.finish())
278}
279
280#[derive(Default)]
281pub(super) struct LabeledPathDigestCache {
282 entries: Vec<LabeledPathDigestCacheEntry>,
283}
284
285struct LabeledPathDigestCacheEntry {
286 domain: String,
287 label: PathBuf,
288 path: PathBuf,
289 canonical_root: PathBuf,
290 excluded_roots: Vec<PathBuf>,
291 traversed_external_path: bool,
292 digest: InputDigest,
293}
294
295struct HashPathTrace {
296 canonical_root: PathBuf,
297 traversed_external_path: bool,
298}
299
300pub(super) fn digest_labeled_paths_composable<'a>(
301 domain: &str,
302 paths: impl IntoIterator<Item = (&'a Path, &'a Path)>,
303 excluded_roots: &[PathBuf],
304 cache: &mut LabeledPathDigestCache,
305) -> io::Result<InputDigest> {
306 let mut paths = paths.into_iter().collect::<Vec<_>>();
307 paths.sort_by(|(left, _), (right, _)| {
308 os_bytes(left.as_os_str()).cmp(&os_bytes(right.as_os_str()))
309 });
310 let excluded_roots = excluded_roots
311 .iter()
312 .filter_map(|path| path.canonicalize().ok())
313 .collect::<Vec<_>>();
314 let mut hasher = InputHasher::new(&format!("{domain}/composable-v1"));
315 for (label, path) in paths {
316 let digest = cache.digest_root(domain, label, path, &excluded_roots)?;
317 hasher.field("input-label", &os_bytes(label.as_os_str()));
318 hasher.field("input-digest", digest.as_bytes());
319 }
320 Ok(hasher.finish())
321}
322
323impl LabeledPathDigestCache {
324 fn digest_root(
325 &mut self,
326 domain: &str,
327 label: &Path,
328 path: &Path,
329 excluded_roots: &[PathBuf],
330 ) -> io::Result<InputDigest> {
331 let canonical_root = path.canonicalize()?;
332 if let Some(entry) = self.entries.iter().find(|entry| {
333 entry.domain == domain
334 && entry.label == label
335 && entry.path == path
336 && entry.excluded_roots.iter().eq(effective_root_exclusions(
337 &entry.canonical_root,
338 excluded_roots,
339 entry.traversed_external_path,
340 ))
341 }) {
342 return Ok(entry.digest);
343 }
344 let mut hasher = InputHasher::new(&format!("{domain}/root-v1"));
345 let mut trace = HashPathTrace {
346 canonical_root: canonical_root.clone(),
347 traversed_external_path: false,
348 };
349 hash_path(
350 &mut hasher,
351 label,
352 path,
353 excluded_roots,
354 &mut BTreeSet::new(),
355 true,
356 Some(&mut trace),
357 )?;
358 let digest = hasher.finish();
359 self.entries.push(LabeledPathDigestCacheEntry {
360 domain: domain.to_owned(),
361 label: label.to_owned(),
362 path: path.to_owned(),
363 canonical_root,
364 excluded_roots: effective_root_exclusions(
365 &trace.canonical_root,
366 excluded_roots,
367 trace.traversed_external_path,
368 )
369 .cloned()
370 .collect(),
371 traversed_external_path: trace.traversed_external_path,
372 digest,
373 });
374 Ok(digest)
375 }
376}
377
378fn effective_root_exclusions<'a>(
379 canonical_root: &'a Path,
380 excluded_roots: &'a [PathBuf],
381 traversed_external_path: bool,
382) -> impl Iterator<Item = &'a PathBuf> {
383 excluded_roots.iter().filter(move |excluded| {
384 traversed_external_path
385 || excluded.starts_with(canonical_root)
386 || canonical_root.starts_with(excluded)
387 })
388}
389
390fn hash_path(
391 hasher: &mut InputHasher,
392 label: &Path,
393 path: &Path,
394 excluded_roots: &[PathBuf],
395 visited_directories: &mut BTreeSet<PathBuf>,
396 declared_root: bool,
397 mut trace: Option<&mut HashPathTrace>,
398) -> io::Result<()> {
399 let context =
400 |error: io::Error| io::Error::new(error.kind(), format!("{}: {error}", path.display()));
401 let canonical = path.canonicalize().map_err(context)?;
402 if let Some(trace) = &mut trace
403 && !canonical.starts_with(&trace.canonical_root)
404 {
405 trace.traversed_external_path = true;
406 }
407 if excluded_roots
408 .iter()
409 .any(|excluded| canonical.starts_with(excluded))
410 {
411 if declared_root {
412 return Err(io::Error::new(
413 io::ErrorKind::InvalidInput,
414 format!(
415 "declared input is located inside an excluded cache root: {}",
416 path.display()
417 ),
418 ));
419 }
420 return Ok(());
421 }
422
423 let metadata = fs::metadata(path).map_err(context)?;
424 let label_bytes = os_bytes(label.as_os_str());
425 if metadata.is_file() {
426 hasher.field("file-path", &label_bytes);
427 hasher.file_field("file-content", path).map_err(context)?;
428 return Ok(());
429 }
430 if !metadata.is_dir() {
431 return Err(io::Error::new(
432 io::ErrorKind::InvalidInput,
433 format!(
434 "watched input is not a regular file or directory: {}",
435 path.display()
436 ),
437 ));
438 }
439
440 hasher.field("directory", &label_bytes);
441 if !visited_directories.insert(canonical) {
442 hasher.field("directory-already-visited", &label_bytes);
443 return Ok(());
444 }
445
446 let mut entries = fs::read_dir(path)
447 .map_err(context)?
448 .map(|entry| entry.map(|entry| entry.file_name()))
449 .collect::<Result<Vec<_>, _>>()
450 .map_err(context)?;
451 #[cfg(unix)]
454 entries.sort_unstable_by(|left, right| os_bytes(left).cmp(&os_bytes(right)));
455 #[cfg(not(unix))]
457 entries.sort_by_cached_key(|name| os_bytes(name).into_owned());
458 for name in entries {
459 hash_path(
460 hasher,
461 &label.join(&name),
462 &path.join(&name),
463 excluded_roots,
464 visited_directories,
465 false,
466 trace.as_deref_mut(),
467 )?;
468 }
469 Ok(())
470}
471
472pub(super) fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<u64> {
473 let result = (|| {
474 let mut source_file = File::open(source)?;
475 ic_host_fs::durable::write_with(destination, |destination_file| {
476 io::copy(&mut source_file, destination_file)
477 })
478 })();
479 result.map_err(|source_error| {
480 io::Error::new(
481 source_error.kind(),
482 AtomicCopyErrorContext {
483 source_path: source.to_owned(),
484 destination_path: destination.to_owned(),
485 source: source_error,
486 },
487 )
488 })
489}
490
491#[cfg(unix)]
492pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
493 Cow::Borrowed(value.as_bytes())
494}
495
496#[cfg(windows)]
497pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
498 Cow::Owned(value.encode_wide().flat_map(u16::to_le_bytes).collect())
499}
500
501#[cfg(not(any(unix, windows)))]
502pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
503 Cow::Owned(value.to_string_lossy().as_bytes().to_vec())
504}
505
506#[cfg(test)]
507mod tests {
508 use super::{
509 LabeledPathDigestCache, copy_file_atomic, digest_bytes, digest_file,
510 digest_labeled_paths_composable,
511 };
512 use crate::artifacts::test_support::unique_temp_directory;
513 use std::{fs, path::PathBuf};
514
515 #[cfg(unix)]
516 use super::{InputHasher, digest_labeled_paths};
517 #[cfg(unix)]
518 use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
519 #[cfg(windows)]
520 use std::{ffi::OsString, os::windows::ffi::OsStringExt as _};
521
522 #[test]
523 fn digest_text_preserves_lowercase_hex_and_leading_zeroes() {
524 let digest = super::InputDigest(std::array::from_fn(|index| {
525 u8::try_from(index).expect("digest byte index must fit")
526 }));
527 let expected = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
528 assert_eq!(digest.to_hex(), expected);
529 assert_eq!(digest.to_string(), expected);
530 assert_eq!(super::InputDigest([0xff; 32]).to_string(), "ff".repeat(32));
531 }
532
533 #[test]
534 #[cfg(unix)]
535 fn labeled_path_digests_preserve_native_names_and_sorted_order() {
536 let names: &[&[u8]] = &[
537 b"\xce\xbb",
538 #[cfg(target_os = "linux")]
539 b"\xff",
540 ];
541 for &name in names {
542 let root = unique_temp_directory("native-path-digest");
543 let tree = root.join("tree");
544 fs::create_dir_all(tree.join("nested")).unwrap();
545 fs::write(tree.join(OsStr::from_bytes(name)), b"native").unwrap();
546 fs::write(tree.join("nested/z"), b"last").unwrap();
547 fs::write(tree.join("a"), b"first").unwrap();
548 fs::write(root.join("top"), b"top").unwrap();
549 let mut paths = [
550 (PathBuf::from("tree"), tree),
551 (PathBuf::from("aaa"), root.join("top")),
552 ];
553
554 let tree_fields = |hasher: &mut InputHasher| {
555 hasher.field("directory", b"tree");
556 hasher.field("file-path", b"tree/a");
557 hasher.field("file-content", b"first");
558 hasher.field("directory", b"tree/nested");
559 hasher.field("file-path", b"tree/nested/z");
560 hasher.field("file-content", b"last");
561 hasher.field("file-path", &[b"tree/".as_slice(), name].concat());
562 hasher.field("file-content", b"native");
563 };
564 let mut expected = InputHasher::new("native-path-test-v1");
565 expected.field("file-path", b"aaa");
566 expected.field("file-content", b"top");
567 tree_fields(&mut expected);
568 let expected = expected.finish();
569
570 let mut top = InputHasher::new("native-path-test-v1/root-v1");
571 top.field("file-path", b"aaa");
572 top.field("file-content", b"top");
573 let mut tree = InputHasher::new("native-path-test-v1/root-v1");
574 tree_fields(&mut tree);
575 let mut composable = InputHasher::new("native-path-test-v1/composable-v1");
576 composable.field("input-label", b"aaa");
577 composable.field("input-digest", top.finish().as_bytes());
578 composable.field("input-label", b"tree");
579 composable.field("input-digest", tree.finish().as_bytes());
580 let composable = composable.finish();
581
582 for _ in 0..2 {
583 assert_eq!(
584 digest_labeled_paths(
585 "native-path-test-v1",
586 paths.iter().map(|(label, path)| (label, path)),
587 &[],
588 )
589 .unwrap(),
590 expected,
591 );
592 assert_eq!(
593 digest_labeled_paths_composable(
594 "native-path-test-v1",
595 paths
596 .iter()
597 .map(|(label, path)| (label.as_path(), path.as_path())),
598 &[],
599 &mut LabeledPathDigestCache::default(),
600 )
601 .unwrap(),
602 composable,
603 );
604 paths.reverse();
605 }
606 fs::remove_dir_all(root).unwrap();
607 }
608 }
609
610 #[test]
611 #[cfg(unix)]
612 fn native_bytes_preserve_non_utf8_without_a_filesystem_roundtrip() {
613 assert_eq!(
614 super::os_bytes(OsStr::from_bytes(b"name\xff")).as_ref(),
615 b"name\xff"
616 );
617 }
618
619 #[test]
620 #[cfg(windows)]
621 fn native_names_preserve_utf16_little_endian_encoding() {
622 let value = OsString::from_wide(&[0x0061, 0xd800, 0x0100]);
623 assert_eq!(super::os_bytes(&value).as_ref(), &[0x61, 0, 0, 0xd8, 0, 1]);
624 }
625
626 #[test]
627 fn streamed_fields_preserve_bytes_across_different_file_sizes() {
628 let root = unique_temp_directory("streamed-field-sizes");
629 let source = root.join("source");
630 let contents = (0..192 * 1024 + 37)
631 .map(|index| u8::try_from(index % 251).unwrap())
632 .collect::<Vec<_>>();
633 let mut streamed = super::InputHasher::new("streamed-fields-v1");
634 let mut expected = super::InputHasher::new("streamed-fields-v1");
635 for length in [1, 64 * 1024 - 1, contents.len(), 0, 7, 1024, 64 * 1024 + 1] {
636 let bytes = &contents[..length];
637 fs::write(&source, bytes).unwrap();
638 assert_eq!(streamed.file_field("part", &source).unwrap(), length as u64);
639 expected.field("part", bytes);
640 }
641 assert_eq!(streamed.finish(), expected.finish());
642 fs::remove_dir_all(root).unwrap();
643 }
644
645 #[test]
646 fn streaming_digest_and_atomic_copy_preserve_exact_bytes() {
647 let root = unique_temp_directory("streaming-digest");
648 let source = root.join("source");
649 let destination = root.join("destination");
650 let mut contents = vec![0_u8; 192 * 1024 + 37];
651 for (index, byte) in contents.iter_mut().enumerate() {
652 *byte = u8::try_from(index % 251).expect("test byte must fit");
653 }
654 for length in [
655 0,
656 1,
657 1024,
658 16 * 1024,
659 64 * 1024 - 1,
660 64 * 1024,
661 64 * 1024 + 1,
662 contents.len(),
663 ] {
664 let data = &contents[..length];
665 fs::write(&source, data).expect("write source");
666 let streamed = digest_file("streaming-test-v1", &source).expect("digest file");
667 assert_eq!(
668 streamed.bytes,
669 u64::try_from(length).expect("fixture length must fit in u64")
670 );
671 assert_eq!(streamed.digest, digest_bytes("streaming-test-v1", data));
672 }
673
674 ic_host_fs::durable::write_bytes(&destination, b"old").expect("write original destination");
675 assert_eq!(
676 copy_file_atomic(&source, &destination).expect("copy source atomically"),
677 u64::try_from(contents.len()).expect("fixture length must fit in u64")
678 );
679 assert_eq!(
680 fs::read(&destination).expect("read copied destination"),
681 contents
682 );
683
684 let missing = root.join("missing");
685 let error = copy_file_atomic(&missing, &destination).expect_err("missing source must fail");
686 let message = error.to_string();
687 assert!(message.contains(&missing.display().to_string()));
688 assert!(message.contains(&destination.display().to_string()));
689 fs::remove_dir_all(root).expect("remove streaming-digest test directory");
690 }
691
692 #[test]
693 #[cfg(unix)]
694 fn atomic_publication_supports_long_destination_names() {
695 let root = unique_temp_directory("atomic-long-destination");
696 let destination = root.join("a".repeat(255));
697 fs::write(&destination, b"original output").unwrap();
699 let source = root.join("source");
700 fs::write(&source, b"copied output").unwrap();
701 assert_eq!(copy_file_atomic(&source, &destination).unwrap(), 13);
702 assert_eq!(fs::read(&destination).unwrap(), b"copied output");
703 assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
704 fs::remove_dir_all(root).unwrap();
705 }
706
707 #[test]
708 fn composable_digest_reuses_roots_across_irrelevant_exclusion_changes() {
709 let root = unique_temp_directory("composable-digest-cache");
710 let input = root.join("input");
711 fs::create_dir_all(&input).expect("create composable input");
712 fs::create_dir_all(root.join("generated-a")).expect("create first generated root");
713 fs::create_dir_all(root.join("generated-b")).expect("create second generated root");
714 fs::write(input.join("source"), b"source").expect("write composable input");
715 let paths = [(PathBuf::from("shared"), input)];
716 let mut cache = LabeledPathDigestCache::default();
717
718 let first = digest_labeled_paths_composable(
719 "composable-test-v1",
720 paths
721 .iter()
722 .map(|(label, path)| (label.as_path(), path.as_path())),
723 &[root.join("generated-a")],
724 &mut cache,
725 )
726 .expect("hash first composable input");
727 let second = digest_labeled_paths_composable(
728 "composable-test-v1",
729 paths
730 .iter()
731 .map(|(label, path)| (label.as_path(), path.as_path())),
732 &[root.join("generated-b")],
733 &mut cache,
734 )
735 .expect("reuse composable input root");
736
737 assert_eq!(first, second);
738 assert_eq!(cache.entries.len(), 1);
739 fs::remove_dir_all(root).expect("remove composable digest fixture");
740 }
741
742 #[test]
743 fn composable_digest_rehashes_changed_descendant_exclusions_and_rejects_ancestors() {
744 let root = unique_temp_directory("composable-relevant-exclusions");
745 let input = root.join("input");
746 let generated = input.join("generated");
747 fs::create_dir_all(&generated).unwrap();
748 fs::write(input.join("source"), b"source").unwrap();
749 fs::write(generated.join("artifact"), b"generated").unwrap();
750 let paths = [(PathBuf::from("input"), input.clone())];
751 let digest = |exclusions: &[PathBuf], cache: &mut LabeledPathDigestCache| {
752 digest_labeled_paths_composable(
753 "exclusions-test-v1",
754 paths
755 .iter()
756 .map(|(label, path)| (label.as_path(), path.as_path())),
757 exclusions,
758 cache,
759 )
760 };
761 let mut cache = LabeledPathDigestCache::default();
762 let excluded = digest(std::slice::from_ref(&generated), &mut cache).unwrap();
763 let included = digest(&[], &mut cache).unwrap();
764 assert_ne!(included, excluded);
765 assert_eq!(
766 included,
767 digest(&[], &mut LabeledPathDigestCache::default()).unwrap(),
768 );
769 for ancestor in [&input, &root] {
770 assert_eq!(
771 digest(std::slice::from_ref(ancestor), &mut cache)
772 .unwrap_err()
773 .kind(),
774 std::io::ErrorKind::InvalidInput,
775 );
776 }
777 assert_eq!(
778 digest(std::slice::from_ref(&generated), &mut cache).unwrap(),
779 excluded,
780 );
781 fs::remove_dir_all(root).unwrap();
782 }
783
784 #[test]
785 #[cfg(unix)]
786 fn composable_digest_tracks_exclusions_beyond_an_external_symlink() {
787 let root = unique_temp_directory("composable-external-exclusions");
788 let input = root.join("input");
789 let external = root.join("external");
790 fs::create_dir_all(&input).unwrap();
791 fs::create_dir_all(external.join("first")).unwrap();
792 fs::create_dir_all(external.join("second")).unwrap();
793 fs::write(input.join("source"), b"source").unwrap();
794 fs::write(external.join("first/file"), b"first").unwrap();
795 fs::write(external.join("second/file"), b"second").unwrap();
796 std::os::unix::fs::symlink(&external, input.join("linked")).unwrap();
797 let paths = [(PathBuf::from("input"), input)];
798 let digest = |exclusion: &PathBuf, cache: &mut LabeledPathDigestCache| {
799 digest_labeled_paths_composable(
800 "external-exclusions-test-v1",
801 paths
802 .iter()
803 .map(|(label, path)| (label.as_path(), path.as_path())),
804 std::slice::from_ref(exclusion),
805 cache,
806 )
807 };
808 let mut cache = LabeledPathDigestCache::default();
809 let first = digest(&external.join("first"), &mut cache).unwrap();
810 let second = digest(&external.join("second"), &mut cache).unwrap();
811 assert_ne!(first, second);
812 assert_eq!(
813 second,
814 digest(
815 &external.join("second"),
816 &mut LabeledPathDigestCache::default(),
817 )
818 .unwrap(),
819 );
820 assert_eq!(digest(&external.join("first"), &mut cache).unwrap(), first);
821 fs::remove_dir_all(root).unwrap();
822 }
823}