server/index-rows/src/helm.rs
//! A Helm chart's `Chart.yaml`, read out of its `.tgz` (moved here from `znippy-helm`'s
//! `index.rs`, lane HOL-PERF-2): the scan worker runs it at intake and answers the fields
//! as an [`crate::IndexRow::Helm`]; `znippy-helm` keeps calling it for an archive that
//! carries no rows.
//!
//! No YAML dependency is used: `Chart.yaml` files only need a handful of top-level scalar
//! fields, handled with a small, explicit parser.
use std::collections::BTreeMap;
/// What a chart that cannot be read says.
pub type Result<T> = std::result::Result<T, String>;
macro_rules! anyhow {
($($t:tt)*) => { format!($($t)*) };
}
/// The subset of `Chart.yaml` fields holger needs to index a chart.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChartMeta {
pub name: String,
pub version: String,
pub app_version: Option<String>,
pub description: Option<String>,
}
/// Hard ceiling on the bytes a chart `.tgz` may inflate to while its `Chart.yaml`
/// is read.
///
/// A `Chart.yaml` is a few KiB and a whole chart is a fraction of a MiB, so this
/// is orders of magnitude of headroom — while a crafted `.tgz` near DEFLATE's
/// ~1032:1 ceiling would otherwise inflate a ~10 MB upload toward 10 GB **inside
/// the index build**, which an unauthenticated `GET /{repo}/index.yaml` triggers
/// for *every* chart in the repository at once. Capped, such a chart costs at most
/// this much before it is refused, and the index build skips it (logged)
/// instead of taking the process down.
pub const MAX_CHART_DECOMPRESSED_BYTES: usize = 32 * 1024 * 1024;
/// Extract the chart metadata from a packaged chart (`.tgz`).
///
/// Helm packages a chart as `gzip(tar)` with every entry under a top-level
/// directory named after the chart, e.g. `njord/Chart.yaml`. We locate the
/// first `Chart.yaml` and parse its top-level scalar fields.
///
/// Decompression is bounded by [`MAX_CHART_DECOMPRESSED_BYTES`]; a chart that
/// inflates past it is an `Err`, never an allocation.
pub fn chart_meta_from_tgz(tgz: &[u8]) -> Result<ChartMeta> {
chart_meta_from_tgz_capped(tgz, MAX_CHART_DECOMPRESSED_BYTES)
}
/// [`chart_meta_from_tgz`] with an explicit decompression ceiling — the seam the
/// bomb test drives with a small cap instead of allocating a real one.
pub fn chart_meta_from_tgz_capped(tgz: &[u8], max_out: usize) -> Result<ChartMeta> {
crate::counted();
// Constellation decompression law: the chart tarball is inflated by the
// znippy-zoomies parallel, zero-copy gzip codec (`lgz`) — not a single-core
// `flate2::GzDecoder`. `decompress_tar_gz_filter_capped` inflates the gzip
// stream and returns only the entries whose path contains the needle, so we
// never materialise the whole archive — and it bails with `OUTPUT_TOO_LARGE`
// the moment the running output (or one tar entry) would cross `max_out`, so
// peak memory stays at ~the cap even for a decompression bomb.
let entries = lgz::decompress_tar_gz_filter_capped(tgz, "Chart.yaml", max_out)
.map_err(|e| anyhow!("lgz decode of chart archive failed: {e}"))?;
// Pick the first (tar-order) `<chart>/Chart.yaml` or bare `Chart.yaml`; the
// needle match above is a substring, so re-apply the exact depth predicate.
let text = entries
.into_iter()
.find(|(path, _)| {
path == "Chart.yaml"
|| (path.rsplit('/').next() == Some("Chart.yaml") && path.matches('/').count() <= 1)
})
.map(|(_, bytes)| String::from_utf8_lossy(&bytes).into_owned())
.ok_or_else(|| anyhow!("Chart.yaml not found in chart archive"))?;
parse_chart_yaml(&text)
}
/// Parse the top-level scalar fields of a `Chart.yaml`.
///
/// Only unindented `key: value` lines are considered (nested maps/sequences are
/// ignored), which is all that is needed for `name`, `version`, `appVersion`,
/// and `description`.
pub fn parse_chart_yaml(text: &str) -> Result<ChartMeta> {
let mut fields: BTreeMap<String, String> = BTreeMap::new();
for line in text.lines() {
// Top-level keys only: no leading whitespace, not a comment/list item.
if line.is_empty()
|| line.starts_with(|c: char| c.is_whitespace())
|| line.starts_with('#')
|| line.starts_with('-')
{
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
if key.is_empty() || key.contains(' ') {
continue;
}
fields.insert(key.to_string(), unquote(value.trim()));
}
}
let name = fields
.get("name")
.filter(|s| !s.is_empty())
.cloned()
.ok_or_else(|| anyhow!("Chart.yaml missing `name`"))?;
let version = fields
.get("version")
.filter(|s| !s.is_empty())
.cloned()
.ok_or_else(|| anyhow!("Chart.yaml missing `version`"))?;
Ok(ChartMeta {
name,
version,
app_version: fields.get("appVersion").filter(|s| !s.is_empty()).cloned(),
description: fields.get("description").filter(|s| !s.is_empty()).cloned(),
})
}
/// Strip surrounding single/double quotes from a YAML scalar.
fn unquote(s: &str) -> String {
let bytes = s.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}