Skip to main content

ic_testkit/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, OpenOptions},
8    io::{self, Read as _, Write as _},
9    path::{Path, PathBuf},
10    sync::atomic::{AtomicU64, Ordering},
11};
12
13#[cfg(unix)]
14use std::os::unix::{ffi::OsStrExt as _, fs::MetadataExt as _};
15#[cfg(windows)]
16use std::os::windows::ffi::OsStrExt as _;
17
18static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
19
20#[derive(Debug)]
21struct AtomicCopyErrorContext {
22    source_path: PathBuf,
23    destination_path: PathBuf,
24    source: io::Error,
25}
26
27impl std::fmt::Display for AtomicCopyErrorContext {
28    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        write!(
30            formatter,
31            "failed to atomically copy {} to {}: {}",
32            self.source_path.display(),
33            self.destination_path.display(),
34            self.source
35        )
36    }
37}
38
39impl std::error::Error for AtomicCopyErrorContext {
40    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
41        Some(&self.source)
42    }
43}
44
45/// SHA-256 digest of one deterministic artifact-input set.
46#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct InputDigest([u8; 32]);
48
49impl InputDigest {
50    /// Borrow the raw SHA-256 bytes.
51    #[must_use]
52    pub const fn as_bytes(&self) -> &[u8; 32] {
53        &self.0
54    }
55
56    /// Render the digest as lowercase hexadecimal.
57    #[must_use]
58    pub fn to_hex(self) -> String {
59        let mut hex = String::with_capacity(64);
60        write!(hex, "{self}").expect("writing to a String cannot fail");
61        hex
62    }
63}
64
65impl std::fmt::Display for InputDigest {
66    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        for byte in self.0 {
68            write!(formatter, "{byte:02x}")?;
69        }
70        Ok(())
71    }
72}
73
74pub(super) struct InputHasher {
75    state: Sha256,
76    read_buffer: Vec<u8>,
77}
78
79impl InputHasher {
80    pub(super) fn new(domain: &str) -> Self {
81        let mut hasher = Self {
82            state: Sha256::new(),
83            read_buffer: Vec::new(),
84        };
85        hasher.field("domain", domain.as_bytes());
86        hasher
87    }
88
89    pub(super) fn field(&mut self, label: &str, value: &[u8]) {
90        self.field_header(
91            label,
92            u64::try_from(value.len()).expect("input value length must fit in u64"),
93        );
94        self.state.update(value);
95    }
96
97    fn field_header(&mut self, label: &str, value_len: u64) {
98        self.state.update(
99            u64::try_from(label.len())
100                .expect("input label length must fit in u64")
101                .to_le_bytes(),
102        );
103        self.state.update(label.as_bytes());
104        self.state.update(value_len.to_le_bytes());
105    }
106
107    fn file_field(&mut self, label: &str, path: &Path) -> io::Result<u64> {
108        let mut file = File::open(path)?;
109        let expected_len = file.metadata()?.len();
110        self.field_header(label, expected_len);
111
112        let mut actual_len = 0_u64;
113        // Small sources need only their declared length; large artifacts use bounded reads.
114        // Even empty files need a nonempty read buffer to detect growth.
115        let buffer_len = usize::try_from(expected_len.clamp(1, 64 * 1024))
116            .expect("bounded artifact buffer length must fit in usize");
117        // A tree hashes many files with one hasher. Retain its bounded scratch
118        // space, reading only this file's window and hashing only returned bytes.
119        if self.read_buffer.len() < buffer_len {
120            // Geometric growth near the read limit would retain almost twice
121            // the scratch space required by any file in this hashing pass.
122            self.read_buffer
123                .reserve_exact(buffer_len - self.read_buffer.len());
124            self.read_buffer.resize(buffer_len, 0);
125        }
126        loop {
127            let read = file.read(&mut self.read_buffer[..buffer_len])?;
128            if read == 0 {
129                break;
130            }
131            actual_len = actual_len
132                .saturating_add(u64::try_from(read).expect("artifact read length must fit in u64"));
133            if actual_len > expected_len {
134                break;
135            }
136            self.state.update(&self.read_buffer[..read]);
137        }
138        if actual_len != expected_len {
139            return Err(io::Error::new(
140                io::ErrorKind::InvalidData,
141                format!(
142                    "file changed size while hashing: expected {expected_len} bytes, read {actual_len}"
143                ),
144            ));
145        }
146        Ok(actual_len)
147    }
148
149    pub(super) fn finish(self) -> InputDigest {
150        InputDigest(self.state.finalize().into())
151    }
152}
153
154pub(super) fn digest_bytes(domain: &str, value: &[u8]) -> InputDigest {
155    let mut hasher = InputHasher::new(domain);
156    hasher.field("content", value);
157    hasher.finish()
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub(super) struct FileDigest {
162    pub(super) bytes: u64,
163    pub(super) digest: InputDigest,
164}
165
166pub(super) fn digest_file(domain: &str, path: &Path) -> io::Result<FileDigest> {
167    let mut hasher = InputHasher::new(domain);
168    let bytes = hasher.file_field("content", path)?;
169    Ok(FileDigest {
170        bytes,
171        digest: hasher.finish(),
172    })
173}
174
175/// Read a UTF-8 stamp without allocating or reading an oversized sidecar in full.
176/// An oversized stamp is stale; other read and decoding errors reach the caller.
177pub(super) fn read_stamp_with_limit(path: &Path, maximum_len: usize) -> io::Result<Option<String>> {
178    read_file_with_limit(path, maximum_len)?
179        .map(|contents| {
180            String::from_utf8(contents)
181                .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
182        })
183        .transpose()
184}
185
186/// Read at most the format's maximum length plus one byte to detect oversized files.
187pub(super) fn read_file_with_limit(path: &Path, maximum_len: usize) -> io::Result<Option<Vec<u8>>> {
188    use ic_host_tools::artifact::{ArtifactError, read_file};
189
190    // The shared library owns bounded reading and allocation. Cache policy owns
191    // the meaning of overflow: an oversized stamp or manifest is a cache miss.
192    match read_file(path, maximum_len) {
193        Ok(contents) => Ok(Some(contents)),
194        Err(ArtifactError::LimitExceeded { .. }) => Ok(None),
195        Err(ArtifactError::Io(source)) => Err(source),
196        Err(ArtifactError::NotRegularFile) => Err(io::Error::new(
197            io::ErrorKind::InvalidData,
198            ArtifactError::NotRegularFile,
199        )),
200        Err(error) => Err(io::Error::other(error)),
201    }
202}
203
204/// Only reuse an independent, caller-owned writable destination. The caller
205/// coordinates other writers and supplies a digest from a verified cache entry.
206pub(super) fn destination_matches_digest(
207    domain: &str,
208    destination: &Path,
209    expected: &FileDigest,
210) -> bool {
211    destination_is_reusable(destination, expected.bytes)
212        && digest_file(domain, destination).is_ok_and(|actual| actual == *expected)
213}
214
215pub(super) fn destination_matches_bytes(destination: &Path, expected: &[u8]) -> bool {
216    destination_is_reusable(
217        destination,
218        u64::try_from(expected.len()).expect("artifact byte length must fit in u64"),
219    ) && read_file_with_limit(destination, expected.len())
220        .is_ok_and(|actual| actual.as_deref() == Some(expected))
221}
222
223fn destination_is_reusable(destination: &Path, expected_bytes: u64) -> bool {
224    #[cfg(unix)]
225    {
226        let Ok(metadata) = fs::symlink_metadata(destination) else {
227            return false;
228        };
229        // SAFETY: geteuid takes no pointers and has no failure case.
230        let effective_uid = unsafe { libc::geteuid() };
231        // Detach links and normalize foreign-owned, restricted or executable
232        // files, even when their bytes match a retained artifact.
233        if !metadata.file_type().is_file()
234            || metadata.nlink() != 1
235            || metadata.uid() != effective_uid
236            || metadata.mode() & 0o600 != 0o600
237            || metadata.mode() & 0o7111 != 0
238            || metadata.len() != expected_bytes
239        {
240            return false;
241        }
242        true
243    }
244    #[cfg(not(unix))]
245    {
246        // Preserve replacement where a portable single-link check is unavailable.
247        let _ = (destination, expected_bytes);
248        false
249    }
250}
251
252pub(super) fn digest_labeled_paths<L: AsRef<Path>, P: AsRef<Path>>(
253    domain: &str,
254    paths: impl IntoIterator<Item = (L, P)>,
255    excluded_roots: &[PathBuf],
256) -> io::Result<InputDigest> {
257    let mut paths = paths.into_iter().collect::<Vec<_>>();
258    paths.sort_by(|(left, _), (right, _)| {
259        os_bytes(left.as_ref().as_os_str()).cmp(&os_bytes(right.as_ref().as_os_str()))
260    });
261
262    let excluded_roots = excluded_roots
263        .iter()
264        .filter_map(|path| path.canonicalize().ok())
265        .collect::<Vec<_>>();
266    let mut visited_directories = BTreeSet::new();
267    let mut hasher = InputHasher::new(domain);
268    for (label, path) in paths {
269        hash_path(
270            &mut hasher,
271            label.as_ref(),
272            path.as_ref(),
273            &excluded_roots,
274            &mut visited_directories,
275            true,
276            None,
277        )?;
278    }
279    Ok(hasher.finish())
280}
281
282#[derive(Default)]
283pub(super) struct LabeledPathDigestCache {
284    entries: Vec<LabeledPathDigestCacheEntry>,
285}
286
287struct LabeledPathDigestCacheEntry {
288    domain: String,
289    label: PathBuf,
290    path: PathBuf,
291    canonical_root: PathBuf,
292    excluded_roots: Vec<PathBuf>,
293    traversed_external_path: bool,
294    digest: InputDigest,
295}
296
297struct HashPathTrace {
298    canonical_root: PathBuf,
299    traversed_external_path: bool,
300}
301
302pub(super) fn digest_labeled_paths_composable<'a>(
303    domain: &str,
304    paths: impl IntoIterator<Item = (&'a Path, &'a Path)>,
305    excluded_roots: &[PathBuf],
306    cache: &mut LabeledPathDigestCache,
307) -> io::Result<InputDigest> {
308    let mut paths = paths.into_iter().collect::<Vec<_>>();
309    paths.sort_by(|(left, _), (right, _)| {
310        os_bytes(left.as_os_str()).cmp(&os_bytes(right.as_os_str()))
311    });
312    let excluded_roots = excluded_roots
313        .iter()
314        .filter_map(|path| path.canonicalize().ok())
315        .collect::<Vec<_>>();
316    let mut hasher = InputHasher::new(&format!("{domain}/composable-v1"));
317    for (label, path) in paths {
318        let digest = cache.digest_root(domain, label, path, &excluded_roots)?;
319        hasher.field("input-label", &os_bytes(label.as_os_str()));
320        hasher.field("input-digest", digest.as_bytes());
321    }
322    Ok(hasher.finish())
323}
324
325impl LabeledPathDigestCache {
326    fn digest_root(
327        &mut self,
328        domain: &str,
329        label: &Path,
330        path: &Path,
331        excluded_roots: &[PathBuf],
332    ) -> io::Result<InputDigest> {
333        let canonical_root = path.canonicalize()?;
334        if let Some(entry) = self.entries.iter().find(|entry| {
335            entry.domain == domain
336                && entry.label == label
337                && entry.path == path
338                && entry.excluded_roots.iter().eq(effective_root_exclusions(
339                    &entry.canonical_root,
340                    excluded_roots,
341                    entry.traversed_external_path,
342                ))
343        }) {
344            return Ok(entry.digest);
345        }
346        let mut hasher = InputHasher::new(&format!("{domain}/root-v1"));
347        let mut trace = HashPathTrace {
348            canonical_root: canonical_root.clone(),
349            traversed_external_path: false,
350        };
351        hash_path(
352            &mut hasher,
353            label,
354            path,
355            excluded_roots,
356            &mut BTreeSet::new(),
357            true,
358            Some(&mut trace),
359        )?;
360        let digest = hasher.finish();
361        self.entries.push(LabeledPathDigestCacheEntry {
362            domain: domain.to_owned(),
363            label: label.to_owned(),
364            path: path.to_owned(),
365            canonical_root,
366            excluded_roots: effective_root_exclusions(
367                &trace.canonical_root,
368                excluded_roots,
369                trace.traversed_external_path,
370            )
371            .cloned()
372            .collect(),
373            traversed_external_path: trace.traversed_external_path,
374            digest,
375        });
376        Ok(digest)
377    }
378}
379
380fn effective_root_exclusions<'a>(
381    canonical_root: &'a Path,
382    excluded_roots: &'a [PathBuf],
383    traversed_external_path: bool,
384) -> impl Iterator<Item = &'a PathBuf> {
385    excluded_roots.iter().filter(move |excluded| {
386        traversed_external_path
387            || excluded.starts_with(canonical_root)
388            || canonical_root.starts_with(excluded)
389    })
390}
391
392fn hash_path(
393    hasher: &mut InputHasher,
394    label: &Path,
395    path: &Path,
396    excluded_roots: &[PathBuf],
397    visited_directories: &mut BTreeSet<PathBuf>,
398    declared_root: bool,
399    mut trace: Option<&mut HashPathTrace>,
400) -> io::Result<()> {
401    let context =
402        |error: io::Error| io::Error::new(error.kind(), format!("{}: {error}", path.display()));
403    let canonical = path.canonicalize().map_err(context)?;
404    if let Some(trace) = &mut trace
405        && !canonical.starts_with(&trace.canonical_root)
406    {
407        trace.traversed_external_path = true;
408    }
409    if excluded_roots
410        .iter()
411        .any(|excluded| canonical.starts_with(excluded))
412    {
413        if declared_root {
414            return Err(io::Error::new(
415                io::ErrorKind::InvalidInput,
416                format!(
417                    "declared input is located inside an excluded cache root: {}",
418                    path.display()
419                ),
420            ));
421        }
422        return Ok(());
423    }
424
425    let metadata = fs::metadata(path).map_err(context)?;
426    let label_bytes = os_bytes(label.as_os_str());
427    if metadata.is_file() {
428        hasher.field("file-path", &label_bytes);
429        hasher.file_field("file-content", path).map_err(context)?;
430        return Ok(());
431    }
432    if !metadata.is_dir() {
433        return Err(io::Error::new(
434            io::ErrorKind::InvalidInput,
435            format!(
436                "watched input is not a regular file or directory: {}",
437                path.display()
438            ),
439        ));
440    }
441
442    hasher.field("directory", &label_bytes);
443    if !visited_directories.insert(canonical) {
444        hasher.field("directory-already-visited", &label_bytes);
445        return Ok(());
446    }
447
448    let mut entries = fs::read_dir(path)
449        .map_err(context)?
450        .map(|entry| entry.map(|entry| entry.file_name()))
451        .collect::<Result<Vec<_>, _>>()
452        .map_err(context)?;
453    // Unix names already own their native byte ordering. Compare borrowed
454    // bytes rather than allocating a second name and cached key per entry.
455    #[cfg(unix)]
456    entries.sort_unstable_by(|left, right| os_bytes(left).cmp(&os_bytes(right)));
457    // Other hosts may need an allocated native encoding; compute it once.
458    #[cfg(not(unix))]
459    entries.sort_by_cached_key(|name| os_bytes(name).into_owned());
460    for name in entries {
461        hash_path(
462            hasher,
463            &label.join(&name),
464            &path.join(&name),
465            excluded_roots,
466            visited_directories,
467            false,
468            trace.as_deref_mut(),
469        )?;
470    }
471    Ok(())
472}
473
474pub(super) fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
475    write_file_atomic(path, |file| file.write_all(contents))
476}
477
478pub(super) fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<u64> {
479    let result = (|| {
480        let mut source_file = File::open(source)?;
481        write_file_atomic(destination, |destination_file| {
482            io::copy(&mut source_file, destination_file)
483        })
484    })();
485    result.map_err(|source_error| {
486        io::Error::new(
487            source_error.kind(),
488            AtomicCopyErrorContext {
489                source_path: source.to_owned(),
490                destination_path: destination.to_owned(),
491                source: source_error,
492            },
493        )
494    })
495}
496
497fn write_file_atomic<T>(
498    path: &Path,
499    write: impl FnOnce(&mut File) -> io::Result<T>,
500) -> io::Result<T> {
501    let parent = path.parent().ok_or_else(|| {
502        io::Error::new(
503            io::ErrorKind::InvalidInput,
504            format!("atomic output path has no parent: {}", path.display()),
505        )
506    })?;
507    fs::create_dir_all(parent)?;
508
509    let file_name = path.file_name().ok_or_else(|| {
510        io::Error::new(
511            io::ErrorKind::InvalidInput,
512            format!("atomic output path has no file name: {}", path.display()),
513        )
514    })?;
515    let temp_path = loop {
516        let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
517        let temp_name = format!(".ic-testkit-tmp-{}-{sequence}", std::process::id());
518        // Keep names short and distinct from the destination, including on
519        // case-insensitive filesystems. The sibling preserves atomic rename.
520        if !file_name
521            .as_encoded_bytes()
522            .eq_ignore_ascii_case(temp_name.as_bytes())
523        {
524            break parent.join(temp_name);
525        }
526    };
527
528    // Cleanup owns this path only after exclusive creation succeeds.
529    let mut file = OpenOptions::new()
530        .create_new(true)
531        .write(true)
532        .open(&temp_path)?;
533    let result = (|| {
534        let value = write(&mut file)?;
535        file.sync_all()?;
536        fs::rename(&temp_path, path)?;
537        Ok(value)
538    })();
539    drop(file);
540    if result.is_err() {
541        let _ = fs::remove_file(&temp_path);
542    }
543    result
544}
545
546#[cfg(unix)]
547pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
548    Cow::Borrowed(value.as_bytes())
549}
550
551#[cfg(windows)]
552pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
553    Cow::Owned(value.encode_wide().flat_map(u16::to_le_bytes).collect())
554}
555
556#[cfg(not(any(unix, windows)))]
557pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
558    Cow::Owned(value.to_string_lossy().as_bytes().to_vec())
559}
560
561#[cfg(test)]
562mod tests {
563    use super::{
564        LabeledPathDigestCache, copy_file_atomic, digest_bytes, digest_file,
565        digest_labeled_paths_composable, write_atomic,
566    };
567    use crate::artifacts::test_support::unique_temp_directory;
568    use std::{
569        fs,
570        io::{self, Write as _},
571        path::PathBuf,
572    };
573
574    #[cfg(unix)]
575    use super::{InputHasher, digest_labeled_paths};
576    #[cfg(unix)]
577    use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
578    #[cfg(windows)]
579    use std::{ffi::OsString, os::windows::ffi::OsStringExt as _};
580
581    #[test]
582    fn digest_text_preserves_lowercase_hex_and_leading_zeroes() {
583        let digest = super::InputDigest(std::array::from_fn(|index| {
584            u8::try_from(index).expect("digest byte index must fit")
585        }));
586        let expected = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
587        assert_eq!(digest.to_hex(), expected);
588        assert_eq!(digest.to_string(), expected);
589        assert_eq!(super::InputDigest([0xff; 32]).to_string(), "ff".repeat(32));
590    }
591
592    #[test]
593    #[cfg(unix)]
594    fn labeled_path_digests_preserve_native_names_and_sorted_order() {
595        let names: &[&[u8]] = &[
596            b"\xce\xbb",
597            #[cfg(target_os = "linux")]
598            b"\xff",
599        ];
600        for &name in names {
601            let root = unique_temp_directory("native-path-digest");
602            let tree = root.join("tree");
603            fs::create_dir_all(tree.join("nested")).unwrap();
604            fs::write(tree.join(OsStr::from_bytes(name)), b"native").unwrap();
605            fs::write(tree.join("nested/z"), b"last").unwrap();
606            fs::write(tree.join("a"), b"first").unwrap();
607            fs::write(root.join("top"), b"top").unwrap();
608            let mut paths = [
609                (PathBuf::from("tree"), tree),
610                (PathBuf::from("aaa"), root.join("top")),
611            ];
612
613            let tree_fields = |hasher: &mut InputHasher| {
614                hasher.field("directory", b"tree");
615                hasher.field("file-path", b"tree/a");
616                hasher.field("file-content", b"first");
617                hasher.field("directory", b"tree/nested");
618                hasher.field("file-path", b"tree/nested/z");
619                hasher.field("file-content", b"last");
620                hasher.field("file-path", &[b"tree/".as_slice(), name].concat());
621                hasher.field("file-content", b"native");
622            };
623            let mut expected = InputHasher::new("native-path-test-v1");
624            expected.field("file-path", b"aaa");
625            expected.field("file-content", b"top");
626            tree_fields(&mut expected);
627            let expected = expected.finish();
628
629            let mut top = InputHasher::new("native-path-test-v1/root-v1");
630            top.field("file-path", b"aaa");
631            top.field("file-content", b"top");
632            let mut tree = InputHasher::new("native-path-test-v1/root-v1");
633            tree_fields(&mut tree);
634            let mut composable = InputHasher::new("native-path-test-v1/composable-v1");
635            composable.field("input-label", b"aaa");
636            composable.field("input-digest", top.finish().as_bytes());
637            composable.field("input-label", b"tree");
638            composable.field("input-digest", tree.finish().as_bytes());
639            let composable = composable.finish();
640
641            for _ in 0..2 {
642                assert_eq!(
643                    digest_labeled_paths(
644                        "native-path-test-v1",
645                        paths.iter().map(|(label, path)| (label, path)),
646                        &[],
647                    )
648                    .unwrap(),
649                    expected,
650                );
651                assert_eq!(
652                    digest_labeled_paths_composable(
653                        "native-path-test-v1",
654                        paths
655                            .iter()
656                            .map(|(label, path)| (label.as_path(), path.as_path())),
657                        &[],
658                        &mut LabeledPathDigestCache::default(),
659                    )
660                    .unwrap(),
661                    composable,
662                );
663                paths.reverse();
664            }
665            fs::remove_dir_all(root).unwrap();
666        }
667    }
668
669    #[test]
670    #[cfg(unix)]
671    fn native_bytes_preserve_non_utf8_without_a_filesystem_roundtrip() {
672        assert_eq!(
673            super::os_bytes(OsStr::from_bytes(b"name\xff")).as_ref(),
674            b"name\xff"
675        );
676    }
677
678    #[test]
679    #[cfg(windows)]
680    fn native_names_preserve_utf16_little_endian_encoding() {
681        let value = OsString::from_wide(&[0x0061, 0xd800, 0x0100]);
682        assert_eq!(super::os_bytes(&value).as_ref(), &[0x61, 0, 0, 0xd8, 0, 1]);
683    }
684
685    #[test]
686    fn streamed_fields_preserve_bytes_across_different_file_sizes() {
687        let root = unique_temp_directory("streamed-field-sizes");
688        let source = root.join("source");
689        let contents = (0..192 * 1024 + 37)
690            .map(|index| u8::try_from(index % 251).unwrap())
691            .collect::<Vec<_>>();
692        let mut streamed = super::InputHasher::new("streamed-fields-v1");
693        let mut expected = super::InputHasher::new("streamed-fields-v1");
694        for length in [1, 64 * 1024 - 1, contents.len(), 0, 7, 1024, 64 * 1024 + 1] {
695            let bytes = &contents[..length];
696            fs::write(&source, bytes).unwrap();
697            assert_eq!(streamed.file_field("part", &source).unwrap(), length as u64);
698            expected.field("part", bytes);
699        }
700        assert_eq!(streamed.finish(), expected.finish());
701        fs::remove_dir_all(root).unwrap();
702    }
703
704    #[test]
705    fn streaming_digest_and_atomic_copy_preserve_exact_bytes() {
706        let root = unique_temp_directory("streaming-digest");
707        let source = root.join("source");
708        let destination = root.join("destination");
709        let mut contents = vec![0_u8; 192 * 1024 + 37];
710        for (index, byte) in contents.iter_mut().enumerate() {
711            *byte = u8::try_from(index % 251).expect("test byte must fit");
712        }
713        for length in [
714            0,
715            1,
716            1024,
717            16 * 1024,
718            64 * 1024 - 1,
719            64 * 1024,
720            64 * 1024 + 1,
721            contents.len(),
722        ] {
723            let data = &contents[..length];
724            fs::write(&source, data).expect("write source");
725            let streamed = digest_file("streaming-test-v1", &source).expect("digest file");
726            assert_eq!(
727                streamed.bytes,
728                u64::try_from(length).expect("fixture length must fit in u64")
729            );
730            assert_eq!(streamed.digest, digest_bytes("streaming-test-v1", data));
731        }
732
733        write_atomic(&destination, b"old").expect("write original destination");
734        assert_eq!(
735            copy_file_atomic(&source, &destination).expect("copy source atomically"),
736            u64::try_from(contents.len()).expect("fixture length must fit in u64")
737        );
738        assert_eq!(
739            fs::read(&destination).expect("read copied destination"),
740            contents
741        );
742
743        let missing = root.join("missing");
744        let error = copy_file_atomic(&missing, &destination).expect_err("missing source must fail");
745        let message = error.to_string();
746        assert!(message.contains(&missing.display().to_string()));
747        assert!(message.contains(&destination.display().to_string()));
748        fs::remove_dir_all(root).expect("remove streaming-digest test directory");
749    }
750
751    #[test]
752    fn atomic_creation_failure_preserves_existing_files() {
753        const CHILD_ENV: &str = "IC_TESTKIT_ATOMIC_CREATION_COLLISION_CHILD";
754        if std::env::var_os(CHILD_ENV).is_none() {
755            // Isolate the temporary-name sequence from other parallel tests.
756            let child = std::process::Command::new(std::env::current_exe().unwrap())
757                .args([
758                    "--exact",
759                    "artifacts::digest::tests::atomic_creation_failure_preserves_existing_files",
760                    "--test-threads=1",
761                ])
762                .env(CHILD_ENV, "1")
763                .output()
764                .unwrap();
765            assert!(
766                child.status.success(),
767                "collision regression failed: {}{}",
768                String::from_utf8_lossy(&child.stdout),
769                String::from_utf8_lossy(&child.stderr)
770            );
771            return;
772        }
773
774        let root = unique_temp_directory("atomic-creation-collision");
775        let destination = root.join("output");
776        fs::write(&destination, b"original output").unwrap();
777        let sequence = super::TEMP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
778        let existing = root.join(format!(".ic-testkit-tmp-{}-{sequence}", std::process::id()));
779        fs::write(&existing, b"existing temporary file").unwrap();
780
781        let error = write_atomic(&destination, b"replacement").unwrap_err();
782        assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
783        assert_eq!(fs::read(&destination).unwrap(), b"original output");
784        assert_eq!(fs::read(&existing).unwrap(), b"existing temporary file");
785
786        // A subsequent acquisition gets a new name and can publish normally.
787        write_atomic(&destination, b"replacement").unwrap();
788        assert_eq!(fs::read(&destination).unwrap(), b"replacement");
789        assert_eq!(fs::read(&existing).unwrap(), b"existing temporary file");
790
791        // A caller may choose a destination in the temporary-name namespace.
792        // It must still stay absent until publication rather than be opened directly.
793        let sequence = super::TEMP_FILE_SEQUENCE.load(super::Ordering::Relaxed);
794        let destination = root.join(format!(".ic-testkit-tmp-{}-{sequence}", std::process::id()));
795        super::write_file_atomic(&destination, |file| {
796            assert!(!destination.exists());
797            std::io::Write::write_all(file, b"separate temporary file")
798        })
799        .unwrap();
800        assert_eq!(fs::read(&destination).unwrap(), b"separate temporary file");
801        fs::remove_dir_all(root).unwrap();
802    }
803
804    #[test]
805    fn atomic_publication_failures_remove_only_the_owned_temporary_file() {
806        let root = unique_temp_directory("atomic-publication-failure");
807        let destination = root.join("output");
808        fs::write(&destination, b"original output").unwrap();
809        let error = super::write_file_atomic(&destination, |file| {
810            file.write_all(b"partial output")?;
811            Err::<(), _>(io::Error::other("synthetic write failure"))
812        })
813        .unwrap_err();
814        assert_eq!(error.to_string(), "synthetic write failure");
815        assert_eq!(fs::read(&destination).unwrap(), b"original output");
816        assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
817
818        // Rename must also leave the old destination and clean up the new file.
819        fs::remove_file(&destination).unwrap();
820        fs::create_dir(&destination).unwrap();
821        fs::write(destination.join("child"), b"original child").unwrap();
822        assert!(write_atomic(&destination, b"replacement").is_err());
823        assert_eq!(
824            fs::read(destination.join("child")).unwrap(),
825            b"original child"
826        );
827        assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
828        fs::remove_dir_all(root).unwrap();
829    }
830
831    #[test]
832    #[cfg(unix)]
833    fn atomic_publication_supports_long_destination_names() {
834        let root = unique_temp_directory("atomic-long-destination");
835        let destination = root.join("a".repeat(255));
836        // Establish that the destination itself is valid on this filesystem.
837        fs::write(&destination, b"original output").unwrap();
838        write_atomic(&destination, b"replacement").unwrap();
839        assert_eq!(fs::read(&destination).unwrap(), b"replacement");
840
841        let source = root.join("source");
842        fs::write(&source, b"copied output").unwrap();
843        assert_eq!(copy_file_atomic(&source, &destination).unwrap(), 13);
844        assert_eq!(fs::read(&destination).unwrap(), b"copied output");
845        assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
846        fs::remove_dir_all(root).unwrap();
847    }
848
849    #[test]
850    fn composable_digest_reuses_roots_across_irrelevant_exclusion_changes() {
851        let root = unique_temp_directory("composable-digest-cache");
852        let input = root.join("input");
853        fs::create_dir_all(&input).expect("create composable input");
854        fs::create_dir_all(root.join("generated-a")).expect("create first generated root");
855        fs::create_dir_all(root.join("generated-b")).expect("create second generated root");
856        fs::write(input.join("source"), b"source").expect("write composable input");
857        let paths = [(PathBuf::from("shared"), input)];
858        let mut cache = LabeledPathDigestCache::default();
859
860        let first = digest_labeled_paths_composable(
861            "composable-test-v1",
862            paths
863                .iter()
864                .map(|(label, path)| (label.as_path(), path.as_path())),
865            &[root.join("generated-a")],
866            &mut cache,
867        )
868        .expect("hash first composable input");
869        let second = digest_labeled_paths_composable(
870            "composable-test-v1",
871            paths
872                .iter()
873                .map(|(label, path)| (label.as_path(), path.as_path())),
874            &[root.join("generated-b")],
875            &mut cache,
876        )
877        .expect("reuse composable input root");
878
879        assert_eq!(first, second);
880        assert_eq!(cache.entries.len(), 1);
881        fs::remove_dir_all(root).expect("remove composable digest fixture");
882    }
883
884    #[test]
885    fn composable_digest_rehashes_changed_descendant_exclusions_and_rejects_ancestors() {
886        let root = unique_temp_directory("composable-relevant-exclusions");
887        let input = root.join("input");
888        let generated = input.join("generated");
889        fs::create_dir_all(&generated).unwrap();
890        fs::write(input.join("source"), b"source").unwrap();
891        fs::write(generated.join("artifact"), b"generated").unwrap();
892        let paths = [(PathBuf::from("input"), input.clone())];
893        let digest = |exclusions: &[PathBuf], cache: &mut LabeledPathDigestCache| {
894            digest_labeled_paths_composable(
895                "exclusions-test-v1",
896                paths
897                    .iter()
898                    .map(|(label, path)| (label.as_path(), path.as_path())),
899                exclusions,
900                cache,
901            )
902        };
903        let mut cache = LabeledPathDigestCache::default();
904        let excluded = digest(std::slice::from_ref(&generated), &mut cache).unwrap();
905        let included = digest(&[], &mut cache).unwrap();
906        assert_ne!(included, excluded);
907        assert_eq!(
908            included,
909            digest(&[], &mut LabeledPathDigestCache::default()).unwrap(),
910        );
911        for ancestor in [&input, &root] {
912            assert_eq!(
913                digest(std::slice::from_ref(ancestor), &mut cache)
914                    .unwrap_err()
915                    .kind(),
916                std::io::ErrorKind::InvalidInput,
917            );
918        }
919        assert_eq!(
920            digest(std::slice::from_ref(&generated), &mut cache).unwrap(),
921            excluded,
922        );
923        fs::remove_dir_all(root).unwrap();
924    }
925
926    #[test]
927    #[cfg(unix)]
928    fn composable_digest_tracks_exclusions_beyond_an_external_symlink() {
929        let root = unique_temp_directory("composable-external-exclusions");
930        let input = root.join("input");
931        let external = root.join("external");
932        fs::create_dir_all(&input).unwrap();
933        fs::create_dir_all(external.join("first")).unwrap();
934        fs::create_dir_all(external.join("second")).unwrap();
935        fs::write(input.join("source"), b"source").unwrap();
936        fs::write(external.join("first/file"), b"first").unwrap();
937        fs::write(external.join("second/file"), b"second").unwrap();
938        std::os::unix::fs::symlink(&external, input.join("linked")).unwrap();
939        let paths = [(PathBuf::from("input"), input)];
940        let digest = |exclusion: &PathBuf, cache: &mut LabeledPathDigestCache| {
941            digest_labeled_paths_composable(
942                "external-exclusions-test-v1",
943                paths
944                    .iter()
945                    .map(|(label, path)| (label.as_path(), path.as_path())),
946                std::slice::from_ref(exclusion),
947                cache,
948            )
949        };
950        let mut cache = LabeledPathDigestCache::default();
951        let first = digest(&external.join("first"), &mut cache).unwrap();
952        let second = digest(&external.join("second"), &mut cache).unwrap();
953        assert_ne!(first, second);
954        assert_eq!(
955            second,
956            digest(
957                &external.join("second"),
958                &mut LabeledPathDigestCache::default(),
959            )
960            .unwrap(),
961        );
962        assert_eq!(digest(&external.join("first"), &mut cache).unwrap(), first);
963        fs::remove_dir_all(root).unwrap();
964    }
965}