helm-schema 0.0.4

Generate an accurate JSON schema for any helm chart
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path};

use flate2::read::GzDecoder;
use serde::Deserialize;
use tracing::instrument;
use vfs::VfsPath;

use super::paths::scope_values_path;
use super::types::{ChartContext, ChartDependencyActivation};
use crate::error::{CliError, EngineResult};
use crate::load_budget::{LoadBudget, read_to_end_capped};

#[derive(Debug, Deserialize)]
struct ChartYaml {
    name: Option<String>,

    #[serde(rename = "type")]
    chart_type: Option<String>,

    dependencies: Option<Vec<ChartDependency>>,
}

#[derive(Debug, Deserialize)]
struct ChartDependency {
    name: String,
    alias: Option<String>,
    condition: Option<String>,
    tags: Option<Vec<String>>,
}

#[derive(Debug, Clone)]
struct DependencyMetadata {
    values_key: String,
    activation: ChartDependencyActivation,
}

#[instrument(skip_all)]
pub fn discover_chart_contexts(root_chart_dir: &VfsPath) -> EngineResult<Vec<ChartContext>> {
    discover_chart_contexts_with_budget(root_chart_dir, LoadBudget::default())
}

#[instrument(skip_all)]
pub(crate) fn discover_chart_contexts_with_budget(
    root_chart_dir: &VfsPath,
    load_budget: LoadBudget,
) -> EngineResult<Vec<ChartContext>> {
    let mut out = Vec::new();
    discover_chart_contexts_inner(root_chart_dir, &[], &[], load_budget, &mut out)?;
    Ok(out)
}

fn discover_chart_contexts_inner(
    chart_dir: &VfsPath,
    parent_prefix: &[String],
    dependency_activation_chain: &[ChartDependencyActivation],
    load_budget: LoadBudget,
    out: &mut Vec<ChartContext>,
) -> EngineResult<()> {
    let chart_yaml = read_chart_yaml(chart_dir)?;

    let is_library = chart_yaml
        .chart_type
        .as_deref()
        .is_some_and(|chart_type| chart_type.eq_ignore_ascii_case("library"));

    out.push(ChartContext {
        chart_dir: chart_dir.clone(),
        values_prefix: parent_prefix.to_vec(),
        is_library,
        dependency_activation_chain: dependency_activation_chain.to_vec(),
    });

    let dependency_metadata_by_name = dependency_metadata_map(&chart_yaml, parent_prefix);

    let vendor_charts_dir = chart_dir.join("charts")?;
    if !vendor_charts_dir.is_dir()? {
        return Ok(());
    }

    // VFS backends do not promise read_dir ordering. Keep dependency traversal
    // stable because DefineIndex and helper graph construction use
    // last-write-wins semantics for duplicate helper definitions.
    let mut vendor_entries: Vec<VfsPath> = vendor_charts_dir.read_dir()?.collect();
    vendor_entries.sort_by_key(VfsPath::filename);

    for entry in vendor_entries {
        let sub_dir = if entry.is_dir()? {
            let chart_yaml_path = entry.join("Chart.yaml")?;
            let chart_template_yaml_path = entry.join("Chart.template.yaml")?;
            if !chart_yaml_path.is_file()? && !chart_template_yaml_path.is_file()? {
                continue;
            }
            entry
        } else if entry.is_file()? {
            if !is_chart_archive(&entry.filename()) {
                continue;
            }

            extract_chart_archive(&entry, load_budget)?
        } else {
            continue;
        };

        let sub_chart_yaml = read_chart_yaml(&sub_dir)?;
        let sub_name = sub_chart_yaml
            .name
            .clone()
            .or_else(|| {
                let name = sub_dir.filename();
                if name.is_empty() { None } else { Some(name) }
            })
            .ok_or_else(|| CliError::SubchartNameMissing {
                path: sub_dir.as_str().to_string(),
            })?;

        let dependency_metadata = dependency_metadata_by_name
            .get(&sub_name)
            .cloned()
            .unwrap_or_else(|| DependencyMetadata {
                values_key: sub_name.clone(),
                activation: ChartDependencyActivation::default(),
            });

        let mut prefix = parent_prefix.to_vec();
        prefix.push(dependency_metadata.values_key);

        // Only condition/tag-carrying edges add an activation level; an
        // unconditional dependency keeps its parent's chain, so a child's
        // chain always extends its parent's as a prefix.
        let mut chain = dependency_activation_chain.to_vec();
        let activation = dependency_metadata.activation;
        if !activation.condition_paths.is_empty() || !activation.tag_paths.is_empty() {
            chain.push(activation);
        }

        discover_chart_contexts_inner(&sub_dir, &prefix, &chain, load_budget, out)?;
    }

    Ok(())
}

fn is_chart_archive(file_name: &str) -> bool {
    let path = Path::new(file_name);
    let is_tgz = path
        .extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| extension.eq_ignore_ascii_case("tgz"));
    let is_tar_gz = path
        .extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| extension.eq_ignore_ascii_case("gz"))
        && path
            .file_stem()
            .and_then(|stem| Path::new(stem).extension())
            .and_then(|extension| extension.to_str())
            .is_some_and(|extension| extension.eq_ignore_ascii_case("tar"));

    is_tgz || is_tar_gz
}

fn extract_chart_archive(path: &VfsPath, load_budget: LoadBudget) -> EngineResult<VfsPath> {
    let mut file = path.open_file()?;
    let bytes = read_to_end_capped(
        &mut file,
        load_budget.max_chart_archive_bytes,
        path.as_str().to_string(),
    )?;

    let gz = GzDecoder::new(bytes.as_slice());
    let mut archive = tar::Archive::new(gz);

    let root = VfsPath::new(vfs::MemoryFS::new());
    let mut extracted_entries = 0usize;
    let mut extracted_bytes = 0usize;
    for entry in archive.entries()? {
        let mut entry = entry?;
        if !entry.header().entry_type().is_file() {
            continue;
        }
        extracted_entries = extracted_entries.saturating_add(1);
        if extracted_entries > load_budget.max_chart_archive_entries {
            return Err(CliError::LoadEntryBudgetExceeded {
                subject: path.as_str().to_string(),
                limit_entries: load_budget.max_chart_archive_entries,
            });
        }
        let entry_path = entry.path()?;
        validate_archive_entry_path(path.as_str(), &entry_path)?;
        let path_str = entry_path.to_string_lossy();
        let out = root.join(path_str.as_ref())?;
        out.parent().create_dir_all()?;
        let mut file = out.create_file()?;
        let copied = std::io::copy(&mut entry, &mut file)?;
        extracted_bytes =
            extracted_bytes.saturating_add(usize::try_from(copied).unwrap_or(usize::MAX));
        if extracted_bytes > load_budget.max_chart_archive_unpacked_bytes {
            return Err(CliError::LoadBudgetExceeded {
                subject: format!("expanded {}", path.as_str()),
                limit_bytes: load_budget.max_chart_archive_unpacked_bytes,
            });
        }
    }

    find_chart_dir(&root)?.ok_or_else(|| CliError::NoChartYamlInArchive {
        archive: path.as_str().to_string(),
    })
}

pub(crate) fn validate_archive_entry_path(archive: &str, entry_path: &Path) -> EngineResult<()> {
    // `components()` parses with the HOST's separator rules, but tar entry
    // names are platform-independent `/`-separated strings: an entry named
    // `..\evil` parses as a traversal on Windows and as one opaque filename
    // on Unix. Chart archives never legitimately contain backslashes (Helm
    // requires `/`-separated member names), so reject them outright — the
    // same entries then pass or fail identically on every platform.
    let is_safe = entry_path.components().all(|component| match component {
        Component::Normal(name) => !name.to_string_lossy().contains('\\'),
        Component::CurDir => true,
        _ => false,
    });
    if is_safe {
        return Ok(());
    }

    Err(CliError::UnsafeArchiveEntryPath {
        archive: archive.to_string(),
        entry_path: entry_path.display().to_string(),
    })
}

fn find_chart_dir(root: &VfsPath) -> EngineResult<Option<VfsPath>> {
    let direct = root.join("Chart.yaml")?;
    let direct_template = root.join("Chart.template.yaml")?;
    if direct.is_file()? || direct_template.is_file()? {
        return Ok(Some(root.clone()));
    }

    // Multi-chart archives are rare, but choosing alphabetically keeps the
    // fallback deterministic across filesystems.
    let mut entries: Vec<VfsPath> = root.read_dir()?.collect();
    entries.sort_by_key(VfsPath::filename);
    for entry in entries {
        if !entry.is_dir()? {
            continue;
        }
        let chart_yaml = entry.join("Chart.yaml")?;
        let chart_template_yaml = entry.join("Chart.template.yaml")?;
        if chart_yaml.is_file()? || chart_template_yaml.is_file()? {
            return Ok(Some(entry));
        }
    }

    Ok(None)
}

fn dependency_metadata_map(
    chart_yaml: &ChartYaml,
    parent_prefix: &[String],
) -> BTreeMap<String, DependencyMetadata> {
    let mut out = BTreeMap::new();
    let deps = chart_yaml.dependencies.as_deref().unwrap_or_default();

    for dependency in deps {
        let values_key = dependency
            .alias
            .clone()
            .unwrap_or_else(|| dependency.name.clone());
        out.insert(
            dependency.name.clone(),
            DependencyMetadata {
                values_key,
                activation: dependency_activation(dependency, parent_prefix),
            },
        );
    }

    out
}

fn dependency_activation(
    dependency: &ChartDependency,
    parent_prefix: &[String],
) -> ChartDependencyActivation {
    let condition_paths = dependency
        .condition
        .as_deref()
        .map(|condition| dependency_condition_paths(condition, parent_prefix))
        .unwrap_or_default();

    let tag_paths = dependency
        .tags
        .as_deref()
        .map(dependency_tag_paths)
        .unwrap_or_default();

    ChartDependencyActivation {
        condition_paths,
        tag_paths,
    }
}

fn dependency_condition_paths(condition: &str, parent_prefix: &[String]) -> Vec<String> {
    let mut seen = BTreeSet::new();
    let mut paths = Vec::new();
    for path in condition
        .split(',')
        .map(str::trim)
        .filter(|path| !path.is_empty())
        .map(|path| scope_chart_yaml_value_path(path, parent_prefix))
    {
        if seen.insert(path.clone()) {
            paths.push(path);
        }
    }
    paths
}

fn dependency_tag_paths(tags: &[String]) -> Vec<String> {
    tags.iter()
        .map(|tag| tag.trim())
        .filter(|tag| !tag.is_empty())
        .map(|tag| format!("tags.{tag}"))
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

fn scope_chart_yaml_value_path(path: &str, prefix: &[String]) -> String {
    let path = path.trim();
    if path == "tags" || path.starts_with("tags.") {
        return path.to_string();
    }
    scope_values_path(path, prefix)
}

fn read_chart_yaml(chart_dir: &VfsPath) -> EngineResult<ChartYaml> {
    let chart_yaml = chart_dir.join("Chart.yaml")?;
    let chart_template_yaml = chart_dir.join("Chart.template.yaml")?;

    let path = if chart_yaml.is_file()? {
        chart_yaml
    } else if chart_template_yaml.is_file()? {
        chart_template_yaml
    } else {
        chart_yaml
    };

    Ok(serde_yaml::from_str(&path.read_to_string()?)?)
}