1use std::path::{Component, Path, PathBuf};
12
13use crate::{Result, UtilsError};
14
15const DEFAULT_DATA_DIR_NAME: &str = "data";
16
17#[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#[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
81pub 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
111pub 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
147pub 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#[must_use]
194pub fn temp_file_path(temp_dir: &str, name: &str) -> String {
195 join_path(temp_dir, name)
196}
197
198#[must_use]
200pub fn runtime_temp_dir(temp_root: &str) -> String {
201 join_path(temp_root, "_runtime")
202}
203
204#[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#[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#[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#[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#[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#[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}