use crate::session_path::WORKSPACE_PREFIX;
use crate::traits::SessionFileSystem;
use serde_json::Value;
#[derive(Debug, Clone)]
pub struct PathIdentityExpectations {
pub expected_root: String,
pub forbidden_prefixes: Vec<String>,
pub allowed_mount_prefixes: Vec<String>,
}
impl PathIdentityExpectations {
pub fn for_store(store: &dyn SessionFileSystem) -> Self {
let root = store.display_root();
if root == WORKSPACE_PREFIX {
Self::vfs()
} else {
Self::host_backed(&root)
}
}
pub fn vfs() -> Self {
Self {
expected_root: WORKSPACE_PREFIX.to_string(),
forbidden_prefixes: vec![],
allowed_mount_prefixes: vec![],
}
}
pub fn host_backed(root: &str) -> Self {
Self {
expected_root: root.to_string(),
forbidden_prefixes: vec![WORKSPACE_PREFIX.to_string()],
allowed_mount_prefixes: vec![format!("{WORKSPACE_PREFIX}/roots/")],
}
}
fn is_allowed_path(&self, path: &str) -> bool {
self.allowed_mount_prefixes
.iter()
.any(|prefix| path.starts_with(prefix))
}
fn violates_forbidden_prefix(&self, path: &str) -> Option<String> {
if self.is_allowed_path(path) {
return None;
}
self.forbidden_prefixes
.iter()
.find(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/")))
.cloned()
}
}
pub fn looks_like_absolute_path(value: &str) -> bool {
value.starts_with('/')
&& !value.contains(' ')
&& value.len() > 1
&& !value.contains('<')
&& !value.contains('>')
&& !value.contains('`')
}
pub fn collect_absolute_paths(value: &Value, json_path: &str, out: &mut Vec<(String, String)>) {
match value {
Value::String(text) if looks_like_absolute_path(text) => {
out.push((json_path.to_string(), text.clone()));
}
Value::Array(items) => {
for (index, item) in items.iter().enumerate() {
collect_absolute_paths(item, &format!("{json_path}[{index}]"), out);
}
}
Value::Object(map) => {
for (key, item) in map {
let child_path = if json_path.is_empty() {
key.clone()
} else {
format!("{json_path}.{key}")
};
collect_absolute_paths(item, &child_path, out);
}
}
_ => {}
}
}
pub fn assert_no_forbidden_prefixes(
value: &Value,
expectations: &PathIdentityExpectations,
context: &str,
) {
let mut paths = Vec::new();
collect_absolute_paths(value, "", &mut paths);
for (json_path, path) in paths {
if let Some(prefix) = expectations.violates_forbidden_prefix(&path) {
panic!("{context}: forbidden path prefix `{prefix}` in `{path}` at `{json_path}`");
}
}
}
pub fn assert_paths_under_expected_root(
value: &Value,
expectations: &PathIdentityExpectations,
context: &str,
) {
let mut paths = Vec::new();
collect_absolute_paths(value, "", &mut paths);
let root = &expectations.expected_root;
for (json_path, path) in paths {
if expectations.is_allowed_path(&path) {
continue;
}
let ok = path == root.as_str() || path.starts_with(&format!("{root}/"));
assert!(
ok,
"{context}: path `{path}` at `{json_path}` is not under expected root `{root}`"
);
}
}
pub fn assert_model_visible_value(value: &Value, store: &dyn SessionFileSystem, context: &str) {
let expectations = PathIdentityExpectations::for_store(store);
assert_no_forbidden_prefixes(value, &expectations, context);
assert_paths_under_expected_root(value, &expectations, context);
}
pub fn assert_system_prompt(prompt: &str, expectations: &PathIdentityExpectations) {
assert!(
prompt.contains(&format!("Workspace root: `{}`", expectations.expected_root))
|| prompt.contains(&format!(
"Workspace root: `{}/`",
expectations.expected_root
)),
"system prompt must identify expected root `{}`; got:\n{prompt}",
expectations.expected_root
);
if expectations.expected_root != WORKSPACE_PREFIX {
for prefix in &expectations.forbidden_prefixes {
assert!(
!prompt.contains(&format!("`{prefix}` is also accepted")),
"host-backed system prompt must not advertise `{prefix}` alias; got:\n{prompt}"
);
}
}
}
pub fn assert_tool_result_paths_conform(
store: &dyn SessionFileSystem,
tool_name: &str,
response: &Value,
) {
assert_model_visible_value(response, store, tool_name);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn looks_like_absolute_path_rejects_prose() {
assert!(!looks_like_absolute_path(
"A leading '/' or '/workspace/' prefix is also accepted."
));
assert!(looks_like_absolute_path("/workspace/crates/server"));
assert!(looks_like_absolute_path("/tmp/repo/src/lib.rs"));
}
#[test]
fn collect_absolute_paths_walks_nested_values() {
let value = json!({
"path": "/repo/crates/server",
"entries": [{"path": "/repo/crates/server/main.rs"}],
"note": "not a path"
});
let mut paths = Vec::new();
collect_absolute_paths(&value, "", &mut paths);
assert_eq!(paths.len(), 2);
let collected: Vec<_> = paths.into_iter().map(|(_, path)| path).collect();
assert!(collected.contains(&"/repo/crates/server".to_string()));
assert!(collected.contains(&"/repo/crates/server/main.rs".to_string()));
}
#[test]
fn assert_no_forbidden_prefixes_allows_secondary_mounts() {
let expectations = PathIdentityExpectations::host_backed("/repo");
let value = json!({
"path": "/workspace/roots/backend/Cargo.toml"
});
assert_no_forbidden_prefixes(&value, &expectations, "secondary mount");
}
#[test]
#[should_panic(expected = "forbidden path prefix `/workspace`")]
fn assert_no_forbidden_prefixes_rejects_primary_alias() {
let expectations = PathIdentityExpectations::host_backed("/repo");
let value = json!({ "path": "/workspace/crates/server" });
assert_no_forbidden_prefixes(&value, &expectations, "read_file");
}
}