greentic-start-dev 1.1.27190108346

Greentic lifecycle runner for start/restart/stop orchestration
Documentation
use std::{
    collections::{BTreeMap, BTreeSet},
    fs::File,
    io::Read,
    path::Path,
};

use anyhow::{Result, anyhow};
use serde::Deserialize;
use serde_cbor::value::Value as CborValue;
use zip::{ZipArchive, result::ZipError};

type CborMap = BTreeMap<CborValue, CborValue>;

/// Derive the set of secret-marked answer keys for a pack using the SAME
/// source the B12a producer (greentic-setup) redacts from: `pack_to_form_spec`
/// unions `setup.yaml` / `qa/*.json` questions flagged `secret: true` AND
/// `assets/secret-requirements.json` entries (required and optional). Keys are
/// canonicalized via `canonical_secret_name` so reader-side lookups line up
/// with the producer's redaction and the dev-store URIs.
///
/// `load_secret_keys_from_pack` (below) is intentionally NOT used for B12a
/// reader paths: it reads only the requirements asset, filtered to
/// required-only, and lowercases (keeping `-`/`.`). That set is narrower than
/// and normalized differently from the producer's, so a form-declared-only or
/// optional secret would be stripped from disk yet never re-fetched. This
/// helper closes that gap by reusing the producer's exact derivation.
pub fn secret_answer_keys_for_pack(pack_path: &Path, provider_id: &str) -> BTreeSet<String> {
    let Some(form) = greentic_setup::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
    else {
        return BTreeSet::new();
    };
    form.questions
        .iter()
        .filter(|q| q.secret)
        .map(|q| greentic_setup::secret_name::canonical_secret_name(&q.id))
        .collect()
}

/// True if `answer_key` is a secret per `secret_keys` (canonical set from
/// [`secret_answer_keys_for_pack`]). Mirrors the producer's
/// `is_secret_answer_key` exactly — canonical equality or a forward suffix
/// match (secret key ends with the answer key, e.g. `webex_bot_token`
/// satisfied by `bot_token`) — so the reader's defense-in-depth skip lines up
/// with what the producer redacts and the dev store seeds.
pub fn answer_key_is_secret(answer_key: &str, secret_keys: &BTreeSet<String>) -> bool {
    let norm = greentic_setup::secret_name::canonical_secret_name(answer_key);
    secret_keys
        .iter()
        .any(|secret| secret == &norm || secret.ends_with(&norm))
}

pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
    let keys = load_keys_from_assets(pack_path)?;
    if !keys.is_empty() {
        return Ok(keys);
    }
    let keys = load_keys_from_manifest(pack_path)?;
    if !keys.is_empty() {
        return Ok(keys);
    }
    load_keys_from_component_manifests(pack_path)
}

fn load_keys_from_assets(pack_path: &Path) -> Result<Vec<String>> {
    let file = File::open(pack_path)?;
    let mut archive = ZipArchive::new(file)?;
    const ASSET_PATHS: &[&str] = &[
        "assets/secret-requirements.json",
        "assets/secret_requirements.json",
        "secret-requirements.json",
        "secret_requirements.json",
    ];
    for asset in ASSET_PATHS {
        if let Ok(mut entry) = archive.by_name(asset) {
            let mut contents = String::new();
            entry.read_to_string(&mut contents)?;
            let requirements: Vec<AssetSecretRequirement> = serde_json::from_str(&contents)?;
            return Ok(requirements
                .into_iter()
                .filter(|req| req.required.unwrap_or(true))
                .filter_map(|req| req.key)
                .map(|key| key.to_lowercase())
                .collect());
        }
    }
    Ok(Vec::new())
}

fn load_keys_from_manifest(pack_path: &Path) -> Result<Vec<String>> {
    let file = File::open(pack_path)?;
    let mut archive = ZipArchive::new(file)?;
    let mut manifest = match archive.by_name("manifest.cbor") {
        Ok(file) => file,
        Err(ZipError::FileNotFound) => return Ok(Vec::new()),
        Err(err) => return Err(err.into()),
    };
    let mut bytes = Vec::new();
    manifest.read_to_end(&mut bytes)?;
    let value: CborValue = serde_cbor::from_slice(&bytes)?;
    if let CborValue::Map(map) = &value {
        return extract_keys_from_manifest_map(map);
    }
    Ok(Vec::new())
}

fn load_keys_from_component_manifests(pack_path: &Path) -> Result<Vec<String>> {
    let file = File::open(pack_path)?;
    let mut archive = ZipArchive::new(file)?;
    let mut keys = BTreeSet::new();

    for index in 0..archive.len() {
        let mut entry = archive.by_index(index)?;
        let name = entry.name().to_string();
        if !name.ends_with("component.manifest.json") {
            continue;
        }

        let mut contents = String::new();
        entry.read_to_string(&mut contents)?;
        let manifest: ComponentManifest = serde_json::from_str(&contents)?;
        for requirement in manifest.secret_requirements {
            if requirement.required.unwrap_or(true)
                && let Some(key) = requirement.key
            {
                keys.insert(key.to_lowercase());
            }
        }
    }

    Ok(keys.into_iter().collect())
}

fn extract_keys_from_manifest_map(map: &CborMap) -> Result<Vec<String>> {
    let symbols = symbols_map(map);
    let mut keys = Vec::new();
    if let Some(CborValue::Array(entries)) = map_get(map, "secret_requirements") {
        for entry in entries {
            if let CborValue::Map(entry_map) = entry {
                if !is_required(entry_map) {
                    continue;
                }
                if let Some(key_value) = map_get(entry_map, "key")
                    && let Some(key) =
                        resolve_string_symbol(Some(key_value), symbols, "secret_requirements")?
                {
                    keys.push(key.to_lowercase());
                }
            }
        }
    }
    Ok(keys)
}

fn is_required(entry: &CborMap) -> bool {
    match map_get(entry, "required") {
        Some(CborValue::Bool(value)) => *value,
        _ => true,
    }
}

fn map_get<'a>(map: &'a CborMap, key: &str) -> Option<&'a CborValue> {
    map.iter().find_map(|(k, v)| match k {
        CborValue::Text(text) if text == key => Some(v),
        _ => None,
    })
}

fn symbols_map(map: &CborMap) -> Option<&CborMap> {
    let symbols = map_get(map, "symbols")?;
    match symbols {
        CborValue::Map(map) => Some(map),
        _ => None,
    }
}

fn resolve_string_symbol(
    value: Option<&CborValue>,
    symbols: Option<&CborMap>,
    symbol_key: &str,
) -> Result<Option<String>> {
    let Some(value) = value else {
        return Ok(None);
    };
    match value {
        CborValue::Text(text) => Ok(Some(text.clone())),
        CborValue::Integer(idx) => {
            let Some(symbols) = symbols else {
                return Ok(Some(idx.to_string()));
            };
            let Some(values) = symbol_array(symbols, symbol_key) else {
                return Ok(Some(idx.to_string()));
            };
            let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
            match values.get(idx) {
                Some(CborValue::Text(text)) => Ok(Some(text.clone())),
                _ => Ok(Some(idx.to_string())),
            }
        }
        _ => Err(anyhow!("expected string or symbol index")),
    }
}

fn symbol_array<'a>(symbols: &'a CborMap, key: &'a str) -> Option<&'a Vec<CborValue>> {
    if let Some(CborValue::Array(values)) = map_get(symbols, key) {
        return Some(values);
    }
    if let Some(stripped) = key.strip_suffix('s')
        && let Some(CborValue::Array(values)) = map_get(symbols, stripped)
    {
        return Some(values);
    }
    None
}

#[derive(Deserialize)]
struct AssetSecretRequirement {
    key: Option<String>,
    #[serde(default)]
    required: Option<bool>,
}

#[derive(Deserialize)]
struct ComponentManifest {
    #[serde(default)]
    secret_requirements: Vec<AssetSecretRequirement>,
}

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

    use tempfile::tempdir;
    use zip::write::FileOptions;

    fn write_pack(path: &Path, entries: &[(&str, Vec<u8>)]) {
        let file = File::create(path).expect("create pack");
        let mut zip = zip::ZipWriter::new(file);
        for (name, bytes) in entries {
            zip.start_file(*name, FileOptions::<()>::default())
                .expect("start file");
            zip.write_all(bytes).expect("write file");
        }
        zip.finish().expect("finish pack");
    }

    #[test]
    fn load_secret_keys_prefers_asset_file_and_filters_optional_entries() {
        let dir = tempdir().expect("tempdir");
        let pack = dir.path().join("pack.gtpack");
        write_pack(
            &pack,
            &[(
                "assets/secret-requirements.json",
                serde_json::to_vec(&serde_json::json!([
                    {"key": "API_TOKEN", "required": true},
                    {"key": "OPTIONAL_TOKEN", "required": false},
                    {"required": true}
                ]))
                .expect("asset json"),
            )],
        );

        assert_eq!(
            load_secret_keys_from_pack(&pack).expect("load keys"),
            vec!["api_token".to_string()]
        );
    }

    #[test]
    fn load_secret_keys_falls_back_to_manifest_symbol_resolution() {
        let dir = tempdir().expect("tempdir");
        let pack = dir.path().join("pack.gtpack");
        let manifest = CborValue::Map(BTreeMap::from([
            (
                CborValue::Text("symbols".to_string()),
                CborValue::Map(BTreeMap::from([(
                    CborValue::Text("secret_requirements".to_string()),
                    CborValue::Array(vec![CborValue::Text("jwt_signing_key".to_string())]),
                )])),
            ),
            (
                CborValue::Text("secret_requirements".to_string()),
                CborValue::Array(vec![
                    CborValue::Map(BTreeMap::from([
                        (CborValue::Text("key".to_string()), CborValue::Integer(0)),
                        (
                            CborValue::Text("required".to_string()),
                            CborValue::Bool(true),
                        ),
                    ])),
                    CborValue::Map(BTreeMap::from([
                        (
                            CborValue::Text("key".to_string()),
                            CborValue::Text("ignored".to_string()),
                        ),
                        (
                            CborValue::Text("required".to_string()),
                            CborValue::Bool(false),
                        ),
                    ])),
                ]),
            ),
        ]));
        write_pack(
            &pack,
            &[(
                "manifest.cbor",
                serde_cbor::to_vec(&manifest).expect("manifest cbor"),
            )],
        );

        assert_eq!(
            load_secret_keys_from_pack(&pack).expect("load keys"),
            vec!["jwt_signing_key".to_string()]
        );
    }

    #[test]
    fn load_secret_keys_falls_back_to_component_manifests() {
        let dir = tempdir().expect("tempdir");
        let pack = dir.path().join("pack.gtpack");
        write_pack(
            &pack,
            &[
                (
                    "assets/secret-requirements.json",
                    serde_json::to_vec(&serde_json::json!([])).expect("asset json"),
                ),
                (
                    "components/provider/component.manifest.json",
                    serde_json::to_vec(&serde_json::json!({
                        "secret_requirements": [
                            {"key": "JWT_SIGNING_KEY", "required": true},
                            {"key": "OPTIONAL_TOKEN", "required": false}
                        ]
                    }))
                    .expect("component json"),
                ),
            ],
        );

        assert_eq!(
            load_secret_keys_from_pack(&pack).expect("load keys"),
            vec!["jwt_signing_key".to_string()]
        );
    }

    #[test]
    fn resolve_string_symbol_handles_text_indices_and_invalid_types() {
        let symbols = BTreeMap::from([(
            CborValue::Text("secret_requirement".to_string()),
            CborValue::Array(vec![CborValue::Text("token".to_string())]),
        )]);

        assert_eq!(
            resolve_string_symbol(
                Some(&CborValue::Text("direct".to_string())),
                Some(&symbols),
                "secret_requirements",
            )
            .expect("text"),
            Some("direct".to_string())
        );
        assert_eq!(
            resolve_string_symbol(
                Some(&CborValue::Integer(0)),
                Some(&symbols),
                "secret_requirements",
            )
            .expect("symbol"),
            Some("token".to_string())
        );
        assert_eq!(
            resolve_string_symbol(Some(&CborValue::Integer(3)), None, "secret_requirements")
                .expect("fallback index"),
            Some("3".to_string())
        );
        assert!(
            resolve_string_symbol(
                Some(&CborValue::Bool(true)),
                Some(&symbols),
                "secret_requirements",
            )
            .is_err()
        );
    }
}