lean_ctx/core/addons/
integrity.rs1use crate::core::gateway::GatewayServer;
16
17#[must_use]
21pub fn wiring_hash(server: &GatewayServer) -> String {
22 let json = serde_json::to_string(server).unwrap_or_default();
23 crate::core::hasher::hash_str(&json)
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum IntegrityStatus {
29 Ok,
31 Drift,
33 Missing,
35 Unpinned,
37}
38
39impl IntegrityStatus {
40 #[must_use]
41 pub fn label(&self) -> &'static str {
42 match self {
43 Self::Ok => "ok",
44 Self::Drift => "DRIFT",
45 Self::Missing => "missing",
46 Self::Unpinned => "unpinned",
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct IntegrityFinding {
54 pub name: String,
55 pub status: IntegrityStatus,
56}
57
58#[must_use]
61pub fn verify(
62 installed: &[&super::store::InstalledAddon],
63 servers: &[GatewayServer],
64) -> Vec<IntegrityFinding> {
65 let mut out: Vec<IntegrityFinding> = installed
66 .iter()
67 .map(|addon| {
68 let live = servers.iter().find(|s| s.name == addon.gateway_server);
69 let status = match (&addon.content_hash, live) {
70 (None, _) => IntegrityStatus::Unpinned,
71 (Some(_), None) => IntegrityStatus::Missing,
72 (Some(pinned), Some(server)) => {
73 if *pinned == wiring_hash(server) {
74 IntegrityStatus::Ok
75 } else {
76 IntegrityStatus::Drift
77 }
78 }
79 };
80 IntegrityFinding {
81 name: addon.name.clone(),
82 status,
83 }
84 })
85 .collect();
86 out.sort_by(|a, b| a.name.cmp(&b.name));
87 out
88}
89
90#[must_use]
92pub fn verify_all() -> Vec<IntegrityFinding> {
93 let store = super::store::InstalledStore::load();
94 let cfg = crate::core::config::Config::load();
95 verify(&store.list(), &cfg.gateway.servers)
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::core::addons::store::InstalledAddon;
102
103 fn server(name: &str, command: &str) -> GatewayServer {
104 GatewayServer {
105 name: name.into(),
106 command: command.into(),
107 ..Default::default()
108 }
109 }
110
111 fn installed(name: &str, hash: Option<String>) -> InstalledAddon {
112 InstalledAddon {
113 name: name.into(),
114 version: "1.0.0".into(),
115 source: "registry".into(),
116 gateway_server: name.into(),
117 granted_capabilities: None,
118 content_hash: hash,
119 install: None,
120 }
121 }
122
123 #[test]
124 fn hash_is_deterministic_and_wiring_sensitive() {
125 let a = wiring_hash(&server("x", "cmd"));
126 let b = wiring_hash(&server("x", "cmd"));
127 assert_eq!(a, b, "same wiring → same hash");
128 let c = wiring_hash(&server("x", "other"));
129 assert_ne!(a, c, "different command → different hash");
130 }
131
132 #[test]
133 fn matching_hash_is_ok_drift_is_detected() {
134 let srv = server("demo", "demo-mcp");
135 let pinned = wiring_hash(&srv);
136 let addon = installed("demo", Some(pinned));
137
138 let findings = verify(&[&addon], std::slice::from_ref(&srv));
140 assert_eq!(findings[0].status, IntegrityStatus::Ok);
141
142 let tampered = server("demo", "evil-mcp");
144 let findings = verify(&[&addon], &[tampered]);
145 assert_eq!(findings[0].status, IntegrityStatus::Drift);
146 }
147
148 #[test]
149 fn missing_and_unpinned_are_reported() {
150 let pinned = wiring_hash(&server("gone", "x"));
151 let missing = installed("gone", Some(pinned));
152 assert_eq!(verify(&[&missing], &[])[0].status, IntegrityStatus::Missing);
153
154 let legacy = installed("old", None);
155 assert_eq!(
156 verify(&[&legacy], &[server("old", "x")])[0].status,
157 IntegrityStatus::Unpinned
158 );
159 }
160
161 #[test]
162 fn findings_are_name_sorted() {
163 let b = installed("b", None);
164 let a = installed("a", None);
165 let findings = verify(&[&b, &a], &[]);
166 assert_eq!(findings[0].name, "a");
167 assert_eq!(findings[1].name, "b");
168 }
169}