decdn-cli 0.1.0

User-facing deCDN CLI (decdn binary): probe, fetch, pools, publishing, origin import, node admin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
//! Integration tests for `decdn origin import` (issue #1904).
//!
//! The strongest check runs the real cache reader against what the command
//! wrote: a `decdn-cache` `FilesystemOrigin` must fetch the blob and, using the
//! sibling `{hex}.obao4`, serve a verified byte range. That proves the outboard
//! is the daemon-compatible `IROH_BLOCK_SIZE` encoding — the whole point of the
//! command — not just that a file with the right name exists.

#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::indexing_slicing,
    clippy::panic
)]

use std::fs;
use std::path::{Path, PathBuf};

use decdn_cache::range_pull::{align_range, encode_verified_range};
use decdn_cache::{
    CHUNK_GROUP_BYTES, FilesystemOrigin, Hash, Origin, OriginFetch, OriginRangeFetch,
    OriginRangeRequest, OutboardFetch,
};
use decdn_cli::commands::origin::origin_import;
use decdn_common::cli::OriginImportArgs;
use tempfile::TempDir;

fn rt() -> tokio::runtime::Runtime {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
}

fn import_args(input: &Path, origin: &Path) -> OriginImportArgs {
    OriginImportArgs {
        input: input.to_path_buf(),
        to: Some(origin.to_path_buf()),
        move_source: false,
        bundle: None,
        force: false,
        follow_symlinks: false,
        exclude: Vec::new(),
        json: false,
        optimize: false,
        chunk_avg: None,
        chunk_min: None,
        chunk_max: None,
        subfolder: None,
        dry_run: false,
    }
}

// A payload spanning several 16 KiB chunk groups so a non-trivial aligned range
// can be served from the written outboard.
fn multi_group_payload() -> Vec<u8> {
    (0..(5 * CHUNK_GROUP_BYTES + 123))
        .map(|i| (i % 251) as u8)
        .collect()
}

fn data_object_path(origin: &Path, hex: &str) -> PathBuf {
    origin.join(&hex[..2]).join(hex)
}

fn hash_of(payload: &[u8]) -> (Hash, String) {
    let h = blake3::hash(payload);
    (Hash::from_bytes(*h.as_bytes()), h.to_hex().to_string())
}

#[test]
fn single_file_import_is_readable_and_range_serves() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    let payload = multi_group_payload();
    let file = src.path().join("blob.bin");
    fs::write(&file, &payload).unwrap();

    rt().block_on(origin_import(&import_args(&file, origin.path())))
        .unwrap();

    let (hash, hex) = hash_of(&payload);
    let data = data_object_path(origin.path(), &hex);
    let obao4 = origin.path().join(&hex[..2]).join(format!("{hex}.obao4"));
    assert!(data.is_file(), "data object missing at {}", data.display());
    assert!(obao4.is_file(), "outboard missing at {}", obao4.display());
    // The data object is the source bytes verbatim.
    assert_eq!(fs::read(&data).unwrap(), payload);

    // Now read it back through the real cache reader.
    rt().block_on(async {
        let reader = FilesystemOrigin::new(origin.path()).await.unwrap();

        // Whole-blob fetch round-trips the exact bytes.
        let fetched = reader.fetch(hash, 1 << 30).await.unwrap();
        let bytes = match fetched {
            OriginFetch::Found { .. } => fetched.collect_to_bytes().await.unwrap().unwrap(),
            other => panic!("expected Found, got {other:?}"),
        };
        assert_eq!(bytes.as_ref(), payload.as_slice());

        // A chunk-group-aligned range verifies against the written outboard —
        // this fails if the `.obao4` is a foreign / wrong-block-size encoding.
        let req = OriginRangeRequest {
            fetch_start: CHUNK_GROUP_BYTES,
            fetch_end: 3 * CHUNK_GROUP_BYTES,
        };
        let start = usize::try_from(CHUNK_GROUP_BYTES).unwrap();
        let end = usize::try_from(3 * CHUNK_GROUP_BYTES).unwrap();
        let outboard = match reader.fetch_outboard(hash, 1 << 30).await.unwrap() {
            OutboardFetch::Found(outboard) => outboard,
            other => panic!("expected the outboard, got {other:?}"),
        };
        match reader.fetch_range_data(hash, req).await.unwrap() {
            OriginRangeFetch::Ranged { data } => {
                assert_eq!(data.as_ref(), &payload[start..end]);
                let total = u64::try_from(payload.len()).unwrap();
                let aligned = align_range(CHUNK_GROUP_BYTES, 2 * CHUNK_GROUP_BYTES, total).unwrap();
                encode_verified_range(*hash.as_bytes(), &aligned, &data, outboard)
                    .expect("the range must verify against the written outboard");
            }
            other => panic!("expected Ranged, got {other:?}"),
        }
        match reader.fetch_outboard(hash, 1 << 30).await.unwrap() {
            OutboardFetch::Found(outboard) => {
                assert!(!outboard.is_empty(), "outboard must be served for a range");
            }
            other => panic!("expected Found, got {other:?}"),
        }
    });
}

#[test]
fn reimport_is_idempotent_noop() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    let payload = vec![9u8; 40 * 1024];
    let file = src.path().join("blob.bin");
    fs::write(&file, &payload).unwrap();

    let args = import_args(&file, origin.path());
    rt().block_on(origin_import(&args)).unwrap();
    let (_, hex) = hash_of(&payload);
    let data = data_object_path(origin.path(), &hex);
    let obao4 = origin.path().join(&hex[..2]).join(format!("{hex}.obao4"));
    let data_bytes = fs::read(&data).unwrap();
    let obao4_bytes = fs::read(&obao4).unwrap();

    // Second import of identical content is a clean no-op — same bytes, no
    // stray temp files left in the shard dir.
    rt().block_on(origin_import(&args)).unwrap();
    assert_eq!(fs::read(&data).unwrap(), data_bytes);
    assert_eq!(fs::read(&obao4).unwrap(), obao4_bytes);
    let shard_entries: Vec<_> = fs::read_dir(origin.path().join(&hex[..2]))
        .unwrap()
        .map(|e| e.unwrap().file_name())
        .collect();
    assert_eq!(shard_entries.len(), 2, "expected only data + .obao4");
}

#[test]
fn move_consumes_the_source_file() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    let payload = vec![1u8; 20 * 1024];
    let file = src.path().join("blob.bin");
    fs::write(&file, &payload).unwrap();

    let mut args = import_args(&file, origin.path());
    args.move_source = true;
    rt().block_on(origin_import(&args)).unwrap();

    assert!(!file.exists(), "source should have been moved");
    let (_, hex) = hash_of(&payload);
    assert!(data_object_path(origin.path(), &hex).is_file());
}

// Data-safety: with --move, a same-size but corrupt/foreign object already at
// the target hash must NOT cause the source (the only correct copy) to be
// deleted. The command re-verifies the present object's content and refuses.
#[test]
fn move_refuses_to_delete_source_when_present_object_is_corrupt() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    let payload = vec![1u8; 20 * 1024];
    let file = src.path().join("blob.bin");
    fs::write(&file, &payload).unwrap();

    // Plant a same-length but wrong-byte object at the content-addressed path.
    let (_, hex) = hash_of(&payload);
    let shard = origin.path().join(&hex[..2]);
    fs::create_dir_all(&shard).unwrap();
    let mut corrupt = payload.clone();
    corrupt[0] ^= 0xFF;
    assert_eq!(corrupt.len(), payload.len());
    fs::write(shard.join(&hex), &corrupt).unwrap();

    let mut args = import_args(&file, origin.path());
    args.move_source = true;
    let err = rt().block_on(origin_import(&args)).unwrap_err();
    assert!(
        format!("{err:#}").contains("do not match"),
        "err was: {err:#}"
    );
    // The source is preserved and the corrupt object is untouched.
    assert!(file.exists(), "source must not be deleted on a mismatch");
    assert_eq!(fs::read(shard.join(&hex)).unwrap(), corrupt);
}

// With --move, a source whose content is already present AND verifies is safely
// consumed (the store holds the correct bytes).
#[test]
fn move_consumes_source_when_present_object_verifies() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    let payload = vec![2u8; 24 * 1024];

    // Seed the origin by copy-importing one copy.
    let first = src.path().join("first.bin");
    fs::write(&first, &payload).unwrap();
    rt().block_on(origin_import(&import_args(&first, origin.path())))
        .unwrap();

    // A second identical file, move-imported: the object is already present and
    // verifies, so the redundant source is removed.
    let second = src.path().join("second.bin");
    fs::write(&second, &payload).unwrap();
    let mut args = import_args(&second, origin.path());
    args.move_source = true;
    rt().block_on(origin_import(&args)).unwrap();
    assert!(
        !second.exists(),
        "verified-present source should be consumed"
    );
}

// A stale/foreign `.obao4` sibling left by a partial prior import is replaced on
// re-import, not trusted — otherwise the node would reject range serving.
#[test]
fn stale_obao4_is_replaced() {
    let origin = TempDir::new().unwrap();
    let payload = vec![5u8; 48 * 1024];
    let (_, hex) = hash_of(&payload);
    let shard = origin.path().join(&hex[..2]);
    fs::create_dir_all(&shard).unwrap();
    // Correct data object, but a garbage sibling outboard.
    fs::write(shard.join(&hex), &payload).unwrap();
    fs::write(shard.join(format!("{hex}.obao4")), b"stale-garbage").unwrap();

    let src = TempDir::new().unwrap();
    let file = src.path().join("blob.bin");
    fs::write(&file, &payload).unwrap();
    rt().block_on(origin_import(&import_args(&file, origin.path())))
        .unwrap();

    let size = u64::try_from(payload.len()).unwrap();
    let expected = decdn_bao_range::encode_outboard(payload.as_slice(), size)
        .unwrap()
        .outboard;
    assert_eq!(
        fs::read(shard.join(format!("{hex}.obao4"))).unwrap(),
        expected,
        "stale outboard must be replaced with the correct encoding"
    );
}

#[test]
fn directory_import_matches_dry_run_and_imports_manifest() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    // Manifest outputs live OUTSIDE the walked tree so neither walk sees them.
    let out = TempDir::new().unwrap();

    // A small tree with nested files.
    fs::create_dir_all(src.path().join("sub")).unwrap();
    fs::write(src.path().join("a.txt"), b"alpha").unwrap();
    fs::write(src.path().join("sub/b.bin"), vec![7u8; 33 * 1024]).unwrap();

    // Reference manifest bytes: what `origin import --dry-run` prints on the
    // same fileset, via the real binary (a subprocess is the only way to
    // observe the dry-run stdout contract).
    let dry_run_out = Command::new(env!("CARGO_BIN_EXE_decdn"))
        .args([
            "origin",
            "import",
            "-i",
            src.path().to_str().unwrap(),
            "--dry-run",
        ])
        .output()
        .expect("run decdn binary");
    assert!(
        dry_run_out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&dry_run_out.stderr)
    );
    let ref_bytes = dry_run_out.stdout;

    // Import the directory, asking for the manifest to be written out too.
    let out_bundle = out.path().join("out.json");
    let mut args = import_args(src.path(), origin.path());
    args.bundle = Some(out_bundle.clone());
    rt().block_on(origin_import(&args)).unwrap();

    // The `--bundle` output is byte-identical to the `--dry-run` stdout — that
    // is what makes the reported bundle hash the one publishers distribute.
    let out_bytes = fs::read(&out_bundle).unwrap();
    assert_eq!(
        out_bytes, ref_bytes,
        "manifest must match origin import --dry-run bytes"
    );

    // The manifest blob itself must be present in the origin under its own hash,
    // so the whole tree is retrievable by that one hash.
    let manifest_hash = blake3::hash(&out_bytes).to_hex().to_string();
    assert!(
        data_object_path(origin.path(), &manifest_hash).is_file(),
        "manifest blob must be imported into the origin"
    );

    // Every referenced file blob is present and readable through the reader.
    let out_json: serde_json::Value = serde_json::from_slice(&out_bytes).unwrap();
    let entries = out_json["entries"].as_array().unwrap();
    rt().block_on(async {
        let reader = FilesystemOrigin::new(origin.path()).await.unwrap();
        for e in entries {
            let hex = e["hash"].as_str().unwrap().strip_prefix("b3:").unwrap();
            let (hash, _) = hash_from_hex(hex);
            match reader.fetch(hash, 1 << 30).await.unwrap() {
                OriginFetch::Found { .. } => {}
                other => panic!("blob {hex} not readable: {other:?}"),
            }
        }
    });
}

// A directory import with `--subfolder` writes a manifest whose every entry
// path is prefixed with that folder, and imports the manifest blob under the
// hash it reports — so a pull materializes the whole tree under one directory.
#[test]
fn directory_import_subfolder_prefixes_manifest_entries() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    let out = TempDir::new().unwrap();

    fs::create_dir_all(src.path().join("sub")).unwrap();
    fs::write(src.path().join("a.txt"), b"alpha").unwrap();
    fs::write(src.path().join("sub/b.txt"), b"beta").unwrap();

    let out_bundle = out.path().join("out.json");
    let mut args = import_args(src.path(), origin.path());
    args.bundle = Some(out_bundle.clone());
    args.subfolder = Some("release/v2".to_string());
    rt().block_on(origin_import(&args)).unwrap();

    // The written manifest carries prefixed, sorted paths.
    let out_bytes = fs::read(&out_bundle).unwrap();
    let out_json: serde_json::Value = serde_json::from_slice(&out_bytes).unwrap();
    let paths: Vec<&str> = out_json["entries"]
        .as_array()
        .unwrap()
        .iter()
        .map(|e| e["path"].as_str().unwrap())
        .collect();
    assert_eq!(paths, vec!["release/v2/a.txt", "release/v2/sub/b.txt"]);

    // The manifest blob is imported under its own hash (of the prefixed bytes).
    let manifest_hash = blake3::hash(&out_bytes).to_hex().to_string();
    assert!(
        data_object_path(origin.path(), &manifest_hash).is_file(),
        "prefixed manifest blob must be imported into the origin"
    );
}

fn hash_from_hex(hex: &str) -> (Hash, String) {
    let mut raw = [0u8; 32];
    for (i, slot) in raw.iter_mut().enumerate() {
        *slot = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).unwrap();
    }
    (Hash::from_bytes(raw), hex.to_string())
}

// --- `--optimize` / `--dry-run` wiring ---------------------------------------
//
// These drive the real `decdn` binary so the actual process stdout/stderr and
// exit code are observed — the dry-run contract is "the canonical manifest
// bytes, and nothing else, on stdout", which only a subprocess can prove.

use std::process::{Command, Output};

fn decdn(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_decdn"))
        .args(args)
        .output()
        .expect("run decdn binary")
}

// Deterministic pseudo-random bytes so content-defined chunk boundaries form.
fn pseudo(seed: u64, len: usize) -> Vec<u8> {
    let mut out = Vec::with_capacity(len);
    let mut x = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
    while out.len() < len {
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        out.extend_from_slice(&x.to_le_bytes());
    }
    out.truncate(len);
    out
}

// The exact canonical manifest bytes a whole-file (non-optimized) import of
// `tree` produces, computed independently of the command under test.
fn expected_whole_file_manifest(tree: &Path) -> Vec<u8> {
    // Two files placed by `two_file_tree`, sorted by path bytes.
    let mut entries: Vec<(String, String, u64)> = ["a.txt", "b.txt"]
        .into_iter()
        .map(|name| {
            let bytes = fs::read(tree.join(name)).unwrap();
            let h = blake3::hash(&bytes);
            (
                name.to_string(),
                format!("b3:{}", h.to_hex()),
                bytes.len() as u64,
            )
        })
        .collect();
    entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
    let frags: Vec<String> = entries
        .iter()
        .map(|(p, h, sz)| format!("{{\"path\":\"{p}\",\"hash\":\"{h}\",\"size\":{sz}}}"))
        .collect();
    format!("{{\"version\":1,\"entries\":[{}]}}", frags.join(",")).into_bytes()
}

fn two_file_tree() -> TempDir {
    let tree = TempDir::new().unwrap();
    fs::write(tree.path().join("a.txt"), b"alpha contents").unwrap();
    fs::write(tree.path().join("b.txt"), b"bravo contents here").unwrap();
    tree
}

#[test]
fn dry_run_prints_canonical_manifest_to_stdout_no_blobs() {
    let tree = two_file_tree();
    let origin = TempDir::new().unwrap();
    // A --to path that does NOT exist yet: dry-run must never create it.
    let ghost = origin.path().join("never-created");

    let out = decdn(&[
        "origin",
        "import",
        "-i",
        tree.path().to_str().unwrap(),
        "--dry-run",
    ]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert_eq!(out.stdout, expected_whole_file_manifest(tree.path()));
    assert!(!ghost.exists(), "dry-run must not create any origin dir");
}

#[test]
fn dry_run_stdout_matches_bundle_written_file() {
    let tree = two_file_tree();
    let out_dir = TempDir::new().unwrap();
    let bundle = out_dir.path().join("m.json");

    let out = decdn(&[
        "origin",
        "import",
        "-i",
        tree.path().to_str().unwrap(),
        "--dry-run",
        "--bundle",
        bundle.to_str().unwrap(),
    ]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let file_bytes = fs::read(&bundle).unwrap();
    assert_eq!(out.stdout, file_bytes);
    assert_eq!(out.stdout, expected_whole_file_manifest(tree.path()));
}

#[test]
fn to_required_unless_dry_run() {
    let tree = two_file_tree();
    let out = decdn(&["origin", "import", "-i", tree.path().to_str().unwrap()]);
    assert!(!out.status.success());
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("--to"), "stderr was: {err}");
}

#[test]
fn chunk_flags_require_optimize() {
    let tree = two_file_tree();
    let origin = TempDir::new().unwrap();
    let out = decdn(&[
        "origin",
        "import",
        "-i",
        tree.path().to_str().unwrap(),
        "--to",
        &origin.path().display().to_string(),
        "--chunk-avg",
        "2MiB",
    ]);
    assert!(!out.status.success());
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("--optimize"), "stderr was: {err}");
}

// A tree of two files that share a >4 MiB identical middle region so CDC
// produces at least one shared chunk that dedups across the two files.
fn shared_region_tree() -> TempDir {
    let tree = TempDir::new().unwrap();
    let shared = pseudo(3, 8 * 1024 * 1024);
    let mut f1 = pseudo(10, 4 * 1024 * 1024);
    f1.extend_from_slice(&shared);
    f1.extend_from_slice(&pseudo(11, 4 * 1024 * 1024));
    let mut f2 = pseudo(20, 4 * 1024 * 1024);
    f2.extend_from_slice(&shared);
    f2.extend_from_slice(&pseudo(21, 4 * 1024 * 1024));
    fs::write(tree.path().join("f1.bin"), &f1).unwrap();
    fs::write(tree.path().join("f2.bin"), &f2).unwrap();
    tree
}

#[test]
fn optimize_stores_whole_file_blobs_and_chunk_hints() {
    let tree = shared_region_tree();
    let origin = TempDir::new().unwrap();

    let out = decdn(&[
        "origin",
        "import",
        "-i",
        tree.path().to_str().unwrap(),
        "--to",
        &origin.path().display().to_string(),
        "--optimize",
        "--json",
    ]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // Status JSON is on stdout for a non-dry-run import.
    let report: serde_json::Value = serde_json::from_slice(out.stdout.trim_ascii_end()).unwrap();
    assert_eq!(report["optimized"].as_bool(), Some(true));
    let total = report["chunks_total"].as_u64().unwrap();
    assert!(total > 0, "expected at least one chunk hint, got {total}");
    assert!(
        report.get("chunks_written").is_none(),
        "chunks_written must be dropped now that optimize stores no per-chunk blobs"
    );

    // The written manifest blob is retrievable; every entry's WHOLE-FILE hash
    // (+ its .obao4 outboard) is stored, exactly like a plain import — the
    // chunk list is manifest-only dedup hints, never separately stored blobs.
    let bundle_hex = report["bundle_hash"]
        .as_str()
        .unwrap()
        .strip_prefix("b3:")
        .unwrap();
    assert!(
        data_object_path(origin.path(), bundle_hex).is_file(),
        "manifest blob must be stored"
    );
    let manifest_bytes = fs::read(data_object_path(origin.path(), bundle_hex)).unwrap();
    let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes).unwrap();
    let entries = manifest["entries"].as_array().unwrap();
    assert_eq!(entries.len(), 2);

    let mut whole_hashes: std::collections::HashSet<String> = std::collections::HashSet::new();
    let mut all_hint_hashes: Vec<String> = Vec::new();
    for e in entries {
        let whole_hex = e["hash"].as_str().unwrap().strip_prefix("b3:").unwrap();
        whole_hashes.insert(whole_hex.to_string());
        let data = data_object_path(origin.path(), whole_hex);
        let obao4 = origin
            .path()
            .join(&whole_hex[..2])
            .join(format!("{whole_hex}.obao4"));
        assert!(data.is_file(), "whole-file blob {whole_hex} must be stored");
        assert!(
            obao4.is_file(),
            "whole-file outboard missing: {}",
            obao4.display()
        );

        let size = e["size"].as_u64().unwrap();
        let chunks = e["chunks"].as_array().expect("entry must carry hints");
        assert!(!chunks.is_empty());
        let sum: u64 = chunks.iter().map(|c| c["size"].as_u64().unwrap()).sum();
        assert_eq!(sum, size, "chunk hint sizes must sum to the entry size");
        for c in chunks {
            let chex = c["hash"].as_str().unwrap().strip_prefix("b3:").unwrap();
            all_hint_hashes.push(chex.to_string());
        }
    }

    // No stored object exists for a chunk-hash-only hint (one that isn't also
    // some entry's whole-file hash) — optimize stores whole files, not chunks.
    for chex in &all_hint_hashes {
        if whole_hashes.contains(chex) {
            continue;
        }
        assert!(
            !data_object_path(origin.path(), chex).is_file(),
            "chunk hint {chex} must NOT be stored as a data object"
        );
    }

    // The two files share an identical middle region, so their hint lists
    // must share at least one identical chunk hash.
    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
    let mut shared = false;
    for h in &all_hint_hashes {
        if !seen.insert(h.as_str()) {
            shared = true;
        }
    }
    assert!(shared, "expected the two entries to share >=1 chunk hint");
}

// `--dry-run --optimize` must preview the chunk-hint count the same way a real
// run would compute it, even though no blob is written.
#[test]
fn optimize_dry_run_previews_chunk_hints_without_writing() {
    let tree = shared_region_tree();
    let origin = TempDir::new().unwrap();
    // A --to path that must NEVER be created by a dry run.
    let ghost = origin.path().join("never-created");

    let out = decdn(&[
        "origin",
        "import",
        "-i",
        tree.path().to_str().unwrap(),
        "--optimize",
        "--dry-run",
        "--json",
    ]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // Status JSON is on stderr under --dry-run; stdout carries only the
    // canonical manifest.
    let report: serde_json::Value =
        serde_json::from_str(String::from_utf8_lossy(&out.stderr).trim()).unwrap();
    assert_eq!(report["optimized"].as_bool(), Some(true));
    let total = report["chunks_total"].as_u64().unwrap();
    assert!(total > 0, "expected at least one chunk hint, got {total}");
    assert!(
        report.get("chunks_written").is_none(),
        "chunks_written must be dropped"
    );

    // Stdout is still the clean manifest, carrying chunk hints.
    let manifest: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let entries = manifest["entries"].as_array().unwrap();
    assert_eq!(entries.len(), 2);
    for e in entries {
        assert!(e["chunks"].as_array().is_some(), "entry must carry hints");
    }

    // No blobs written, and the never-requested origin dir was never created.
    assert!(!ghost.exists(), "dry-run must not create any origin dir");
}

#[test]
fn optimize_single_file_emits_one_entry_manifest() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    let out_dir = TempDir::new().unwrap();
    let file = src.path().join("model.bin");
    fs::write(&file, pseudo(42, 10 * 1024 * 1024)).unwrap();
    let bundle = out_dir.path().join("m.json");

    let out = decdn(&[
        "origin",
        "import",
        "-i",
        file.to_str().unwrap(),
        "--to",
        &origin.path().display().to_string(),
        "--optimize",
        "--bundle",
        bundle.to_str().unwrap(),
    ]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let manifest: serde_json::Value = serde_json::from_slice(&fs::read(&bundle).unwrap()).unwrap();
    let entries = manifest["entries"].as_array().unwrap();
    assert_eq!(entries.len(), 1, "single file → exactly one entry");
    let e = &entries[0];
    assert_eq!(e["path"].as_str(), Some("model.bin"));
    let whole_hex = e["hash"].as_str().unwrap().strip_prefix("b3:").unwrap();
    assert!(
        data_object_path(origin.path(), whole_hex).is_file(),
        "whole-file blob {whole_hex} must be written"
    );
    let obao4 = origin
        .path()
        .join(&whole_hex[..2])
        .join(format!("{whole_hex}.obao4"));
    assert!(obao4.is_file(), "whole-file outboard must be written");

    let chunks = e["chunks"].as_array().expect("entry must carry hints");
    assert!(!chunks.is_empty());
    let size = e["size"].as_u64().unwrap();
    let sum: u64 = chunks.iter().map(|c| c["size"].as_u64().unwrap()).sum();
    assert_eq!(sum, size, "chunk hint sizes must sum to the entry size");
    for c in chunks {
        let chex = c["hash"].as_str().unwrap().strip_prefix("b3:").unwrap();
        if chex == whole_hex {
            continue;
        }
        assert!(
            !data_object_path(origin.path(), chex).is_file(),
            "chunk hint {chex} must NOT be stored as its own data object"
        );
    }
}

// `--optimize` composes with `--move`: the source is renamed into the store as
// the whole-file blob, and the chunk-hint pass reads that stored object rather
// than the (now gone) source. The source disappears, the whole-file blob lands
// at its content address, and the manifest carries chunk hints.
#[test]
fn optimize_and_move_compose() {
    let src = TempDir::new().unwrap();
    let origin = TempDir::new().unwrap();
    let file = src.path().join("blob.bin");
    fs::write(&file, pseudo(1, 8 * 1024 * 1024)).unwrap();

    let out = decdn(&[
        "origin",
        "import",
        "-i",
        file.to_str().unwrap(),
        "--to",
        &origin.path().display().to_string(),
        "--optimize",
        "--move",
        "--json",
    ]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // The source is gone — it was moved into the store.
    assert!(!file.exists(), "source must be moved away");

    let report: serde_json::Value = serde_json::from_slice(out.stdout.trim_ascii_end()).unwrap();
    assert_eq!(report["moved"], serde_json::json!(true));
    assert_eq!(report["optimized"], serde_json::json!(true));
    assert!(
        report["chunks_total"].as_u64().unwrap() > 0,
        "optimize must produce chunk hints"
    );

    // The whole-file blob is stored at its content address and carries chunk hints.
    let bundle_hex = report["bundle_hash"]
        .as_str()
        .unwrap()
        .strip_prefix("b3:")
        .unwrap();
    let manifest_bytes = fs::read(data_object_path(origin.path(), bundle_hex)).unwrap();
    let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes).unwrap();
    let entry = &manifest["entries"].as_array().unwrap()[0];
    let whole_hex = entry["hash"].as_str().unwrap().strip_prefix("b3:").unwrap();
    let stored = fs::read(data_object_path(origin.path(), whole_hex)).unwrap();
    assert_eq!(blake3::hash(&stored).to_hex().as_str(), whole_hex);
    assert!(
        !entry["chunks"].as_array().unwrap().is_empty(),
        "manifest entry must carry chunk hints"
    );
}

// The stored whole-file blob is byte-identical to the source, and its content
// hash is exactly the entry's `hash` and the BLAKE3 the chunker independently
// computed over the same bytes — the two streaming passes (`import_one_file`
// and `chunk_file`) agree on both hash and size.
#[test]
fn optimize_whole_file_blob_matches_entry_hash() {
    let tree = shared_region_tree();
    let origin = TempDir::new().unwrap();

    let out = decdn(&[
        "origin",
        "import",
        "-i",
        tree.path().to_str().unwrap(),
        "--to",
        &origin.path().display().to_string(),
        "--optimize",
        "--json",
    ]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let report: serde_json::Value = serde_json::from_slice(out.stdout.trim_ascii_end()).unwrap();
    let bundle_hex = report["bundle_hash"]
        .as_str()
        .unwrap()
        .strip_prefix("b3:")
        .unwrap();
    let manifest_bytes = fs::read(data_object_path(origin.path(), bundle_hex)).unwrap();
    let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes).unwrap();

    for e in manifest["entries"].as_array().unwrap() {
        let whole_hex = e["hash"].as_str().unwrap().strip_prefix("b3:").unwrap();
        let stored = fs::read(data_object_path(origin.path(), whole_hex)).unwrap();
        assert_eq!(blake3::hash(&stored).to_hex().as_str(), whole_hex);
        let source = fs::read(tree.path().join(e["path"].as_str().unwrap())).unwrap();
        assert_eq!(stored, source, "stored blob must equal the source bytes");
    }
}