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

nordisk/holger

server/repository/znippy-helm/src/index.rs

//! Helm repository index generation.
//!
//! A Helm chart repository is, at minimum, a set of `*.tgz` chart archives plus
//! an `index.yaml` that lists every chart/version with its SHA-256 digest and
//! download URL. This module turns a set of chart tarballs into a valid
//! `index.yaml` (Helm `apiVersion: v1`) and extracts the chart metadata from a
//! packaged chart, so holger can *host* charts rather than only serve a
//! pre-baked index.
//!
//! No YAML dependency is used: `index.yaml` has a fixed, simple shape and chart
//! `Chart.yaml` files only need a handful of top-level scalar fields, so both are
//! handled with small, explicit parsers/emitters. This keeps the airgap
//! dependency surface minimal.

use std::collections::BTreeMap;

use anyhow::{anyhow, Result};

pub use holger_index_rows::helm::{
    chart_meta_from_tgz_capped, parse_chart_yaml, ChartMeta, MAX_CHART_DECOMPRESSED_BYTES,
};

/// [`holger_index_rows::helm::chart_meta_from_tgz`], as this crate's `anyhow` result.
pub fn chart_meta_from_tgz(tgz: &[u8]) -> Result<ChartMeta> {
    holger_index_rows::helm::chart_meta_from_tgz(tgz).map_err(|e| anyhow!(e))
}

/// SHA-256 hex digest of a chart tarball — the Helm `digest` field.
pub fn chart_digest(tgz: &[u8]) -> String {
    // REUSE-law: the ONE shared content-hash primitive (edda `nornir_hash`),
    // consumed instead of re-rolling `Sha256::new(); hex(...)`. Helm clients verify
    // the `digest` field against the served chart bytes → byte-identical.
    nornir_hash::sha256_hex(tgz)
}

/// One chart artifact to index: its file name and tarball bytes.
pub struct ChartArtifact<'a> {
    pub filename: &'a str,
    pub tgz: &'a [u8],
}

/// Build a Helm `index.yaml` (apiVersion v1) from a set of chart tarballs.
///
/// * `base_url` is prefixed to each chart's file name to form its download URL
///   (use `""` for relative URLs, which Helm resolves against the repo URL).
/// * `created` is an RFC 3339 timestamp string stamped on each entry and on the
///   index itself; passing it in keeps generation deterministic and testable.
pub fn generate_index(
    charts: &[ChartArtifact<'_>],
    base_url: &str,
    created: &str,
) -> Result<String> {
    let mut read = Vec::with_capacity(charts.len());
    for chart in charts {
        // M9: skip-and-log a malformed chart instead of aborting the whole
        // index. One bad `.tgz` previously `?`-aborted generation and the caller
        // served `entries: {}` — hiding *every* chart in the repo.
        match chart_meta_from_tgz(chart.tgz) {
            Ok(m) => read.push((chart.filename.to_string(), m, chart_digest(chart.tgz))),
            Err(e) => log::warn!("helm: skipping malformed chart {}: {e:#}", chart.filename),
        }
    }
    Ok(render_index(&read, base_url, created))
}

/// **Render `index.yaml`** from charts already read: `(file name, Chart.yaml fields,
/// sha256 digest)` each, in order (lane HOL-PERF-2: a chain view hands the rows the scan
/// worker read at intake, so no chart is opened here).
pub fn render_index(
    charts: &[(String, ChartMeta, String)],
    base_url: &str,
    created: &str,
) -> String {
    // chart name → list of versioned entries
    let mut entries: BTreeMap<String, Vec<String>> = BTreeMap::new();

    for (filename, meta, digest) in charts {
        let url = format!("{base_url}{filename}");

        let mut entry = String::new();
        entry.push_str(&format!("    - name: {}\n", yaml_scalar(&meta.name)));
        entry.push_str(&format!("      version: {}\n", yaml_scalar(&meta.version)));
        if let Some(av) = &meta.app_version {
            entry.push_str(&format!("      appVersion: {}\n", yaml_scalar(av)));
        }
        if let Some(desc) = &meta.description {
            entry.push_str(&format!("      description: {}\n", yaml_scalar(desc)));
        }
        entry.push_str(&format!("      created: {}\n", yaml_scalar(created)));
        entry.push_str(&format!("      digest: {digest}\n"));
        entry.push_str("      urls:\n");
        entry.push_str(&format!("        - {}\n", yaml_scalar(&url)));

        entries.entry(meta.name.clone()).or_default().push(entry);
    }

    let mut out = String::new();
    out.push_str("apiVersion: v1\n");
    out.push_str("entries:\n");
    for (name, versions) in &entries {
        out.push_str(&format!("  {}:\n", yaml_scalar(name)));
        for v in versions {
            out.push_str(v);
        }
    }
    out.push_str(&format!("generated: {}\n", yaml_scalar(created)));
    out
}

/// Render a string as a safely double-quoted YAML scalar.
fn yaml_scalar(s: &str) -> String {
    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
    format!("\"{escaped}\"")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(feature = "testmatrix")]
    use traits::test_support::fstatus;

    /// Build a minimal in-memory chart `.tgz` with the given Chart.yaml body.
    fn make_chart_tgz(dir: &str, chart_yaml: &str) -> Vec<u8> {
        use flate2::write::GzEncoder;
        use flate2::Compression;

        let mut tar = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
        let mut header = tar::Header::new_gnu();
        let body = chart_yaml.as_bytes();
        header.set_size(body.len() as u64);
        header.set_mode(0o644);
        header.set_cksum();
        tar.append_data(&mut header, format!("{dir}/Chart.yaml"), body)
            .unwrap();
        let gz = tar.into_inner().unwrap();
        gz.finish().unwrap()
    }

    #[test]
    fn parse_chart_yaml_extracts_fields() {
        let meta = parse_chart_yaml(
            "apiVersion: v2\nname: njord\nversion: 0.1.0\nappVersion: \"0.1.0\"\ndescription: A test chart\ntype: application\n",
        )
        .unwrap();
        assert_eq!(meta.name, "njord");
        assert_eq!(meta.version, "0.1.0");
        assert_eq!(meta.app_version.as_deref(), Some("0.1.0"));
        assert_eq!(meta.description.as_deref(), Some("A test chart"));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "parse_chart_yaml_fields",
            meta.name == "njord"
                && meta.version == "0.1.0"
                && meta.app_version.as_deref() == Some("0.1.0"),
            &format!(
                "name={} version={} appVersion={:?}",
                meta.name, meta.version, meta.app_version
            ),
        );
    }

    #[test]
    fn parse_chart_yaml_ignores_nested_and_requires_name_version() {
        // Nested keys (indented) must not leak into top-level fields.
        let text = "name: app\nversion: 1.2.3\nmaintainers:\n  - name: someone\n";
        let meta = parse_chart_yaml(text).unwrap();
        assert_eq!(meta.name, "app");
        assert_eq!(meta.version, "1.2.3");
        // The indented `name: someone` must not override the chart name.
        assert_eq!(meta.name, "app");

        let no_name_err = parse_chart_yaml("version: 1.0.0\n").is_err();
        assert!(no_name_err);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "parse_chart_yaml_nested_and_required",
            meta.name == "app" && meta.version == "1.2.3" && no_name_err,
            &format!(
                "nested name ignored (name={}), missing-name errors={no_name_err}",
                meta.name
            ),
        );
    }

    /// Build a chart `.tgz` whose `Chart.yaml` is VALID but padded out to
    /// `pad_bytes` of highly compressible comment lines — a small archive that
    /// inflates to something big, i.e. a (miniature) decompression bomb.
    fn make_bomb_chart_tgz(dir: &str, chart_yaml: &str, pad_bytes: usize) -> Vec<u8> {
        const PAD_LINE: &str = "# padding padding padding padding padding\n";
        let mut body = String::with_capacity(chart_yaml.len() + pad_bytes + PAD_LINE.len());
        body.push_str(chart_yaml);
        while body.len() < pad_bytes {
            body.push_str(PAD_LINE);
        }
        make_chart_tgz(dir, &body)
    }

    /// The decompression ceiling is real: a `.tgz` that inflates past the cap is
    /// refused with an error instead of being allocated. Driven through the
    /// explicit-cap seam with a small bomb + a small cap, so the test never
    /// allocates a real one.
    #[test]
    fn chart_meta_from_tgz_capped_refuses_a_bomb() {
        // ~1 MiB of inflate from a few KiB of gzip; cap it at 64 KiB.
        let bomb = make_bomb_chart_tgz("bomb", "name: bomb\nversion: 1.0.0\n", 1024 * 1024);
        assert!(
            bomb.len() < 64 * 1024,
            "the fixture must be a BOMB: {} compressed bytes inflate to ~1 MiB",
            bomb.len()
        );

        let capped = chart_meta_from_tgz_capped(&bomb, 64 * 1024);
        assert!(
            capped.is_err(),
            "a chart inflating past the cap must be refused"
        );

        // Same bytes, a cap above the inflated size → parses normally, so the cap
        // rejects on SIZE and not because the fixture is malformed.
        let ok = chart_meta_from_tgz_capped(&bomb, 4 * 1024 * 1024)
            .expect("under the cap the very same chart parses");
        assert_eq!(ok.name, "bomb");
        assert_eq!(ok.version, "1.0.0");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "chart_decompression_capped",
            capped.is_err() && ok.name == "bomb",
            "chart .tgz inflate is bounded (lgz capped decode); over-cap chart errors, under-cap parses",
        );
    }

    /// A malicious chart in the repository must not be able to take the whole
    /// `index.yaml` (and with it the server) down: it is skipped and logged, and
    /// the honest charts still index. RED without the cap — an uncapped decode
    /// happily inflates the bomb and lists it.
    #[test]
    fn generate_index_skips_decompression_bomb() {
        let good = make_chart_tgz("njord", "name: njord\nversion: 0.2.0\n");
        // Inflates past MAX_CHART_DECOMPRESSED_BYTES from a tiny upload.
        let bomb = make_bomb_chart_tgz(
            "bomb",
            "name: bomb\nversion: 6.6.6\n",
            MAX_CHART_DECOMPRESSED_BYTES + 1024 * 1024,
        );
        assert!(
            bomb.len() < 512 * 1024,
            "fixture must stay small on the wire, got {} bytes",
            bomb.len()
        );

        let charts = [
            ChartArtifact {
                filename: "njord-0.2.0.tgz",
                tgz: &good,
            },
            ChartArtifact {
                filename: "bomb-6.6.6.tgz",
                tgz: &bomb,
            },
        ];
        let yaml = generate_index(&charts, "", "2026-06-27T00:00:00Z")
            .expect("one bomb must not abort index generation");

        let bomb_skipped = !yaml.contains("bomb");
        assert!(
            bomb_skipped,
            "the over-cap chart must be skipped, not indexed:\n{yaml}"
        );
        assert!(yaml.contains("njord"), "honest charts still index:\n{yaml}");
        assert!(yaml.contains("version: \"0.2.0\""), "{yaml}");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "generate_index_skips_bomb",
            bomb_skipped && yaml.contains("njord"),
            "a decompression-bomb chart is skipped (logged); index.yaml still lists the honest charts",
        );
    }

    #[test]
    fn generate_index_skips_malformed_chart() {
        // One valid chart + one garbage "tgz". M9: the bad chart must be
        // skipped (logged), the good one still indexed — generation must NOT
        // abort and empty the whole index.
        let good = make_chart_tgz("njord", "name: njord\nversion: 0.2.0\n");
        let bad = b"not a gzip tarball at all".to_vec();
        let charts = [
            ChartArtifact {
                filename: "njord-0.2.0.tgz",
                tgz: &good,
            },
            ChartArtifact {
                filename: "broken-1.0.0.tgz",
                tgz: &bad,
            },
        ];
        let yaml = generate_index(&charts, "", "2026-06-27T00:00:00Z")
            .expect("generation must not abort on one malformed chart");
        assert!(yaml.contains("njord"), "good chart must be indexed: {yaml}");
        assert!(
            !yaml.contains("broken"),
            "malformed chart must be skipped: {yaml}"
        );
        // Entries are non-empty — the bad chart didn't poison the index.
        assert!(yaml.contains("version: \"0.2.0\""), "{yaml}");
    }

    #[test]
    fn chart_meta_from_tgz_roundtrip() {
        let tgz = make_chart_tgz("njord", "name: njord\nversion: 0.2.0\nappVersion: \"9\"\n");
        let meta = chart_meta_from_tgz(&tgz).unwrap();
        assert_eq!(meta.name, "njord");
        assert_eq!(meta.version, "0.2.0");
        assert_eq!(meta.app_version.as_deref(), Some("9"));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "chart_meta_from_tgz_roundtrip",
            meta.name == "njord"
                && meta.version == "0.2.0"
                && meta.app_version.as_deref() == Some("9"),
            &format!(
                "tgz -> name={} version={} appVersion={:?}",
                meta.name, meta.version, meta.app_version
            ),
        );
    }

    /// Constellation decompression law: chart `.tgz` decode must run through the
    /// znippy-zoomies parallel, zero-copy gzip codec (`lgz`), never a single-core
    /// `flate2::GzDecoder`. Prove it by decoding the same fixture *directly* via
    /// `lgz::decompress_tar_gz_filter` and checking it yields the Chart.yaml that
    /// `chart_meta_from_tgz` parsed — i.e. the public path is wired to `lgz`.
    #[test]
    fn chart_decode_uses_parallel_lgz() {
        let tgz = make_chart_tgz("njord", "name: njord\nversion: 3.1.4\n");

        // Public path (must be lgz-backed).
        let meta = chart_meta_from_tgz(&tgz).unwrap();

        // Independent lgz decode of the same archive — the parallel codec must be
        // able to recover Chart.yaml, confirming it is the codec in the path.
        let entries = lgz::decompress_tar_gz_filter(&tgz, "Chart.yaml").unwrap();
        let via_lgz = entries.iter().any(|(p, b)| {
            p.ends_with("Chart.yaml") && String::from_utf8_lossy(b).contains("name: njord")
        });

        let ok = meta.name == "njord" && meta.version == "3.1.4" && via_lgz;
        assert!(ok, "chart decode must go through lgz parallel codec");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "chart_decode_uses_parallel_lgz",
            ok,
            "chart .tgz decode wired to znippy-zoomies lgz (parallel, zero-copy gzip) — no single-core GzDecoder",
        );
    }

    #[test]
    fn digest_is_stable_sha256() {
        let d = chart_digest(b"hello");
        assert_eq!(
            d,
            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "digest_is_stable_sha256",
            d == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
            &format!("sha256(\"hello\") = {}", &d[..16]),
        );
    }

    #[test]
    fn generate_index_lists_entry_with_digest_and_url() {
        let tgz = make_chart_tgz(
            "njord",
            "name: njord\nversion: 0.1.0\nappVersion: \"0.1.0\"\n",
        );
        let digest = chart_digest(&tgz);
        let charts = [ChartArtifact {
            filename: "njord-0.1.0.tgz",
            tgz: &tgz,
        }];
        let index = generate_index(&charts, "", "2026-01-01T00:00:00Z").unwrap();

        assert!(index.starts_with("apiVersion: v1\n"));
        assert!(index.contains("\n  \"njord\":\n"));
        assert!(index.contains("version: \"0.1.0\""));
        let has_digest = index.contains(&format!("digest: {digest}"));
        assert!(has_digest);
        assert!(index.contains("- \"njord-0.1.0.tgz\""));
        assert!(index.contains("generated: \"2026-01-01T00:00:00Z\""));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "generate_index_entry",
            index.starts_with("apiVersion: v1\n")
                && has_digest
                && index.contains("- \"njord-0.1.0.tgz\""),
            &format!("index entry has version+digest({})+url", &digest[..16]),
        );
    }

    #[test]
    fn generate_index_groups_versions_under_one_chart() {
        let v1 = make_chart_tgz("njord", "name: njord\nversion: 0.1.0\n");
        let v2 = make_chart_tgz("njord", "name: njord\nversion: 0.2.0\n");
        let charts = [
            ChartArtifact {
                filename: "njord-0.1.0.tgz",
                tgz: &v1,
            },
            ChartArtifact {
                filename: "njord-0.2.0.tgz",
                tgz: &v2,
            },
        ];
        let index =
            generate_index(&charts, "https://charts.example/", "2026-01-01T00:00:00Z").unwrap();
        // Exactly one chart key, two versioned entries.
        let keys = index.matches("  \"njord\":").count();
        let entries = index.matches("    - name: \"njord\"").count();
        assert_eq!(keys, 1);
        assert_eq!(entries, 2);
        assert!(index.contains("- \"https://charts.example/njord-0.2.0.tgz\""));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "generate_index_groups_versions",
            keys == 1 && entries == 2,
            &format!("{keys} chart key, {entries} versioned entries"),
        );
    }

    #[test]
    fn generate_index_empty_is_valid_header() {
        // No charts → still a well-formed, Helm-parseable empty index (apiVersion +
        // an empty `entries:` map + the `generated` stamp), never a truncated file.
        let index = generate_index(&[], "", "2026-02-02T00:00:00Z").unwrap();
        assert!(index.starts_with("apiVersion: v1\n"), "{index}");
        assert!(
            index.contains("entries:\n"),
            "empty entries map present: {index}"
        );
        assert!(
            index.contains("generated: \"2026-02-02T00:00:00Z\""),
            "{index}"
        );
        // No chart keys were emitted.
        assert!(
            !index.contains("    - name:"),
            "no entries for an empty repo: {index}"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "generate_index_empty",
            index.starts_with("apiVersion: v1\n")
                && index.contains("entries:\n")
                && !index.contains("    - name:"),
            "empty chart set → valid empty index.yaml",
        );
    }

    #[test]
    fn parse_chart_yaml_unquotes_single_quotes() {
        // Single-quoted scalars (Helm allows either quote style) are unwrapped.
        let meta = parse_chart_yaml(
            "name: 'my-app'\nversion: '1.4.2'\ndescription: 'a single-quoted desc'\n",
        )
        .unwrap();
        assert_eq!(meta.name, "my-app", "single-quoted name unwrapped");
        assert_eq!(meta.version, "1.4.2", "single-quoted version unwrapped");
        assert_eq!(meta.description.as_deref(), Some("a single-quoted desc"));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "parse_chart_yaml_single_quotes",
            meta.name == "my-app" && meta.version == "1.4.2",
            "single-quoted Chart.yaml scalars are unwrapped",
        );
    }

    #[test]
    fn generate_index_escapes_special_scalars() {
        // A description carrying a double-quote and a backslash must be emitted as a
        // valid double-quoted YAML scalar (escaped), so the index stays parseable.
        let tgz = make_chart_tgz(
            "quoter",
            "name: quoter\nversion: 1.0.0\ndescription: he said \"hi\" and used C:\\path\n",
        );
        let charts = [ChartArtifact {
            filename: "quoter-1.0.0.tgz",
            tgz: &tgz,
        }];
        let index = generate_index(&charts, "", "2026-01-01T00:00:00Z").unwrap();

        // The embedded quote is backslash-escaped, and the backslash is doubled.
        let has_escaped_quote = index.contains("\\\"hi\\\"");
        let has_escaped_backslash = index.contains("C:\\\\path");
        assert!(
            has_escaped_quote,
            "embedded quote escaped in the YAML scalar: {index}"
        );
        assert!(has_escaped_backslash, "embedded backslash doubled: {index}");
        // The name/version still parse back out cleanly through the same decoder.
        let round = chart_meta_from_tgz(&tgz).unwrap();
        assert_eq!(round.name, "quoter");
        assert_eq!(
            round.description.as_deref(),
            Some("he said \"hi\" and used C:\\path")
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-helm",
            "generate_index_escapes_scalars",
            has_escaped_quote && has_escaped_backslash,
            "special chars in Chart.yaml emit as escaped, valid YAML scalars",
        );
    }
}