agpm_cli/utils/fs/
paths.rs

1//! Path utilities for normalization, validation, and discovery.
2//!
3//! This module provides path manipulation functions with security
4//! checks for path traversal and project root discovery.
5
6use anyhow::Result;
7use std::path::{Path, PathBuf};
8
9/// Normalizes a path by resolving `.` and `..` components.
10///
11/// This function cleans up path components by:
12/// - Removing `.` (current directory) components
13/// - Resolving `..` (parent directory) components
14/// - Maintaining the path's absolute or relative nature
15///
16/// Note: This function performs logical path resolution without accessing the filesystem.
17/// It does not resolve symbolic links or verify that the path exists.
18///
19/// # Arguments
20///
21/// * `path` - The path to normalize
22///
23/// # Returns
24///
25/// A normalized [`PathBuf`] with `.` and `..` components resolved
26///
27/// # Examples
28///
29/// ```rust,no_run
30/// use agpm_cli::utils::fs::normalize_path;
31/// use std::path::{Path, PathBuf};
32///
33/// let path = Path::new("/foo/./bar/../baz");
34/// let normalized = normalize_path(path);
35/// assert_eq!(normalized, PathBuf::from("/foo/baz"));
36///
37/// let relative = Path::new("../src/./lib.rs");
38/// let normalized_rel = normalize_path(relative);
39/// assert_eq!(normalized_rel, PathBuf::from("../src/lib.rs"));
40/// ```
41///
42/// # Use Cases
43///
44/// - Cleaning user input paths
45/// - Path comparison and deduplication
46/// - Security checks for path traversal
47/// - Canonical path representation
48///
49/// # See Also
50///
51/// - `is_safe_path` for security validation using this normalization
52/// - `safe_canonicalize` for filesystem-aware path resolution
53#[must_use]
54pub fn normalize_path(path: &Path) -> PathBuf {
55    let mut components = Vec::new();
56
57    for component in path.components() {
58        match component {
59            std::path::Component::CurDir => {} // Skip .
60            std::path::Component::ParentDir => {
61                components.pop(); // Remove previous component for ..
62            }
63            c => components.push(c),
64        }
65    }
66
67    components.iter().collect()
68}
69
70/// Checks if a path is safe and doesn't escape the base directory.
71///
72/// This function prevents directory traversal attacks by ensuring that the resolved
73/// path remains within the base directory. It handles both absolute and relative paths,
74/// normalizing them before comparison.
75///
76/// # Arguments
77///
78/// * `base` - The base directory that should contain the path
79/// * `path` - The path to validate (can be absolute or relative)
80///
81/// # Returns
82///
83/// - `true` if the path is safe and stays within the base directory
84/// - `false` if the path would escape the base directory
85///
86/// # Examples
87///
88/// ```rust,no_run
89/// use agpm_cli::utils::fs::is_safe_path;
90/// use std::path::Path;
91///
92/// let base = Path::new("/home/user/project");
93///
94/// // Safe paths
95/// assert!(is_safe_path(base, Path::new("src/main.rs")));
96/// assert!(is_safe_path(base, Path::new("./config/settings.toml")));
97///
98/// // Unsafe paths (directory traversal)
99/// assert!(!is_safe_path(base, Path::new("../../../etc/passwd")));
100/// assert!(!is_safe_path(base, Path::new("/etc/passwd")));
101/// ```
102///
103/// # Security
104///
105/// This function is essential for preventing directory traversal vulnerabilities
106/// when processing user-provided paths. It should be used whenever:
107/// - Extracting archives or packages
108/// - Processing configuration files with path references
109/// - Handling user input that specifies file locations
110///
111/// # Implementation
112///
113/// The function normalizes both paths using `normalize_path` before comparison,
114/// ensuring that path traversal attempts using `../` are properly detected.
115#[must_use]
116pub fn is_safe_path(base: &Path, path: &Path) -> bool {
117    let normalized_base = normalize_path(base);
118    let normalized_path = if path.is_absolute() {
119        normalize_path(path)
120    } else {
121        normalize_path(&base.join(path))
122    };
123
124    normalized_path.starts_with(normalized_base)
125}
126
127/// Finds the AGPM project root by searching for `agpm.toml` in the directory hierarchy.
128///
129/// This function starts from the given directory and walks up the directory tree
130/// looking for a `agpm.toml` file, which indicates the root of a AGPM project.
131/// This is similar to how Git finds the repository root by looking for `.git`.
132///
133/// # Arguments
134///
135/// * `start` - The directory to start searching from (typically current directory)
136///
137/// # Returns
138///
139/// The path to the directory containing `agpm.toml`, or an error if not found
140///
141/// # Examples
142///
143/// ```rust,no_run
144/// use agpm_cli::utils::fs::find_project_root;
145/// use std::env;
146///
147/// # fn example() -> anyhow::Result<()> {
148/// // Find project root from current directory
149/// let current_dir = env::current_dir()?;
150/// let project_root = find_project_root(&current_dir)?;
151/// println!("Project root: {}", project_root.display());
152/// # Ok(())
153/// # }
154/// ```
155///
156/// # Behavior
157///
158/// - Starts from the given directory and searches upward
159/// - Returns the first directory containing `agpm.toml`
160/// - Canonicalizes the starting path to handle symlinks
161/// - Stops at filesystem root if no `agpm.toml` is found
162///
163/// # Error Cases
164///
165/// - No `agpm.toml` found in the directory hierarchy
166/// - Permission denied accessing parent directories
167/// - Invalid or inaccessible starting path
168///
169/// # Use Cases
170///
171/// - CLI commands that need to operate on the current project
172/// - Finding configuration files relative to project root
173/// - Validating that commands are run within a AGPM project
174pub fn find_project_root(start: &Path) -> Result<PathBuf> {
175    let mut current = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
176
177    loop {
178        if current.join("agpm.toml").exists() {
179            return Ok(current);
180        }
181
182        if !current.pop() {
183            return Err(anyhow::anyhow!(
184                "No agpm.toml found in current directory or any parent directory"
185            ));
186        }
187    }
188}
189
190/// Returns the path to the global AGPM configuration file.
191///
192/// This function constructs the path to the global configuration file following
193/// platform conventions. The global config contains user-specific settings like
194/// authentication tokens and private repository URLs.
195///
196/// # Returns
197///
198/// The path to `~/.config/agpm/config.toml`, or an error if the home directory
199/// cannot be determined
200///
201/// # Examples
202///
203/// ```rust,no_run
204/// use agpm_cli::utils::fs::get_global_config_path;
205///
206/// # fn example() -> anyhow::Result<()> {
207/// let config_path = get_global_config_path()?;
208/// println!("Global config at: {}", config_path.display());
209///
210/// // Check if global config exists
211/// if config_path.exists() {
212///     let config = std::fs::read_to_string(&config_path)?;
213///     println!("Config contents: {}", config);
214/// }
215/// # Ok(())
216/// # }
217/// ```
218///
219/// # Platform Paths
220///
221/// - **Linux**: `~/.config/agpm/config.toml`
222/// - **macOS**: `~/.config/agpm/config.toml`
223/// - **Windows**: `%USERPROFILE%\.config\agpm\config.toml`
224///
225/// # Use Cases
226///
227/// - Loading global user configuration
228/// - Storing authentication tokens securely
229/// - Sharing settings across multiple projects
230///
231/// # Security Note
232///
233/// This file may contain sensitive information like API tokens. It should never
234/// be committed to version control or shared publicly.
235pub fn get_global_config_path() -> Result<PathBuf> {
236    let home = crate::utils::platform::get_home_dir()?;
237    Ok(home.join(".config").join("agpm").join("config.toml"))
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use tempfile::tempdir;
244
245    #[test]
246    fn test_normalize_path() {
247        let path = Path::new("/foo/./bar/../baz");
248        let normalized = normalize_path(path);
249        assert_eq!(normalized, PathBuf::from("/foo/baz"));
250    }
251
252    #[test]
253    fn test_normalize_path_complex() {
254        // Test various path normalization scenarios
255        assert_eq!(normalize_path(Path::new("/")), PathBuf::from("/"));
256        assert_eq!(normalize_path(Path::new("/foo/bar")), PathBuf::from("/foo/bar"));
257        assert_eq!(normalize_path(Path::new("/foo/./bar")), PathBuf::from("/foo/bar"));
258        assert_eq!(normalize_path(Path::new("/foo/../bar")), PathBuf::from("/bar"));
259        assert_eq!(normalize_path(Path::new("/foo/bar/..")), PathBuf::from("/foo"));
260        assert_eq!(normalize_path(Path::new("foo/./bar")), PathBuf::from("foo/bar"));
261        assert_eq!(normalize_path(Path::new("./foo/bar")), PathBuf::from("foo/bar"));
262    }
263
264    #[test]
265    fn test_is_safe_path() {
266        let base = Path::new("/home/user/project");
267
268        assert!(is_safe_path(base, Path::new("subdir/file.txt")));
269        assert!(is_safe_path(base, Path::new("./subdir/file.txt")));
270        assert!(!is_safe_path(base, Path::new("../other/file.txt")));
271        assert!(!is_safe_path(base, Path::new("/etc/passwd")));
272    }
273
274    #[test]
275    fn test_is_safe_path_edge_cases() {
276        let base = Path::new("/home/user/project");
277
278        // Safe paths
279        assert!(is_safe_path(base, Path::new("")));
280        assert!(is_safe_path(base, Path::new(".")));
281        assert!(is_safe_path(base, Path::new("./nested/./path")));
282
283        // Unsafe paths
284        assert!(!is_safe_path(base, Path::new("..")));
285        assert!(!is_safe_path(base, Path::new("../../etc")));
286        assert!(!is_safe_path(base, Path::new("/absolute/path")));
287
288        // Windows-style paths (on Unix these are relative)
289        if cfg!(windows) {
290            assert!(!is_safe_path(base, Path::new("C:\\Windows")));
291        }
292    }
293
294    #[test]
295    fn test_find_project_root() {
296        let temp = tempdir().unwrap();
297        let project = temp.path().join("project");
298        let subdir = project.join("src").join("subdir");
299
300        crate::utils::fs::ensure_dir(&subdir).unwrap();
301        std::fs::write(project.join("agpm.toml"), "[sources]").unwrap();
302
303        let root = find_project_root(&subdir).unwrap();
304        assert_eq!(root.canonicalize().unwrap(), project.canonicalize().unwrap());
305    }
306
307    #[test]
308    fn test_find_project_root_not_found() {
309        let temp = tempdir().unwrap();
310        let result = find_project_root(temp.path());
311        assert!(result.is_err());
312    }
313
314    #[test]
315    fn test_find_project_root_multiple_markers() {
316        let temp = tempdir().unwrap();
317        let root = temp.path().join("project");
318        let subproject = root.join("subproject");
319        let deep = subproject.join("src");
320
321        crate::utils::fs::ensure_dir(&deep).unwrap();
322        std::fs::write(root.join("agpm.toml"), "[sources]").unwrap();
323        std::fs::write(subproject.join("agpm.toml"), "[sources]").unwrap();
324
325        // Should find the closest agpm.toml
326        let found = find_project_root(&deep).unwrap();
327        assert_eq!(found.canonicalize().unwrap(), subproject.canonicalize().unwrap());
328    }
329
330    #[test]
331    fn test_get_global_config_path() {
332        let config_path = get_global_config_path().unwrap();
333        assert!(config_path.to_string_lossy().contains(".config"));
334        assert!(config_path.to_string_lossy().contains("agpm"));
335    }
336}