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}