use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const TRUST_ALL_ENV: &str = "LEAN_CTX_TRUST_WORKSPACE";
const TRUSTED_ROOTS_ENV: &str = "LEAN_CTX_TRUSTED_ROOTS";
const FILE_NAME: &str = "workspace-trust.toml";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedWorkspace {
pub path: String,
pub config_hash: String,
pub added_at: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustStore {
#[serde(default, rename = "workspace", skip_serializing_if = "Vec::is_empty")]
pub workspaces: Vec<TrustedWorkspace>,
}
pub fn store_path() -> Result<PathBuf, String> {
Ok(crate::core::paths::config_dir()?.join(FILE_NAME))
}
pub fn load() -> Result<TrustStore, String> {
let path = store_path()?;
if !path.exists() {
return Ok(TrustStore::default());
}
let text =
std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
toml::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
}
pub fn save(store: &TrustStore) -> Result<(), String> {
let path = store_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir config: {e}"))?;
}
let text = toml::to_string_pretty(store).map_err(|e| format!("serialize trust store: {e}"))?;
std::fs::write(&path, &text).map_err(|e| format!("write {}: {e}", path.display()))?;
restrict_permissions(&path);
Ok(())
}
#[cfg(unix)]
fn restrict_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &Path) {}
fn canonical(root: &Path) -> String {
std::fs::canonicalize(root)
.unwrap_or_else(|_| root.to_path_buf())
.to_string_lossy()
.to_string()
}
#[must_use]
pub fn config_hash_for(root: &Path) -> String {
let local = crate::core::config::Config::local_path(&root.to_string_lossy());
std::fs::read_to_string(&local)
.ok()
.map(|c| crate::core::hasher::hash_str(&c))
.unwrap_or_default()
}
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
fn env_trusted_roots() -> Vec<String> {
std::env::var(TRUSTED_ROOTS_ENV)
.ok()
.into_iter()
.flat_map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| canonical(Path::new(s)))
.collect::<Vec<_>>()
})
.collect()
}
fn trust_all_env() -> bool {
matches!(
std::env::var(TRUST_ALL_ENV).ok().as_deref(),
Some("1" | "true")
)
}
#[must_use]
pub fn is_trusted_for(root: &Path, config_hash: &str) -> bool {
if trust_all_env() {
return true;
}
let canon = canonical(root);
if canon.is_empty() {
return false;
}
if env_trusted_roots().contains(&canon) {
return true;
}
load().is_ok_and(|s| {
s.workspaces
.iter()
.any(|w| w.path == canon && w.config_hash == config_hash)
})
}
#[must_use]
pub fn is_trusted(root: &Path) -> bool {
is_trusted_for(root, &config_hash_for(root))
}
pub fn trust(root: &Path) -> Result<TrustedWorkspace, String> {
let canon = canonical(root);
if canon.is_empty() {
return Err("cannot resolve workspace path".into());
}
let hash = config_hash_for(root);
let mut store = load()?;
if let Some(existing) = store.workspaces.iter_mut().find(|w| w.path == canon) {
existing.config_hash = hash;
existing.added_at = now();
let updated = existing.clone();
save(&store)?;
return Ok(updated);
}
let entry = TrustedWorkspace {
path: canon,
config_hash: hash,
added_at: now(),
};
store.workspaces.push(entry.clone());
save(&store)?;
Ok(entry)
}
pub fn untrust(root: &Path) -> Result<bool, String> {
let canon = canonical(root);
let mut store = load()?;
let before = store.workspaces.len();
store.workspaces.retain(|w| w.path != canon);
let removed = store.workspaces.len() != before;
if removed {
save(&store)?;
}
Ok(removed)
}
#[must_use]
pub fn list() -> Vec<TrustedWorkspace> {
load().map(|s| s.workspaces).unwrap_or_default()
}
#[must_use]
pub fn untrusted_override_notice() -> Option<String> {
let root = crate::core::config::Config::find_project_root()?;
untrusted_override_notice_for(Path::new(&root))
}
fn untrusted_override_notice_for(root: &Path) -> Option<String> {
let local = crate::core::config::Config::local_path(&root.to_string_lossy());
let toml = std::fs::read_to_string(&local).ok()?;
let withheld = crate::core::config::local_sensitive_overrides(&toml);
if withheld.is_empty() || is_trusted(root) {
return None;
}
let cfg_path = crate::core::config::Config::path().map_or_else(
|| "the global config".to_string(),
|p| p.display().to_string(),
);
Some(format!(
"This workspace's .lean-ctx.toml sets security-sensitive override(s) [{keys}] that \
lean-ctx IGNORES because the workspace is untrusted — the usual reason such an edit \
appears to do nothing. To apply them, review the file then run `lean-ctx trust` in \
{root}, or move the key(s) into the global config ({cfg_path}), which is never \
trust-gated.",
keys = withheld.join(", "),
root = root.display(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::data_dir::isolated_data_dir;
#[test]
fn untrusted_root_is_not_trusted() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
assert!(!is_trusted(dir.path()));
}
#[test]
fn trust_then_is_trusted_then_untrust() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
assert!(!is_trusted(dir.path()));
trust(dir.path()).unwrap();
assert!(is_trusted(dir.path()));
assert!(untrust(dir.path()).unwrap());
assert!(!is_trusted(dir.path()));
}
#[test]
fn editing_local_config_after_trust_invalidates_pin() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let local = dir.path().join(".lean-ctx.toml");
std::fs::write(&local, "theme = \"a\"\n").unwrap();
trust(dir.path()).unwrap();
assert!(is_trusted(dir.path()));
std::fs::write(&local, "theme = \"b\"\n").unwrap();
assert!(!is_trusted(dir.path()));
}
#[test]
fn env_trust_all_overrides_store() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
crate::test_env::set_var(TRUST_ALL_ENV, "1");
assert!(is_trusted(dir.path()));
crate::test_env::remove_var(TRUST_ALL_ENV);
assert!(!is_trusted(dir.path()));
}
#[test]
fn env_trusted_roots_lists_canonical_path() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let canon = canonical(dir.path());
crate::test_env::set_var(TRUSTED_ROOTS_ENV, &canon);
assert!(is_trusted(dir.path()));
crate::test_env::remove_var(TRUSTED_ROOTS_ENV);
}
#[test]
fn retrust_after_edit_repins_new_hash() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
let local = dir.path().join(".lean-ctx.toml");
std::fs::write(&local, "theme = \"a\"\n").unwrap();
trust(dir.path()).unwrap();
std::fs::write(&local, "theme = \"b\"\n").unwrap();
assert!(!is_trusted(dir.path()));
trust(dir.path()).unwrap();
assert!(is_trusted(dir.path()));
}
#[test]
fn untrusted_sensitive_override_yields_actionable_notice() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".lean-ctx.toml"),
"allow_paths = [\"/srv/data\"]\nshell_allowlist_extra = [\"glab\"]\n",
)
.unwrap();
let notice = untrusted_override_notice_for(dir.path()).expect("untrusted → notice");
assert!(notice.contains("allow_paths"), "{notice}");
assert!(notice.contains("shell_allowlist_extra"), "{notice}");
assert!(notice.contains("lean-ctx trust"), "{notice}");
}
#[test]
fn trusted_workspace_yields_no_notice() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join(".lean-ctx.toml"),
"allow_paths = [\"/srv/data\"]\n",
)
.unwrap();
trust(dir.path()).unwrap();
assert!(untrusted_override_notice_for(dir.path()).is_none());
}
#[test]
fn no_local_config_yields_no_notice() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
assert!(untrusted_override_notice_for(dir.path()).is_none());
}
#[test]
fn comfort_only_override_yields_no_notice() {
let _iso = isolated_data_dir();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".lean-ctx.toml"), "theme = \"dark\"\n").unwrap();
assert!(untrusted_override_notice_for(dir.path()).is_none());
}
}