Skip to main content

soma_codemode/
path_safety.rs

1use std::path::{Component, Path, PathBuf};
2
3use crate::ToolError;
4
5pub fn reject_path_traversal(rel_path: &str) -> Result<(), ToolError> {
6    let normalized = rel_path.replace('\\', "/");
7    for component in Path::new(&normalized).components() {
8        if matches!(
9            component,
10            Component::ParentDir | Component::RootDir | Component::Prefix(_)
11        ) {
12            return Err(path_error(format!(
13                "path traversal rejected: `{rel_path}` must be relative"
14            )));
15        }
16    }
17    for denied in [".git", ".soma-state", legacy_state_dir()] {
18        if normalized.split('/').any(|part| part == denied) {
19            return Err(path_error(format!(
20                "path `{rel_path}` targets reserved state"
21            )));
22        }
23    }
24    Ok(())
25}
26
27fn legacy_state_dir() -> &'static str {
28    concat!(".la", "bby-state")
29}
30
31pub fn rel_to_unix_string(path: &Path) -> String {
32    path.components()
33        .filter_map(|component| match component {
34            Component::Normal(value) => Some(value.to_string_lossy().to_string()),
35            _ => None,
36        })
37        .collect::<Vec<_>>()
38        .join("/")
39}
40
41pub fn reject_existing_symlink_ancestors(
42    write_root: &Path,
43    target: &Path,
44) -> Result<(), ToolError> {
45    let root = normalize_lexical(write_root);
46    let target = normalize_lexical(target);
47    if !target.starts_with(&root) {
48        return Err(path_error(format!(
49            "target path `{}` escapes write root `{}`",
50            target.display(),
51            root.display()
52        )));
53    }
54    let mut current = root.clone();
55    reject_existing_symlink(&current)?;
56    let relative = target.strip_prefix(&root).map_err(|_| {
57        path_error(format!(
58            "target path `{}` escapes write root `{}`",
59            target.display(),
60            root.display()
61        ))
62    })?;
63    for component in relative.components() {
64        current.push(component.as_os_str());
65        reject_existing_symlink(&current)?;
66    }
67    Ok(())
68}
69
70pub fn reject_existing_symlinks_in_path(path: &Path) -> Result<(), ToolError> {
71    let path = normalize_lexical(path);
72    let mut current = PathBuf::new();
73    for component in path.components() {
74        current.push(component.as_os_str());
75        reject_existing_symlink(&current)?;
76    }
77    Ok(())
78}
79
80fn reject_existing_symlink(path: &Path) -> Result<(), ToolError> {
81    match std::fs::symlink_metadata(path) {
82        Ok(metadata) if metadata.file_type().is_symlink() => Err(symlink_error(format!(
83            "refusing to operate through symlink `{}`",
84            path.display()
85        ))),
86        Ok(_) => Ok(()),
87        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
88        Err(error) => Err(ToolError::internal_message(format!(
89            "lstat failed for `{}`: {error}",
90            path.display()
91        ))),
92    }
93}
94
95fn normalize_lexical(path: &Path) -> PathBuf {
96    let mut out = PathBuf::new();
97    for component in path.components() {
98        match component {
99            Component::CurDir => {}
100            other => out.push(other.as_os_str()),
101        }
102    }
103    out
104}
105
106fn path_error(message: String) -> ToolError {
107    ToolError::Sdk {
108        sdk_kind: "path_traversal".into(),
109        message,
110    }
111}
112
113fn symlink_error(message: String) -> ToolError {
114    ToolError::Sdk {
115        sdk_kind: "symlink_rejected".into(),
116        message,
117    }
118}