Re-validate a path before unlinking (remove_file/remove_dir). Unlike revalidate_path, this does NOT canonicalize the final component, allowing safe removal of symlinks that point outside project_root. The symlink entry itself must be within project_root, but its target can be anywhere.
(&self, path: &Path)
| 215 | /// allowing safe removal of symlinks that point outside project_root. |
| 216 | /// The symlink entry itself must be within project_root, but its target can be anywhere. |
| 217 | fn revalidate_unlink_path(&self, path: &Path) -> Result<()> { |
| 218 | let display_path = path.display().to_string(); |
| 219 | |
| 220 | // SECURITY: Reject absolute paths |
| 221 | if path.is_absolute() { |
| 222 | // If path is already absolute and under project_root, validate parent only |
| 223 | if !path.starts_with(&self.project_root) { |
| 224 | anyhow::bail!("Path is outside project root: {}", display_path); |
| 225 | } |
| 226 | // For absolute paths under project_root, validate the parent directory |
| 227 | if let Some(parent) = path.parent() { |
| 228 | let canonical_parent = self.canonicalize_uncached(parent).with_context(|| { |
| 229 | format!("Failed to canonicalize parent: {}", parent.display()) |
| 230 | })?; |
| 231 | |
| 232 | let canonical_root = self.get_canonical_project_root()?; |
| 233 | |
| 234 | if !canonical_parent.starts_with(&canonical_root) { |
| 235 | anyhow::bail!( |
| 236 | "Path parent resolves outside project root: {}", |
| 237 | display_path |
| 238 | ); |
| 239 | } |
| 240 | } |
| 241 | return Ok(()); |
| 242 | } |
| 243 | |
| 244 | // SECURITY: Reject paths with ParentDir components before resolution |
| 245 | for component in path.components() { |
| 246 | if matches!(component, Component::ParentDir) { |
| 247 | anyhow::bail!( |
| 248 | "Path contains parent directory (..) component: {}", |
| 249 | display_path |
| 250 | ); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // Validate that the parent directory (if any) is within project_root |
| 255 | // We canonicalize the parent but NOT the final component (which might be a symlink) |
| 256 | if let Some(parent) = path.parent() { |
| 257 | if parent.as_os_str().is_empty() { |
| 258 | // Path has no parent (e.g., just a filename), it's relative to project_root |
| 259 | return Ok(()); |
| 260 | } |
| 261 | |
| 262 | let parent_absolute = if parent.is_absolute() { |
| 263 | parent.to_path_buf() |
| 264 | } else { |
| 265 | self.project_root.join(parent) |
| 266 | }; |
| 267 | |
| 268 | // Only canonicalize if the parent exists; for cleanup operations the parent might not exist yet |
| 269 | if parent_absolute.exists() { |
| 270 | let canonical_parent = |
| 271 | self.canonicalize_uncached(&parent_absolute) |
| 272 | .with_context(|| { |
| 273 | format!( |
| 274 | "Failed to canonicalize parent: {}", |
no test coverage detected