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