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