Skip to main content

ic_build/artifacts/
digest.rs

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