1use 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
36pub 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#[derive(Debug)]
48enum ImportTarget {
49 Fs(PathBuf),
51}
52
53fn 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#[derive(Serialize)]
95struct ImportReport {
96 imported: u64,
98 bytes: u64,
100 origin: String,
102 files: BTreeMap<String, String>,
107 bundle_hash: Option<String>,
110 moved: bool,
115 optimized: bool,
119 chunks_total: u64,
123 skipped_symlinks: u64,
126}
127
128#[expect(
134 clippy::struct_excessive_bools,
135 reason = "independent per-invocation toggles resolved from CLI flags, not modelled state"
136)]
137struct ImportCtx {
138 base: Option<PathBuf>,
140 write: bool,
142 dry_run: bool,
145 move_source: bool,
147 force: bool,
149 bundle_out: Option<PathBuf>,
151 origin_label: String,
154 optimize: bool,
157 sizes: Option<ChunkSizes>,
159 subfolder: Option<String>,
163}
164
165fn 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
191fn 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
202pub async fn origin_import(args: &OriginImportArgs) -> anyhow::Result<()> {
206 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 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 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 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 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 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
319fn 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
385fn 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 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
438fn 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 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
465fn 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 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 moved: ctx.move_source && ctx.write,
499 optimized: false,
500 chunks_total: 0,
501 skipped_symlinks: 0,
502 })
503}
504
505fn 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 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_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 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 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 moved: ctx.move_source && ctx.write,
565 optimized,
566 chunks_total,
567 skipped_symlinks,
568 })
569}
570
571struct ImportedBlob {
573 hash_hex: String,
574 size: u64,
575}
576
577fn b3_hex_str_from_hex(hex: &str) -> String {
579 format!("b3:{hex}")
580}
581
582fn 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 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 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
662fn 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
691fn 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
699fn 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
729fn ensure_obao4(shard_dir: &Path, obao4_path: &Path, outboard: &[u8]) -> anyhow::Result<()> {
737 match std::fs::read(obao4_path) {
738 Ok(existing) if existing == outboard => return Ok(()),
740 Ok(_) => {}
742 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
758fn 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
787fn is_cross_device(err: &std::io::Error) -> bool {
791 err.kind() == std::io::ErrorKind::CrossesDevices
792}
793
794struct 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
817fn 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 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;