Skip to main content

ic_build/artifacts/
digest.rs

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/// SHA-256 digest of one deterministic artifact-input set.
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44pub struct InputDigest([u8; 32]);
45
46impl InputDigest {
47    /// Borrow the raw SHA-256 bytes.
48    #[must_use]
49    pub const fn as_bytes(&self) -> &[u8; 32] {
50        &self.0
51    }
52
53    /// Render the digest as lowercase hexadecimal.
54    #[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        // Small sources need only their declared length; large artifacts use bounded reads.
111        // Even empty files need a nonempty read buffer to detect growth.
112        let buffer_len = usize::try_from(expected_len.clamp(1, 64 * 1024))
113            .expect("bounded artifact buffer length must fit in usize");
114        // A tree hashes many files with one hasher. Retain its bounded scratch
115        // space, reading only this file's window and hashing only returned bytes.
116        if self.read_buffer.len() < buffer_len {
117            // Geometric growth near the read limit would retain almost twice
118            // the scratch space required by any file in this hashing pass.
119            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
172/// Read a UTF-8 stamp without allocating or reading an oversized sidecar in full.
173/// An oversized stamp is stale; other read and decoding errors reach the caller.
174pub(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
183/// Read at most the format's maximum length plus one byte to detect oversized files.
184pub(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    // The shared library owns bounded reading and allocation. Cache policy owns
189    // the meaning of overflow: an oversized stamp or manifest is a cache miss.
190    match read_file(path, maximum_len) {
191        Ok(contents) => Ok(Some(contents)),
192        Err(ArtifactError::LimitExceeded { .. }) => Ok(None),
193        Err(ArtifactError::NotRegularFile) => Err(io::Error::new(
194            io::ErrorKind::InvalidData,
195            ArtifactError::NotRegularFile,
196        )),
197        Err(error) => Err(error.into()),
198    }
199}
200
201/// Only reuse an independent, caller-owned writable destination. The caller
202/// coordinates other writers and supplies a digest from a verified cache entry.
203pub(super) fn destination_matches_digest(
204    domain: &str,
205    destination: &Path,
206    expected: &FileDigest,
207) -> bool {
208    destination_is_reusable(destination, expected.bytes)
209        && digest_file(domain, destination).is_ok_and(|actual| actual == *expected)
210}
211
212pub(super) fn destination_matches_bytes(destination: &Path, expected: &[u8]) -> bool {
213    destination_is_reusable(
214        destination,
215        u64::try_from(expected.len()).expect("artifact byte length must fit in u64"),
216    ) && read_file_with_limit(destination, expected.len())
217        .is_ok_and(|actual| actual.as_deref() == Some(expected))
218}
219
220fn destination_is_reusable(destination: &Path, expected_bytes: u64) -> bool {
221    #[cfg(unix)]
222    {
223        let Ok(metadata) = fs::symlink_metadata(destination) else {
224            return false;
225        };
226        // SAFETY: geteuid takes no pointers and has no failure case.
227        #[expect(unsafe_code, reason = "geteuid has no pointers and no failure case")]
228        let effective_uid = unsafe { libc::geteuid() };
229        // Detach links and normalize foreign-owned, restricted or executable
230        // files, even when their bytes match a retained artifact.
231        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        // Preserve replacement where a portable single-link check is unavailable.
245        let _ = (destination, expected_bytes);
246        false
247    }
248}
249
250// A cache root may not have been created yet. Other failures must not silently
251// remove a declared exclusion and change which inputs contribute to identity.
252fn resolve_excluded_roots(paths: &[PathBuf]) -> io::Result<Vec<PathBuf>> {
253    paths
254        .iter()
255        .filter_map(|path| match path.canonicalize() {
256            Ok(path) => Some(Ok(path)),
257            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
258            Err(error) => Some(Err(io::Error::new(
259                error.kind(),
260                format!(
261                    "failed to resolve excluded root {}: {error}",
262                    path.display()
263                ),
264            ))),
265        })
266        .collect()
267}
268
269pub(super) fn digest_labeled_paths<L: AsRef<Path>, P: AsRef<Path>>(
270    domain: &str,
271    paths: impl IntoIterator<Item = (L, P)>,
272    excluded_roots: &[PathBuf],
273) -> io::Result<InputDigest> {
274    let mut paths = paths.into_iter().collect::<Vec<_>>();
275    paths.sort_by(|(left, _), (right, _)| {
276        os_bytes(left.as_ref().as_os_str()).cmp(&os_bytes(right.as_ref().as_os_str()))
277    });
278
279    let excluded_roots = resolve_excluded_roots(excluded_roots)?;
280    let mut visited_directories = BTreeSet::new();
281    let mut hasher = InputHasher::new(domain);
282    for (label, path) in paths {
283        hash_path(
284            &mut hasher,
285            label.as_ref(),
286            path.as_ref(),
287            &excluded_roots,
288            &mut visited_directories,
289            true,
290            None,
291        )?;
292    }
293    Ok(hasher.finish())
294}
295
296#[derive(Default)]
297pub(super) struct LabeledPathDigestCache {
298    entries: Vec<LabeledPathDigestCacheEntry>,
299}
300
301struct LabeledPathDigestCacheEntry {
302    domain: String,
303    label: PathBuf,
304    path: PathBuf,
305    canonical_root: PathBuf,
306    excluded_roots: Vec<PathBuf>,
307    traversed_external_path: bool,
308    digest: InputDigest,
309}
310
311struct HashPathTrace {
312    canonical_root: PathBuf,
313    traversed_external_path: bool,
314}
315
316pub(super) fn digest_labeled_paths_composable<'a>(
317    domain: &str,
318    paths: impl IntoIterator<Item = (&'a Path, &'a Path)>,
319    excluded_roots: &[PathBuf],
320    cache: &mut LabeledPathDigestCache,
321) -> io::Result<InputDigest> {
322    let mut paths = paths.into_iter().collect::<Vec<_>>();
323    paths.sort_by(|(left, _), (right, _)| {
324        os_bytes(left.as_os_str()).cmp(&os_bytes(right.as_os_str()))
325    });
326    let excluded_roots = resolve_excluded_roots(excluded_roots)?;
327    let mut hasher = InputHasher::new(&format!("{domain}/composable-v1"));
328    for (label, path) in paths {
329        let digest = cache.digest_root(domain, label, path, &excluded_roots)?;
330        hasher.field("input-label", &os_bytes(label.as_os_str()));
331        hasher.field("input-digest", digest.as_bytes());
332    }
333    Ok(hasher.finish())
334}
335
336impl LabeledPathDigestCache {
337    fn digest_root(
338        &mut self,
339        domain: &str,
340        label: &Path,
341        path: &Path,
342        excluded_roots: &[PathBuf],
343    ) -> io::Result<InputDigest> {
344        let canonical_root = path.canonicalize()?;
345        if let Some(entry) = self.entries.iter().find(|entry| {
346            entry.domain == domain
347                && entry.label == label
348                && entry.path == path
349                && entry.excluded_roots.iter().eq(effective_root_exclusions(
350                    &entry.canonical_root,
351                    excluded_roots,
352                    entry.traversed_external_path,
353                ))
354        }) {
355            return Ok(entry.digest);
356        }
357        let mut hasher = InputHasher::new(&format!("{domain}/root-v1"));
358        let mut trace = HashPathTrace {
359            canonical_root: canonical_root.clone(),
360            traversed_external_path: false,
361        };
362        hash_path(
363            &mut hasher,
364            label,
365            path,
366            excluded_roots,
367            &mut BTreeSet::new(),
368            true,
369            Some(&mut trace),
370        )?;
371        let digest = hasher.finish();
372        self.entries.push(LabeledPathDigestCacheEntry {
373            domain: domain.to_owned(),
374            label: label.to_owned(),
375            path: path.to_owned(),
376            canonical_root,
377            excluded_roots: effective_root_exclusions(
378                &trace.canonical_root,
379                excluded_roots,
380                trace.traversed_external_path,
381            )
382            .cloned()
383            .collect(),
384            traversed_external_path: trace.traversed_external_path,
385            digest,
386        });
387        Ok(digest)
388    }
389}
390
391fn effective_root_exclusions<'a>(
392    canonical_root: &'a Path,
393    excluded_roots: &'a [PathBuf],
394    traversed_external_path: bool,
395) -> impl Iterator<Item = &'a PathBuf> {
396    excluded_roots.iter().filter(move |excluded| {
397        traversed_external_path
398            || excluded.starts_with(canonical_root)
399            || canonical_root.starts_with(excluded)
400    })
401}
402
403fn hash_path(
404    hasher: &mut InputHasher,
405    label: &Path,
406    path: &Path,
407    excluded_roots: &[PathBuf],
408    visited_directories: &mut BTreeSet<PathBuf>,
409    declared_root: bool,
410    mut trace: Option<&mut HashPathTrace>,
411) -> io::Result<()> {
412    let context =
413        |error: io::Error| io::Error::new(error.kind(), format!("{}: {error}", path.display()));
414    let canonical = path.canonicalize().map_err(context)?;
415    if let Some(trace) = &mut trace
416        && !canonical.starts_with(&trace.canonical_root)
417    {
418        trace.traversed_external_path = true;
419    }
420    if excluded_roots
421        .iter()
422        .any(|excluded| canonical.starts_with(excluded))
423    {
424        if declared_root {
425            return Err(io::Error::new(
426                io::ErrorKind::InvalidInput,
427                format!(
428                    "declared input is located inside an excluded cache root: {}",
429                    path.display()
430                ),
431            ));
432        }
433        return Ok(());
434    }
435
436    let metadata = fs::metadata(path).map_err(context)?;
437    let label_bytes = os_bytes(label.as_os_str());
438    if metadata.is_file() {
439        hasher.field("file-path", &label_bytes);
440        hasher.file_field("file-content", path).map_err(context)?;
441        return Ok(());
442    }
443    if !metadata.is_dir() {
444        return Err(io::Error::new(
445            io::ErrorKind::InvalidInput,
446            format!(
447                "watched input is not a regular file or directory: {}",
448                path.display()
449            ),
450        ));
451    }
452
453    hasher.field("directory", &label_bytes);
454    if !visited_directories.insert(canonical) {
455        hasher.field("directory-already-visited", &label_bytes);
456        return Ok(());
457    }
458
459    let mut entries = fs::read_dir(path)
460        .map_err(context)?
461        .map(|entry| entry.map(|entry| entry.file_name()))
462        .collect::<Result<Vec<_>, _>>()
463        .map_err(context)?;
464    // Unix names already own their native byte ordering. Compare borrowed
465    // bytes rather than allocating a second name and cached key per entry.
466    #[cfg(unix)]
467    entries.sort_unstable_by(|left, right| os_bytes(left).cmp(&os_bytes(right)));
468    // Other hosts may need an allocated native encoding; compute it once.
469    #[cfg(not(unix))]
470    entries.sort_by_cached_key(|name| os_bytes(name).into_owned());
471    for name in entries {
472        hash_path(
473            hasher,
474            &label.join(&name),
475            &path.join(&name),
476            excluded_roots,
477            visited_directories,
478            false,
479            trace.as_deref_mut(),
480        )?;
481    }
482    Ok(())
483}
484
485pub(super) fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<u64> {
486    let result = (|| {
487        let mut source_file = File::open(source)?;
488        ic_host_fs::durable::write_with(
489            destination,
490            ic_host_fs::durable::WriteOptions {
491                mode: ic_host_fs::durable::PublicationMode::Replace,
492                permissions: 0o666,
493            },
494            |destination_file| io::copy(&mut source_file, destination_file),
495        )
496        .map_err(io::Error::other)
497    })();
498    result.map_err(|source_error| {
499        io::Error::new(
500            source_error.kind(),
501            AtomicCopyErrorContext {
502                source_path: source.to_owned(),
503                destination_path: destination.to_owned(),
504                source: source_error,
505            },
506        )
507    })
508}
509
510#[cfg(unix)]
511pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
512    Cow::Borrowed(value.as_bytes())
513}
514
515#[cfg(windows)]
516pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
517    Cow::Owned(value.encode_wide().flat_map(u16::to_le_bytes).collect())
518}
519
520#[cfg(not(any(unix, windows)))]
521pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
522    Cow::Owned(value.to_string_lossy().as_bytes().to_vec())
523}
524
525#[cfg(test)]
526mod tests {
527    use super::{
528        LabeledPathDigestCache, copy_file_atomic, digest_bytes, digest_file,
529        digest_labeled_paths_composable,
530    };
531    use crate::artifacts::test_support::unique_temp_directory;
532    use std::{fs, path::PathBuf};
533
534    #[cfg(unix)]
535    use super::{InputHasher, digest_labeled_paths};
536    #[cfg(unix)]
537    use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
538    #[cfg(windows)]
539    use std::{ffi::OsString, os::windows::ffi::OsStringExt as _};
540
541    #[test]
542    #[cfg(unix)]
543    fn exclusion_resolution_errors_stop_both_fingerprint_paths() {
544        use std::os::unix::fs::symlink;
545        let root = unique_temp_directory("invalid-digest-exclusions");
546        let input = root.join("input");
547        fs::write(&input, b"source").unwrap();
548        let cycle = root.join("cycle");
549        symlink("cycle", &cycle).unwrap();
550        let paths = [(std::path::Path::new("input"), input.as_path())];
551        assert!(
552            digest_labeled_paths("exclusions-v1", paths, std::slice::from_ref(&cycle)).is_err()
553        );
554        let mut cache = LabeledPathDigestCache::default();
555        let expected =
556            digest_labeled_paths_composable("exclusions-v1", paths, &[], &mut cache).unwrap();
557        assert!(
558            digest_labeled_paths_composable("exclusions-v1", paths, &[cycle], &mut cache).is_err()
559        );
560        // A not-yet-created cache root remains an accepted exclusion, including
561        // on a reused source-lease digest. A failed resolution cannot reuse it.
562        assert_eq!(
563            digest_labeled_paths_composable(
564                "exclusions-v1",
565                paths,
566                &[root.join("missing")],
567                &mut cache,
568            )
569            .unwrap(),
570            expected
571        );
572        assert_eq!(
573            digest_labeled_paths("exclusions-v1", paths, &[]).unwrap(),
574            digest_labeled_paths("exclusions-v1", paths, &[root.join("missing")]).unwrap()
575        );
576        fs::remove_dir_all(root).unwrap();
577    }
578
579    #[test]
580    fn digest_text_preserves_lowercase_hex_and_leading_zeroes() {
581        let digest = super::InputDigest(std::array::from_fn(|index| {
582            u8::try_from(index).expect("digest byte index must fit")
583        }));
584        let expected = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
585        assert_eq!(digest.to_hex(), expected);
586        assert_eq!(digest.to_string(), expected);
587        assert_eq!(super::InputDigest([0xff; 32]).to_string(), "ff".repeat(32));
588    }
589
590    #[test]
591    #[cfg(unix)]
592    fn labeled_path_digests_preserve_native_names_and_sorted_order() {
593        let names: &[&[u8]] = &[
594            b"\xce\xbb",
595            #[cfg(target_os = "linux")]
596            b"\xff",
597        ];
598        for &name in names {
599            let root = unique_temp_directory("native-path-digest");
600            let tree = root.join("tree");
601            fs::create_dir_all(tree.join("nested")).unwrap();
602            fs::write(tree.join(OsStr::from_bytes(name)), b"native").unwrap();
603            fs::write(tree.join("nested/z"), b"last").unwrap();
604            fs::write(tree.join("a"), b"first").unwrap();
605            fs::write(root.join("top"), b"top").unwrap();
606            let mut paths = [
607                (PathBuf::from("tree"), tree),
608                (PathBuf::from("aaa"), root.join("top")),
609            ];
610
611            let tree_fields = |hasher: &mut InputHasher| {
612                hasher.field("directory", b"tree");
613                hasher.field("file-path", b"tree/a");
614                hasher.field("file-content", b"first");
615                hasher.field("directory", b"tree/nested");
616                hasher.field("file-path", b"tree/nested/z");
617                hasher.field("file-content", b"last");
618                hasher.field("file-path", &[b"tree/".as_slice(), name].concat());
619                hasher.field("file-content", b"native");
620            };
621            let mut expected = InputHasher::new("native-path-test-v1");
622            expected.field("file-path", b"aaa");
623            expected.field("file-content", b"top");
624            tree_fields(&mut expected);
625            let expected = expected.finish();
626
627            let mut top = InputHasher::new("native-path-test-v1/root-v1");
628            top.field("file-path", b"aaa");
629            top.field("file-content", b"top");
630            let mut tree = InputHasher::new("native-path-test-v1/root-v1");
631            tree_fields(&mut tree);
632            let mut composable = InputHasher::new("native-path-test-v1/composable-v1");
633            composable.field("input-label", b"aaa");
634            composable.field("input-digest", top.finish().as_bytes());
635            composable.field("input-label", b"tree");
636            composable.field("input-digest", tree.finish().as_bytes());
637            let composable = composable.finish();
638
639            for _ in 0..2 {
640                assert_eq!(
641                    digest_labeled_paths(
642                        "native-path-test-v1",
643                        paths.iter().map(|(label, path)| (label, path)),
644                        &[],
645                    )
646                    .unwrap(),
647                    expected,
648                );
649                assert_eq!(
650                    digest_labeled_paths_composable(
651                        "native-path-test-v1",
652                        paths
653                            .iter()
654                            .map(|(label, path)| (label.as_path(), path.as_path())),
655                        &[],
656                        &mut LabeledPathDigestCache::default(),
657                    )
658                    .unwrap(),
659                    composable,
660                );
661                paths.reverse();
662            }
663            fs::remove_dir_all(root).unwrap();
664        }
665    }
666
667    #[test]
668    #[cfg(unix)]
669    fn native_bytes_preserve_non_utf8_without_a_filesystem_roundtrip() {
670        assert_eq!(
671            super::os_bytes(OsStr::from_bytes(b"name\xff")).as_ref(),
672            b"name\xff"
673        );
674    }
675
676    #[test]
677    #[cfg(windows)]
678    fn native_names_preserve_utf16_little_endian_encoding() {
679        let value = OsString::from_wide(&[0x0061, 0xd800, 0x0100]);
680        assert_eq!(super::os_bytes(&value).as_ref(), &[0x61, 0, 0, 0xd8, 0, 1]);
681    }
682
683    #[test]
684    fn streamed_fields_preserve_bytes_across_different_file_sizes() {
685        let root = unique_temp_directory("streamed-field-sizes");
686        let source = root.join("source");
687        let contents = (0..192 * 1024 + 37)
688            .map(|index| u8::try_from(index % 251).unwrap())
689            .collect::<Vec<_>>();
690        let mut streamed = super::InputHasher::new("streamed-fields-v1");
691        let mut expected = super::InputHasher::new("streamed-fields-v1");
692        for length in [1, 64 * 1024 - 1, contents.len(), 0, 7, 1024, 64 * 1024 + 1] {
693            let bytes = &contents[..length];
694            fs::write(&source, bytes).unwrap();
695            assert_eq!(streamed.file_field("part", &source).unwrap(), length as u64);
696            expected.field("part", bytes);
697        }
698        assert_eq!(streamed.finish(), expected.finish());
699        fs::remove_dir_all(root).unwrap();
700    }
701
702    #[test]
703    fn streaming_digest_and_atomic_copy_preserve_exact_bytes() {
704        let root = unique_temp_directory("streaming-digest");
705        let source = root.join("source");
706        let destination = root.join("destination");
707        let mut contents = vec![0_u8; 192 * 1024 + 37];
708        for (index, byte) in contents.iter_mut().enumerate() {
709            *byte = u8::try_from(index % 251).expect("test byte must fit");
710        }
711        for length in [
712            0,
713            1,
714            1024,
715            16 * 1024,
716            64 * 1024 - 1,
717            64 * 1024,
718            64 * 1024 + 1,
719            contents.len(),
720        ] {
721            let data = &contents[..length];
722            fs::write(&source, data).expect("write source");
723            let streamed = digest_file("streaming-test-v1", &source).expect("digest file");
724            assert_eq!(
725                streamed.bytes,
726                u64::try_from(length).expect("fixture length must fit in u64")
727            );
728            assert_eq!(streamed.digest, digest_bytes("streaming-test-v1", data));
729        }
730
731        ic_host_fs::durable::write_bytes(&destination, b"old").expect("write original destination");
732        assert_eq!(
733            copy_file_atomic(&source, &destination).expect("copy source atomically"),
734            u64::try_from(contents.len()).expect("fixture length must fit in u64")
735        );
736        assert_eq!(
737            fs::read(&destination).expect("read copied destination"),
738            contents
739        );
740
741        let missing = root.join("missing");
742        let error = copy_file_atomic(&missing, &destination).expect_err("missing source must fail");
743        let message = error.to_string();
744        assert!(message.contains(&missing.display().to_string()));
745        assert!(message.contains(&destination.display().to_string()));
746        fs::remove_dir_all(root).expect("remove streaming-digest test directory");
747    }
748
749    #[test]
750    #[cfg(unix)]
751    fn atomic_copy_retains_publication_phase_and_original_output_on_failure() {
752        use ic_host_fs::durable::NamedWriteError;
753
754        let root = unique_temp_directory("atomic-copy-publication-errors");
755        let destination = root.join("destination");
756        fs::write(&destination, b"retained").unwrap();
757        // Opening a directory succeeds on Unix, but copying its bytes fails
758        // inside the producer after staging has started.
759        let error = copy_file_atomic(&root, &destination).unwrap_err();
760        let context = error
761            .get_ref()
762            .unwrap()
763            .downcast_ref::<super::AtomicCopyErrorContext>()
764            .unwrap();
765        let publication = context
766            .source
767            .get_ref()
768            .unwrap()
769            .downcast_ref::<NamedWriteError<std::io::Error>>()
770            .unwrap();
771        assert!(matches!(
772            publication,
773            NamedWriteError::Producer {
774                cleanup_error: None,
775                ..
776            }
777        ));
778        assert_eq!(fs::read(&destination).unwrap(), b"retained");
779        assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
780        fs::remove_dir_all(root).unwrap();
781    }
782
783    #[test]
784    #[cfg(unix)]
785    fn atomic_copy_rejects_invalid_destinations_without_publication() {
786        use ic_host_fs::durable::NamedWriteError;
787
788        let root = unique_temp_directory("atomic-copy-directory-suffix");
789        let source = root.join("source");
790        let destination = root.join("destination");
791        fs::write(&source, b"replacement").unwrap();
792        fs::write(&destination, b"retained").unwrap();
793        for (name, suffix) in [
794            ("destination", "/"),
795            ("destination", "/."),
796            ("missing/child", "/"),
797            ("missing/child", "/."),
798            ("missing/invalid\0name", ""),
799            ("invalid\0parent/child", ""),
800        ] {
801            let mut raw = root.join(name).into_os_string();
802            raw.push(suffix);
803            let target = PathBuf::from(raw);
804            let error = copy_file_atomic(&source, &target).unwrap_err();
805            let context = error
806                .get_ref()
807                .unwrap()
808                .downcast_ref::<super::AtomicCopyErrorContext>()
809                .unwrap();
810            assert_eq!(context.source_path, source);
811            assert_eq!(context.destination_path, target);
812            let publication = context
813                .source
814                .get_ref()
815                .unwrap()
816                .downcast_ref::<NamedWriteError<std::io::Error>>()
817                .unwrap();
818            assert!(matches!(
819                publication,
820                NamedWriteError::BeforePublication { source, cleanup_error: None }
821                    if source.kind() == std::io::ErrorKind::InvalidInput
822            ));
823            assert_eq!(fs::read(&destination).unwrap(), b"retained");
824            assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
825        }
826        fs::remove_dir_all(root).unwrap();
827    }
828
829    #[test]
830    #[cfg(unix)]
831    fn atomic_publication_supports_long_destination_names() {
832        let root = unique_temp_directory("atomic-long-destination");
833        let destination = root.join("a".repeat(255));
834        // Establish that the destination itself is valid on this filesystem.
835        fs::write(&destination, b"original output").unwrap();
836        let source = root.join("source");
837        fs::write(&source, b"copied output").unwrap();
838        assert_eq!(copy_file_atomic(&source, &destination).unwrap(), 13);
839        assert_eq!(fs::read(&destination).unwrap(), b"copied output");
840        assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
841        fs::remove_dir_all(root).unwrap();
842    }
843
844    #[test]
845    fn composable_digest_reuses_roots_across_irrelevant_exclusion_changes() {
846        let root = unique_temp_directory("composable-digest-cache");
847        let input = root.join("input");
848        fs::create_dir_all(&input).expect("create composable input");
849        fs::create_dir_all(root.join("generated-a")).expect("create first generated root");
850        fs::create_dir_all(root.join("generated-b")).expect("create second generated root");
851        fs::write(input.join("source"), b"source").expect("write composable input");
852        let paths = [(PathBuf::from("shared"), input)];
853        let mut cache = LabeledPathDigestCache::default();
854
855        let first = digest_labeled_paths_composable(
856            "composable-test-v1",
857            paths
858                .iter()
859                .map(|(label, path)| (label.as_path(), path.as_path())),
860            &[root.join("generated-a")],
861            &mut cache,
862        )
863        .expect("hash first composable input");
864        let second = digest_labeled_paths_composable(
865            "composable-test-v1",
866            paths
867                .iter()
868                .map(|(label, path)| (label.as_path(), path.as_path())),
869            &[root.join("generated-b")],
870            &mut cache,
871        )
872        .expect("reuse composable input root");
873
874        assert_eq!(first, second);
875        assert_eq!(cache.entries.len(), 1);
876        fs::remove_dir_all(root).expect("remove composable digest fixture");
877    }
878
879    #[test]
880    fn composable_digest_rehashes_changed_descendant_exclusions_and_rejects_ancestors() {
881        let root = unique_temp_directory("composable-relevant-exclusions");
882        let input = root.join("input");
883        let generated = input.join("generated");
884        fs::create_dir_all(&generated).unwrap();
885        fs::write(input.join("source"), b"source").unwrap();
886        fs::write(generated.join("artifact"), b"generated").unwrap();
887        let paths = [(PathBuf::from("input"), input.clone())];
888        let digest = |exclusions: &[PathBuf], cache: &mut LabeledPathDigestCache| {
889            digest_labeled_paths_composable(
890                "exclusions-test-v1",
891                paths
892                    .iter()
893                    .map(|(label, path)| (label.as_path(), path.as_path())),
894                exclusions,
895                cache,
896            )
897        };
898        let mut cache = LabeledPathDigestCache::default();
899        let excluded = digest(std::slice::from_ref(&generated), &mut cache).unwrap();
900        let included = digest(&[], &mut cache).unwrap();
901        assert_ne!(included, excluded);
902        assert_eq!(
903            included,
904            digest(&[], &mut LabeledPathDigestCache::default()).unwrap(),
905        );
906        for ancestor in [&input, &root] {
907            assert_eq!(
908                digest(std::slice::from_ref(ancestor), &mut cache)
909                    .unwrap_err()
910                    .kind(),
911                std::io::ErrorKind::InvalidInput,
912            );
913        }
914        assert_eq!(
915            digest(std::slice::from_ref(&generated), &mut cache).unwrap(),
916            excluded,
917        );
918        fs::remove_dir_all(root).unwrap();
919    }
920
921    #[test]
922    #[cfg(unix)]
923    fn composable_digest_tracks_exclusions_beyond_an_external_symlink() {
924        let root = unique_temp_directory("composable-external-exclusions");
925        let input = root.join("input");
926        let external = root.join("external");
927        fs::create_dir_all(&input).unwrap();
928        fs::create_dir_all(external.join("first")).unwrap();
929        fs::create_dir_all(external.join("second")).unwrap();
930        fs::write(input.join("source"), b"source").unwrap();
931        fs::write(external.join("first/file"), b"first").unwrap();
932        fs::write(external.join("second/file"), b"second").unwrap();
933        std::os::unix::fs::symlink(&external, input.join("linked")).unwrap();
934        let paths = [(PathBuf::from("input"), input)];
935        let digest = |exclusion: &PathBuf, cache: &mut LabeledPathDigestCache| {
936            digest_labeled_paths_composable(
937                "external-exclusions-test-v1",
938                paths
939                    .iter()
940                    .map(|(label, path)| (label.as_path(), path.as_path())),
941                std::slice::from_ref(exclusion),
942                cache,
943            )
944        };
945        let mut cache = LabeledPathDigestCache::default();
946        let first = digest(&external.join("first"), &mut cache).unwrap();
947        let second = digest(&external.join("second"), &mut cache).unwrap();
948        assert_ne!(first, second);
949        assert_eq!(
950            second,
951            digest(
952                &external.join("second"),
953                &mut LabeledPathDigestCache::default(),
954            )
955            .unwrap(),
956        );
957        assert_eq!(digest(&external.join("first"), &mut cache).unwrap(), first);
958        fs::remove_dir_all(root).unwrap();
959    }
960}