Skip to main content

opendev_tools_impl/
path_utils.rs

1//! Shared path resolution utilities for tool implementations.
2//!
3//! Path resolution functions (`expand_home`, `strip_curdir`, `normalize_path`,
4//! `resolve_file_path`, `resolve_dir_path`) are defined in `opendev-tools-core::path`
5//! and re-exported here for backward compatibility. This module additionally provides
6//! security-boundary functions (`validate_path_access`, `is_sensitive_file`) that
7//! are tool-level concerns.
8
9use std::path::Path;
10
11pub use opendev_tools_core::path::{
12    expand_home, normalize_path, resolve_dir_path, resolve_file_path, strip_curdir,
13};
14
15/// Validate that a resolved path is safe to access.
16///
17/// Returns `Ok(())` if the path is within the working directory or an allowed
18/// global config location. Returns `Err(message)` if the path would escape
19/// the project boundary (e.g., via `../../../etc/passwd`).
20///
21/// Allowed paths outside working_dir:
22/// - `~/.opendev/` (user config, memory, skills)
23/// - `~/.config/opendev/` (XDG config)
24/// - `/tmp/` (temporary files)
25pub fn validate_path_access(resolved: &Path, working_dir: &Path) -> Result<(), String> {
26    // Normalize the path: collapse `.` and `..` components logically.
27    let normalized = normalize_path(resolved);
28
29    // Check if it's under the working directory.
30    if normalized.starts_with(working_dir) {
31        return Ok(());
32    }
33
34    // Also accept if working_dir has symlinks — try canonical forms.
35    if let (Ok(canon_path), Ok(canon_wd)) = (normalized.canonicalize(), working_dir.canonicalize())
36        && canon_path.starts_with(&canon_wd)
37    {
38        return Ok(());
39    }
40
41    // Allow well-known global config directories.
42    if let Some(home) = dirs::home_dir() {
43        let allowed_prefixes = [home.join(".opendev"), home.join(".config").join("opendev")];
44        for prefix in &allowed_prefixes {
45            if normalized.starts_with(prefix) {
46                return Ok(());
47            }
48        }
49    }
50
51    // Allow /tmp for temporary files.
52    if normalized.starts_with("/tmp") || normalized.starts_with("/var/tmp") {
53        return Ok(());
54    }
55
56    Err(format!(
57        "Access denied: path '{}' is outside the project directory '{}'",
58        resolved.display(),
59        working_dir.display()
60    ))
61}
62
63/// Check if a file is likely to contain sensitive data (secrets, credentials, keys).
64///
65/// Matches patterns from `.gitignore` for Node.js (`.env` family) plus
66/// common credential/key files. Returns a human-readable reason if sensitive.
67pub fn is_sensitive_file(path: &Path) -> Option<&'static str> {
68    let name = path
69        .file_name()
70        .and_then(|n| n.to_str())
71        .unwrap_or("")
72        .to_lowercase();
73
74    // .env files (matches .env, .env.local, .env.production, etc.)
75    // but NOT .env.example or .env.sample
76    if name == ".env"
77        || (name.starts_with(".env.") && !name.ends_with(".example") && !name.ends_with(".sample"))
78    {
79        return Some("environment file (may contain secrets)");
80    }
81
82    // Private keys
83    if name.ends_with(".pem")
84        || name.ends_with(".key")
85        || name == "id_rsa"
86        || name == "id_ed25519"
87        || name == "id_ecdsa"
88    {
89        return Some("private key file");
90    }
91
92    // Known credential files
93    let credential_names = [
94        "credentials",
95        "credentials.json",
96        "credentials.yaml",
97        "credentials.yml",
98        "service-account.json",
99        ".npmrc",
100        ".pypirc",
101        ".netrc",
102        ".htpasswd",
103    ];
104    if credential_names.contains(&name.as_str()) {
105        return Some("credentials file");
106    }
107
108    // Token/secret files
109    if name.contains("secret")
110        && (name.ends_with(".json") || name.ends_with(".yaml") || name.ends_with(".yml"))
111    {
112        return Some("secrets file");
113    }
114
115    None
116}
117
118#[cfg(test)]
119#[path = "path_utils_tests.rs"]
120mod tests;