1#![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
17pub const LOCK_VERSION: u32 = 1;
19
20#[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 #[serde(rename = "tool", default)]
32 pub tools: Vec<LockedTool>,
33}
34
35#[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 #[serde(default)]
44 pub platform: BTreeMap<String, PlatformPin>,
45}
46
47#[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 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 pub fn tool_names(&self) -> BTreeSet<String> {
77 self.tools.iter().map(|t| t.name.clone()).collect()
78 }
79
80 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
138pub struct Reconcile {
139 pub missing: Vec<String>,
141 pub extra: Vec<String>,
143}
144
145impl Reconcile {
146 pub fn is_clean(&self) -> bool {
148 self.missing.is_empty() && self.extra.is_empty()
149 }
150}
151
152pub 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()]); 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}