use anyhow::{Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::config::Config;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockEntry {
pub version: String,
pub registry: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary_sha256: Option<String>,
#[serde(default, alias = "verified", alias = "sha256")]
pub verification_method: String,
pub updated: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IntegrityResult {
Ok,
VersionChanged,
ChecksumChanged,
#[allow(dead_code)]
RegistryChanged { from: String, to: String },
New,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change {
Added {
name: String,
},
Updated {
name: String,
from: String,
to: String,
},
Removed {
name: String,
},
RegistryMoved {
name: String,
from: String,
to: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LockfileDoc {
#[serde(default)]
resolved: HashMap<String, LockEntry>,
}
#[derive(Debug, Clone)]
pub struct Lockfile {
pub entries: HashMap<String, LockEntry>,
}
impl Lockfile {
pub fn load_from(path: &std::path::Path) -> Result<Self> {
if !path.exists() {
return Ok(Self {
entries: HashMap::new(),
});
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let doc: LockfileDoc = toml::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(Self {
entries: doc.resolved,
})
}
pub fn save_to(&self, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let doc = LockfileDoc {
resolved: self.entries.clone(),
};
let mut content = String::from("# Auto-generated by kit sync. Do not edit.\n");
content.push_str(&toml::to_string_pretty(&doc).context("failed to serialize lockfile")?);
let tmp_path = path.with_extension("lock.tmp");
std::fs::write(&tmp_path, &content)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, path).with_context(|| {
format!(
"failed to rename {} -> {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
#[allow(dead_code)]
pub fn global_path() -> Result<PathBuf> {
let config_dir = Config::config_dir()?;
Ok(config_dir.join("kit.lock"))
}
pub fn get(&self, name: &str) -> Option<&LockEntry> {
self.entries.get(name)
}
pub fn set(&mut self, name: &str, entry: LockEntry) {
self.entries.insert(name.to_string(), entry);
}
#[allow(dead_code)]
pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
self.entries.remove(name)
}
pub fn check_integrity(
&self,
name: &str,
new_version: &str,
new_registry_sha256: Option<&str>,
) -> IntegrityResult {
let existing = match self.entries.get(name) {
Some(e) => e,
None => return IntegrityResult::New,
};
if existing.version != new_version {
return IntegrityResult::VersionChanged;
}
if let (Some(existing_sha), Some(new_sha)) =
(existing.registry_sha256.as_deref(), new_registry_sha256)
&& existing_sha != new_sha
{
return IntegrityResult::ChecksumChanged;
}
IntegrityResult::Ok
}
#[allow(dead_code)]
pub fn check_registry(&self, name: &str, new_registry: &str) -> Option<IntegrityResult> {
let existing = self.entries.get(name)?;
if existing.registry != new_registry {
Some(IntegrityResult::RegistryChanged {
from: existing.registry.clone(),
to: new_registry.to_string(),
})
} else {
None
}
}
}
pub fn new_entry(
version: &str,
registry: &str,
url: Option<&str>,
registry_sha256: Option<&str>,
binary_sha256: Option<&str>,
verification_method: &str,
) -> LockEntry {
LockEntry {
version: version.to_string(),
registry: registry.to_string(),
url: url.map(String::from),
registry_sha256: registry_sha256.map(String::from),
binary_sha256: binary_sha256.map(String::from),
verification_method: verification_method.to_string(),
updated: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
}
}
pub fn diff(old: &Lockfile, new_resolved: &[(String, String, String)]) -> Vec<Change> {
let mut changes = Vec::new();
let mut seen = std::collections::HashSet::new();
for (name, version, registry) in new_resolved {
seen.insert(name.as_str());
match old.entries.get(name.as_str()) {
None => {
changes.push(Change::Added { name: name.clone() });
}
Some(existing) => {
if existing.registry != *registry {
changes.push(Change::RegistryMoved {
name: name.clone(),
from: existing.registry.clone(),
to: registry.clone(),
});
}
if existing.version != *version {
changes.push(Change::Updated {
name: name.clone(),
from: existing.version.clone(),
to: version.clone(),
});
}
}
}
}
for name in old.entries.keys() {
if !seen.contains(name.as_str()) {
changes.push(Change::Removed { name: name.clone() });
}
}
changes.sort_by(|a, b| {
let name_a = match a {
Change::Added { name } => name,
Change::Updated { name, .. } => name,
Change::Removed { name } => name,
Change::RegistryMoved { name, .. } => name,
};
let name_b = match b {
Change::Added { name } => name,
Change::Updated { name, .. } => name,
Change::Removed { name } => name,
Change::RegistryMoved { name, .. } => name,
};
name_a.cmp(name_b)
});
changes
}
impl std::fmt::Display for Change {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Change::Added { name } => write!(f, " + {name} (new)"),
Change::Updated { name, from, to } => {
write!(f, " ~ {name} {from} -> {to}")
}
Change::Removed { name } => write!(f, " - {name} (removed)"),
Change::RegistryMoved { name, from, to } => {
write!(f, " ! {name} registry: {from} -> {to}")
}
}
}
}
impl std::fmt::Display for IntegrityResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IntegrityResult::Ok => write!(f, "ok"),
IntegrityResult::VersionChanged => write!(f, "version changed"),
IntegrityResult::ChecksumChanged => {
write!(f, "CHECKSUM CHANGED -- possible supply chain attack")
}
IntegrityResult::RegistryChanged { from, to } => {
write!(f, "registry changed: {from} -> {to}")
}
IntegrityResult::New => write!(f, "new"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_lockfile(entries: Vec<(&str, LockEntry)>) -> Lockfile {
Lockfile {
entries: entries
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect(),
}
}
fn entry(version: &str, registry: &str, sha256: Option<&str>) -> LockEntry {
LockEntry {
version: version.to_string(),
registry: registry.to_string(),
url: Some("https://example.com/tool".to_string()),
registry_sha256: sha256.map(String::from),
binary_sha256: None,
verification_method: "checksum".to_string(),
updated: "2026-04-06T14:30:00Z".to_string(),
}
}
#[test]
fn integrity_new_tool() {
let lock = make_lockfile(vec![]);
let result = lock.check_integrity("gh", "2.89.0", Some("abc123"));
assert_eq!(result, IntegrityResult::New);
}
#[test]
fn integrity_same_version_same_checksum() {
let lock = make_lockfile(vec![("gh", entry("2.89.0", "dunn", Some("abc123")))]);
let result = lock.check_integrity("gh", "2.89.0", Some("abc123"));
assert_eq!(result, IntegrityResult::Ok);
}
#[test]
fn integrity_version_changed() {
let lock = make_lockfile(vec![("gh", entry("2.88.0", "dunn", Some("old123")))]);
let result = lock.check_integrity("gh", "2.89.0", Some("new456"));
assert_eq!(result, IntegrityResult::VersionChanged);
}
#[test]
fn integrity_checksum_changed_same_version_is_attack() {
let lock = make_lockfile(vec![("gh", entry("2.89.0", "dunn", Some("abc123")))]);
let result = lock.check_integrity("gh", "2.89.0", Some("TAMPERED"));
assert_eq!(result, IntegrityResult::ChecksumChanged);
}
#[test]
fn integrity_no_checksums_is_ok() {
let lock = make_lockfile(vec![("gh", entry("2.89.0", "dunn", None))]);
let result = lock.check_integrity("gh", "2.89.0", Some("abc123"));
assert_eq!(result, IntegrityResult::Ok);
let lock = make_lockfile(vec![("gh", entry("2.89.0", "dunn", Some("abc123")))]);
let result = lock.check_integrity("gh", "2.89.0", None);
assert_eq!(result, IntegrityResult::Ok);
}
#[test]
fn registry_change_detected() {
let lock = make_lockfile(vec![("gh", entry("2.89.0", "dunn", Some("abc123")))]);
let result = lock.check_registry("gh", "untrusted");
assert_eq!(
result,
Some(IntegrityResult::RegistryChanged {
from: "dunn".to_string(),
to: "untrusted".to_string(),
})
);
}
#[test]
fn registry_same_returns_none() {
let lock = make_lockfile(vec![("gh", entry("2.89.0", "dunn", Some("abc123")))]);
assert_eq!(lock.check_registry("gh", "dunn"), None);
}
#[test]
fn registry_check_unknown_tool() {
let lock = make_lockfile(vec![]);
assert_eq!(lock.check_registry("gh", "dunn"), None);
}
#[test]
fn diff_added() {
let old = make_lockfile(vec![]);
let new_resolved = vec![("gh".to_string(), "2.89.0".to_string(), "dunn".to_string())];
let changes = diff(&old, &new_resolved);
assert_eq!(
changes,
vec![Change::Added {
name: "gh".to_string()
}]
);
}
#[test]
fn diff_updated() {
let old = make_lockfile(vec![("gh", entry("2.88.0", "dunn", Some("old")))]);
let new_resolved = vec![("gh".to_string(), "2.89.0".to_string(), "dunn".to_string())];
let changes = diff(&old, &new_resolved);
assert_eq!(
changes,
vec![Change::Updated {
name: "gh".to_string(),
from: "2.88.0".to_string(),
to: "2.89.0".to_string(),
}]
);
}
#[test]
fn diff_removed() {
let old = make_lockfile(vec![("gh", entry("2.89.0", "dunn", Some("abc")))]);
let new_resolved: Vec<(String, String, String)> = vec![];
let changes = diff(&old, &new_resolved);
assert_eq!(
changes,
vec![Change::Removed {
name: "gh".to_string()
}]
);
}
#[test]
fn diff_registry_moved() {
let old = make_lockfile(vec![("gh", entry("2.89.0", "dunn", Some("abc")))]);
let new_resolved = vec![(
"gh".to_string(),
"2.89.0".to_string(),
"community".to_string(),
)];
let changes = diff(&old, &new_resolved);
assert_eq!(
changes,
vec![Change::RegistryMoved {
name: "gh".to_string(),
from: "dunn".to_string(),
to: "community".to_string(),
}]
);
}
#[test]
fn diff_complex_scenario() {
let old = make_lockfile(vec![
("gh", entry("2.88.0", "dunn", Some("old"))),
("jq", entry("1.7.0", "dunn", Some("jq_hash"))),
("removed-tool", entry("1.0.0", "dunn", Some("x"))),
]);
let new_resolved = vec![
("gh".to_string(), "2.89.0".to_string(), "dunn".to_string()),
("jq".to_string(), "1.7.0".to_string(), "dunn".to_string()),
("yq".to_string(), "4.40.0".to_string(), "dunn".to_string()),
];
let changes = diff(&old, &new_resolved);
assert!(changes.contains(&Change::Updated {
name: "gh".to_string(),
from: "2.88.0".to_string(),
to: "2.89.0".to_string(),
}));
assert!(changes.contains(&Change::Removed {
name: "removed-tool".to_string(),
}));
assert!(changes.contains(&Change::Added {
name: "yq".to_string(),
}));
assert!(!changes.iter().any(|c| matches!(c,
Change::Updated { name, .. } | Change::Added { name } if name == "jq"
)));
}
#[test]
fn toml_round_trip() {
let doc = LockfileDoc {
resolved: HashMap::from([
(
"gh".to_string(),
LockEntry {
version: "2.89.0".to_string(),
registry: "dunn".to_string(),
url: Some(
"https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_macOS_arm64.zip"
.to_string(),
),
registry_sha256: Some("abc123".to_string()),
binary_sha256: None,
verification_method: "checksum".to_string(),
updated: "2026-04-06T14:30:00Z".to_string(),
},
),
(
"muxr".to_string(),
LockEntry {
version: "0.6.2".to_string(),
registry: "dunn".to_string(),
url: None,
registry_sha256: None,
binary_sha256: None,
verification_method: "cosign".to_string(),
updated: "2026-04-06T14:30:00Z".to_string(),
},
),
]),
};
let serialized = toml::to_string_pretty(&doc).unwrap();
let parsed: LockfileDoc = toml::from_str(&serialized).unwrap();
assert_eq!(parsed.resolved.len(), 2);
assert_eq!(parsed.resolved["gh"].version, "2.89.0");
assert_eq!(parsed.resolved["gh"].registry, "dunn");
assert_eq!(
parsed.resolved["gh"].url.as_deref(),
Some("https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_macOS_arm64.zip")
);
assert_eq!(parsed.resolved["muxr"].version, "0.6.2");
assert!(parsed.resolved["muxr"].url.is_none());
assert!(parsed.resolved["muxr"].registry_sha256.is_none());
}
#[test]
fn get_set_remove() {
let mut lock = Lockfile {
entries: HashMap::new(),
};
assert!(lock.get("gh").is_none());
lock.set("gh", entry("2.89.0", "dunn", Some("abc")));
assert_eq!(lock.get("gh").unwrap().version, "2.89.0");
lock.set("gh", entry("2.90.0", "dunn", Some("def")));
assert_eq!(lock.get("gh").unwrap().version, "2.90.0");
let removed = lock.remove("gh");
assert!(removed.is_some());
assert!(lock.get("gh").is_none());
}
#[test]
fn new_entry_has_timestamp() {
let e = new_entry("2.89.0", "dunn", None, None, None, "none");
assert!(chrono::DateTime::parse_from_rfc3339(&e.updated).is_ok());
}
#[test]
fn display_impls() {
assert_eq!(format!("{}", IntegrityResult::Ok), "ok");
assert_eq!(format!("{}", IntegrityResult::New), "new");
assert!(format!("{}", IntegrityResult::ChecksumChanged).contains("supply chain"));
let change = Change::Updated {
name: "gh".to_string(),
from: "2.88.0".to_string(),
to: "2.89.0".to_string(),
};
assert!(format!("{change}").contains("2.88.0 -> 2.89.0"));
}
}