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