znippy-cli/src/lib.rs
// znippy-cli/src/main.rs
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
use znippy_common::{VerifyReport, list_archive_contents, verify_archive_integrity};
use znippy_common::plugin::PluginRegistry;
use znippy_common::plugins::wasm_loader::WasmPlugin;
use znippy_compress::compress_dir;
use znippy_decompress::{decompress_archive, decompress_archive_filtered};
pub mod handlers;
/// Short git commit hash the binary was built from, stamped by `build.rs` at
/// build time via **pure `std::fs`** (no shellout — the zero-shell law). `env!`
/// (not `option_env!`) because `build.rs` emits `ZNIPPY_GIT_HASH` on every
/// build; `unknown` off a git checkout.
pub const GIT_HASH: &str = env!("ZNIPPY_GIT_HASH");
/// The canonical version-identity line: `v<CARGO_PKG_VERSION> (<git hash>)`.
/// The ONE source of truth for the `znippy` binary's identity string — printed
/// by `znippy --version` / `-V`. Combined with clap's command name `znippy`,
/// the version flag emits `znippy v<version> (<hash>)`.
pub const VERSION_LINE: &str =
concat!("v", env!("CARGO_PKG_VERSION"), " (", env!("ZNIPPY_GIT_HASH"), ")");
/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps `nornir_testmatrix::functional_status` behind the
/// `testmatrix` feature (a compiled-out `#[inline]` no-op otherwise, with no
/// nornir dep). `component` is the reporting surface (e.g. `"znippy-cli/verify"`),
/// `check` what it verified, `ok` the verdict, `detail` a short human note. The
/// CLI signing/verify/run verbs call this so `nornir test --features testmatrix`
/// SEES each surface's health.
#[inline]
fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(component, check, ok, detail);
#[cfg(not(feature = "testmatrix"))]
{
let _ = (component, check, ok, detail);
}
}
#[derive(Parser)]
#[command(name = "znippy")]
#[command(version = VERSION_LINE)]
#[command(about = "Znippy: fast archive format with per-file compression", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Compress a directory into a .znippy archive
Compress {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
no_skip: bool,
/// Package handler to use: a name or alias (`rust`/`cargo`, `python`,
/// `maven`, …), or `generic` (aliases `none`/`raw`/`plain`/`blob`) for a
/// tree that is not a package registry at all — a container-registry
/// mirror, a build output, a data dump. `generic` extracts no metadata and
/// claims no package type; the default `rust` would record `pkg_type = 1`
/// over such a tree and every `--type rust` consumer would believe it.
/// One archive carries one package type. `znippy handlers` lists them.
#[arg(long, default_value = "rust")]
format: String,
/// Where the metadata index is written: `arrow-ipc` (default — inline in
/// the .znippy container) or `iceberg` (a real Iceberg table in
/// --warehouse; blobs stay in the .znippy sidecar). The iceberg backend
/// requires the CLI to be built with `--features iceberg`.
#[arg(long, default_value = "arrow-ipc")]
meta_format: String,
/// Warehouse directory for `--meta-format iceberg`. Required for, and
/// only used by, the iceberg backend.
#[arg(long)]
warehouse: Option<PathBuf>,
/// Path to a .wasm plugin for metadata extraction (overrides --format).
#[arg(long)]
plugin: Option<PathBuf>,
/// DenseUnion type_id for the WASM plugin given via --plugin.
#[arg(long, default_value_t = 1)]
plugin_type_id: i8,
/// Provenance signing (feature `sign`): PKCS#8 (DER) private key of the
/// signer. Emits detached CMS signatures as reserved manifest sections —
/// the archive stays byte-compatible and the blob/compress path is
/// untouched. Requires `--sign-cert`. Build with `--features sign`.
#[arg(long, value_name = "KEY")]
sign: Option<PathBuf>,
/// X.509 signer certificate (DER) whose public key matches `--sign`.
#[arg(long, value_name = "CERT")]
sign_cert: Option<PathBuf>,
/// Signature algorithm for `--sign`: `p256` (default) or `ed25519`.
#[arg(long, default_value = "p256")]
sign_alg: String,
},
/// Append files from a directory into an existing sealed .znippy archive.
///
/// Reuses every existing blob byte verbatim (no recompression — blobs are
/// content-addressed by blake3) and re-seals the merged lookup + trie +
/// manifest + footer over old+new rows. The grown archive opens with the
/// ordinary reader. This is the CLI front-end for the native blob-append
/// primitive (`znippy_common::append_files`, arrow-ipc idea C).
///
/// A path that is ALREADY in the archive is REPLACED (last writer wins): its
/// old index rows are dropped and its old blob bytes become unreferenced dead
/// payload. Re-running an append over a changed directory is therefore safe —
/// it never leaves two live copies of a path.
Append {
/// Existing sealed .znippy archive to grow.
#[arg(short, long)]
input: PathBuf,
/// Directory whose files are compressed and appended. Relative paths are
/// taken from this root, matching how `compress` derives `relative_path`.
#[arg(short, long)]
add: PathBuf,
/// Codec level for the newly-appended blobs (existing blobs are untouched).
#[arg(short, long, default_value_t = 3)]
level: i32,
/// Attach a searchable metadata fact to an appended entry:
/// `--meta <relative_path>=<key>=<value>` (repeatable). The value is
/// typed by shape — `12` is an i64, `1.5` an f64, `true`/`false` a bool,
/// `@<file>` the raw bytes of that file, anything else a string.
#[arg(long = "meta", value_name = "PATH=KEY=VALUE")]
meta: Vec<String>,
/// Archive-level metadata fact: `--meta-archive <key>=<value>`
/// (repeatable, same value typing as `--meta`).
#[arg(long = "meta-archive", value_name = "KEY=VALUE")]
meta_archive: Vec<String>,
},
/// Search an archive's metadata index **without decompressing anything**.
///
/// Reads the footer, the manifest and the `__znippy_meta__` sub-index only,
/// so the cost is set by the size of the metadata and not by the payload.
///
/// Reports the three outcomes distinctly, because they are three different
/// facts: the archive has NO metadata index (exit 2 — nothing was searched);
/// it has one and nothing matched (exit 1 — searched, found nothing); it has
/// one and these entries matched (exit 0).
Meta {
/// Archive to search.
#[arg(short, long)]
input: PathBuf,
/// Exact key to look for. Omit to list the whole index.
#[arg(short, long)]
key: Option<String>,
/// Key prefix to look for (e.g. `build-thing` matches `build-thing.abi`).
#[arg(short, long)]
prefix: Option<String>,
/// Print only the matching `relative_path`s — the entries a caller would
/// then `znippy get`, one per line, ready to pipe.
#[arg(long)]
paths_only: bool,
},
/// Decompress a .znippy archive
Decompress {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
/// Selective extract: only files of this package type (a handler
/// name/alias, e.g. `maven`/`rust`/`python`). Omit to extract all types.
#[arg(long = "type")]
pkg_type: Option<String>,
/// Selective extract: only files from this repo. Omit to extract all repos.
#[arg(long)]
repo: Option<String>,
},
/// List contents of a .znippy archive
List {
#[arg(short, long)]
input: PathBuf,
},
/// Random-access read of one file by its relative path (O(log n)/O(key) via
/// the lookup sub-index + trie). Writes to --output, or stdout if omitted.
Get {
#[arg(short, long)]
input: PathBuf,
/// Relative path of the file inside the archive.
#[arg(short, long)]
path: String,
/// Destination file. When omitted, the bytes are written to stdout.
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Verify archive integrity (checksum)
Verify {
#[arg(short, long)]
input: PathBuf,
/// Also verify provenance signatures (feature `sign`): recompute every
/// artifact + archive digest from the index and check the detached CMS
/// against the trusted roots. Requires at least one `--root`. Build with
/// `--features sign`.
#[arg(long)]
signed: bool,
/// Trusted root CA certificate (DER) for `--signed`. Repeatable.
#[arg(long, value_name = "CA")]
root: Vec<PathBuf>,
},
/// Seal a dynamic, iceberg-backed archive into a static, immutable native
/// `.znippy` (inline Arrow-IPC sub-indexes + lookup + trie + footer).
///
/// Reads the archive metadata from the skade-iceberg `--warehouse` and
/// writes it as the v0.7 inline container, REUSING the blob bytes already in
/// the `--input` `.znippy` sidecar (no recompress, content-addressed). The
/// sealed artifact opens with the ordinary reader — the warehouse is no
/// longer needed to read it. Requires `--features iceberg`.
Seal {
/// The `.znippy` blob sidecar written when the archive was compressed
/// with `--meta-format iceberg` (pure blobs, no footer).
#[arg(short, long)]
input: PathBuf,
/// The skade-iceberg warehouse holding the archive metadata tables.
#[arg(long)]
warehouse: PathBuf,
/// Iceberg namespace of the archive (its file stem). Defaults to the
/// `--input` file stem, matching how `compress` derives it.
#[arg(long)]
namespace: Option<String>,
/// Destination for the sealed native `.znippy`.
#[arg(short, long)]
output: PathBuf,
},
/// List the available package handlers (the compiled-in register).
Handlers,
/// Run a handler-specific subcommand, e.g. `znippy run rust coords foo.crate`.
Run {
/// Handler name/alias to dispatch to.
format: String,
/// Subcommand advertised by the handler's meta().
cmd: String,
/// Arguments passed to the subcommand.
args: Vec<String>,
},
}
/// Build the metadata-sink factory for `--meta-format` / `--warehouse`.
/// `arrow-ipc` (default) → `None`, so `compress_dir` uses the inline
/// `ArrowIpcSink`. `iceberg` (CLI feature `iceberg`) → a factory that builds an
/// `IcebergSink` over `--warehouse`; the namespace is the archive's file stem.
/// Blobs always stay in the `.znippy` file; only the index location changes.
fn build_meta_sink(
meta_format: &str,
warehouse: Option<PathBuf>,
output: &std::path::Path,
) -> Result<Option<znippy_common::MetaSinkFactory>> {
match meta_format {
"arrow-ipc" => Ok(None),
"iceberg" => {
#[cfg(feature = "iceberg")]
{
let wh = warehouse.ok_or_else(|| {
anyhow::anyhow!("--warehouse <DIR> is required for --meta-format iceberg")
})?;
let namespace = output
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "znippy".to_string());
println!(
"🧊 Metadata → Iceberg table (namespace `{namespace}`) in {}",
wh.display()
);
Ok(Some(Box::new(move |_file, _off| {
Box::new(znippy_iceberg::IcebergSink::new(wh, namespace))
as Box<dyn znippy_common::ArchiveMetaSink>
})))
}
#[cfg(not(feature = "iceberg"))]
{
let _ = (warehouse, output);
anyhow::bail!(
"iceberg metadata backend not compiled in; rebuild znippy-cli with `--features iceberg`"
)
}
}
other => anyhow::bail!("unknown --meta-format '{other}' (expected arrow-ipc|iceberg)"),
}
}
/// Compress `input` → `output`, recording the CLI **compress** surface's
/// functional status: GREEN `archive_written` on success (files/chunks/ratio in
/// the detail), RED with the error text on failure — so `nornir test
/// --features testmatrix` SEES a broken compress as a RED matrix row instead of
/// just a non-zero exit. Threads the ready `compress_dir` untouched; the emit is
/// the only addition and is a compiled-out no-op in the default build.
fn compress_reporting(
input: &PathBuf,
output: &PathBuf,
no_skip: bool,
registry: &PluginRegistry,
sink_factory: Option<znippy_common::MetaSinkFactory>,
) -> Result<znippy_common::CompressionReport> {
match compress_dir(input, output, no_skip, Some(registry), None, sink_factory) {
Ok(report) => {
// GREEN only on a REAL committed result: files enumerated, at least
// one chunk written, and NO file silently dropped after an open/read
// failure. Mirrors the append path, which gates on
// `report.rows_added >= file_count`. Without this a run where every
// file failed to open — or an all-dirs input — would still light
// `archive_written` green over "0 chunks".
let ok = report.total_files > 0
&& report.chunks > 0
&& report.files_failed == 0;
functional_status(
"znippy-cli/compress",
"archive_written",
ok,
&format!(
"{} files ({} failed), {} chunks, {:.2}% ratio → {}",
report.total_files,
report.files_failed,
report.chunks,
report.compression_ratio,
output.display()
),
);
if report.files_failed > 0 {
eprintln!(
"⚠️ {} av {} filer kunde inte läsas och utelämnades ur arkivet",
report.files_failed, report.total_files
);
}
Ok(report)
}
Err(e) => {
functional_status(
"znippy-cli/compress",
"archive_written",
false,
&format!("compress failed: {e}"),
);
Err(e)
}
}
}
/// Verify an archive's integrity, recording the CLI **verify** surface's
/// functional status: RED `integrity_checksum` on a hard read error, GREEN when
/// every file's blake3 checksum reconciled, RED when any file/byte is corrupt.
/// The `format-version-guard` row still confirms the reader accepted the on-disk
/// format version (reaching here at all means the version guard passed). Emits
/// are compiled-out no-ops in the default build.
fn verify_reporting(input: &Path) -> Result<VerifyReport> {
let report = match verify_archive_integrity(input) {
Ok(r) => r,
Err(e) => {
functional_status(
"znippy-cli/verify",
"integrity_checksum",
false,
&format!("verify failed: {e}"),
);
return Err(e);
}
};
// Reaching Ok here means znippy-common's reader accepted the archive's
// recorded on-disk format version (the `check_format_version` guard):
// an unsupported version would have errored out above, never here.
functional_status(
"znippy-cli/format-version-guard",
"on_disk_version_supported",
true,
&format!("reader max v{}", znippy_common::index::ZNIPPY_FORMAT_VERSION),
);
let integrity_ok = report.corrupt_files == 0 && report.corrupt_bytes == 0;
functional_status(
"znippy-cli/verify",
"integrity_checksum",
integrity_ok,
&format!(
"{} verified, {} corrupt files",
report.verified_files, report.corrupt_files
),
);
Ok(report)
}
/// Decompress `input` → `output` (optionally filtered), recording the CLI
/// **decompress** surface's functional status: RED `reconstruct_verify` on a
/// hard read error, GREEN when every reconstructed file's checksum reconciled,
/// RED when any file/byte is corrupt. Returns the report so the caller can print
/// it and decide the exit code; the RED row is emitted even on the corrupt path.
fn decompress_reporting(
input: &PathBuf,
output: &PathBuf,
filter: &znippy_common::IndexFilter,
pkg_type: Option<&str>,
repo: Option<&str>,
) -> Result<VerifyReport> {
let result = if filter.is_empty() {
decompress_archive(input, output)
} else {
println!(
"🔎 Selective extract: type={} repo={}",
pkg_type.unwrap_or("*"),
repo.unwrap_or("*"),
);
decompress_archive_filtered(input, output, filter)
};
let report = match result {
Ok(r) => r,
Err(e) => {
functional_status(
"znippy-cli/decompress",
"reconstruct_verify",
false,
&format!("decompress failed: {e}"),
);
return Err(e);
}
};
let integrity_ok = report.corrupt_files == 0 && report.corrupt_bytes == 0;
functional_status(
"znippy-cli/decompress",
"reconstruct_verify",
integrity_ok,
&format!(
"{} verified, {} corrupt files, {} corrupt bytes",
report.verified_files, report.corrupt_files, report.corrupt_bytes
),
);
Ok(report)
}
/// Build a boxed provenance signer from the `--sign` / `--sign-cert` / `--sign-alg`
/// flags, reading the PKCS#8 key + DER cert from disk and handing the bytes to the
/// ready `znippy-common` loader. `Ok(None)` when `--sign` is absent. On a load
/// failure (missing cert, bad key/alg) records a RED `signer_loaded` row for the
/// CLI **sign** surface before propagating — the GREEN counterpart is emitted at
/// the call site once the signer is armed.
#[cfg(feature = "sign")]
fn build_signer(
sign: &Option<PathBuf>,
sign_cert: &Option<PathBuf>,
sign_alg: &str,
) -> Result<Option<Box<dyn znippy_common::sign::ArchiveSigner + Send>>> {
let Some(key_path) = sign else { return Ok(None) };
let load = (|| -> Result<Box<dyn znippy_common::sign::ArchiveSigner + Send>> {
let cert_path = sign_cert.as_ref().ok_or_else(|| {
anyhow::anyhow!("--sign requires --sign-cert <CERT> (DER signer certificate)")
})?;
let alg = znippy_common::sign::SigAlg::from_name(sign_alg)?;
let key = std::fs::read(key_path)?;
let cert = std::fs::read(cert_path)?;
Ok(znippy_common::sign::signer_from_pkcs8(alg, &key, &cert)?)
})();
match load {
Ok(signer) => Ok(Some(signer)),
Err(e) => {
functional_status(
"znippy-cli/compress-sign",
"signer_loaded",
false,
&format!("signer load failed ({sign_alg}): {e}"),
);
Err(e)
}
}
}
/// Wrap a signer in the inline Arrow-IPC sink factory so [`compress_dir`] seals a
/// signed archive (detached CMS in reserved sections; blob/compress path
/// unchanged). The hot path never sees the signer — it runs at `finish()`.
#[cfg(feature = "sign")]
fn sign_meta_factory(
signer: Box<dyn znippy_common::sign::ArchiveSigner + Send>,
) -> znippy_common::MetaSinkFactory {
Box::new(move |file, off| {
Box::new(znippy_common::ArrowIpcSink::new(file, off).with_signer(signer))
as Box<dyn znippy_common::ArchiveMetaSink>
})
}
/// Verify an archive's provenance: load the DER roots, chain + check every
/// detached CMS via the ready `verify_archive`, and print the report. Records the
/// CLI **verify --signed** surface's functional status: GREEN `provenance_chain`
/// when the CMS chains to a trusted root, RED (with the reason) on any failure —
/// no roots, an unreadable CA, or a chain that doesn't verify. So a tampered or
/// wrongly-rooted archive shows up as a RED matrix row, not a bare exit code.
#[cfg(feature = "sign")]
fn run_signed_verify(input: &std::path::Path, roots: &[PathBuf]) -> Result<()> {
match run_signed_verify_inner(input, roots) {
Ok(()) => Ok(()),
Err(e) => {
functional_status(
"znippy-cli/verify-signed",
"provenance_chain",
false,
&format!("provenance verify failed: {e}"),
);
Err(e)
}
}
}
/// Inner provenance verify: does the work and emits the GREEN `provenance_chain`
/// row on success. [`run_signed_verify`] wraps it to turn any error into the RED
/// counterpart before propagating.
/// **Is a provenance chain genuinely verified?** The archive-root signature is a real
/// cryptographic gate and `verify_archive` errors out if it does not chain to a trusted
/// root — but it says nothing about any individual artifact. `verify_archive` walks
/// `sigs.artifacts`, and an EMPTY map never enters the loop: no error, and the count
/// stays 0. Two states produce that, and neither is a verified chain — an archive sealed
/// over zero files, and an archive whose `__znippy_sign_artifacts__` section is absent
/// (`read_archive_signatures` turns a missing section into an empty map).
///
/// Kept as a pure function on purpose: the decision has to be assertable on its own, or
/// the guard is a local `bool` nobody can drive red. Same lesson as the compress
/// false-green — a surface that reports success over work that did not happen.
#[cfg(feature = "sign")]
pub fn provenance_is_verified(artifacts_verified: usize) -> bool {
artifacts_verified > 0
}
#[cfg(feature = "sign")]
fn run_signed_verify_inner(input: &std::path::Path, roots: &[PathBuf]) -> Result<()> {
anyhow::ensure!(
!roots.is_empty(),
"--signed requires at least one --root <CA> (DER trusted root)"
);
let ders: Vec<Vec<u8>> = roots.iter().map(std::fs::read).collect::<std::io::Result<_>>()?;
let store = znippy_common::sign::CertStore::from_der_certs(&ders)?;
let report = znippy_common::sign::verify_archive(input, &store)?;
println!("\n🔏 Provenans verifierad:");
println!("✍️ Signerad av (CN): {}", report.signer.id.common_name);
println!("🪪 Subjekt: {}", report.signer.id.subject);
// The fingerprint is the identity a trust policy is written against — the
// subject is only a label, and a CA may issue it twice.
println!("🔑 Fingeravtryck (SHA-256): {}", report.signer.id.fingerprint_hex());
println!("📦 Verifierade artefakter: {}", report.artifacts_verified);
// GREEN only on a REAL verified result. The archive-root signature above is a
// genuine cryptographic gate — reaching here means it chained to a trusted root
// — but it says nothing about any individual artifact. `verify_archive` walks
// `sigs.artifacts` and an EMPTY map simply never enters the loop: no error, and
// `artifacts_verified` stays 0. Two things produce that, and neither is a
// verified provenance chain: an archive sealed over zero files, and an archive
// whose `__znippy_sign_artifacts__` section is absent (which
// `read_archive_signatures` turns into an empty map). Same shape as the compress
// false-green fixed in "gate green on the REAL committed result": a surface that
// reports success over work that did not happen.
let ok = provenance_is_verified(report.artifacts_verified);
if !ok {
eprintln!(
"⚠️ arkivsignaturen kedjar till en betrodd rot, men NOLL artefakter \
verifierades — arkivet är antingen förseglat utan filer eller saknar \
sin per-artefakt-sektion"
);
}
functional_status(
"znippy-cli/verify-signed",
"provenance_chain",
ok,
&format!(
"CMS chained to root; signer={}, fp={}, artifacts={}",
report.signer.id.common_name,
report.signer.id.fingerprint_hex(),
report.artifacts_verified
),
);
Ok(())
}
/// Recursively collect every regular file under `dir` into `(relative_path,
/// bytes)` pairs, deriving `relative_path` from `root` exactly as the compress
/// path does (`path.strip_prefix(root).to_string_lossy()`, `slot_packer.rs`), so
/// an appended tree lands under the same keys a fresh `compress` would produce.
fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<()> {
let mut entries: Vec<_> = std::fs::read_dir(dir)?
.collect::<std::io::Result<Vec<_>>>()?;
// Stable, reproducible ordering independent of the filesystem's readdir order.
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let ft = entry.file_type()?;
if ft.is_dir() {
collect_files(root, &path, out)?;
} else if ft.is_file() {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let bytes = std::fs::read(&path)?;
out.push((rel, bytes));
}
}
Ok(())
}
/// Parse `--meta PATH=KEY=VALUE` / `--meta-archive KEY=VALUE` into a table.
///
/// Returns `None` when the caller gave neither, which is NOT the same as an
/// empty table: `None` leaves the archive's metadata exactly as it was (possibly
/// absent), while an empty table would seal a present-but-empty index. The
/// distinction is the whole point of the feature, so it is preserved right at
/// the CLI boundary rather than flattened here.
fn parse_meta_args(
entry_args: &[String],
archive_args: &[String],
) -> Result<Option<znippy_common::MetaTable>> {
if entry_args.is_empty() && archive_args.is_empty() {
return Ok(None);
}
let mut table = znippy_common::MetaTable::new();
for raw in entry_args {
let (path, rest) = raw
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--meta {raw:?}: expected PATH=KEY=VALUE"))?;
let (key, value) = rest
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--meta {raw:?}: expected PATH=KEY=VALUE"))?;
table.insert(path, key, parse_meta_value(value)?);
}
for raw in archive_args {
let (key, value) = raw
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("--meta-archive {raw:?}: expected KEY=VALUE"))?;
table.insert_archive(key, parse_meta_value(value)?);
}
Ok(Some(table))
}
/// Type a CLI-supplied value by shape: `@file` → the file's raw bytes,
/// `true`/`false` → bool, an integer → i64, a decimal → f64, else a string.
/// Deliberately narrow and documented rather than clever — the typed columns
/// exist so a consumer can rely on the type, so guessing must be predictable.
fn parse_meta_value(raw: &str) -> Result<znippy_common::MetaValue> {
use znippy_common::MetaValue;
if let Some(file) = raw.strip_prefix('@') {
let bytes = std::fs::read(file)
.map_err(|e| anyhow::anyhow!("--meta value @{file}: {e}"))?;
return Ok(MetaValue::Bytes(bytes));
}
Ok(match raw {
"true" => MetaValue::Bool(true),
"false" => MetaValue::Bool(false),
_ => {
if let Ok(i) = raw.parse::<i64>() {
MetaValue::I64(i)
} else if let Ok(f) = raw.parse::<f64>() {
MetaValue::F64(f)
} else {
MetaValue::Str(raw.to_string())
}
}
})
}
/// `znippy meta` — search the metadata index without decompressing anything.
///
/// Exit codes carry the distinction the API is built around, so a shell script
/// gets it too:
/// 0 — there is an index and these entries matched
/// 1 — there is an index and nothing matched ("searched, found nothing")
/// 2 — there is NO index ("nothing was searched"), which is not the same thing
fn run_meta_search(
input: &Path,
key: Option<&str>,
prefix: Option<&str>,
paths_only: bool,
) -> Result<()> {
use std::io::Write;
use znippy_common::{ArchiveMeta, MetaValue, read_archive_meta};
let meta = read_archive_meta(input)?;
let index = match &meta {
ArchiveMeta::NoMetadata => {
if !paths_only {
eprintln!(
"ℹ️ {} carries NO metadata index — nothing was searched. \
(That is not the same as searching and finding nothing.)",
input.display()
);
}
functional_status(
"znippy-cli/meta",
"no_metadata_reported_distinctly",
true,
"archive has no __znippy_meta__ section; reported as NoMetadata, exit 2",
);
std::io::stdout().flush().ok();
std::process::exit(2);
}
ArchiveMeta::Index(i) => i,
};
let hits: &[znippy_common::MetaEntry] = match (key, prefix) {
(Some(k), _) => index.find_by_key(k),
(None, Some(p)) => index.find_by_prefix(p),
(None, None) => index.find_by_prefix(""),
};
if paths_only {
for h in hits {
if let Some(p) = h.path() {
println!("{p}");
}
}
} else {
println!(
"🔎 {} — metadata index: {} rows, {} distinct keys",
input.display(),
index.len(),
index.keys().len()
);
if hits.is_empty() {
println!(" (searched — no row matches)");
}
for h in hits {
let scope = h.path().unwrap_or("<archive>");
let shown = match &h.value {
MetaValue::Str(v) => format!("{v:?}"),
MetaValue::I64(v) => v.to_string(),
MetaValue::F64(v) => v.to_string(),
MetaValue::Bool(v) => v.to_string(),
MetaValue::Bytes(b) => format!("<{} bytes>", b.len()),
};
println!(" {scope} {} = {shown}", h.key);
}
}
functional_status(
"znippy-cli/meta",
"index_searched_without_payload_read",
true,
&format!("{} rows in index, {} hits", index.len(), hits.len()),
);
std::io::stdout().flush().ok();
if hits.is_empty() {
std::process::exit(1);
}
Ok(())
}
pub fn run() -> Result<()> {
env_logger::init();
let cli = Cli::parse();
match cli.command {
Commands::Compress {
input,
output,
no_skip,
format,
meta_format,
warehouse,
plugin,
plugin_type_id,
sign,
sign_cert,
sign_alg,
} => {
let registry = match plugin {
Some(wasm_path) => {
let wp = WasmPlugin::load(&wasm_path.to_string_lossy(), "wasm-plugin", plugin_type_id)?;
PluginRegistry::with_plugin(Box::new(wp))
}
None => {
let handler = handlers::find_handler(&format)?;
println!("🔌 Handler: {} (type_id {})", handler.meta().name, handler.type_id());
PluginRegistry::with_plugin(handler)
}
};
// `mut` is only exercised under feature `sign` (the signing rebind).
#[allow(unused_mut)]
let mut sink_factory = build_meta_sink(&meta_format, warehouse, &output)?;
// Provenance signing rides in via the metadata sink (the `finish()`
// tail), never the blob/compress hot path.
#[cfg(feature = "sign")]
{
if let Some(signer) = build_signer(&sign, &sign_cert, &sign_alg)? {
anyhow::ensure!(
sink_factory.is_none(),
"--sign is only supported with --meta-format arrow-ipc"
);
println!("🔏 Signering aktiverad ({sign_alg})");
sink_factory = Some(sign_meta_factory(signer));
functional_status(
"znippy-cli/compress-sign",
"signer_loaded",
true,
&format!("detached CMS provenance armed ({sign_alg})"),
);
}
}
#[cfg(not(feature = "sign"))]
{
let _ = &sign_alg;
anyhow::ensure!(
sign.is_none() && sign_cert.is_none(),
"signing not compiled in; rebuild znippy-cli with `--features sign`"
);
}
let report = compress_reporting(&input, &output, no_skip, ®istry, sink_factory)?;
if report.files_failed == 0 {
println!("\n✅ Komprimering klar:");
} else {
println!("\n⚠️ Komprimering klar med fel:");
}
println!("📁 Totalt antal filer: {}", report.total_files);
println!("📁 Totalt antal chunks: {}", report.chunks);
println!("❌ Filer som misslyckades: {}", report.files_failed);
println!("📂 Totalt antal kataloger: {}", report.total_dirs);
println!("📦 Filer komprimerade: {}", report.compressed_files);
println!(
"📄 Filer ej komprimerade: {}",
report.uncompressed_files
);
println!("📥 Totalt inlästa bytes: {}", report.total_bytes_in);
println!("📤 Totalt skrivna bytes: {}", report.total_bytes_out);
println!("📉 Bytes som komprimerades: {}", report.compressed_bytes);
println!(
"📃 Bytes ej komprimerade: {}",
report.uncompressed_bytes
);
println!(
"📊 Komprimeringsgrad: {:.2}%",
report.compression_ratio
);
}
Commands::Append { input, add, level, meta, meta_archive } => {
let mut files = Vec::new();
collect_files(&add, &add, &mut files)?;
let file_count = files.len();
anyhow::ensure!(
file_count > 0,
"inga filer att lägga till hittades under {}",
add.display()
);
// `None` when the caller said nothing about metadata — which leaves
// the archive's existing index exactly as it was, including having
// none. Only an explicit --meta/--meta-archive creates or grows one.
let meta_table = parse_meta_args(&meta, &meta_archive)?;
let meta_rows = meta_table.as_ref().map_or(0, |t| t.len());
let report =
znippy_common::append_files_with_meta(&input, &files, level, meta_table)?;
println!("\n✅ Append klar:");
println!("📦 Arkiv: {}", input.display());
println!("📁 Filer tillagda: {}", file_count);
println!("➕ Nya rader: {}", report.rows_added);
println!("📊 Rader innan: {}", report.rows_before);
println!("♻️ Ersatta rader: {}", report.rows_replaced);
println!("📍 Blob-append-offset: {}", report.blob_append_offset);
println!("📤 Nya blob-bytes: {}", report.blob_bytes_added);
println!("💾 Slutlig arkivstorlek: {}", report.sealed_total_bytes);
if meta_rows > 0 {
println!("🔎 Metadata-rader tillagda: {meta_rows}");
}
functional_status(
"znippy-cli/append",
"native_append",
report.rows_added >= file_count as u64,
&format!(
"appended {file_count} files ({} new rows) into {}",
report.rows_added,
input.display()
),
);
}
Commands::Decompress { input, output, pkg_type, repo } => {
let filter = znippy_common::IndexFilter {
pkg_type: match &pkg_type {
Some(name) => Some(handlers::find_handler(name)?.type_id()),
None => None,
},
repo: repo.clone(),
};
let report: VerifyReport = decompress_reporting(
&input,
&output,
&filter,
pkg_type.as_deref(),
repo.as_deref(),
)?;
println!("\n✅ Dekomprimering och verifiering klar:");
println!("📁 Totala filer: {}", report.total_files);
println!("🔐 Verifierade filer: {}", report.verified_files);
println!("📥 chunks: {}", report.chunks);
println!("❌ Korrupta filer: {}", report.corrupt_files);
println!("📥 Totala bytes: {}", report.total_bytes);
println!("📤 Verifierade bytes: {}", report.verified_bytes);
println!("⚠️ Korrupta bytes: {}", report.corrupt_bytes);
// Never present a corrupt/incomplete extraction as success: fail with
// a non-zero exit so callers don't trust the output as good.
if report.corrupt_files > 0 || report.corrupt_bytes > 0 {
anyhow::bail!(
"dekomprimering misslyckades: {} korrupta filer, {} korrupta bytes — utdata är ofullständig/otillförlitlig",
report.corrupt_files,
report.corrupt_bytes
);
}
}
Commands::List { input } => {
list_archive_contents(&input)?;
}
Commands::Meta { input, key, prefix, paths_only } => {
return run_meta_search(&input, key.as_deref(), prefix.as_deref(), paths_only);
}
Commands::Get { input, path, output } => {
// Route through the cached ArchiveReader (arrow-ipc idea B): open the
// manifest + lookup + trie once, then serve the file from the held-open
// handle. For a single Get this matches get_file; the point is that this
// is now the canonical selective-restore path callers reuse across files.
let reader = znippy_common::ArchiveReader::open(&input)?;
let data = reader.read_file(&path)?;
match output {
Some(dest) => {
std::fs::write(&dest, &data)?;
eprintln!("📤 {} ({} bytes) → {}", path, data.len(), dest.display());
}
None => {
use std::io::Write;
std::io::stdout().write_all(&data)?;
}
}
}
Commands::Verify { input, signed, root } => {
let report: VerifyReport = verify_reporting(&input)?;
println!("\n🔍 Verifiering klar:");
println!("📁 Totala filer: {}", report.total_files);
println!("🔐 Verifierade filer: {}", report.verified_files);
println!("❌ Korrupta filer: {}", report.corrupt_files);
println!("📥 Totala bytes: {}", report.total_bytes);
println!("📤 Verifierade bytes: {}", report.verified_bytes);
println!("⚠️ Korrupta bytes: {}", report.corrupt_bytes);
// `verify` is documented as a CI integrity gate (`znippy verify -i A &&
// ship`), and the exit status is the only machine-readable signal such a
// gate consumes — the Swedish stdout text is not. Reporting corruption on
// stdout and still returning 0 makes the gate pass on a corrupt archive.
// Mirror the Decompress arm and fail with a non-zero exit.
if report.corrupt_files > 0 || report.corrupt_bytes > 0 {
anyhow::bail!(
"verifiering misslyckades: {} korrupta filer, {} korrupta bytes — arkivet är skadat",
report.corrupt_files,
report.corrupt_bytes
);
}
if signed {
#[cfg(feature = "sign")]
run_signed_verify(&input, &root)?;
#[cfg(not(feature = "sign"))]
{
let _ = &root;
anyhow::bail!(
"signature verification not compiled in; rebuild znippy-cli with `--features sign`"
);
}
}
}
Commands::Seal { input, warehouse, namespace, output } => {
#[cfg(feature = "iceberg")]
{
let ns = namespace.unwrap_or_else(|| {
input
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "znippy".to_string())
});
println!(
"🧊→📦 Sealing iceberg archive (namespace `{ns}`) in {} → {}",
warehouse.display(),
output.display()
);
let report = znippy_iceberg::seal(&input, &warehouse, &ns, &output)?;
println!("\n✅ Sealed (static native .znippy):");
println!("📁 Filer: {}", report.files);
println!("🧱 Chunk-rader: {}", report.rows);
println!(
"📤 Blob-bytes återanvända: {} (ingen omkomprimering)",
report.blob_bytes_copied
);
println!("📦 Sealad total storlek: {}", report.sealed_total_bytes);
println!(
"📊 Metadata-svans + footer: {} bytes",
report.sealed_total_bytes - report.blob_bytes_copied
);
}
#[cfg(not(feature = "iceberg"))]
{
let _ = (input, warehouse, namespace, output);
anyhow::bail!(
"iceberg backend not compiled in; rebuild znippy-cli with `--features iceberg`"
);
}
}
Commands::Handlers => {
handlers::print_catalog();
}
Commands::Run { format, cmd, args } => {
let handler = handlers::find_handler(&format)?;
let dispatch = handler.run_command(&cmd, &args);
functional_status(
"znippy-cli/run-dispatch",
"handler_command",
dispatch.is_ok(),
&format!("handler `{}` cmd `{}`", handler.meta().name, cmd),
);
dispatch?;
}
}
Ok(())
}
/// Test-only serialization lock for the process-global functional-status buffer.
/// Every test that DRAINS `nornir_testmatrix::drain_functional_rows()` takes it
/// first so a concurrent drain in another test can't steal its rows (cargo runs
/// tests in parallel; the buffer is one process-global).
#[cfg(test)]
mod meta_cli_tests {
use super::*;
use znippy_common::MetaValue;
/// The CLI's value typing is a GUESS, so it has to be a predictable one —
/// a consumer that relies on `size` being an i64 must get an i64. Inject each
/// shape, assert the exact variant, including the two that are easy to get
/// wrong: a bare `1` must not become an f64, and a version-looking `1.0.2`
/// must stay a string rather than half-parse.
#[test]
fn cli_meta_values_are_typed_by_shape_predictably() {
assert_eq!(parse_meta_value("12").unwrap(), MetaValue::I64(12));
assert_eq!(parse_meta_value("-3").unwrap(), MetaValue::I64(-3));
assert_eq!(parse_meta_value("1.5").unwrap(), MetaValue::F64(1.5));
assert_eq!(parse_meta_value("true").unwrap(), MetaValue::Bool(true));
assert_eq!(parse_meta_value("false").unwrap(), MetaValue::Bool(false));
assert_eq!(parse_meta_value("wasi-p2").unwrap(), MetaValue::Str("wasi-p2".into()));
assert_eq!(parse_meta_value("1.0.2").unwrap(), MetaValue::Str("1.0.2".into()));
assert_eq!(parse_meta_value("").unwrap(), MetaValue::Str(String::new()));
// `@file` reads real bytes, and a missing file is an error rather than a
// silent empty value — an empty build-thing would be worse than none.
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("m.wasm");
std::fs::write(&f, b"\0asm\x01\0\0\0").unwrap();
assert_eq!(
parse_meta_value(&format!("@{}", f.display())).unwrap(),
MetaValue::Bytes(b"\0asm\x01\0\0\0".to_vec())
);
assert!(parse_meta_value("@/nonexistent/x.wasm").is_err());
}
/// NO metadata flags must yield `None`, not an empty table: `None` leaves an
/// archive's index untouched, an empty table would seal a present-but-empty
/// one. Flattening those here would reintroduce the exact conflation the
/// whole feature is shaped to prevent.
#[test]
fn absent_meta_flags_are_none_not_an_empty_table() {
assert!(parse_meta_args(&[], &[]).unwrap().is_none(), "no flags must mean NO section");
let t = parse_meta_args(
&["app/x.wasm=build-thing=@/dev/null".into()],
&["producer=znippy".into()],
)
.unwrap()
.expect("flags given → a table");
assert_eq!(t.len(), 2);
assert_eq!(t.rows()[0].path(), Some("app/x.wasm"));
assert_eq!(t.rows()[1].path(), None, "--meta-archive is archive-scoped");
// A malformed pair is refused rather than half-applied.
assert!(parse_meta_args(&["nokey".into()], &[]).is_err());
assert!(parse_meta_args(&["path=keyonly".into()], &[]).is_err());
assert!(parse_meta_args(&[], &["novalue".into()]).is_err());
}
}
#[cfg(all(test, feature = "testmatrix"))]
fn fs_test_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
#[cfg(all(test, feature = "sign"))]
mod sign_tests {
use super::*;
use znippy_common::sign;
/// End-to-end CLI wiring: `compress --sign` (via the sink factory) seals a
/// signed archive, then `verify --signed` chains + checks every detached CMS.
#[test]
fn compress_sign_then_verify_signed_round_trip() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("src");
std::fs::create_dir_all(&input).unwrap();
std::fs::write(input.join("a.txt"), b"hello znippy provenance").unwrap();
std::fs::write(input.join("b.txt"), vec![7u8; 4096]).unwrap();
// One dev CA + a P-256 signer (the ready lib bootstrap path).
let (ca_key, ca_der) = sign::dev::mint_ca("Znippy CLI Test CA").unwrap();
let signer = sign::dev::new_p256_signer(&ca_key, &ca_der, "cli-signer").unwrap();
// Drive the SAME factory the Compress arm builds.
let factory = sign_meta_factory(Box::new(signer));
let registry = PluginRegistry::with_plugin(handlers::find_handler("rust").unwrap());
let output = dir.path().join("out");
compress_dir(&input, &output, false, Some(®istry), None, Some(factory)).unwrap();
let archive = output.with_extension("znippy");
assert!(archive.exists());
// The CLI verify helper, against a DER root written to disk.
let ca_path = dir.path().join("ca.der");
std::fs::write(&ca_path, &ca_der).unwrap();
run_signed_verify(&archive, &[ca_path]).unwrap();
// A wrong root must fail to chain.
let (_other_key, other_der) = sign::dev::mint_ca("Rogue CA").unwrap();
let rogue_path = dir.path().join("rogue.der");
std::fs::write(&rogue_path, &other_der).unwrap();
assert!(run_signed_verify(&archive, &[rogue_path]).is_err());
// `--signed` with no roots is a clear error, not a silent pass.
assert!(run_signed_verify(&archive, &[]).is_err());
}
/// The signing surface RED path: a signer that can't load (missing
/// `--sign-cert`) must both error AND record a RED `signer_loaded` row, so
/// `nornir test` sees the broken signing config as a RED matrix row.
#[cfg(feature = "testmatrix")]
#[test]
fn build_signer_missing_cert_emits_red_row() {
let _guard = super::fs_test_lock();
let _ = nornir_testmatrix::drain_functional_rows();
let dir = tempfile::tempdir().unwrap();
let key = dir.path().join("k.pkcs8");
std::fs::write(&key, b"not-a-real-key").unwrap();
// --sign given, --sign-cert omitted → build_signer must fail + emit RED.
let out = build_signer(&Some(key), &None, "p256");
assert!(out.is_err(), "missing --sign-cert must be an error");
let rows = nornir_testmatrix::drain_functional_rows();
let red = rows
.iter()
.find(|r| r.suite == "znippy-cli/compress-sign" && r.test_name == "signer_loaded")
.expect("a signer_loaded row was emitted");
assert_eq!(red.status, "fail", "broken signer config is a RED row");
}
}
/// Red-when-broken coverage for the CLI compress / decompress / verify surfaces:
/// a clean round-trip records GREEN functional rows, a corrupted archive records
/// RED ones. Gated on `testmatrix` so the rows are actually recorded/drained.
#[cfg(all(test, feature = "testmatrix"))]
mod functional_status_tests {
use super::*;
fn drained_status(suite: &str, check: &str) -> Option<String> {
nornir_testmatrix::drain_functional_rows()
.into_iter()
.filter(|r| r.suite == suite && r.test_name == check)
.next_back()
.map(|r| r.status)
}
/// Whole compress → verify → decompress round trip is GREEN; corrupting a blob
/// byte flips verify + decompress to RED. One serial test (the functional
/// buffer is process-global): each step drains right after it emits.
#[test]
fn green_roundtrip_then_red_on_corruption() {
let _guard = super::fs_test_lock();
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("src");
std::fs::create_dir_all(&input).unwrap();
// Plenty of highly-compressible bytes so a blob actually exists to corrupt.
std::fs::write(input.join("a.txt"), vec![b'a'; 64 * 1024]).unwrap();
std::fs::write(input.join("b.txt"), b"znippy functional status coverage").unwrap();
let registry = PluginRegistry::with_plugin(handlers::find_handler("rust").unwrap());
let output = dir.path().join("out");
let archive = output.with_extension("znippy");
// ── compress: GREEN archive_written ──
let _ = nornir_testmatrix::drain_functional_rows();
compress_reporting(&input, &output, false, ®istry, None).unwrap();
assert_eq!(
drained_status("znippy-cli/compress", "archive_written").as_deref(),
Some("pass"),
"clean compress records a GREEN row"
);
// ── verify (clean): GREEN integrity_checksum ──
let _ = nornir_testmatrix::drain_functional_rows();
let vr = verify_reporting(&archive).unwrap();
assert_eq!(vr.corrupt_files, 0, "clean archive has no corrupt files");
assert_eq!(
drained_status("znippy-cli/verify", "integrity_checksum").as_deref(),
Some("pass"),
"clean verify records a GREEN row"
);
// ── decompress (clean): GREEN reconstruct_verify ──
let _ = nornir_testmatrix::drain_functional_rows();
let out_clean = dir.path().join("extract_clean");
let filter = znippy_common::IndexFilter { pkg_type: None, repo: None };
decompress_reporting(&archive, &out_clean, &filter, None, None).unwrap();
assert_eq!(
drained_status("znippy-cli/decompress", "reconstruct_verify").as_deref(),
Some("pass"),
"clean decompress records a GREEN row"
);
// ── corrupt a blob byte (blobs live at the front; index/footer at the end) ──
let mut bytes = std::fs::read(&archive).unwrap();
let flip = 16.min(bytes.len() - 1);
bytes[flip] ^= 0xFF;
std::fs::write(&archive, &bytes).unwrap();
// ── verify (corrupt): RED integrity_checksum ──
// The flipped blob byte surfaces EITHER as a corrupt-checksum report OR as
// a hard codec/read error — both must record a RED row (that's the point).
let _ = nornir_testmatrix::drain_functional_rows();
let _ = verify_reporting(&archive);
assert_eq!(
drained_status("znippy-cli/verify", "integrity_checksum").as_deref(),
Some("fail"),
"corrupt verify records a RED row"
);
// ── decompress (corrupt): RED reconstruct_verify (bails, but emits first) ──
let _ = nornir_testmatrix::drain_functional_rows();
let out_bad = dir.path().join("extract_bad");
let _ = decompress_reporting(&archive, &out_bad, &filter, None, None);
assert_eq!(
drained_status("znippy-cli/decompress", "reconstruct_verify").as_deref(),
Some("fail"),
"corrupt decompress records a RED row"
);
}
}
#[cfg(all(test, feature = "sign"))]
mod provenance_green_tests {
/// RED against the pre-fix surface, which passed a hardcoded `true`: an archive
/// whose root signature chains to a trusted root but which carries ZERO verified
/// artifacts must NOT light `provenance_chain` green. Drive the decision itself —
/// a `bool` computed inline in the reporting arm cannot be driven at all.
#[test]
fn zero_verified_artifacts_is_not_a_verified_chain() {
assert!(
!super::provenance_is_verified(0),
"an archive with a valid ROOT signature but zero verified artifacts is not a \
verified provenance chain — this is the hardcoded-true surface the compress \
false-green fix already retired"
);
assert!(super::provenance_is_verified(1), "one verified artifact IS a chain");
assert!(super::provenance_is_verified(9_999));
}
}