lean_ctx/core/addons/
integrity.rs1use crate::core::mcp_catalog::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 artifact: None,
121 }
122 }
123
124 #[test]
125 fn hash_is_deterministic_and_wiring_sensitive() {
126 let a = wiring_hash(&server("x", "cmd"));
127 let b = wiring_hash(&server("x", "cmd"));
128 assert_eq!(a, b, "same wiring → same hash");
129 let c = wiring_hash(&server("x", "other"));
130 assert_ne!(a, c, "different command → different hash");
131 }
132
133 #[test]
134 fn matching_hash_is_ok_drift_is_detected() {
135 let srv = server("demo", "demo-mcp");
136 let pinned = wiring_hash(&srv);
137 let addon = installed("demo", Some(pinned));
138
139 let findings = verify(&[&addon], std::slice::from_ref(&srv));
141 assert_eq!(findings[0].status, IntegrityStatus::Ok);
142
143 let tampered = server("demo", "evil-mcp");
145 let findings = verify(&[&addon], &[tampered]);
146 assert_eq!(findings[0].status, IntegrityStatus::Drift);
147 }
148
149 #[test]
150 fn missing_and_unpinned_are_reported() {
151 let pinned = wiring_hash(&server("gone", "x"));
152 let missing = installed("gone", Some(pinned));
153 assert_eq!(verify(&[&missing], &[])[0].status, IntegrityStatus::Missing);
154
155 let legacy = installed("old", None);
156 assert_eq!(
157 verify(&[&legacy], &[server("old", "x")])[0].status,
158 IntegrityStatus::Unpinned
159 );
160 }
161
162 #[test]
163 fn findings_are_name_sorted() {
164 let b = installed("b", None);
165 let a = installed("a", None);
166 let findings = verify(&[&b, &a], &[]);
167 assert_eq!(findings[0].name, "a");
168 assert_eq!(findings[1].name, "b");
169 }
170}