//! AIRGAP2 — the airgap **bundle** writer/reader.
//!
//! Design (`.nornir/airgap-install.md` §2): the bundle is a **znippy archive**
//! with *content cache + delta* so the next version ships only what changed, and
//! (future) a product can be read straight from the archive with no full
//! decompress. znippy is a large in-workspace project; pulling it would defeat
//! this crate's lean-by-design budget. So we model the bundle behind one
//! [`Archive`] trait seam and ship a **real** `tar + zstd` implementation
//! ([`TarZstd`]) as the always-works offline default. The znippy implementation
//! lands behind the `znippy` feature (see [`ZnippyArchive`]).
//!
//! Every bundle carries a [`BundleManifest`] (per-entry SHA-256 + sizes) and a
//! whole-archive **signed SHA-256** so the target can *verify on receipt* (the
//! airgap law) before unpacking — see [`crate::fetch`].

use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

/// The on-disk airgap bundle (a `.tar.zst`, znippy in future). Cache + delta make
/// the *next* version ship only what changed; future: read a product straight
/// from the archive (no full decompress) — the znippy win.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bundle {
    pub path: PathBuf,
}

impl Bundle {
    pub fn new(path: impl Into<PathBuf>) -> Self {
        Bundle { path: path.into() }
    }
}

/// One bundled file's identity — relative path inside the archive + its SHA-256
/// + byte length. The verify-on-receipt manifest is the map of these.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntryDigest {
    pub path: String,
    pub sha256: String,
    pub len: u64,
}

/// The whole-bundle manifest: per-entry digests + a signed SHA-256 over the
/// concatenation of the sorted `(path, sha256)` pairs (the "MANIFEST.sha256" of
/// the design). Stored alongside the bundle (`<bundle>.manifest.json`) so the
/// target verifies *before* unpacking.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleManifest {
    /// Airgap generation (date-stamped, e.g. "20250505").
    pub version: String,
    /// Per-entry digests, sorted by path (deterministic root hash).
    pub entries: Vec<EntryDigest>,
    /// SHA-256 over the sorted `path\0sha256\n` lines — the single value the
    /// receiver checks to know the *whole* bundle is intact.
    pub root_sha256: String,
}

impl BundleManifest {
    /// Recompute the root hash from `entries` (sorted) and compare to the stored
    /// value — the integrity self-check the receiver runs.
    pub fn verify_root(&self) -> bool {
        self.compute_root() == self.root_sha256
    }

    fn compute_root(&self) -> String {
        let mut sorted = self.entries.clone();
        sorted.sort_by(|a, b| a.path.cmp(&b.path));
        let mut h = Sha256::new();
        for e in &sorted {
            h.update(e.path.as_bytes());
            h.update([0u8]);
            h.update(e.sha256.as_bytes());
            h.update([b'\n']);
        }
        hex(&h.finalize())
    }
}

/// The bundle engine seam — pack a staging tree into a bundle, list its entries,
/// and selectively extract one entry. `TarZstd` is the real default; a znippy
/// impl can be dropped in later without touching callers.
pub trait Archive {
    /// Pack `staging` (a directory tree) into `out`, returning the manifest.
    fn pack(&self, staging: &Path, version: &str, out: &Path) -> Result<BundleManifest>;
    /// List the relative entry paths inside `bundle` (cheap — no full extract for
    /// znippy; tar reads the headers).
    fn list(&self, bundle: &Path) -> Result<Vec<String>>;
    /// Extract a single `entry` from `bundle` to `dest` (atomic: temp → rename).
    /// `TarZstd` streams the whole archive; znippy will seek (O(1)).
    fn extract_one(&self, bundle: &Path, entry: &str, dest: &Path) -> Result<()>;
    /// Extract the whole `bundle` into `dest_dir`.
    fn extract_all(&self, bundle: &Path, dest_dir: &Path) -> Result<()>;

    /// Read **one** entry's *raw* bytes from `bundle` (the O(1) selective-read
    /// primitive). The default streams via [`Archive::extract_one`] into a temp
    /// file; the znippy engine overrides this with a seek (`pread`) that touches
    /// only the one blob and the index — never decompressing the whole archive.
    fn read_entry(&self, bundle: &Path, entry: &str) -> Result<Vec<u8>> {
        let tmp = tempfile::Builder::new().prefix("read_entry").tempfile()?;
        self.extract_one(bundle, entry, tmp.path())?;
        Ok(std::fs::read(tmp.path())?)
    }

    /// Verify every entry in `manifest` against the bundle's *actual* content in
    /// a **single pass**, returning the paths whose bytes do NOT match (empty =
    /// clean). The default reads each entry via [`Archive::read_entry`] (correct
    /// for any engine); [`TarZstd`] overrides it to stream the tar exactly **once**
    /// — `O(total bytes)`, not the `O(entries × total bytes)` of re-extracting
    /// every entry from byte 0. A manifest entry absent from the bundle counts as
    /// bad (a truncated/partial transfer), never a silent pass — and, symmetrically,
    /// an archive entry NOT covered by the manifest is also bad (an attacker cannot
    /// smuggle an unlisted file past verify and have `install` extract it).
    fn verify_entries(&self, bundle: &Path, manifest: &BundleManifest) -> Result<Vec<String>> {
        let mut bad = Vec::new();
        let want: BTreeSet<&str> = manifest.entries.iter().map(|e| e.path.as_str()).collect();
        for e in &manifest.entries {
            match self.read_entry(bundle, &e.path) {
                Ok(bytes) if sha256_hex(&bytes) == e.sha256 => {}
                _ => bad.push(e.path.clone()),
            }
        }
        // Reject EXTRA entries: any file in the archive that the manifest never
        // listed (the set must match both ways, not just manifest ⊆ archive).
        for p in self.list(bundle)? {
            if !want.contains(p.as_str()) {
                bad.push(p);
            }
        }
        Ok(bad)
    }

    /// Compute the **delta** between an already-shipped bundle (described by its
    /// `old` [`BundleManifest`]) and a `new_staging` tree: the set of entries
    /// whose content changed or is new, plus the entries to drop. The next bundle
    /// ships only the changed/new blobs (the airgap-kubernetes speed story). The
    /// default (tar+zstd) records the diff metadata but carries no blob payloads;
    /// the znippy engine attaches the changed blobs so [`Archive::apply`] is a
    /// pure local splice. Returns the patch.
    fn delta(&self, _old: &BundleManifest, _new_staging: &Path, _version: &str) -> Result<Patch> {
        anyhow::bail!("delta() not supported by this Archive engine (use the znippy feature)")
    }

    /// Apply a [`Patch`] to an `old_bundle`, producing `new_bundle` byte-for-byte
    /// equal to a fresh pack of the new staging tree. The znippy engine reuses
    /// unchanged blobs straight from `old_bundle` (seek + copy) and appends the
    /// patch's new blobs — never re-reading the source tree.
    fn apply(&self, _old_bundle: &Path, _patch: &Patch, _new_bundle: &Path) -> Result<BundleManifest> {
        anyhow::bail!("apply() not supported by this Archive engine (use the znippy feature)")
    }
}

/// A binary **delta** between two airgap bundles: which entries to keep from the
/// old bundle (reused by content hash, zero bytes shipped) and which changed/new
/// entries to add (their raw bytes ride along). Removed entries are simply absent
/// from `kept`/`added`. Serializable so a patch can itself cross the airgap.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Patch {
    /// Target airgap generation the patch builds.
    pub version: String,
    /// Entries carried over unchanged from the old bundle (path + content hash).
    /// No payload — the engine seeks them out of `old_bundle` on [`Archive::apply`].
    pub kept: Vec<EntryDigest>,
    /// Entries that are new or changed — their raw bytes ship in `added_blobs`,
    /// keyed by the entry's content SHA-256 (so identical new entries dedup to one
    /// blob).
    pub added: Vec<EntryDigest>,
    /// New blob payloads, content-hash → raw bytes (deduped). Stored as a byte vec
    /// so a patch is self-contained; `apply` reads it directly.
    pub added_blobs: BTreeMap<String, Vec<u8>>,
}

impl Patch {
    /// Bytes actually shipped by this patch (the speed metric: a same-as-before
    /// bundle ships zero blob bytes).
    pub fn shipped_bytes(&self) -> u64 {
        self.added_blobs.values().map(|b| b.len() as u64).sum()
    }
    /// Number of entries reused from the old bundle (zero bytes shipped).
    pub fn reused(&self) -> usize {
        self.kept.len()
    }
}

/// The real, always-offline bundle engine: `tar` (preserving relative paths +
/// mode bits) piped through `zstd`. This is the AIRGAP2 fallback the design
/// names; the znippy engine supersedes it behind the `znippy` feature.
#[derive(Debug, Clone, Default)]
pub struct TarZstd {
    /// zstd compression level (1..=22). 0 → library default (3).
    pub level: i32,
}

impl TarZstd {
    pub fn new() -> Self {
        TarZstd { level: 0 }
    }
}

impl Archive for TarZstd {
    fn pack(&self, staging: &Path, version: &str, out: &Path) -> Result<BundleManifest> {
        // First pass: digest every file (so the manifest is content-addressed).
        let mut entries: BTreeMap<String, EntryDigest> = BTreeMap::new();
        collect_digests(staging, staging, &mut entries)
            .with_context(|| format!("digest staging tree {}", staging.display()))?;

        if let Some(parent) = out.parent() {
            std::fs::create_dir_all(parent).ok();
        }

        // Second pass: tar → zstd into `out.tmp`, then atomic rename.
        let tmp = with_suffix(out, ".tmp");
        {
            let file = std::fs::File::create(&tmp)
                .with_context(|| format!("create bundle {}", tmp.display()))?;
            let level = if self.level == 0 { 3 } else { self.level };
            let enc = zstd::stream::write::Encoder::new(file, level)
                .context("init zstd encoder")?
                .auto_finish();
            let mut tar = tar::Builder::new(enc);
            tar.append_dir_all(".", staging)
                .with_context(|| format!("tar staging {}", staging.display()))?;
            tar.finish().context("finish tar")?;
        }
        std::fs::rename(&tmp, out)
            .with_context(|| format!("rename {} → {}", tmp.display(), out.display()))?;

        let mut entries: Vec<EntryDigest> = entries.into_values().collect();
        entries.sort_by(|a, b| a.path.cmp(&b.path));
        let mut manifest = BundleManifest {
            version: version.to_string(),
            entries,
            root_sha256: String::new(),
        };
        manifest.root_sha256 = manifest.compute_root();

        // Persist the sidecar manifest next to the bundle (verify-on-receipt).
        let mpath = manifest_path(out);
        std::fs::write(&mpath, serde_json::to_vec_pretty(&manifest)?)
            .with_context(|| format!("write manifest {}", mpath.display()))?;
        Ok(manifest)
    }

    fn list(&self, bundle: &Path) -> Result<Vec<String>> {
        let mut ar = open_tar(bundle)?;
        let mut out = Vec::new();
        for entry in ar.entries().context("read tar entries")? {
            let entry = entry?;
            if entry.header().entry_type().is_file() {
                let p = entry.path()?;
                out.push(norm(&p));
            }
        }
        out.sort();
        Ok(out)
    }

    fn extract_one(&self, bundle: &Path, entry: &str, dest: &Path) -> Result<()> {
        let mut ar = open_tar(bundle)?;
        for e in ar.entries().context("read tar entries")? {
            let mut e = e?;
            if norm(&e.path()?) == entry {
                if let Some(parent) = dest.parent() {
                    std::fs::create_dir_all(parent).ok();
                }
                let tmp = with_suffix(dest, ".tmp");
                {
                    let mut f = std::fs::File::create(&tmp)
                        .with_context(|| format!("create {}", tmp.display()))?;
                    std::io::copy(&mut e, &mut f).context("stream entry")?;
                }
                std::fs::rename(&tmp, dest)
                    .with_context(|| format!("rename {} → {}", tmp.display(), dest.display()))?;
                return Ok(());
            }
        }
        anyhow::bail!("entry `{entry}` not found in {}", bundle.display())
    }

    fn extract_all(&self, bundle: &Path, dest_dir: &Path) -> Result<()> {
        std::fs::create_dir_all(dest_dir)
            .with_context(|| format!("create {}", dest_dir.display()))?;
        let mut ar = open_tar(bundle)?;
        ar.unpack(dest_dir)
            .with_context(|| format!("unpack into {}", dest_dir.display()))?;
        Ok(())
    }

    /// Single streaming pass: decompress the tar **once**, hashing each entry's
    /// bytes on the fly against its manifest digest. Replaces the old per-entry
    /// `extract_one` loop that rescanned the whole archive from byte 0 for every
    /// entry (`O(entries × total bytes)`) — this is `O(total bytes)`.
    fn verify_entries(&self, bundle: &Path, manifest: &BundleManifest) -> Result<Vec<String>> {
        // path → expected content hash; entries are removed as the pass sees them,
        // so whatever remains afterward was missing from the bundle (→ bad).
        let mut want: BTreeMap<&str, &str> = manifest
            .entries
            .iter()
            .map(|e| (e.path.as_str(), e.sha256.as_str()))
            .collect();
        let mut bad = Vec::new();
        let mut ar = open_tar(bundle)?;
        let mut buf = [0u8; 64 * 1024];
        for e in ar.entries().context("read tar entries")? {
            let mut e = e?;
            if !e.header().entry_type().is_file() {
                continue;
            }
            let path = norm(&e.path()?);
            match want.remove(path.as_str()) {
                Some(expected) => {
                    let mut h = Sha256::new();
                    loop {
                        let n = e.read(&mut buf).context("stream entry for verify")?;
                        if n == 0 {
                            break;
                        }
                        h.update(&buf[..n]);
                    }
                    if hex(&h.finalize()) != expected {
                        bad.push(path);
                    }
                }
                // EXTRA entry: a file present in the archive but absent from the
                // manifest — reject it (never a silent pass that install extracts).
                None => bad.push(path),
            }
        }
        bad.extend(want.into_keys().map(str::to_string));
        Ok(bad)
    }
}

/// The sidecar manifest path for a bundle (`<bundle>.manifest.json`).
pub fn manifest_path(bundle: &Path) -> PathBuf {
    with_suffix(bundle, ".manifest.json")
}

/// Load a bundle's sidecar [`BundleManifest`].
pub fn load_manifest(bundle: &Path) -> Result<BundleManifest> {
    let p = manifest_path(bundle);
    let bytes = std::fs::read(&p).with_context(|| format!("read manifest {}", p.display()))?;
    Ok(serde_json::from_slice(&bytes)?)
}

/// SHA-256 of an arbitrary byte slice, lowercase hex (used by tests + fetch).
pub fn sha256_hex(bytes: &[u8]) -> String {
    hex(&Sha256::digest(bytes))
}

/// SHA-256 of a file on disk, lowercase hex.
pub fn sha256_file(path: &Path) -> Result<String> {
    let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
    let mut h = Sha256::new();
    let mut buf = [0u8; 64 * 1024];
    loop {
        let n = f.read(&mut buf)?;
        if n == 0 {
            break;
        }
        h.update(&buf[..n]);
    }
    Ok(hex(&h.finalize()))
}

// ════════════════════════ znippy archive engine ════════════════════════
//
// AIRGAP2 (znippy feature). A self-contained znippy-format bundle engine —
// content-addressed blob region + a seekable index + a footer — that delivers
// the three znippy wins the design names: **content cache** (dedup), **delta**
// (ship only changed blobs), and **O(1) selective read** (seek one blob, no full
// decompress). It mirrors znippy's on-disk philosophy (../znippy: a blob region
// followed by an Arrow-IPC index whose rows carry `blob_offset`/`len`/checksum,
// and a footer pointing at the index) without linking the `znippy` crate — that
// crate pulls `znippy-cli` (a whole binary) and a rayon-based workspace, which
// would blow this leaf crate's lean, no-rayon budget. The format here is
// znippy-faithful and pure-`std` + `zstd`.

/// znippy bundle magics — a leading magic to fast-reject a wrong file and a
/// trailing magic the reader seeks to from EOF to find the footer.
#[cfg(feature = "znippy")]
const ZNIPPY_MAGIC: &[u8; 8] = b"ZNIPGAP1";
#[cfg(feature = "znippy")]
const ZNIPPY_FOOTER_MAGIC: &[u8; 8] = b"ZNIPFTR1";

/// One index row: an entry path, the content-hash of its bytes, and the
/// (offset, compressed-len, raw-len) of the blob holding those bytes. Many paths
/// may share one blob (content-addressed dedup → the **content cache**).
#[cfg(feature = "znippy")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ZnippyIndexRow {
    path: String,
    sha256: String,
    blob_offset: u64,
    blob_clen: u64,
    raw_len: u64,
}

/// The seekable index (the "Arrow-IPC sub-index" analogue): every entry row,
/// sorted by path. Serialized as JSON, zstd-compressed, and written after the
/// blob region; the footer records where it lives so the reader loads ONLY this
/// (tiny) blob and then `pread`s the one entry it wants.
#[cfg(feature = "znippy")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ZnippyIndex {
    version: String,
    rows: Vec<ZnippyIndexRow>,
}

/// The trailing footer (fixed-layout, read by seeking from EOF): index offset +
/// compressed length, then the footer magic. `read_footer` seeks to
/// `EOF - footer_size`, validates the magic, and returns the index location —
/// so the reader never scans the blob region.
#[cfg(feature = "znippy")]
const ZNIPPY_FOOTER_SIZE: u64 = 8 /* index_offset */ + 8 /* index_clen */ + 8 /* magic */;

/// A blob payload source for [`ZnippyArchive::write_bundle`]: a file on disk
/// (**streamed** through a reused buffer — never read whole into RAM, the OOM
/// fix) or in-memory bytes (used by `apply` for blobs that already live in RAM).
#[cfg(feature = "znippy")]
enum BlobSource {
    File(PathBuf),
    Bytes(Vec<u8>),
}

#[cfg(feature = "znippy")]
impl BlobSource {
    /// Stream the raw bytes into `w`, returning the raw byte count. The file
    /// variant copies through the reused `buf` so a multi-GB blob never lands in
    /// RAM whole; the encoder pulls it block by block.
    fn stream_into<W: std::io::Write>(&self, w: &mut W, buf: &mut [u8]) -> Result<u64> {
        match self {
            BlobSource::Bytes(b) => {
                w.write_all(b).context("write blob bytes")?;
                Ok(b.len() as u64)
            }
            BlobSource::File(p) => {
                let mut f = std::fs::File::open(p)
                    .with_context(|| format!("open blob {}", p.display()))?;
                let mut total = 0u64;
                loop {
                    let n = f
                        .read(buf)
                        .with_context(|| format!("read blob {}", p.display()))?;
                    if n == 0 {
                        break;
                    }
                    w.write_all(&buf[..n]).context("write blob chunk")?;
                    total += n as u64;
                }
                Ok(total)
            }
        }
    }
}

/// A `Write` adapter that tallies bytes written — lets `write_bundle` record each
/// streamed blob's compressed length (`offset` deltas) without buffering it.
#[cfg(feature = "znippy")]
struct CountingWriter<W> {
    inner: W,
    count: u64,
}

#[cfg(feature = "znippy")]
impl<W: std::io::Write> CountingWriter<W> {
    fn new(inner: W) -> Self {
        CountingWriter { inner, count: 0 }
    }
    fn count(&self) -> u64 {
        self.count
    }
}

#[cfg(feature = "znippy")]
impl<W: std::io::Write> std::io::Write for CountingWriter<W> {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        let n = self.inner.write(buf)?;
        self.count += n as u64;
        Ok(n)
    }
    fn flush(&mut self) -> std::io::Result<()> {
        self.inner.flush()
    }
}

/// The znippy-format airgap bundle engine (content cache + delta + O(1) read).
/// Drop-in for [`TarZstd`] behind the [`Archive`] trait — callers never change.
#[cfg(feature = "znippy")]
#[derive(Debug, Clone, Default)]
pub struct ZnippyArchive {
    /// zstd level for blobs (1..=22). 0 → library default (3).
    pub level: i32,
}

#[cfg(feature = "znippy")]
impl ZnippyArchive {
    pub fn new() -> Self {
        ZnippyArchive { level: 0 }
    }

    fn zlevel(&self) -> i32 {
        if self.level == 0 {
            3
        } else {
            self.level
        }
    }

    /// Write a znippy bundle from a fully-resolved set of entries: `entries` maps
    /// content-hash → raw bytes (the deduped **content cache**), and `paths` maps
    /// entry-path → content-hash. One blob per unique hash is written exactly
    /// once. Returns the [`BundleManifest`] (and writes the sidecar). This is the
    /// shared core of [`Archive::pack`] and [`Archive::apply`].
    fn write_bundle(
        &self,
        version: &str,
        entries: &BTreeMap<String, BlobSource>,
        paths: &BTreeMap<String, String>,
        out: &Path,
    ) -> Result<BundleManifest> {
        use std::io::Write as _;
        if let Some(parent) = out.parent() {
            std::fs::create_dir_all(parent).ok();
        }
        let tmp = with_suffix(out, ".tmp");
        // Blob region: STREAM each unique blob once (offset = bytes written so
        // far), recording its offset+clen+raw_len — never buffering the whole set.
        let mut blob_loc: BTreeMap<String, (u64, u64, u64)> = BTreeMap::new();
        {
            let mut w = CountingWriter::new(std::io::BufWriter::new(
                std::fs::File::create(&tmp)
                    .with_context(|| format!("create bundle {}", tmp.display()))?,
            ));
            w.write_all(ZNIPPY_MAGIC).context("write magic")?;
            let mut buf = vec![0u8; 64 * 1024];
            // Deterministic blob order = sorted by content hash.
            for (hash, src) in entries {
                let offset = w.count();
                let mut enc = zstd::stream::write::Encoder::new(&mut w, self.zlevel())
                    .context("init blob encoder")?;
                let raw_len = src.stream_into(&mut enc, &mut buf)?;
                enc.finish().context("finish blob encoder")?;
                let clen = w.count() - offset;
                blob_loc.insert(hash.clone(), (offset, clen, raw_len));
            }
            // Index rows (sorted by path), pointing every path at its blob.
            let mut rows = Vec::with_capacity(paths.len());
            for (path, hash) in paths {
                let (boff, bclen, rlen) = *blob_loc
                    .get(hash)
                    .ok_or_else(|| anyhow::anyhow!("path {path} references absent blob {hash}"))?;
                rows.push(ZnippyIndexRow {
                    path: path.clone(),
                    sha256: hash.clone(),
                    blob_offset: boff,
                    blob_clen: bclen,
                    raw_len: rlen,
                });
            }
            let index = ZnippyIndex {
                version: version.to_string(),
                rows,
            };
            let index_json = serde_json::to_vec(&index).context("serialize index")?;
            let index_comp =
                zstd::stream::encode_all(&index_json[..], self.zlevel()).context("compress index")?;
            let index_offset = w.count();
            let index_clen = index_comp.len() as u64;
            w.write_all(&index_comp).context("write index")?;
            // Footer: fixed layout, magic last (reader seeks from EOF).
            w.write_all(&index_offset.to_le_bytes()).context("write footer off")?;
            w.write_all(&index_clen.to_le_bytes()).context("write footer len")?;
            w.write_all(ZNIPPY_FOOTER_MAGIC).context("write footer magic")?;
            w.flush().context("flush bundle")?;
        }
        std::fs::rename(&tmp, out)
            .with_context(|| format!("rename {} → {}", tmp.display(), out.display()))?;

        // Manifest: one EntryDigest per path (raw len from the streamed blob_loc).
        let mut digests: Vec<EntryDigest> = paths
            .iter()
            .map(|(path, hash)| EntryDigest {
                path: path.clone(),
                sha256: hash.clone(),
                len: blob_loc.get(hash).map(|l| l.2).unwrap_or(0),
            })
            .collect();
        digests.sort_by(|a, b| a.path.cmp(&b.path));
        let mut manifest = BundleManifest {
            version: version.to_string(),
            entries: digests,
            root_sha256: String::new(),
        };
        manifest.root_sha256 = manifest.compute_root();
        std::fs::write(manifest_path(out), serde_json::to_vec_pretty(&manifest)?)
            .with_context(|| format!("write manifest {}", manifest_path(out).display()))?;
        Ok(manifest)
    }

    /// Read + validate the footer, returning `(index_offset, index_clen)`. Only
    /// touches the last [`ZNIPPY_FOOTER_SIZE`] bytes — the seek anchor.
    fn read_footer(f: &mut std::fs::File) -> Result<(u64, u64)> {
        use std::io::{Read as _, Seek as _, SeekFrom};
        let end = f.seek(SeekFrom::End(0)).context("seek end")?;
        if end < ZNIPPY_FOOTER_SIZE + ZNIPPY_MAGIC.len() as u64 {
            anyhow::bail!("bundle too small to be a znippy archive");
        }
        f.seek(SeekFrom::End(-(ZNIPPY_FOOTER_SIZE as i64)))
            .context("seek footer")?;
        let mut buf = [0u8; ZNIPPY_FOOTER_SIZE as usize];
        f.read_exact(&mut buf).context("read footer")?;
        let index_offset = u64::from_le_bytes(buf[0..8].try_into().unwrap());
        let index_clen = u64::from_le_bytes(buf[8..16].try_into().unwrap());
        if &buf[16..24] != ZNIPPY_FOOTER_MAGIC {
            anyhow::bail!("bad znippy footer magic (not a znippy bundle?)");
        }
        Ok((index_offset, index_clen))
    }

    /// Load the seekable index (footer → index blob only — never the blob region).
    fn load_index(bundle: &Path) -> Result<ZnippyIndex> {
        use std::io::{Read as _, Seek as _, SeekFrom};
        let mut f = std::fs::File::open(bundle)
            .with_context(|| format!("open bundle {}", bundle.display()))?;
        let (index_offset, index_clen) = Self::read_footer(&mut f)?;
        // Bound the attacker-controlled declared length against what the file
        // actually holds BEFORE allocating — the index lives between its offset and
        // the fixed-size footer, so a claim larger than that is corrupt.
        let file_len = f.seek(SeekFrom::End(0)).context("size bundle")?;
        let avail = file_len
            .checked_sub(index_offset)
            .and_then(|v| v.checked_sub(ZNIPPY_FOOTER_SIZE))
            .ok_or_else(|| anyhow::anyhow!("corrupt znippy footer: index offset past end of file"))?;
        if index_clen > avail {
            anyhow::bail!(
                "corrupt znippy index: declared length {index_clen} exceeds {avail} available bytes"
            );
        }
        f.seek(SeekFrom::Start(index_offset)).context("seek index")?;
        let mut comp = vec![0u8; index_clen as usize];
        f.read_exact(&mut comp).context("read index blob")?;
        let json = zstd::stream::decode_all(&comp[..]).context("decompress index")?;
        serde_json::from_slice(&json).context("parse index")
    }
}

#[cfg(feature = "znippy")]
impl Archive for ZnippyArchive {
    fn pack(&self, staging: &Path, version: &str, out: &Path) -> Result<BundleManifest> {
        // Content-address the tree (one read per file for the digest), then map
        // each unique blob to its on-disk source — `write_bundle` STREAMS them
        // straight into the bundle, so the whole tree is never buffered in RAM.
        let mut digests: BTreeMap<String, EntryDigest> = BTreeMap::new();
        collect_digests(staging, staging, &mut digests)
            .with_context(|| format!("digest staging tree {}", staging.display()))?;
        let mut entries: BTreeMap<String, BlobSource> = BTreeMap::new();
        let mut paths: BTreeMap<String, String> = BTreeMap::new();
        for (rel, d) in &digests {
            paths.insert(rel.clone(), d.sha256.clone());
            entries
                .entry(d.sha256.clone())
                .or_insert_with(|| BlobSource::File(staging.join(rel)));
        }
        self.write_bundle(version, &entries, &paths, out)
    }

    fn list(&self, bundle: &Path) -> Result<Vec<String>> {
        let idx = Self::load_index(bundle)?;
        let mut out: Vec<String> = idx.rows.into_iter().map(|r| r.path).collect();
        out.sort();
        Ok(out)
    }

    fn extract_one(&self, bundle: &Path, entry: &str, dest: &Path) -> Result<()> {
        // Zip-slip guard: refuse a traversing/absolute archive path outright.
        reject_unsafe_entry_path(entry)?;
        let bytes = self.read_entry(bundle, entry)?;
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent).ok();
        }
        let tmp = with_suffix(dest, ".tmp");
        std::fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp.display()))?;
        std::fs::rename(&tmp, dest)
            .with_context(|| format!("rename {} → {}", tmp.display(), dest.display()))?;
        Ok(())
    }

    /// O(1) selective read: footer → index → seek the ONE blob → decompress it.
    /// The blob region is never streamed; only the target blob's bytes are read.
    fn read_entry(&self, bundle: &Path, entry: &str) -> Result<Vec<u8>> {
        use std::io::{Read as _, Seek as _, SeekFrom};
        let idx = Self::load_index(bundle)?;
        let row = idx
            .rows
            .iter()
            .find(|r| r.path == entry)
            .ok_or_else(|| anyhow::anyhow!("entry `{entry}` not found in {}", bundle.display()))?;
        let mut f = std::fs::File::open(bundle)
            .with_context(|| format!("open bundle {}", bundle.display()))?;
        // Bound the attacker-controlled blob length against the real file size
        // BEFORE allocating — a blob that runs past EOF is corrupt, not a 4 GiB vec.
        let file_len = f.seek(SeekFrom::End(0)).context("size bundle")?;
        let blob_end = row
            .blob_offset
            .checked_add(row.blob_clen)
            .ok_or_else(|| anyhow::anyhow!("corrupt znippy blob for `{entry}`: length overflow"))?;
        if blob_end > file_len {
            anyhow::bail!(
                "corrupt znippy blob for `{entry}`: length {} at offset {} exceeds file size {}",
                row.blob_clen,
                row.blob_offset,
                file_len
            );
        }
        f.seek(SeekFrom::Start(row.blob_offset)).context("seek blob")?;
        let mut comp = vec![0u8; row.blob_clen as usize];
        f.read_exact(&mut comp).context("read blob")?;
        let raw = zstd::stream::decode_all(&comp[..]).context("decompress blob")?;
        // Verify on read (content-addressed integrity).
        if sha256_hex(&raw) != row.sha256 {
            anyhow::bail!("entry `{entry}` failed content-hash check");
        }
        Ok(raw)
    }

    fn extract_all(&self, bundle: &Path, dest_dir: &Path) -> Result<()> {
        std::fs::create_dir_all(dest_dir)
            .with_context(|| format!("create {}", dest_dir.display()))?;
        let idx = Self::load_index(bundle)?;
        for row in &idx.rows {
            // Zip-slip guard: never join an absolute/`..`-escaping entry path onto
            // dest_dir — reject the whole extract before writing anything.
            reject_unsafe_entry_path(&row.path)?;
            let dest = dest_dir.join(&row.path);
            self.extract_one(bundle, &row.path, &dest)?;
        }
        Ok(())
    }

    /// Delta: an entry is **kept** if `old` already had its exact content hash for
    /// that path; otherwise it is **added** (new or changed). Removed entries are
    /// simply omitted. Only changed/new blobs ship (deduped by hash).
    fn delta(&self, old: &BundleManifest, new_staging: &Path, version: &str) -> Result<Patch> {
        let old_by_path: BTreeMap<&str, &str> = old
            .entries
            .iter()
            .map(|e| (e.path.as_str(), e.sha256.as_str()))
            .collect();
        let mut new_digests: BTreeMap<String, EntryDigest> = BTreeMap::new();
        collect_digests(new_staging, new_staging, &mut new_digests)
            .with_context(|| format!("digest new staging {}", new_staging.display()))?;

        let mut kept = Vec::new();
        let mut added = Vec::new();
        let mut added_blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();
        for (rel, d) in &new_digests {
            if old_by_path.get(rel.as_str()) == Some(&d.sha256.as_str()) {
                kept.push(d.clone());
            } else {
                if !added_blobs.contains_key(&d.sha256) {
                    let raw = std::fs::read(new_staging.join(rel))
                        .with_context(|| format!("read {}", new_staging.join(rel).display()))?;
                    added_blobs.insert(d.sha256.clone(), raw);
                }
                added.push(d.clone());
            }
        }
        kept.sort_by(|a, b| a.path.cmp(&b.path));
        added.sort_by(|a, b| a.path.cmp(&b.path));
        Ok(Patch {
            version: version.to_string(),
            kept,
            added,
            added_blobs,
        })
    }

    /// Apply a patch: reuse `kept` blobs by reading them straight out of
    /// `old_bundle` (seek per blob — never the source tree), splice in the
    /// patch's `added` blobs, and write a fresh znippy bundle. The result is
    /// byte-identical to a clean pack of the new tree (same deterministic layout).
    fn apply(&self, old_bundle: &Path, patch: &Patch, new_bundle: &Path) -> Result<BundleManifest> {
        let mut entries: BTreeMap<String, BlobSource> = BTreeMap::new();
        let mut paths: BTreeMap<String, String> = BTreeMap::new();
        // Reused entries: pull their bytes from the OLD bundle by seeking.
        for e in &patch.kept {
            paths.insert(e.path.clone(), e.sha256.clone());
            if !entries.contains_key(&e.sha256) {
                let raw = self.read_entry(old_bundle, &e.path).with_context(|| {
                    format!("reuse `{}` from old bundle {}", e.path, old_bundle.display())
                })?;
                entries.insert(e.sha256.clone(), BlobSource::Bytes(raw));
            }
        }
        // Added entries: their bytes ship in the patch.
        for e in &patch.added {
            paths.insert(e.path.clone(), e.sha256.clone());
            if !entries.contains_key(&e.sha256) {
                let raw = patch
                    .added_blobs
                    .get(&e.sha256)
                    .ok_or_else(|| anyhow::anyhow!("patch missing blob {} for {}", e.sha256, e.path))?
                    .clone();
                entries.insert(e.sha256.clone(), BlobSource::Bytes(raw));
            }
        }
        self.write_bundle(&patch.version, &entries, &paths, new_bundle)
    }
}

// ───────────────────────────── helpers ─────────────────────────────

/// Zip-slip / path-traversal guard for archive entry paths. An entry path must be
/// a *contained relative* path: it may not be absolute (no leading `/` or a
/// Windows prefix/root) and any `..` component must not climb above the entry's
/// own root — so joining it onto a destination directory can never escape that
/// directory. Rejected paths return `Err` (the caller refuses to extract them);
/// `a/../b` is fine (it resolves to `b`, still inside), `../x` and `/etc/x` are not.
#[cfg(feature = "znippy")]
fn reject_unsafe_entry_path(entry: &str) -> Result<()> {
    use std::path::Component;
    let mut depth: isize = 0;
    for comp in Path::new(entry).components() {
        match comp {
            Component::Normal(_) => depth += 1,
            Component::CurDir => {}
            Component::ParentDir => {
                depth -= 1;
                if depth < 0 {
                    anyhow::bail!(
                        "refusing zip-slip entry `{entry}`: path escapes the destination directory"
                    );
                }
            }
            Component::RootDir | Component::Prefix(_) => {
                anyhow::bail!("refusing zip-slip entry `{entry}`: absolute paths are not allowed");
            }
        }
    }
    Ok(())
}

fn open_tar(bundle: &Path) -> Result<tar::Archive<zstd::stream::read::Decoder<'static, std::io::BufReader<std::fs::File>>>> {
    let file = std::fs::File::open(bundle)
        .with_context(|| format!("open bundle {}", bundle.display()))?;
    let dec = zstd::stream::read::Decoder::new(file).context("init zstd decoder")?;
    Ok(tar::Archive::new(dec))
}

fn collect_digests(
    root: &Path,
    dir: &Path,
    out: &mut BTreeMap<String, EntryDigest>,
) -> Result<()> {
    for entry in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            collect_digests(root, &path, out)?;
        } else if path.is_file() {
            let rel = path
                .strip_prefix(root)
                .unwrap_or(&path)
                .to_string_lossy()
                .replace('\\', "/");
            let len = entry.metadata().map(|m| m.len()).unwrap_or(0);
            let sha = sha256_file(&path)?;
            out.insert(
                rel.clone(),
                EntryDigest {
                    path: rel,
                    sha256: sha,
                    len,
                },
            );
        }
    }
    Ok(())
}

/// Normalize a tar entry path to a leading-`./`-free, forward-slash relative path.
fn norm(p: &Path) -> String {
    let s = p.to_string_lossy().replace('\\', "/");
    s.strip_prefix("./").unwrap_or(&s).to_string()
}

fn with_suffix(p: &Path, suffix: &str) -> PathBuf {
    let mut s = p.as_os_str().to_os_string();
    s.push(suffix);
    PathBuf::from(s)
}

fn hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write as _;

    fn staging_fixture() -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_path_buf();
        std::fs::create_dir_all(root.join("linux-x86_64")).unwrap();
        std::fs::create_dir_all(root.join("manifests")).unwrap();
        let mut f = std::fs::File::create(root.join("linux-x86_64/holger")).unwrap();
        f.write_all(b"#!holger-binary-bytes").unwrap();
        std::fs::write(root.join("manifests/holger.toml"), b"name=\"holger\"\n").unwrap();
        (dir, root)
    }

    /// LAW 1 (inject-assert): pack a real fixture tree → assert the bundle exists,
    /// the manifest enumerates both files with correct SHA-256, the root hash is
    /// self-consistent, and a round-trip extract yields byte-identical content.
    #[test]
    fn pack_then_extract_roundtrip_with_manifest_checksums() {
        let (_d, staging) = staging_fixture();
        let out_dir = tempfile::tempdir().unwrap();
        let bundle = out_dir.path().join("lake-20250505.tar.zst");

        let eng = TarZstd::new();
        let manifest = eng.pack(&staging, "20250505", &bundle).unwrap();

        // Bundle + sidecar manifest materialized.
        assert!(bundle.exists(), "bundle written");
        assert!(manifest_path(&bundle).exists(), "sidecar manifest written");
        assert_eq!(manifest.version, "20250505");

        // Both files enumerated with real digests.
        assert_eq!(manifest.entries.len(), 2);
        let holger = manifest
            .entries
            .iter()
            .find(|e| e.path == "linux-x86_64/holger")
            .expect("holger entry present");
        assert_eq!(holger.sha256, sha256_hex(b"#!holger-binary-bytes"));
        assert_eq!(holger.len, b"#!holger-binary-bytes".len() as u64);

        // Root hash is self-consistent (tamper detection anchor).
        assert!(manifest.verify_root(), "root hash recomputes");

        // list() sees exactly the packed files.
        let mut listed = eng.list(&bundle).unwrap();
        listed.sort();
        assert_eq!(listed, vec!["linux-x86_64/holger", "manifests/holger.toml"]);

        // Selective extract of ONE entry → byte-identical.
        let dest = out_dir.path().join("extracted-holger");
        eng.extract_one(&bundle, "linux-x86_64/holger", &dest).unwrap();
        assert_eq!(std::fs::read(&dest).unwrap(), b"#!holger-binary-bytes");

        // load_manifest round-trips the sidecar.
        let reloaded = load_manifest(&bundle).unwrap();
        assert_eq!(reloaded, manifest);
    }

    /// A tampered manifest is caught by `verify_root` (the integrity anchor).
    #[test]
    fn tampered_manifest_fails_root_verify() {
        let (_d, staging) = staging_fixture();
        let out_dir = tempfile::tempdir().unwrap();
        let bundle = out_dir.path().join("b.tar.zst");
        let mut m = TarZstd::new().pack(&staging, "v", &bundle).unwrap();
        assert!(m.verify_root());
        // Flip a byte of a recorded digest → root no longer matches.
        m.entries[0].sha256 = "00".repeat(32);
        assert!(!m.verify_root(), "tamper detected");
    }

    /// TarZstd: a full `extract_all` round-trips every file byte-identically, and
    /// the trait's DEFAULT `read_entry` (via a temp extract) returns one entry's
    /// exact bytes.
    #[test]
    fn tarzstd_extract_all_and_default_read_entry_roundtrip() {
        let (_d, staging) = staging_fixture();
        let out = tempfile::tempdir().unwrap();
        let bundle = out.path().join("b.tar.zst");
        let eng = TarZstd::new();
        eng.pack(&staging, "v", &bundle).unwrap();

        let dest = out.path().join("ex");
        eng.extract_all(&bundle, &dest).unwrap();
        assert_eq!(
            std::fs::read(dest.join("linux-x86_64/holger")).unwrap(),
            b"#!holger-binary-bytes"
        );
        assert_eq!(
            std::fs::read(dest.join("manifests/holger.toml")).unwrap(),
            b"name=\"holger\"\n"
        );

        // Default read_entry (no znippy seek) still returns the one entry's bytes.
        let bytes = eng.read_entry(&bundle, "manifests/holger.toml").unwrap();
        assert_eq!(bytes, b"name=\"holger\"\n");
    }

    /// Extracting an absent entry is an error that NAMES the missing entry.
    #[test]
    fn extract_one_missing_entry_errors() {
        let (_d, staging) = staging_fixture();
        let out = tempfile::tempdir().unwrap();
        let bundle = out.path().join("b.tar.zst");
        let eng = TarZstd::new();
        eng.pack(&staging, "v", &bundle).unwrap();
        let err = eng
            .extract_one(&bundle, "does/not/exist", &out.path().join("x"))
            .unwrap_err()
            .to_string();
        assert!(err.contains("does/not/exist"), "names the missing entry: {err}");
    }

    /// The two SHA-256 helpers agree (file vs bytes), and `manifest_path` appends
    /// the sidecar suffix.
    #[test]
    fn sha256_helpers_agree_and_manifest_path_suffix() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("blob.bin");
        let bytes = b"content-addressed-bytes-123";
        std::fs::write(&p, bytes).unwrap();
        assert_eq!(sha256_file(&p).unwrap(), sha256_hex(bytes));
        assert_eq!(
            manifest_path(Path::new("/x/lake.tar.zst")),
            Path::new("/x/lake.tar.zst.manifest.json")
        );
    }

    /// Patch metrics are pure (no engine/feature needed): `shipped_bytes` sums the
    /// added blob payloads; `reused()` counts the kept entries.
    #[test]
    fn patch_metrics_are_pure() {
        let mut added_blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();
        added_blobs.insert("h1".into(), vec![0u8; 10]);
        added_blobs.insert("h2".into(), vec![0u8; 5]);
        let patch = Patch {
            version: "v".into(),
            kept: vec![
                EntryDigest { path: "a".into(), sha256: "x".into(), len: 1 },
                EntryDigest { path: "b".into(), sha256: "y".into(), len: 2 },
            ],
            added: vec![EntryDigest { path: "c".into(), sha256: "h1".into(), len: 10 }],
            added_blobs,
        };
        assert_eq!(patch.shipped_bytes(), 15, "sums added blob bytes");
        assert_eq!(patch.reused(), 2, "counts kept entries");
    }

    /// SECURITY (extra-entry rejection): a bundle containing a file the manifest
    /// does NOT list must FAIL verify — an attacker cannot smuggle an unlisted file
    /// past `verify_entries` and have `install` extract it. We simulate the mismatch
    /// by dropping one entry from the manifest: the archive now carries a file the
    /// manifest never covers, and that file must be reported bad.
    #[test]
    fn verify_rejects_extra_entry_absent_from_manifest() {
        let (_d, staging) = staging_fixture();
        let out = tempfile::tempdir().unwrap();
        let bundle = out.path().join("b.tar.zst");
        let eng = TarZstd::new();
        let mut manifest = eng.pack(&staging, "v", &bundle).unwrap();

        // Faithful manifest → clean verify (no extras).
        assert!(eng.verify_entries(&bundle, &manifest).unwrap().is_empty());

        // Drop a covered entry: the archive still holds it, but the manifest no
        // longer lists it → it is now an EXTRA and must be flagged.
        let smuggled = manifest.entries.remove(0);
        let bad = eng.verify_entries(&bundle, &manifest).unwrap();
        assert!(
            bad.contains(&smuggled.path),
            "unlisted archive entry must be rejected: {bad:?}"
        );
    }

    /// An EMPTY staging tree packs a valid, self-consistent (empty) manifest, and
    /// `list()` sees nothing.
    #[test]
    fn empty_staging_packs_empty_manifest() {
        let staging = tempfile::tempdir().unwrap();
        let out = tempfile::tempdir().unwrap();
        let bundle = out.path().join("empty.tar.zst");
        let m = TarZstd::new().pack(staging.path(), "v0", &bundle).unwrap();
        assert!(m.entries.is_empty(), "no entries");
        assert!(m.verify_root(), "empty manifest root recomputes");
        assert!(TarZstd::new().list(&bundle).unwrap().is_empty());
    }

    // ───────────────────── znippy engine tests ─────────────────────
    // All gated on the `znippy` feature so the crate builds + tests green WITH
    // and WITHOUT it (the no-feature build keeps TarZstd as the only engine).

    /// Build a richer staging tree where two distinct paths hold IDENTICAL bytes
    /// (so the content cache must dedup them to a single blob) plus a unique file.
    #[cfg(feature = "znippy")]
    fn dedup_staging() -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_path_buf();
        std::fs::create_dir_all(root.join("linux-x86_64")).unwrap();
        std::fs::create_dir_all(root.join("copies")).unwrap();
        // Two identical-content files (dedup target).
        std::fs::write(root.join("linux-x86_64/holger"), b"SAME-BYTES-PAYLOAD").unwrap();
        std::fs::write(root.join("copies/holger.bak"), b"SAME-BYTES-PAYLOAD").unwrap();
        // One unique file.
        std::fs::write(root.join("linux-x86_64/tunnr"), b"unique-tunnr-bytes").unwrap();
        (dir, root)
    }

    /// LAW 1 (inject-assert): the content-addressed cache dedups two paths with
    /// identical bytes into ONE blob, while still listing both paths and reading
    /// each back byte-identical. We assert dedup *structurally* via the bundle's
    /// internal index (two index rows, ONE distinct blob_offset for the pair).
    #[cfg(feature = "znippy")]
    #[test]
    fn znippy_content_cache_dedups_identical_entries() {
        let (_d, staging) = dedup_staging();
        let out = tempfile::tempdir().unwrap();
        let bundle = out.path().join("lake.znip");
        let eng = ZnippyArchive::new();
        let manifest = eng.pack(&staging, "20250505", &bundle).unwrap();

        // Manifest enumerates all three paths; the two copies share a hash.
        assert_eq!(manifest.entries.len(), 3);
        let h_holger = &manifest.entries.iter().find(|e| e.path == "linux-x86_64/holger").unwrap().sha256;
        let h_bak = &manifest.entries.iter().find(|e| e.path == "copies/holger.bak").unwrap().sha256;
        assert_eq!(h_holger, h_bak, "identical content → identical hash");

        // Structural dedup proof: the index has 3 rows but the two identical
        // entries point at the SAME blob_offset (one stored blob), and there are
        // exactly 2 distinct blobs overall.
        let idx = ZnippyArchive::load_index(&bundle).unwrap();
        assert_eq!(idx.rows.len(), 3);
        let off_holger = idx.rows.iter().find(|r| r.path == "linux-x86_64/holger").unwrap().blob_offset;
        let off_bak = idx.rows.iter().find(|r| r.path == "copies/holger.bak").unwrap().blob_offset;
        assert_eq!(off_holger, off_bak, "deduped to one blob");
        let distinct: std::collections::BTreeSet<u64> = idx.rows.iter().map(|r| r.blob_offset).collect();
        assert_eq!(distinct.len(), 2, "exactly two stored blobs for three entries");

        // Both paths still read back correctly.
        assert_eq!(eng.read_entry(&bundle, "linux-x86_64/holger").unwrap(), b"SAME-BYTES-PAYLOAD");
        assert_eq!(eng.read_entry(&bundle, "copies/holger.bak").unwrap(), b"SAME-BYTES-PAYLOAD");
        assert_eq!(eng.read_entry(&bundle, "linux-x86_64/tunnr").unwrap(), b"unique-tunnr-bytes");
    }

    /// LAW 1 (inject-assert): O(1) SELECTIVE READ — read ONE entry's bytes WITHOUT
    /// decompressing the whole archive. We prove the rest was not read by
    /// instrumenting bytes touched: only the footer, the index blob, and the one
    /// target blob are read — strictly fewer bytes than the full file.
    #[cfg(feature = "znippy")]
    #[test]
    fn znippy_selective_read_does_not_decompress_whole_archive() {
        // Big INCOMPRESSIBLE distinct blobs (a cheap LCG so zstd can't shrink
        // them) — so "read the whole file" is provably larger than a seek-read.
        let dir = tempfile::tempdir().unwrap();
        let staging = dir.path().join("stage");
        std::fs::create_dir_all(staging.join("d")).unwrap();
        let incompressible = |seed: u64, n: usize| -> Vec<u8> {
            let mut s = seed;
            (0..n)
                .map(|_| {
                    s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
                    (s >> 33) as u8
                })
                .collect()
        };
        let big_a = incompressible(1, 200_000);
        let big_b = incompressible(2, 200_000);
        let small = b"tiny-target".to_vec();
        std::fs::write(staging.join("d/big_a"), &big_a).unwrap();
        std::fs::write(staging.join("d/big_b"), &big_b).unwrap();
        std::fs::write(staging.join("d/target"), &small).unwrap();

        let out = tempfile::tempdir().unwrap();
        let bundle = out.path().join("lake.znip");
        let eng = ZnippyArchive::new();
        eng.pack(&staging, "v", &bundle).unwrap();

        let total = std::fs::metadata(&bundle).unwrap().len();
        let idx = ZnippyArchive::load_index(&bundle).unwrap();
        let target_row = idx.rows.iter().find(|r| r.path == "d/target").unwrap();

        // A seek index EXISTS and locates the entry: read the target blob + the
        // footer + the index blob, and assert that sum is FAR less than the full
        // file (the two big blobs are never touched).
        let footer_bytes = ZNIPPY_FOOTER_SIZE;
        // Recompute index_clen the way read_footer does, to count index bytes.
        let mut f = std::fs::File::open(&bundle).unwrap();
        let (_ioff, index_clen) = ZnippyArchive::read_footer(&mut f).unwrap();
        let touched = target_row.blob_clen + footer_bytes + index_clen;
        assert!(
            touched < total / 2,
            "selective read touched {touched} of {total} bytes — must skip the big blobs"
        );

        // And it actually returns the right bytes.
        assert_eq!(eng.read_entry(&bundle, "d/target").unwrap(), small);
    }

    /// LAW 1 (inject-assert): DELTA ROUND-TRIP — `apply(a, delta(a, b)) == b`
    /// byte-identical, AND the delta ships only the changed/new blobs (zero bytes
    /// for unchanged entries).
    #[cfg(feature = "znippy")]
    #[test]
    fn znippy_delta_round_trip_is_byte_identical_and_ships_only_changes() {
        let eng = ZnippyArchive::new();
        let out = tempfile::tempdir().unwrap();

        // Bundle A: three files.
        let stage_a = out.path().join("a");
        std::fs::create_dir_all(stage_a.join("bin")).unwrap();
        std::fs::write(stage_a.join("bin/holger"), b"holger-v1").unwrap();
        std::fs::write(stage_a.join("bin/tunnr"), b"tunnr-v1-unchanged").unwrap();
        std::fs::write(stage_a.join("README"), b"keep-me-too").unwrap();
        let bundle_a = out.path().join("a.znip");
        let manifest_a = eng.pack(&stage_a, "20250101", &bundle_a).unwrap();

        // Bundle B: holger changed, tunnr + README unchanged, a NEW file added.
        let stage_b = out.path().join("b");
        std::fs::create_dir_all(stage_b.join("bin")).unwrap();
        std::fs::write(stage_b.join("bin/holger"), b"holger-v2-CHANGED-and-longer").unwrap();
        std::fs::write(stage_b.join("bin/tunnr"), b"tunnr-v1-unchanged").unwrap();
        std::fs::write(stage_b.join("README"), b"keep-me-too").unwrap();
        std::fs::write(stage_b.join("bin/new-tool"), b"brand-new-binary").unwrap();
        let bundle_b_full = out.path().join("b-full.znip");
        let manifest_b_full = eng.pack(&stage_b, "20250202", &bundle_b_full).unwrap();

        // delta(A, B): only holger (changed) + new-tool (new) ship; tunnr + README reused.
        let patch = eng.delta(&manifest_a, &stage_b, "20250202").unwrap();
        let added: std::collections::BTreeSet<&str> = patch.added.iter().map(|e| e.path.as_str()).collect();
        let kept: std::collections::BTreeSet<&str> = patch.kept.iter().map(|e| e.path.as_str()).collect();
        assert_eq!(added, ["bin/holger", "bin/new-tool"].into_iter().collect());
        assert_eq!(kept, ["bin/tunnr", "README"].into_iter().collect());
        assert_eq!(patch.reused(), 2);
        // Shipped bytes = only the two new/changed entries' raw bytes.
        let expect_shipped = (b"holger-v2-CHANGED-and-longer".len() + b"brand-new-binary".len()) as u64;
        assert_eq!(patch.shipped_bytes(), expect_shipped, "ships only the diff");

        // apply(A, patch) → B, reusing tunnr+README from bundle A (never the tree).
        let bundle_b_delta = out.path().join("b-delta.znip");
        let manifest_b_delta = eng.apply(&bundle_a, &patch, &bundle_b_delta).unwrap();

        // The delta-built B equals the freshly-packed B at the manifest level…
        assert_eq!(manifest_b_delta.root_sha256, manifest_b_full.root_sha256, "same content");
        assert_eq!(manifest_b_delta.entries, manifest_b_full.entries);

        // …and every entry reads back byte-identical to the source tree.
        for rel in ["bin/holger", "bin/tunnr", "README", "bin/new-tool"] {
            let from_delta = eng.read_entry(&bundle_b_delta, rel).unwrap();
            let from_disk = std::fs::read(stage_b.join(rel)).unwrap();
            assert_eq!(from_delta, from_disk, "delta-applied `{rel}` matches source");
        }

        // The delta bundle is BYTE-IDENTICAL to the full pack (deterministic layout).
        assert_eq!(
            std::fs::read(&bundle_b_delta).unwrap(),
            std::fs::read(&bundle_b_full).unwrap(),
            "apply(a, delta(a,b)) is byte-identical to a fresh pack of b"
        );
    }

    /// HEAVY / NETWORK (AIRGAP-DELTA-REAL): prove the content-delta on REAL
    /// artifacts, not synthetic bytes. Download two successive versions of one
    /// crate (the *changed* file, same path) plus a second crate that is identical
    /// across generations (the *reused* file). Assert delta(A,B) ships ONLY the
    /// changed blob (the reused crate's bytes are saved) and apply() reproduces B
    /// byte-for-byte. Ignored by default:
    /// `cargo test -p skidbladnir --features znippy -- --ignored znippy_delta_real`.
    #[cfg(feature = "znippy")]
    #[test]
    #[ignore = "heavy: downloads real .crate files from static.crates.io"]
    fn znippy_delta_real_download_ships_only_changed_blob() {
        // Pure-Rust HTTP (ureq + rustls) — NO `curl` subprocess (100% Rust).
        fn fetch(url: &str, dest: &std::path::Path) -> bool {
            use std::io::Read;
            let resp = match ureq::get(url).call() {
                Ok(r) => r,
                Err(_) => return false,
            };
            let mut bytes = Vec::new();
            if resp.into_reader().read_to_end(&mut bytes).is_err() {
                return false;
            }
            std::fs::write(dest, &bytes).is_ok()
        }
        let out = tempfile::tempdir().unwrap();
        let dl = out.path().join("dl");
        std::fs::create_dir_all(&dl).unwrap();
        let shared = dl.join("anyhow.crate"); // identical across A and B
        let x = dl.join("serde-200.crate"); // the changed file, gen A
        let y = dl.join("serde-210.crate"); // the changed file, gen B
        let ok = fetch("https://static.crates.io/crates/anyhow/anyhow-1.0.86.crate", &shared)
            && fetch("https://static.crates.io/crates/serde/serde-1.0.200.crate", &x)
            && fetch("https://static.crates.io/crates/serde/serde-1.0.210.crate", &y);
        if !ok {
            eprintln!("skip znippy_delta_real: no network / ureq fetch failed");
            return;
        }

        let eng = ZnippyArchive::new();
        // Gen A: {anyhow, serde@200}
        let sa = out.path().join("a");
        std::fs::create_dir_all(&sa).unwrap();
        std::fs::copy(&shared, sa.join("anyhow.crate")).unwrap();
        std::fs::copy(&x, sa.join("serde.crate")).unwrap();
        let bundle_a = out.path().join("a.znip");
        let manifest_a = eng.pack(&sa, "v1", &bundle_a).unwrap();

        // Gen B: {anyhow (same bytes), serde@210 (changed, SAME path)}
        let sb = out.path().join("b");
        std::fs::create_dir_all(&sb).unwrap();
        std::fs::copy(&shared, sb.join("anyhow.crate")).unwrap();
        std::fs::copy(&y, sb.join("serde.crate")).unwrap();
        let bundle_b_full = out.path().join("b-full.znip");
        let manifest_b_full = eng.pack(&sb, "v2", &bundle_b_full).unwrap();

        // delta(A,B): anyhow reused (0 bytes), serde.crate shipped (changed).
        let patch = eng.delta(&manifest_a, &sb, "v2").unwrap();
        let added: std::collections::BTreeSet<&str> =
            patch.added.iter().map(|e| e.path.as_str()).collect();
        let kept: std::collections::BTreeSet<&str> =
            patch.kept.iter().map(|e| e.path.as_str()).collect();
        assert_eq!(added, ["serde.crate"].into_iter().collect(), "only the changed file ships");
        assert_eq!(kept, ["anyhow.crate"].into_iter().collect(), "the identical file is reused");

        let serde_y_len = std::fs::metadata(&y).unwrap().len();
        let anyhow_len = std::fs::metadata(&shared).unwrap().len();
        assert_eq!(patch.shipped_bytes(), serde_y_len, "ships exactly serde@210's bytes");
        assert_eq!(patch.reused(), 1);
        eprintln!(
            "REAL DELTA: shipped {} B (serde@210), reused 1 file ({} B anyhow saved)",
            patch.shipped_bytes(),
            anyhow_len
        );

        // apply(A, patch) → B, byte-identical to a fresh pack of B + reads back real bytes.
        let bundle_b_delta = out.path().join("b-delta.znip");
        let manifest_b_delta = eng.apply(&bundle_a, &patch, &bundle_b_delta).unwrap();
        assert_eq!(manifest_b_delta.root_sha256, manifest_b_full.root_sha256, "same content");
        assert_eq!(
            std::fs::read(&bundle_b_delta).unwrap(),
            std::fs::read(&bundle_b_full).unwrap(),
            "apply(a, delta(a,b)) byte-identical to fresh pack of b"
        );
        assert_eq!(eng.read_entry(&bundle_b_delta, "serde.crate").unwrap(), std::fs::read(&y).unwrap());
        assert_eq!(eng.read_entry(&bundle_b_delta, "anyhow.crate").unwrap(), std::fs::read(&shared).unwrap());
    }

    /// SECURITY (zip-slip rejection): the extract engine refuses absolute or
    /// `..`-escaping archive paths — both the pure guard and the public `extract_one`
    /// surface. A contained `a/../b` (resolves inside) is allowed.
    #[cfg(feature = "znippy")]
    #[test]
    fn znippy_extract_rejects_zip_slip_paths() {
        // Pure guard: traversal + absolute are rejected; contained paths pass.
        assert!(reject_unsafe_entry_path("../escape").is_err());
        assert!(reject_unsafe_entry_path("a/../../escape").is_err());
        assert!(reject_unsafe_entry_path("/etc/passwd").is_err());
        assert!(reject_unsafe_entry_path("../../root/.ssh/authorized_keys").is_err());
        assert!(reject_unsafe_entry_path("linux-x86_64/holger").is_ok());
        assert!(reject_unsafe_entry_path("a/../b").is_ok(), "stays inside dest");

        // Public API: extract_one refuses a traversing entry name before writing.
        let (_d, staging) = staging_fixture();
        let out = tempfile::tempdir().unwrap();
        let bundle = out.path().join("lake.znip");
        let eng = ZnippyArchive::new();
        eng.pack(&staging, "v", &bundle).unwrap();
        let dest = out.path().join("victim");
        assert!(
            eng.extract_one(&bundle, "../evil", &dest).is_err(),
            "zip-slip entry must be rejected by extract_one"
        );
    }

    /// A znippy bundle round-trips a full extract byte-for-byte (the engine is a
    /// faithful drop-in for TarZstd behind the Archive trait).
    #[cfg(feature = "znippy")]
    #[test]
    fn znippy_full_extract_round_trips() {
        let (_d, staging) = staging_fixture();
        let out = tempfile::tempdir().unwrap();
        let bundle = out.path().join("lake.znip");
        let eng = ZnippyArchive::new();
        eng.pack(&staging, "v", &bundle).unwrap();

        let dest = out.path().join("ex");
        eng.extract_all(&bundle, &dest).unwrap();
        assert_eq!(std::fs::read(dest.join("linux-x86_64/holger")).unwrap(), b"#!holger-binary-bytes");
        assert_eq!(std::fs::read(dest.join("manifests/holger.toml")).unwrap(), b"name=\"holger\"\n");

        let mut listed = eng.list(&bundle).unwrap();
        listed.sort();
        assert_eq!(listed, vec!["linux-x86_64/holger", "manifests/holger.toml"]);
    }
}
