Skip to main content

decdn_cli/commands/
manifest.rs

1//! Shared bundle-manifest machinery: the directory walk, path-safety rules,
2//! canonical JSON serialization, and atomic manifest write used by
3//! `decdn origin import` (issues #391, #1904).
4//!
5//! `origin import` walks a directory and emits canonical manifest bytes; with
6//! `--dry-run` it prints the exact same bytes to stdout instead of importing,
7//! so operators can inspect or capture the manifest the import would produce.
8//! Keeping the walk and the serializer in one place is what makes that
9//! byte-identity hold by construction rather than by two copies staying in
10//! sync.
11//!
12//! The on-disk schema, hash format (`b3:<hex>`), path-safety rules, and the
13//! determinism contract that makes single-hash bundle distribution viable are
14//! specified in [`appendix-bundles`](https://github.com/decdn/decdn/blob/main/adr/appendix-bundles.md).
15
16use std::fs::File;
17use std::io::Write;
18use std::path::{Component, Path};
19
20use anyhow::{Context as _, anyhow, bail};
21use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
22use serde::Serialize;
23use walkdir::WalkDir;
24
25// Do not reorder: JSON key order is part of the bundle hash. Determinism
26// requires the produced bytes to be stable across runs, so the only way to add
27// a field in v1 is to add it at the end (and prefer a v2 bump instead — see
28// `appendix-bundles.md` § Determinism).
29#[derive(Serialize)]
30struct Bundle<'a> {
31    version: u32,
32    entries: &'a [BundleEntry],
33}
34
35/// One manifest entry: a file's relative POSIX path, its `b3:<hex>` content
36/// address, and its byte length.
37///
38/// `hash`/`size` always describe the **whole file**, stored and served as one
39/// blob. When `chunks` is present it lists advisory `{hash, size}`
40/// range-dedup hints over that same file's byte layout, in content order; a
41/// hint's hash is never independently stored or fetched, only matched
42/// against bytes a client already holds. `hash` is the authoritative
43/// end-to-end validator regardless of whether hints are present. A plain
44/// (unhinted) entry omits `chunks` entirely and serializes byte-identically
45/// to a bundle with no chunk hints.
46#[derive(Serialize)]
47pub(crate) struct BundleEntry {
48    /// Relative POSIX path within the bundle root (`a/b.txt`, never absolute).
49    pub(crate) path: String,
50    /// The whole file's BLAKE3 content address, `b3:<64 lowercase hex>`.
51    pub(crate) hash: String,
52    /// The whole file's length in bytes.
53    pub(crate) size: u64,
54    /// Optional ordered range-dedup hints over the file's bytes. The file is
55    /// always stored and fetched as the one whole-file blob named by `hash`;
56    /// a hint only lets a client recognize a byte range it already holds (a
57    /// sibling entry with a matching chunk hash) and skip re-downloading it.
58    /// Skipped from the serialized form when absent so plain entries keep
59    /// their pre-hints byte layout.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub(crate) chunks: Option<Vec<Chunk>>,
62}
63
64/// One range-dedup hint over a hinted [`BundleEntry`]'s bytes: a BLAKE3 over
65/// that byte range and its length. Hints are listed in content order — the
66/// range they cover, in sequence, spans the whole file — and a hint `hash`
67/// shared across entries tells a client the two files share that byte range,
68/// so it fetches and pays for it once and splices the other copy in locally.
69/// The hash is never independently stored, served, or fetched as a blob.
70#[derive(Serialize)]
71pub(crate) struct Chunk {
72    /// The BLAKE3 content address of this byte range, `b3:<64 lowercase hex>`.
73    pub(crate) hash: String,
74    /// The chunk's length in bytes. The chunk sizes sum to the entry's `size`.
75    pub(crate) size: u64,
76}
77
78/// The result of walking a directory: the sorted entries plus the two
79/// counters the operator-facing status report (`origin import`) surfaces.
80pub(crate) struct WalkOutput {
81    /// Manifest entries, sorted by path bytes (deterministic).
82    pub(crate) entries: Vec<BundleEntry>,
83    /// Count of symlinks skipped (only non-zero without `--follow-symlinks`).
84    pub(crate) skipped_symlinks: u64,
85    /// Sum of every recorded entry's size.
86    pub(crate) total_size: u64,
87}
88
89/// Format a BLAKE3 hash as the `b3:<hex>` content-address string.
90pub(crate) fn b3_hex_str(h: blake3::Hash) -> String {
91    format!("b3:{}", h.to_hex())
92}
93
94/// Compile the repeatable `--exclude` globs into a single matcher.
95pub(crate) fn build_excluder(patterns: &[String]) -> anyhow::Result<GlobSet> {
96    build_glob_set(patterns, "--exclude")
97}
98
99/// Compile the repeatable glob `patterns` given for `flag` into a single
100/// matcher. `flag` names the source flag so a bad pattern reports the flag the
101/// operator actually typed. Shared by `origin import`'s `--exclude` and
102/// bundle pull's `--include`/`--exclude` entry filter.
103pub(crate) fn build_glob_set(patterns: &[String], flag: &str) -> anyhow::Result<GlobSet> {
104    let mut builder = GlobSetBuilder::new();
105    for raw in patterns {
106        // `literal_separator(true)` aligns with gitignore semantics: `*` does
107        // not cross `/`. Operators expect `tmp/*` to match files directly under
108        // `tmp/` and `tmp/**` to recurse, not for `*` to greedily span
109        // separators.
110        let glob = GlobBuilder::new(raw)
111            .literal_separator(true)
112            .build()
113            .with_context(|| format!("invalid {flag} pattern {raw:?}"))?;
114        builder.add(glob);
115    }
116    builder
117        .build()
118        .map_err(|e| anyhow!("failed to build glob set: {e}"))
119}
120
121/// `filter_entry` predicate: whether the walker keeps (descends into / yields) a
122/// non-root entry at relative path `rel`. `is_dir` is the walker's lexical file
123/// type (does NOT follow symlinks). The directory branch only prunes when the
124/// exclude pattern is recursive at this directory (sentinel-child probe) so a
125/// one-level `tmp/*` can't strand a deeper `tmp/sub/y`.
126fn keep_entry(rel: &Path, is_dir: bool, excluder: &GlobSet) -> bool {
127    if !excluder.is_match(rel) {
128        return true;
129    }
130    if !is_dir {
131        // Matched file/symlink: skip it before canonicalize.
132        return false;
133    }
134    // Matched directory: prune only if its whole subtree is excluded.
135    // `__decdn_subtree_probe__` is a synthetic leaf name — its only role is to
136    // distinguish a recursive pattern (`tmp/**`, which also matches the child)
137    // from a one-level one (`tmp/*`, which does not).
138    !excluder.is_match(rel.join("__decdn_subtree_probe__"))
139}
140
141/// Walk `root` (already canonicalized by the caller), applying the exclude set
142/// and symlink-containment rules, and invoke `per_file` for every regular file
143/// kept. `per_file` receives the file's canonicalized path and its validated
144/// relative POSIX string, and returns the file's whole-file `b3:<hex>` address,
145/// size, and an optional chunk decomposition — a dry-run caller just hashes;
146/// `origin import` (without `--dry-run`) hashes **and** writes the blob; a
147/// plain (non-chunking) caller always returns `None` for the third element.
148///
149/// The entries are sorted by path bytes so the emitted manifest — and therefore
150/// its own BLAKE3 — is byte-stable across runs.
151pub(crate) fn walk_and_collect<F>(
152    root: &Path,
153    follow_symlinks: bool,
154    excluder: &GlobSet,
155    mut per_file: F,
156) -> anyhow::Result<WalkOutput>
157where
158    F: FnMut(&Path, &str) -> anyhow::Result<(String, u64, Option<Vec<Chunk>>)>,
159{
160    let mut entries: Vec<BundleEntry> = Vec::new();
161    let mut skipped_symlinks: u64 = 0;
162    let mut total_size: u64 = 0;
163
164    // Skip / prune excluded entries *lexically*, before any canonicalize.
165    // `filter_entry` runs on every yielded entry including the root: when the
166    // entry is the root (empty relative path) or `strip_prefix` can't produce a
167    // relative path, keep it — never prune the root. globset's `Candidate`
168    // normalizes a `&Path` to its forward-slash form internally, so matching the
169    // relative `&Path` here is equivalent to matching the validated `rel_str`
170    // below.
171    //
172    // A matched FILE/symlink is skipped here, before the canonicalize step — so
173    // an escaping symlink that the operator excluded (e.g. via `tmp/**`) is
174    // never resolved and cannot trip the escape `bail!` below.
175    //
176    // A matched DIRECTORY is pruned only when its WHOLE subtree is excluded; the
177    // sentinel-probe in `keep_entry` distinguishes a recursive pattern from a
178    // one-level one so pruning never strands non-excluded descendants.
179    let walker = WalkDir::new(root)
180        .follow_links(follow_symlinks)
181        .into_iter()
182        .filter_entry(|entry| match entry.path().strip_prefix(root) {
183            Ok(rel) if rel.as_os_str().is_empty() => true,
184            Ok(rel) => keep_entry(rel, entry.file_type().is_dir(), excluder),
185            Err(_) => true,
186        });
187
188    for step in walker {
189        // walkdir surfaces opendir/readdir errors and (with follow_links)
190        // symlink loops as Err here — propagate; never silently skip.
191        let entry = step.with_context(|| format!("walking {}", root.display()))?;
192        let ftype = entry.file_type();
193        if ftype.is_dir() {
194            continue;
195        }
196        // With follow_links=false, symlinks come through as symlink entries we
197        // never read — skip silently and surface the count in the report. With
198        // follow_links=true, walkdir resolves the link transparently and the
199        // entry presents as a regular file; only then does the path-safety check
200        // below run and catch escapes via in-tree symlinks.
201        if ftype.is_symlink() {
202            skipped_symlinks = skipped_symlinks.saturating_add(1);
203            continue;
204        }
205        if !ftype.is_file() {
206            continue;
207        }
208
209        // Canonicalize-and-contain — same shape as `FilesystemOrigin::fetch` in
210        // the cache crate. With follow_links=true a walked path can resolve
211        // outside the root via a symlink in any ancestor; the manifest's `path`
212        // field is a relative POSIX string and cannot truthfully describe an
213        // external target. Hard error.
214        let canonical = std::fs::canonicalize(entry.path())
215            .with_context(|| format!("canonicalize {}", entry.path().display()))?;
216        if !canonical.starts_with(root) {
217            bail!(
218                "{} resolves to {} which is outside root {}",
219                entry.path().display(),
220                canonical.display(),
221                root.display()
222            );
223        }
224
225        // Use the lexical walked path (rooted at the canonical root) for the
226        // manifest's `path` field, not the canonical resolved target — a
227        // followed symlink is recorded under the name a user sees, and an
228        // in-root symlink + its real target each get a distinct entry.
229        let rel = entry
230            .path()
231            .strip_prefix(root)
232            .map_err(|_| anyhow!("strip_prefix failed for {}", entry.path().display()))?;
233        let rel_str = validate_relpath(rel)?;
234
235        // Cheap backstop: `filter_entry` above already pruned excluded subtrees
236        // lexically. Keep it so the exclusion contract holds even if the
237        // pre-walk filter and this validated key ever diverge.
238        if excluder.is_match(&rel_str) {
239            continue;
240        }
241
242        let (hash, size, chunks) = per_file(&canonical, &rel_str)
243            .with_context(|| format!("processing {}", canonical.display()))?;
244
245        total_size = total_size.checked_add(size).ok_or_else(|| {
246            anyhow!("total_size overflow at {rel_str}: running={total_size} adding={size}")
247        })?;
248
249        entries.push(BundleEntry {
250            path: rel_str,
251            hash,
252            size,
253            chunks,
254        });
255    }
256
257    // Sort by path bytes — deterministic and matches the byte order any verifier
258    // would use when independently rebuilding the manifest from the same tree.
259    entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
260
261    Ok(WalkOutput {
262        entries,
263        skipped_symlinks,
264        total_size,
265    })
266}
267
268/// Build the POSIX-`/`-joined relative path string from a `Path`, rejecting any
269/// non-`Normal` component. Defensive — `strip_prefix` on canonical paths
270/// shouldn't produce these segments — but the manifest's `path` field is the
271/// contract verifiers re-validate against, so we assert here rather than
272/// trusting the upstream walker.
273pub(crate) fn validate_relpath(rel: &Path) -> anyhow::Result<String> {
274    let mut out = String::new();
275    let mut empty = true;
276    for comp in rel.components() {
277        match comp {
278            Component::Normal(os) => {
279                let s = os
280                    .to_str()
281                    .ok_or_else(|| anyhow!("non-UTF-8 path component in {}", rel.display()))?;
282                if !empty {
283                    out.push('/');
284                }
285                out.push_str(s);
286                empty = false;
287            }
288            Component::CurDir => {
289                // ADR `appendix-bundles.md` § Path-safety rules requires every
290                // component to be `Component::Normal`. Rust's Path::components
291                // normalizes most `.` segments away, but a leading `./` still
292                // surfaces here — bail to match the spec rather than silently
293                // collapse to `Normal` order.
294                bail!("rejected current-dir component '.' in {}", rel.display());
295            }
296            Component::ParentDir => {
297                bail!("rejected parent-dir component '..' in {}", rel.display());
298            }
299            Component::RootDir => {
300                bail!("rejected root-dir component '/' in {}", rel.display());
301            }
302            Component::Prefix(_) => {
303                bail!("rejected path prefix component in {}", rel.display());
304            }
305        }
306    }
307    if out.is_empty() {
308        bail!("empty relative path");
309    }
310    Ok(out)
311}
312
313/// Open a file once and pull both the BLAKE3 hash and the size from the open
314/// handle. The fd-derived `metadata()` (`fstat` on Unix) closes the TOCTOU
315/// window between a separate metadata call and the read.
316/// `blake3::Hasher::update_reader` is the upstream-recommended streaming
317/// primitive; it manages its own buffer and retries on `Interrupted` internally.
318pub(crate) fn hash_file_at(path: &Path) -> std::io::Result<(blake3::Hash, u64)> {
319    let mut file = File::open(path)?;
320    let size = file.metadata()?.len();
321    let mut hasher = blake3::Hasher::new();
322    hasher.update_reader(&mut file)?;
323    Ok((hasher.finalize(), size))
324}
325
326/// Render the manifest to its canonical byte form: single-line compact JSON, no
327/// trailing newline, UTF-8, struct-order keys.
328pub(crate) fn serialize_canonical(entries: &[BundleEntry]) -> anyhow::Result<Vec<u8>> {
329    let bundle = Bundle {
330        version: 1,
331        entries,
332    };
333    serde_json::to_vec(&bundle).map_err(|e| anyhow!("serialize bundle: {e}"))
334}
335
336/// Write manifest bytes to `target` atomically. `NamedTempFile` creates a
337/// uniquely-named file in the destination directory via `O_CREAT|O_EXCL`, so
338/// there is no predictable `<output>.partial` path an adversary or concurrent
339/// writer can plant a symlink at. `persist` does the cross-platform atomic
340/// rename-replace — when `target` already exists as a symlink, `rename(2)`
341/// replaces the symlink itself with the new file, it does not follow it.
342pub(crate) fn write_bundle(target: &Path, bytes: &[u8]) -> std::io::Result<()> {
343    let parent = target.parent().filter(|p| !p.as_os_str().is_empty());
344    let mut tmp = match parent {
345        Some(p) => tempfile::NamedTempFile::new_in(p)?,
346        None => tempfile::NamedTempFile::new_in(".")?,
347    };
348    tmp.write_all(bytes)?;
349    tmp.as_file().sync_all()?;
350    tmp.persist(target).map_err(|e| e.error)?;
351    Ok(())
352}
353
354#[cfg(test)]
355#[allow(
356    clippy::unwrap_used,
357    clippy::expect_used,
358    clippy::indexing_slicing,
359    clippy::panic
360)]
361mod tests;