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