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