lean_ctx/core/policy/org/
trust.rs1use std::path::PathBuf;
20
21use serde::{Deserialize, Serialize};
22
23const TRUST_ENV: &str = "LEANCTX_ORG_TRUST_KEY";
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct TrustedKey {
30 pub org: String,
32 pub public_key: String,
34 pub added_at: String,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct TrustStore {
41 #[serde(default, rename = "trusted_key", skip_serializing_if = "Vec::is_empty")]
42 pub trusted_keys: Vec<TrustedKey>,
43}
44
45pub fn trust_path() -> Result<PathBuf, String> {
47 Ok(crate::core::paths::config_dir()?.join("org-trust.toml"))
48}
49
50pub fn load() -> Result<TrustStore, String> {
53 let path = trust_path()?;
54 if !path.exists() {
55 return Ok(TrustStore::default());
56 }
57 let text =
58 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
59 toml::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
60}
61
62pub fn save(store: &TrustStore) -> Result<(), String> {
64 let path = trust_path()?;
65 if let Some(parent) = path.parent() {
66 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir config: {e}"))?;
67 }
68 let text = toml::to_string_pretty(store).map_err(|e| format!("serialize trust store: {e}"))?;
69 std::fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))
70}
71
72pub fn pin(org: &str, public_key: &str) -> Result<bool, String> {
76 let public_key = normalize_key(public_key)?;
77 let mut store = load()?;
78 if let Some(existing) = store
79 .trusted_keys
80 .iter_mut()
81 .find(|k| k.public_key == public_key)
82 {
83 let changed = existing.org != org;
84 existing.org = org.to_string();
85 existing.added_at = now();
86 save(&store)?;
87 return Ok(changed);
88 }
89 store.trusted_keys.push(TrustedKey {
90 org: org.to_string(),
91 public_key,
92 added_at: now(),
93 });
94 save(&store)?;
95 Ok(true)
96}
97
98pub fn remove(public_key: &str) -> Result<bool, String> {
100 let public_key = normalize_key(public_key)?;
101 let mut store = load()?;
102 let before = store.trusted_keys.len();
103 store.trusted_keys.retain(|k| k.public_key != public_key);
104 let removed = store.trusted_keys.len() != before;
105 if removed {
106 save(&store)?;
107 }
108 Ok(removed)
109}
110
111pub fn trusted_keys() -> Vec<TrustedKey> {
114 let mut keys: Vec<TrustedKey> = env_keys()
115 .into_iter()
116 .map(|public_key| TrustedKey {
117 org: "env".to_string(),
118 public_key,
119 added_at: String::new(),
120 })
121 .collect();
122 if let Ok(store) = load() {
123 for k in store.trusted_keys {
124 if !keys.iter().any(|e| e.public_key == k.public_key) {
125 keys.push(k);
126 }
127 }
128 }
129 keys
130}
131
132#[must_use]
134pub fn is_trusted(public_key: &str) -> bool {
135 let Ok(key) = normalize_key(public_key) else {
136 return false;
137 };
138 env_keys().contains(&key)
139 || load().is_ok_and(|s| s.trusted_keys.iter().any(|k| k.public_key == key))
140}
141
142#[must_use]
145pub fn any_pinned() -> bool {
146 !env_keys().is_empty() || load().is_ok_and(|s| !s.trusted_keys.is_empty())
147}
148
149fn env_keys() -> Vec<String> {
150 std::env::var(TRUST_ENV)
151 .ok()
152 .into_iter()
153 .flat_map(|v| {
154 v.split(',')
155 .filter_map(|k| normalize_key(k).ok())
156 .collect::<Vec<_>>()
157 })
158 .collect()
159}
160
161fn normalize_key(key: &str) -> Result<String, String> {
164 let k = key.trim().to_ascii_lowercase();
165 if k.len() != 64 || !k.bytes().all(|b| b.is_ascii_hexdigit()) {
166 return Err(format!(
167 "invalid public key (expected 64 hex chars, got {})",
168 k.len()
169 ));
170 }
171 Ok(k)
172}
173
174fn now() -> String {
175 chrono::Utc::now().to_rfc3339()
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::core::data_dir::isolated_data_dir;
182
183 fn sample_key() -> String {
184 "ab".repeat(32)
185 }
186
187 #[test]
188 fn normalize_rejects_bad_keys() {
189 assert!(normalize_key("xyz").is_err());
190 assert!(normalize_key(&"zz".repeat(32)).is_err());
191 assert!(normalize_key(&sample_key()).is_ok());
192 }
193
194 #[test]
195 fn pin_then_trusted_then_remove() {
196 let _iso = isolated_data_dir();
197 let key = sample_key();
198 assert!(!is_trusted(&key));
199 assert!(pin("acme", &key).unwrap());
200 assert!(is_trusted(&key));
201 assert!(any_pinned());
202 assert!(remove(&key).unwrap());
203 assert!(!is_trusted(&key));
204 }
205
206 #[test]
207 fn repin_same_key_updates_org() {
208 let _iso = isolated_data_dir();
209 let key = sample_key();
210 pin("acme", &key).unwrap();
211 assert!(pin("acme-renamed", &key).unwrap());
213 assert_eq!(
214 trusted_keys()
215 .iter()
216 .filter(|k| k.public_key == key)
217 .count(),
218 1
219 );
220 }
221
222 #[test]
223 fn env_override_is_trusted() {
224 let _iso = isolated_data_dir();
225 let key = sample_key();
226 crate::test_env::set_var(TRUST_ENV, &key);
227 assert!(is_trusted(&key));
228 assert!(any_pinned());
229 crate::test_env::remove_var(TRUST_ENV);
230 }
231}