|
1 | 1 | //! Path utilities shared across nono library and CLI. |
2 | 2 |
|
3 | | -use std::path::{Path, PathBuf}; |
| 3 | +use std::collections::HashSet; |
| 4 | +use std::path::{Component, Path, PathBuf}; |
4 | 5 |
|
5 | 6 | /// Canonicalize a path using an ancestor-walk fallback. |
6 | 7 | /// |
@@ -49,6 +50,106 @@ pub(crate) fn try_canonicalize_ancestor_walk(path: &Path) -> PathBuf { |
49 | 50 | path.to_path_buf() |
50 | 51 | } |
51 | 52 |
|
| 53 | +/// Maximum number of symlink dereferences [`collect_symlink_hops`] will |
| 54 | +/// follow, matching typical OS `ELOOP`/`MAXSYMLINKS` limits. Bounds the walk |
| 55 | +/// so a symlink cycle truncates instead of looping forever. |
| 56 | +const MAX_SYMLINK_HOPS: usize = 40; |
| 57 | + |
| 58 | +/// Enumerate every intermediate absolute path produced while resolving a |
| 59 | +/// multi-hop symlink chain, e.g. a symlinked leaf pointing through a |
| 60 | +/// symlinked directory component. |
| 61 | +/// |
| 62 | +/// Unlike [`try_canonicalize`], which resolves an entire chain in one |
| 63 | +/// `std::fs::canonicalize()` call and only ever sees the two endpoints, this |
| 64 | +/// walks the path component by component and records the literal path of |
| 65 | +/// every symlink it dereferences along the way (including symlinked |
| 66 | +/// directory components, not just a symlinked leaf). Callers use this to |
| 67 | +/// find hops that are neither the original path, the final resolved path, |
| 68 | +/// nor an ancestor of either -- and so would otherwise get no sandbox grant |
| 69 | +/// at all, causing `EPERM` when the kernel dereferences that literal |
| 70 | +/// component. |
| 71 | +/// |
| 72 | +/// Returns an empty vec when `path` contains no symlinks. Silently stops |
| 73 | +/// (returning whatever was collected so far) on a cycle or a chain deeper |
| 74 | +/// than [`MAX_SYMLINK_HOPS`], rather than erroring -- a cycle or excessively |
| 75 | +/// deep chain will fail its own `open()` at run time regardless. |
| 76 | +/// |
| 77 | +/// Callers that already hold a canonicalized `resolved` path (e.g. |
| 78 | +/// `FsCapability`) call this separately, against the live filesystem, at |
| 79 | +/// profile-generation time. If the on-disk symlink chain changes between |
| 80 | +/// that earlier canonicalization and this walk, the hop set and `resolved` |
| 81 | +/// can briefly disagree -- the same TOCTOU window `original`/`resolved` |
| 82 | +/// already have, just with a few more syscalls in it. |
| 83 | +pub fn collect_symlink_hops(path: &Path) -> Vec<PathBuf> { |
| 84 | + let mut hops = Vec::new(); |
| 85 | + let mut seen = HashSet::new(); |
| 86 | + let mut depth = 0usize; |
| 87 | + resolve_hops(path, &mut hops, &mut seen, &mut depth); |
| 88 | + hops |
| 89 | +} |
| 90 | + |
| 91 | +/// Resolve `path` component by component, recording the literal path of each |
| 92 | +/// symlink dereferenced into `hops`, and returning the fully resolved |
| 93 | +/// (as far as `depth` allows) absolute path. |
| 94 | +/// |
| 95 | +/// `path` need not be absolute on entry (a symlink target can be relative); |
| 96 | +/// relative components are resolved against `result` as they're pushed. |
| 97 | +fn resolve_hops( |
| 98 | + path: &Path, |
| 99 | + hops: &mut Vec<PathBuf>, |
| 100 | + seen: &mut HashSet<PathBuf>, |
| 101 | + depth: &mut usize, |
| 102 | +) -> PathBuf { |
| 103 | + let mut result = PathBuf::new(); |
| 104 | + |
| 105 | + for component in path.components() { |
| 106 | + match component { |
| 107 | + Component::Prefix(_) | Component::RootDir => { |
| 108 | + result.push(component.as_os_str()); |
| 109 | + } |
| 110 | + Component::CurDir => {} |
| 111 | + Component::ParentDir => { |
| 112 | + result.pop(); |
| 113 | + } |
| 114 | + Component::Normal(segment) => { |
| 115 | + result.push(segment); |
| 116 | + |
| 117 | + if *depth >= MAX_SYMLINK_HOPS { |
| 118 | + continue; |
| 119 | + } |
| 120 | + |
| 121 | + let Ok(metadata) = std::fs::symlink_metadata(&result) else { |
| 122 | + continue; |
| 123 | + }; |
| 124 | + if !metadata.file_type().is_symlink() { |
| 125 | + continue; |
| 126 | + } |
| 127 | + |
| 128 | + *depth += 1; |
| 129 | + if seen.insert(result.clone()) { |
| 130 | + hops.push(result.clone()); |
| 131 | + } |
| 132 | + |
| 133 | + let Ok(target) = std::fs::read_link(&result) else { |
| 134 | + continue; |
| 135 | + }; |
| 136 | + let target = if target.is_absolute() { |
| 137 | + target |
| 138 | + } else { |
| 139 | + result |
| 140 | + .parent() |
| 141 | + .map(|parent| parent.join(&target)) |
| 142 | + .unwrap_or(target) |
| 143 | + }; |
| 144 | + |
| 145 | + result = resolve_hops(&target, hops, seen, depth); |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + result |
| 151 | +} |
| 152 | + |
52 | 153 | #[cfg(test)] |
53 | 154 | mod tests { |
54 | 155 | use super::*; |
@@ -93,4 +194,103 @@ mod tests { |
93 | 194 | assert_eq!(result, real_file.canonicalize().expect("canonicalize")); |
94 | 195 | } |
95 | 196 | } |
| 197 | + |
| 198 | + #[cfg(unix)] |
| 199 | + #[test] |
| 200 | + fn collect_symlink_hops_empty_for_plain_path() { |
| 201 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 202 | + let canonical_dir = dir.path().canonicalize().expect("canonicalize"); |
| 203 | + let real_file = canonical_dir.join("real.txt"); |
| 204 | + fs::write(&real_file, "hello").expect("write file"); |
| 205 | + assert!(collect_symlink_hops(&real_file).is_empty()); |
| 206 | + } |
| 207 | + |
| 208 | + #[cfg(unix)] |
| 209 | + #[test] |
| 210 | + fn collect_symlink_hops_single_hop_leaf_symlink() { |
| 211 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 212 | + let canonical_dir = dir.path().canonicalize().expect("canonicalize"); |
| 213 | + let real_file = canonical_dir.join("real.txt"); |
| 214 | + fs::write(&real_file, "hello").expect("write file"); |
| 215 | + let link = canonical_dir.join("link.txt"); |
| 216 | + std::os::unix::fs::symlink(&real_file, &link).expect("symlink"); |
| 217 | + |
| 218 | + let hops = collect_symlink_hops(&link); |
| 219 | + assert_eq!(hops, vec![link]); |
| 220 | + } |
| 221 | + |
| 222 | + /// A symlinked leaf pointing through a symlinked directory component, |
| 223 | + /// e.g. |
| 224 | + /// `~/.gitconfig -> hosts/current/gitconfig -> hosts/mymac/gitconfig` |
| 225 | + /// where `hosts/current -> hosts/mymac` is itself a symlinked directory. |
| 226 | + #[cfg(unix)] |
| 227 | + #[test] |
| 228 | + fn collect_symlink_hops_multi_hop_through_symlinked_directory() { |
| 229 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 230 | + let canonical_dir = dir.path().canonicalize().expect("canonicalize"); |
| 231 | + |
| 232 | + let hosts = canonical_dir.join("hosts"); |
| 233 | + let mymac = hosts.join("mymac"); |
| 234 | + fs::create_dir_all(&mymac).expect("mkdir mymac"); |
| 235 | + let real_gitconfig = mymac.join("gitconfig"); |
| 236 | + fs::write(&real_gitconfig, "[user]\n").expect("write gitconfig"); |
| 237 | + |
| 238 | + let current = hosts.join("current"); |
| 239 | + std::os::unix::fs::symlink(&mymac, ¤t).expect("symlink dir"); |
| 240 | + |
| 241 | + let gitconfig_link = canonical_dir.join(".gitconfig"); |
| 242 | + std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link) |
| 243 | + .expect("symlink leaf"); |
| 244 | + |
| 245 | + let hops = collect_symlink_hops(&gitconfig_link); |
| 246 | + assert_eq!(hops, vec![gitconfig_link.clone(), current]); |
| 247 | + |
| 248 | + // The fully resolved target is unaffected -- std canonicalization |
| 249 | + // still collapses the whole chain in one shot. |
| 250 | + assert_eq!( |
| 251 | + try_canonicalize(&gitconfig_link), |
| 252 | + real_gitconfig.canonicalize().expect("canonicalize") |
| 253 | + ); |
| 254 | + } |
| 255 | + |
| 256 | + #[cfg(unix)] |
| 257 | + #[test] |
| 258 | + fn collect_symlink_hops_cycle_truncates_instead_of_looping() { |
| 259 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 260 | + let canonical_dir = dir.path().canonicalize().expect("canonicalize"); |
| 261 | + let a = canonical_dir.join("a"); |
| 262 | + let b = canonical_dir.join("b"); |
| 263 | + std::os::unix::fs::symlink(&b, &a).expect("symlink a->b"); |
| 264 | + std::os::unix::fs::symlink(&a, &b).expect("symlink b->a"); |
| 265 | + |
| 266 | + let hops = collect_symlink_hops(&a); |
| 267 | + // Must terminate (this test itself is the proof); depth-bounded, so |
| 268 | + // only ever sees the two distinct literal hop paths. |
| 269 | + assert!(hops.len() <= MAX_SYMLINK_HOPS); |
| 270 | + assert!(hops.contains(&a)); |
| 271 | + assert!(hops.contains(&b)); |
| 272 | + } |
| 273 | + |
| 274 | + #[cfg(unix)] |
| 275 | + #[test] |
| 276 | + fn collect_symlink_hops_nonexistent_final_target_keeps_existing_hops() { |
| 277 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 278 | + let canonical_dir = dir.path().canonicalize().expect("canonicalize"); |
| 279 | + |
| 280 | + let hosts = canonical_dir.join("hosts"); |
| 281 | + let mymac = hosts.join("mymac"); |
| 282 | + fs::create_dir_all(&mymac).expect("mkdir mymac"); |
| 283 | + // Note: no `gitconfig` file written under mymac -- final target |
| 284 | + // does not exist, but the directory symlink hop is still real. |
| 285 | + |
| 286 | + let current = hosts.join("current"); |
| 287 | + std::os::unix::fs::symlink(&mymac, ¤t).expect("symlink dir"); |
| 288 | + |
| 289 | + let gitconfig_link = canonical_dir.join(".gitconfig"); |
| 290 | + std::os::unix::fs::symlink(current.join("gitconfig"), &gitconfig_link) |
| 291 | + .expect("symlink leaf"); |
| 292 | + |
| 293 | + let hops = collect_symlink_hops(&gitconfig_link); |
| 294 | + assert_eq!(hops, vec![gitconfig_link, current]); |
| 295 | + } |
96 | 296 | } |
0 commit comments