#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
use vanta_core::{Area, VtaError, VtaResult};
pub const LOCK_VERSION: u32 = 1;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Lock {
pub lock_version: u32,
#[serde(default)]
pub generated_by: String,
#[serde(default)]
pub targets: Vec<String>,
#[serde(default)]
pub registry_revision: String,
#[serde(rename = "tool", default)]
pub tools: Vec<LockedTool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LockedTool {
pub name: String,
pub request: String,
pub version: String,
pub provider: String,
#[serde(default)]
pub platform: BTreeMap<String, PlatformPin>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlatformPin {
pub store_key: String,
pub url: String,
#[serde(default)]
pub size: Option<u64>,
pub sha256: String,
#[serde(default)]
pub blake3: Option<String>,
#[serde(default)]
pub signature: Option<String>,
#[serde(default)]
pub bin: Vec<String>,
}
impl Lock {
pub fn new(generated_by: impl Into<String>, targets: Vec<String>) -> Lock {
Lock {
lock_version: LOCK_VERSION,
generated_by: generated_by.into(),
targets,
registry_revision: String::new(),
tools: Vec::new(),
}
}
pub fn tool_names(&self) -> BTreeSet<String> {
self.tools.iter().map(|t| t.name.clone()).collect()
}
pub fn canonical(&self) -> Lock {
let mut out = self.clone();
out.tools.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn to_toml(&self) -> VtaResult<String> {
toml::to_string_pretty(&self.canonical())
.map_err(|e| VtaError::new(Area::Lock, 4, format!("serialize lock: {e}")))
}
pub fn from_toml(src: &str) -> VtaResult<Lock> {
let lock: Lock = toml::from_str(src)
.map_err(|e| VtaError::new(Area::Lock, 1, format!("parse lock: {e}")))?;
if lock.lock_version > LOCK_VERSION {
return Err(VtaError::new(
Area::Lock,
2,
format!(
"lock_version {} is newer than this Vanta supports ({}); upgrade Vanta",
lock.lock_version, LOCK_VERSION
),
));
}
Ok(lock)
}
pub fn load_file(path: &Path) -> VtaResult<Lock> {
let src = fs::read_to_string(path).map_err(|e| {
VtaError::new(
Area::Lock,
1,
format!("cannot read {}: {e}", path.display()),
)
})?;
Lock::from_toml(&src)
}
pub fn write_file(&self, path: &Path) -> VtaResult<()> {
let body = self.to_toml()?;
fs::write(path, body).map_err(|e| {
VtaError::new(
Area::Lock,
7,
format!("cannot write {}: {e}", path.display()),
)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Reconcile {
pub missing: Vec<String>,
pub extra: Vec<String>,
}
impl Reconcile {
pub fn is_clean(&self) -> bool {
self.missing.is_empty() && self.extra.is_empty()
}
}
pub fn reconcile(manifest_tools: &BTreeSet<String>, lock: &Lock) -> Reconcile {
let locked = lock.tool_names();
Reconcile {
missing: manifest_tools.difference(&locked).cloned().collect(),
extra: locked.difference(manifest_tools).cloned().collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Lock {
let mut lock = Lock::new(
"vanta 0.0.0",
vec!["macos/aarch64".into(), "linux/x86_64/gnu".into()],
);
let mut platform = BTreeMap::new();
platform.insert(
"macos/aarch64".to_string(),
PlatformPin {
store_key: "blake3-aa3f".into(),
url: "https://example.test/node.tar.xz".into(),
size: Some(24117248),
sha256: "5f2c".into(),
blake3: Some("aa3f".into()),
signature: Some("minisign:RWQf".into()),
bin: vec!["bin/node".into()],
},
);
lock.tools.push(LockedTool {
name: "node".into(),
request: "24".into(),
version: "24.6.0".into(),
provider: "official/node@3".into(),
platform,
});
lock
}
#[test]
fn roundtrips_through_toml() {
let lock = sample();
let text = lock.to_toml().unwrap();
let parsed = Lock::from_toml(&text).unwrap();
assert_eq!(parsed, lock.canonical());
}
#[test]
fn rejects_newer_format() {
let err = Lock::from_toml("lock_version = 999\n").unwrap_err();
assert_eq!(err.area, Area::Lock);
assert_eq!(err.number, 2);
}
#[test]
fn canonical_sorts_tools() {
let mut lock = Lock::new("t", vec![]);
for n in ["terraform", "node", "go"] {
lock.tools.push(LockedTool {
name: n.into(),
request: "latest".into(),
version: "1".into(),
provider: "p".into(),
platform: BTreeMap::new(),
});
}
let names: Vec<_> = lock.canonical().tools.into_iter().map(|t| t.name).collect();
assert_eq!(names, vec!["go", "node", "terraform"]);
}
#[test]
fn reconcile_detects_drift() {
let lock = sample();
let manifest: BTreeSet<String> = ["node", "python"].iter().map(|s| s.to_string()).collect();
let r = reconcile(&manifest, &lock);
assert_eq!(r.missing, vec!["python".to_string()]); assert!(r.extra.is_empty());
assert!(!r.is_clean());
}
}
#[cfg(test)]
mod fuzz {
use super::*;
proptest::proptest! {
#[test]
fn lock_parse_never_panics(s in ".*") { let _ = Lock::from_toml(&s); }
}
}