greentic_pack/
pack_lock.rs1#![forbid(unsafe_code)]
2
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use greentic_types::ComponentId;
8use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12pub struct PackLockV1 {
13 pub schema_version: u32,
14 pub components: Vec<LockedComponent>,
15}
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct LockedComponent {
20 pub name: String,
21 pub r#ref: String,
22 pub digest: String,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub component_id: Option<ComponentId>,
25 #[serde(default, skip_serializing_if = "is_false")]
26 pub bundled: bool,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub bundled_path: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub wasm_sha256: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub resolved_digest: Option<String>,
33}
34
35impl PackLockV1 {
36 pub fn new(components: Vec<LockedComponent>) -> Self {
37 Self {
38 schema_version: 1,
39 components,
40 }
41 }
42}
43
44pub fn validate_pack_lock(lock: &PackLockV1) -> Result<()> {
46 if lock.schema_version != 1 {
47 anyhow::bail!("pack.lock schema_version must be 1");
48 }
49
50 for component in &lock.components {
51 if component.name.trim().is_empty() {
52 anyhow::bail!("pack.lock component name must not be empty");
53 }
54 if let Some(component_id) = component.component_id.as_ref()
55 && component_id.as_str().trim().is_empty()
56 {
57 anyhow::bail!("pack.lock component_id must not be empty when set");
58 }
59 if component.r#ref.trim().is_empty() {
60 anyhow::bail!("pack.lock component ref must not be empty");
61 }
62 if !component.digest.starts_with("sha256:") || component.digest.len() <= 7 {
63 anyhow::bail!(
64 "pack.lock component digest for {} must start with sha256:<hex>",
65 component.name
66 );
67 }
68 if component.bundled {
69 let bundled_path = component
70 .bundled_path
71 .as_ref()
72 .map(|path| path.trim())
73 .filter(|path| !path.is_empty())
74 .ok_or_else(|| {
75 anyhow::anyhow!(
76 "pack.lock component {} is bundled but missing bundled_path",
77 component.name
78 )
79 })?;
80 let wasm_sha256 = component
81 .wasm_sha256
82 .as_ref()
83 .map(|hash| hash.trim())
84 .filter(|hash| !hash.is_empty())
85 .ok_or_else(|| {
86 anyhow::anyhow!(
87 "pack.lock component {} is bundled but missing wasm_sha256",
88 component.name
89 )
90 })?;
91 if wasm_sha256.len() != 64 || !wasm_sha256.chars().all(|c| c.is_ascii_hexdigit()) {
92 anyhow::bail!(
93 "pack.lock component {} wasm_sha256 must be 64 hex chars",
94 component.name
95 );
96 }
97 if bundled_path.ends_with('/') {
98 anyhow::bail!(
99 "pack.lock component {} bundled_path must be a file path",
100 component.name
101 );
102 }
103 } else if component.bundled_path.is_some() || component.wasm_sha256.is_some() {
104 anyhow::bail!(
105 "pack.lock component {} has bundling metadata but bundled=false",
106 component.name
107 );
108 }
109 if let Some(resolved) = component.resolved_digest.as_ref()
110 && (!resolved.starts_with("sha256:") || resolved.len() <= 7)
111 {
112 anyhow::bail!(
113 "pack.lock component resolved_digest for {} must start with sha256:<hex>",
114 component.name
115 );
116 }
117 }
118
119 Ok(())
120}
121
122fn is_false(value: &bool) -> bool {
123 !*value
124}
125
126pub fn read_pack_lock(path: &Path) -> Result<PackLockV1> {
128 let raw =
129 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
130 let lock: PackLockV1 = serde_json::from_str(&raw).context("failed to parse pack.lock.json")?;
131 validate_pack_lock(&lock)?;
132 Ok(lock)
133}
134
135pub fn write_pack_lock(path: &Path, lock: &PackLockV1) -> Result<()> {
137 validate_pack_lock(lock)?;
138 let mut normalized = lock.clone();
139 normalized
140 .components
141 .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.r#ref.cmp(&b.r#ref)));
142
143 let json =
144 serde_json::to_string_pretty(&normalized).context("failed to serialize pack.lock.json")?;
145 fs::write(path, json).with_context(|| format!("failed to write {}", path.display()))?;
146 Ok(())
147}