Skip to main content

vanta_lock/
lib.rs

1//! `vanta-lock` — the `vanta.lock` model, canonical serialization, and the
2//! manifest↔lock reconcile.
3//!
4//! The lock pins exact versions and per-platform artifact hashes for every
5//! target so a single committed file reproduces on any OS. See
6//! `docs/11-reproducibility.md` and `docs/31-lockfile-and-manifest-reference.md`.
7//! Serialization is canonical (sorted tools, sorted platform keys) so the file
8//! diffs cleanly in VCS.
9#![forbid(unsafe_code)]
10
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fs;
14use std::path::Path;
15use vanta_core::{Area, VtaError, VtaResult};
16
17/// The current lock format version.
18pub const LOCK_VERSION: u32 = 1;
19
20/// A `vanta.lock` file.
21#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
22pub struct Lock {
23    pub lock_version: u32,
24    #[serde(default)]
25    pub generated_by: String,
26    #[serde(default)]
27    pub targets: Vec<String>,
28    #[serde(default)]
29    pub registry_revision: String,
30    /// One entry per locked tool, serialized as `[[tool]]`.
31    #[serde(rename = "tool", default)]
32    pub tools: Vec<LockedTool>,
33}
34
35/// A locked tool: the resolution plus a per-platform artifact pin.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct LockedTool {
38    pub name: String,
39    pub request: String,
40    pub version: String,
41    pub provider: String,
42    /// platform token → artifact pin (sorted for canonical output).
43    #[serde(default)]
44    pub platform: BTreeMap<String, PlatformPin>,
45}
46
47/// The per-platform artifact pin recorded in the lock.
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct PlatformPin {
50    pub store_key: String,
51    pub url: String,
52    #[serde(default)]
53    pub size: Option<u64>,
54    pub sha256: String,
55    #[serde(default)]
56    pub blake3: Option<String>,
57    #[serde(default)]
58    pub signature: Option<String>,
59    #[serde(default)]
60    pub bin: Vec<String>,
61}
62
63impl Lock {
64    /// A new, empty lock with the current format version.
65    pub fn new(generated_by: impl Into<String>, targets: Vec<String>) -> Lock {
66        Lock {
67            lock_version: LOCK_VERSION,
68            generated_by: generated_by.into(),
69            targets,
70            registry_revision: String::new(),
71            tools: Vec::new(),
72        }
73    }
74
75    /// The set of tool names this lock pins.
76    pub fn tool_names(&self) -> BTreeSet<String> {
77        self.tools.iter().map(|t| t.name.clone()).collect()
78    }
79
80    /// Return a canonicalized clone: tools sorted by name (platform maps are
81    /// already sorted by `BTreeMap`). Determinism keeps VCS diffs minimal.
82    pub fn canonical(&self) -> Lock {
83        let mut out = self.clone();
84        out.tools.sort_by(|a, b| a.name.cmp(&b.name));
85        out
86    }
87
88    /// Serialize to canonical TOML.
89    pub fn to_toml(&self) -> VtaResult<String> {
90        toml::to_string_pretty(&self.canonical())
91            .map_err(|e| VtaError::new(Area::Lock, 4, format!("serialize lock: {e}")))
92    }
93
94    /// Parse a lock from TOML, rejecting a format version newer than supported.
95    pub fn from_toml(src: &str) -> VtaResult<Lock> {
96        let lock: Lock = toml::from_str(src)
97            .map_err(|e| VtaError::new(Area::Lock, 1, format!("parse lock: {e}")))?;
98        if lock.lock_version > LOCK_VERSION {
99            return Err(VtaError::new(
100                Area::Lock,
101                2,
102                format!(
103                    "lock_version {} is newer than this Vanta supports ({}); upgrade Vanta",
104                    lock.lock_version, LOCK_VERSION
105                ),
106            ));
107        }
108        Ok(lock)
109    }
110
111    /// Load a lock file.
112    pub fn load_file(path: &Path) -> VtaResult<Lock> {
113        let src = fs::read_to_string(path).map_err(|e| {
114            VtaError::new(
115                Area::Lock,
116                1,
117                format!("cannot read {}: {e}", path.display()),
118            )
119        })?;
120        Lock::from_toml(&src)
121    }
122
123    /// Write the lock file canonically.
124    pub fn write_file(&self, path: &Path) -> VtaResult<()> {
125        let body = self.to_toml()?;
126        fs::write(path, body).map_err(|e| {
127            VtaError::new(
128                Area::Lock,
129                7,
130                format!("cannot write {}: {e}", path.display()),
131            )
132        })
133    }
134}
135
136/// The difference between what a manifest declares and what the lock pins.
137#[derive(Debug, Clone, PartialEq, Eq, Default)]
138pub struct Reconcile {
139    /// Declared in the manifest but absent from the lock (need re-lock).
140    pub missing: Vec<String>,
141    /// Present in the lock but no longer declared (prune candidates).
142    pub extra: Vec<String>,
143}
144
145impl Reconcile {
146    /// Whether the manifest and lock fully agree on tool membership.
147    pub fn is_clean(&self) -> bool {
148        self.missing.is_empty() && self.extra.is_empty()
149    }
150}
151
152/// Compare the manifest's declared tool names against the lock. Tool-name level
153/// only; deeper drift (a changed constraint a pin no longer satisfies) is checked
154/// during resolution (`docs/06-resolution.md`).
155pub fn reconcile(manifest_tools: &BTreeSet<String>, lock: &Lock) -> Reconcile {
156    let locked = lock.tool_names();
157    Reconcile {
158        missing: manifest_tools.difference(&locked).cloned().collect(),
159        extra: locked.difference(manifest_tools).cloned().collect(),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn sample() -> Lock {
168        let mut lock = Lock::new(
169            "vanta 0.0.0",
170            vec!["macos/aarch64".into(), "linux/x86_64/gnu".into()],
171        );
172        let mut platform = BTreeMap::new();
173        platform.insert(
174            "macos/aarch64".to_string(),
175            PlatformPin {
176                store_key: "blake3-aa3f".into(),
177                url: "https://example.test/node.tar.xz".into(),
178                size: Some(24117248),
179                sha256: "5f2c".into(),
180                blake3: Some("aa3f".into()),
181                signature: Some("minisign:RWQf".into()),
182                bin: vec!["bin/node".into()],
183            },
184        );
185        lock.tools.push(LockedTool {
186            name: "node".into(),
187            request: "24".into(),
188            version: "24.6.0".into(),
189            provider: "official/node@3".into(),
190            platform,
191        });
192        lock
193    }
194
195    #[test]
196    fn roundtrips_through_toml() {
197        let lock = sample();
198        let text = lock.to_toml().unwrap();
199        let parsed = Lock::from_toml(&text).unwrap();
200        assert_eq!(parsed, lock.canonical());
201    }
202
203    #[test]
204    fn rejects_newer_format() {
205        let err = Lock::from_toml("lock_version = 999\n").unwrap_err();
206        assert_eq!(err.area, Area::Lock);
207        assert_eq!(err.number, 2);
208    }
209
210    #[test]
211    fn canonical_sorts_tools() {
212        let mut lock = Lock::new("t", vec![]);
213        for n in ["terraform", "node", "go"] {
214            lock.tools.push(LockedTool {
215                name: n.into(),
216                request: "latest".into(),
217                version: "1".into(),
218                provider: "p".into(),
219                platform: BTreeMap::new(),
220            });
221        }
222        let names: Vec<_> = lock.canonical().tools.into_iter().map(|t| t.name).collect();
223        assert_eq!(names, vec!["go", "node", "terraform"]);
224    }
225
226    #[test]
227    fn reconcile_detects_drift() {
228        let lock = sample();
229        let manifest: BTreeSet<String> = ["node", "python"].iter().map(|s| s.to_string()).collect();
230        let r = reconcile(&manifest, &lock);
231        assert_eq!(r.missing, vec!["python".to_string()]); // declared, not locked
232        assert!(r.extra.is_empty());
233        assert!(!r.is_clean());
234    }
235}
236
237#[cfg(test)]
238mod fuzz {
239    use super::*;
240    proptest::proptest! {
241        #[test]
242        fn lock_parse_never_panics(s in ".*") { let _ = Lock::from_toml(&s); }
243    }
244}