Skip to main content

ic_build/artifacts/
digest.rs

1use sha2::{Digest, Sha256};
2use std::{
3    borrow::Cow,
4    collections::BTreeSet,
5    ffi::{OsStr, OsString},
6    fmt::Write as _,
7    fs::{self, File},
8    io::{self, Read as _},
9    path::{Path, PathBuf},
10};
11
12#[cfg(unix)]
13use std::os::unix::{ffi::OsStrExt as _, fs::MetadataExt as _};
14#[cfg(windows)]
15use std::os::windows::ffi::OsStrExt as _;
16
17#[derive(Debug)]
18struct AtomicCopyErrorContext {
19    source_path: PathBuf,
20    destination_path: PathBuf,
21    source: io::Error,
22}
23
24impl std::fmt::Display for AtomicCopyErrorContext {
25    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(
27            formatter,
28            "failed to atomically copy {} to {}: {}",
29            self.source_path.display(),
30            self.destination_path.display(),
31            self.source
32        )
33    }
34}
35
36impl std::error::Error for AtomicCopyErrorContext {
37    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
38        Some(&self.source)
39    }
40}
41
42/// SHA-256 digest of one deterministic artifact-input set.
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44pub struct InputDigest([u8; 32]);
45
46impl InputDigest {
47    pub(super) const fn from_bytes(bytes: [u8; 32]) -> Self {
48        Self(bytes)
49    }
50    /// Borrow the raw SHA-256 bytes.
51    #[must_use]
52    pub const fn as_bytes(&self) -> &[u8; 32] {
53        &self.0
54    }
55
56    /// Render the digest as lowercase hexadecimal.
57    #[must_use]
58    pub fn to_hex(self) -> String {
59        let mut hex = String::with_capacity(64);
60        write!(hex, "{self}").expect("writing to a String cannot fail");
61        hex
62    }
63}
64
65impl std::fmt::Display for InputDigest {
66    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        for byte in self.0 {
68            write!(formatter, "{byte:02x}")?;
69        }
70        Ok(())
71    }
72}
73
74pub(super) struct InputHasher {
75    state: Sha256,
76    read_buffer: Vec<u8>,
77}
78
79impl InputHasher {
80    pub(super) fn new(domain: &str) -> Self {
81        let mut hasher = Self {
82            state: Sha256::new(),
83            read_buffer: Vec::new(),
84        };
85        hasher.field("domain", domain.as_bytes());
86        hasher
87    }
88
89    pub(super) fn field(&mut self, label: &str, value: &[u8]) {
90        self.field_header(
91            label,
92            u64::try_from(value.len()).expect("input value length must fit in u64"),
93        );
94        self.state.update(value);
95    }
96
97    fn field_header(&mut self, label: &str, value_len: u64) {
98        self.state.update(
99            u64::try_from(label.len())
100                .expect("input label length must fit in u64")
101                .to_le_bytes(),
102        );
103        self.state.update(label.as_bytes());
104        self.state.update(value_len.to_le_bytes());
105    }
106
107    fn file_field(&mut self, label: &str, path: &Path) -> io::Result<u64> {
108        let mut file = File::open(path)?;
109        let expected_len = file.metadata()?.len();
110        self.field_header(label, expected_len);
111
112        let mut actual_len = 0_u64;
113        // Small sources need only their declared length; large artifacts use bounded reads.
114        // Even empty files need a nonempty read buffer to detect growth.
115        let buffer_len = usize::try_from(expected_len.clamp(1, 64 * 1024))
116            .expect("bounded artifact buffer length must fit in usize");
117        // A tree hashes many files with one hasher. Retain its bounded scratch
118        // space, reading only this file's window and hashing only returned bytes.
119        if self.read_buffer.len() < buffer_len {
120            // Geometric growth near the read limit would retain almost twice
121            // the scratch space required by any file in this hashing pass.
122            self.read_buffer
123                .reserve_exact(buffer_len - self.read_buffer.len());
124            self.read_buffer.resize(buffer_len, 0);
125        }
126        loop {
127            let read = file.read(&mut self.read_buffer[..buffer_len])?;
128            if read == 0 {
129                break;
130            }
131            actual_len = actual_len
132                .saturating_add(u64::try_from(read).expect("artifact read length must fit in u64"));
133            if actual_len > expected_len {
134                break;
135            }
136            self.state.update(&self.read_buffer[..read]);
137        }
138        if actual_len != expected_len {
139            return Err(io::Error::new(
140                io::ErrorKind::InvalidData,
141                format!(
142                    "file changed size while hashing: expected {expected_len} bytes, read {actual_len}"
143                ),
144            ));
145        }
146        Ok(actual_len)
147    }
148
149    pub(super) fn finish(self) -> InputDigest {
150        InputDigest(self.state.finalize().into())
151    }
152}
153
154pub(super) fn digest_bytes(domain: &str, value: &[u8]) -> InputDigest {
155    let mut hasher = InputHasher::new(domain);
156    hasher.field("content", value);
157    hasher.finish()
158}
159
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub(super) struct FileDigest {
162    pub(super) bytes: u64,
163    pub(super) digest: InputDigest,
164}
165
166pub(super) fn digest_file(domain: &str, path: &Path) -> io::Result<FileDigest> {
167    let mut hasher = InputHasher::new(domain);
168    let bytes = hasher.file_field("content", path)?;
169    Ok(FileDigest {
170        bytes,
171        digest: hasher.finish(),
172    })
173}
174
175/// Read a UTF-8 stamp without allocating or reading an oversized sidecar in full.
176/// An oversized stamp is stale; other read and decoding errors reach the caller.
177pub(super) fn read_stamp_with_limit(path: &Path, maximum_len: usize) -> io::Result<Option<String>> {
178    read_file_with_limit(path, maximum_len)?
179        .map(|contents| {
180            String::from_utf8(contents)
181                .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
182        })
183        .transpose()
184}
185
186/// Read at most the format's maximum length plus one byte to detect oversized files.
187pub(super) fn read_file_with_limit(path: &Path, maximum_len: usize) -> io::Result<Option<Vec<u8>>> {
188    use ic_host_artifacts::artifact::ArtifactError;
189    use ic_host_fs::read::read_file;
190
191    // The shared library owns bounded reading and allocation. Cache policy owns
192    // the meaning of overflow: an oversized stamp or manifest is a cache miss.
193    match read_file(path, maximum_len) {
194        Ok(contents) => Ok(Some(contents)),
195        Err(ArtifactError::LimitExceeded { .. }) => Ok(None),
196        Err(ArtifactError::NotRegularFile) => Err(io::Error::new(
197            io::ErrorKind::InvalidData,
198            ArtifactError::NotRegularFile,
199        )),
200        Err(error) => Err(error.into()),
201    }
202}
203
204/// Only reuse an independent, caller-owned writable destination. The caller
205/// coordinates other writers and supplies a digest from a verified cache entry.
206fn destination_matches_digest(domain: &str, destination: &Path, expected: &FileDigest) -> bool {
207    destination_is_reusable(destination, expected.bytes)
208        && digest_file(domain, destination).is_ok_and(|actual| actual == *expected)
209}
210
211/// Cold publication always copies; only previously verified outputs may preserve
212/// matching caller-owned destinations and their metadata.
213pub(super) fn materialize_verified_output(
214    domain: &str,
215    source: &Path,
216    destination: &Path,
217    verified: Option<&FileDigest>,
218) -> io::Result<()> {
219    if verified.is_some_and(|info| destination_matches_digest(domain, destination, info)) {
220        return Ok(());
221    }
222    copy_file_atomic(source, destination).map(|_| ())
223}
224
225pub(super) fn destination_matches_bytes(destination: &Path, expected: &[u8]) -> bool {
226    destination_is_reusable(
227        destination,
228        u64::try_from(expected.len()).expect("artifact byte length must fit in u64"),
229    ) && read_file_with_limit(destination, expected.len())
230        .is_ok_and(|actual| actual.as_deref() == Some(expected))
231}
232
233fn destination_is_reusable(destination: &Path, expected_bytes: u64) -> bool {
234    #[cfg(unix)]
235    {
236        let Ok(metadata) = fs::symlink_metadata(destination) else {
237            return false;
238        };
239        // SAFETY: geteuid takes no pointers and has no failure case.
240        #[expect(unsafe_code, reason = "geteuid has no pointers and no failure case")]
241        let effective_uid = unsafe { libc::geteuid() };
242        // Detach links and normalize foreign-owned, restricted or executable
243        // files, even when their bytes match a retained artifact.
244        if !metadata.file_type().is_file()
245            || metadata.nlink() != 1
246            || metadata.uid() != effective_uid
247            || metadata.mode() & 0o600 != 0o600
248            || metadata.mode() & 0o7111 != 0
249            || metadata.len() != expected_bytes
250        {
251            return false;
252        }
253        true
254    }
255    #[cfg(not(unix))]
256    {
257        // Preserve replacement where a portable single-link check is unavailable.
258        let _ = (destination, expected_bytes);
259        false
260    }
261}
262
263// A cache root may not have been created yet. Other failures must not silently
264// remove a declared exclusion and change which inputs contribute to identity.
265fn resolve_excluded_roots(paths: &[PathBuf]) -> io::Result<Vec<PathBuf>> {
266    paths
267        .iter()
268        .filter_map(|path| match path.canonicalize() {
269            Ok(path) => Some(Ok(path)),
270            Err(error) if error.kind() == io::ErrorKind::NotFound => None,
271            Err(error) => Some(Err(io::Error::new(
272                error.kind(),
273                format!(
274                    "failed to resolve excluded root {}: {error}",
275                    path.display()
276                ),
277            ))),
278        })
279        .collect()
280}
281
282pub(super) fn digest_labeled_paths<L: AsRef<Path>, P: AsRef<Path>>(
283    domain: &str,
284    paths: impl IntoIterator<Item = (L, P)>,
285    excluded_roots: &[PathBuf],
286) -> io::Result<InputDigest> {
287    let mut paths = paths.into_iter().collect::<Vec<_>>();
288    paths.sort_by(|(left, _), (right, _)| {
289        os_bytes(left.as_ref().as_os_str()).cmp(&os_bytes(right.as_ref().as_os_str()))
290    });
291
292    let excluded_roots = resolve_excluded_roots(excluded_roots)?;
293    let mut visited_directories = BTreeSet::new();
294    let mut hasher = InputHasher::new(domain);
295    for (label, path) in paths {
296        hash_path(
297            &mut hasher,
298            label.as_ref(),
299            path.as_ref(),
300            &excluded_roots,
301            &mut visited_directories,
302            true,
303            None,
304        )?;
305    }
306    Ok(hasher.finish())
307}
308
309#[derive(Default)]
310pub(super) struct LabeledPathDigestCache {
311    entries: Vec<LabeledPathDigestCacheEntry>,
312}
313
314struct LabeledPathDigestCacheEntry {
315    domain: String,
316    label: PathBuf,
317    path: PathBuf,
318    canonical_root: PathBuf,
319    excluded_roots: Vec<PathBuf>,
320    traversed_external_path: bool,
321    digest: InputDigest,
322}
323
324struct HashPathTrace {
325    canonical_root: PathBuf,
326    traversed_external_path: bool,
327}
328
329pub(super) fn digest_labeled_paths_composable<'a>(
330    domain: &str,
331    paths: impl IntoIterator<Item = (&'a Path, &'a Path)>,
332    excluded_roots: &[PathBuf],
333    cache: &mut LabeledPathDigestCache,
334) -> io::Result<InputDigest> {
335    let mut paths = paths.into_iter().collect::<Vec<_>>();
336    paths.sort_by(|(left, _), (right, _)| {
337        os_bytes(left.as_os_str()).cmp(&os_bytes(right.as_os_str()))
338    });
339    let excluded_roots = resolve_excluded_roots(excluded_roots)?;
340    let mut hasher = InputHasher::new(&format!("{domain}/composable-v1"));
341    for (label, path) in paths {
342        let digest = cache.digest_root(domain, label, path, &excluded_roots)?;
343        hasher.field("input-label", &os_bytes(label.as_os_str()));
344        hasher.field("input-digest", digest.as_bytes());
345    }
346    Ok(hasher.finish())
347}
348
349impl LabeledPathDigestCache {
350    fn digest_root(
351        &mut self,
352        domain: &str,
353        label: &Path,
354        path: &Path,
355        excluded_roots: &[PathBuf],
356    ) -> io::Result<InputDigest> {
357        let canonical_root = path.canonicalize()?;
358        if let Some(entry) = self.entries.iter().find(|entry| {
359            entry.domain == domain
360                && entry.label == label
361                && entry.path == path
362                && entry.excluded_roots.iter().eq(effective_root_exclusions(
363                    &entry.canonical_root,
364                    excluded_roots,
365                    entry.traversed_external_path,
366                ))
367        }) {
368            return Ok(entry.digest);
369        }
370        let mut hasher = InputHasher::new(&format!("{domain}/root-v1"));
371        let mut trace = HashPathTrace {
372            canonical_root: canonical_root.clone(),
373            traversed_external_path: false,
374        };
375        hash_path(
376            &mut hasher,
377            label,
378            path,
379            excluded_roots,
380            &mut BTreeSet::new(),
381            true,
382            Some(&mut trace),
383        )?;
384        let digest = hasher.finish();
385        self.entries.push(LabeledPathDigestCacheEntry {
386            domain: domain.to_owned(),
387            label: label.to_owned(),
388            path: path.to_owned(),
389            canonical_root,
390            excluded_roots: effective_root_exclusions(
391                &trace.canonical_root,
392                excluded_roots,
393                trace.traversed_external_path,
394            )
395            .cloned()
396            .collect(),
397            traversed_external_path: trace.traversed_external_path,
398            digest,
399        });
400        Ok(digest)
401    }
402}
403
404fn effective_root_exclusions<'a>(
405    canonical_root: &'a Path,
406    excluded_roots: &'a [PathBuf],
407    traversed_external_path: bool,
408) -> impl Iterator<Item = &'a PathBuf> {
409    excluded_roots.iter().filter(move |excluded| {
410        traversed_external_path
411            || excluded.starts_with(canonical_root)
412            || canonical_root.starts_with(excluded)
413    })
414}
415
416fn hash_path(
417    hasher: &mut InputHasher,
418    label: &Path,
419    path: &Path,
420    excluded_roots: &[PathBuf],
421    visited_directories: &mut BTreeSet<PathBuf>,
422    declared_root: bool,
423    trace: Option<&mut HashPathTrace>,
424) -> io::Result<()> {
425    let mut traversal = InputTreeWalk {
426        excluded_roots,
427        excluded_names: &[],
428        visited_directories,
429        trace,
430        observe_missing: false,
431        reject_links: false,
432    };
433    traversal.visit(label, path, declared_root, &mut |entry| {
434        match entry {
435            InputTreeEntry::File { label, path } => {
436                hasher.field("file-path", &os_bytes(label.as_os_str()));
437                hasher.file_field("file-content", path)?;
438            }
439            InputTreeEntry::Directory {
440                label, repeated, ..
441            } => {
442                hasher.field("directory", &os_bytes(label.as_os_str()));
443                if repeated {
444                    hasher.field("directory-already-visited", &os_bytes(label.as_os_str()));
445                }
446            }
447            InputTreeEntry::Missing { path } => {
448                return Err(io::Error::new(
449                    io::ErrorKind::NotFound,
450                    path.display().to_string(),
451                ));
452            }
453        }
454        Ok(())
455    })
456}
457
458/// One filesystem observation emitted by the shared input traversal.
459pub(super) enum InputTreeEntry<'a> {
460    File {
461        label: &'a Path,
462        path: &'a Path,
463    },
464    Directory {
465        label: &'a Path,
466        path: &'a Path,
467        repeated: bool,
468    },
469    Missing {
470        path: &'a Path,
471    },
472}
473
474/// Path context shared by fingerprint and standalone-evidence failures.
475#[derive(Debug)]
476pub(super) struct InputPathError {
477    pub(super) path: PathBuf,
478    pub(super) source: io::Error,
479}
480
481impl std::fmt::Display for InputPathError {
482    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        write!(formatter, "{}: {}", self.path.display(), self.source)
484    }
485}
486
487impl std::error::Error for InputPathError {
488    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
489        Some(&self.source)
490    }
491}
492
493/// One traversal owner; cache fingerprints retain their original follow-link policy.
494pub(super) struct InputTreeWalk<'a> {
495    excluded_roots: &'a [PathBuf],
496    excluded_names: &'a [OsString],
497    visited_directories: &'a mut BTreeSet<PathBuf>,
498    trace: Option<&'a mut HashPathTrace>,
499    observe_missing: bool,
500    reject_links: bool,
501}
502
503impl<'a> InputTreeWalk<'a> {
504    pub(super) const fn evidence(
505        excluded_roots: &'a [PathBuf],
506        excluded_names: &'a [OsString],
507        visited_directories: &'a mut BTreeSet<PathBuf>,
508    ) -> Self {
509        Self {
510            excluded_roots,
511            excluded_names,
512            visited_directories,
513            trace: None,
514            observe_missing: true,
515            reject_links: true,
516        }
517    }
518
519    pub(super) fn visit(
520        &mut self,
521        label: &Path,
522        path: &Path,
523        declared_root: bool,
524        visitor: &mut impl FnMut(InputTreeEntry<'_>) -> io::Result<()>,
525    ) -> io::Result<()> {
526        let context = |source: io::Error| {
527            io::Error::new(
528                source.kind(),
529                InputPathError {
530                    path: path.to_owned(),
531                    source,
532                },
533            )
534        };
535        // Standalone evidence records absence and refuses links. Existing cache
536        // hashing still canonicalizes first and follows links exactly as before.
537        let inspected = if self.reject_links {
538            match fs::symlink_metadata(path) {
539                Ok(metadata) if metadata.file_type().is_symlink() => {
540                    return Err(context(io::Error::new(
541                        io::ErrorKind::InvalidInput,
542                        "input is a symbolic link",
543                    )));
544                }
545                Ok(metadata) => Some(metadata),
546                Err(error) if self.observe_missing && error.kind() == io::ErrorKind::NotFound => {
547                    let resolved =
548                        super::cache_fs::canonicalize_allow_missing(path).map_err(context)?;
549                    if self
550                        .excluded_roots
551                        .iter()
552                        .any(|root| resolved.starts_with(root))
553                    {
554                        if declared_root {
555                            return Err(context(io::Error::new(
556                                io::ErrorKind::InvalidInput,
557                                "declared missing input is inside an excluded root",
558                            )));
559                        }
560                        return Ok(());
561                    }
562                    return visitor(InputTreeEntry::Missing { path }).map_err(context);
563                }
564                Err(error) => return Err(context(error)),
565            }
566        } else {
567            None
568        };
569        let canonical = path.canonicalize().map_err(context)?;
570        if let Some(trace) = &mut self.trace
571            && !canonical.starts_with(&trace.canonical_root)
572        {
573            trace.traversed_external_path = true;
574        }
575        if self
576            .excluded_roots
577            .iter()
578            .any(|root| canonical.starts_with(root))
579        {
580            if declared_root {
581                return Err(context(io::Error::new(
582                    io::ErrorKind::InvalidInput,
583                    "declared input is inside an excluded root",
584                )));
585            }
586            return Ok(());
587        }
588        let metadata = match inspected {
589            Some(metadata) => metadata,
590            None => fs::metadata(path).map_err(context)?,
591        };
592        if metadata.is_file() {
593            return visitor(InputTreeEntry::File { label, path }).map_err(context);
594        }
595        if !metadata.is_dir() {
596            return Err(context(io::Error::new(
597                io::ErrorKind::InvalidInput,
598                "input is not a regular file or directory",
599            )));
600        }
601        // Cache fingerprints preserve cycle/alias markers. Standalone evidence
602        // refuses child links, so each declared spelling can be fully scanned;
603        // an alias must not be recorded as an empty, complete directory.
604        let repeated = !self.visited_directories.insert(canonical) && !self.reject_links;
605        visitor(InputTreeEntry::Directory {
606            label,
607            path,
608            repeated,
609        })
610        .map_err(context)?;
611        if repeated {
612            return Ok(());
613        }
614        let mut entries = fs::read_dir(path)
615            .map_err(context)?
616            .map(|entry| entry.map(|entry| entry.file_name()))
617            .collect::<Result<Vec<_>, _>>()
618            .map_err(context)?;
619        #[cfg(unix)]
620        entries.sort_unstable_by(|left, right| os_bytes(left).cmp(&os_bytes(right)));
621        #[cfg(not(unix))]
622        entries.sort_by_cached_key(|name| os_bytes(name).into_owned());
623        for name in entries {
624            if self.excluded_names.contains(&name) {
625                continue;
626            }
627            self.visit(&label.join(&name), &path.join(&name), false, visitor)?;
628        }
629        Ok(())
630    }
631}
632
633pub(super) fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<u64> {
634    let result = (|| {
635        let mut source_file = File::open(source)?;
636        ic_host_fs::durable::write_with(
637            destination,
638            ic_host_fs::durable::WriteOptions {
639                mode: ic_host_fs::durable::PublicationMode::Replace,
640                permissions: 0o666,
641            },
642            |destination_file| io::copy(&mut source_file, destination_file),
643        )
644        .map_err(io::Error::other)
645    })();
646    result.map_err(|source_error| {
647        io::Error::new(
648            source_error.kind(),
649            AtomicCopyErrorContext {
650                source_path: source.to_owned(),
651                destination_path: destination.to_owned(),
652                source: source_error,
653            },
654        )
655    })
656}
657
658#[cfg(unix)]
659pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
660    Cow::Borrowed(value.as_bytes())
661}
662
663#[cfg(windows)]
664pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
665    Cow::Owned(value.encode_wide().flat_map(u16::to_le_bytes).collect())
666}
667
668#[cfg(not(any(unix, windows)))]
669pub(super) fn os_bytes(value: &OsStr) -> Cow<'_, [u8]> {
670    Cow::Owned(value.to_string_lossy().as_bytes().to_vec())
671}
672
673#[cfg(test)]
674mod tests {
675    use super::{
676        LabeledPathDigestCache, copy_file_atomic, digest_bytes, digest_file,
677        digest_labeled_paths_composable,
678    };
679    use crate::artifacts::test_support::unique_temp_directory;
680    use std::{fs, path::PathBuf};
681
682    #[cfg(unix)]
683    use super::{InputHasher, digest_labeled_paths};
684    #[cfg(unix)]
685    use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
686    #[cfg(windows)]
687    use std::{ffi::OsString, os::windows::ffi::OsStringExt as _};
688
689    #[test]
690    #[cfg(unix)]
691    fn exclusion_resolution_errors_stop_both_fingerprint_paths() {
692        use std::os::unix::fs::symlink;
693        let root = unique_temp_directory("invalid-digest-exclusions");
694        let input = root.join("input");
695        fs::write(&input, b"source").unwrap();
696        let cycle = root.join("cycle");
697        symlink("cycle", &cycle).unwrap();
698        let paths = [(std::path::Path::new("input"), input.as_path())];
699        assert!(
700            digest_labeled_paths("exclusions-v1", paths, std::slice::from_ref(&cycle)).is_err()
701        );
702        let mut cache = LabeledPathDigestCache::default();
703        let expected =
704            digest_labeled_paths_composable("exclusions-v1", paths, &[], &mut cache).unwrap();
705        assert!(
706            digest_labeled_paths_composable("exclusions-v1", paths, &[cycle], &mut cache).is_err()
707        );
708        // A not-yet-created cache root remains an accepted exclusion, including
709        // on a reused source-lease digest. A failed resolution cannot reuse it.
710        assert_eq!(
711            digest_labeled_paths_composable(
712                "exclusions-v1",
713                paths,
714                &[root.join("missing")],
715                &mut cache,
716            )
717            .unwrap(),
718            expected
719        );
720        assert_eq!(
721            digest_labeled_paths("exclusions-v1", paths, &[]).unwrap(),
722            digest_labeled_paths("exclusions-v1", paths, &[root.join("missing")]).unwrap()
723        );
724        fs::remove_dir_all(root).unwrap();
725    }
726
727    #[test]
728    fn digest_text_preserves_lowercase_hex_and_leading_zeroes() {
729        let digest = super::InputDigest(std::array::from_fn(|index| {
730            u8::try_from(index).expect("digest byte index must fit")
731        }));
732        let expected = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
733        assert_eq!(digest.to_hex(), expected);
734        assert_eq!(digest.to_string(), expected);
735        assert_eq!(super::InputDigest([0xff; 32]).to_string(), "ff".repeat(32));
736    }
737
738    #[test]
739    #[cfg(unix)]
740    fn labeled_path_digests_preserve_native_names_and_sorted_order() {
741        let names: &[&[u8]] = &[
742            b"\xce\xbb",
743            #[cfg(target_os = "linux")]
744            b"\xff",
745        ];
746        for &name in names {
747            let root = unique_temp_directory("native-path-digest");
748            let tree = root.join("tree");
749            fs::create_dir_all(tree.join("nested")).unwrap();
750            fs::write(tree.join(OsStr::from_bytes(name)), b"native").unwrap();
751            fs::write(tree.join("nested/z"), b"last").unwrap();
752            fs::write(tree.join("a"), b"first").unwrap();
753            fs::write(root.join("top"), b"top").unwrap();
754            let mut paths = [
755                (PathBuf::from("tree"), tree),
756                (PathBuf::from("aaa"), root.join("top")),
757            ];
758
759            let tree_fields = |hasher: &mut InputHasher| {
760                hasher.field("directory", b"tree");
761                hasher.field("file-path", b"tree/a");
762                hasher.field("file-content", b"first");
763                hasher.field("directory", b"tree/nested");
764                hasher.field("file-path", b"tree/nested/z");
765                hasher.field("file-content", b"last");
766                hasher.field("file-path", &[b"tree/".as_slice(), name].concat());
767                hasher.field("file-content", b"native");
768            };
769            let mut expected = InputHasher::new("native-path-test-v1");
770            expected.field("file-path", b"aaa");
771            expected.field("file-content", b"top");
772            tree_fields(&mut expected);
773            let expected = expected.finish();
774
775            let mut top = InputHasher::new("native-path-test-v1/root-v1");
776            top.field("file-path", b"aaa");
777            top.field("file-content", b"top");
778            let mut tree = InputHasher::new("native-path-test-v1/root-v1");
779            tree_fields(&mut tree);
780            let mut composable = InputHasher::new("native-path-test-v1/composable-v1");
781            composable.field("input-label", b"aaa");
782            composable.field("input-digest", top.finish().as_bytes());
783            composable.field("input-label", b"tree");
784            composable.field("input-digest", tree.finish().as_bytes());
785            let composable = composable.finish();
786
787            for _ in 0..2 {
788                assert_eq!(
789                    digest_labeled_paths(
790                        "native-path-test-v1",
791                        paths.iter().map(|(label, path)| (label, path)),
792                        &[],
793                    )
794                    .unwrap(),
795                    expected,
796                );
797                assert_eq!(
798                    digest_labeled_paths_composable(
799                        "native-path-test-v1",
800                        paths
801                            .iter()
802                            .map(|(label, path)| (label.as_path(), path.as_path())),
803                        &[],
804                        &mut LabeledPathDigestCache::default(),
805                    )
806                    .unwrap(),
807                    composable,
808                );
809                paths.reverse();
810            }
811            fs::remove_dir_all(root).unwrap();
812        }
813    }
814
815    #[test]
816    #[cfg(unix)]
817    fn native_bytes_preserve_non_utf8_without_a_filesystem_roundtrip() {
818        assert_eq!(
819            super::os_bytes(OsStr::from_bytes(b"name\xff")).as_ref(),
820            b"name\xff"
821        );
822    }
823
824    #[test]
825    #[cfg(windows)]
826    fn native_names_preserve_utf16_little_endian_encoding() {
827        let value = OsString::from_wide(&[0x0061, 0xd800, 0x0100]);
828        assert_eq!(super::os_bytes(&value).as_ref(), &[0x61, 0, 0, 0xd8, 0, 1]);
829    }
830
831    #[test]
832    fn streamed_fields_preserve_bytes_across_different_file_sizes() {
833        let root = unique_temp_directory("streamed-field-sizes");
834        let source = root.join("source");
835        let contents = (0..192 * 1024 + 37)
836            .map(|index| u8::try_from(index % 251).unwrap())
837            .collect::<Vec<_>>();
838        let mut streamed = super::InputHasher::new("streamed-fields-v1");
839        let mut expected = super::InputHasher::new("streamed-fields-v1");
840        for length in [1, 64 * 1024 - 1, contents.len(), 0, 7, 1024, 64 * 1024 + 1] {
841            let bytes = &contents[..length];
842            fs::write(&source, bytes).unwrap();
843            assert_eq!(streamed.file_field("part", &source).unwrap(), length as u64);
844            expected.field("part", bytes);
845        }
846        assert_eq!(streamed.finish(), expected.finish());
847        fs::remove_dir_all(root).unwrap();
848    }
849
850    #[test]
851    fn streaming_digest_and_atomic_copy_preserve_exact_bytes() {
852        let root = unique_temp_directory("streaming-digest");
853        let source = root.join("source");
854        let destination = root.join("destination");
855        let mut contents = vec![0_u8; 192 * 1024 + 37];
856        for (index, byte) in contents.iter_mut().enumerate() {
857            *byte = u8::try_from(index % 251).expect("test byte must fit");
858        }
859        for length in [
860            0,
861            1,
862            1024,
863            16 * 1024,
864            64 * 1024 - 1,
865            64 * 1024,
866            64 * 1024 + 1,
867            contents.len(),
868        ] {
869            let data = &contents[..length];
870            fs::write(&source, data).expect("write source");
871            let streamed = digest_file("streaming-test-v1", &source).expect("digest file");
872            assert_eq!(
873                streamed.bytes,
874                u64::try_from(length).expect("fixture length must fit in u64")
875            );
876            assert_eq!(streamed.digest, digest_bytes("streaming-test-v1", data));
877        }
878
879        ic_host_fs::durable::write_bytes(&destination, b"old").expect("write original destination");
880        assert_eq!(
881            copy_file_atomic(&source, &destination).expect("copy source atomically"),
882            u64::try_from(contents.len()).expect("fixture length must fit in u64")
883        );
884        assert_eq!(
885            fs::read(&destination).expect("read copied destination"),
886            contents
887        );
888
889        let missing = root.join("missing");
890        let error = copy_file_atomic(&missing, &destination).expect_err("missing source must fail");
891        let message = error.to_string();
892        assert!(message.contains(&missing.display().to_string()));
893        assert!(message.contains(&destination.display().to_string()));
894        fs::remove_dir_all(root).expect("remove streaming-digest test directory");
895    }
896
897    #[test]
898    #[cfg(unix)]
899    fn atomic_copy_retains_publication_phase_and_original_output_on_failure() {
900        use ic_host_fs::durable::NamedWriteError;
901
902        let root = unique_temp_directory("atomic-copy-publication-errors");
903        let destination = root.join("destination");
904        fs::write(&destination, b"retained").unwrap();
905        // Opening a directory succeeds on Unix, but copying its bytes fails
906        // inside the producer after staging has started.
907        let error = copy_file_atomic(&root, &destination).unwrap_err();
908        let context = error
909            .get_ref()
910            .unwrap()
911            .downcast_ref::<super::AtomicCopyErrorContext>()
912            .unwrap();
913        let publication = context
914            .source
915            .get_ref()
916            .unwrap()
917            .downcast_ref::<NamedWriteError<std::io::Error>>()
918            .unwrap();
919        assert!(matches!(
920            publication,
921            NamedWriteError::Producer {
922                cleanup_error: None,
923                ..
924            }
925        ));
926        assert_eq!(fs::read(&destination).unwrap(), b"retained");
927        assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
928        fs::remove_dir_all(root).unwrap();
929    }
930
931    #[test]
932    #[cfg(unix)]
933    fn atomic_copy_rejects_invalid_destinations_without_publication() {
934        use ic_host_fs::durable::NamedWriteError;
935
936        let root = unique_temp_directory("atomic-copy-directory-suffix");
937        let source = root.join("source");
938        let destination = root.join("destination");
939        fs::write(&source, b"replacement").unwrap();
940        fs::write(&destination, b"retained").unwrap();
941        for (name, suffix) in [
942            ("destination", "/"),
943            ("destination", "/."),
944            ("missing/child", "/"),
945            ("missing/child", "/."),
946            ("missing/invalid\0name", ""),
947            ("invalid\0parent/child", ""),
948        ] {
949            let mut raw = root.join(name).into_os_string();
950            raw.push(suffix);
951            let target = PathBuf::from(raw);
952            let error = copy_file_atomic(&source, &target).unwrap_err();
953            let context = error
954                .get_ref()
955                .unwrap()
956                .downcast_ref::<super::AtomicCopyErrorContext>()
957                .unwrap();
958            assert_eq!(context.source_path, source);
959            assert_eq!(context.destination_path, target);
960            let publication = context
961                .source
962                .get_ref()
963                .unwrap()
964                .downcast_ref::<NamedWriteError<std::io::Error>>()
965                .unwrap();
966            assert!(matches!(
967                publication,
968                NamedWriteError::BeforePublication { source, cleanup_error: None }
969                    if source.kind() == std::io::ErrorKind::InvalidInput
970            ));
971            assert_eq!(fs::read(&destination).unwrap(), b"retained");
972            assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
973        }
974        fs::remove_dir_all(root).unwrap();
975    }
976
977    #[test]
978    #[cfg(unix)]
979    fn atomic_publication_supports_long_destination_names() {
980        let root = unique_temp_directory("atomic-long-destination");
981        let destination = root.join("a".repeat(255));
982        // Establish that the destination itself is valid on this filesystem.
983        fs::write(&destination, b"original output").unwrap();
984        let source = root.join("source");
985        fs::write(&source, b"copied output").unwrap();
986        assert_eq!(copy_file_atomic(&source, &destination).unwrap(), 13);
987        assert_eq!(fs::read(&destination).unwrap(), b"copied output");
988        assert_eq!(fs::read_dir(&root).unwrap().count(), 2);
989        fs::remove_dir_all(root).unwrap();
990    }
991
992    #[test]
993    fn composable_digest_reuses_roots_across_irrelevant_exclusion_changes() {
994        let root = unique_temp_directory("composable-digest-cache");
995        let input = root.join("input");
996        fs::create_dir_all(&input).expect("create composable input");
997        fs::create_dir_all(root.join("generated-a")).expect("create first generated root");
998        fs::create_dir_all(root.join("generated-b")).expect("create second generated root");
999        fs::write(input.join("source"), b"source").expect("write composable input");
1000        let paths = [(PathBuf::from("shared"), input)];
1001        let mut cache = LabeledPathDigestCache::default();
1002
1003        let first = digest_labeled_paths_composable(
1004            "composable-test-v1",
1005            paths
1006                .iter()
1007                .map(|(label, path)| (label.as_path(), path.as_path())),
1008            &[root.join("generated-a")],
1009            &mut cache,
1010        )
1011        .expect("hash first composable input");
1012        let second = digest_labeled_paths_composable(
1013            "composable-test-v1",
1014            paths
1015                .iter()
1016                .map(|(label, path)| (label.as_path(), path.as_path())),
1017            &[root.join("generated-b")],
1018            &mut cache,
1019        )
1020        .expect("reuse composable input root");
1021
1022        assert_eq!(first, second);
1023        assert_eq!(cache.entries.len(), 1);
1024        fs::remove_dir_all(root).expect("remove composable digest fixture");
1025    }
1026
1027    #[test]
1028    fn composable_digest_rehashes_changed_descendant_exclusions_and_rejects_ancestors() {
1029        let root = unique_temp_directory("composable-relevant-exclusions");
1030        let input = root.join("input");
1031        let generated = input.join("generated");
1032        fs::create_dir_all(&generated).unwrap();
1033        fs::write(input.join("source"), b"source").unwrap();
1034        fs::write(generated.join("artifact"), b"generated").unwrap();
1035        let paths = [(PathBuf::from("input"), input.clone())];
1036        let digest = |exclusions: &[PathBuf], cache: &mut LabeledPathDigestCache| {
1037            digest_labeled_paths_composable(
1038                "exclusions-test-v1",
1039                paths
1040                    .iter()
1041                    .map(|(label, path)| (label.as_path(), path.as_path())),
1042                exclusions,
1043                cache,
1044            )
1045        };
1046        let mut cache = LabeledPathDigestCache::default();
1047        let excluded = digest(std::slice::from_ref(&generated), &mut cache).unwrap();
1048        let included = digest(&[], &mut cache).unwrap();
1049        assert_ne!(included, excluded);
1050        assert_eq!(
1051            included,
1052            digest(&[], &mut LabeledPathDigestCache::default()).unwrap(),
1053        );
1054        for ancestor in [&input, &root] {
1055            assert_eq!(
1056                digest(std::slice::from_ref(ancestor), &mut cache)
1057                    .unwrap_err()
1058                    .kind(),
1059                std::io::ErrorKind::InvalidInput,
1060            );
1061        }
1062        assert_eq!(
1063            digest(std::slice::from_ref(&generated), &mut cache).unwrap(),
1064            excluded,
1065        );
1066        fs::remove_dir_all(root).unwrap();
1067    }
1068
1069    #[test]
1070    #[cfg(unix)]
1071    fn composable_digest_tracks_exclusions_beyond_an_external_symlink() {
1072        let root = unique_temp_directory("composable-external-exclusions");
1073        let input = root.join("input");
1074        let external = root.join("external");
1075        fs::create_dir_all(&input).unwrap();
1076        fs::create_dir_all(external.join("first")).unwrap();
1077        fs::create_dir_all(external.join("second")).unwrap();
1078        fs::write(input.join("source"), b"source").unwrap();
1079        fs::write(external.join("first/file"), b"first").unwrap();
1080        fs::write(external.join("second/file"), b"second").unwrap();
1081        std::os::unix::fs::symlink(&external, input.join("linked")).unwrap();
1082        let paths = [(PathBuf::from("input"), input)];
1083        let digest = |exclusion: &PathBuf, cache: &mut LabeledPathDigestCache| {
1084            digest_labeled_paths_composable(
1085                "external-exclusions-test-v1",
1086                paths
1087                    .iter()
1088                    .map(|(label, path)| (label.as_path(), path.as_path())),
1089                std::slice::from_ref(exclusion),
1090                cache,
1091            )
1092        };
1093        let mut cache = LabeledPathDigestCache::default();
1094        let first = digest(&external.join("first"), &mut cache).unwrap();
1095        let second = digest(&external.join("second"), &mut cache).unwrap();
1096        assert_ne!(first, second);
1097        assert_eq!(
1098            second,
1099            digest(
1100                &external.join("second"),
1101                &mut LabeledPathDigestCache::default(),
1102            )
1103            .unwrap(),
1104        );
1105        assert_eq!(digest(&external.join("first"), &mut cache).unwrap(), first);
1106        fs::remove_dir_all(root).unwrap();
1107    }
1108}