use std::hash::{Hash, Hasher};
use std::path::{Component, Path, PathBuf};
pub const WORKSPACE_DIR_ENV: &str = "POLYCHROME_WORKSPACE_DIR";
const DEFAULT_WORKSPACE_DIR: &str = "/workspace";
pub const SANDBOX_MODE_ENV: &str = "POLYCHROME_SANDBOX_MODE";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxMode {
ReadOnly,
#[default]
WorkspaceWrite,
DangerFullAccess,
}
impl SandboxMode {
#[must_use]
pub fn from_env() -> Self {
match std::env::var(SANDBOX_MODE_ENV).ok().as_deref() {
Some("read-only") => Self::ReadOnly,
Some("danger-full-access") => Self::DangerFullAccess,
_ => Self::WorkspaceWrite,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
}
}
}
#[must_use]
pub fn current_sandbox_mode() -> String {
SandboxMode::from_env().as_str().to_owned()
}
#[must_use]
pub fn root() -> PathBuf {
std::env::var(WORKSPACE_DIR_ENV)
.ok()
.filter(|s| !s.is_empty())
.map_or_else(|| PathBuf::from(DEFAULT_WORKSPACE_DIR), PathBuf::from)
}
pub fn resolve(root: &Path, rel: &str) -> Result<PathBuf, String> {
let candidate = Path::new(rel);
let mut out = root.to_path_buf();
for component in candidate.components() {
match component {
Component::Normal(part) => out.push(part),
Component::CurDir => {}
Component::ParentDir => {
return Err(format!(
"path `{rel}` escapes the workspace (`..` is not allowed)"
));
}
Component::RootDir | Component::Prefix(_) => {
return Err(format!(
"path `{rel}` must be relative to the workspace (no leading `/`)"
));
}
}
}
Ok(out)
}
pub(super) const WORKER_SUBDIR_PREFIX: &str = ".worker-";
#[must_use]
pub fn worker_subdir(call_id: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
call_id.hash(&mut hasher);
let hash = hasher.finish();
let mut sanitized = String::with_capacity(call_id.len());
for ch in call_id.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
sanitized.push(ch);
} else {
sanitized.push('_');
}
}
let sanitized = if sanitized.is_empty() || sanitized.chars().all(|c| c == '.') {
"call".to_owned()
} else {
sanitized
};
format!("{WORKER_SUBDIR_PREFIX}{sanitized}-{hash:08x}")
}
#[must_use]
pub fn worker_root(conversation_root: &Path, call_id: &str) -> PathBuf {
conversation_root.join(worker_subdir(call_id))
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn resolve_keeps_paths_inside_root() {
let root = Path::new("/workspace");
assert_eq!(
resolve(root, "src/main.rs").unwrap(),
Path::new("/workspace/src/main.rs")
);
assert_eq!(
resolve(root, "./a/./b").unwrap(),
Path::new("/workspace/a/b")
);
}
#[test]
fn resolve_rejects_traversal_and_absolute() {
let root = Path::new("/workspace");
assert!(resolve(root, "../etc/passwd").is_err());
assert!(resolve(root, "a/../../b").is_err());
assert!(resolve(root, "/etc/passwd").is_err());
}
#[test]
fn sandbox_mode_default_is_workspace_write() {
assert_eq!(SandboxMode::default(), SandboxMode::WorkspaceWrite);
}
#[test]
fn worker_subdir_sanitizes_traversal_ish_ids() {
for id in ["../../etc", "/abs", "", ".", ".."] {
let dir = worker_subdir(id);
assert!(dir.starts_with(WORKER_SUBDIR_PREFIX), "{id:?} -> {dir}");
assert_ne!(dir, "", "{id:?}");
assert_ne!(dir, ".", "{id:?}");
assert_ne!(dir, "..", "{id:?}");
assert!(
dir.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
"{id:?} -> {dir}"
);
}
}
#[test]
fn worker_subdir_disambiguates_ids_that_sanitize_identically() {
let a = worker_subdir("a/b");
let b = worker_subdir("a_b");
assert_ne!(a, b, "distinct raw ids must never collapse into one dir");
}
#[test]
fn worker_subdir_is_deterministic_for_the_same_id() {
assert_eq!(worker_subdir("call-123"), worker_subdir("call-123"));
}
#[test]
fn worker_root_resolve_stays_inside_both_roots() {
let conversation_root = Path::new("/workspace");
for id in ["call-a", "../../etc", "", ".."] {
let root = worker_root(conversation_root, id);
assert!(
root.starts_with(conversation_root),
"worker root for {id:?} must stay inside the conversation root: {root:?}"
);
let resolved = resolve(&root, "src/main.rs").unwrap();
assert!(resolved.starts_with(&root));
assert!(resolved.starts_with(conversation_root));
assert!(resolve(&root, "../../etc/passwd").is_err());
assert!(resolve(&root, "/etc/passwd").is_err());
}
}
}