1use std::path::{Component, Path, PathBuf};
12
13use crate::{Result, UtilsError};
14
15const DEFAULT_DATA_DIR_NAME: &str = "data";
16
17pub 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
48pub 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
79pub 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
105pub 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
137pub 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
178pub fn temp_file_path(temp_dir: &str, name: &str) -> String {
180 join_path(temp_dir, name)
181}
182
183pub fn runtime_temp_dir(temp_root: &str) -> String {
185 join_path(temp_root, "_runtime")
186}
187
188pub fn runtime_temp_file_path(temp_root: &str, name: &str) -> String {
190 join_path(&runtime_temp_dir(temp_root), name)
191}
192
193pub fn upload_temp_dir(upload_temp_root: &str, upload_id: &str) -> String {
195 join_path(upload_temp_root, upload_id)
196}
197
198pub 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
206pub 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
211pub fn task_temp_dir(temp_root: &str, task_id: i64) -> String {
213 join_path(temp_root, &format!("tasks/{task_id}"))
214}
215
216pub 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}