Skip to main content

oxihuman_cli/commands/
pack.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pack/bundle subcommands: pack-build, zip-pack, quantize, morph-export,
5//! asset-bundle, validate-pack, sign-pack, verify-sign.
6
7use anyhow::{bail, Context, Result};
8use std::path::PathBuf;
9
10use oxihuman_core::policy::{Policy, PolicyProfile};
11use oxihuman_core::{
12    read_signature_file, sign_pack_dir, verify_pack_signature, write_signature_file,
13};
14use oxihuman_export::asset_bundle::{bundle_from_dir, export_bundle, AssetBundle as OxbBundle};
15use oxihuman_export::pack::{build_pack, PackBuilderConfig};
16use oxihuman_export::{
17    from_target_files, morph_delta_stats, pack_mesh_assets_with_options, quantize_mesh,
18    quantize_stats, write_morph_delta_bin, write_quantized_bin,
19};
20use oxihuman_mesh::MeshBuffers;
21
22// ── policy helpers ───────────────────────────────────────────────────────────
23
24/// Partition a list of target/asset name *stems* into `(allowed, rejected)`
25/// per `policy`, using the same `is_target_allowed(name, &[])` name-based
26/// check that `oxihuman_export::pack::build_pack` already applies for
27/// `pack-build`. Shared with `wizard.rs`'s pack-wizard target scan.
28pub(crate) fn partition_by_policy<'a>(
29    names: &'a [String],
30    policy: &Policy,
31) -> (Vec<&'a str>, Vec<&'a str>) {
32    let mut allowed = Vec::new();
33    let mut rejected = Vec::new();
34    for name in names {
35        if policy.is_target_allowed(name, &[]) {
36            allowed.push(name.as_str());
37        } else {
38            rejected.push(name.as_str());
39        }
40    }
41    (allowed, rejected)
42}
43
44/// Print a clear, consistent rejection notice for names blocked by policy.
45pub(crate) fn warn_rejected(command: &str, rejected: &[&str]) {
46    if !rejected.is_empty() {
47        eprintln!(
48            "OxiHuman: {command}: {} target(s) rejected by policy: {}",
49            rejected.len(),
50            rejected.join(", ")
51        );
52    }
53}
54
55// ── pack-build ────────────────────────────────────────────────────────────────
56
57pub fn cmd_pack_build(args: &[String]) -> Result<()> {
58    let mut targets_dir: Option<PathBuf> = None;
59    let mut output: Option<PathBuf> = None;
60    let mut max_targets: Option<usize> = None;
61    let mut strict = false;
62
63    let mut i = 0;
64    while i < args.len() {
65        match args[i].as_str() {
66            "--targets" => {
67                i += 1;
68                targets_dir = Some(PathBuf::from(&args[i]));
69            }
70            "--output" => {
71                i += 1;
72                output = Some(PathBuf::from(&args[i]));
73            }
74            "--max-targets" => {
75                i += 1;
76                max_targets = Some(args[i].parse()?);
77            }
78            "--strict" => {
79                strict = true;
80            }
81            other => bail!("unknown option: {}", other),
82        }
83        i += 1;
84    }
85
86    let targets_dir = targets_dir.context("--targets is required for pack-build")?;
87    if !targets_dir.exists() {
88        bail!("targets directory not found: {}", targets_dir.display());
89    }
90
91    let policy = if strict {
92        Policy::new(PolicyProfile::Strict)
93    } else {
94        Policy::new(PolicyProfile::Standard)
95    };
96
97    eprintln!("OxiHuman: scanning {}...", targets_dir.display());
98    let manifest = build_pack(PackBuilderConfig {
99        targets_dir,
100        policy,
101        max_files: max_targets,
102    })?;
103
104    eprintln!(
105        "OxiHuman: {} files ({} allowed, {} blocked), {} deltas, ~{} KB",
106        manifest.stats.total_files,
107        manifest.stats.allowed_files,
108        manifest.stats.blocked_files,
109        manifest.stats.total_deltas,
110        manifest.stats.estimated_memory_bytes / 1024,
111    );
112
113    if let Some(out) = output {
114        manifest.write_to(&out)?;
115        eprintln!("OxiHuman: manifest -> {}", out.display());
116    } else {
117        println!("{}", manifest.to_toml()?);
118    }
119
120    Ok(())
121}
122
123// ── quantize ──────────────────────────────────────────────────────────────────
124
125pub fn cmd_quantize(args: &[String]) -> Result<()> {
126    use oxihuman_core::parser::obj::parse_obj;
127
128    let mut base: Option<PathBuf> = None;
129    let mut output: Option<PathBuf> = None;
130    let mut stats = false;
131    let mut strict = false;
132
133    let mut i = 0;
134    while i < args.len() {
135        match args[i].as_str() {
136            "--base" => {
137                i += 1;
138                base = Some(PathBuf::from(&args[i]));
139            }
140            "--output" => {
141                i += 1;
142                output = Some(PathBuf::from(&args[i]));
143            }
144            "--stats" => {
145                stats = true;
146            }
147            "--strict" => {
148                strict = true;
149            }
150            other => bail!("unknown option: {}", other),
151        }
152        i += 1;
153    }
154
155    let base = base.context("--base is required for quantize")?;
156    let output = output.context("--output is required for quantize")?;
157
158    if !base.exists() {
159        bail!("base mesh not found: {}", base.display());
160    }
161
162    let policy = if strict {
163        Policy::new(PolicyProfile::Strict)
164    } else {
165        Policy::new(PolicyProfile::Standard)
166    };
167    let base_name = base
168        .file_stem()
169        .and_then(|s| s.to_str())
170        .unwrap_or("base")
171        .to_string();
172    if !policy.is_target_allowed(&base_name, &[]) {
173        bail!(
174            "quantize: input '{}' rejected by policy (blocked tag in name)",
175            base_name
176        );
177    }
178
179    let src = std::fs::read_to_string(&base)
180        .with_context(|| format!("reading OBJ: {}", base.display()))?;
181    let obj = parse_obj(&src).context("parsing OBJ")?;
182    let morph_buf = oxihuman_morph::engine::MeshBuffers {
183        positions: obj.positions,
184        normals: obj.normals,
185        uvs: obj.uvs,
186        indices: obj.indices,
187        has_suit: false,
188    };
189    let mesh = MeshBuffers::from_morph(morph_buf);
190
191    let qmesh = quantize_mesh(&mesh);
192
193    if stats {
194        let qs = quantize_stats(&mesh, &qmesh);
195        println!("Quantize stats:");
196        println!("  position_error_rms: {:.6}", qs.position_error_rms);
197        println!("  normal_error_rms:   {:.6}", qs.normal_error_rms);
198        println!("  uv_error_rms:       {:.6}", qs.uv_error_rms);
199        println!("  compression_ratio:  {:.3}x", qs.compression_ratio);
200    }
201
202    let bytes = write_quantized_bin(&qmesh, &output)
203        .with_context(|| format!("writing QMSH to {}", output.display()))?;
204
205    println!(
206        "Written QMSH: {} vertices, {} indices → {} bytes",
207        qmesh.positions.len(),
208        qmesh.indices.len(),
209        bytes
210    );
211
212    Ok(())
213}
214
215// ── morph-export ───────────────────────────────────────────────────────────────
216
217pub fn cmd_morph_export(args: &[String]) -> Result<()> {
218    use oxihuman_core::parser::obj::parse_obj;
219    use oxihuman_core::parser::target::parse_target;
220
221    let mut base: Option<PathBuf> = None;
222    let mut targets_dir: Option<PathBuf> = None;
223    let mut output: Option<PathBuf> = None;
224    let mut max_targets: Option<usize> = None;
225    let mut strict = false;
226
227    let mut i = 0;
228    while i < args.len() {
229        match args[i].as_str() {
230            "--base" => {
231                i += 1;
232                base = Some(PathBuf::from(&args[i]));
233            }
234            "--targets" => {
235                i += 1;
236                targets_dir = Some(PathBuf::from(&args[i]));
237            }
238            "--output" => {
239                i += 1;
240                output = Some(PathBuf::from(&args[i]));
241            }
242            "--max-targets" => {
243                i += 1;
244                max_targets = Some(args[i].parse()?);
245            }
246            "--strict" => {
247                strict = true;
248            }
249            other => bail!("unknown option: {}", other),
250        }
251        i += 1;
252    }
253
254    let base = base.context("--base is required for morph-export")?;
255    let targets_dir = targets_dir.context("--targets is required for morph-export")?;
256    let output = output.context("--output is required for morph-export")?;
257
258    if !base.exists() {
259        bail!("base mesh not found: {}", base.display());
260    }
261    if !targets_dir.exists() {
262        bail!("targets directory not found: {}", targets_dir.display());
263    }
264
265    let policy = if strict {
266        Policy::new(PolicyProfile::Strict)
267    } else {
268        Policy::new(PolicyProfile::Standard)
269    };
270
271    let src = std::fs::read_to_string(&base)
272        .with_context(|| format!("reading OBJ: {}", base.display()))?;
273    let obj = parse_obj(&src).context("parsing OBJ")?;
274    let vertex_count = obj.positions.len() as u32;
275
276    // Collect .target files from directory
277    let mut entries = std::fs::read_dir(&targets_dir)
278        .with_context(|| format!("reading targets dir: {}", targets_dir.display()))?
279        .flatten()
280        .filter(|e| e.path().extension().map(|x| x == "target").unwrap_or(false))
281        .collect::<Vec<_>>();
282    entries.sort_by_key(|e| e.path());
283
284    if let Some(max) = max_targets {
285        entries.truncate(max);
286    }
287
288    let mut target_pairs: Vec<(String, oxihuman_core::parser::target::TargetFile)> = Vec::new();
289    let mut rejected_names: Vec<String> = Vec::new();
290    for entry in &entries {
291        let path = entry.path();
292        let name = path
293            .file_stem()
294            .and_then(|s| s.to_str())
295            .unwrap_or_default()
296            .to_string();
297        if !policy.is_target_allowed(&name, &[]) {
298            rejected_names.push(name);
299            continue;
300        }
301        let text = std::fs::read_to_string(&path)
302            .with_context(|| format!("reading target: {}", path.display()))?;
303        let tf = parse_target(&name, &text)
304            .with_context(|| format!("parsing target: {}", path.display()))?;
305        target_pairs.push((name, tf));
306    }
307    let rejected_refs: Vec<&str> = rejected_names.iter().map(|s| s.as_str()).collect();
308    warn_rejected("morph-export", &rejected_refs);
309
310    let ref_pairs: Vec<(String, &oxihuman_core::parser::target::TargetFile)> =
311        target_pairs.iter().map(|(n, t)| (n.clone(), t)).collect();
312
313    let bin = from_target_files(&ref_pairs, vertex_count);
314    let stats = morph_delta_stats(&bin);
315
316    write_morph_delta_bin(&bin, &output)
317        .with_context(|| format!("writing OXMD to {}", output.display()))?;
318
319    let file_size = std::fs::metadata(&output)?.len() as usize;
320
321    println!(
322        "Written OXMD: {} targets, {} total deltas → {} bytes",
323        bin.targets.len(),
324        stats.total_deltas,
325        file_size
326    );
327
328    Ok(())
329}
330
331// ── zip-pack ───────────────────────────────────────────────────────────────────
332
333pub fn cmd_zip_pack(args: &[String]) -> Result<()> {
334    use oxihuman_core::parser::obj::parse_obj;
335
336    let mut base: Option<PathBuf> = None;
337    let mut targets_dir: Option<PathBuf> = None;
338    let mut output: Option<PathBuf> = None;
339    let mut strict = false;
340    let mut deflate = false;
341
342    let mut i = 0;
343    while i < args.len() {
344        match args[i].as_str() {
345            "--base" => {
346                i += 1;
347                base = Some(PathBuf::from(&args[i]));
348            }
349            "--targets" => {
350                i += 1;
351                targets_dir = Some(PathBuf::from(&args[i]));
352            }
353            "--output" => {
354                i += 1;
355                output = Some(PathBuf::from(&args[i]));
356            }
357            "--strict" => {
358                strict = true;
359            }
360            "--deflate" => {
361                deflate = true;
362            }
363            other => bail!("unknown option: {}", other),
364        }
365        i += 1;
366    }
367
368    let base = base.context("--base is required for zip-pack")?;
369    let targets_dir = targets_dir.context("--targets is required for zip-pack")?;
370    let output = output.context("--output is required for zip-pack")?;
371
372    if !base.exists() {
373        bail!("base mesh not found: {}", base.display());
374    }
375    if !targets_dir.exists() {
376        bail!("targets directory not found: {}", targets_dir.display());
377    }
378
379    let policy = if strict {
380        Policy::new(PolicyProfile::Strict)
381    } else {
382        Policy::new(PolicyProfile::Standard)
383    };
384
385    // Build GLB from base OBJ
386    let src = std::fs::read_to_string(&base)
387        .with_context(|| format!("reading OBJ: {}", base.display()))?;
388    let obj = parse_obj(&src).context("parsing OBJ")?;
389    let morph_buf = oxihuman_morph::engine::MeshBuffers {
390        positions: obj.positions,
391        normals: obj.normals,
392        uvs: obj.uvs,
393        indices: obj.indices,
394        has_suit: false,
395    };
396    let mut mesh = MeshBuffers::from_morph(morph_buf);
397
398    // Apply suit flag and normals so GLB export accepts the mesh
399    oxihuman_mesh::normals::compute_normals(&mut mesh);
400    oxihuman_mesh::suit::apply_suit_flag(&mut mesh);
401
402    // Export mesh to GLB bytes
403    let tmp_glb = output.with_extension("_tmp.glb");
404    oxihuman_export::glb::export_glb(&mesh, &tmp_glb)
405        .with_context(|| format!("exporting GLB to {}", tmp_glb.display()))?;
406    let glb_bytes = std::fs::read(&tmp_glb)?;
407    let _ = std::fs::remove_file(&tmp_glb);
408
409    // Build params JSON
410    let params_json = serde_json::to_vec(&serde_json::json!({
411        "base": base.display().to_string(),
412        "targets": targets_dir.display().to_string(),
413    }))?;
414
415    // Build manifest JSON (list of .target file names), filtered by policy so
416    // blocked-tag target names never end up embedded in the pack manifest.
417    let all_target_files: Vec<(String, String)> = std::fs::read_dir(&targets_dir)
418        .with_context(|| format!("reading targets dir: {}", targets_dir.display()))?
419        .flatten()
420        .filter(|e| e.path().extension().map(|x| x == "target").unwrap_or(false))
421        .map(|e| {
422            let filename = e.file_name().to_string_lossy().into_owned();
423            let stem = e
424                .path()
425                .file_stem()
426                .and_then(|s| s.to_str())
427                .unwrap_or(&filename)
428                .to_string();
429            (filename, stem)
430        })
431        .collect();
432    let mut target_names: Vec<String> = Vec::new();
433    let mut rejected_names: Vec<String> = Vec::new();
434    for (filename, stem) in &all_target_files {
435        if policy.is_target_allowed(stem, &[]) {
436            target_names.push(filename.clone());
437        } else {
438            rejected_names.push(stem.clone());
439        }
440    }
441    let rejected_refs: Vec<&str> = rejected_names.iter().map(|s| s.as_str()).collect();
442    warn_rejected("zip-pack", &rejected_refs);
443    let manifest_json = serde_json::to_vec(&serde_json::json!({ "targets": target_names }))?;
444
445    let result =
446        pack_mesh_assets_with_options(&glb_bytes, &params_json, &manifest_json, &output, deflate)
447            .with_context(|| format!("writing ZIP to {}", output.display()))?;
448
449    println!(
450        "Written ZIP pack: {} entries → {}",
451        result.entry_count,
452        output.display()
453    );
454
455    Ok(())
456}
457
458// ── asset-bundle ──────────────────────────────────────────────────────────────
459
460#[allow(dead_code)]
461pub fn cmd_asset_bundle(args: &[String]) -> Result<()> {
462    use oxihuman_core::parser::obj::parse_obj;
463
464    let mut base: Option<PathBuf> = None;
465    let mut targets_dir: Option<PathBuf> = None;
466    let mut output: Option<PathBuf> = None;
467    let mut manifest: Option<PathBuf> = None;
468    let mut strict = false;
469
470    let mut i = 0;
471    while i < args.len() {
472        match args[i].as_str() {
473            "--base" => {
474                i += 1;
475                base = Some(PathBuf::from(&args[i]));
476            }
477            "--targets" => {
478                i += 1;
479                targets_dir = Some(PathBuf::from(&args[i]));
480            }
481            "--output" => {
482                i += 1;
483                output = Some(PathBuf::from(&args[i]));
484            }
485            "--manifest" => {
486                i += 1;
487                manifest = Some(PathBuf::from(&args[i]));
488            }
489            "--strict" => {
490                strict = true;
491            }
492            other => bail!("unknown option: {}", other),
493        }
494        i += 1;
495    }
496
497    let base = base.context("--base is required for asset-bundle")?;
498    let targets_dir = targets_dir.context("--targets is required for asset-bundle")?;
499    let output = output.context("--output is required for asset-bundle")?;
500
501    if !base.exists() {
502        bail!("base mesh not found: {}", base.display());
503    }
504    if !targets_dir.exists() {
505        bail!("targets directory not found: {}", targets_dir.display());
506    }
507
508    let policy = if strict {
509        Policy::new(PolicyProfile::Strict)
510    } else {
511        Policy::new(PolicyProfile::Standard)
512    };
513
514    // Load the OBJ file bytes.
515    let obj_bytes =
516        std::fs::read(&base).with_context(|| format!("reading OBJ: {}", base.display()))?;
517
518    // Build a bundle starting with the base OBJ.
519    let mut bundle = OxbBundle::new();
520    let base_name = base
521        .file_name()
522        .and_then(|n| n.to_str())
523        .unwrap_or("base.obj")
524        .to_string();
525    bundle
526        .add_bytes(&base_name, obj_bytes)
527        .with_context(|| format!("adding base OBJ '{}' to bundle", base_name))?;
528
529    // Also scan the OBJ for a quick stat check.
530    let obj_src = std::fs::read_to_string(&base)
531        .with_context(|| format!("reading OBJ: {}", base.display()))?;
532    let _obj = parse_obj(&obj_src).context("parsing OBJ")?;
533
534    // Scan the targets directory and add each file, filtered by policy so
535    // blocked-tag targets are never bundled.
536    let target_bundle = bundle_from_dir(&targets_dir)
537        .with_context(|| format!("scanning targets dir: {}", targets_dir.display()))?;
538    let target_count = target_bundle.entry_count();
539    let mut rejected_names: Vec<String> = Vec::new();
540    for name in target_bundle.entry_names() {
541        let stem = std::path::Path::new(name)
542            .file_stem()
543            .and_then(|s| s.to_str())
544            .unwrap_or(name);
545        if !policy.is_target_allowed(stem, &[]) {
546            rejected_names.push(stem.to_string());
547            continue;
548        }
549        if let Some(entry) = target_bundle.get(name) {
550            let entry_data = entry.data.clone();
551            // Avoid name collisions with base OBJ by prefixing with "targets/".
552            let bundle_name = format!("targets/{}", name);
553            bundle.add_bytes(bundle_name, entry_data).ok(); // skip duplicates silently
554        }
555    }
556    let rejected_refs: Vec<&str> = rejected_names.iter().map(|s| s.as_str()).collect();
557    warn_rejected("asset-bundle", &rejected_refs);
558
559    // Optionally include manifest bytes.
560    if let Some(ref mp) = manifest {
561        if !mp.exists() {
562            bail!("manifest file not found: {}", mp.display());
563        }
564        let manifest_bytes =
565            std::fs::read(mp).with_context(|| format!("reading manifest: {}", mp.display()))?;
566        bundle.add_bytes("manifest.toml", manifest_bytes).ok();
567    }
568
569    let total_assets = bundle.entry_count();
570    export_bundle(&bundle, &output)
571        .with_context(|| format!("writing bundle: {}", output.display()))?;
572
573    println!(
574        "Written bundle: {} assets → {}",
575        total_assets,
576        output.display()
577    );
578    let _ = target_count; // suppress unused warning if targets dir was empty
579    Ok(())
580}
581
582// ── validate-pack ─────────────────────────────────────────────────────────────
583
584#[allow(dead_code)]
585pub fn cmd_validate_pack(args: &[String]) -> Result<()> {
586    use oxihuman_core::{scan_pack, verify_manifest_present, verify_pack};
587
588    let mut pack_dir: Option<PathBuf> = None;
589
590    let mut i = 0;
591    while i < args.len() {
592        match args[i].as_str() {
593            "--pack-dir" => {
594                i += 1;
595                pack_dir = Some(PathBuf::from(&args[i]));
596            }
597            other => bail!("unknown option: {}", other),
598        }
599        i += 1;
600    }
601
602    let pack_dir = pack_dir.context("--pack-dir is required for validate-pack")?;
603
604    if !pack_dir.exists() {
605        bail!("pack directory not found: {}", pack_dir.display());
606    }
607
608    // Check for manifest file.
609    match verify_manifest_present(&pack_dir) {
610        Ok(()) => println!("Manifest: present"),
611        Err(e) => println!("Manifest: MISSING — {}", e),
612    }
613
614    // Scan and verify all files.
615    let records =
616        scan_pack(&pack_dir).with_context(|| format!("scanning pack: {}", pack_dir.display()))?;
617    println!("Scanned {} file(s)", records.len());
618
619    let report = verify_pack(&pack_dir, &records);
620    println!("{}", report.summary());
621
622    if !report.is_valid {
623        if !report.failed_files.is_empty() {
624            println!("Failed files:");
625            for f in &report.failed_files {
626                println!("  FAIL: {}", f);
627            }
628        }
629        if !report.missing_files.is_empty() {
630            println!("Missing files:");
631            for f in &report.missing_files {
632                println!("  MISS: {}", f);
633            }
634        }
635        bail!("pack validation failed");
636    }
637    Ok(())
638}
639
640// ── sign-pack ─────────────────────────────────────────────────────────────────
641
642#[allow(dead_code)]
643pub fn cmd_sign_pack(args: &[String]) -> Result<()> {
644    let mut pack_dir: Option<PathBuf> = None;
645    let mut key_str: Option<String> = None;
646    let mut signer_id = String::from("oxihuman-cli");
647    let mut output: Option<PathBuf> = None;
648
649    let mut i = 0;
650    while i < args.len() {
651        match args[i].as_str() {
652            "--pack-dir" => {
653                i += 1;
654                pack_dir = Some(PathBuf::from(&args[i]));
655            }
656            "--key" => {
657                i += 1;
658                key_str = Some(args[i].clone());
659            }
660            "--signer-id" => {
661                i += 1;
662                signer_id = args[i].clone();
663            }
664            "--output" => {
665                i += 1;
666                output = Some(PathBuf::from(&args[i]));
667            }
668            other => bail!("sign-pack: unknown option: {}", other),
669        }
670        i += 1;
671    }
672
673    let pack_dir = pack_dir.context("--pack-dir is required for sign-pack")?;
674    let key_str = key_str.context("--key is required for sign-pack")?;
675    let output = output.context("--output is required for sign-pack")?;
676
677    if !pack_dir.exists() {
678        bail!("pack-dir not found: {}", pack_dir.display());
679    }
680
681    let signed = sign_pack_dir(&pack_dir, key_str.as_bytes(), &signer_id)
682        .with_context(|| format!("signing pack dir: {}", pack_dir.display()))?;
683    write_signature_file(&signed, &output)
684        .with_context(|| format!("writing signature file: {}", output.display()))?;
685    println!("Pack signed. Signature written to: {}", output.display());
686    Ok(())
687}
688
689// ── verify-sign ───────────────────────────────────────────────────────────────
690
691#[allow(dead_code)]
692pub fn cmd_verify_sign(args: &[String]) -> Result<()> {
693    let mut pack_dir: Option<PathBuf> = None;
694    let mut sig_file: Option<PathBuf> = None;
695    let mut key_str: Option<String> = None;
696
697    let mut i = 0;
698    while i < args.len() {
699        match args[i].as_str() {
700            "--pack-dir" => {
701                i += 1;
702                pack_dir = Some(PathBuf::from(&args[i]));
703            }
704            "--sig-file" => {
705                i += 1;
706                sig_file = Some(PathBuf::from(&args[i]));
707            }
708            "--key" => {
709                i += 1;
710                key_str = Some(args[i].clone());
711            }
712            other => bail!("verify-sign: unknown option: {}", other),
713        }
714        i += 1;
715    }
716
717    let pack_dir = pack_dir.context("--pack-dir is required for verify-sign")?;
718    let sig_file = sig_file.context("--sig-file is required for verify-sign")?;
719    let key_str = key_str.context("--key is required for verify-sign")?;
720
721    if !pack_dir.exists() {
722        bail!("pack-dir not found: {}", pack_dir.display());
723    }
724    if !sig_file.exists() {
725        bail!("sig-file not found: {}", sig_file.display());
726    }
727
728    let signed = read_signature_file(&sig_file)
729        .with_context(|| format!("reading sig file: {}", sig_file.display()))?;
730    if verify_pack_signature(&pack_dir, &signed, key_str.as_bytes()) {
731        println!("VALID");
732    } else {
733        println!("INVALID");
734    }
735    Ok(())
736}
737
738// ── pack-dist-manifest ────────────────────────────────────────────────────────
739
740/// Generate a SHA-256 distribution manifest for all files in a pack directory.
741///
742/// Usage: `oxihuman pack-dist-manifest --pack-dir <DIR>`
743///
744/// Prints the manifest JSON to stdout.
745#[allow(dead_code)]
746pub fn cmd_pack_dist_manifest(args: &[String]) -> Result<()> {
747    let mut pack_dir: Option<PathBuf> = None;
748
749    let mut i = 0;
750    while i < args.len() {
751        match args[i].as_str() {
752            "--pack-dir" => {
753                i += 1;
754                if i >= args.len() {
755                    bail!("pack-dist-manifest: --pack-dir requires an argument");
756                }
757                pack_dir = Some(PathBuf::from(&args[i]));
758            }
759            other => bail!("pack-dist-manifest: unknown option: {}", other),
760        }
761        i += 1;
762    }
763
764    let pack_dir = pack_dir.context("--pack-dir is required for pack-dist-manifest")?;
765    if !pack_dir.exists() {
766        bail!("pack-dir not found: {}", pack_dir.display());
767    }
768
769    let manifest = oxihuman_core::asset_pack_builder::generate_distribution_manifest(&pack_dir)
770        .with_context(|| {
771            format!(
772                "generating distribution manifest for: {}",
773                pack_dir.display()
774            )
775        })?;
776    println!("{manifest}");
777    Ok(())
778}
779
780// ── pack-verify-dist ──────────────────────────────────────────────────────────
781
782/// Verify all files in a pack directory against a distribution manifest.
783///
784/// Usage: `oxihuman pack-verify-dist --manifest <FILE> --pack-dir <DIR>`
785///
786/// Exits with code 0 on success, 1 on verification failure.
787#[allow(dead_code)]
788pub fn cmd_pack_verify_dist(args: &[String]) -> Result<()> {
789    let mut manifest_path: Option<PathBuf> = None;
790    let mut pack_dir: Option<PathBuf> = None;
791
792    let mut i = 0;
793    while i < args.len() {
794        match args[i].as_str() {
795            "--manifest" => {
796                i += 1;
797                if i >= args.len() {
798                    bail!("pack-verify-dist: --manifest requires an argument");
799                }
800                manifest_path = Some(PathBuf::from(&args[i]));
801            }
802            "--pack-dir" => {
803                i += 1;
804                if i >= args.len() {
805                    bail!("pack-verify-dist: --pack-dir requires an argument");
806                }
807                pack_dir = Some(PathBuf::from(&args[i]));
808            }
809            other => bail!("pack-verify-dist: unknown option: {}", other),
810        }
811        i += 1;
812    }
813
814    let manifest_path = manifest_path.context("--manifest is required for pack-verify-dist")?;
815    let pack_dir = pack_dir.context("--pack-dir is required for pack-verify-dist")?;
816
817    if !manifest_path.exists() {
818        bail!("manifest file not found: {}", manifest_path.display());
819    }
820    if !pack_dir.exists() {
821        bail!("pack-dir not found: {}", pack_dir.display());
822    }
823
824    let json = std::fs::read_to_string(&manifest_path)
825        .with_context(|| format!("reading manifest: {}", manifest_path.display()))?;
826
827    let ok = oxihuman_core::asset_pack_builder::verify_distribution_manifest(&json, &pack_dir)
828        .with_context(|| {
829            format!(
830                "verifying distribution manifest against: {}",
831                pack_dir.display()
832            )
833        })?;
834
835    if ok {
836        println!("Manifest verification: OK");
837    } else {
838        eprintln!("Manifest verification: FAILED");
839        std::process::exit(1);
840    }
841    Ok(())
842}