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