Skip to main content

soma_codemode/state/
path.rs

1use std::path::{Component, Path, PathBuf};
2
3use crate::ToolError;
4
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
6pub struct VirtualPath(String);
7
8impl VirtualPath {
9    pub fn parse(raw: &str) -> Result<Self, ToolError> {
10        let trimmed = raw.trim();
11        if trimmed.is_empty() || trimmed == "/" {
12            return Err(invalid_path(
13                "state path must name a file or directory inside the workspace",
14            ));
15        }
16        let normalized = normalize(trimmed, false)?;
17        Ok(Self(normalized))
18    }
19
20    pub fn parse_read_scope(raw: &str) -> Result<Self, ToolError> {
21        Ok(Self(normalize(raw, true)?))
22    }
23
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27}
28
29pub fn state_root() -> PathBuf {
30    crate::soma_home().join(".soma-state")
31}
32
33fn normalize(raw: &str, allow_root: bool) -> Result<String, ToolError> {
34    let trimmed = raw.trim();
35    if allow_root && (trimmed.is_empty() || trimmed == "." || trimmed == "/") {
36        return Ok(String::new());
37    }
38    let replaced = trimmed.replace('\\', "/");
39    if has_windows_drive_prefix(&replaced) {
40        return Err(path_traversal(raw));
41    }
42    let stripped = replaced.trim_start_matches('/');
43    let mut parts = Vec::new();
44    for component in Path::new(stripped).components() {
45        match component {
46            Component::Normal(value) => parts.push(value.to_string_lossy().to_string()),
47            Component::CurDir => {}
48            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
49                return Err(path_traversal(raw));
50            }
51        }
52    }
53    let value = parts.join("/");
54    if value.is_empty() {
55        return Err(invalid_path(
56            "state path must name a file or directory inside the workspace",
57        ));
58    }
59    reject_credential_like_path(&value)?;
60    Ok(value)
61}
62
63pub fn is_reserved_metadata_path(path: &str) -> bool {
64    path.split('/').any(|part| {
65        part.eq_ignore_ascii_case(".git")
66            || part.eq_ignore_ascii_case(".soma-state")
67            || part == concat!(".la", "bby-state")
68    })
69}
70
71fn reject_credential_like_path(path: &str) -> Result<(), ToolError> {
72    let lower = path.to_ascii_lowercase();
73    if is_reserved_metadata_path(&lower) {
74        return Err(ToolError::Sdk {
75            sdk_kind: "permission_denied".to_string(),
76            message: "state path is reserved runtime metadata".to_string(),
77        });
78    }
79    let denied = [
80        ".env",
81        ".ssh/",
82        ".aws/",
83        ".config/gcloud/",
84        ".netrc",
85        "id_rsa",
86        "id_ed25519",
87    ];
88    if denied
89        .iter()
90        .any(|needle| lower == *needle || lower.contains(needle))
91    {
92        return Err(ToolError::Sdk {
93            sdk_kind: "permission_denied".to_string(),
94            message: "state path is denied because it looks credential-related".to_string(),
95        });
96    }
97    Ok(())
98}
99
100fn has_windows_drive_prefix(path: &str) -> bool {
101    let bytes = path.as_bytes();
102    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
103}
104
105fn invalid_path(message: &str) -> ToolError {
106    ToolError::InvalidParam {
107        message: message.to_string(),
108        param: "path".to_string(),
109    }
110}
111
112fn path_traversal(raw: &str) -> ToolError {
113    ToolError::Sdk {
114        sdk_kind: "path_traversal".to_string(),
115        message: format!("state path `{raw}` escapes the workspace"),
116    }
117}