lean_ctx/core/config/
shell_policy.rs1use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex};
42
43use super::{Config, ShellApproval};
44
45enum Source {
47 #[cfg_attr(not(any(test, debug_assertions)), allow(dead_code))]
49 Ambient,
50 #[cfg_attr(all(debug_assertions, not(test)), allow(dead_code))]
53 Pinned(Option<PathBuf>),
54}
55
56#[cfg(any(test, feature = "test-support"))]
57static TEST_PIN: Mutex<Option<PathBuf>> = Mutex::new(None);
58
59fn source() -> Source {
60 #[cfg(any(test, feature = "test-support"))]
61 {
62 if let Some(home) = TEST_PIN
63 .lock()
64 .unwrap_or_else(std::sync::PoisonError::into_inner)
65 .clone()
66 {
67 return Source::Pinned(Some(home));
68 }
69 }
70 #[cfg(any(test, debug_assertions))]
71 {
72 Source::Ambient
73 }
74 #[cfg(not(any(test, debug_assertions)))]
75 {
76 Source::Pinned(real_user_home())
77 }
78}
79
80#[cfg(any(test, feature = "test-support"))]
84#[must_use]
85pub fn pin_for_test(home: &Path) -> PinGuard {
86 assert!(
87 crate::core::data_dir::holds_test_env_lock(),
88 "pin_for_test requires test_env_lock()"
89 );
90 *TEST_PIN
91 .lock()
92 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(home.to_path_buf());
93 PinGuard(())
94}
95
96#[cfg(any(test, feature = "test-support"))]
98pub struct PinGuard(());
99
100#[cfg(any(test, feature = "test-support"))]
101impl Drop for PinGuard {
102 fn drop(&mut self) {
103 *TEST_PIN
104 .lock()
105 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
106 }
107}
108
109#[must_use]
111pub fn pinned() -> bool {
112 matches!(source(), Source::Pinned(_))
113}
114
115#[cfg(unix)]
118#[must_use]
119pub fn real_user_home() -> Option<PathBuf> {
120 use std::ffi::{CStr, OsStr};
121 use std::os::unix::ffi::OsStrExt;
122
123 let uid = unsafe { libc::geteuid() };
125 let mut buf = vec![0_u8; 4096];
126 loop {
127 let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
130 let mut result: *mut libc::passwd = std::ptr::null_mut();
131 let rc = unsafe {
134 libc::getpwuid_r(
135 uid,
136 &raw mut pwd,
137 buf.as_mut_ptr().cast(),
138 buf.len(),
139 &raw mut result,
140 )
141 };
142 if rc == libc::ERANGE && buf.len() < (1 << 20) {
143 buf.resize(buf.len() * 2, 0);
144 continue;
145 }
146 if rc != 0 || result.is_null() || pwd.pw_dir.is_null() {
147 return None;
148 }
149 let dir = unsafe { CStr::from_ptr(pwd.pw_dir) };
152 let path = PathBuf::from(OsStr::from_bytes(dir.to_bytes()));
153 return (path.is_absolute()).then_some(path);
154 }
155}
156
157#[cfg(not(unix))]
161#[must_use]
162pub fn real_user_home() -> Option<PathBuf> {
163 dirs::home_dir().filter(|p| p.is_absolute())
164}
165
166fn config_dir_in(home: &Path) -> PathBuf {
170 let xdg_config = home.join(".config");
171 let xdg_data = home.join(".local").join("share");
172 crate::core::paths::single_dir_override_fs(home, &xdg_config, Some(&xdg_data))
173 .unwrap_or_else(|| xdg_config.join("lean-ctx"))
174}
175
176#[must_use]
178pub fn config_path() -> Option<PathBuf> {
179 match source() {
180 Source::Ambient => Config::path(),
181 Source::Pinned(home) => home.map(|h| config_dir_in(&h).join("config.toml")),
182 }
183}
184
185#[must_use]
187pub fn settings_home() -> Option<PathBuf> {
188 match source() {
189 Source::Ambient => crate::core::home::resolve_home_dir(),
190 Source::Pinned(home) => home,
191 }
192}
193
194type PinnedCache = Option<(String, Arc<Config>)>;
195
196#[must_use]
198pub fn config() -> Arc<Config> {
199 match source() {
200 Source::Ambient => Config::load_arc(),
201 Source::Pinned(None) => Arc::new(Config::default()),
202 Source::Pinned(Some(home)) => pinned_config(&home),
203 }
204}
205
206fn pinned_config(home: &Path) -> Arc<Config> {
207 static CACHE: Mutex<PinnedCache> = Mutex::new(None);
208 let dir = config_dir_in(home);
209 let global = std::fs::read_to_string(dir.join("config.toml")).ok();
210 let root = project_root_from_cwd();
213 let local = root.as_deref().and_then(|r| {
214 let path = Config::local_path(r);
215 crate::core::pathutil::may_probe_path(&path)
216 .then(|| std::fs::read_to_string(path).ok())
217 .flatten()
218 });
219 let local_hash = local.as_deref().map(crate::core::hasher::hash_str);
220 let trusted = match (root.as_deref(), local_hash.as_deref()) {
221 (Some(r), Some(hash)) => crate::core::workspace_trust::is_pinned_in_store(
222 &dir.join(crate::core::workspace_trust::FILE_NAME),
223 Path::new(r),
224 hash,
225 ),
226 _ => false,
227 };
228 let key = format!(
229 "{}\0{}\0{}\0{}\0{trusted}",
230 dir.display(),
231 global
232 .as_deref()
233 .map(crate::core::hasher::hash_str)
234 .unwrap_or_default(),
235 root.as_deref().unwrap_or_default(),
236 local_hash.as_deref().unwrap_or_default(),
237 );
238 let mut guard = CACHE
239 .lock()
240 .unwrap_or_else(std::sync::PoisonError::into_inner);
241 if let Some((cached_key, cfg)) = guard.as_ref()
242 && *cached_key == key
243 {
244 return Arc::clone(cfg);
245 }
246 let mut cfg = global
248 .as_deref()
249 .and_then(|raw| super::loader::parse_config_with_profile(raw, None).ok())
250 .unwrap_or_default();
251 if let Some(local) = local.as_deref() {
252 super::loader::without_env_profile(|| cfg.merge_local(local, trusted));
253 }
254 let cfg = Arc::new(cfg);
255 *guard = Some((key, Arc::clone(&cfg)));
256 cfg
257}
258
259fn project_root_from_cwd() -> Option<String> {
262 let cwd = std::env::current_dir().ok()?;
263 let root = cwd
264 .ancestors()
265 .find(|dir| dir.join(".git").exists())
266 .unwrap_or(&cwd);
267 Some(root.to_string_lossy().into_owned())
268}
269
270#[must_use]
273pub fn env(name: &str) -> Option<String> {
274 match source() {
275 Source::Ambient => std::env::var(name).ok(),
276 Source::Pinned(_) => None,
277 }
278}
279
280fn env_flag(name: &str) -> Option<bool> {
281 env(name).map(|raw| {
282 matches!(
283 raw.trim().to_ascii_lowercase().as_str(),
284 "1" | "true" | "yes" | "on"
285 )
286 })
287}
288
289#[must_use]
291pub fn allow_inline_scripts() -> bool {
292 env_flag("LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS")
293 .unwrap_or_else(|| config().shell_allow_inline_scripts)
294}
295
296#[must_use]
298pub fn allow_writes() -> bool {
299 env_flag("LEAN_CTX_SHELL_ALLOW_WRITES").unwrap_or_else(|| config().shell_allow_writes)
300}
301
302#[must_use]
304pub fn approval() -> ShellApproval {
305 match source() {
306 Source::Ambient => ShellApproval::effective(&config()),
307 Source::Pinned(_) => config().shell_approval,
308 }
309}
310
311#[must_use]
313pub fn shell_hook_mode() -> String {
314 env("LEAN_CTX_SHELL_HOOK_MODE")
315 .filter(|v| !v.trim().is_empty())
316 .or_else(|| config().shell_hook_mode.clone())
317 .unwrap_or_default()
318}
319
320#[must_use]
322pub fn cli_warn_only_requested() -> bool {
323 env("LEAN_CTX_ALLOWLIST_WARN_ONLY").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn real_user_home_ignores_home_env() {
332 let _lock = crate::core::data_dir::test_env_lock();
333 let before = real_user_home();
334 let _home = crate::test_env::VarGuard::set("HOME", "/tmp/forged-home");
335 assert_eq!(real_user_home(), before);
336 assert_ne!(
337 real_user_home().as_deref(),
338 Some(Path::new("/tmp/forged-home"))
339 );
340 }
341
342 #[test]
343 fn pinned_source_ignores_env_and_relocated_config() {
344 let _lock = crate::core::data_dir::test_env_lock();
345 let real = tempfile::tempdir().unwrap();
346 let forged = tempfile::tempdir().unwrap();
347 std::fs::create_dir_all(forged.path().join(".config/lean-ctx")).unwrap();
348 std::fs::write(
349 forged.path().join(".config/lean-ctx/config.toml"),
350 "shell_security = \"off\"\nshell_allow_inline_scripts = true\n",
351 )
352 .unwrap();
353 let _pin = pin_for_test(real.path());
354 let _a = crate::test_env::VarGuard::set("LEAN_CTX_SHELL_SECURITY", "off");
355 let _b = crate::test_env::VarGuard::set("LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS", "1");
356 let _c = crate::test_env::VarGuard::set("HOME", forged.path());
357 let _d = crate::test_env::VarGuard::set("LEAN_CTX_CONFIG_DIR", forged.path());
358 assert_eq!(env("LEAN_CTX_SHELL_SECURITY"), None);
359 assert!(!allow_inline_scripts());
360 assert_eq!(config().shell_security, None);
361 assert_eq!(
362 config_path(),
363 Some(real.path().join(".config/lean-ctx/config.toml"))
364 );
365 }
366
367 #[test]
368 fn pinned_source_reads_the_real_home_config() {
369 let _lock = crate::core::data_dir::test_env_lock();
370 let real = tempfile::tempdir().unwrap();
371 std::fs::create_dir_all(real.path().join(".config/lean-ctx")).unwrap();
372 std::fs::write(
373 real.path().join(".config/lean-ctx/config.toml"),
374 "shell_security = \"warn\"\n",
375 )
376 .unwrap();
377 let _pin = pin_for_test(real.path());
378 assert_eq!(config().shell_security.as_deref(), Some("warn"));
379 }
380}