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}