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

nordisk/holger

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()
    }
}