everruns_core/
path_identity.rs1use crate::session_path::WORKSPACE_PREFIX;
8use crate::traits::SessionFileSystem;
9use serde_json::Value;
10
11#[derive(Debug, Clone)]
13pub struct PathIdentityExpectations {
14 pub expected_root: String,
17 pub forbidden_prefixes: Vec<String>,
19 pub allowed_mount_prefixes: Vec<String>,
22}
23
24impl PathIdentityExpectations {
25 pub fn for_store(store: &dyn SessionFileSystem) -> Self {
27 let root = store.display_root();
28 if root == WORKSPACE_PREFIX {
29 Self::vfs()
30 } else {
31 Self::host_backed(&root)
32 }
33 }
34
35 pub fn vfs() -> Self {
37 Self {
38 expected_root: WORKSPACE_PREFIX.to_string(),
39 forbidden_prefixes: vec![],
40 allowed_mount_prefixes: vec![],
41 }
42 }
43
44 pub fn host_backed(root: &str) -> Self {
47 Self {
48 expected_root: root.to_string(),
49 forbidden_prefixes: vec![WORKSPACE_PREFIX.to_string()],
50 allowed_mount_prefixes: vec![format!("{WORKSPACE_PREFIX}/roots/")],
51 }
52 }
53
54 fn is_allowed_path(&self, path: &str) -> bool {
55 self.allowed_mount_prefixes
56 .iter()
57 .any(|prefix| path.starts_with(prefix))
58 }
59
60 fn violates_forbidden_prefix(&self, path: &str) -> Option<String> {
61 if self.is_allowed_path(path) {
62 return None;
63 }
64 self.forbidden_prefixes
65 .iter()
66 .find(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/")))
67 .cloned()
68 }
69}
70
71pub fn looks_like_absolute_path(value: &str) -> bool {
74 value.starts_with('/')
75 && !value.contains(' ')
76 && value.len() > 1
77 && !value.contains('<')
78 && !value.contains('>')
79 && !value.contains('`')
80}
81
82pub fn collect_absolute_paths(value: &Value, json_path: &str, out: &mut Vec<(String, String)>) {
84 match value {
85 Value::String(text) if looks_like_absolute_path(text) => {
86 out.push((json_path.to_string(), text.clone()));
87 }
88 Value::Array(items) => {
89 for (index, item) in items.iter().enumerate() {
90 collect_absolute_paths(item, &format!("{json_path}[{index}]"), out);
91 }
92 }
93 Value::Object(map) => {
94 for (key, item) in map {
95 let child_path = if json_path.is_empty() {
96 key.clone()
97 } else {
98 format!("{json_path}.{key}")
99 };
100 collect_absolute_paths(item, &child_path, out);
101 }
102 }
103 _ => {}
104 }
105}
106
107pub fn assert_no_forbidden_prefixes(
111 value: &Value,
112 expectations: &PathIdentityExpectations,
113 context: &str,
114) {
115 let mut paths = Vec::new();
116 collect_absolute_paths(value, "", &mut paths);
117 for (json_path, path) in paths {
118 if let Some(prefix) = expectations.violates_forbidden_prefix(&path) {
119 panic!("{context}: forbidden path prefix `{prefix}` in `{path}` at `{json_path}`");
120 }
121 }
122}
123
124pub fn assert_paths_under_expected_root(
127 value: &Value,
128 expectations: &PathIdentityExpectations,
129 context: &str,
130) {
131 let mut paths = Vec::new();
132 collect_absolute_paths(value, "", &mut paths);
133 let root = &expectations.expected_root;
134 for (json_path, path) in paths {
135 if expectations.is_allowed_path(&path) {
136 continue;
137 }
138 let ok = path == root.as_str() || path.starts_with(&format!("{root}/"));
139 assert!(
140 ok,
141 "{context}: path `{path}` at `{json_path}` is not under expected root `{root}`"
142 );
143 }
144}
145
146pub fn assert_model_visible_value(value: &Value, store: &dyn SessionFileSystem, context: &str) {
148 let expectations = PathIdentityExpectations::for_store(store);
149 assert_no_forbidden_prefixes(value, &expectations, context);
150 assert_paths_under_expected_root(value, &expectations, context);
151}
152
153pub fn assert_system_prompt(prompt: &str, expectations: &PathIdentityExpectations) {
156 assert!(
157 prompt.contains(&format!("Workspace root: `{}`", expectations.expected_root))
158 || prompt.contains(&format!(
159 "Workspace root: `{}/`",
160 expectations.expected_root
161 )),
162 "system prompt must identify expected root `{}`; got:\n{prompt}",
163 expectations.expected_root
164 );
165
166 if expectations.expected_root != WORKSPACE_PREFIX {
167 for prefix in &expectations.forbidden_prefixes {
168 assert!(
169 !prompt.contains(&format!("`{prefix}` is also accepted")),
170 "host-backed system prompt must not advertise `{prefix}` alias; got:\n{prompt}"
171 );
172 }
173 }
174}
175
176pub fn assert_tool_result_paths_conform(
178 store: &dyn SessionFileSystem,
179 tool_name: &str,
180 response: &Value,
181) {
182 assert_model_visible_value(response, store, tool_name);
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use serde_json::json;
189
190 #[test]
191 fn looks_like_absolute_path_rejects_prose() {
192 assert!(!looks_like_absolute_path(
193 "A leading '/' or '/workspace/' prefix is also accepted."
194 ));
195 assert!(looks_like_absolute_path("/workspace/crates/server"));
196 assert!(looks_like_absolute_path("/tmp/repo/src/lib.rs"));
197 }
198
199 #[test]
200 fn collect_absolute_paths_walks_nested_values() {
201 let value = json!({
202 "path": "/repo/crates/server",
203 "entries": [{"path": "/repo/crates/server/main.rs"}],
204 "note": "not a path"
205 });
206 let mut paths = Vec::new();
207 collect_absolute_paths(&value, "", &mut paths);
208 assert_eq!(paths.len(), 2);
209 let collected: Vec<_> = paths.into_iter().map(|(_, path)| path).collect();
210 assert!(collected.contains(&"/repo/crates/server".to_string()));
211 assert!(collected.contains(&"/repo/crates/server/main.rs".to_string()));
212 }
213
214 #[test]
215 fn assert_no_forbidden_prefixes_allows_secondary_mounts() {
216 let expectations = PathIdentityExpectations::host_backed("/repo");
217 let value = json!({
218 "path": "/workspace/roots/backend/Cargo.toml"
219 });
220 assert_no_forbidden_prefixes(&value, &expectations, "secondary mount");
221 }
222
223 #[test]
224 #[should_panic(expected = "forbidden path prefix `/workspace`")]
225 fn assert_no_forbidden_prefixes_rejects_primary_alias() {
226 let expectations = PathIdentityExpectations::host_backed("/repo");
227 let value = json!({ "path": "/workspace/crates/server" });
228 assert_no_forbidden_prefixes(&value, &expectations, "read_file");
229 }
230}