Skip to main content

aster_forge_utils/
paths.rs

1//! Path rendering and configuration-relative path helpers.
2//!
3//! Aster services commonly accept paths from static configuration while running with a fixed data
4//! base directory. This module centralizes the string-level path joining and normalization rules
5//! used by those services: redundant slashes are trimmed, `.` and safe `..` components are folded,
6//! paths configured relative to the config file are rendered back as runtime-relative paths under
7//! the data base directory, and sqlite URLs keep their query string while resolving the embedded
8//! filesystem path. The helpers intentionally avoid filesystem canonicalization so they work before
9//! directories or database files exist.
10
11use std::path::{Component, Path, PathBuf};
12
13use crate::{Result, UtilsError};
14
15const DEFAULT_DATA_DIR_NAME: &str = "data";
16
17/// Joins two slash-separated path fragments without emitting duplicate separators.
18///
19/// The helper is designed for runtime paths stored in configuration or database records. It keeps a
20/// leading slash from `root`, trims trailing slashes from `root`, and trims leading/trailing slashes
21/// from `leaf`.
22pub fn join_path(root: &str, leaf: &str) -> String {
23    let root_had_leading_slash = root.starts_with('/');
24    let root = root.trim_end_matches('/');
25    let leaf = leaf.trim_matches('/');
26
27    if root.is_empty() {
28        return if leaf.is_empty() {
29            if root_had_leading_slash {
30                "/".to_string()
31            } else {
32                String::new()
33            }
34        } else if root_had_leading_slash {
35            format!("/{leaf}")
36        } else {
37            leaf.to_string()
38        };
39    }
40
41    if leaf.is_empty() {
42        return root.to_string();
43    }
44
45    format!("{root}/{leaf}")
46}
47
48/// Normalizes a path lexically without touching the filesystem.
49///
50/// `.` components are dropped. `..` removes the previous normal component when possible, but it is
51/// preserved when removing it would cross an unknown relative root. Absolute roots and platform
52/// prefixes are retained.
53pub fn normalize_path(path: &Path) -> PathBuf {
54    let mut normalized = PathBuf::new();
55
56    for component in path.components() {
57        match component {
58            Component::CurDir => {}
59            Component::ParentDir => match normalized.components().next_back() {
60                Some(Component::Normal(_)) => {
61                    normalized.pop();
62                }
63                Some(Component::RootDir) | Some(Component::Prefix(_)) => {}
64                _ => normalized.push(component.as_os_str()),
65            },
66            Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
67                normalized.push(component.as_os_str());
68            }
69        }
70    }
71
72    if normalized.as_os_str().is_empty() {
73        PathBuf::from(".")
74    } else {
75        normalized
76    }
77}
78
79/// Renders a resolved path as a runtime-relative path under `base_dir`.
80///
81/// The returned path is relative to the normalized `base_dir`. If `resolved` points outside
82/// `base_dir`, the function rejects it instead of returning a path with leading `..` segments.
83pub fn render_runtime_relative_path(base_dir: &Path, resolved: &Path) -> Result<String> {
84    let normalized_base_dir = normalize_path(base_dir);
85    let normalized_resolved = normalize_path(resolved);
86
87    match normalized_resolved.strip_prefix(&normalized_base_dir) {
88        Ok(stripped) if stripped.as_os_str().is_empty() => Ok(".".to_string()),
89        Ok(stripped) => Ok(stripped.to_string_lossy().to_string()),
90        Err(_) => Err(UtilsError::invalid_value(format!(
91            "configured relative path resolves outside data base_dir: base_dir='{}', resolved='{}'",
92            normalized_base_dir.display(),
93            normalized_resolved.display()
94        ))),
95    }
96}
97
98fn is_data_prefixed_relative_path(path: &Path) -> bool {
99    matches!(
100        path.components().next(),
101        Some(Component::Normal(component)) if component == DEFAULT_DATA_DIR_NAME
102    )
103}
104
105/// Resolves a config value into the runtime path form used under `base_dir`.
106///
107/// Empty values are preserved. Absolute paths are normalized and returned as absolute paths.
108/// Relative values starting with `data` are anchored at `base_dir`; all other relative values are
109/// anchored at `config_dir`, then rendered relative to `base_dir`. Values resolving outside
110/// `base_dir` are rejected.
111pub fn resolve_config_relative_path(
112    base_dir: &Path,
113    config_dir: &Path,
114    value: &str,
115) -> Result<String> {
116    if value.is_empty() {
117        return Ok(value.to_string());
118    }
119
120    let configured_path = Path::new(value);
121    if configured_path.is_absolute() {
122        return Ok(normalize_path(configured_path)
123            .to_string_lossy()
124            .to_string());
125    }
126
127    let anchor_dir = if is_data_prefixed_relative_path(configured_path) {
128        base_dir
129    } else {
130        config_dir
131    };
132    let resolved = normalize_path(&anchor_dir.join(configured_path));
133
134    render_runtime_relative_path(base_dir, &resolved)
135}
136
137/// Resolves the filesystem path inside a sqlite URL while preserving sqlite-specific values.
138///
139/// Non-sqlite URLs, `sqlite::memory:`, `sqlite://`, and `sqlite://:memory:` are returned unchanged.
140/// For file-backed sqlite URLs, the embedded path is resolved with
141/// [`resolve_config_relative_path`] and the original query string is retained.
142pub fn resolve_config_relative_sqlite_url(
143    base_dir: &Path,
144    config_dir: &Path,
145    value: &str,
146) -> Result<String> {
147    if value == "sqlite::memory:" {
148        return Ok(value.to_string());
149    }
150
151    let Some(path_and_query) = value.strip_prefix("sqlite://") else {
152        return Ok(value.to_string());
153    };
154    let (raw_path, raw_query) = match path_and_query.split_once('?') {
155        Some((path, query)) => (path, Some(query)),
156        None => (path_and_query, None),
157    };
158
159    if raw_path.is_empty() || raw_path == ":memory:" {
160        return Ok(value.to_string());
161    }
162
163    let configured_path = Path::new(raw_path);
164    let resolved_path = if configured_path.is_absolute() {
165        normalize_path(configured_path)
166            .to_string_lossy()
167            .to_string()
168    } else {
169        resolve_config_relative_path(base_dir, config_dir, raw_path)?
170    };
171
172    match raw_query {
173        Some(query) => Ok(format!("sqlite://{resolved_path}?{query}")),
174        None => Ok(format!("sqlite://{resolved_path}")),
175    }
176}
177
178/// Returns the path to a temporary file under `temp_dir`.
179pub fn temp_file_path(temp_dir: &str, name: &str) -> String {
180    join_path(temp_dir, name)
181}
182
183/// Returns the namespaced runtime temporary directory under `temp_root`.
184pub fn runtime_temp_dir(temp_root: &str) -> String {
185    join_path(temp_root, "_runtime")
186}
187
188/// Returns a runtime temporary file path under the `_runtime` namespace.
189pub fn runtime_temp_file_path(temp_root: &str, name: &str) -> String {
190    join_path(&runtime_temp_dir(temp_root), name)
191}
192
193/// Returns the temporary directory for a multipart upload session.
194pub fn upload_temp_dir(upload_temp_root: &str, upload_id: &str) -> String {
195    join_path(upload_temp_root, upload_id)
196}
197
198/// Returns the temporary path for one uploaded chunk.
199pub fn upload_chunk_path(upload_temp_root: &str, upload_id: &str, chunk_number: i32) -> String {
200    join_path(
201        &upload_temp_dir(upload_temp_root, upload_id),
202        &format!("chunk_{chunk_number}"),
203    )
204}
205
206/// Returns the assembled-file temporary path for a multipart upload session.
207pub fn upload_assembled_path(upload_temp_root: &str, upload_id: &str) -> String {
208    join_path(&upload_temp_dir(upload_temp_root, upload_id), "_assembled")
209}
210
211/// Returns the temporary directory for a background task.
212pub fn task_temp_dir(temp_root: &str, task_id: i64) -> String {
213    join_path(temp_root, &format!("tasks/{task_id}"))
214}
215
216/// Returns the temporary directory for a specific task processing token.
217///
218/// The processing token keeps artifacts from separate leases isolated when an old worker wakes up
219/// after a newer lease has already started.
220pub fn task_token_temp_dir(temp_root: &str, task_id: i64, processing_token: i64) -> String {
221    join_path(
222        &task_temp_dir(temp_root, task_id),
223        &processing_token.to_string(),
224    )
225}
226
227#[cfg(test)]
228mod tests {
229    use super::{
230        join_path, normalize_path, render_runtime_relative_path, resolve_config_relative_path,
231        resolve_config_relative_sqlite_url, runtime_temp_dir, runtime_temp_file_path,
232        task_temp_dir, task_token_temp_dir, temp_file_path, upload_assembled_path,
233        upload_chunk_path, upload_temp_dir,
234    };
235    use crate::UtilsError;
236    use std::path::{Path, PathBuf};
237
238    fn assert_no_double_slash(path: &str) {
239        assert!(
240            !path.contains("//"),
241            "path should not contain double slashes: {path}"
242        );
243    }
244
245    #[test]
246    fn join_path_handles_empty_and_absolute_roots() {
247        assert_eq!(join_path("", ""), "");
248        assert_eq!(join_path("", "leaf"), "leaf");
249        assert_eq!(join_path("/", ""), "/");
250        assert_eq!(join_path("/", "/leaf/"), "/leaf");
251        assert_eq!(join_path("/tmp///", "///runtime.bin"), "/tmp/runtime.bin");
252    }
253
254    #[test]
255    fn normalize_path_folds_current_and_parent_components() {
256        assert_eq!(
257            normalize_path(Path::new("/srv/app/data/../data/./.tmp")),
258            PathBuf::from("/srv/app/data/.tmp")
259        );
260        assert_eq!(
261            normalize_path(Path::new("./data/./.tmp")),
262            PathBuf::from("data/.tmp")
263        );
264        assert_eq!(
265            normalize_path(Path::new("../shared")),
266            PathBuf::from("../shared")
267        );
268        assert_eq!(normalize_path(Path::new(".")), PathBuf::from("."));
269    }
270
271    #[test]
272    fn render_runtime_relative_path_rejects_paths_outside_base_dir() {
273        let base_dir = Path::new("/srv/app");
274        let resolved = Path::new("/srv/shared");
275
276        let error = render_runtime_relative_path(base_dir, resolved).unwrap_err();
277        assert!(matches!(error, UtilsError::InvalidValue(_)));
278        assert!(error.to_string().contains("outside data base_dir"));
279    }
280
281    #[test]
282    fn temp_file_path_joins_normal_inputs() {
283        let path = temp_file_path("data/.tmp", "abc123");
284        assert_eq!(path, "data/.tmp/abc123");
285        assert_no_double_slash(&path);
286    }
287
288    #[test]
289    fn temp_file_path_trims_user_supplied_slashes() {
290        let path = temp_file_path("data/.tmp///", "/nested/file.tmp/");
291        assert_eq!(path, "data/.tmp/nested/file.tmp");
292        assert_no_double_slash(&path);
293    }
294
295    #[test]
296    fn temp_file_path_preserves_absolute_root_without_double_slash() {
297        let path = temp_file_path("/tmp///", "///upload.bin");
298        assert_eq!(path, "/tmp/upload.bin");
299        assert_no_double_slash(&path);
300    }
301
302    #[test]
303    fn runtime_temp_file_path_nests_under_runtime_subdir() {
304        let path = runtime_temp_file_path("data/.tmp///", "/abc123/");
305        assert_eq!(path, "data/.tmp/_runtime/abc123");
306        assert_no_double_slash(&path);
307    }
308
309    #[test]
310    fn runtime_temp_dir_uses_namespaced_subdir() {
311        let path = runtime_temp_dir("/tmp///");
312        assert_eq!(path, "/tmp/_runtime");
313        assert_no_double_slash(&path);
314    }
315
316    #[test]
317    fn upload_paths_trim_edge_case_inputs() {
318        let dir = upload_temp_dir("data/.uploads///", "/session-123/");
319        assert_eq!(dir, "data/.uploads/session-123");
320        assert_no_double_slash(&dir);
321
322        let chunk = upload_chunk_path("data/.uploads///", "///session-123///", 7);
323        assert_eq!(chunk, "data/.uploads/session-123/chunk_7");
324        assert_no_double_slash(&chunk);
325
326        let assembled = upload_assembled_path("/var/tmp/uploads///", "///session-123///");
327        assert_eq!(assembled, "/var/tmp/uploads/session-123/_assembled");
328        assert_no_double_slash(&assembled);
329    }
330
331    #[test]
332    fn empty_upload_id_returns_normalized_upload_root() {
333        let path = upload_temp_dir("data/.uploads///", "");
334        assert_eq!(path, "data/.uploads");
335        assert_no_double_slash(&path);
336    }
337
338    #[test]
339    fn task_paths_do_not_emit_double_slashes() {
340        let dir = task_temp_dir("data/.tmp///", 42);
341        assert_eq!(dir, "data/.tmp/tasks/42");
342        assert_no_double_slash(&dir);
343    }
344
345    #[test]
346    fn task_token_temp_dir_nests_under_task_root() {
347        let path = task_token_temp_dir("data/.tmp///", 42, 7);
348        assert_eq!(path, "data/.tmp/tasks/42/7");
349        assert_no_double_slash(&path);
350    }
351
352    #[test]
353    fn resolve_config_relative_path_accepts_plain_and_data_prefixed_relative_values() {
354        let base_dir = Path::new("/srv/asterapp");
355        let config_dir = Path::new("/srv/asterapp/data");
356
357        assert_eq!(
358            resolve_config_relative_path(base_dir, config_dir, ".tmp").unwrap(),
359            "data/.tmp"
360        );
361        assert_eq!(
362            resolve_config_relative_path(base_dir, config_dir, "data/.tmp").unwrap(),
363            "data/.tmp"
364        );
365        assert_eq!(
366            resolve_config_relative_path(base_dir, config_dir, "../shared").unwrap(),
367            "shared"
368        );
369    }
370
371    #[test]
372    fn resolve_config_relative_path_preserves_empty_and_absolute_values() {
373        let base_dir = Path::new("/srv/asterapp");
374        let config_dir = Path::new("/srv/asterapp/data");
375
376        assert_eq!(
377            resolve_config_relative_path(base_dir, config_dir, "").unwrap(),
378            ""
379        );
380        assert_eq!(
381            resolve_config_relative_path(base_dir, config_dir, "/var/lib/asterapp/../app/data")
382                .unwrap(),
383            "/var/lib/app/data"
384        );
385    }
386
387    #[test]
388    fn resolve_config_relative_path_rejects_values_outside_base_dir() {
389        let base_dir = Path::new("/srv/asterapp");
390        let config_dir = Path::new("/srv/asterapp/data");
391
392        let error = resolve_config_relative_path(base_dir, config_dir, "../../shared")
393            .expect_err("path outside base_dir should be rejected");
394        assert!(error.to_string().contains("outside data base_dir"));
395    }
396
397    #[test]
398    fn resolve_config_relative_sqlite_url_accepts_plain_and_data_prefixed_relative_values() {
399        let base_dir = Path::new("/srv/asterapp");
400        let config_dir = Path::new("/srv/asterapp/data");
401
402        assert_eq!(
403            resolve_config_relative_sqlite_url(
404                base_dir,
405                config_dir,
406                "sqlite://asterapp.db?mode=rwc"
407            )
408            .unwrap(),
409            "sqlite://data/asterapp.db?mode=rwc"
410        );
411        assert_eq!(
412            resolve_config_relative_sqlite_url(
413                base_dir,
414                config_dir,
415                "sqlite://data/asterapp.db?mode=rwc"
416            )
417            .unwrap(),
418            "sqlite://data/asterapp.db?mode=rwc"
419        );
420        assert_eq!(
421            resolve_config_relative_sqlite_url(
422                base_dir,
423                config_dir,
424                "sqlite:///var/lib/asterapp/custom.db?mode=rwc"
425            )
426            .unwrap(),
427            "sqlite:///var/lib/asterapp/custom.db?mode=rwc"
428        );
429    }
430
431    #[test]
432    fn resolve_config_relative_sqlite_url_preserves_non_file_backed_values() {
433        let base_dir = Path::new("/srv/asterapp");
434        let config_dir = Path::new("/srv/asterapp/data");
435
436        assert_eq!(
437            resolve_config_relative_sqlite_url(base_dir, config_dir, "sqlite::memory:").unwrap(),
438            "sqlite::memory:"
439        );
440        assert_eq!(
441            resolve_config_relative_sqlite_url(base_dir, config_dir, "sqlite://:memory:").unwrap(),
442            "sqlite://:memory:"
443        );
444        assert_eq!(
445            resolve_config_relative_sqlite_url(base_dir, config_dir, "postgres://localhost/db")
446                .unwrap(),
447            "postgres://localhost/db"
448        );
449    }
450
451    #[test]
452    fn resolve_config_relative_sqlite_url_rejects_values_outside_base_dir() {
453        let base_dir = Path::new("/srv/asterapp");
454        let config_dir = Path::new("/srv/asterapp/data");
455
456        let error = resolve_config_relative_sqlite_url(
457            base_dir,
458            config_dir,
459            "sqlite://../../shared/asterapp.db?mode=rwc",
460        )
461        .expect_err("sqlite path outside base_dir should be rejected");
462        assert!(error.to_string().contains("outside data base_dir"));
463    }
464}