1use std::fs;
5use std::io::ErrorKind;
6use std::path::{Component, Path, PathBuf};
7
8use anyhow::{anyhow, bail, Context, Result};
9use serde::Serialize;
10
11use crate::config::{SearchScopeConfig, SearchScopeMode};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum AccessRootOrigin {
16 HarnessRoots,
17 ExplicitConfig,
18 InvocationDirectory,
19}
20
21impl AccessRootOrigin {
22 pub const fn as_str(self) -> &'static str {
23 match self {
24 Self::HarnessRoots => "harness-roots",
25 Self::ExplicitConfig => "explicit-config",
26 Self::InvocationDirectory => "invocation-directory",
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
32pub struct AccessRootSource {
33 configured_path: PathBuf,
34 canonicalized_at_startup: bool,
35 origin: AccessRootOrigin,
36}
37
38impl AccessRootSource {
39 pub fn configured_path(&self) -> &Path {
40 &self.configured_path
41 }
42
43 pub const fn origin(&self) -> AccessRootOrigin {
44 self.origin
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
49pub struct AccessRoot {
50 canonical_path: PathBuf,
51 match_paths: Vec<PathBuf>,
52 sources: Vec<AccessRootSource>,
53}
54
55impl AccessRoot {
56 pub fn path(&self) -> &Path {
57 &self.canonical_path
58 }
59
60 pub fn configured_path(&self) -> &Path {
61 self.sources[0].configured_path()
62 }
63
64 pub fn origin(&self) -> AccessRootOrigin {
65 self.sources[0].origin()
66 }
67
68 pub fn sources(&self) -> &[AccessRootSource] {
69 &self.sources
70 }
71
72 fn database_prefixes(&self) -> impl Iterator<Item = &str> {
73 self.match_paths
74 .iter()
75 .map(|path| path.to_str().expect("validated access roots are UTF-8"))
76 }
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct TrustedAccessInputs {
81 pub harness_roots: Vec<PathBuf>,
82 pub invocation_directory: Option<PathBuf>,
83}
84
85impl TrustedAccessInputs {
86 pub(crate) fn capture(config: &SearchScopeConfig, harness_roots: Vec<PathBuf>) -> Result<Self> {
87 let invocation_directory = if config.include_invocation_directory {
88 Some(std::env::current_dir().map_err(|error| {
89 anyhow!("cannot resolve invocation directory for allowed-roots scope: {error}")
90 })?)
91 } else {
92 None
93 };
94 Ok(Self {
95 harness_roots,
96 invocation_directory,
97 })
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
102#[serde(tag = "mode", rename_all = "kebab-case")]
103pub enum EffectiveAccessScope {
104 All,
105 AllowedRoots { roots: Vec<AccessRoot> },
106}
107
108impl EffectiveAccessScope {
109 pub fn resolve(config: &SearchScopeConfig, inputs: TrustedAccessInputs) -> Result<Self> {
110 if config.mode == SearchScopeMode::All {
111 return Ok(Self::All);
112 }
113
114 let mut candidates = Vec::new();
115 candidates.extend(
116 inputs
117 .harness_roots
118 .into_iter()
119 .map(|path| (path, AccessRootOrigin::HarnessRoots)),
120 );
121 candidates.extend(
122 config
123 .roots
124 .iter()
125 .map(|path| (PathBuf::from(path), AccessRootOrigin::ExplicitConfig)),
126 );
127 if config.include_invocation_directory {
128 if let Some(path) = inputs.invocation_directory {
129 candidates.push((path, AccessRootOrigin::InvocationDirectory));
130 }
131 }
132
133 let mut roots = Vec::new();
134 for (path, origin) in candidates {
135 let candidate = normalize_authority_root(&path, origin).with_context(|| {
136 format!("invalid {} access root {:?}", origin_name(origin), path)
137 })?;
138 if let Some(existing) = roots
139 .iter_mut()
140 .find(|root: &&mut AccessRoot| root.canonical_path == candidate.canonical_path)
141 {
142 for path in candidate.match_paths {
143 if !existing.match_paths.contains(&path) {
144 existing.match_paths.push(path);
145 }
146 }
147 existing.sources.extend(candidate.sources);
148 } else {
149 roots.push(candidate);
150 }
151 }
152
153 if roots.is_empty() {
154 bail!(
155 "search scope mode allowed-roots resolved no authoritative roots; configure search.scope.roots, enable include_invocation_directory, or supply trusted harness roots"
156 );
157 }
158 Ok(Self::AllowedRoots { roots })
159 }
160
161 pub const fn is_unrestricted(&self) -> bool {
162 matches!(self, Self::All)
163 }
164
165 pub fn roots(&self) -> &[AccessRoot] {
166 match self {
167 Self::All => &[],
168 Self::AllowedRoots { roots } => roots,
169 }
170 }
171
172 pub fn workspace_prefixes(&self) -> impl Iterator<Item = &str> {
173 self.roots().iter().flat_map(AccessRoot::database_prefixes)
174 }
175
176 pub fn allows_workspace_path(&self, path: &Path) -> bool {
177 match self {
178 Self::All => true,
179 Self::AllowedRoots { roots } => lexical_absolute(path).is_ok_and(|path| {
180 roots
181 .iter()
182 .flat_map(|root| &root.match_paths)
183 .any(|root| path.starts_with(root))
184 }),
185 }
186 }
187
188 pub fn validate_stable(&self) -> Result<()> {
189 for root in self.roots() {
190 for source in &root.sources {
191 let current = match fs::canonicalize(&source.configured_path) {
192 Ok(current) => current,
193 Err(error)
194 if error.kind() == ErrorKind::NotFound
195 && !source.canonicalized_at_startup =>
196 {
197 continue;
198 }
199 Err(error) => {
200 return Err(error).with_context(|| {
201 format!(
202 "allowed root {:?} disappeared after scope resolution",
203 source.configured_path
204 )
205 });
206 }
207 };
208 if !current.is_dir() {
209 bail!(
210 "allowed root {:?} is no longer a directory; refusing to widen access",
211 source.configured_path
212 );
213 }
214 if current != root.canonical_path {
215 bail!(
216 "allowed root {:?} changed target after scope resolution; refusing to widen access",
217 source.configured_path
218 );
219 }
220 }
221 }
222 Ok(())
223 }
224}
225
226fn origin_name(origin: AccessRootOrigin) -> &'static str {
227 origin.as_str()
228}
229
230fn normalize_authority_root(path: &Path, origin: AccessRootOrigin) -> Result<AccessRoot> {
231 let lexical = lexical_absolute(path)?;
232 let (canonical, canonicalized_at_startup) = match fs::canonicalize(&lexical) {
233 Ok(path) => {
234 if !path.is_dir() {
235 bail!("access root exists but is not a directory");
236 }
237 (lexical_absolute(&path)?, true)
238 }
239 Err(error)
240 if error.kind() == ErrorKind::NotFound
241 && origin == AccessRootOrigin::ExplicitConfig =>
242 {
243 (lexical.clone(), false)
244 }
245 Err(error) if error.kind() == ErrorKind::NotFound => {
246 bail!("trusted runtime access root does not exist")
247 }
248 Err(error) => {
249 return Err(error).with_context(|| format!("cannot resolve access root {lexical:?}"));
250 }
251 };
252 if canonical.parent().is_none() {
253 bail!("filesystem roots are not valid allowed-roots entries");
254 }
255 if lexical.to_str().is_none() || canonical.to_str().is_none() {
256 bail!("access roots must be valid UTF-8 for the SQLite path model");
257 }
258 let mut match_paths = vec![canonical.clone()];
259 if lexical != canonical {
260 match_paths.push(lexical.clone());
261 }
262 Ok(AccessRoot {
263 canonical_path: canonical,
264 match_paths,
265 sources: vec![AccessRootSource {
266 configured_path: lexical,
267 canonicalized_at_startup,
268 origin,
269 }],
270 })
271}
272
273pub(crate) fn validate_configured_root(path: &Path) -> Result<()> {
274 normalize_authority_root(path, AccessRootOrigin::ExplicitConfig).map(|_| ())
275}
276
277pub(crate) fn ensure_raw_sql_allowed(config: &SearchScopeConfig, operation: &str) -> Result<()> {
278 if config.mode == SearchScopeMode::AllowedRoots {
279 bail!(
280 "{operation} is unavailable while search.scope.mode is allowed-roots because arbitrary SQL cannot enforce workspace authority; use typed search, session, message, analysis, file, or export operations"
281 );
282 }
283 Ok(())
284}
285
286fn lexical_absolute(path: &Path) -> Result<PathBuf> {
287 if !path.is_absolute() {
288 bail!("access roots must be absolute paths");
289 }
290 let mut normalized = PathBuf::new();
291 for component in path.components() {
292 match component {
293 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
294 normalized.push(component.as_os_str());
295 }
296 Component::CurDir => {}
297 Component::ParentDir => {
298 if !normalized.pop() || !normalized.has_root() {
299 return Err(anyhow!("access root escapes its filesystem root"));
300 }
301 }
302 }
303 }
304 Ok(normalized)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn restricted(roots: &[&Path]) -> SearchScopeConfig {
312 SearchScopeConfig {
313 mode: SearchScopeMode::AllowedRoots,
314 roots: roots
315 .iter()
316 .map(|path| path.to_string_lossy().into_owned())
317 .collect(),
318 include_invocation_directory: false,
319 }
320 }
321
322 #[test]
323 fn unrestricted_scope_preserves_every_path_and_ignores_unused_inputs() {
324 let scope = EffectiveAccessScope::resolve(
325 &SearchScopeConfig::default(),
326 TrustedAccessInputs {
327 harness_roots: vec![PathBuf::from("relative-is-unused")],
328 invocation_directory: None,
329 },
330 )
331 .unwrap();
332 assert!(scope.is_unrestricted());
333 assert!(scope.allows_workspace_path(Path::new("relative-is-still-visible")));
334 }
335
336 #[test]
337 fn restricted_scope_unions_deduplicates_and_records_origins() {
338 let root = tempfile::tempdir().unwrap();
339 let configured = root.path().join("configured");
340 let harness = root.path().join("harness");
341 let invocation = root.path().join("invocation");
342 fs::create_dir_all(&configured).unwrap();
343 fs::create_dir_all(&harness).unwrap();
344 fs::create_dir_all(&invocation).unwrap();
345 let mut config = restricted(&[&configured]);
346 config.include_invocation_directory = true;
347
348 let scope = EffectiveAccessScope::resolve(
349 &config,
350 TrustedAccessInputs {
351 harness_roots: vec![harness.clone(), configured.clone()],
352 invocation_directory: Some(invocation.clone()),
353 },
354 )
355 .unwrap();
356
357 assert_eq!(scope.roots().len(), 3);
358 assert_eq!(scope.roots()[0].origin(), AccessRootOrigin::HarnessRoots);
359 assert_eq!(scope.roots()[1].origin(), AccessRootOrigin::HarnessRoots);
360 assert_eq!(scope.roots()[1].sources().len(), 2);
361 assert_eq!(
362 scope.roots()[1].sources()[1].origin(),
363 AccessRootOrigin::ExplicitConfig
364 );
365 assert_eq!(
366 scope.roots()[2].origin(),
367 AccessRootOrigin::InvocationDirectory
368 );
369 assert!(scope.allows_workspace_path(&harness.join("child")));
370 assert!(scope.allows_workspace_path(&configured));
371 assert!(scope.allows_workspace_path(&invocation.join("child")));
372 assert!(!scope.allows_workspace_path(root.path().join("outside").as_path()));
373 }
374
375 #[test]
376 fn restricted_scope_fails_closed_without_authoritative_roots() {
377 let error = EffectiveAccessScope::resolve(&restricted(&[]), TrustedAccessInputs::default())
378 .unwrap_err()
379 .to_string();
380 assert!(error.contains("resolved no authoritative roots"), "{error}");
381 }
382
383 #[test]
384 fn roots_require_absolute_non_root_directory_paths() {
385 for value in [Path::new("relative"), Path::new("/")] {
386 let error = EffectiveAccessScope::resolve(
387 &restricted(&[value]),
388 TrustedAccessInputs::default(),
389 )
390 .unwrap_err()
391 .to_string();
392 assert!(
393 error.contains("invalid explicit-config access root"),
394 "{error}"
395 );
396 }
397 }
398
399 #[test]
400 fn arbitrary_sql_is_allowed_only_for_unrestricted_scope() {
401 ensure_raw_sql_allowed(&SearchScopeConfig::default(), "test query").unwrap();
402 let error = ensure_raw_sql_allowed(&restricted(&[Path::new("/configured")]), "test query")
403 .unwrap_err()
404 .to_string();
405 assert!(error.contains("test query is unavailable"));
406 assert!(error.contains("arbitrary SQL cannot enforce workspace authority"));
407 }
408
409 #[test]
410 fn lexical_normalization_preserves_component_boundaries() {
411 let root = tempfile::tempdir().unwrap();
412 let allowed = root.path().join("a/inside/../allowed");
413 fs::create_dir_all(root.path().join("a/allowed")).unwrap();
414 let scope =
415 EffectiveAccessScope::resolve(&restricted(&[&allowed]), TrustedAccessInputs::default())
416 .unwrap();
417 assert!(scope.allows_workspace_path(&root.path().join("a/allowed/child")));
418 assert!(!scope.allows_workspace_path(&root.path().join("a/allowed-sibling")));
419 }
420
421 #[cfg(unix)]
422 #[test]
423 fn existing_symlink_keeps_lexical_alias_and_fails_closed_after_retarget() {
424 use std::os::unix::fs::symlink;
425
426 let root = tempfile::tempdir().unwrap();
427 let target = root.path().join("target");
428 let alias = root.path().join("alias");
429 fs::create_dir(&target).unwrap();
430 symlink(&target, &alias).unwrap();
431
432 let scope =
433 EffectiveAccessScope::resolve(&restricted(&[&alias]), TrustedAccessInputs::default())
434 .unwrap();
435 assert_eq!(scope.roots()[0].path(), target.canonicalize().unwrap());
436 assert!(scope.allows_workspace_path(&scope.roots()[0].path().join("child")));
437 assert!(scope.allows_workspace_path(&alias.join("child")));
438 scope.validate_stable().unwrap();
439
440 let other = root.path().join("other");
441 fs::create_dir(&other).unwrap();
442 fs::remove_file(&alias).unwrap();
443 symlink(&other, &alias).unwrap();
444 let error = scope.validate_stable().unwrap_err().to_string();
445 assert!(error.contains("changed target"), "{error}");
446 }
447
448 #[cfg(unix)]
449 #[test]
450 fn every_deduplicated_symlink_alias_is_revalidated() {
451 use std::os::unix::fs::symlink;
452
453 let root = tempfile::tempdir().unwrap();
454 let target = root.path().join("target");
455 let other = root.path().join("other");
456 let configured_alias = root.path().join("configured-alias");
457 let harness_alias = root.path().join("harness-alias");
458 fs::create_dir(&target).unwrap();
459 fs::create_dir(&other).unwrap();
460 symlink(&target, &configured_alias).unwrap();
461 symlink(&target, &harness_alias).unwrap();
462
463 let scope = EffectiveAccessScope::resolve(
464 &restricted(&[&configured_alias]),
465 TrustedAccessInputs {
466 harness_roots: vec![harness_alias],
467 invocation_directory: None,
468 },
469 )
470 .unwrap();
471 assert_eq!(scope.roots().len(), 1);
472 assert_eq!(scope.roots()[0].sources().len(), 2);
473
474 fs::remove_file(&configured_alias).unwrap();
475 symlink(&other, &configured_alias).unwrap();
476 let error = scope.validate_stable().unwrap_err().to_string();
477 assert!(error.contains("changed target"), "{error}");
478 }
479
480 #[cfg(unix)]
481 #[test]
482 fn configured_root_missing_at_startup_rejects_later_symlink_target() {
483 use std::os::unix::fs::symlink;
484
485 let root = tempfile::tempdir().unwrap();
486 let configured = root.path().join("future-root");
487 let outside = root.path().join("outside");
488 fs::create_dir(&outside).unwrap();
489
490 let scope = EffectiveAccessScope::resolve(
491 &restricted(&[&configured]),
492 TrustedAccessInputs::default(),
493 )
494 .unwrap();
495 scope.validate_stable().unwrap();
496
497 symlink(&outside, &configured).unwrap();
498 let error = scope.validate_stable().unwrap_err().to_string();
499 assert!(error.contains("changed target"), "{error}");
500 }
501}