Skip to main content

packc/
extensions.rs

1use anyhow::{Context, Result, bail};
2use greentic_types::pack_manifest::{ExtensionInline, ExtensionRef};
3use serde_json::{Map as JsonMap, Value as JsonValue};
4use std::collections::BTreeMap;
5
6pub const COMPONENTS_EXTENSION_KEY: &str = "greentic.components";
7
8#[derive(Debug, Clone)]
9pub struct ComponentsExtension {
10    pub refs: Vec<String>,
11    pub mode: Option<String>,
12    pub allow_tags: Option<bool>,
13}
14
15pub fn validate_components_extension(
16    extensions: &Option<BTreeMap<String, ExtensionRef>>,
17    allow_tags: bool,
18) -> Result<Option<ComponentsExtension>> {
19    let Some(ext) = extensions
20        .as_ref()
21        .and_then(|all| all.get(COMPONENTS_EXTENSION_KEY))
22    else {
23        return Ok(None);
24    };
25
26    let payload = ext.inline.as_ref().ok_or_else(|| {
27        anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline is required")
28    })?;
29
30    let payload = match payload {
31        ExtensionInline::Other(value) => value.clone(),
32        other => serde_json::to_value(other).context("serialize inline extension")?,
33    };
34
35    let map = payload.as_object().cloned().ok_or_else(|| {
36        anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline must be an object")
37    })?;
38
39    let refs = extract_refs(&map, allow_tags)?;
40    let mode = extract_mode(&map)?;
41    let allow_tags_inline = map.get("allow_tags").and_then(JsonValue::as_bool);
42
43    Ok(Some(ComponentsExtension {
44        refs,
45        mode,
46        allow_tags: allow_tags_inline,
47    }))
48}
49
50fn extract_refs(map: &JsonMap<String, JsonValue>, allow_tags: bool) -> Result<Vec<String>> {
51    let refs = map.get("refs").ok_or_else(|| {
52        anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs is required")
53    })?;
54    let arr = refs.as_array().ok_or_else(|| {
55        anyhow::anyhow!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs must be an array")
56    })?;
57
58    let mut result = Vec::new();
59    for value in arr {
60        let reference = value.as_str().ok_or_else(|| {
61            anyhow::anyhow!(
62                "extensions[{COMPONENTS_EXTENSION_KEY}] inline.refs entries must be strings"
63            )
64        })?;
65        validate_oci_ref(reference, allow_tags)?;
66        result.push(reference.to_string());
67    }
68    Ok(result)
69}
70
71fn extract_mode(map: &JsonMap<String, JsonValue>) -> Result<Option<String>> {
72    let Some(mode) = map.get("mode") else {
73        return Ok(None);
74    };
75    let Some(mode_str) = mode.as_str() else {
76        bail!("extensions[{COMPONENTS_EXTENSION_KEY}] inline.mode must be a string when present");
77    };
78    match mode_str {
79        "eager" | "lazy" => Ok(Some(mode_str.to_string())),
80        other => bail!(
81            "extensions[{COMPONENTS_EXTENSION_KEY}] inline.mode must be one of [eager, lazy]; found `{other}`"
82        ),
83    }
84}
85
86fn validate_oci_ref(reference: &str, allow_tags: bool) -> Result<()> {
87    if let Some((repo, digest)) = reference.rsplit_once('@') {
88        if repo.trim().is_empty() {
89            bail!("OCI component ref is missing a repository before the digest: `{reference}`");
90        }
91        if !digest.starts_with("sha256:") {
92            bail!("OCI component ref digest must start with sha256: `{reference}`");
93        }
94        let hex = &digest["sha256:".len()..];
95        if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
96            bail!("OCI component ref must include a 64-character hex sha256 digest: `{reference}`");
97        }
98        if !repo.contains('/') {
99            bail!("OCI component ref must include a registry/repository path: `{reference}`");
100        }
101        return Ok(());
102    }
103
104    let last_slash = reference.rfind('/').ok_or_else(|| {
105        anyhow::anyhow!("OCI component ref must include a registry/repository path: `{reference}`")
106    })?;
107    let last_colon = reference.rfind(':').ok_or_else(|| {
108        anyhow::anyhow!(
109            "OCI component ref must be digest-pinned (...@sha256:...){}",
110            if allow_tags {
111                " or include a tag (:tag)"
112            } else {
113                ""
114            }
115        )
116    })?;
117
118    if last_colon <= last_slash {
119        bail!("OCI component ref must include a tag or digest: `{reference}`");
120    }
121
122    let tag = &reference[last_colon + 1..];
123    if tag.is_empty() {
124        bail!("OCI component ref tag must not be empty: `{reference}`");
125    }
126    if !allow_tags {
127        bail!(
128            "OCI component ref must be digest-pinned (...@sha256:...). Re-run with --allow-oci-tags to permit tags."
129        );
130    }
131    Ok(())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use serde_json::json;
138
139    fn ext_with_payload(payload: JsonValue) -> BTreeMap<String, ExtensionRef> {
140        let mut map = BTreeMap::new();
141        map.insert(
142            COMPONENTS_EXTENSION_KEY.to_string(),
143            ExtensionRef {
144                kind: COMPONENTS_EXTENSION_KEY.to_string(),
145                version: "v1".to_string(),
146                digest: None,
147                location: None,
148                inline: Some(ExtensionInline::Other(payload)),
149            },
150        );
151        map
152    }
153
154    #[test]
155    fn digest_refs_are_allowed_by_default() {
156        let extensions = ext_with_payload(json!({
157            "refs": ["ghcr.io/org/demo@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
158        }));
159        validate_components_extension(&Some(extensions), false).expect("digest ok");
160    }
161
162    #[test]
163    fn tag_refs_are_rejected_by_default() {
164        let extensions = ext_with_payload(json!({
165            "refs": ["ghcr.io/org/demo:latest"]
166        }));
167        let err = validate_components_extension(&Some(extensions), false).unwrap_err();
168        assert!(
169            err.to_string().contains("digest-pinned"),
170            "unexpected error: {err}"
171        );
172    }
173
174    #[test]
175    fn tag_refs_are_allowed_with_flag() {
176        let extensions = ext_with_payload(json!({
177            "refs": ["ghcr.io/org/demo:latest"]
178        }));
179        validate_components_extension(&Some(extensions), true).expect("tag allowed");
180    }
181
182    #[test]
183    fn invalid_refs_are_rejected() {
184        let extensions = ext_with_payload(json!({
185            "refs": ["not-an-oci-ref"]
186        }));
187        assert!(validate_components_extension(&Some(extensions), true).is_err());
188    }
189}