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(¤t_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}