1use anyhow::Result;
4use clap::{Parser, Subcommand};
5use std::path::{Path, PathBuf};
6
7use znippy_common::{VerifyReport, list_archive_contents, verify_archive_integrity};
8use znippy_common::plugin::PluginRegistry;
9use znippy_common::plugins::wasm_loader::WasmPlugin;
10use znippy_compress::compress_dir;
11use znippy_decompress::{decompress_archive, decompress_archive_filtered};
12
13pub mod handlers;
14
15pub const GIT_HASH: &str = env!("ZNIPPY_GIT_HASH");
20
21pub const VERSION_LINE: &str =
26 concat!("v", env!("CARGO_PKG_VERSION"), " (", env!("ZNIPPY_GIT_HASH"), ")");
27
28#[inline]
36fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
37 #[cfg(feature = "testmatrix")]
38 nornir_testmatrix::functional_status(component, check, ok, detail);
39 #[cfg(not(feature = "testmatrix"))]
40 {
41 let _ = (component, check, ok, detail);
42 }
43}
44
45#[derive(Parser)]
46#[command(name = "znippy")]
47#[command(version = VERSION_LINE)]
48#[command(about = "Znippy: fast archive format with per-file compression", long_about = None)]
49struct Cli {
50 #[command(subcommand)]
51 command: Commands,
52}
53
54#[derive(Subcommand)]
55enum Commands {
56 Compress {
58 #[arg(short, long)]
59 input: PathBuf,
60
61 #[arg(short, long)]
62 output: PathBuf,
63
64 #[arg(long)]
65 no_skip: bool,
66
67 #[arg(long, default_value = "rust")]
70 format: String,
71
72 #[arg(long, default_value = "arrow-ipc")]
77 meta_format: String,
78
79 #[arg(long)]
82 warehouse: Option<PathBuf>,
83
84 #[arg(long)]
86 plugin: Option<PathBuf>,
87
88 #[arg(long, default_value_t = 1)]
90 plugin_type_id: i8,
91
92 #[arg(long, value_name = "KEY")]
97 sign: Option<PathBuf>,
98
99 #[arg(long, value_name = "CERT")]
101 sign_cert: Option<PathBuf>,
102
103 #[arg(long, default_value = "p256")]
105 sign_alg: String,
106 },
107
108 Append {
121 #[arg(short, long)]
123 input: PathBuf,
124
125 #[arg(short, long)]
128 add: PathBuf,
129
130 #[arg(short, long, default_value_t = 3)]
132 level: i32,
133
134 #[arg(long = "meta", value_name = "PATH=KEY=VALUE")]
139 meta: Vec<String>,
140
141 #[arg(long = "meta-archive", value_name = "KEY=VALUE")]
144 meta_archive: Vec<String>,
145 },
146
147 Meta {
157 #[arg(short, long)]
159 input: PathBuf,
160
161 #[arg(short, long)]
163 key: Option<String>,
164
165 #[arg(short, long)]
167 prefix: Option<String>,
168
169 #[arg(long)]
172 paths_only: bool,
173 },
174
175 Decompress {
177 #[arg(short, long)]
178 input: PathBuf,
179
180 #[arg(short, long)]
181 output: PathBuf,
182
183 #[arg(long = "type")]
186 pkg_type: Option<String>,
187
188 #[arg(long)]
190 repo: Option<String>,
191 },
192
193 List {
195 #[arg(short, long)]
196 input: PathBuf,
197 },
198
199 Get {
202 #[arg(short, long)]
203 input: PathBuf,
204
205 #[arg(short, long)]
207 path: String,
208
209 #[arg(short, long)]
211 output: Option<PathBuf>,
212 },
213
214 Verify {
216 #[arg(short, long)]
217 input: PathBuf,
218
219 #[arg(long)]
224 signed: bool,
225
226 #[arg(long, value_name = "CA")]
228 root: Vec<PathBuf>,
229 },
230
231 Seal {
240 #[arg(short, long)]
243 input: PathBuf,
244
245 #[arg(long)]
247 warehouse: PathBuf,
248
249 #[arg(long)]
252 namespace: Option<String>,
253
254 #[arg(short, long)]
256 output: PathBuf,
257 },
258
259 Handlers,
261
262 Run {
264 format: String,
266 cmd: String,
268 args: Vec<String>,
270 },
271}
272
273fn build_meta_sink(
279 meta_format: &str,
280 warehouse: Option<PathBuf>,
281 output: &std::path::Path,
282) -> Result<Option<znippy_common::MetaSinkFactory>> {
283 match meta_format {
284 "arrow-ipc" => Ok(None),
285 "iceberg" => {
286 #[cfg(feature = "iceberg")]
287 {
288 let wh = warehouse.ok_or_else(|| {
289 anyhow::anyhow!("--warehouse <DIR> is required for --meta-format iceberg")
290 })?;
291 let namespace = output
292 .file_stem()
293 .map(|s| s.to_string_lossy().to_string())
294 .unwrap_or_else(|| "znippy".to_string());
295 println!(
296 "🧊 Metadata → Iceberg table (namespace `{namespace}`) in {}",
297 wh.display()
298 );
299 Ok(Some(Box::new(move |_file, _off| {
300 Box::new(znippy_iceberg::IcebergSink::new(wh, namespace))
301 as Box<dyn znippy_common::ArchiveMetaSink>
302 })))
303 }
304 #[cfg(not(feature = "iceberg"))]
305 {
306 let _ = (warehouse, output);
307 anyhow::bail!(
308 "iceberg metadata backend not compiled in; rebuild znippy-cli with `--features iceberg`"
309 )
310 }
311 }
312 other => anyhow::bail!("unknown --meta-format '{other}' (expected arrow-ipc|iceberg)"),
313 }
314}
315
316fn compress_reporting(
323 input: &PathBuf,
324 output: &PathBuf,
325 no_skip: bool,
326 registry: &PluginRegistry,
327 sink_factory: Option<znippy_common::MetaSinkFactory>,
328) -> Result<znippy_common::CompressionReport> {
329 match compress_dir(input, output, no_skip, Some(registry), None, sink_factory) {
330 Ok(report) => {
331 let ok = report.total_files > 0
338 && report.chunks > 0
339 && report.files_failed == 0;
340 functional_status(
341 "znippy-cli/compress",
342 "archive_written",
343 ok,
344 &format!(
345 "{} files ({} failed), {} chunks, {:.2}% ratio → {}",
346 report.total_files,
347 report.files_failed,
348 report.chunks,
349 report.compression_ratio,
350 output.display()
351 ),
352 );
353 if report.files_failed > 0 {
354 eprintln!(
355 "⚠️ {} av {} filer kunde inte läsas och utelämnades ur arkivet",
356 report.files_failed, report.total_files
357 );
358 }
359 Ok(report)
360 }
361 Err(e) => {
362 functional_status(
363 "znippy-cli/compress",
364 "archive_written",
365 false,
366 &format!("compress failed: {e}"),
367 );
368 Err(e)
369 }
370 }
371}
372
373fn verify_reporting(input: &Path) -> Result<VerifyReport> {
380 let report = match verify_archive_integrity(input) {
381 Ok(r) => r,
382 Err(e) => {
383 functional_status(
384 "znippy-cli/verify",
385 "integrity_checksum",
386 false,
387 &format!("verify failed: {e}"),
388 );
389 return Err(e);
390 }
391 };
392 functional_status(
396 "znippy-cli/format-version-guard",
397 "on_disk_version_supported",
398 true,
399 &format!("reader max v{}", znippy_common::index::ZNIPPY_FORMAT_VERSION),
400 );
401 let integrity_ok = report.corrupt_files == 0 && report.corrupt_bytes == 0;
402 functional_status(
403 "znippy-cli/verify",
404 "integrity_checksum",
405 integrity_ok,
406 &format!(
407 "{} verified, {} corrupt files",
408 report.verified_files, report.corrupt_files
409 ),
410 );
411 Ok(report)
412}
413
414fn decompress_reporting(
420 input: &PathBuf,
421 output: &PathBuf,
422 filter: &znippy_common::IndexFilter,
423 pkg_type: Option<&str>,
424 repo: Option<&str>,
425) -> Result<VerifyReport> {
426 let result = if filter.is_empty() {
427 decompress_archive(input, output)
428 } else {
429 println!(
430 "🔎 Selective extract: type={} repo={}",
431 pkg_type.unwrap_or("*"),
432 repo.unwrap_or("*"),
433 );
434 decompress_archive_filtered(input, output, filter)
435 };
436 let report = match result {
437 Ok(r) => r,
438 Err(e) => {
439 functional_status(
440 "znippy-cli/decompress",
441 "reconstruct_verify",
442 false,
443 &format!("decompress failed: {e}"),
444 );
445 return Err(e);
446 }
447 };
448 let integrity_ok = report.corrupt_files == 0 && report.corrupt_bytes == 0;
449 functional_status(
450 "znippy-cli/decompress",
451 "reconstruct_verify",
452 integrity_ok,
453 &format!(
454 "{} verified, {} corrupt files, {} corrupt bytes",
455 report.verified_files, report.corrupt_files, report.corrupt_bytes
456 ),
457 );
458 Ok(report)
459}
460
461#[cfg(feature = "sign")]
468fn build_signer(
469 sign: &Option<PathBuf>,
470 sign_cert: &Option<PathBuf>,
471 sign_alg: &str,
472) -> Result<Option<Box<dyn znippy_common::sign::ArchiveSigner + Send>>> {
473 let Some(key_path) = sign else { return Ok(None) };
474 let load = (|| -> Result<Box<dyn znippy_common::sign::ArchiveSigner + Send>> {
475 let cert_path = sign_cert.as_ref().ok_or_else(|| {
476 anyhow::anyhow!("--sign requires --sign-cert <CERT> (DER signer certificate)")
477 })?;
478 let alg = znippy_common::sign::SigAlg::from_name(sign_alg)?;
479 let key = std::fs::read(key_path)?;
480 let cert = std::fs::read(cert_path)?;
481 Ok(znippy_common::sign::signer_from_pkcs8(alg, &key, &cert)?)
482 })();
483 match load {
484 Ok(signer) => Ok(Some(signer)),
485 Err(e) => {
486 functional_status(
487 "znippy-cli/compress-sign",
488 "signer_loaded",
489 false,
490 &format!("signer load failed ({sign_alg}): {e}"),
491 );
492 Err(e)
493 }
494 }
495}
496
497#[cfg(feature = "sign")]
501fn sign_meta_factory(
502 signer: Box<dyn znippy_common::sign::ArchiveSigner + Send>,
503) -> znippy_common::MetaSinkFactory {
504 Box::new(move |file, off| {
505 Box::new(znippy_common::ArrowIpcSink::new(file, off).with_signer(signer))
506 as Box<dyn znippy_common::ArchiveMetaSink>
507 })
508}
509
510#[cfg(feature = "sign")]
517fn run_signed_verify(input: &std::path::Path, roots: &[PathBuf]) -> Result<()> {
518 match run_signed_verify_inner(input, roots) {
519 Ok(()) => Ok(()),
520 Err(e) => {
521 functional_status(
522 "znippy-cli/verify-signed",
523 "provenance_chain",
524 false,
525 &format!("provenance verify failed: {e}"),
526 );
527 Err(e)
528 }
529 }
530}
531
532#[cfg(feature = "sign")]
547pub fn provenance_is_verified(artifacts_verified: usize) -> bool {
548 artifacts_verified > 0
549}
550
551#[cfg(feature = "sign")]
552fn run_signed_verify_inner(input: &std::path::Path, roots: &[PathBuf]) -> Result<()> {
553 anyhow::ensure!(
554 !roots.is_empty(),
555 "--signed requires at least one --root <CA> (DER trusted root)"
556 );
557 let ders: Vec<Vec<u8>> = roots.iter().map(std::fs::read).collect::<std::io::Result<_>>()?;
558 let store = znippy_common::sign::CertStore::from_der_certs(&ders)?;
559 let report = znippy_common::sign::verify_archive(input, &store)?;
560 println!("\n🔏 Provenans verifierad:");
561 println!("✍️ Signerad av (CN): {}", report.signer.id.common_name);
562 println!("🪪 Subjekt: {}", report.signer.id.subject);
563 println!("🔑 Fingeravtryck (SHA-256): {}", report.signer.id.fingerprint_hex());
566 println!("📦 Verifierade artefakter: {}", report.artifacts_verified);
567 let ok = provenance_is_verified(report.artifacts_verified);
578 if !ok {
579 eprintln!(
580 "⚠️ arkivsignaturen kedjar till en betrodd rot, men NOLL artefakter \
581 verifierades — arkivet är antingen förseglat utan filer eller saknar \
582 sin per-artefakt-sektion"
583 );
584 }
585 functional_status(
586 "znippy-cli/verify-signed",
587 "provenance_chain",
588 ok,
589 &format!(
590 "CMS chained to root; signer={}, fp={}, artifacts={}",
591 report.signer.id.common_name,
592 report.signer.id.fingerprint_hex(),
593 report.artifacts_verified
594 ),
595 );
596 Ok(())
597}
598
599fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<()> {
604 let mut entries: Vec<_> = std::fs::read_dir(dir)?
605 .collect::<std::io::Result<Vec<_>>>()?;
606 entries.sort_by_key(|e| e.file_name());
608 for entry in entries {
609 let path = entry.path();
610 let ft = entry.file_type()?;
611 if ft.is_dir() {
612 collect_files(root, &path, out)?;
613 } else if ft.is_file() {
614 let rel = path
615 .strip_prefix(root)
616 .unwrap_or(&path)
617 .to_string_lossy()
618 .into_owned();
619 let bytes = std::fs::read(&path)?;
620 out.push((rel, bytes));
621 }
622 }
623 Ok(())
624}
625
626fn parse_meta_args(
634 entry_args: &[String],
635 archive_args: &[String],
636) -> Result<Option<znippy_common::MetaTable>> {
637 if entry_args.is_empty() && archive_args.is_empty() {
638 return Ok(None);
639 }
640 let mut table = znippy_common::MetaTable::new();
641 for raw in entry_args {
642 let (path, rest) = raw
643 .split_once('=')
644 .ok_or_else(|| anyhow::anyhow!("--meta {raw:?}: expected PATH=KEY=VALUE"))?;
645 let (key, value) = rest
646 .split_once('=')
647 .ok_or_else(|| anyhow::anyhow!("--meta {raw:?}: expected PATH=KEY=VALUE"))?;
648 table.insert(path, key, parse_meta_value(value)?);
649 }
650 for raw in archive_args {
651 let (key, value) = raw
652 .split_once('=')
653 .ok_or_else(|| anyhow::anyhow!("--meta-archive {raw:?}: expected KEY=VALUE"))?;
654 table.insert_archive(key, parse_meta_value(value)?);
655 }
656 Ok(Some(table))
657}
658
659fn parse_meta_value(raw: &str) -> Result<znippy_common::MetaValue> {
664 use znippy_common::MetaValue;
665 if let Some(file) = raw.strip_prefix('@') {
666 let bytes = std::fs::read(file)
667 .map_err(|e| anyhow::anyhow!("--meta value @{file}: {e}"))?;
668 return Ok(MetaValue::Bytes(bytes));
669 }
670 Ok(match raw {
671 "true" => MetaValue::Bool(true),
672 "false" => MetaValue::Bool(false),
673 _ => {
674 if let Ok(i) = raw.parse::<i64>() {
675 MetaValue::I64(i)
676 } else if let Ok(f) = raw.parse::<f64>() {
677 MetaValue::F64(f)
678 } else {
679 MetaValue::Str(raw.to_string())
680 }
681 }
682 })
683}
684
685fn run_meta_search(
693 input: &Path,
694 key: Option<&str>,
695 prefix: Option<&str>,
696 paths_only: bool,
697) -> Result<()> {
698 use std::io::Write;
699 use znippy_common::{ArchiveMeta, MetaValue, read_archive_meta};
700
701 let meta = read_archive_meta(input)?;
702 let index = match &meta {
703 ArchiveMeta::NoMetadata => {
704 if !paths_only {
705 eprintln!(
706 "ℹ️ {} carries NO metadata index — nothing was searched. \
707 (That is not the same as searching and finding nothing.)",
708 input.display()
709 );
710 }
711 functional_status(
712 "znippy-cli/meta",
713 "no_metadata_reported_distinctly",
714 true,
715 "archive has no __znippy_meta__ section; reported as NoMetadata, exit 2",
716 );
717 std::io::stdout().flush().ok();
718 std::process::exit(2);
719 }
720 ArchiveMeta::Index(i) => i,
721 };
722
723 let hits: &[znippy_common::MetaEntry] = match (key, prefix) {
724 (Some(k), _) => index.find_by_key(k),
725 (None, Some(p)) => index.find_by_prefix(p),
726 (None, None) => index.find_by_prefix(""),
727 };
728
729 if paths_only {
730 for h in hits {
731 if let Some(p) = h.path() {
732 println!("{p}");
733 }
734 }
735 } else {
736 println!(
737 "🔎 {} — metadata index: {} rows, {} distinct keys",
738 input.display(),
739 index.len(),
740 index.keys().len()
741 );
742 if hits.is_empty() {
743 println!(" (searched — no row matches)");
744 }
745 for h in hits {
746 let scope = h.path().unwrap_or("<archive>");
747 let shown = match &h.value {
748 MetaValue::Str(v) => format!("{v:?}"),
749 MetaValue::I64(v) => v.to_string(),
750 MetaValue::F64(v) => v.to_string(),
751 MetaValue::Bool(v) => v.to_string(),
752 MetaValue::Bytes(b) => format!("<{} bytes>", b.len()),
753 };
754 println!(" {scope} {} = {shown}", h.key);
755 }
756 }
757
758 functional_status(
759 "znippy-cli/meta",
760 "index_searched_without_payload_read",
761 true,
762 &format!("{} rows in index, {} hits", index.len(), hits.len()),
763 );
764 std::io::stdout().flush().ok();
765 if hits.is_empty() {
766 std::process::exit(1);
767 }
768 Ok(())
769}
770
771pub fn run() -> Result<()> {
772 env_logger::init();
773 let cli = Cli::parse();
774
775 match cli.command {
776 Commands::Compress {
777 input,
778 output,
779 no_skip,
780 format,
781 meta_format,
782 warehouse,
783 plugin,
784 plugin_type_id,
785 sign,
786 sign_cert,
787 sign_alg,
788 } => {
789 let registry = match plugin {
790 Some(wasm_path) => {
791 let wp = WasmPlugin::load(&wasm_path.to_string_lossy(), "wasm-plugin", plugin_type_id)?;
792 PluginRegistry::with_plugin(Box::new(wp))
793 }
794 None => {
795 let handler = handlers::find_handler(&format)?;
796 println!("🔌 Handler: {} (type_id {})", handler.meta().name, handler.type_id());
797 PluginRegistry::with_plugin(handler)
798 }
799 };
800 #[allow(unused_mut)]
802 let mut sink_factory = build_meta_sink(&meta_format, warehouse, &output)?;
803
804 #[cfg(feature = "sign")]
807 {
808 if let Some(signer) = build_signer(&sign, &sign_cert, &sign_alg)? {
809 anyhow::ensure!(
810 sink_factory.is_none(),
811 "--sign is only supported with --meta-format arrow-ipc"
812 );
813 println!("🔏 Signering aktiverad ({sign_alg})");
814 sink_factory = Some(sign_meta_factory(signer));
815 functional_status(
816 "znippy-cli/compress-sign",
817 "signer_loaded",
818 true,
819 &format!("detached CMS provenance armed ({sign_alg})"),
820 );
821 }
822 }
823 #[cfg(not(feature = "sign"))]
824 {
825 let _ = &sign_alg;
826 anyhow::ensure!(
827 sign.is_none() && sign_cert.is_none(),
828 "signing not compiled in; rebuild znippy-cli with `--features sign`"
829 );
830 }
831
832 let report = compress_reporting(&input, &output, no_skip, ®istry, sink_factory)?;
833 if report.files_failed == 0 {
834 println!("\n✅ Komprimering klar:");
835 } else {
836 println!("\n⚠️ Komprimering klar med fel:");
837 }
838 println!("📁 Totalt antal filer: {}", report.total_files);
839 println!("📁 Totalt antal chunks: {}", report.chunks);
840 println!("❌ Filer som misslyckades: {}", report.files_failed);
841
842 println!("📂 Totalt antal kataloger: {}", report.total_dirs);
843 println!("📦 Filer komprimerade: {}", report.compressed_files);
844 println!(
845 "📄 Filer ej komprimerade: {}",
846 report.uncompressed_files
847 );
848 println!("📥 Totalt inlästa bytes: {}", report.total_bytes_in);
849 println!("📤 Totalt skrivna bytes: {}", report.total_bytes_out);
850 println!("📉 Bytes som komprimerades: {}", report.compressed_bytes);
851 println!(
852 "📃 Bytes ej komprimerade: {}",
853 report.uncompressed_bytes
854 );
855 println!(
856 "📊 Komprimeringsgrad: {:.2}%",
857 report.compression_ratio
858 );
859 }
860
861 Commands::Append { input, add, level, meta, meta_archive } => {
862 let mut files = Vec::new();
863 collect_files(&add, &add, &mut files)?;
864 let file_count = files.len();
865 anyhow::ensure!(
866 file_count > 0,
867 "inga filer att lägga till hittades under {}",
868 add.display()
869 );
870 let meta_table = parse_meta_args(&meta, &meta_archive)?;
874 let meta_rows = meta_table.as_ref().map_or(0, |t| t.len());
875 let report =
876 znippy_common::append_files_with_meta(&input, &files, level, meta_table)?;
877 println!("\n✅ Append klar:");
878 println!("📦 Arkiv: {}", input.display());
879 println!("📁 Filer tillagda: {}", file_count);
880 println!("➕ Nya rader: {}", report.rows_added);
881 println!("📊 Rader innan: {}", report.rows_before);
882 println!("♻️ Ersatta rader: {}", report.rows_replaced);
883 println!("📍 Blob-append-offset: {}", report.blob_append_offset);
884 println!("📤 Nya blob-bytes: {}", report.blob_bytes_added);
885 println!("💾 Slutlig arkivstorlek: {}", report.sealed_total_bytes);
886 if meta_rows > 0 {
887 println!("🔎 Metadata-rader tillagda: {meta_rows}");
888 }
889 functional_status(
890 "znippy-cli/append",
891 "native_append",
892 report.rows_added >= file_count as u64,
893 &format!(
894 "appended {file_count} files ({} new rows) into {}",
895 report.rows_added,
896 input.display()
897 ),
898 );
899 }
900
901 Commands::Decompress { input, output, pkg_type, repo } => {
902 let filter = znippy_common::IndexFilter {
903 pkg_type: match &pkg_type {
904 Some(name) => Some(handlers::find_handler(name)?.type_id()),
905 None => None,
906 },
907 repo: repo.clone(),
908 };
909 let report: VerifyReport = decompress_reporting(
910 &input,
911 &output,
912 &filter,
913 pkg_type.as_deref(),
914 repo.as_deref(),
915 )?;
916 println!("\n✅ Dekomprimering och verifiering klar:");
917 println!("📁 Totala filer: {}", report.total_files);
918 println!("🔐 Verifierade filer: {}", report.verified_files);
919 println!("📥 chunks: {}", report.chunks);
920 println!("❌ Korrupta filer: {}", report.corrupt_files);
921 println!("📥 Totala bytes: {}", report.total_bytes);
922 println!("📤 Verifierade bytes: {}", report.verified_bytes);
923 println!("⚠️ Korrupta bytes: {}", report.corrupt_bytes);
924 if report.corrupt_files > 0 || report.corrupt_bytes > 0 {
927 anyhow::bail!(
928 "dekomprimering misslyckades: {} korrupta filer, {} korrupta bytes — utdata är ofullständig/otillförlitlig",
929 report.corrupt_files,
930 report.corrupt_bytes
931 );
932 }
933 }
934
935 Commands::List { input } => {
936 list_archive_contents(&input)?;
937 }
938
939 Commands::Meta { input, key, prefix, paths_only } => {
940 return run_meta_search(&input, key.as_deref(), prefix.as_deref(), paths_only);
941 }
942
943 Commands::Get { input, path, output } => {
944 let reader = znippy_common::ArchiveReader::open(&input)?;
949 let data = reader.read_file(&path)?;
950 match output {
951 Some(dest) => {
952 std::fs::write(&dest, &data)?;
953 eprintln!("📤 {} ({} bytes) → {}", path, data.len(), dest.display());
954 }
955 None => {
956 use std::io::Write;
957 std::io::stdout().write_all(&data)?;
958 }
959 }
960 }
961
962 Commands::Verify { input, signed, root } => {
963 let report: VerifyReport = verify_reporting(&input)?;
964 println!("\n🔍 Verifiering klar:");
965 println!("📁 Totala filer: {}", report.total_files);
966 println!("🔐 Verifierade filer: {}", report.verified_files);
967 println!("❌ Korrupta filer: {}", report.corrupt_files);
968 println!("📥 Totala bytes: {}", report.total_bytes);
969 println!("📤 Verifierade bytes: {}", report.verified_bytes);
970 println!("⚠️ Korrupta bytes: {}", report.corrupt_bytes);
971
972 if report.corrupt_files > 0 || report.corrupt_bytes > 0 {
978 anyhow::bail!(
979 "verifiering misslyckades: {} korrupta filer, {} korrupta bytes — arkivet är skadat",
980 report.corrupt_files,
981 report.corrupt_bytes
982 );
983 }
984
985 if signed {
986 #[cfg(feature = "sign")]
987 run_signed_verify(&input, &root)?;
988 #[cfg(not(feature = "sign"))]
989 {
990 let _ = &root;
991 anyhow::bail!(
992 "signature verification not compiled in; rebuild znippy-cli with `--features sign`"
993 );
994 }
995 }
996 }
997
998 Commands::Seal { input, warehouse, namespace, output } => {
999 #[cfg(feature = "iceberg")]
1000 {
1001 let ns = namespace.unwrap_or_else(|| {
1002 input
1003 .file_stem()
1004 .map(|s| s.to_string_lossy().to_string())
1005 .unwrap_or_else(|| "znippy".to_string())
1006 });
1007 println!(
1008 "🧊→📦 Sealing iceberg archive (namespace `{ns}`) in {} → {}",
1009 warehouse.display(),
1010 output.display()
1011 );
1012 let report = znippy_iceberg::seal(&input, &warehouse, &ns, &output)?;
1013 println!("\n✅ Sealed (static native .znippy):");
1014 println!("📁 Filer: {}", report.files);
1015 println!("🧱 Chunk-rader: {}", report.rows);
1016 println!(
1017 "📤 Blob-bytes återanvända: {} (ingen omkomprimering)",
1018 report.blob_bytes_copied
1019 );
1020 println!("📦 Sealad total storlek: {}", report.sealed_total_bytes);
1021 println!(
1022 "📊 Metadata-svans + footer: {} bytes",
1023 report.sealed_total_bytes - report.blob_bytes_copied
1024 );
1025 }
1026 #[cfg(not(feature = "iceberg"))]
1027 {
1028 let _ = (input, warehouse, namespace, output);
1029 anyhow::bail!(
1030 "iceberg backend not compiled in; rebuild znippy-cli with `--features iceberg`"
1031 );
1032 }
1033 }
1034
1035 Commands::Handlers => {
1036 handlers::print_catalog();
1037 }
1038
1039 Commands::Run { format, cmd, args } => {
1040 let handler = handlers::find_handler(&format)?;
1041 let dispatch = handler.run_command(&cmd, &args);
1042 functional_status(
1043 "znippy-cli/run-dispatch",
1044 "handler_command",
1045 dispatch.is_ok(),
1046 &format!("handler `{}` cmd `{}`", handler.meta().name, cmd),
1047 );
1048 dispatch?;
1049 }
1050 }
1051
1052 Ok(())
1053}
1054
1055#[cfg(test)]
1060mod meta_cli_tests {
1061 use super::*;
1062 use znippy_common::MetaValue;
1063
1064 #[test]
1070 fn cli_meta_values_are_typed_by_shape_predictably() {
1071 assert_eq!(parse_meta_value("12").unwrap(), MetaValue::I64(12));
1072 assert_eq!(parse_meta_value("-3").unwrap(), MetaValue::I64(-3));
1073 assert_eq!(parse_meta_value("1.5").unwrap(), MetaValue::F64(1.5));
1074 assert_eq!(parse_meta_value("true").unwrap(), MetaValue::Bool(true));
1075 assert_eq!(parse_meta_value("false").unwrap(), MetaValue::Bool(false));
1076 assert_eq!(parse_meta_value("wasi-p2").unwrap(), MetaValue::Str("wasi-p2".into()));
1077 assert_eq!(parse_meta_value("1.0.2").unwrap(), MetaValue::Str("1.0.2".into()));
1078 assert_eq!(parse_meta_value("").unwrap(), MetaValue::Str(String::new()));
1079
1080 let dir = tempfile::tempdir().unwrap();
1083 let f = dir.path().join("m.wasm");
1084 std::fs::write(&f, b"\0asm\x01\0\0\0").unwrap();
1085 assert_eq!(
1086 parse_meta_value(&format!("@{}", f.display())).unwrap(),
1087 MetaValue::Bytes(b"\0asm\x01\0\0\0".to_vec())
1088 );
1089 assert!(parse_meta_value("@/nonexistent/x.wasm").is_err());
1090 }
1091
1092 #[test]
1097 fn absent_meta_flags_are_none_not_an_empty_table() {
1098 assert!(parse_meta_args(&[], &[]).unwrap().is_none(), "no flags must mean NO section");
1099
1100 let t = parse_meta_args(
1101 &["app/x.wasm=build-thing=@/dev/null".into()],
1102 &["producer=znippy".into()],
1103 )
1104 .unwrap()
1105 .expect("flags given → a table");
1106 assert_eq!(t.len(), 2);
1107 assert_eq!(t.rows()[0].path(), Some("app/x.wasm"));
1108 assert_eq!(t.rows()[1].path(), None, "--meta-archive is archive-scoped");
1109
1110 assert!(parse_meta_args(&["nokey".into()], &[]).is_err());
1112 assert!(parse_meta_args(&["path=keyonly".into()], &[]).is_err());
1113 assert!(parse_meta_args(&[], &["novalue".into()]).is_err());
1114 }
1115}
1116
1117#[cfg(all(test, feature = "testmatrix"))]
1118fn fs_test_lock() -> std::sync::MutexGuard<'static, ()> {
1119 use std::sync::{Mutex, OnceLock};
1120 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1121 LOCK.get_or_init(|| Mutex::new(()))
1122 .lock()
1123 .unwrap_or_else(|p| p.into_inner())
1124}
1125
1126#[cfg(all(test, feature = "sign"))]
1127mod sign_tests {
1128 use super::*;
1129 use znippy_common::sign;
1130
1131 #[test]
1134 fn compress_sign_then_verify_signed_round_trip() {
1135 let dir = tempfile::tempdir().unwrap();
1136 let input = dir.path().join("src");
1137 std::fs::create_dir_all(&input).unwrap();
1138 std::fs::write(input.join("a.txt"), b"hello znippy provenance").unwrap();
1139 std::fs::write(input.join("b.txt"), vec![7u8; 4096]).unwrap();
1140
1141 let (ca_key, ca_der) = sign::dev::mint_ca("Znippy CLI Test CA").unwrap();
1143 let signer = sign::dev::new_p256_signer(&ca_key, &ca_der, "cli-signer").unwrap();
1144
1145 let factory = sign_meta_factory(Box::new(signer));
1147 let registry = PluginRegistry::with_plugin(handlers::find_handler("rust").unwrap());
1148 let output = dir.path().join("out");
1149 compress_dir(&input, &output, false, Some(®istry), None, Some(factory)).unwrap();
1150 let archive = output.with_extension("znippy");
1151 assert!(archive.exists());
1152
1153 let ca_path = dir.path().join("ca.der");
1155 std::fs::write(&ca_path, &ca_der).unwrap();
1156 run_signed_verify(&archive, &[ca_path]).unwrap();
1157
1158 let (_other_key, other_der) = sign::dev::mint_ca("Rogue CA").unwrap();
1160 let rogue_path = dir.path().join("rogue.der");
1161 std::fs::write(&rogue_path, &other_der).unwrap();
1162 assert!(run_signed_verify(&archive, &[rogue_path]).is_err());
1163
1164 assert!(run_signed_verify(&archive, &[]).is_err());
1166 }
1167
1168 #[cfg(feature = "testmatrix")]
1172 #[test]
1173 fn build_signer_missing_cert_emits_red_row() {
1174 let _guard = super::fs_test_lock();
1175 let _ = nornir_testmatrix::drain_functional_rows();
1176 let dir = tempfile::tempdir().unwrap();
1177 let key = dir.path().join("k.pkcs8");
1178 std::fs::write(&key, b"not-a-real-key").unwrap();
1179 let out = build_signer(&Some(key), &None, "p256");
1181 assert!(out.is_err(), "missing --sign-cert must be an error");
1182 let rows = nornir_testmatrix::drain_functional_rows();
1183 let red = rows
1184 .iter()
1185 .find(|r| r.suite == "znippy-cli/compress-sign" && r.test_name == "signer_loaded")
1186 .expect("a signer_loaded row was emitted");
1187 assert_eq!(red.status, "fail", "broken signer config is a RED row");
1188 }
1189}
1190
1191#[cfg(all(test, feature = "testmatrix"))]
1195mod functional_status_tests {
1196 use super::*;
1197
1198 fn drained_status(suite: &str, check: &str) -> Option<String> {
1199 nornir_testmatrix::drain_functional_rows()
1200 .into_iter()
1201 .filter(|r| r.suite == suite && r.test_name == check)
1202 .next_back()
1203 .map(|r| r.status)
1204 }
1205
1206 #[test]
1210 fn green_roundtrip_then_red_on_corruption() {
1211 let _guard = super::fs_test_lock();
1212 let dir = tempfile::tempdir().unwrap();
1213 let input = dir.path().join("src");
1214 std::fs::create_dir_all(&input).unwrap();
1215 std::fs::write(input.join("a.txt"), vec![b'a'; 64 * 1024]).unwrap();
1217 std::fs::write(input.join("b.txt"), b"znippy functional status coverage").unwrap();
1218
1219 let registry = PluginRegistry::with_plugin(handlers::find_handler("rust").unwrap());
1220 let output = dir.path().join("out");
1221 let archive = output.with_extension("znippy");
1222
1223 let _ = nornir_testmatrix::drain_functional_rows();
1225 compress_reporting(&input, &output, false, ®istry, None).unwrap();
1226 assert_eq!(
1227 drained_status("znippy-cli/compress", "archive_written").as_deref(),
1228 Some("pass"),
1229 "clean compress records a GREEN row"
1230 );
1231
1232 let _ = nornir_testmatrix::drain_functional_rows();
1234 let vr = verify_reporting(&archive).unwrap();
1235 assert_eq!(vr.corrupt_files, 0, "clean archive has no corrupt files");
1236 assert_eq!(
1237 drained_status("znippy-cli/verify", "integrity_checksum").as_deref(),
1238 Some("pass"),
1239 "clean verify records a GREEN row"
1240 );
1241
1242 let _ = nornir_testmatrix::drain_functional_rows();
1244 let out_clean = dir.path().join("extract_clean");
1245 let filter = znippy_common::IndexFilter { pkg_type: None, repo: None };
1246 decompress_reporting(&archive, &out_clean, &filter, None, None).unwrap();
1247 assert_eq!(
1248 drained_status("znippy-cli/decompress", "reconstruct_verify").as_deref(),
1249 Some("pass"),
1250 "clean decompress records a GREEN row"
1251 );
1252
1253 let mut bytes = std::fs::read(&archive).unwrap();
1255 let flip = 16.min(bytes.len() - 1);
1256 bytes[flip] ^= 0xFF;
1257 std::fs::write(&archive, &bytes).unwrap();
1258
1259 let _ = nornir_testmatrix::drain_functional_rows();
1263 let _ = verify_reporting(&archive);
1264 assert_eq!(
1265 drained_status("znippy-cli/verify", "integrity_checksum").as_deref(),
1266 Some("fail"),
1267 "corrupt verify records a RED row"
1268 );
1269
1270 let _ = nornir_testmatrix::drain_functional_rows();
1272 let out_bad = dir.path().join("extract_bad");
1273 let _ = decompress_reporting(&archive, &out_bad, &filter, None, None);
1274 assert_eq!(
1275 drained_status("znippy-cli/decompress", "reconstruct_verify").as_deref(),
1276 Some("fail"),
1277 "corrupt decompress records a RED row"
1278 );
1279 }
1280}
1281
1282#[cfg(all(test, feature = "sign"))]
1283mod provenance_green_tests {
1284 #[test]
1289 fn zero_verified_artifacts_is_not_a_verified_chain() {
1290 assert!(
1291 !super::provenance_is_verified(0),
1292 "an archive with a valid ROOT signature but zero verified artifacts is not a \
1293 verified provenance chain — this is the hardcoded-true surface the compress \
1294 false-green fix already retired"
1295 );
1296 assert!(super::provenance_is_verified(1), "one verified artifact IS a chain");
1297 assert!(super::provenance_is_verified(9_999));
1298 }
1299}