Skip to main content

packc/
extension_refs.rs

1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PackExtensionsFile {
12    pub version: u32,
13    #[serde(default)]
14    pub extensions: Vec<ExtensionDependency>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ExtensionDependency {
19    pub id: String,
20    pub role: String,
21    pub source: ExtensionDependencySource,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ExtensionDependencySource {
26    pub kind: String,
27    #[serde(rename = "ref")]
28    pub reference: String,
29    #[serde(default)]
30    pub allow_tags: bool,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PackExtensionsLockFile {
35    pub version: u32,
36    #[serde(default)]
37    pub extensions: Vec<LockedExtensionDependency>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct LockedExtensionDependency {
42    pub id: String,
43    pub role: String,
44    pub source_ref: String,
45    pub resolved_ref: String,
46    pub digest: String,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub media_type: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub size_bytes: Option<u64>,
51}
52
53impl PackExtensionsFile {
54    pub fn new(extensions: Vec<ExtensionDependency>) -> Self {
55        Self {
56            version: 1,
57            extensions,
58        }
59    }
60}
61
62impl PackExtensionsLockFile {
63    pub fn new(extensions: Vec<LockedExtensionDependency>) -> Self {
64        Self {
65            version: 1,
66            extensions,
67        }
68    }
69}
70
71pub fn read_extensions_file(path: &Path) -> Result<PackExtensionsFile> {
72    let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
73    let file: PackExtensionsFile =
74        serde_json::from_slice(&bytes).with_context(|| format!("decode {}", path.display()))?;
75    validate_extensions_file(&file)?;
76    Ok(file)
77}
78
79pub fn write_extensions_file(path: &Path, file: &PackExtensionsFile) -> Result<()> {
80    validate_extensions_file(file)?;
81    if let Some(parent) = path.parent()
82        && !parent.as_os_str().is_empty()
83    {
84        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
85    }
86    let bytes = serde_json::to_vec_pretty(file).context("serialize pack.extensions.json")?;
87    fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
88    Ok(())
89}
90
91pub fn read_extensions_lock_file(path: &Path) -> Result<PackExtensionsLockFile> {
92    let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
93    let file: PackExtensionsLockFile =
94        serde_json::from_slice(&bytes).with_context(|| format!("decode {}", path.display()))?;
95    validate_extensions_lock_file(&file)?;
96    Ok(file)
97}
98
99pub fn write_extensions_lock_file(path: &Path, file: &PackExtensionsLockFile) -> Result<()> {
100    validate_extensions_lock_file(file)?;
101    if let Some(parent) = path.parent()
102        && !parent.as_os_str().is_empty()
103    {
104        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
105    }
106    let bytes = serde_json::to_vec_pretty(file).context("serialize pack.extensions.lock.json")?;
107    fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
108    Ok(())
109}
110
111pub fn validate_extensions_file(file: &PackExtensionsFile) -> Result<()> {
112    if file.version != 1 {
113        bail!("pack.extensions.json version must be 1");
114    }
115    let mut seen = BTreeMap::new();
116    for extension in &file.extensions {
117        if extension.id.trim().is_empty() {
118            bail!("pack.extensions.json extension id must not be empty");
119        }
120        if extension.role.trim().is_empty() {
121            bail!(
122                "pack.extensions.json extension `{}` role must not be empty",
123                extension.id
124            );
125        }
126        if extension.source.kind.trim().is_empty() {
127            bail!(
128                "pack.extensions.json extension `{}` source.kind must not be empty",
129                extension.id
130            );
131        }
132        if extension.source.reference.trim().is_empty() {
133            bail!(
134                "pack.extensions.json extension `{}` source.ref must not be empty",
135                extension.id
136            );
137        }
138        validate_reference_kind(
139            &extension.source.kind,
140            &extension.source.reference,
141            extension.source.allow_tags,
142        )?;
143        if let Some(previous_role) = seen.insert(extension.id.as_str(), extension.role.as_str()) {
144            bail!(
145                "pack.extensions.json extension `{}` is duplicated (roles `{previous_role}` and `{}`)",
146                extension.id,
147                extension.role
148            );
149        }
150    }
151    Ok(())
152}
153
154pub fn validate_extensions_lock_file(file: &PackExtensionsLockFile) -> Result<()> {
155    if file.version != 1 {
156        bail!("pack.extensions.lock.json version must be 1");
157    }
158    let mut seen = BTreeMap::new();
159    for extension in &file.extensions {
160        if extension.id.trim().is_empty() {
161            bail!("pack.extensions.lock.json extension id must not be empty");
162        }
163        if extension.role.trim().is_empty() {
164            bail!(
165                "pack.extensions.lock.json extension `{}` role must not be empty",
166                extension.id
167            );
168        }
169        if extension.source_ref.trim().is_empty() {
170            bail!(
171                "pack.extensions.lock.json extension `{}` source_ref must not be empty",
172                extension.id
173            );
174        }
175        if extension.resolved_ref.trim().is_empty() {
176            bail!(
177                "pack.extensions.lock.json extension `{}` resolved_ref must not be empty",
178                extension.id
179            );
180        }
181        if !extension.digest.starts_with("sha256:") {
182            bail!(
183                "pack.extensions.lock.json extension `{}` digest must start with sha256:",
184                extension.id
185            );
186        }
187        if let Some(previous_role) = seen.insert(extension.id.as_str(), extension.role.as_str()) {
188            bail!(
189                "pack.extensions.lock.json extension `{}` is duplicated (roles `{previous_role}` and `{}`)",
190                extension.id,
191                extension.role
192            );
193        }
194    }
195    Ok(())
196}
197
198pub fn validate_extensions_lock_alignment(
199    source: &PackExtensionsFile,
200    lock: &PackExtensionsLockFile,
201) -> Result<()> {
202    let source_by_id = source
203        .extensions
204        .iter()
205        .map(|extension| (extension.id.as_str(), extension))
206        .collect::<BTreeMap<_, _>>();
207    let lock_by_id = lock
208        .extensions
209        .iter()
210        .map(|extension| (extension.id.as_str(), extension))
211        .collect::<BTreeMap<_, _>>();
212
213    for (id, source_extension) in &source_by_id {
214        let Some(lock_extension) = lock_by_id.get(id) else {
215            bail!(
216                "pack.extensions.lock.json is missing extension `{id}` present in pack.extensions.json"
217            );
218        };
219        if lock_extension.role != source_extension.role {
220            bail!(
221                "pack.extensions.lock.json extension `{id}` role `{}` does not match pack.extensions.json role `{}`",
222                lock_extension.role,
223                source_extension.role
224            );
225        }
226        if lock_extension.source_ref != source_extension.source.reference {
227            bail!(
228                "pack.extensions.lock.json extension `{id}` source_ref `{}` does not match pack.extensions.json ref `{}`",
229                lock_extension.source_ref,
230                source_extension.source.reference
231            );
232        }
233    }
234
235    for id in lock_by_id.keys() {
236        if !source_by_id.contains_key(id) {
237            bail!(
238                "pack.extensions.lock.json contains extension `{id}` that is not present in pack.extensions.json"
239            );
240        }
241    }
242
243    Ok(())
244}
245
246pub fn default_extensions_file_path(pack_dir: &Path) -> PathBuf {
247    pack_dir.join("pack.extensions.json")
248}
249
250pub fn default_extensions_lock_file_path(pack_dir: &Path) -> PathBuf {
251    pack_dir.join("pack.extensions.lock.json")
252}
253
254pub fn infer_reference_kind(reference: &str) -> Result<String> {
255    let normalized = reference.trim();
256    if normalized.starts_with("oci://") {
257        return Ok("oci".to_string());
258    }
259    if normalized.starts_with("file://") {
260        return Ok("file".to_string());
261    }
262    if normalized.starts_with("http://") || normalized.starts_with("https://") {
263        return Ok("http".to_string());
264    }
265    if normalized.starts_with("repo://") {
266        return Ok("repo".to_string());
267    }
268    if normalized.starts_with("store://") {
269        return Ok("store".to_string());
270    }
271    bail!("unsupported extension source ref scheme: {reference}");
272}
273
274pub fn pin_reference(reference: &str, digest: &str) -> String {
275    if let Some(rest) = reference.strip_prefix("oci://") {
276        return format!("oci://{}@{}", strip_tag_or_digest(rest), digest);
277    }
278    if let Some(rest) = reference.strip_prefix("repo://") {
279        return format!("repo://{}@{}", strip_tag_or_digest(rest), digest);
280    }
281    if let Some(rest) = reference.strip_prefix("store://") {
282        return format!("store://{}@{}", strip_tag_or_digest(rest), digest);
283    }
284    reference.to_string()
285}
286
287fn strip_tag_or_digest(reference: &str) -> &str {
288    if let Some((repo, _)) = reference.rsplit_once('@') {
289        return repo;
290    }
291    let last_slash = reference.rfind('/');
292    let last_colon = reference.rfind(':');
293    if let (Some(slash), Some(colon)) = (last_slash, last_colon)
294        && colon > slash
295    {
296        return &reference[..colon];
297    }
298    reference
299}
300
301fn validate_reference_kind(kind: &str, reference: &str, allow_tags: bool) -> Result<()> {
302    let expected_kind = infer_reference_kind(reference)?;
303    if kind != expected_kind {
304        bail!(
305            "pack.extensions.json source.kind `{kind}` does not match ref scheme `{expected_kind}`"
306        );
307    }
308    if matches!(kind, "oci" | "repo" | "store") && !allow_tags && !reference.contains("@sha256:") {
309        bail!(
310            "pack.extensions.json ref `{reference}` must be digest-pinned or set allow_tags=true"
311        );
312    }
313    Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn pin_reference_rewrites_oci_tag_to_digest() {
322        let pinned = pin_reference("oci://ghcr.io/acme/demo:latest", "sha256:abcd");
323        assert_eq!(pinned, "oci://ghcr.io/acme/demo@sha256:abcd");
324    }
325
326    #[test]
327    fn validate_extensions_file_rejects_unpinned_oci_without_allow_tags() {
328        let file = PackExtensionsFile::new(vec![ExtensionDependency {
329            id: "greentic.deployer.v1".to_string(),
330            role: "deployer".to_string(),
331            source: ExtensionDependencySource {
332                kind: "oci".to_string(),
333                reference: "oci://ghcr.io/acme/demo:latest".to_string(),
334                allow_tags: false,
335            },
336        }]);
337        let err = validate_extensions_file(&file).expect_err("should reject tag ref");
338        assert!(err.to_string().contains("must be digest-pinned"));
339    }
340
341    #[test]
342    fn validate_extensions_lock_alignment_rejects_source_ref_drift() {
343        let source = PackExtensionsFile::new(vec![ExtensionDependency {
344            id: "greentic.deployer.v1".to_string(),
345            role: "deployer".to_string(),
346            source: ExtensionDependencySource {
347                kind: "file".to_string(),
348                reference: "file:///tmp/a.json".to_string(),
349                allow_tags: false,
350            },
351        }]);
352        let lock = PackExtensionsLockFile::new(vec![LockedExtensionDependency {
353            id: "greentic.deployer.v1".to_string(),
354            role: "deployer".to_string(),
355            source_ref: "file:///tmp/b.json".to_string(),
356            resolved_ref: "file:///tmp/b.json".to_string(),
357            digest: "sha256:abcd".to_string(),
358            media_type: None,
359            size_bytes: None,
360        }]);
361
362        let err = validate_extensions_lock_alignment(&source, &lock).expect_err("should reject");
363        assert!(
364            err.to_string()
365                .contains("does not match pack.extensions.json ref")
366        );
367    }
368}