Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy

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, &registry, 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(&registry), 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, &registry, 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));
    }
}