Skip to main content

relay_knowledge/application/runtime/
file_index.rs

1use std::{
2    error::Error,
3    fmt,
4    path::{Component, PathBuf},
5    time::Duration,
6};
7
8use crate::{
9    env::{EnvironmentConfig, PlatformKind},
10    identity::stable_hash64,
11    paths::{PathError, default_user_document_roots},
12};
13
14/// Runtime budgets and authorized roots for local file-location indexing.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct FileIndexRuntimeConfig {
17    pub enabled: bool,
18    pub roots: Vec<FileIndexRootConfig>,
19    pub excludes: Vec<String>,
20    pub max_depth: usize,
21    pub max_file_bytes: u64,
22    pub scan_interval: Duration,
23    pub scan_timeout: Duration,
24    pub max_files_per_root: usize,
25    pub query_timeout: Duration,
26}
27
28impl FileIndexRuntimeConfig {
29    pub const DEFAULT_MAX_DEPTH: usize = 32;
30    pub const DEFAULT_MAX_FILE_BYTES: u64 = 512 * 1024 * 1024;
31    pub const DEFAULT_SCAN_INTERVAL: Duration = Duration::from_secs(900);
32    pub const DEFAULT_SCAN_TIMEOUT: Duration = Duration::from_secs(300);
33    pub const DEFAULT_MAX_FILES_PER_ROOT: usize = 50_000;
34    pub const DEFAULT_QUERY_TIMEOUT: Duration = Duration::from_millis(750);
35
36    pub fn from_environment(
37        environment: &EnvironmentConfig,
38    ) -> Result<Self, FileIndexRuntimeConfigError> {
39        let mut roots = default_user_document_roots(&environment.platform)
40            .map_err(FileIndexRuntimeConfigError::Paths)?
41            .into_iter()
42            .map(|path| FileIndexRootConfig::new("user-documents", path))
43            .collect::<Vec<_>>();
44        for root in split_semicolon(environment.file_index.roots.as_deref())? {
45            roots.push(file_index_root_from_environment(
46                "local-files",
47                root,
48                environment.platform.platform,
49            )?);
50        }
51        roots.sort_by(|left, right| {
52            left.scope_id
53                .cmp(&right.scope_id)
54                .then(left.root_id.cmp(&right.root_id))
55        });
56        roots.dedup_by(|left, right| {
57            left.scope_id == right.scope_id && left.root_id == right.root_id
58        });
59
60        Ok(Self {
61            enabled: environment.file_index.enabled.unwrap_or(false),
62            roots,
63            excludes: split_semicolon(environment.file_index.excludes.as_deref())?,
64            max_depth: environment
65                .file_index
66                .max_depth
67                .unwrap_or(Self::DEFAULT_MAX_DEPTH),
68            max_file_bytes: environment
69                .file_index
70                .max_file_bytes
71                .unwrap_or(Self::DEFAULT_MAX_FILE_BYTES),
72            scan_interval: Duration::from_millis(
73                environment
74                    .file_index
75                    .scan_interval_ms
76                    .unwrap_or(duration_millis(Self::DEFAULT_SCAN_INTERVAL)),
77            ),
78            scan_timeout: Duration::from_millis(
79                environment
80                    .file_index
81                    .scan_timeout_ms
82                    .unwrap_or(duration_millis(Self::DEFAULT_SCAN_TIMEOUT)),
83            ),
84            max_files_per_root: environment
85                .file_index
86                .max_files_per_root
87                .unwrap_or(Self::DEFAULT_MAX_FILES_PER_ROOT),
88            query_timeout: Duration::from_millis(
89                environment
90                    .file_index
91                    .query_timeout_ms
92                    .unwrap_or(duration_millis(Self::DEFAULT_QUERY_TIMEOUT)),
93            ),
94        })
95    }
96}
97
98/// One authorized local file index root.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct FileIndexRootConfig {
101    pub scope_id: String,
102    pub root_id: String,
103    pub root_path: PathBuf,
104}
105
106impl FileIndexRootConfig {
107    pub fn new(scope_id: impl Into<String>, root_path: PathBuf) -> Self {
108        let root_path = normalize_file_index_root_path(root_path);
109        let root_id = format!(
110            "root-{:016x}",
111            stable_hash64(root_path.to_string_lossy().as_bytes())
112        );
113
114        Self {
115            scope_id: scope_id.into(),
116            root_id,
117            root_path,
118        }
119    }
120}
121
122fn normalize_file_index_root_path(root_path: PathBuf) -> PathBuf {
123    if let Ok(canonical) = std::fs::canonicalize(&root_path) {
124        return canonical;
125    }
126
127    let mut normalized = PathBuf::new();
128    for component in root_path.components() {
129        match component {
130            Component::CurDir => {}
131            Component::ParentDir => normalized.push(".."),
132            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
133            Component::RootDir => normalized.push(component.as_os_str()),
134            Component::Normal(value) => normalized.push(value),
135        }
136    }
137
138    if normalized.as_os_str().is_empty() {
139        root_path
140    } else {
141        normalized
142    }
143}
144
145/// File index runtime validation error.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum FileIndexRuntimeConfigError {
148    EmptyListValue,
149    RelativeRoot(String),
150    Paths(PathError),
151}
152
153impl fmt::Display for FileIndexRuntimeConfigError {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            Self::EmptyListValue => {
157                write!(formatter, "file index lists must not contain empty values")
158            }
159            Self::RelativeRoot(path) => {
160                write!(
161                    formatter,
162                    "file index root '{path}' must be an absolute path"
163                )
164            }
165            Self::Paths(error) => write!(formatter, "{error}"),
166        }
167    }
168}
169
170impl Error for FileIndexRuntimeConfigError {}
171
172fn split_semicolon(value: Option<&str>) -> Result<Vec<String>, FileIndexRuntimeConfigError> {
173    value
174        .map(|items| {
175            items
176                .split(';')
177                .map(str::trim)
178                .map(|item| {
179                    if item.is_empty() {
180                        Err(FileIndexRuntimeConfigError::EmptyListValue)
181                    } else {
182                        Ok(item.to_owned())
183                    }
184                })
185                .collect()
186        })
187        .unwrap_or_else(|| Ok(Vec::new()))
188}
189
190fn file_index_root_from_environment(
191    scope_id: &'static str,
192    root: String,
193    platform: PlatformKind,
194) -> Result<FileIndexRootConfig, FileIndexRuntimeConfigError> {
195    if !is_absolute_file_index_root(&root, platform) {
196        return Err(FileIndexRuntimeConfigError::RelativeRoot(root));
197    }
198
199    Ok(FileIndexRootConfig::new(scope_id, PathBuf::from(root)))
200}
201
202fn is_absolute_file_index_root(root: &str, platform: PlatformKind) -> bool {
203    match platform {
204        PlatformKind::Windows => is_absolute_windows_path(root),
205        _ => PathBuf::from(root).is_absolute(),
206    }
207}
208
209fn is_absolute_windows_path(root: &str) -> bool {
210    let bytes = root.as_bytes();
211    let drive_rooted = bytes.len() >= 3
212        && bytes[0].is_ascii_alphabetic()
213        && bytes[1] == b':'
214        && matches!(bytes[2], b'\\' | b'/');
215    if drive_rooted {
216        return true;
217    }
218
219    if !(root.starts_with("\\\\") || root.starts_with("//")) {
220        return false;
221    }
222    root[2..]
223        .split(['\\', '/'])
224        .filter(|component| !component.is_empty())
225        .take(2)
226        .count()
227        == 2
228}
229
230fn duration_millis(duration: Duration) -> u64 {
231    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
232}
233
234#[cfg(test)]
235#[path = "file_index_tests.rs"]
236mod tests;