Skip to main content

convergio_types/
platform_paths.rs

1//! Platform-aware directory resolution.
2//!
3//! macOS: ~/Library/Application Support/Convergio
4//! Linux: ~/.local/share/convergio
5//! Windows: %APPDATA%/Convergio
6//! Fallback: ~/.convergio/
7
8use std::path::PathBuf;
9
10/// Primary Convergio data directory.
11pub fn convergio_data_dir() -> PathBuf {
12    if let Some(data) = dirs::data_dir() {
13        return data.join("Convergio");
14    }
15    dirs::home_dir()
16        .unwrap_or_else(|| PathBuf::from("."))
17        .join(".convergio")
18}
19
20/// Output directory for a named project.
21/// The project name is validated to prevent path traversal.
22pub fn project_output_dir(project_name: &str) -> Result<PathBuf, String> {
23    validate_path_components(std::path::Path::new(project_name))?;
24    if project_name.is_empty() {
25        return Err("project name must not be empty".into());
26    }
27    Ok(convergio_data_dir()
28        .join("projects")
29        .join(project_name)
30        .join("output"))
31}
32
33/// Validate a path is within an allowed base directory. Prevents path traversal.
34/// Returns the canonicalized path on success, or an error if traversal detected.
35pub fn sanitize_path(
36    path: &std::path::Path,
37    allowed_base: &std::path::Path,
38) -> Result<PathBuf, String> {
39    let canonical = path
40        .canonicalize()
41        .map_err(|e| format!("invalid path {}: {e}", path.display()))?;
42    let base = allowed_base
43        .canonicalize()
44        .map_err(|e| format!("invalid base {}: {e}", allowed_base.display()))?;
45    if canonical.starts_with(&base) {
46        Ok(canonical)
47    } else {
48        Err(format!(
49            "path {} is outside allowed directory {}",
50            canonical.display(),
51            base.display()
52        ))
53    }
54}
55
56/// Validate a path only contains safe characters (no traversal components).
57/// Does NOT require the path to exist yet (for create operations).
58/// Rejects absolute paths (`/foo`) and parent traversal (`..`).
59pub fn validate_path_components(path: &std::path::Path) -> Result<(), String> {
60    for component in path.components() {
61        match component {
62            std::path::Component::ParentDir => {
63                return Err(format!("path traversal '..' in {}", path.display()));
64            }
65            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
66                return Err(format!("absolute path not allowed: {}", path.display()));
67            }
68            std::path::Component::Normal(s) => {
69                let s = s.to_string_lossy();
70                if s.starts_with('.') && s.len() > 1 && !s.starts_with("..") {
71                    // allow hidden files like .convergio, .worktrees
72                }
73                if s.contains('\0') {
74                    return Err("null byte in path component".into());
75                }
76            }
77            _ => {}
78        }
79    }
80    Ok(())
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn data_dir_is_absolute_or_fallback() {
89        let dir = convergio_data_dir();
90        let name = dir.file_name().unwrap().to_str().unwrap();
91        assert!(
92            name == "Convergio" || name == ".convergio",
93            "unexpected dir name: {name}"
94        );
95    }
96
97    #[test]
98    fn project_output_dir_structure() {
99        let out = project_output_dir("my-app").unwrap();
100        assert!(out.ends_with("projects/my-app/output"));
101    }
102
103    #[test]
104    fn project_output_dir_rejects_traversal() {
105        assert!(project_output_dir("../etc/passwd").is_err());
106        assert!(project_output_dir("").is_err());
107    }
108
109    #[test]
110    fn validate_rejects_absolute_path() {
111        assert!(validate_path_components(std::path::Path::new("/etc/passwd")).is_err());
112    }
113}