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",
);
}
}