lean_ctx/core/
workspace_trust.rs1use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25const TRUST_ALL_ENV: &str = "LEAN_CTX_TRUST_WORKSPACE";
29
30const TRUSTED_ROOTS_ENV: &str = "LEAN_CTX_TRUSTED_ROOTS";
33
34pub(crate) const FILE_NAME: &str = "workspace-trust.toml";
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct TrustedWorkspace {
40 pub path: String,
42 pub config_hash: String,
44 pub added_at: String,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct TrustStore {
51 #[serde(default, rename = "workspace", skip_serializing_if = "Vec::is_empty")]
52 pub workspaces: Vec<TrustedWorkspace>,
53}
54
55pub(crate) fn store_path() -> Result<PathBuf, String> {
57 Ok(crate::core::paths::config_dir()?.join(FILE_NAME))
58}
59
60pub fn load() -> Result<TrustStore, String> {
63 let path = store_path()?;
64 if !path.exists() {
65 return Ok(TrustStore::default());
66 }
67 let text =
68 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
69 toml::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
70}
71
72pub(crate) fn save(store: &TrustStore) -> Result<(), String> {
74 let path = store_path()?;
75 if let Some(parent) = path.parent() {
76 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir config: {e}"))?;
77 }
78 let text = toml::to_string_pretty(store).map_err(|e| format!("serialize trust store: {e}"))?;
79 std::fs::write(&path, &text).map_err(|e| format!("write {}: {e}", path.display()))?;
80 restrict_permissions(&path);
81 Ok(())
82}
83
84#[cfg(unix)]
85fn restrict_permissions(path: &Path) {
86 use std::os::unix::fs::PermissionsExt;
87 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
88}
89
90#[cfg(not(unix))]
91fn restrict_permissions(_path: &Path) {}
92
93fn canonical(root: &Path) -> String {
96 std::fs::canonicalize(root)
97 .unwrap_or_else(|_| root.to_path_buf())
98 .to_string_lossy()
99 .to_string()
100}
101
102#[must_use]
105pub(crate) fn config_hash_for(root: &Path) -> String {
106 let local = crate::core::config::Config::local_path(&root.to_string_lossy());
107 std::fs::read_to_string(&local)
108 .ok()
109 .map(|c| crate::core::hasher::hash_str(&c))
110 .unwrap_or_default()
111}
112
113fn now() -> String {
114 chrono::Utc::now().to_rfc3339()
115}
116
117fn env_trusted_roots() -> Vec<String> {
118 std::env::var(TRUSTED_ROOTS_ENV)
119 .ok()
120 .into_iter()
121 .flat_map(|v| {
122 v.split(',')
123 .map(str::trim)
124 .filter(|s| !s.is_empty())
125 .map(|s| canonical(Path::new(s)))
126 .collect::<Vec<_>>()
127 })
128 .collect()
129}
130
131fn trust_all_env() -> bool {
132 matches!(
133 std::env::var(TRUST_ALL_ENV).ok().as_deref(),
134 Some("1" | "true")
135 )
136}
137
138#[must_use]
146pub(crate) fn is_trusted_for(root: &Path, config_hash: &str) -> bool {
147 if trust_all_env() {
148 return true;
149 }
150 let canon = canonical(root);
151 if canon.is_empty() {
152 return false;
153 }
154 if env_trusted_roots().contains(&canon) {
155 return true;
156 }
157 load().is_ok_and(|s| {
158 s.workspaces
159 .iter()
160 .any(|w| w.path == canon && w.config_hash == config_hash)
161 })
162}
163
164#[must_use]
168pub(crate) fn is_pinned_in_store(store: &Path, root: &Path, config_hash: &str) -> bool {
169 let canon = canonical(root);
170 if canon.is_empty() {
171 return false;
172 }
173 std::fs::read_to_string(store)
174 .ok()
175 .and_then(|text| toml::from_str::<TrustStore>(&text).ok())
176 .is_some_and(|s| {
177 s.workspaces
178 .iter()
179 .any(|w| w.path == canon && w.config_hash == config_hash)
180 })
181}
182
183#[must_use]
187pub fn is_trusted(root: &Path) -> bool {
188 is_trusted_for(root, &config_hash_for(root))
189}
190
191pub fn trust(root: &Path) -> Result<TrustedWorkspace, String> {
194 let canon = canonical(root);
195 if canon.is_empty() {
196 return Err("cannot resolve workspace path".into());
197 }
198 let hash = config_hash_for(root);
199 let mut store = load()?;
200 if let Some(existing) = store.workspaces.iter_mut().find(|w| w.path == canon) {
201 existing.config_hash = hash;
202 existing.added_at = now();
203 let updated = existing.clone();
204 save(&store)?;
205 return Ok(updated);
206 }
207 let entry = TrustedWorkspace {
208 path: canon,
209 config_hash: hash,
210 added_at: now(),
211 };
212 store.workspaces.push(entry.clone());
213 save(&store)?;
214 Ok(entry)
215}
216
217pub fn untrust(root: &Path) -> Result<bool, String> {
219 let canon = canonical(root);
220 let mut store = load()?;
221 let before = store.workspaces.len();
222 store.workspaces.retain(|w| w.path != canon);
223 let removed = store.workspaces.len() != before;
224 if removed {
225 save(&store)?;
226 }
227 Ok(removed)
228}
229
230#[must_use]
233pub fn list() -> Vec<TrustedWorkspace> {
234 load().map(|s| s.workspaces).unwrap_or_default()
235}
236
237#[must_use]
249#[doc(hidden)]
250pub fn untrusted_override_notice() -> Option<String> {
251 let root = crate::core::config::Config::find_project_root()?;
252 untrusted_override_notice_for(Path::new(&root))
253}
254
255fn untrusted_override_notice_for(root: &Path) -> Option<String> {
258 let local = crate::core::config::Config::local_path(&root.to_string_lossy());
259 let toml = std::fs::read_to_string(&local).ok()?;
260 let withheld = crate::core::config::local_sensitive_overrides(&toml);
261 if withheld.is_empty() || is_trusted(root) {
262 return None;
263 }
264 let cfg_path = crate::core::config::Config::path().map_or_else(
265 || "the global config".to_string(),
266 |p| p.display().to_string(),
267 );
268 Some(format!(
269 "This workspace's .lean-ctx.toml sets security-sensitive override(s) [{keys}] that \
270 lean-ctx IGNORES because the workspace is untrusted — the usual reason such an edit \
271 appears to do nothing. To apply them, review the file then run `lean-ctx trust` in \
272 {root}, or move the key(s) into the global config ({cfg_path}), which is never \
273 trust-gated.",
274 keys = withheld.join(", "),
275 root = root.display(),
276 ))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::core::data_dir::isolated_data_dir;
283
284 #[test]
285 fn untrusted_root_is_not_trusted() {
286 let _iso = isolated_data_dir();
287 let dir = tempfile::tempdir().unwrap();
288 assert!(!is_trusted(dir.path()));
289 }
290
291 #[test]
292 fn trust_then_is_trusted_then_untrust() {
293 let _iso = isolated_data_dir();
294 let dir = tempfile::tempdir().unwrap();
295 assert!(!is_trusted(dir.path()));
296 trust(dir.path()).unwrap();
297 assert!(is_trusted(dir.path()));
298 assert!(untrust(dir.path()).unwrap());
299 assert!(!is_trusted(dir.path()));
300 }
301
302 #[test]
303 fn editing_local_config_after_trust_invalidates_pin() {
304 let _iso = isolated_data_dir();
305 let dir = tempfile::tempdir().unwrap();
306 let local = dir.path().join(".lean-ctx.toml");
307 std::fs::write(&local, "theme = \"a\"\n").unwrap();
308 trust(dir.path()).unwrap();
309 assert!(is_trusted(dir.path()));
310 std::fs::write(&local, "theme = \"b\"\n").unwrap();
312 assert!(!is_trusted(dir.path()));
313 }
314
315 #[test]
316 fn env_trust_all_overrides_store() {
317 let _iso = isolated_data_dir();
318 let dir = tempfile::tempdir().unwrap();
319 crate::test_env::set_var(TRUST_ALL_ENV, "1");
320 assert!(is_trusted(dir.path()));
321 crate::test_env::remove_var(TRUST_ALL_ENV);
322 assert!(!is_trusted(dir.path()));
323 }
324
325 #[test]
326 fn env_trusted_roots_lists_canonical_path() {
327 let _iso = isolated_data_dir();
328 let dir = tempfile::tempdir().unwrap();
329 let canon = canonical(dir.path());
330 crate::test_env::set_var(TRUSTED_ROOTS_ENV, &canon);
331 assert!(is_trusted(dir.path()));
332 crate::test_env::remove_var(TRUSTED_ROOTS_ENV);
333 }
334
335 #[test]
336 fn retrust_after_edit_repins_new_hash() {
337 let _iso = isolated_data_dir();
338 let dir = tempfile::tempdir().unwrap();
339 let local = dir.path().join(".lean-ctx.toml");
340 std::fs::write(&local, "theme = \"a\"\n").unwrap();
341 trust(dir.path()).unwrap();
342 std::fs::write(&local, "theme = \"b\"\n").unwrap();
343 assert!(!is_trusted(dir.path()));
344 trust(dir.path()).unwrap();
345 assert!(is_trusted(dir.path()));
346 }
347
348 #[test]
352 fn untrusted_sensitive_override_yields_actionable_notice() {
353 let _iso = isolated_data_dir();
354 let dir = tempfile::tempdir().unwrap();
355 std::fs::write(
356 dir.path().join(".lean-ctx.toml"),
357 "allow_paths = [\"/srv/data\"]\nshell_allowlist_extra = [\"glab\"]\n",
358 )
359 .unwrap();
360 let notice = untrusted_override_notice_for(dir.path()).expect("untrusted → notice");
361 assert!(notice.contains("allow_paths"), "{notice}");
362 assert!(notice.contains("shell_allowlist_extra"), "{notice}");
363 assert!(notice.contains("lean-ctx trust"), "{notice}");
364 }
365
366 #[test]
367 fn trusted_workspace_yields_no_notice() {
368 let _iso = isolated_data_dir();
369 let dir = tempfile::tempdir().unwrap();
370 std::fs::write(
371 dir.path().join(".lean-ctx.toml"),
372 "allow_paths = [\"/srv/data\"]\n",
373 )
374 .unwrap();
375 trust(dir.path()).unwrap();
376 assert!(untrusted_override_notice_for(dir.path()).is_none());
377 }
378
379 #[test]
380 fn no_local_config_yields_no_notice() {
381 let _iso = isolated_data_dir();
382 let dir = tempfile::tempdir().unwrap();
383 assert!(untrusted_override_notice_for(dir.path()).is_none());
384 }
385
386 #[test]
387 fn comfort_only_override_yields_no_notice() {
388 let _iso = isolated_data_dir();
389 let dir = tempfile::tempdir().unwrap();
390 std::fs::write(dir.path().join(".lean-ctx.toml"), "theme = \"dark\"\n").unwrap();
392 assert!(untrusted_override_notice_for(dir.path()).is_none());
393 }
394}