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