Skip to main content

ic_build/artifacts/
digest.rs

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