Skip to main content

znippy_cli/
lib.rs

1// znippy-cli/src/main.rs
2
3use anyhow::Result;
4use clap::{Parser, Subcommand};
5use std::path::{Path, PathBuf};
6
7use znippy_common::{VerifyReport, list_archive_contents, verify_archive_integrity};
8use znippy_common::plugin::PluginRegistry;
9use znippy_common::plugins::wasm_loader::WasmPlugin;
10use znippy_compress::compress_dir;
11use znippy_decompress::{decompress_archive, decompress_archive_filtered};
12
13pub mod handlers;
14
15/// Short git commit hash the binary was built from, stamped by `build.rs` at
16/// build time via **pure `std::fs`** (no shellout — the zero-shell law). `env!`
17/// (not `option_env!`) because `build.rs` emits `ZNIPPY_GIT_HASH` on every
18/// build; `unknown` off a git checkout.
19pub const GIT_HASH: &str = env!("ZNIPPY_GIT_HASH");
20
21/// The canonical version-identity line: `v<CARGO_PKG_VERSION> (<git hash>)`.
22/// The ONE source of truth for the `znippy` binary's identity string — printed
23/// by `znippy --version` / `-V`. Combined with clap's command name `znippy`,
24/// the version flag emits `znippy v<version> (<hash>)`.
25pub const VERSION_LINE: &str =
26    concat!("v", env!("CARGO_PKG_VERSION"), " (", env!("ZNIPPY_GIT_HASH"), ")");
27
28/// **Introspection / emit marker** — record one functional-status row for the
29/// nornir test matrix. Wraps `nornir_testmatrix::functional_status` behind the
30/// `testmatrix` feature (a compiled-out `#[inline]` no-op otherwise, with no
31/// nornir dep). `component` is the reporting surface (e.g. `"znippy-cli/verify"`),
32/// `check` what it verified, `ok` the verdict, `detail` a short human note. The
33/// CLI signing/verify/run verbs call this so `nornir test --features testmatrix`
34/// SEES each surface's health.
35#[inline]
36fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
37    #[cfg(feature = "testmatrix")]
38    nornir_testmatrix::functional_status(component, check, ok, detail);
39    #[cfg(not(feature = "testmatrix"))]
40    {
41        let _ = (component, check, ok, detail);
42    }
43}
44
45#[derive(Parser)]
46#[command(name = "znippy")]
47#[command(version = VERSION_LINE)]
48#[command(about = "Znippy: fast archive format with per-file compression", long_about = None)]
49struct Cli {
50    #[command(subcommand)]
51    command: Commands,
52}
53
54#[derive(Subcommand)]
55enum Commands {
56    /// Compress a directory into a .znippy archive
57    Compress {
58        #[arg(short, long)]
59        input: PathBuf,
60
61        #[arg(short, long)]
62        output: PathBuf,
63
64        #[arg(long)]
65        no_skip: bool,
66
67        /// Package handler to use: a name or alias (`rust`/`cargo`, `python`, `maven`).
68        /// One archive carries one package type.
69        #[arg(long, default_value = "rust")]
70        format: String,
71
72        /// Where the metadata index is written: `arrow-ipc` (default — inline in
73        /// the .znippy container) or `iceberg` (a real Iceberg table in
74        /// --warehouse; blobs stay in the .znippy sidecar). The iceberg backend
75        /// requires the CLI to be built with `--features iceberg`.
76        #[arg(long, default_value = "arrow-ipc")]
77        meta_format: String,
78
79        /// Warehouse directory for `--meta-format iceberg`. Required for, and
80        /// only used by, the iceberg backend.
81        #[arg(long)]
82        warehouse: Option<PathBuf>,
83
84        /// Path to a .wasm plugin for metadata extraction (overrides --format).
85        #[arg(long)]
86        plugin: Option<PathBuf>,
87
88        /// DenseUnion type_id for the WASM plugin given via --plugin.
89        #[arg(long, default_value_t = 1)]
90        plugin_type_id: i8,
91
92        /// Provenance signing (feature `sign`): PKCS#8 (DER) private key of the
93        /// signer. Emits detached CMS signatures as reserved manifest sections —
94        /// the archive stays byte-compatible and the blob/compress path is
95        /// untouched. Requires `--sign-cert`. Build with `--features sign`.
96        #[arg(long, value_name = "KEY")]
97        sign: Option<PathBuf>,
98
99        /// X.509 signer certificate (DER) whose public key matches `--sign`.
100        #[arg(long, value_name = "CERT")]
101        sign_cert: Option<PathBuf>,
102
103        /// Signature algorithm for `--sign`: `p256` (default) or `ed25519`.
104        #[arg(long, default_value = "p256")]
105        sign_alg: String,
106    },
107
108    /// Append files from a directory into an existing sealed .znippy archive.
109    ///
110    /// Reuses every existing blob byte verbatim (no recompression — blobs are
111    /// content-addressed by blake3) and re-seals the merged lookup + trie +
112    /// manifest + footer over old+new rows. The grown archive opens with the
113    /// ordinary reader. This is the CLI front-end for the native blob-append
114    /// primitive (`znippy_common::append_files`, arrow-ipc idea C).
115    ///
116    /// A path that is ALREADY in the archive is REPLACED (last writer wins): its
117    /// old index rows are dropped and its old blob bytes become unreferenced dead
118    /// payload. Re-running an append over a changed directory is therefore safe —
119    /// it never leaves two live copies of a path.
120    Append {
121        /// Existing sealed .znippy archive to grow.
122        #[arg(short, long)]
123        input: PathBuf,
124
125        /// Directory whose files are compressed and appended. Relative paths are
126        /// taken from this root, matching how `compress` derives `relative_path`.
127        #[arg(short, long)]
128        add: PathBuf,
129
130        /// Codec level for the newly-appended blobs (existing blobs are untouched).
131        #[arg(short, long, default_value_t = 3)]
132        level: i32,
133
134        /// Attach a searchable metadata fact to an appended entry:
135        /// `--meta <relative_path>=<key>=<value>` (repeatable). The value is
136        /// typed by shape — `12` is an i64, `1.5` an f64, `true`/`false` a bool,
137        /// `@<file>` the raw bytes of that file, anything else a string.
138        #[arg(long = "meta", value_name = "PATH=KEY=VALUE")]
139        meta: Vec<String>,
140
141        /// Archive-level metadata fact: `--meta-archive <key>=<value>`
142        /// (repeatable, same value typing as `--meta`).
143        #[arg(long = "meta-archive", value_name = "KEY=VALUE")]
144        meta_archive: Vec<String>,
145    },
146
147    /// Search an archive's metadata index **without decompressing anything**.
148    ///
149    /// Reads the footer, the manifest and the `__znippy_meta__` sub-index only,
150    /// so the cost is set by the size of the metadata and not by the payload.
151    ///
152    /// Reports the three outcomes distinctly, because they are three different
153    /// facts: the archive has NO metadata index (exit 2 — nothing was searched);
154    /// it has one and nothing matched (exit 1 — searched, found nothing); it has
155    /// one and these entries matched (exit 0).
156    Meta {
157        /// Archive to search.
158        #[arg(short, long)]
159        input: PathBuf,
160
161        /// Exact key to look for. Omit to list the whole index.
162        #[arg(short, long)]
163        key: Option<String>,
164
165        /// Key prefix to look for (e.g. `build-thing` matches `build-thing.abi`).
166        #[arg(short, long)]
167        prefix: Option<String>,
168
169        /// Print only the matching `relative_path`s — the entries a caller would
170        /// then `znippy get`, one per line, ready to pipe.
171        #[arg(long)]
172        paths_only: bool,
173    },
174
175    /// Decompress a .znippy archive
176    Decompress {
177        #[arg(short, long)]
178        input: PathBuf,
179
180        #[arg(short, long)]
181        output: PathBuf,
182
183        /// Selective extract: only files of this package type (a handler
184        /// name/alias, e.g. `maven`/`rust`/`python`). Omit to extract all types.
185        #[arg(long = "type")]
186        pkg_type: Option<String>,
187
188        /// Selective extract: only files from this repo. Omit to extract all repos.
189        #[arg(long)]
190        repo: Option<String>,
191    },
192
193    /// List contents of a .znippy archive
194    List {
195        #[arg(short, long)]
196        input: PathBuf,
197    },
198
199    /// Random-access read of one file by its relative path (O(log n)/O(key) via
200    /// the lookup sub-index + trie). Writes to --output, or stdout if omitted.
201    Get {
202        #[arg(short, long)]
203        input: PathBuf,
204
205        /// Relative path of the file inside the archive.
206        #[arg(short, long)]
207        path: String,
208
209        /// Destination file. When omitted, the bytes are written to stdout.
210        #[arg(short, long)]
211        output: Option<PathBuf>,
212    },
213
214    /// Verify archive integrity (checksum)
215    Verify {
216        #[arg(short, long)]
217        input: PathBuf,
218
219        /// Also verify provenance signatures (feature `sign`): recompute every
220        /// artifact + archive digest from the index and check the detached CMS
221        /// against the trusted roots. Requires at least one `--root`. Build with
222        /// `--features sign`.
223        #[arg(long)]
224        signed: bool,
225
226        /// Trusted root CA certificate (DER) for `--signed`. Repeatable.
227        #[arg(long, value_name = "CA")]
228        root: Vec<PathBuf>,
229    },
230
231    /// Seal a dynamic, iceberg-backed archive into a static, immutable native
232    /// `.znippy` (inline Arrow-IPC sub-indexes + lookup + trie + footer).
233    ///
234    /// Reads the archive metadata from the skade-iceberg `--warehouse` and
235    /// writes it as the v0.7 inline container, REUSING the blob bytes already in
236    /// the `--input` `.znippy` sidecar (no recompress, content-addressed). The
237    /// sealed artifact opens with the ordinary reader — the warehouse is no
238    /// longer needed to read it. Requires `--features iceberg`.
239    Seal {
240        /// The `.znippy` blob sidecar written when the archive was compressed
241        /// with `--meta-format iceberg` (pure blobs, no footer).
242        #[arg(short, long)]
243        input: PathBuf,
244
245        /// The skade-iceberg warehouse holding the archive metadata tables.
246        #[arg(long)]
247        warehouse: PathBuf,
248
249        /// Iceberg namespace of the archive (its file stem). Defaults to the
250        /// `--input` file stem, matching how `compress` derives it.
251        #[arg(long)]
252        namespace: Option<String>,
253
254        /// Destination for the sealed native `.znippy`.
255        #[arg(short, long)]
256        output: PathBuf,
257    },
258
259    /// List the available package handlers (the compiled-in register).
260    Handlers,
261
262    /// Run a handler-specific subcommand, e.g. `znippy run rust coords foo.crate`.
263    Run {
264        /// Handler name/alias to dispatch to.
265        format: String,
266        /// Subcommand advertised by the handler's meta().
267        cmd: String,
268        /// Arguments passed to the subcommand.
269        args: Vec<String>,
270    },
271}
272
273/// Build the metadata-sink factory for `--meta-format` / `--warehouse`.
274/// `arrow-ipc` (default) → `None`, so `compress_dir` uses the inline
275/// `ArrowIpcSink`. `iceberg` (CLI feature `iceberg`) → a factory that builds an
276/// `IcebergSink` over `--warehouse`; the namespace is the archive's file stem.
277/// Blobs always stay in the `.znippy` file; only the index location changes.
278fn build_meta_sink(
279    meta_format: &str,
280    warehouse: Option<PathBuf>,
281    output: &std::path::Path,
282) -> Result<Option<znippy_common::MetaSinkFactory>> {
283    match meta_format {
284        "arrow-ipc" => Ok(None),
285        "iceberg" => {
286            #[cfg(feature = "iceberg")]
287            {
288                let wh = warehouse.ok_or_else(|| {
289                    anyhow::anyhow!("--warehouse <DIR> is required for --meta-format iceberg")
290                })?;
291                let namespace = output
292                    .file_stem()
293                    .map(|s| s.to_string_lossy().to_string())
294                    .unwrap_or_else(|| "znippy".to_string());
295                println!(
296                    "🧊 Metadata → Iceberg table (namespace `{namespace}`) in {}",
297                    wh.display()
298                );
299                Ok(Some(Box::new(move |_file, _off| {
300                    Box::new(znippy_iceberg::IcebergSink::new(wh, namespace))
301                        as Box<dyn znippy_common::ArchiveMetaSink>
302                })))
303            }
304            #[cfg(not(feature = "iceberg"))]
305            {
306                let _ = (warehouse, output);
307                anyhow::bail!(
308                    "iceberg metadata backend not compiled in; rebuild znippy-cli with `--features iceberg`"
309                )
310            }
311        }
312        other => anyhow::bail!("unknown --meta-format '{other}' (expected arrow-ipc|iceberg)"),
313    }
314}
315
316/// Compress `input` → `output`, recording the CLI **compress** surface's
317/// functional status: GREEN `archive_written` on success (files/chunks/ratio in
318/// the detail), RED with the error text on failure — so `nornir test
319/// --features testmatrix` SEES a broken compress as a RED matrix row instead of
320/// just a non-zero exit. Threads the ready `compress_dir` untouched; the emit is
321/// the only addition and is a compiled-out no-op in the default build.
322fn compress_reporting(
323    input: &PathBuf,
324    output: &PathBuf,
325    no_skip: bool,
326    registry: &PluginRegistry,
327    sink_factory: Option<znippy_common::MetaSinkFactory>,
328) -> Result<znippy_common::CompressionReport> {
329    match compress_dir(input, output, no_skip, Some(registry), None, sink_factory) {
330        Ok(report) => {
331            // GREEN only on a REAL committed result: files enumerated, at least
332            // one chunk written, and NO file silently dropped after an open/read
333            // failure. Mirrors the append path, which gates on
334            // `report.rows_added >= file_count`. Without this a run where every
335            // file failed to open — or an all-dirs input — would still light
336            // `archive_written` green over "0 chunks".
337            let ok = report.total_files > 0
338                && report.chunks > 0
339                && report.files_failed == 0;
340            functional_status(
341                "znippy-cli/compress",
342                "archive_written",
343                ok,
344                &format!(
345                    "{} files ({} failed), {} chunks, {:.2}% ratio → {}",
346                    report.total_files,
347                    report.files_failed,
348                    report.chunks,
349                    report.compression_ratio,
350                    output.display()
351                ),
352            );
353            if report.files_failed > 0 {
354                eprintln!(
355                    "⚠️  {} av {} filer kunde inte läsas och utelämnades ur arkivet",
356                    report.files_failed, report.total_files
357                );
358            }
359            Ok(report)
360        }
361        Err(e) => {
362            functional_status(
363                "znippy-cli/compress",
364                "archive_written",
365                false,
366                &format!("compress failed: {e}"),
367            );
368            Err(e)
369        }
370    }
371}
372
373/// Verify an archive's integrity, recording the CLI **verify** surface's
374/// functional status: RED `integrity_checksum` on a hard read error, GREEN when
375/// every file's blake3 checksum reconciled, RED when any file/byte is corrupt.
376/// The `format-version-guard` row still confirms the reader accepted the on-disk
377/// format version (reaching here at all means the version guard passed). Emits
378/// are compiled-out no-ops in the default build.
379fn verify_reporting(input: &Path) -> Result<VerifyReport> {
380    let report = match verify_archive_integrity(input) {
381        Ok(r) => r,
382        Err(e) => {
383            functional_status(
384                "znippy-cli/verify",
385                "integrity_checksum",
386                false,
387                &format!("verify failed: {e}"),
388            );
389            return Err(e);
390        }
391    };
392    // Reaching Ok here means znippy-common's reader accepted the archive's
393    // recorded on-disk format version (the `check_format_version` guard):
394    // an unsupported version would have errored out above, never here.
395    functional_status(
396        "znippy-cli/format-version-guard",
397        "on_disk_version_supported",
398        true,
399        &format!("reader max v{}", znippy_common::index::ZNIPPY_FORMAT_VERSION),
400    );
401    let integrity_ok = report.corrupt_files == 0 && report.corrupt_bytes == 0;
402    functional_status(
403        "znippy-cli/verify",
404        "integrity_checksum",
405        integrity_ok,
406        &format!(
407            "{} verified, {} corrupt files",
408            report.verified_files, report.corrupt_files
409        ),
410    );
411    Ok(report)
412}
413
414/// Decompress `input` → `output` (optionally filtered), recording the CLI
415/// **decompress** surface's functional status: RED `reconstruct_verify` on a
416/// hard read error, GREEN when every reconstructed file's checksum reconciled,
417/// RED when any file/byte is corrupt. Returns the report so the caller can print
418/// it and decide the exit code; the RED row is emitted even on the corrupt path.
419fn decompress_reporting(
420    input: &PathBuf,
421    output: &PathBuf,
422    filter: &znippy_common::IndexFilter,
423    pkg_type: Option<&str>,
424    repo: Option<&str>,
425) -> Result<VerifyReport> {
426    let result = if filter.is_empty() {
427        decompress_archive(input, output)
428    } else {
429        println!(
430            "🔎 Selective extract: type={} repo={}",
431            pkg_type.unwrap_or("*"),
432            repo.unwrap_or("*"),
433        );
434        decompress_archive_filtered(input, output, filter)
435    };
436    let report = match result {
437        Ok(r) => r,
438        Err(e) => {
439            functional_status(
440                "znippy-cli/decompress",
441                "reconstruct_verify",
442                false,
443                &format!("decompress failed: {e}"),
444            );
445            return Err(e);
446        }
447    };
448    let integrity_ok = report.corrupt_files == 0 && report.corrupt_bytes == 0;
449    functional_status(
450        "znippy-cli/decompress",
451        "reconstruct_verify",
452        integrity_ok,
453        &format!(
454            "{} verified, {} corrupt files, {} corrupt bytes",
455            report.verified_files, report.corrupt_files, report.corrupt_bytes
456        ),
457    );
458    Ok(report)
459}
460
461/// Build a boxed provenance signer from the `--sign` / `--sign-cert` / `--sign-alg`
462/// flags, reading the PKCS#8 key + DER cert from disk and handing the bytes to the
463/// ready `znippy-common` loader. `Ok(None)` when `--sign` is absent. On a load
464/// failure (missing cert, bad key/alg) records a RED `signer_loaded` row for the
465/// CLI **sign** surface before propagating — the GREEN counterpart is emitted at
466/// the call site once the signer is armed.
467#[cfg(feature = "sign")]
468fn build_signer(
469    sign: &Option<PathBuf>,
470    sign_cert: &Option<PathBuf>,
471    sign_alg: &str,
472) -> Result<Option<Box<dyn znippy_common::sign::ArchiveSigner + Send>>> {
473    let Some(key_path) = sign else { return Ok(None) };
474    let load = (|| -> Result<Box<dyn znippy_common::sign::ArchiveSigner + Send>> {
475        let cert_path = sign_cert.as_ref().ok_or_else(|| {
476            anyhow::anyhow!("--sign requires --sign-cert <CERT> (DER signer certificate)")
477        })?;
478        let alg = znippy_common::sign::SigAlg::from_name(sign_alg)?;
479        let key = std::fs::read(key_path)?;
480        let cert = std::fs::read(cert_path)?;
481        Ok(znippy_common::sign::signer_from_pkcs8(alg, &key, &cert)?)
482    })();
483    match load {
484        Ok(signer) => Ok(Some(signer)),
485        Err(e) => {
486            functional_status(
487                "znippy-cli/compress-sign",
488                "signer_loaded",
489                false,
490                &format!("signer load failed ({sign_alg}): {e}"),
491            );
492            Err(e)
493        }
494    }
495}
496
497/// Wrap a signer in the inline Arrow-IPC sink factory so [`compress_dir`] seals a
498/// signed archive (detached CMS in reserved sections; blob/compress path
499/// unchanged). The hot path never sees the signer — it runs at `finish()`.
500#[cfg(feature = "sign")]
501fn sign_meta_factory(
502    signer: Box<dyn znippy_common::sign::ArchiveSigner + Send>,
503) -> znippy_common::MetaSinkFactory {
504    Box::new(move |file, off| {
505        Box::new(znippy_common::ArrowIpcSink::new(file, off).with_signer(signer))
506            as Box<dyn znippy_common::ArchiveMetaSink>
507    })
508}
509
510/// Verify an archive's provenance: load the DER roots, chain + check every
511/// detached CMS via the ready `verify_archive`, and print the report. Records the
512/// CLI **verify --signed** surface's functional status: GREEN `provenance_chain`
513/// when the CMS chains to a trusted root, RED (with the reason) on any failure —
514/// no roots, an unreadable CA, or a chain that doesn't verify. So a tampered or
515/// wrongly-rooted archive shows up as a RED matrix row, not a bare exit code.
516#[cfg(feature = "sign")]
517fn run_signed_verify(input: &std::path::Path, roots: &[PathBuf]) -> Result<()> {
518    match run_signed_verify_inner(input, roots) {
519        Ok(()) => Ok(()),
520        Err(e) => {
521            functional_status(
522                "znippy-cli/verify-signed",
523                "provenance_chain",
524                false,
525                &format!("provenance verify failed: {e}"),
526            );
527            Err(e)
528        }
529    }
530}
531
532/// Inner provenance verify: does the work and emits the GREEN `provenance_chain`
533/// row on success. [`run_signed_verify`] wraps it to turn any error into the RED
534/// counterpart before propagating.
535/// **Is a provenance chain genuinely verified?** The archive-root signature is a real
536/// cryptographic gate and `verify_archive` errors out if it does not chain to a trusted
537/// root — but it says nothing about any individual artifact. `verify_archive` walks
538/// `sigs.artifacts`, and an EMPTY map never enters the loop: no error, and the count
539/// stays 0. Two states produce that, and neither is a verified chain — an archive sealed
540/// over zero files, and an archive whose `__znippy_sign_artifacts__` section is absent
541/// (`read_archive_signatures` turns a missing section into an empty map).
542///
543/// Kept as a pure function on purpose: the decision has to be assertable on its own, or
544/// the guard is a local `bool` nobody can drive red. Same lesson as the compress
545/// false-green — a surface that reports success over work that did not happen.
546#[cfg(feature = "sign")]
547pub fn provenance_is_verified(artifacts_verified: usize) -> bool {
548    artifacts_verified > 0
549}
550
551#[cfg(feature = "sign")]
552fn run_signed_verify_inner(input: &std::path::Path, roots: &[PathBuf]) -> Result<()> {
553    anyhow::ensure!(
554        !roots.is_empty(),
555        "--signed requires at least one --root <CA> (DER trusted root)"
556    );
557    let ders: Vec<Vec<u8>> = roots.iter().map(std::fs::read).collect::<std::io::Result<_>>()?;
558    let store = znippy_common::sign::CertStore::from_der_certs(&ders)?;
559    let report = znippy_common::sign::verify_archive(input, &store)?;
560    println!("\n🔏 Provenans verifierad:");
561    println!("✍️  Signerad av (CN):   {}", report.signer.id.common_name);
562    println!("🪪  Subjekt:            {}", report.signer.id.subject);
563    // The fingerprint is the identity a trust policy is written against — the
564    // subject is only a label, and a CA may issue it twice.
565    println!("🔑 Fingeravtryck (SHA-256): {}", report.signer.id.fingerprint_hex());
566    println!("📦 Verifierade artefakter: {}", report.artifacts_verified);
567    // GREEN only on a REAL verified result. The archive-root signature above is a
568    // genuine cryptographic gate — reaching here means it chained to a trusted root
569    // — but it says nothing about any individual artifact. `verify_archive` walks
570    // `sigs.artifacts` and an EMPTY map simply never enters the loop: no error, and
571    // `artifacts_verified` stays 0. Two things produce that, and neither is a
572    // verified provenance chain: an archive sealed over zero files, and an archive
573    // whose `__znippy_sign_artifacts__` section is absent (which
574    // `read_archive_signatures` turns into an empty map). Same shape as the compress
575    // false-green fixed in "gate green on the REAL committed result": a surface that
576    // reports success over work that did not happen.
577    let ok = provenance_is_verified(report.artifacts_verified);
578    if !ok {
579        eprintln!(
580            "⚠️  arkivsignaturen kedjar till en betrodd rot, men NOLL artefakter \
581             verifierades — arkivet är antingen förseglat utan filer eller saknar \
582             sin per-artefakt-sektion"
583        );
584    }
585    functional_status(
586        "znippy-cli/verify-signed",
587        "provenance_chain",
588        ok,
589        &format!(
590            "CMS chained to root; signer={}, fp={}, artifacts={}",
591            report.signer.id.common_name,
592            report.signer.id.fingerprint_hex(),
593            report.artifacts_verified
594        ),
595    );
596    Ok(())
597}
598
599/// Recursively collect every regular file under `dir` into `(relative_path,
600/// bytes)` pairs, deriving `relative_path` from `root` exactly as the compress
601/// path does (`path.strip_prefix(root).to_string_lossy()`, `slot_packer.rs`), so
602/// an appended tree lands under the same keys a fresh `compress` would produce.
603fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<()> {
604    let mut entries: Vec<_> = std::fs::read_dir(dir)?
605        .collect::<std::io::Result<Vec<_>>>()?;
606    // Stable, reproducible ordering independent of the filesystem's readdir order.
607    entries.sort_by_key(|e| e.file_name());
608    for entry in entries {
609        let path = entry.path();
610        let ft = entry.file_type()?;
611        if ft.is_dir() {
612            collect_files(root, &path, out)?;
613        } else if ft.is_file() {
614            let rel = path
615                .strip_prefix(root)
616                .unwrap_or(&path)
617                .to_string_lossy()
618                .into_owned();
619            let bytes = std::fs::read(&path)?;
620            out.push((rel, bytes));
621        }
622    }
623    Ok(())
624}
625
626/// Parse `--meta PATH=KEY=VALUE` / `--meta-archive KEY=VALUE` into a table.
627///
628/// Returns `None` when the caller gave neither, which is NOT the same as an
629/// empty table: `None` leaves the archive's metadata exactly as it was (possibly
630/// absent), while an empty table would seal a present-but-empty index. The
631/// distinction is the whole point of the feature, so it is preserved right at
632/// the CLI boundary rather than flattened here.
633fn parse_meta_args(
634    entry_args: &[String],
635    archive_args: &[String],
636) -> Result<Option<znippy_common::MetaTable>> {
637    if entry_args.is_empty() && archive_args.is_empty() {
638        return Ok(None);
639    }
640    let mut table = znippy_common::MetaTable::new();
641    for raw in entry_args {
642        let (path, rest) = raw
643            .split_once('=')
644            .ok_or_else(|| anyhow::anyhow!("--meta {raw:?}: expected PATH=KEY=VALUE"))?;
645        let (key, value) = rest
646            .split_once('=')
647            .ok_or_else(|| anyhow::anyhow!("--meta {raw:?}: expected PATH=KEY=VALUE"))?;
648        table.insert(path, key, parse_meta_value(value)?);
649    }
650    for raw in archive_args {
651        let (key, value) = raw
652            .split_once('=')
653            .ok_or_else(|| anyhow::anyhow!("--meta-archive {raw:?}: expected KEY=VALUE"))?;
654        table.insert_archive(key, parse_meta_value(value)?);
655    }
656    Ok(Some(table))
657}
658
659/// Type a CLI-supplied value by shape: `@file` → the file's raw bytes,
660/// `true`/`false` → bool, an integer → i64, a decimal → f64, else a string.
661/// Deliberately narrow and documented rather than clever — the typed columns
662/// exist so a consumer can rely on the type, so guessing must be predictable.
663fn parse_meta_value(raw: &str) -> Result<znippy_common::MetaValue> {
664    use znippy_common::MetaValue;
665    if let Some(file) = raw.strip_prefix('@') {
666        let bytes = std::fs::read(file)
667            .map_err(|e| anyhow::anyhow!("--meta value @{file}: {e}"))?;
668        return Ok(MetaValue::Bytes(bytes));
669    }
670    Ok(match raw {
671        "true" => MetaValue::Bool(true),
672        "false" => MetaValue::Bool(false),
673        _ => {
674            if let Ok(i) = raw.parse::<i64>() {
675                MetaValue::I64(i)
676            } else if let Ok(f) = raw.parse::<f64>() {
677                MetaValue::F64(f)
678            } else {
679                MetaValue::Str(raw.to_string())
680            }
681        }
682    })
683}
684
685/// `znippy meta` — search the metadata index without decompressing anything.
686///
687/// Exit codes carry the distinction the API is built around, so a shell script
688/// gets it too:
689///   0 — there is an index and these entries matched
690///   1 — there is an index and nothing matched ("searched, found nothing")
691///   2 — there is NO index ("nothing was searched"), which is not the same thing
692fn run_meta_search(
693    input: &Path,
694    key: Option<&str>,
695    prefix: Option<&str>,
696    paths_only: bool,
697) -> Result<()> {
698    use std::io::Write;
699    use znippy_common::{ArchiveMeta, MetaValue, read_archive_meta};
700
701    let meta = read_archive_meta(input)?;
702    let index = match &meta {
703        ArchiveMeta::NoMetadata => {
704            if !paths_only {
705                eprintln!(
706                    "ℹ️  {} carries NO metadata index — nothing was searched. \
707                     (That is not the same as searching and finding nothing.)",
708                    input.display()
709                );
710            }
711            functional_status(
712                "znippy-cli/meta",
713                "no_metadata_reported_distinctly",
714                true,
715                "archive has no __znippy_meta__ section; reported as NoMetadata, exit 2",
716            );
717            std::io::stdout().flush().ok();
718            std::process::exit(2);
719        }
720        ArchiveMeta::Index(i) => i,
721    };
722
723    let hits: &[znippy_common::MetaEntry] = match (key, prefix) {
724        (Some(k), _) => index.find_by_key(k),
725        (None, Some(p)) => index.find_by_prefix(p),
726        (None, None) => index.find_by_prefix(""),
727    };
728
729    if paths_only {
730        for h in hits {
731            if let Some(p) = h.path() {
732                println!("{p}");
733            }
734        }
735    } else {
736        println!(
737            "🔎 {} — metadata index: {} rows, {} distinct keys",
738            input.display(),
739            index.len(),
740            index.keys().len()
741        );
742        if hits.is_empty() {
743            println!("   (searched — no row matches)");
744        }
745        for h in hits {
746            let scope = h.path().unwrap_or("<archive>");
747            let shown = match &h.value {
748                MetaValue::Str(v) => format!("{v:?}"),
749                MetaValue::I64(v) => v.to_string(),
750                MetaValue::F64(v) => v.to_string(),
751                MetaValue::Bool(v) => v.to_string(),
752                MetaValue::Bytes(b) => format!("<{} bytes>", b.len()),
753            };
754            println!("   {scope}  {}  = {shown}", h.key);
755        }
756    }
757
758    functional_status(
759        "znippy-cli/meta",
760        "index_searched_without_payload_read",
761        true,
762        &format!("{} rows in index, {} hits", index.len(), hits.len()),
763    );
764    std::io::stdout().flush().ok();
765    if hits.is_empty() {
766        std::process::exit(1);
767    }
768    Ok(())
769}
770
771pub fn run() -> Result<()> {
772    env_logger::init();
773    let cli = Cli::parse();
774
775    match cli.command {
776        Commands::Compress {
777            input,
778            output,
779            no_skip,
780            format,
781            meta_format,
782            warehouse,
783            plugin,
784            plugin_type_id,
785            sign,
786            sign_cert,
787            sign_alg,
788        } => {
789            let registry = match plugin {
790                Some(wasm_path) => {
791                    let wp = WasmPlugin::load(&wasm_path.to_string_lossy(), "wasm-plugin", plugin_type_id)?;
792                    PluginRegistry::with_plugin(Box::new(wp))
793                }
794                None => {
795                    let handler = handlers::find_handler(&format)?;
796                    println!("🔌 Handler: {} (type_id {})", handler.meta().name, handler.type_id());
797                    PluginRegistry::with_plugin(handler)
798                }
799            };
800            // `mut` is only exercised under feature `sign` (the signing rebind).
801            #[allow(unused_mut)]
802            let mut sink_factory = build_meta_sink(&meta_format, warehouse, &output)?;
803
804            // Provenance signing rides in via the metadata sink (the `finish()`
805            // tail), never the blob/compress hot path.
806            #[cfg(feature = "sign")]
807            {
808                if let Some(signer) = build_signer(&sign, &sign_cert, &sign_alg)? {
809                    anyhow::ensure!(
810                        sink_factory.is_none(),
811                        "--sign is only supported with --meta-format arrow-ipc"
812                    );
813                    println!("🔏 Signering aktiverad ({sign_alg})");
814                    sink_factory = Some(sign_meta_factory(signer));
815                    functional_status(
816                        "znippy-cli/compress-sign",
817                        "signer_loaded",
818                        true,
819                        &format!("detached CMS provenance armed ({sign_alg})"),
820                    );
821                }
822            }
823            #[cfg(not(feature = "sign"))]
824            {
825                let _ = &sign_alg;
826                anyhow::ensure!(
827                    sign.is_none() && sign_cert.is_none(),
828                    "signing not compiled in; rebuild znippy-cli with `--features sign`"
829                );
830            }
831
832            let report = compress_reporting(&input, &output, no_skip, &registry, sink_factory)?;
833            if report.files_failed == 0 {
834                println!("\n✅ Komprimering klar:");
835            } else {
836                println!("\n⚠️  Komprimering klar med fel:");
837            }
838            println!("📁 Totalt antal filer:         {}", report.total_files);
839            println!("📁 Totalt antal chunks:         {}", report.chunks);
840            println!("❌ Filer som misslyckades:     {}", report.files_failed);
841
842            println!("📂 Totalt antal kataloger:     {}", report.total_dirs);
843            println!("📦 Filer komprimerade:         {}", report.compressed_files);
844            println!(
845                "📄 Filer ej komprimerade:      {}",
846                report.uncompressed_files
847            );
848            println!("📥 Totalt inlästa bytes:       {}", report.total_bytes_in);
849            println!("📤 Totalt skrivna bytes:       {}", report.total_bytes_out);
850            println!("📉 Bytes som komprimerades:    {}", report.compressed_bytes);
851            println!(
852                "📃 Bytes ej komprimerade:      {}",
853                report.uncompressed_bytes
854            );
855            println!(
856                "📊 Komprimeringsgrad:          {:.2}%",
857                report.compression_ratio
858            );
859        }
860
861        Commands::Append { input, add, level, meta, meta_archive } => {
862            let mut files = Vec::new();
863            collect_files(&add, &add, &mut files)?;
864            let file_count = files.len();
865            anyhow::ensure!(
866                file_count > 0,
867                "inga filer att lägga till hittades under {}",
868                add.display()
869            );
870            // `None` when the caller said nothing about metadata — which leaves
871            // the archive's existing index exactly as it was, including having
872            // none. Only an explicit --meta/--meta-archive creates or grows one.
873            let meta_table = parse_meta_args(&meta, &meta_archive)?;
874            let meta_rows = meta_table.as_ref().map_or(0, |t| t.len());
875            let report =
876                znippy_common::append_files_with_meta(&input, &files, level, meta_table)?;
877            println!("\n✅ Append klar:");
878            println!("📦 Arkiv:                     {}", input.display());
879            println!("📁 Filer tillagda:            {}", file_count);
880            println!("➕ Nya rader:                 {}", report.rows_added);
881            println!("📊 Rader innan:               {}", report.rows_before);
882            println!("♻️  Ersatta rader:            {}", report.rows_replaced);
883            println!("📍 Blob-append-offset:        {}", report.blob_append_offset);
884            println!("📤 Nya blob-bytes:            {}", report.blob_bytes_added);
885            println!("💾 Slutlig arkivstorlek:      {}", report.sealed_total_bytes);
886            if meta_rows > 0 {
887                println!("🔎 Metadata-rader tillagda:   {meta_rows}");
888            }
889            functional_status(
890                "znippy-cli/append",
891                "native_append",
892                report.rows_added >= file_count as u64,
893                &format!(
894                    "appended {file_count} files ({} new rows) into {}",
895                    report.rows_added,
896                    input.display()
897                ),
898            );
899        }
900
901        Commands::Decompress { input, output, pkg_type, repo } => {
902            let filter = znippy_common::IndexFilter {
903                pkg_type: match &pkg_type {
904                    Some(name) => Some(handlers::find_handler(name)?.type_id()),
905                    None => None,
906                },
907                repo: repo.clone(),
908            };
909            let report: VerifyReport = decompress_reporting(
910                &input,
911                &output,
912                &filter,
913                pkg_type.as_deref(),
914                repo.as_deref(),
915            )?;
916            println!("\n✅ Dekomprimering och verifiering klar:");
917            println!("📁 Totala filer:       {}", report.total_files);
918            println!("🔐 Verifierade filer:  {}", report.verified_files);
919            println!("📥  chunks:    {}", report.chunks);
920            println!("❌ Korrupta filer:     {}", report.corrupt_files);
921            println!("📥 Totala bytes:       {}", report.total_bytes);
922            println!("📤 Verifierade bytes:  {}", report.verified_bytes);
923            println!("⚠️  Korrupta bytes:    {}", report.corrupt_bytes);
924            // Never present a corrupt/incomplete extraction as success: fail with
925            // a non-zero exit so callers don't trust the output as good.
926            if report.corrupt_files > 0 || report.corrupt_bytes > 0 {
927                anyhow::bail!(
928                    "dekomprimering misslyckades: {} korrupta filer, {} korrupta bytes — utdata är ofullständig/otillförlitlig",
929                    report.corrupt_files,
930                    report.corrupt_bytes
931                );
932            }
933        }
934
935        Commands::List { input } => {
936            list_archive_contents(&input)?;
937        }
938
939        Commands::Meta { input, key, prefix, paths_only } => {
940            return run_meta_search(&input, key.as_deref(), prefix.as_deref(), paths_only);
941        }
942
943        Commands::Get { input, path, output } => {
944            // Route through the cached ArchiveReader (arrow-ipc idea B): open the
945            // manifest + lookup + trie once, then serve the file from the held-open
946            // handle. For a single Get this matches get_file; the point is that this
947            // is now the canonical selective-restore path callers reuse across files.
948            let reader = znippy_common::ArchiveReader::open(&input)?;
949            let data = reader.read_file(&path)?;
950            match output {
951                Some(dest) => {
952                    std::fs::write(&dest, &data)?;
953                    eprintln!("📤 {} ({} bytes) → {}", path, data.len(), dest.display());
954                }
955                None => {
956                    use std::io::Write;
957                    std::io::stdout().write_all(&data)?;
958                }
959            }
960        }
961
962        Commands::Verify { input, signed, root } => {
963            let report: VerifyReport = verify_reporting(&input)?;
964            println!("\n🔍 Verifiering klar:");
965            println!("📁 Totala filer:       {}", report.total_files);
966            println!("🔐 Verifierade filer:  {}", report.verified_files);
967            println!("❌ Korrupta filer:     {}", report.corrupt_files);
968            println!("📥 Totala bytes:       {}", report.total_bytes);
969            println!("📤 Verifierade bytes:  {}", report.verified_bytes);
970            println!("⚠️  Korrupta bytes:    {}", report.corrupt_bytes);
971
972            // `verify` is documented as a CI integrity gate (`znippy verify -i A &&
973            // ship`), and the exit status is the only machine-readable signal such a
974            // gate consumes — the Swedish stdout text is not. Reporting corruption on
975            // stdout and still returning 0 makes the gate pass on a corrupt archive.
976            // Mirror the Decompress arm and fail with a non-zero exit.
977            if report.corrupt_files > 0 || report.corrupt_bytes > 0 {
978                anyhow::bail!(
979                    "verifiering misslyckades: {} korrupta filer, {} korrupta bytes — arkivet är skadat",
980                    report.corrupt_files,
981                    report.corrupt_bytes
982                );
983            }
984
985            if signed {
986                #[cfg(feature = "sign")]
987                run_signed_verify(&input, &root)?;
988                #[cfg(not(feature = "sign"))]
989                {
990                    let _ = &root;
991                    anyhow::bail!(
992                        "signature verification not compiled in; rebuild znippy-cli with `--features sign`"
993                    );
994                }
995            }
996        }
997
998        Commands::Seal { input, warehouse, namespace, output } => {
999            #[cfg(feature = "iceberg")]
1000            {
1001                let ns = namespace.unwrap_or_else(|| {
1002                    input
1003                        .file_stem()
1004                        .map(|s| s.to_string_lossy().to_string())
1005                        .unwrap_or_else(|| "znippy".to_string())
1006                });
1007                println!(
1008                    "🧊→📦 Sealing iceberg archive (namespace `{ns}`) in {} → {}",
1009                    warehouse.display(),
1010                    output.display()
1011                );
1012                let report = znippy_iceberg::seal(&input, &warehouse, &ns, &output)?;
1013                println!("\n✅ Sealed (static native .znippy):");
1014                println!("📁 Filer:                      {}", report.files);
1015                println!("🧱 Chunk-rader:                {}", report.rows);
1016                println!(
1017                    "📤 Blob-bytes återanvända:     {} (ingen omkomprimering)",
1018                    report.blob_bytes_copied
1019                );
1020                println!("📦 Sealad total storlek:       {}", report.sealed_total_bytes);
1021                println!(
1022                    "📊 Metadata-svans + footer:    {} bytes",
1023                    report.sealed_total_bytes - report.blob_bytes_copied
1024                );
1025            }
1026            #[cfg(not(feature = "iceberg"))]
1027            {
1028                let _ = (input, warehouse, namespace, output);
1029                anyhow::bail!(
1030                    "iceberg backend not compiled in; rebuild znippy-cli with `--features iceberg`"
1031                );
1032            }
1033        }
1034
1035        Commands::Handlers => {
1036            handlers::print_catalog();
1037        }
1038
1039        Commands::Run { format, cmd, args } => {
1040            let handler = handlers::find_handler(&format)?;
1041            let dispatch = handler.run_command(&cmd, &args);
1042            functional_status(
1043                "znippy-cli/run-dispatch",
1044                "handler_command",
1045                dispatch.is_ok(),
1046                &format!("handler `{}` cmd `{}`", handler.meta().name, cmd),
1047            );
1048            dispatch?;
1049        }
1050    }
1051
1052    Ok(())
1053}
1054
1055/// Test-only serialization lock for the process-global functional-status buffer.
1056/// Every test that DRAINS `nornir_testmatrix::drain_functional_rows()` takes it
1057/// first so a concurrent drain in another test can't steal its rows (cargo runs
1058/// tests in parallel; the buffer is one process-global).
1059#[cfg(test)]
1060mod meta_cli_tests {
1061    use super::*;
1062    use znippy_common::MetaValue;
1063
1064    /// The CLI's value typing is a GUESS, so it has to be a predictable one —
1065    /// a consumer that relies on `size` being an i64 must get an i64. Inject each
1066    /// shape, assert the exact variant, including the two that are easy to get
1067    /// wrong: a bare `1` must not become an f64, and a version-looking `1.0.2`
1068    /// must stay a string rather than half-parse.
1069    #[test]
1070    fn cli_meta_values_are_typed_by_shape_predictably() {
1071        assert_eq!(parse_meta_value("12").unwrap(), MetaValue::I64(12));
1072        assert_eq!(parse_meta_value("-3").unwrap(), MetaValue::I64(-3));
1073        assert_eq!(parse_meta_value("1.5").unwrap(), MetaValue::F64(1.5));
1074        assert_eq!(parse_meta_value("true").unwrap(), MetaValue::Bool(true));
1075        assert_eq!(parse_meta_value("false").unwrap(), MetaValue::Bool(false));
1076        assert_eq!(parse_meta_value("wasi-p2").unwrap(), MetaValue::Str("wasi-p2".into()));
1077        assert_eq!(parse_meta_value("1.0.2").unwrap(), MetaValue::Str("1.0.2".into()));
1078        assert_eq!(parse_meta_value("").unwrap(), MetaValue::Str(String::new()));
1079
1080        // `@file` reads real bytes, and a missing file is an error rather than a
1081        // silent empty value — an empty build-thing would be worse than none.
1082        let dir = tempfile::tempdir().unwrap();
1083        let f = dir.path().join("m.wasm");
1084        std::fs::write(&f, b"\0asm\x01\0\0\0").unwrap();
1085        assert_eq!(
1086            parse_meta_value(&format!("@{}", f.display())).unwrap(),
1087            MetaValue::Bytes(b"\0asm\x01\0\0\0".to_vec())
1088        );
1089        assert!(parse_meta_value("@/nonexistent/x.wasm").is_err());
1090    }
1091
1092    /// NO metadata flags must yield `None`, not an empty table: `None` leaves an
1093    /// archive's index untouched, an empty table would seal a present-but-empty
1094    /// one. Flattening those here would reintroduce the exact conflation the
1095    /// whole feature is shaped to prevent.
1096    #[test]
1097    fn absent_meta_flags_are_none_not_an_empty_table() {
1098        assert!(parse_meta_args(&[], &[]).unwrap().is_none(), "no flags must mean NO section");
1099
1100        let t = parse_meta_args(
1101            &["app/x.wasm=build-thing=@/dev/null".into()],
1102            &["producer=znippy".into()],
1103        )
1104        .unwrap()
1105        .expect("flags given → a table");
1106        assert_eq!(t.len(), 2);
1107        assert_eq!(t.rows()[0].path(), Some("app/x.wasm"));
1108        assert_eq!(t.rows()[1].path(), None, "--meta-archive is archive-scoped");
1109
1110        // A malformed pair is refused rather than half-applied.
1111        assert!(parse_meta_args(&["nokey".into()], &[]).is_err());
1112        assert!(parse_meta_args(&["path=keyonly".into()], &[]).is_err());
1113        assert!(parse_meta_args(&[], &["novalue".into()]).is_err());
1114    }
1115}
1116
1117#[cfg(all(test, feature = "testmatrix"))]
1118fn fs_test_lock() -> std::sync::MutexGuard<'static, ()> {
1119    use std::sync::{Mutex, OnceLock};
1120    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1121    LOCK.get_or_init(|| Mutex::new(()))
1122        .lock()
1123        .unwrap_or_else(|p| p.into_inner())
1124}
1125
1126#[cfg(all(test, feature = "sign"))]
1127mod sign_tests {
1128    use super::*;
1129    use znippy_common::sign;
1130
1131    /// End-to-end CLI wiring: `compress --sign` (via the sink factory) seals a
1132    /// signed archive, then `verify --signed` chains + checks every detached CMS.
1133    #[test]
1134    fn compress_sign_then_verify_signed_round_trip() {
1135        let dir = tempfile::tempdir().unwrap();
1136        let input = dir.path().join("src");
1137        std::fs::create_dir_all(&input).unwrap();
1138        std::fs::write(input.join("a.txt"), b"hello znippy provenance").unwrap();
1139        std::fs::write(input.join("b.txt"), vec![7u8; 4096]).unwrap();
1140
1141        // One dev CA + a P-256 signer (the ready lib bootstrap path).
1142        let (ca_key, ca_der) = sign::dev::mint_ca("Znippy CLI Test CA").unwrap();
1143        let signer = sign::dev::new_p256_signer(&ca_key, &ca_der, "cli-signer").unwrap();
1144
1145        // Drive the SAME factory the Compress arm builds.
1146        let factory = sign_meta_factory(Box::new(signer));
1147        let registry = PluginRegistry::with_plugin(handlers::find_handler("rust").unwrap());
1148        let output = dir.path().join("out");
1149        compress_dir(&input, &output, false, Some(&registry), None, Some(factory)).unwrap();
1150        let archive = output.with_extension("znippy");
1151        assert!(archive.exists());
1152
1153        // The CLI verify helper, against a DER root written to disk.
1154        let ca_path = dir.path().join("ca.der");
1155        std::fs::write(&ca_path, &ca_der).unwrap();
1156        run_signed_verify(&archive, &[ca_path]).unwrap();
1157
1158        // A wrong root must fail to chain.
1159        let (_other_key, other_der) = sign::dev::mint_ca("Rogue CA").unwrap();
1160        let rogue_path = dir.path().join("rogue.der");
1161        std::fs::write(&rogue_path, &other_der).unwrap();
1162        assert!(run_signed_verify(&archive, &[rogue_path]).is_err());
1163
1164        // `--signed` with no roots is a clear error, not a silent pass.
1165        assert!(run_signed_verify(&archive, &[]).is_err());
1166    }
1167
1168    /// The signing surface RED path: a signer that can't load (missing
1169    /// `--sign-cert`) must both error AND record a RED `signer_loaded` row, so
1170    /// `nornir test` sees the broken signing config as a RED matrix row.
1171    #[cfg(feature = "testmatrix")]
1172    #[test]
1173    fn build_signer_missing_cert_emits_red_row() {
1174        let _guard = super::fs_test_lock();
1175        let _ = nornir_testmatrix::drain_functional_rows();
1176        let dir = tempfile::tempdir().unwrap();
1177        let key = dir.path().join("k.pkcs8");
1178        std::fs::write(&key, b"not-a-real-key").unwrap();
1179        // --sign given, --sign-cert omitted → build_signer must fail + emit RED.
1180        let out = build_signer(&Some(key), &None, "p256");
1181        assert!(out.is_err(), "missing --sign-cert must be an error");
1182        let rows = nornir_testmatrix::drain_functional_rows();
1183        let red = rows
1184            .iter()
1185            .find(|r| r.suite == "znippy-cli/compress-sign" && r.test_name == "signer_loaded")
1186            .expect("a signer_loaded row was emitted");
1187        assert_eq!(red.status, "fail", "broken signer config is a RED row");
1188    }
1189}
1190
1191/// Red-when-broken coverage for the CLI compress / decompress / verify surfaces:
1192/// a clean round-trip records GREEN functional rows, a corrupted archive records
1193/// RED ones. Gated on `testmatrix` so the rows are actually recorded/drained.
1194#[cfg(all(test, feature = "testmatrix"))]
1195mod functional_status_tests {
1196    use super::*;
1197
1198    fn drained_status(suite: &str, check: &str) -> Option<String> {
1199        nornir_testmatrix::drain_functional_rows()
1200            .into_iter()
1201            .filter(|r| r.suite == suite && r.test_name == check)
1202            .next_back()
1203            .map(|r| r.status)
1204    }
1205
1206    /// Whole compress → verify → decompress round trip is GREEN; corrupting a blob
1207    /// byte flips verify + decompress to RED. One serial test (the functional
1208    /// buffer is process-global): each step drains right after it emits.
1209    #[test]
1210    fn green_roundtrip_then_red_on_corruption() {
1211        let _guard = super::fs_test_lock();
1212        let dir = tempfile::tempdir().unwrap();
1213        let input = dir.path().join("src");
1214        std::fs::create_dir_all(&input).unwrap();
1215        // Plenty of highly-compressible bytes so a blob actually exists to corrupt.
1216        std::fs::write(input.join("a.txt"), vec![b'a'; 64 * 1024]).unwrap();
1217        std::fs::write(input.join("b.txt"), b"znippy functional status coverage").unwrap();
1218
1219        let registry = PluginRegistry::with_plugin(handlers::find_handler("rust").unwrap());
1220        let output = dir.path().join("out");
1221        let archive = output.with_extension("znippy");
1222
1223        // ── compress: GREEN archive_written ──
1224        let _ = nornir_testmatrix::drain_functional_rows();
1225        compress_reporting(&input, &output, false, &registry, None).unwrap();
1226        assert_eq!(
1227            drained_status("znippy-cli/compress", "archive_written").as_deref(),
1228            Some("pass"),
1229            "clean compress records a GREEN row"
1230        );
1231
1232        // ── verify (clean): GREEN integrity_checksum ──
1233        let _ = nornir_testmatrix::drain_functional_rows();
1234        let vr = verify_reporting(&archive).unwrap();
1235        assert_eq!(vr.corrupt_files, 0, "clean archive has no corrupt files");
1236        assert_eq!(
1237            drained_status("znippy-cli/verify", "integrity_checksum").as_deref(),
1238            Some("pass"),
1239            "clean verify records a GREEN row"
1240        );
1241
1242        // ── decompress (clean): GREEN reconstruct_verify ──
1243        let _ = nornir_testmatrix::drain_functional_rows();
1244        let out_clean = dir.path().join("extract_clean");
1245        let filter = znippy_common::IndexFilter { pkg_type: None, repo: None };
1246        decompress_reporting(&archive, &out_clean, &filter, None, None).unwrap();
1247        assert_eq!(
1248            drained_status("znippy-cli/decompress", "reconstruct_verify").as_deref(),
1249            Some("pass"),
1250            "clean decompress records a GREEN row"
1251        );
1252
1253        // ── corrupt a blob byte (blobs live at the front; index/footer at the end) ──
1254        let mut bytes = std::fs::read(&archive).unwrap();
1255        let flip = 16.min(bytes.len() - 1);
1256        bytes[flip] ^= 0xFF;
1257        std::fs::write(&archive, &bytes).unwrap();
1258
1259        // ── verify (corrupt): RED integrity_checksum ──
1260        // The flipped blob byte surfaces EITHER as a corrupt-checksum report OR as
1261        // a hard codec/read error — both must record a RED row (that's the point).
1262        let _ = nornir_testmatrix::drain_functional_rows();
1263        let _ = verify_reporting(&archive);
1264        assert_eq!(
1265            drained_status("znippy-cli/verify", "integrity_checksum").as_deref(),
1266            Some("fail"),
1267            "corrupt verify records a RED row"
1268        );
1269
1270        // ── decompress (corrupt): RED reconstruct_verify (bails, but emits first) ──
1271        let _ = nornir_testmatrix::drain_functional_rows();
1272        let out_bad = dir.path().join("extract_bad");
1273        let _ = decompress_reporting(&archive, &out_bad, &filter, None, None);
1274        assert_eq!(
1275            drained_status("znippy-cli/decompress", "reconstruct_verify").as_deref(),
1276            Some("fail"),
1277            "corrupt decompress records a RED row"
1278        );
1279    }
1280}
1281
1282#[cfg(all(test, feature = "sign"))]
1283mod provenance_green_tests {
1284    /// RED against the pre-fix surface, which passed a hardcoded `true`: an archive
1285    /// whose root signature chains to a trusted root but which carries ZERO verified
1286    /// artifacts must NOT light `provenance_chain` green. Drive the decision itself —
1287    /// a `bool` computed inline in the reporting arm cannot be driven at all.
1288    #[test]
1289    fn zero_verified_artifacts_is_not_a_verified_chain() {
1290        assert!(
1291            !super::provenance_is_verified(0),
1292            "an archive with a valid ROOT signature but zero verified artifacts is not a \
1293             verified provenance chain — this is the hardcoded-true surface the compress \
1294             false-green fix already retired"
1295        );
1296        assert!(super::provenance_is_verified(1), "one verified artifact IS a chain");
1297        assert!(super::provenance_is_verified(9_999));
1298    }
1299}