soma_codemode/
path_safety.rs1use 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(¤t)?;
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(¤t)?;
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(¤t)?;
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}