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;