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