soma_codemode/state/
path.rs1use 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}