|
1 | 1 | use crate::error::{Result, SofosError}; |
2 | | -use crate::tools::utils::{ConfirmationType, confirm_multi_choice}; |
| 2 | +use crate::tools::utils::{ConfirmationType, confirm_multi_choice, is_absolute_path}; |
3 | 3 | use globset::{Glob, GlobSet, GlobSetBuilder}; |
4 | 4 | use serde::{Deserialize, Serialize}; |
5 | 5 | use std::collections::HashSet; |
@@ -396,14 +396,20 @@ impl PermissionManager { |
396 | 396 | } |
397 | 397 |
|
398 | 398 | /// Extract path patterns from Bash() entries. |
399 | | - /// Only treats entries as path grants if the content starts with / or ~ AND contains a glob char. |
400 | | - /// Plain commands like Bash(npm test) are not path patterns. |
| 399 | + /// Only treats entries as path grants if the content is absolute or |
| 400 | + /// a tilde path AND contains a glob char. Plain commands like |
| 401 | + /// Bash(npm test) are not path patterns. `is_absolute_path` handles |
| 402 | + /// Unix (`/tmp/**`), Windows drive-letter (`C:\Users\**`), and UNC |
| 403 | + /// paths on every platform — using `Path::is_absolute` alone would |
| 404 | + /// mis-classify a Unix-style `Bash(/var/log/**)` config entry as a |
| 405 | + /// command pattern when the binary runs on Windows. |
401 | 406 | fn extract_bash_path_pattern(entry: &str) -> Option<&str> { |
402 | 407 | let trimmed = entry.trim(); |
403 | 408 | if let Some(rest) = trimmed.strip_prefix("Bash(") { |
404 | 409 | if let Some(end) = rest.rfind(')') { |
405 | 410 | let content = &rest[..end]; |
406 | | - if (content.starts_with('/') || content.starts_with('~')) && content.contains('*') { |
| 411 | + let looks_like_path = content.starts_with('~') || is_absolute_path(content); |
| 412 | + if looks_like_path && content.contains('*') { |
407 | 413 | return Some(content); |
408 | 414 | } |
409 | 415 | } |
@@ -459,14 +465,49 @@ impl PermissionManager { |
459 | 465 | .unwrap_or(without_prefix) |
460 | 466 | } |
461 | 467 |
|
| 468 | + /// Look up the user's home directory in a way that works on both |
| 469 | + /// Unix (`$HOME`) and Windows (`%USERPROFILE%`). `std::env::home_dir` |
| 470 | + /// was re-stabilised with a correct Windows implementation in Rust |
| 471 | + /// 1.85, but reading the platform-native env var directly keeps us |
| 472 | + /// compatible with older toolchains and makes the per-platform |
| 473 | + /// choice explicit. Returns `None` when the env var is unset, which |
| 474 | + /// is the same "fall through unexpanded" signal the caller uses. |
| 475 | + fn home_dir() -> Option<PathBuf> { |
| 476 | + #[cfg(windows)] |
| 477 | + { |
| 478 | + std::env::var_os("USERPROFILE").map(PathBuf::from) |
| 479 | + } |
| 480 | + #[cfg(not(windows))] |
| 481 | + { |
| 482 | + std::env::var_os("HOME").map(PathBuf::from) |
| 483 | + } |
| 484 | + } |
| 485 | + |
| 486 | + /// Expand a leading `~` or `~/` to the user's home directory. Uses |
| 487 | + /// `PathBuf::push` so the separator between the home directory and |
| 488 | + /// the rest of the path is the platform's native one — the old |
| 489 | + /// `format!("{}/{}", home, rest)` produced `C:\Users\alice/foo` on |
| 490 | + /// Windows, which Windows accepts but looks wrong on inspection. |
| 491 | + /// Paths not starting with `~` are returned unchanged. |
| 492 | + /// |
| 493 | + /// Strips leading separators from the remainder before pushing |
| 494 | + /// because `PathBuf::push` *replaces* self when the argument is |
| 495 | + /// absolute — so `expand_tilde("~//foo")` without the trim would |
| 496 | + /// return `/foo` (escaped out of home) instead of the |
| 497 | + /// bash-semantic `~/foo` = `home/foo`. Matters more on Windows |
| 498 | + /// where a user-supplied `~/\\server\share\file` would be UNC- |
| 499 | + /// absolute and would likewise replace the home prefix. |
462 | 500 | fn expand_tilde(path: &str) -> String { |
| 501 | + if path == "~" { |
| 502 | + return Self::home_dir() |
| 503 | + .map(|p| p.to_string_lossy().to_string()) |
| 504 | + .unwrap_or_else(|| path.to_string()); |
| 505 | + } |
463 | 506 | if let Some(rest) = path.strip_prefix("~/") { |
464 | | - if let Ok(home) = std::env::var("HOME") { |
465 | | - return format!("{}/{}", home, rest); |
466 | | - } |
467 | | - } else if path == "~" { |
468 | | - if let Ok(home) = std::env::var("HOME") { |
469 | | - return home; |
| 507 | + if let Some(mut home) = Self::home_dir() { |
| 508 | + let rest = rest.trim_start_matches(['/', '\\']); |
| 509 | + home.push(rest); |
| 510 | + return home.to_string_lossy().to_string(); |
470 | 511 | } |
471 | 512 | } |
472 | 513 | path.to_string() |
@@ -1186,6 +1227,67 @@ ask = [] |
1186 | 1227 | assert_eq!(not_tilde, "./file.txt"); |
1187 | 1228 | } |
1188 | 1229 |
|
| 1230 | + #[test] |
| 1231 | + fn test_tilde_expansion_trims_leading_separator_in_remainder() { |
| 1232 | + // `PathBuf::push` replaces self when the pushed argument is |
| 1233 | + // absolute. If the caller types `~//foo` (double slash after |
| 1234 | + // the tilde), the remainder after `~/` starts with `/`, which |
| 1235 | + // `push` would treat as absolute and escape the home |
| 1236 | + // directory entirely — `~//foo` would resolve to `/foo`, |
| 1237 | + // which is not what bash would do. The trim keeps the |
| 1238 | + // expansion rooted at the home directory. |
| 1239 | + let _lock = HOME_MUTEX.lock().unwrap(); |
| 1240 | + let _temp_dir = TempDir::new().unwrap(); |
| 1241 | + |
| 1242 | + let original_home = std::env::var_os("HOME"); |
| 1243 | + std::env::set_var("HOME", "/home/testuser"); |
| 1244 | + |
| 1245 | + let single = PermissionManager::expand_tilde_pub("~/foo"); |
| 1246 | + let double = PermissionManager::expand_tilde_pub("~//foo"); |
| 1247 | + let triple = PermissionManager::expand_tilde_pub("~///foo"); |
| 1248 | + |
| 1249 | + match original_home { |
| 1250 | + Some(home) => std::env::set_var("HOME", home), |
| 1251 | + None => std::env::remove_var("HOME"), |
| 1252 | + } |
| 1253 | + |
| 1254 | + assert_eq!(single, "/home/testuser/foo"); |
| 1255 | + assert_eq!( |
| 1256 | + double, "/home/testuser/foo", |
| 1257 | + "double-slash after tilde must not escape home" |
| 1258 | + ); |
| 1259 | + assert_eq!( |
| 1260 | + triple, "/home/testuser/foo", |
| 1261 | + "any number of leading slashes must not escape home" |
| 1262 | + ); |
| 1263 | + } |
| 1264 | + |
| 1265 | + #[cfg(windows)] |
| 1266 | + #[test] |
| 1267 | + fn test_tilde_expansion_uses_userprofile_on_windows() { |
| 1268 | + // Regression: `expand_tilde` used to read `$HOME`, which isn't |
| 1269 | + // the canonical home-directory env var on Windows. A user |
| 1270 | + // typing `~/docs` got no expansion. The fix reads |
| 1271 | + // `%USERPROFILE%` on Windows and joins via `PathBuf::push` so |
| 1272 | + // the resulting separator is native (backslash on Windows). |
| 1273 | + let _lock = HOME_MUTEX.lock().unwrap(); |
| 1274 | + let _temp_dir = TempDir::new().unwrap(); |
| 1275 | + |
| 1276 | + let original = std::env::var_os("USERPROFILE"); |
| 1277 | + std::env::set_var("USERPROFILE", r"C:\Users\testuser"); |
| 1278 | + |
| 1279 | + let expanded = PermissionManager::expand_tilde_pub("~/docs/file.txt"); |
| 1280 | + let expanded_dir = PermissionManager::expand_tilde_pub("~"); |
| 1281 | + |
| 1282 | + match original { |
| 1283 | + Some(home) => std::env::set_var("USERPROFILE", home), |
| 1284 | + None => std::env::remove_var("USERPROFILE"), |
| 1285 | + } |
| 1286 | + |
| 1287 | + assert_eq!(expanded, r"C:\Users\testuser\docs\file.txt"); |
| 1288 | + assert_eq!(expanded_dir, r"C:\Users\testuser"); |
| 1289 | + } |
| 1290 | + |
1189 | 1291 | #[test] |
1190 | 1292 | fn test_tilde_in_allow_rules() { |
1191 | 1293 | let _lock = HOME_MUTEX.lock().unwrap(); |
|
0 commit comments