Skip to main content

greentic_pack/
pack_lock.rs

1#![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/// Canonical pack lock format (v1).
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12pub struct PackLockV1 {
13    pub schema_version: u32,
14    pub components: Vec<LockedComponent>,
15}
16
17/// Locked component entry.
18#[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
44/// Validate a pack.lock document.
45pub 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
126/// Read a pack.lock.json file from disk.
127pub 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
135/// Write a pack.lock.json file to disk with deterministic ordering.
136pub 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}