1use std::{
2 collections::VecDeque,
3 path::{Path, PathBuf},
4 sync::{Arc, OnceLock},
5 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
6};
7
8use tokio::sync::{Semaphore, oneshot};
9
10use crate::{
11 api::{
12 ApiError, ApiMetadata, FileIndexRequest, FileIndexResponse, FileQueryRequest,
13 FileQueryResponse, RequestContext,
14 },
15 domain::GraphVersion,
16 storage::{
17 FileIndexEntry, FileIndexRoot, FileIndexRootUpdate, FileIndexScanSummary,
18 FileSearchRequest, StorageError,
19 },
20};
21
22use super::{FileIndexRootConfig, service::RelayKnowledgeService};
23
24pub const DEFAULT_FILE_QUERY_LIMIT: usize = 20;
25const MAX_FILE_QUERY_LIMIT: usize = 500;
26const MAX_CONCURRENT_FILE_SCANS: usize = 4;
27static FILE_SCAN_LIMITER: OnceLock<Arc<Semaphore>> = OnceLock::new();
28
29#[derive(Clone)]
30struct ScanBudget {
31 max_depth: usize,
32 max_file_bytes: u64,
33 max_files_per_root: usize,
34 excludes: Vec<String>,
35}
36
37impl RelayKnowledgeService {
38 pub async fn index_files(
40 &self,
41 request: FileIndexRequest,
42 context: RequestContext,
43 ) -> Result<FileIndexResponse, ApiError> {
44 let configured_scan = request.roots.is_empty();
45 let roots = self
46 .file_index_roots_from_request(request)
47 .map_err(ApiError::invalid_argument)?;
48 let active_roots = roots
49 .iter()
50 .map(file_index_root_from_config)
51 .collect::<Vec<_>>();
52 let store = self.storage.get().await.map_err(storage_api_error)?;
53 let now_ms = current_time_millis();
54 let updates = scan_roots(
55 roots,
56 ScanBudget {
57 max_depth: self.runtime.file_index.max_depth,
58 max_file_bytes: self.runtime.file_index.max_file_bytes,
59 max_files_per_root: self.runtime.file_index.max_files_per_root,
60 excludes: self.runtime.file_index.excludes.clone(),
61 },
62 now_ms,
63 self.runtime.file_index.scan_timeout,
64 )
65 .await
66 .map_err(storage_api_error)?;
67 let mut summary = FileIndexScanSummary::default();
68 for update in updates {
69 let status = store
70 .replace_file_index_root(update)
71 .await
72 .map_err(storage_api_error)?;
73 summary.root_count = summary.root_count.saturating_add(1);
74 summary.indexed_file_count = summary
75 .indexed_file_count
76 .saturating_add(status.indexed_file_count);
77 summary.missing_file_count = summary
78 .missing_file_count
79 .saturating_add(status.missing_file_count);
80 summary.scan_error_count = summary
81 .scan_error_count
82 .saturating_add(status.scan_error_count);
83 if status.truncated {
84 summary.truncated_root_count = summary.truncated_root_count.saturating_add(1);
85 }
86 summary.roots.push(status);
87 }
88 if configured_scan {
89 let diagnostics = store
90 .mark_file_index_roots_unconfigured(active_roots, now_ms)
91 .await
92 .map_err(storage_api_error)?;
93 summary = summary_from_diagnostics(diagnostics);
94 }
95
96 Ok(FileIndexResponse {
97 metadata: ApiMetadata::graph_only(&context, GraphVersion::ZERO),
98 summary,
99 })
100 }
101
102 pub async fn index_configured_files_once(&self) -> Result<FileIndexResponse, ApiError> {
104 if self.runtime.file_index.roots.is_empty() {
105 let store = self.storage.get().await.map_err(storage_api_error)?;
106 let diagnostics = store
107 .mark_file_index_roots_unconfigured(Vec::new(), current_time_millis())
108 .await
109 .map_err(storage_api_error)?;
110 return Ok(FileIndexResponse {
111 metadata: ApiMetadata::graph_only(
112 &RequestContext::for_interface(crate::api::InterfaceKind::Cli),
113 GraphVersion::ZERO,
114 ),
115 summary: summary_from_diagnostics(diagnostics),
116 });
117 }
118
119 self.index_files(
120 FileIndexRequest {
121 source_scope: None,
122 roots: Vec::new(),
123 },
124 RequestContext::for_interface(crate::api::InterfaceKind::Cli),
125 )
126 .await
127 }
128
129 pub async fn query_files(
131 &self,
132 request: FileQueryRequest,
133 context: RequestContext,
134 ) -> Result<FileQueryResponse, ApiError> {
135 let query = required_query(request.query).map_err(ApiError::invalid_argument)?;
136 let limit = bounded_limit(request.limit).map_err(ApiError::invalid_argument)?;
137 let store = self.storage.get().await.map_err(storage_api_error)?;
138 let started = Instant::now();
139 let source_scope =
140 normalize_optional_text(request.source_scope).map_err(ApiError::invalid_argument)?;
141 let root_id =
142 normalize_optional_text(request.root_id).map_err(ApiError::invalid_argument)?;
143 let results = match store
144 .search_files(FileSearchRequest {
145 query: query.clone(),
146 source_scope: source_scope.clone(),
147 root_id: root_id.clone(),
148 limit: limit.saturating_add(1),
149 timeout_ms: query_timeout_ms(self.runtime.file_index.query_timeout),
150 })
151 .await
152 {
153 Ok(results) => results,
154 Err(error) if storage_error_timed_out(&error) => {
155 return Ok(FileQueryResponse {
156 metadata: ApiMetadata::graph_only(&context, GraphVersion::ZERO),
157 query,
158 source_scope,
159 root_id,
160 results: Vec::new(),
161 truncated: false,
162 duration_ms: elapsed_ms(started),
163 degraded_reason: Some("file query timed out".to_owned()),
164 });
165 }
166 Err(error) => return Err(storage_api_error(error)),
167 };
168 let mut results = results;
169 let truncated = results.len() > limit;
170 results.truncate(limit);
171
172 Ok(FileQueryResponse {
173 metadata: ApiMetadata::graph_only(&context, GraphVersion::ZERO),
174 query,
175 source_scope,
176 root_id,
177 results,
178 truncated,
179 duration_ms: elapsed_ms(started),
180 degraded_reason: None,
181 })
182 }
183
184 fn file_index_roots_from_request(
185 &self,
186 request: FileIndexRequest,
187 ) -> Result<Vec<FileIndexRootConfig>, String> {
188 if request.roots.is_empty() {
189 if self.runtime.file_index.roots.is_empty() {
190 return Err("no file index roots are configured".to_owned());
191 }
192 return Ok(self.runtime.file_index.roots.clone());
193 }
194
195 let scope_id = normalize_optional_text(request.source_scope)?
196 .unwrap_or_else(|| "local-files".to_owned());
197 if self.runtime.file_index.roots.is_empty() {
198 return Err(
199 "file index roots must be configured before explicit roots can be scanned"
200 .to_owned(),
201 );
202 }
203 let mut roots = request
204 .roots
205 .into_iter()
206 .map(|root| {
207 let root = root.trim();
208 if root.is_empty() {
209 Err("file index root must not be empty".to_owned())
210 } else {
211 let root_path = PathBuf::from(root);
212 if !root_path.is_absolute() {
213 return Err("file index root must be an absolute path".to_owned());
214 }
215 let requested = FileIndexRootConfig::new(&scope_id, root_path);
216 self.runtime
217 .file_index
218 .roots
219 .iter()
220 .find(|authorized| {
221 authorized.scope_id == requested.scope_id
222 && authorized.root_id == requested.root_id
223 })
224 .cloned()
225 .ok_or_else(|| {
226 format!(
227 "file index root '{root}' is not configured for scope '{scope_id}'"
228 )
229 })
230 }
231 })
232 .collect::<Result<Vec<_>, _>>()?;
233 roots.sort_by(|left, right| {
234 left.scope_id
235 .cmp(&right.scope_id)
236 .then(left.root_id.cmp(&right.root_id))
237 });
238 roots.dedup_by(|left, right| {
239 left.scope_id == right.scope_id && left.root_id == right.root_id
240 });
241
242 Ok(roots)
243 }
244}
245
246async fn scan_roots(
247 roots: Vec<FileIndexRootConfig>,
248 budget: ScanBudget,
249 now_ms: u64,
250 scan_timeout: Duration,
251) -> Result<Vec<FileIndexRootUpdate>, StorageError> {
252 let mut updates = Vec::with_capacity(roots.len());
253 for root in roots {
254 updates.push(scan_root_with_timeout(root, budget.clone(), now_ms, scan_timeout).await?);
255 }
256
257 Ok(updates)
258}
259
260async fn scan_root_with_timeout(
261 root: FileIndexRootConfig,
262 budget: ScanBudget,
263 now_ms: u64,
264 scan_timeout: Duration,
265) -> Result<FileIndexRootUpdate, StorageError> {
266 if scan_timeout.is_zero() {
267 return Ok(timed_out_file_index_root_update(root, now_ms));
268 }
269 let permit = match file_scan_limiter().try_acquire_owned() {
270 Ok(permit) => permit,
271 Err(_) => return Ok(scan_worker_busy_file_index_root_update(root, now_ms)),
272 };
273 let timeout_root = root.clone();
274 let (sender, receiver) = oneshot::channel();
275 std::thread::Builder::new()
276 .name("relay-file-index-scan".to_owned())
277 .spawn(move || {
278 let _permit = permit;
279 let _ = sender.send(scan_root(root, &budget, now_ms));
280 })?;
281
282 match tokio::time::timeout(scan_timeout, receiver).await {
283 Ok(Ok(result)) => result,
284 Ok(Err(_)) => Err(StorageError::InvalidInput(
285 "file index scan worker stopped before reporting".to_owned(),
286 )),
287 Err(_) => Ok(timed_out_file_index_root_update(timeout_root, now_ms)),
288 }
289}
290
291fn file_scan_limiter() -> Arc<Semaphore> {
292 Arc::clone(
293 FILE_SCAN_LIMITER.get_or_init(|| Arc::new(Semaphore::new(MAX_CONCURRENT_FILE_SCANS))),
294 )
295}
296
297fn scan_worker_busy_file_index_root_update(
298 root: FileIndexRootConfig,
299 now_ms: u64,
300) -> FileIndexRootUpdate {
301 FileIndexRootUpdate {
302 root: storage_root(root.scope_id, root.root_id, &root.root_path),
303 entries: Vec::new(),
304 scan_error_count: 1,
305 truncated: true,
306 last_error: Some("file index scan worker is still busy".to_owned()),
307 now_ms,
308 }
309}
310
311fn timed_out_file_index_root_update(root: FileIndexRootConfig, now_ms: u64) -> FileIndexRootUpdate {
312 FileIndexRootUpdate {
313 root: storage_root(root.scope_id, root.root_id, &root.root_path),
314 entries: Vec::new(),
315 scan_error_count: 1,
316 truncated: true,
317 last_error: Some("file index scan timed out".to_owned()),
318 now_ms,
319 }
320}
321
322fn scan_root(
323 root: FileIndexRootConfig,
324 budget: &ScanBudget,
325 now_ms: u64,
326) -> Result<FileIndexRootUpdate, StorageError> {
327 let root_path = root.root_path;
328 let mut entries = Vec::new();
329 let mut scan_error_count = 0usize;
330 let mut truncated = false;
331 let mut last_error = None;
332 let canonical_root = match std::fs::canonicalize(&root_path) {
333 Ok(path) => path,
334 Err(error) => {
335 return Ok(FileIndexRootUpdate {
336 root: storage_root(root.scope_id, root.root_id, &root_path),
337 entries,
338 scan_error_count: 1,
339 truncated: false,
340 last_error: Some(error.to_string()),
341 now_ms,
342 });
343 }
344 };
345 let mut pending = VecDeque::from([(canonical_root.clone(), 0usize)]);
346
347 while let Some((directory, depth)) = pending.pop_front() {
348 if entries.len() >= budget.max_files_per_root {
349 truncated = true;
350 break;
351 }
352 if depth > budget.max_depth {
353 truncated = true;
354 continue;
355 }
356 let read_dir = match std::fs::read_dir(&directory) {
357 Ok(read_dir) => read_dir,
358 Err(error) => {
359 scan_error_count = scan_error_count.saturating_add(1);
360 last_error = Some(error.to_string());
361 continue;
362 }
363 };
364 for child in read_dir {
365 if entries.len() >= budget.max_files_per_root {
366 truncated = true;
367 pending.clear();
368 break;
369 }
370 let child = match child {
371 Ok(child) => child,
372 Err(error) => {
373 scan_error_count = scan_error_count.saturating_add(1);
374 last_error = Some(error.to_string());
375 continue;
376 }
377 };
378 let path = child.path();
379 if excluded(&path, &budget.excludes) {
380 continue;
381 }
382 let file_type = match child.file_type() {
383 Ok(file_type) => file_type,
384 Err(error) => {
385 scan_error_count = scan_error_count.saturating_add(1);
386 last_error = Some(error.to_string());
387 continue;
388 }
389 };
390 if file_type.is_symlink() {
391 continue;
392 }
393 if file_type.is_dir() {
394 pending.push_back((path, depth.saturating_add(1)));
395 continue;
396 }
397 let metadata = match child.metadata() {
398 Ok(metadata) => metadata,
399 Err(error) => {
400 scan_error_count = scan_error_count.saturating_add(1);
401 last_error = Some(error.to_string());
402 continue;
403 }
404 };
405 if file_type.is_file() && metadata.len() <= budget.max_file_bytes {
406 entries.push(file_entry(
407 &root.scope_id,
408 &root.root_id,
409 &canonical_root,
410 &path,
411 &metadata,
412 ));
413 }
414 }
415 }
416
417 Ok(FileIndexRootUpdate {
418 root: storage_root(root.scope_id, root.root_id, &canonical_root),
419 entries,
420 scan_error_count,
421 truncated,
422 last_error,
423 now_ms,
424 })
425}
426
427fn file_index_root_from_config(root: &FileIndexRootConfig) -> FileIndexRoot {
428 FileIndexRoot {
429 scope_id: root.scope_id.clone(),
430 root_id: root.root_id.clone(),
431 root_path: root.root_path.to_string_lossy().to_string(),
432 }
433}
434
435fn summary_from_diagnostics(
436 diagnostics: crate::storage::FileIndexDiagnostics,
437) -> FileIndexScanSummary {
438 FileIndexScanSummary {
439 root_count: diagnostics.root_count,
440 indexed_file_count: diagnostics.indexed_file_count,
441 missing_file_count: diagnostics.missing_file_count,
442 scan_error_count: diagnostics.scan_error_count,
443 truncated_root_count: diagnostics.truncated_root_count,
444 roots: diagnostics.roots,
445 }
446}
447
448fn file_entry(
449 scope_id: &str,
450 root_id: &str,
451 root: &Path,
452 path: &Path,
453 metadata: &std::fs::Metadata,
454) -> FileIndexEntry {
455 let relative_path = path.strip_prefix(root).unwrap_or(path);
456 let file_name = path
457 .file_name()
458 .map(|value| value.to_string_lossy().to_string())
459 .unwrap_or_default();
460 let extension = path
461 .extension()
462 .map(|value| value.to_string_lossy().to_ascii_lowercase());
463 let parent_dir = path
464 .parent()
465 .map(|value| value.to_string_lossy().to_string())
466 .unwrap_or_default();
467 let modified_at_ms = metadata
468 .modified()
469 .ok()
470 .and_then(system_time_millis)
471 .unwrap_or_default();
472
473 FileIndexEntry {
474 scope_id: scope_id.to_owned(),
475 root_id: root_id.to_owned(),
476 path: path.to_string_lossy().to_string(),
477 relative_path: relative_path.to_string_lossy().to_string(),
478 file_name,
479 extension,
480 parent_dir,
481 size_bytes: metadata.len(),
482 modified_at_ms,
483 fingerprint: format!("{}:{modified_at_ms}", metadata.len()),
484 }
485}
486
487fn storage_root(scope_id: String, root_id: String, root_path: &Path) -> FileIndexRoot {
488 FileIndexRoot {
489 scope_id,
490 root_id,
491 root_path: root_path.to_string_lossy().to_string(),
492 }
493}
494
495fn excluded(path: &Path, configured: &[String]) -> bool {
496 let Some(name) = path.file_name().map(|value| value.to_string_lossy()) else {
497 return false;
498 };
499 if name.starts_with('.') {
500 return true;
501 }
502 let lower = name.to_ascii_lowercase();
503 matches!(
504 lower.as_str(),
505 "target" | "node_modules" | ".git" | "__pycache__" | "tmp" | "temp" | "cache"
506 ) || configured
507 .iter()
508 .any(|pattern| lower.contains(&pattern.to_ascii_lowercase()))
509}
510
511fn required_query(query: String) -> Result<String, String> {
512 let query = query.trim().to_owned();
513 if query.is_empty() {
514 Err("file query must not be empty".to_owned())
515 } else {
516 Ok(query)
517 }
518}
519
520fn bounded_limit(limit: usize) -> Result<usize, String> {
521 match limit {
522 0 => Err("file query limit must be greater than zero".to_owned()),
523 value if value > MAX_FILE_QUERY_LIMIT => Err(format!(
524 "file query limit must not exceed {MAX_FILE_QUERY_LIMIT}"
525 )),
526 value => Ok(value),
527 }
528}
529
530fn normalize_optional_text(value: Option<String>) -> Result<Option<String>, String> {
531 value
532 .map(|value| {
533 let value = value.trim().to_owned();
534 if value.is_empty() {
535 Err("optional file query filter must not be empty".to_owned())
536 } else {
537 Ok(value)
538 }
539 })
540 .transpose()
541}
542
543fn current_time_millis() -> u64 {
544 SystemTime::now()
545 .duration_since(UNIX_EPOCH)
546 .map_or(0, |duration| {
547 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
548 })
549}
550
551fn system_time_millis(time: SystemTime) -> Option<u64> {
552 time.duration_since(UNIX_EPOCH)
553 .ok()
554 .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
555}
556
557fn elapsed_ms(started: Instant) -> u64 {
558 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
559}
560
561fn query_timeout_ms(timeout: std::time::Duration) -> u64 {
562 u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)
563}
564
565fn storage_error_timed_out(error: &StorageError) -> bool {
566 matches!(error, StorageError::InvalidInput(message) if message.contains("file query timed out"))
567}
568
569fn storage_api_error(error: StorageError) -> ApiError {
570 ApiError::storage_unavailable(error.to_string())
571}
572
573#[cfg(test)]
574mod tests {
575 use std::{
576 fs,
577 path::{Path, PathBuf},
578 time::{SystemTime, UNIX_EPOCH},
579 };
580
581 use crate::{
582 env::{EnvironmentConfig, PlatformKind},
583 storage::{KnowledgeStore, SqliteGraphStore},
584 };
585
586 use super::*;
587
588 #[tokio::test]
589 async fn scan_roots_respects_budget_excludes_and_metadata() {
590 let fixture = TempFixture::new("scan-budget");
591 fixture.write("docs/report.pdf", "pdf");
592 fixture.write("target/generated.txt", "generated");
593 fixture.write(".hidden/secret.txt", "secret");
594 fixture.write("deep/a/b/c/too-deep.txt", "deep");
595 fixture.write("large.bin", "too large for budget");
596 fixture.write("notes/skipme.txt", "configured exclusion");
597 #[cfg(unix)]
598 std::os::unix::fs::symlink("/", fixture.path().join("escape"))
599 .expect("symlink fixture should be created");
600
601 let updates = scan_roots(
602 vec![FileIndexRootConfig::new(
603 "local-files",
604 fixture.path().to_path_buf(),
605 )],
606 ScanBudget {
607 max_depth: 2,
608 max_file_bytes: 8,
609 max_files_per_root: 10,
610 excludes: vec!["skipme".to_owned()],
611 },
612 42,
613 Duration::from_secs(30),
614 )
615 .await
616 .expect("scan should complete");
617
618 let update = updates.into_iter().next().expect("one root is scanned");
619 assert_eq!(update.root.scope_id, "local-files");
620 assert_eq!(update.now_ms, 42);
621 assert!(update.truncated);
622 assert_eq!(update.scan_error_count, 0);
623 assert_eq!(update.entries.len(), 1);
624 let entry = &update.entries[0];
625 assert_eq!(entry.file_name, "report.pdf");
626 assert_eq!(entry.extension.as_deref(), Some("pdf"));
627 assert!(entry.relative_path.ends_with("docs/report.pdf"));
628 assert!(entry.parent_dir.ends_with("docs"));
629 assert_eq!(entry.size_bytes, 3);
630 assert!(entry.fingerprint.starts_with("3:"));
631 }
632
633 #[tokio::test]
634 async fn scan_roots_reports_missing_roots_and_file_count_truncation() {
635 let fixture = TempFixture::new("scan-truncated");
636 fixture.write("first.txt", "one");
637 fixture.write("second.txt", "two");
638 let missing = fixture.path().join("missing");
639
640 let updates = scan_roots(
641 vec![
642 FileIndexRootConfig::new("local-files", fixture.path().to_path_buf()),
643 FileIndexRootConfig::new("local-files", missing),
644 ],
645 ScanBudget {
646 max_depth: 4,
647 max_file_bytes: 128,
648 max_files_per_root: 1,
649 excludes: Vec::new(),
650 },
651 7,
652 Duration::from_secs(30),
653 )
654 .await
655 .expect("scan should complete");
656
657 let truncated = updates
658 .iter()
659 .find(|update| update.root.root_path == fixture.path().to_string_lossy())
660 .expect("fixture root should be present");
661 assert!(truncated.truncated);
662 assert_eq!(truncated.entries.len(), 1);
663
664 let missing = updates
665 .iter()
666 .find(|update| update.root.root_path.ends_with("missing"))
667 .expect("missing root should be reported");
668 assert_eq!(missing.scan_error_count, 1);
669 assert!(missing.entries.is_empty());
670 assert!(missing.last_error.is_some());
671 }
672
673 #[tokio::test]
674 async fn scan_timeout_returns_degraded_root_update() {
675 let fixture = TempFixture::new("scan-timeout");
676
677 let update = scan_root_with_timeout(
678 FileIndexRootConfig::new("local-files", fixture.path().to_path_buf()),
679 ScanBudget {
680 max_depth: 4,
681 max_file_bytes: 128,
682 max_files_per_root: 1,
683 excludes: Vec::new(),
684 },
685 9,
686 Duration::ZERO,
687 )
688 .await
689 .expect("timeout update should be produced");
690
691 assert_eq!(update.scan_error_count, 1);
692 assert!(update.truncated);
693 assert!(update.entries.is_empty());
694 assert_eq!(
695 update.last_error.as_deref(),
696 Some("file index scan timed out")
697 );
698 }
699
700 #[test]
701 fn scan_worker_busy_update_reports_bounded_backpressure() {
702 let update = scan_worker_busy_file_index_root_update(
703 FileIndexRootConfig::new("local-files", PathBuf::from("/opt/docs")),
704 11,
705 );
706
707 assert_eq!(update.scan_error_count, 1);
708 assert!(update.truncated);
709 assert!(update.entries.is_empty());
710 assert_eq!(
711 update.last_error.as_deref(),
712 Some("file index scan worker is still busy")
713 );
714 assert_eq!(update.now_ms, 11);
715 }
716
717 #[test]
718 fn query_validation_helpers_reject_unbounded_inputs() {
719 assert_eq!(required_query(" quarter ".to_owned()).unwrap(), "quarter");
720 assert!(required_query(" \t ".to_owned()).is_err());
721 assert_eq!(bounded_limit(1).unwrap(), 1);
722 assert!(bounded_limit(0).is_err());
723 assert!(bounded_limit(MAX_FILE_QUERY_LIMIT + 1).is_err());
724 assert_eq!(
725 normalize_optional_text(Some(" root ".to_owned())).unwrap(),
726 Some("root".to_owned())
727 );
728 assert!(normalize_optional_text(Some(" ".to_owned())).is_err());
729 assert_eq!(normalize_optional_text(None).unwrap(), None);
730 }
731
732 #[test]
733 fn query_timeout_helpers_map_runtime_budget_and_storage_errors() {
734 assert_eq!(query_timeout_ms(std::time::Duration::from_millis(125)), 125);
735 assert!(storage_error_timed_out(&StorageError::InvalidInput(
736 "file query timed out waiting for storage lock".to_owned()
737 )));
738 assert!(!storage_error_timed_out(&StorageError::InvalidInput(
739 "different validation failure".to_owned()
740 )));
741 }
742
743 #[tokio::test]
744 async fn explicit_roots_must_match_authorized_runtime_roots() {
745 let fixture = TempFixture::new("authorized-roots");
746 let service = service_for_root(fixture.path()).await;
747 let authorized = service
748 .file_index_roots_from_request(FileIndexRequest {
749 source_scope: Some("local-files".to_owned()),
750 roots: vec![fixture.path().join(".").to_string_lossy().to_string()],
751 })
752 .expect("configured root spelling should be authorized");
753 assert_eq!(authorized.len(), 1);
754
755 let denied = service
756 .file_index_roots_from_request(FileIndexRequest {
757 source_scope: Some("local-files".to_owned()),
758 roots: vec![fixture.path().join("other").to_string_lossy().to_string()],
759 })
760 .expect_err("unconfigured root should be denied");
761 assert!(denied.contains("is not configured"));
762
763 let relative = service
764 .file_index_roots_from_request(FileIndexRequest {
765 source_scope: Some("local-files".to_owned()),
766 roots: vec!["relative/docs".to_owned()],
767 })
768 .expect_err("relative roots should be denied");
769 assert!(relative.contains("absolute path"));
770 }
771
772 #[tokio::test]
773 async fn same_path_roots_remain_distinct_across_scopes() {
774 let fixture = TempFixture::new("scope-roots");
775 let home = fixture.path().join("home");
776 let documents = home.join("Documents");
777 fs::create_dir_all(&documents).expect("documents directory should be created");
778 let environment = EnvironmentConfig::from_pairs(
779 PlatformKind::Unix,
780 [
781 ("HOME", home.to_string_lossy().to_string()),
782 ("TMPDIR", "/tmp".to_owned()),
783 (
784 "RELAY_KNOWLEDGE_FILE_INDEX_ROOTS",
785 documents.to_string_lossy().to_string(),
786 ),
787 (
788 "RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS",
789 "120000".to_owned(),
790 ),
791 ],
792 )
793 .expect("environment should parse");
794 let runtime = super::super::RuntimeConfiguration::from_environment(&environment)
795 .await
796 .expect("runtime should compose");
797 assert_eq!(runtime.file_index.scan_timeout, Duration::from_secs(120));
798
799 let matching_roots = runtime
800 .file_index
801 .roots
802 .iter()
803 .filter(|root| root.root_path.as_path() == documents.as_path())
804 .collect::<Vec<_>>();
805 assert_eq!(matching_roots.len(), 2);
806 assert_ne!(matching_roots[0].scope_id, matching_roots[1].scope_id);
807 }
808
809 struct TempFixture {
810 root: PathBuf,
811 }
812
813 impl TempFixture {
814 fn new(name: &str) -> Self {
815 let suffix = SystemTime::now()
816 .duration_since(UNIX_EPOCH)
817 .expect("time should be valid")
818 .as_nanos();
819 let root = std::env::temp_dir().join(format!(
820 "relay-knowledge-{name}-{}-{suffix}",
821 std::process::id()
822 ));
823 fs::create_dir_all(&root).expect("fixture root should be created");
824
825 Self { root }
826 }
827
828 fn path(&self) -> &Path {
829 &self.root
830 }
831
832 fn write(&self, relative: &str, content: &str) {
833 let path = self.root.join(relative);
834 if let Some(parent) = path.parent() {
835 fs::create_dir_all(parent).expect("fixture parent should be created");
836 }
837 fs::write(path, content).expect("fixture file should be written");
838 }
839 }
840
841 impl Drop for TempFixture {
842 fn drop(&mut self) {
843 let _ = fs::remove_dir_all(&self.root);
844 }
845 }
846
847 async fn service_for_root(root: &Path) -> RelayKnowledgeService {
848 let home = root.join("home");
849 fs::create_dir_all(&home).expect("home should be created");
850 let relay_home = root.join("relay");
851 let environment = EnvironmentConfig::from_pairs(
852 PlatformKind::Unix,
853 [
854 ("HOME", home.to_string_lossy().to_string()),
855 ("TMPDIR", "/tmp".to_owned()),
856 (
857 "RELAY_KNOWLEDGE_HOME",
858 relay_home.to_string_lossy().to_string(),
859 ),
860 (
861 "RELAY_KNOWLEDGE_FILE_INDEX_ROOTS",
862 root.to_string_lossy().to_string(),
863 ),
864 ],
865 )
866 .expect("environment should parse");
867 let runtime = super::super::RuntimeConfiguration::from_environment(&environment)
868 .await
869 .expect("runtime should compose");
870 let store = Arc::new(SqliteGraphStore::open_in_memory().expect("store should open"))
871 as Arc<dyn KnowledgeStore>;
872
873 RelayKnowledgeService::with_store(runtime, store)
874 }
875}