Skip to main content

decdn_cli/commands/
origin.rs

1//! `decdn origin import` — seed a cache origin store with local content
2//! (issue #1904).
3//!
4//! For every blob it writes the sharded data object `{base}/{hex[0..2]}/{hex}`
5//! and its sibling pre-order bao outboard `{hex}.obao4` — the exact layout the
6//! daemon reads back (ADR 037 §Origin-tier pull-through).
7//!
8//! `--to` is a local filesystem directory: the store is written to disk and
9//! nowhere else. Populating an S3 origin needs no separate writer, because the
10//! fs and S3 object layouts are byte-identical (the S3 reader keys objects
11//! `{prefix}{hex[0..2]}/{hex}` to match this on-disk shard) — import to a local
12//! directory and `aws s3 sync` it up. `parse_target` turns an `s3://` (or
13//! `http(s)://`) target into an error that spells out that recipe rather than
14//! writing to a directory named after the URL.
15//!
16//! The work is synchronous (bao encoding + filesystem writes are sync), wrapped
17//! once in `tokio::task::spawn_blocking` from the async entry point so the CLI's
18//! runtime isn't held up.
19
20use std::collections::BTreeMap;
21use std::fs::File;
22use std::io::{Read, Write};
23use std::path::{Path, PathBuf};
24
25use anyhow::{anyhow, bail};
26use decdn_bao_range::{EncodedOutboard, encode_outboard, shard_prefix};
27use decdn_common::cli::{OriginArgs, OriginCommand, OriginImportArgs};
28use serde::Serialize;
29
30use super::chunker::{ChunkSizes, chunk_file};
31use super::manifest::{
32    BundleEntry, Chunk, b3_hex_str, build_excluder, hash_file_at, serialize_canonical,
33    validate_relpath, walk_and_collect, write_bundle,
34};
35
36/// Dispatcher for `decdn origin ...`. Every subcommand is offline, config-free
37/// local work, so no `config_path` is threaded through.
38pub async fn origin_dispatch(args: &OriginArgs) -> anyhow::Result<()> {
39    match &args.cmd {
40        OriginCommand::Import(import_args) => origin_import(import_args).await,
41    }
42}
43
44/// The parsed `--to` target. The filesystem is the only write backend, so this
45/// is a one-variant enum — it stays an enum so the write dispatch reads as a
46/// `match` and a future backend is an added arm, not a rewrite.
47#[derive(Debug)]
48enum ImportTarget {
49    /// A local filesystem origin store rooted at this directory.
50    Fs(PathBuf),
51}
52
53/// Parse a `--to <dir>` target. The target is a local filesystem directory —
54/// the default and only write backend. An `s3://` or `http(s)://` target is not
55/// a write target and produces an error that spells out the supported path. An
56/// `fs:` prefix (the config `kind` name, not a URI scheme here) is rejected too,
57/// so a typo does not silently seed a relative directory literally named `fs:…`
58/// that the node never reads.
59///
60/// The scheme checks run on the path's UTF-8 view. A non-UTF-8 path (valid on
61/// Unix) can be none of those ASCII schemes, so it falls straight through to
62/// the filesystem arm rather than being rejected for not being UTF-8.
63fn parse_target(raw: &Path) -> anyhow::Result<ImportTarget> {
64    if let Some(s) = raw.to_str() {
65        if s.is_empty() {
66            bail!("--to needs a directory, e.g. --to /var/lib/decdn/origin");
67        }
68        if s.starts_with("s3://") {
69            bail!(
70                "--to {s} is not a write target: `origin import` writes a local \
71                 filesystem store only. The fs and S3 object layouts are identical, so \
72                 import to a directory and sync it up:\n  \
73                 decdn origin import -i <input> --to <dir>\n  \
74                 aws s3 sync <dir> {s}"
75            );
76        }
77        if s.starts_with("http://") || s.starts_with("https://") {
78            bail!(
79                "--to {s} is not a write target: an HTTP origin is a read-only static \
80                 server — seed a local directory and serve it from that disk instead"
81            );
82        }
83        if s.starts_with("fs:") {
84            bail!(
85                "--to takes a directory path, not an `fs:` URI: pass the path directly, \
86                 e.g. --to /var/lib/decdn/origin"
87            );
88        }
89    }
90    Ok(ImportTarget::Fs(raw.to_path_buf()))
91}
92
93/// One-line JSON / human status report emitted after a successful import.
94#[derive(Serialize)]
95struct ImportReport {
96    /// Number of source files imported (excludes the derived bundle manifest).
97    imported: u64,
98    /// Sum of the imported source files' sizes in bytes.
99    bytes: u64,
100    /// The `--to` target string the blobs were written to.
101    origin: String,
102    /// Every imported source file's path, relative to `--input`, mapped to its
103    /// `b3:<hex>` content address. For a single-file import the one key is the
104    /// file's own name; for a directory it is the POSIX relative path within the
105    /// tree. Excludes the derived bundle manifest — that is `bundle_hash`.
106    files: BTreeMap<String, String>,
107    /// The bundle hash publishers distribute — `Some` for a directory import,
108    /// `None` for a single file (which produces no manifest).
109    bundle_hash: Option<String>,
110    /// Whether sources were moved (`--move`) rather than copied. Always
111    /// `false` under `--dry-run`, since nothing is written; `--move` is
112    /// rejected together with `--optimize`, so this is never `true` for an
113    /// optimized import.
114    moved: bool,
115    /// Whether content-defined chunking (`--optimize`) was applied — each file
116    /// is still stored as one whole-file blob, but the manifest also carries
117    /// its chunk decomposition as dedup hints.
118    optimized: bool,
119    /// Chunk hint count across every file (0 unless `--optimize`). Each file is
120    /// stored as one whole-file blob; the chunks are manifest-only dedup hints,
121    /// not separately stored blobs. Equals the sum of every entry's chunk count.
122    chunks_total: u64,
123    /// Count of symlinks skipped during a directory walk (0 for a single-file
124    /// import, and only non-zero without `--follow-symlinks`).
125    skipped_symlinks: u64,
126}
127
128/// The resolved import context, threaded through the sync import paths. It
129/// captures the two axes that steer every write decision — `write` (whether
130/// blobs land on disk at all, false under `--dry-run`) and `optimize` (whether
131/// files are content-defined-chunked) — plus the shared flags and the resolved
132/// filesystem `base` (`Some` exactly when `write`).
133#[expect(
134    clippy::struct_excessive_bools,
135    reason = "independent per-invocation toggles resolved from CLI flags, not modelled state"
136)]
137struct ImportCtx {
138    /// The origin store root the blobs are written under, `Some` iff `write`.
139    base: Option<PathBuf>,
140    /// Whether blobs are written. False under `--dry-run` (hash + manifest only).
141    write: bool,
142    /// Whether the status report and manifest bytes route for a dry run:
143    /// manifest to stdout, status to stderr.
144    dry_run: bool,
145    /// Move sources into the store instead of copying (`--move`).
146    move_source: bool,
147    /// Overwrite an object already present at its hash (`--force`).
148    force: bool,
149    /// Also write the canonical manifest to this file (`--bundle`).
150    bundle_out: Option<PathBuf>,
151    /// The `--to` target string surfaced in the report (a placeholder in a
152    /// no-target dry run).
153    origin_label: String,
154    /// Content-define-chunk each file and record its chunk decomposition as
155    /// manifest dedup hints, on top of the usual whole-file blob (`--optimize`).
156    optimize: bool,
157    /// The validated chunk-size triple, `Some` iff `optimize`.
158    sizes: Option<ChunkSizes>,
159    /// The validated `--subfolder` prefix (a relative POSIX path, no trailing
160    /// slash), prepended to every manifest entry's path so a pull lands the
161    /// whole bundle under one directory. `None` when the flag is absent.
162    subfolder: Option<String>,
163}
164
165/// Validate `--subfolder` into the normalized prefix stored in [`ImportCtx`].
166/// Reuses the manifest path-safety rules ([`validate_relpath`]): a relative
167/// POSIX path with only `Normal` components — `..`, absolute paths, and root
168/// prefixes are rejected — and normalizes a trailing slash away, so `assets/`
169/// and `assets` both yield the `assets` prefix.
170///
171/// A backslash is rejected before that check. `\` is not a POSIX separator, so
172/// on Unix `a\..\evil` collapses to one `Normal` component and slips past the
173/// `..` check — yet a Windows `bundle pull` parses `\` as a separator in
174/// `safe_join`, turning that same manifest path into a parent-dir escape.
175/// Requiring `/` keeps the published manifest identical and safe on every
176/// platform.
177fn resolve_subfolder(raw: Option<&str>) -> anyhow::Result<Option<String>> {
178    let Some(raw) = raw else { return Ok(None) };
179    if raw.contains('\\') {
180        bail!(
181            "invalid --subfolder {raw:?}: use '/' as the path separator; \
182             '\\' is not allowed (a POSIX-relative subfolder keeps the \
183             manifest identical and safe across platforms)"
184        );
185    }
186    let normalized = validate_relpath(Path::new(raw))
187        .map_err(|e| anyhow!("invalid --subfolder {raw:?}: {e}"))?;
188    Ok(Some(normalized))
189}
190
191/// Place a bundle-relative path under the `--subfolder` prefix when one is set,
192/// so a pull writes the file to `<out>/<subfolder>/<rel>`. `prefix` is already
193/// validated to a relative POSIX path, and `rel` is a validated relative POSIX
194/// path, so the join stays all-`Normal`-component and re-validatable.
195fn place_under(prefix: Option<&str>, rel: &str) -> String {
196    match prefix {
197        Some(sub) => format!("{sub}/{rel}"),
198        None => rel.to_string(),
199    }
200}
201
202/// Entry point. Resolves the target and chunk options, dispatches file vs
203/// directory import inside `spawn_blocking`, and routes the status report —
204/// stdout normally, stderr under `--dry-run` (where stdout carries the manifest).
205pub async fn origin_import(args: &OriginImportArgs) -> anyhow::Result<()> {
206    // `--to` is required unless `--dry-run`; a dry run needs no target because it
207    // writes no blobs.
208    let target = match (&args.to, args.dry_run) {
209        (Some(t), _) => Some(parse_target(t)?),
210        (None, true) => None,
211        (None, false) => bail!("--to <target> is required unless --dry-run"),
212    };
213    // The chunk-size flags only mean something with `--optimize`; reject them
214    // otherwise so a typo like `--chunk-avg` without `--optimize` is not silently
215    // ignored.
216    if !args.optimize
217        && (args.chunk_avg.is_some() || args.chunk_min.is_some() || args.chunk_max.is_some())
218    {
219        bail!("--chunk-avg/--chunk-min/--chunk-max require --optimize");
220    }
221    // The 4 MiB avg default lives here, not in clap, so an explicit `--chunk-avg`
222    // without `--optimize` stays detectable as `Some` above.
223    let avg = args.chunk_avg.unwrap_or(4 * 1024 * 1024);
224    let sizes = if args.optimize {
225        Some(ChunkSizes::resolve(avg, args.chunk_min, args.chunk_max)?)
226    } else {
227        None
228    };
229
230    let subfolder = resolve_subfolder(args.subfolder.as_deref())?;
231
232    let write = !args.dry_run;
233    // `--to` is a path; the report's `origin` field is a display string, so a
234    // non-UTF-8 target degrades lossily here — only for the human/JSON label,
235    // never for the actual write path, which keeps the raw `PathBuf`.
236    let origin_label = args
237        .to
238        .as_ref()
239        .map_or_else(|| "(dry-run)".to_string(), |p| p.display().to_string());
240
241    let input = args.input.clone();
242    let follow = args.follow_symlinks;
243    let exclude = args.exclude.clone();
244    let json = args.json;
245    let dry_run = args.dry_run;
246
247    let ctx = ImportCtx {
248        base: None,
249        write,
250        dry_run,
251        move_source: args.move_source,
252        force: args.force,
253        bundle_out: args.bundle.clone(),
254        origin_label,
255        optimize: args.optimize,
256        sizes,
257        subfolder,
258    };
259
260    let report = tokio::task::spawn_blocking(move || -> anyhow::Result<ImportReport> {
261        let mut ctx = ctx;
262        // Create + canonicalize the origin root only when writing; a dry run
263        // must never touch the filesystem target.
264        if ctx.write {
265            let ImportTarget::Fs(base) =
266                target.ok_or_else(|| anyhow!("internal: write without a --to target"))?;
267            std::fs::create_dir_all(&base)
268                .map_err(|e| anyhow!("create origin dir {}: {e}", base.display()))?;
269            let base = std::fs::canonicalize(&base)
270                .map_err(|e| anyhow!("canonicalize origin dir {}: {e}", base.display()))?;
271            ctx.base = Some(base);
272        }
273
274        let meta = std::fs::symlink_metadata(&input)
275            .map_err(|e| anyhow!("--input {}: {e}", input.display()))?;
276
277        if meta.is_dir() {
278            import_directory(&ctx, &input, follow, &exclude)
279        } else if meta.is_file() {
280            if ctx.optimize {
281                import_single_optimized(&ctx, &input)
282            } else {
283                import_single_plain(&ctx, &input)
284            }
285        } else {
286            bail!(
287                "--input {} is neither a regular file nor a directory",
288                input.display()
289            );
290        }
291    })
292    .await
293    .map_err(|e| {
294        let note = if e.is_panic() {
295            "origin import task panicked"
296        } else if e.is_cancelled() {
297            "origin import task cancelled"
298        } else {
299            "origin import task failed to join"
300        };
301        anyhow::Error::from(e).context(note)
302    })??;
303
304    // The manifest bytes already went to stdout inside `emit_manifest` during a
305    // dry run; the status report follows on stderr so stdout is exactly the
306    // manifest. A normal import prints the status to stdout.
307    if dry_run {
308        let mut stderr = std::io::stderr().lock();
309        write_import_report(&mut stderr, &report, json)
310            .map_err(|e| anyhow!("failed to write status report: {e}"))?;
311    } else {
312        let mut stdout = std::io::stdout().lock();
313        write_import_report(&mut stdout, &report, json)
314            .map_err(|e| anyhow!("failed to write status report: {e}"))?;
315    }
316    Ok(())
317}
318
319/// Import a whole directory tree and emit its canonical bundle manifest. Every
320/// regular file is stored as one whole-file blob, plain or `--optimize` alike;
321/// `--optimize` additionally content-defined-chunks each file and records the
322/// chunk list in the manifest as dedup hints — hints only, never separately
323/// stored blobs. Either way the manifest bytes are the same canonical bytes
324/// `--dry-run` would print, so the tree is retrievable by the reported bundle
325/// hash.
326fn import_directory(
327    ctx: &ImportCtx,
328    input: &Path,
329    follow: bool,
330    exclude: &[String],
331) -> anyhow::Result<ImportReport> {
332    let excluder = build_excluder(exclude)?;
333    let root = std::fs::canonicalize(input)
334        .map_err(|e| anyhow!("canonicalize --input {}: {e}", input.display()))?;
335
336    if ctx.optimize {
337        let sizes = ctx
338            .sizes
339            .as_ref()
340            .ok_or_else(|| anyhow!("internal: optimize without chunk sizes"))?;
341        let mut chunks_total: u64 = 0;
342        let collected = walk_and_collect(&root, follow, &excluder, |canonical, _rel| {
343            let (hash_hex, size, chunks) = import_optimized_file(ctx, canonical, sizes)?;
344            let n = u64::try_from(chunks.len())
345                .map_err(|_| anyhow!("chunk count {} exceeds u64", chunks.len()))?;
346            chunks_total = chunks_total
347                .checked_add(n)
348                .ok_or_else(|| anyhow!("chunk count overflow"))?;
349            Ok((b3_hex_str_from_hex(&hash_hex), size, Some(chunks)))
350        })?;
351        emit_manifest(
352            ctx,
353            collected.entries,
354            collected.total_size,
355            true,
356            chunks_total,
357            collected.skipped_symlinks,
358        )
359    } else {
360        let collected = walk_and_collect(&root, follow, &excluder, |canonical, _rel| {
361            if ctx.write {
362                let base = ctx
363                    .base
364                    .as_deref()
365                    .ok_or_else(|| anyhow!("internal: write without a base"))?;
366                let blob = import_one_file(base, canonical, ctx.move_source, ctx.force)?;
367                Ok((b3_hex_str_from_hex(&blob.hash_hex), blob.size, None))
368            } else {
369                let (h, s) = hash_file_at(canonical)
370                    .map_err(|e| anyhow!("hash {}: {e}", canonical.display()))?;
371                Ok((b3_hex_str(h), s, None))
372            }
373        })?;
374        emit_manifest(
375            ctx,
376            collected.entries,
377            collected.total_size,
378            false,
379            0,
380            collected.skipped_symlinks,
381        )
382    }
383}
384
385/// Import one `--optimize` file: store its whole-file blob + `.obao4` outboard
386/// exactly like a plain import (`ctx.write` gates the write, same as
387/// `import_one_file`/`hash_file_at` elsewhere), then compute its chunk hints
388/// with a second, no-op-sink streaming pass over the same bytes. Returns the
389/// whole-file `(hex hash, size, chunk hints)`.
390///
391/// The chunk pass reads the just-written **stored object** at its
392/// content-addressed path, not the source. The store copy always exists after
393/// `import_one_file` — a `--move` renames the source into the store, a copy
394/// stages it there — so `--optimize` composes with `--move` for free, and
395/// chunking the immutable stored bytes rules out any read race between the two
396/// passes. Re-chunking those bytes must reproduce the whole-file hash and size;
397/// a mismatch would mean the stored object was corrupted between the write and
398/// the chunk pass, and is reported rather than emitting hints that don't
399/// describe the stored blob.
400fn import_optimized_file(
401    ctx: &ImportCtx,
402    path: &Path,
403    sizes: &ChunkSizes,
404) -> anyhow::Result<(String, u64, Vec<Chunk>)> {
405    if ctx.write {
406        let base = ctx
407            .base
408            .as_deref()
409            .ok_or_else(|| anyhow!("internal: write without a base"))?;
410        let blob = import_one_file(base, path, ctx.move_source, ctx.force)?;
411
412        let stored = base.join(shard_prefix(&blob.hash_hex)).join(&blob.hash_hex);
413        let file = File::open(&stored)
414            .map_err(|e| anyhow!("open stored object {}: {e}", stored.display()))?;
415        let cf = chunk_file(file, sizes, |_chash, _data| Ok(()))?;
416
417        if cf.whole_hash.to_hex().as_str() != blob.hash_hex || cf.total_size != blob.size {
418            bail!(
419                "stored object {} is corrupt: whole-file pass wrote {} bytes at {}, \
420                 chunk pass read {} bytes at {}",
421                stored.display(),
422                blob.size,
423                blob.hash_hex,
424                cf.total_size,
425                cf.whole_hash.to_hex()
426            );
427        }
428        Ok((blob.hash_hex, blob.size, cf.chunks))
429    } else {
430        // A dry run writes nothing, so one streaming pass suffices for both the
431        // whole-file hash and the chunk hints.
432        let file = File::open(path).map_err(|e| anyhow!("open {}: {e}", path.display()))?;
433        let cf = chunk_file(file, sizes, |_chash, _data| Ok(()))?;
434        Ok((cf.whole_hash.to_hex().to_string(), cf.total_size, cf.chunks))
435    }
436}
437
438/// Import a single file with `--optimize`: store its whole-file blob (exactly
439/// like a plain import) plus a manifest chunk-hint list, and emit a one-entry
440/// manifest keyed by the file's own name.
441fn import_single_optimized(ctx: &ImportCtx, input: &Path) -> anyhow::Result<ImportReport> {
442    let sizes = ctx
443        .sizes
444        .as_ref()
445        .ok_or_else(|| anyhow!("internal: optimize without chunk sizes"))?;
446    let (hash_hex, total_size, chunks) = import_optimized_file(ctx, input, sizes)?;
447
448    // Key the one entry by the file's own name, validated to the same POSIX
449    // path rules a directory entry obeys.
450    let name = input
451        .file_name()
452        .ok_or_else(|| anyhow!("--input {} has no file name", input.display()))?;
453    let path = validate_relpath(Path::new(name))?;
454    let chunks_total =
455        u64::try_from(chunks.len()).map_err(|_| anyhow!("chunk count exceeds u64"))?;
456    let entry = BundleEntry {
457        path,
458        hash: b3_hex_str_from_hex(&hash_hex),
459        size: total_size,
460        chunks: Some(chunks),
461    };
462    emit_manifest(ctx, vec![entry], total_size, true, chunks_total, 0)
463}
464
465/// Import a single file without optimization: one whole-file blob, no manifest.
466/// Honors `--dry-run` (hash only, no write); the report keys the file by its own
467/// name and carries the one blob's content hash.
468fn import_single_plain(ctx: &ImportCtx, input: &Path) -> anyhow::Result<ImportReport> {
469    let (hash_hex, size) = if ctx.write {
470        let base = ctx
471            .base
472            .as_deref()
473            .ok_or_else(|| anyhow!("internal: write without a base"))?;
474        let blob = import_one_file(base, input, ctx.move_source, ctx.force)?;
475        (blob.hash_hex, blob.size)
476    } else {
477        let (h, s) = hash_file_at(input).map_err(|e| anyhow!("hash {}: {e}", input.display()))?;
478        (h.to_hex().to_string(), s)
479    };
480    // Key the single entry by the file's own name — never the absolute or
481    // working-directory-relative path the operator typed.
482    let name = input.file_name().map_or_else(
483        || input.display().to_string(),
484        |n| n.to_string_lossy().into_owned(),
485    );
486    let files = BTreeMap::from([(
487        place_under(ctx.subfolder.as_deref(), &name),
488        b3_hex_str_from_hex(&hash_hex),
489    )]);
490    Ok(ImportReport {
491        imported: 1,
492        bytes: size,
493        origin: ctx.origin_label.clone(),
494        files,
495        bundle_hash: None,
496        // A dry run writes nothing, so it never actually moved the source —
497        // even though `--move` was passed, don't claim it happened.
498        moved: ctx.move_source && ctx.write,
499        optimized: false,
500        chunks_total: 0,
501        skipped_symlinks: 0,
502    })
503}
504
505/// Shared manifest tail for every path that emits a bundle (a directory import
506/// and a single-file `--optimize`). Serializes the canonical manifest, then —
507/// gated on the context — imports the manifest blob (`write`), writes the
508/// `--bundle` file, and prints the exact canonical bytes to stdout (`--dry-run`,
509/// no trailing newline, so stdout equals the `--bundle` file equals the bundle
510/// hash preimage). Returns the assembled report; its `bundle_hash` is the
511/// manifest's own content address whether or not the blob was written.
512fn emit_manifest(
513    ctx: &ImportCtx,
514    mut entries: Vec<BundleEntry>,
515    total_size: u64,
516    optimized: bool,
517    chunks_total: u64,
518    skipped_symlinks: u64,
519) -> anyhow::Result<ImportReport> {
520    // Prefix every entry's path with `--subfolder` before serializing, so the
521    // bundle hash and every downstream pull place the tree under that folder.
522    // The prefix is uniform, so it preserves the by-path-bytes order the walk
523    // already imposed — no re-sort needed.
524    if let Some(sub) = ctx.subfolder.as_deref() {
525        for entry in &mut entries {
526            entry.path = place_under(Some(sub), &entry.path);
527        }
528    }
529    let bundle_bytes = serialize_canonical(&entries)?;
530
531    if ctx.write
532        && let Some(base) = ctx.base.as_deref()
533    {
534        // Import the manifest blob itself so the whole tree is retrievable by
535        // one hash. The manifest is in-memory bytes, always copied.
536        import_bytes(base, &bundle_bytes, ctx.force)?;
537    }
538    if let Some(out) = ctx.bundle_out.as_deref() {
539        write_bundle(out, &bundle_bytes)
540            .map_err(|e| anyhow!("write --bundle {}: {e}", out.display()))?;
541    }
542    if ctx.dry_run {
543        // The canonical bytes are the sole stdout payload of a dry run.
544        let mut stdout = std::io::stdout().lock();
545        stdout
546            .write_all(&bundle_bytes)
547            .map_err(|e| anyhow!("write manifest to stdout: {e}"))?;
548    }
549
550    let bundle_hash = Some(b3_hex_str(blake3::hash(&bundle_bytes)));
551    let imported = u64::try_from(entries.len())
552        .map_err(|_| anyhow!("entry count {} exceeds u64", entries.len()))?;
553    // Each entry's `path` is the validated POSIX path and its `hash` the
554    // `b3:<hex>` address — exactly the file→hash map the report surfaces.
555    let files = entries.into_iter().map(|e| (e.path, e.hash)).collect();
556    Ok(ImportReport {
557        imported,
558        bytes: total_size,
559        origin: ctx.origin_label.clone(),
560        files,
561        bundle_hash,
562        // A dry run writes nothing, so it never actually moved a source;
563        // gating on `ctx.write` keeps this line self-evidently truthful.
564        moved: ctx.move_source && ctx.write,
565        optimized,
566        chunks_total,
567        skipped_symlinks,
568    })
569}
570
571/// A blob written to the origin store: its content address and byte length.
572struct ImportedBlob {
573    hash_hex: String,
574    size: u64,
575}
576
577/// Prefix a bare 64-hex address with the manifest's `b3:` scheme.
578fn b3_hex_str_from_hex(hex: &str) -> String {
579    format!("b3:{hex}")
580}
581
582/// Import a single source file into the filesystem origin `base`. Streams the
583/// file once to compute its hash + outboard; copies (default) or moves
584/// (`--move`) the data object into `{base}/{hex[0..2]}/{hex}` and writes the
585/// `{hex}.obao4` sibling, both atomically. Idempotent: a data object already
586/// present at its content address is left as-is (a matching hash is matching
587/// bytes); a size mismatch is refused unless `--force`.
588fn import_one_file(
589    base: &Path,
590    source: &Path,
591    move_source: bool,
592    force: bool,
593) -> anyhow::Result<ImportedBlob> {
594    let file = File::open(source).map_err(|e| anyhow!("open {}: {e}", source.display()))?;
595    let size = file
596        .metadata()
597        .map_err(|e| anyhow!("stat {}: {e}", source.display()))?
598        .len();
599
600    // Prep (hash + outboard) is backend-independent. In copy mode we tee the
601    // read into a staged temp data object so the single read pass produces both
602    // the outboard and the on-disk copy; in move mode we rename the source into
603    // place afterward, so no copy is staged.
604    let (eo, staged): (EncodedOutboard, Option<tempfile::NamedTempFile>) = if move_source {
605        let eo = encode_outboard(file, size)
606            .map_err(|e| anyhow!("encode outboard for {}: {e}", source.display()))?;
607        (eo, None)
608    } else {
609        let tmp = tempfile::NamedTempFile::new_in(base)
610            .map_err(|e| anyhow!("stage temp data object in {}: {e}", base.display()))?;
611        let eo = {
612            let sink = tmp.as_file();
613            let tee = TeeReader::new(file, sink);
614            encode_outboard(tee, size)
615                .map_err(|e| anyhow!("encode outboard for {}: {e}", source.display()))?
616        };
617        tmp.as_file()
618            .sync_all()
619            .map_err(|e| anyhow!("sync staged data object: {e}"))?;
620        (eo, Some(tmp))
621    };
622
623    let (shard_dir, data_path, obao4_path) = object_paths(base, &eo);
624    std::fs::create_dir_all(&shard_dir)
625        .map_err(|e| anyhow!("create shard dir {}: {e}", shard_dir.display()))?;
626
627    if data_slot_needs_write(&data_path, size, force, &eo.hash_hex)? {
628        if move_source {
629            move_into_place(source, base, &data_path)?;
630        } else if let Some(tmp) = staged {
631            tmp.persist(&data_path)
632                .map_err(|e| anyhow!("persist data object {}: {}", data_path.display(), e.error))?;
633        }
634    } else if move_source {
635        // The object is already present (its size matched) and `--move` is about
636        // to delete the only other copy. A length match does NOT prove the store
637        // holds the right bytes — a same-size corrupt/foreign object at this hash
638        // would let us silently discard the source. Re-hash the present object
639        // and refuse to delete the source unless it verifies.
640        let (existing_hash, _) = hash_file_at(&data_path)
641            .map_err(|e| anyhow!("verify existing object {}: {e}", data_path.display()))?;
642        if existing_hash.to_hex().as_str() != eo.hash_hex.as_str() {
643            bail!(
644                "origin object {} already exists at {} but its bytes do not match \
645                 their hash; refusing to delete the moved source (pass --force to overwrite)",
646                eo.hash_hex,
647                data_path.display()
648            );
649        }
650        std::fs::remove_file(source)
651            .map_err(|e| anyhow!("remove moved source {}: {e}", source.display()))?;
652    }
653
654    ensure_obao4(&shard_dir, &obao4_path, &eo.outboard)?;
655
656    Ok(ImportedBlob {
657        hash_hex: eo.hash_hex,
658        size,
659    })
660}
661
662/// Import an in-memory blob (the bundle manifest) into the origin `base`. Always
663/// a copy — there is no source file to move.
664fn import_bytes(base: &Path, bytes: &[u8], force: bool) -> anyhow::Result<ImportedBlob> {
665    let size = u64::try_from(bytes.len()).map_err(|_| anyhow!("blob length exceeds u64"))?;
666    let eo = encode_outboard(bytes, size).map_err(|e| anyhow!("encode outboard for blob: {e}"))?;
667
668    let (shard_dir, data_path, obao4_path) = object_paths(base, &eo);
669    std::fs::create_dir_all(&shard_dir)
670        .map_err(|e| anyhow!("create shard dir {}: {e}", shard_dir.display()))?;
671
672    if data_slot_needs_write(&data_path, size, force, &eo.hash_hex)? {
673        let mut tmp = tempfile::NamedTempFile::new_in(&shard_dir)
674            .map_err(|e| anyhow!("stage temp data object in {}: {e}", shard_dir.display()))?;
675        tmp.write_all(bytes)
676            .map_err(|e| anyhow!("write data object: {e}"))?;
677        tmp.as_file()
678            .sync_all()
679            .map_err(|e| anyhow!("sync data object: {e}"))?;
680        tmp.persist(&data_path)
681            .map_err(|e| anyhow!("persist data object {}: {}", data_path.display(), e.error))?;
682    }
683
684    ensure_obao4(&shard_dir, &obao4_path, &eo.outboard)?;
685    Ok(ImportedBlob {
686        hash_hex: eo.hash_hex,
687        size,
688    })
689}
690
691/// Build the `(shard_dir, data_path, obao4_path)` triple for a blob under `base`.
692fn object_paths(base: &Path, eo: &EncodedOutboard) -> (PathBuf, PathBuf, PathBuf) {
693    let shard_dir = base.join(eo.shard());
694    let data_path = shard_dir.join(&eo.hash_hex);
695    let obao4_path = shard_dir.join(eo.obao4_name());
696    (shard_dir, data_path, obao4_path)
697}
698
699/// Decide whether the data object at `data_path` must be written. Returns
700/// `false` (a no-op) when a correct object is already present; `true` when it is
701/// absent or `--force` was passed. A present object whose size differs from the
702/// incoming blob is refused unless `--force` — a content-addressed path should
703/// never hold different bytes, so a size mismatch signals a corrupt/foreign
704/// store the operator must resolve deliberately.
705fn data_slot_needs_write(
706    data_path: &Path,
707    size: u64,
708    force: bool,
709    hex: &str,
710) -> anyhow::Result<bool> {
711    match std::fs::metadata(data_path) {
712        Ok(_) if force => Ok(true),
713        Ok(meta) => {
714            if meta.len() != size {
715                bail!(
716                    "origin object {hex} already exists at {} with a different size \
717                     ({} vs {size}); pass --force to overwrite",
718                    data_path.display(),
719                    meta.len()
720                );
721            }
722            Ok(false)
723        }
724        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
725        Err(e) => Err(anyhow!("stat {}: {e}", data_path.display())),
726    }
727}
728
729/// Ensure the `{hex}.obao4` outboard sibling holds exactly `outboard`. Writes it
730/// when missing, and replaces it when a present sibling's bytes differ — a stale
731/// or foreign outboard from a partial prior import must not be left in place,
732/// because the node rejects a mismatching `.obao4` and drops to whole-blob range
733/// serving. A byte-identical present sibling is left untouched, so a clean
734/// re-import stays a no-op. The outboard is small (~1/256 of the blob), so the
735/// read-and-compare is cheap.
736fn ensure_obao4(shard_dir: &Path, obao4_path: &Path, outboard: &[u8]) -> anyhow::Result<()> {
737    match std::fs::read(obao4_path) {
738        // Present and already correct — nothing to do.
739        Ok(existing) if existing == outboard => return Ok(()),
740        // Present but stale/foreign — fall through and replace atomically.
741        Ok(_) => {}
742        // Missing — fall through and write.
743        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
744        Err(e) => return Err(anyhow!("read outboard {}: {e}", obao4_path.display())),
745    }
746    let mut tmp = tempfile::NamedTempFile::new_in(shard_dir)
747        .map_err(|e| anyhow!("stage temp outboard in {}: {e}", shard_dir.display()))?;
748    tmp.write_all(outboard)
749        .map_err(|e| anyhow!("write outboard: {e}"))?;
750    tmp.as_file()
751        .sync_all()
752        .map_err(|e| anyhow!("sync outboard: {e}"))?;
753    tmp.persist(obao4_path)
754        .map_err(|e| anyhow!("persist outboard {}: {}", obao4_path.display(), e.error))?;
755    Ok(())
756}
757
758/// Move `source` to `data_path`, falling back to copy-then-unlink when the two
759/// live on different filesystems (`rename(2)` returns `EXDEV`). The copy lands
760/// in a staged temp under `base` first so a partial copy never appears at the
761/// content-addressed path.
762fn move_into_place(source: &Path, base: &Path, data_path: &Path) -> anyhow::Result<()> {
763    match std::fs::rename(source, data_path) {
764        Ok(()) => Ok(()),
765        Err(e) if is_cross_device(&e) => {
766            let tmp = tempfile::NamedTempFile::new_in(base)
767                .map_err(|e| anyhow!("stage cross-device copy in {}: {e}", base.display()))?;
768            std::fs::copy(source, tmp.path())
769                .map_err(|e| anyhow!("copy {} across filesystems: {e}", source.display()))?;
770            tmp.as_file()
771                .sync_all()
772                .map_err(|e| anyhow!("sync cross-device copy: {e}"))?;
773            tmp.persist(data_path)
774                .map_err(|e| anyhow!("persist data object {}: {}", data_path.display(), e.error))?;
775            std::fs::remove_file(source)
776                .map_err(|e| anyhow!("remove moved source {}: {e}", source.display()))?;
777            Ok(())
778        }
779        Err(e) => Err(anyhow!(
780            "move {} into {}: {e}",
781            source.display(),
782            data_path.display()
783        )),
784    }
785}
786
787/// Whether an `io::Error` is a cross-device link (`EXDEV`, or
788/// `ERROR_NOT_SAME_DEVICE` on Windows), the one `rename` failure that a
789/// copy-then-unlink can recover.
790fn is_cross_device(err: &std::io::Error) -> bool {
791    err.kind() == std::io::ErrorKind::CrossesDevices
792}
793
794/// A `Read` wrapper that copies every byte it yields into `sink` — used to
795/// compute the outboard and write the staged data object in one read pass.
796struct TeeReader<R, W> {
797    inner: R,
798    sink: W,
799}
800
801impl<R: Read, W: Write> TeeReader<R, W> {
802    const fn new(inner: R, sink: W) -> Self {
803        Self { inner, sink }
804    }
805}
806
807impl<R: Read, W: Write> Read for TeeReader<R, W> {
808    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
809        let n = self.inner.read(buf)?;
810        if let Some(chunk) = buf.get(..n) {
811            self.sink.write_all(chunk)?;
812        }
813        Ok(n)
814    }
815}
816
817/// The sole blob's content hash when the report describes a single-file import
818/// (no manifest, exactly one entry), else `None` — used for the concise human
819/// line, where a directory's full file map would be noise.
820fn single_file_hash(report: &ImportReport) -> Option<&str> {
821    if report.bundle_hash.is_some() {
822        return None;
823    }
824    let mut it = report.files.values();
825    match (it.next(), it.next()) {
826        (Some(h), None) => Some(h.as_str()),
827        _ => None,
828    }
829}
830
831fn write_import_report(
832    w: &mut impl Write,
833    report: &ImportReport,
834    json: bool,
835) -> std::io::Result<()> {
836    if json {
837        let line = serde_json::to_string(report)
838            .map_err(|e| std::io::Error::other(format!("serialize status report: {e}")))?;
839        writeln!(w, "{line}")
840    } else {
841        let verb = if report.moved { "moved" } else { "imported" };
842        write!(
843            w,
844            "{verb} {} file(s), {} bytes, into {}",
845            report.imported, report.bytes, report.origin
846        )?;
847        if report.optimized {
848            write!(w, ", {} chunk hint(s)", report.chunks_total)?;
849        }
850        // A directory import is addressed by its bundle hash; a single-file
851        // import has no manifest, so surface the one blob's content hash. The
852        // full file→hash map is reserved for `--json`.
853        match (&report.bundle_hash, single_file_hash(report)) {
854            (Some(b), _) => writeln!(w, " (bundle {b})")?,
855            (None, Some(h)) => writeln!(w, " ({h})")?,
856            (None, None) => writeln!(w)?,
857        }
858        if report.skipped_symlinks > 0 {
859            writeln!(
860                w,
861                "skipped {} symlink(s); pass --follow-symlinks to include",
862                report.skipped_symlinks
863            )?;
864        }
865        Ok(())
866    }
867}
868
869#[cfg(test)]
870#[allow(
871    clippy::unwrap_used,
872    clippy::expect_used,
873    clippy::indexing_slicing,
874    clippy::panic
875)]
876mod tests;