use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::error::DevToolError;
#[derive(Clone)]
pub struct Workspace {
root: PathBuf,
writable: bool,
allow_bash: bool,
bash_timeout: Duration,
max_output_bytes: usize,
read_tracker: Arc<Mutex<HashSet<PathBuf>>>,
inherit_env: bool,
env_allowlist: Vec<String>,
}
pub const DEFAULT_ENV_ALLOWLIST: &[&str] =
&["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TERM", "USER", "SHELL"];
impl Workspace {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let root = std::fs::canonicalize(&root).unwrap_or(root);
Self {
root,
writable: true,
allow_bash: true,
bash_timeout: Duration::from_secs(120),
max_output_bytes: 1_048_576,
read_tracker: Arc::new(Mutex::new(HashSet::new())),
inherit_env: false,
env_allowlist: DEFAULT_ENV_ALLOWLIST.iter().map(|k| (*k).to_string()).collect(),
}
}
pub fn read_only(root: impl Into<PathBuf>) -> Self {
let mut ws = Self::new(root);
ws.writable = false;
ws.allow_bash = false;
ws
}
pub fn writable(mut self, yes: bool) -> Self {
self.writable = yes;
self
}
pub fn allow_bash(mut self, yes: bool) -> Self {
self.allow_bash = yes;
self
}
pub fn bash_timeout(mut self, timeout: Duration) -> Self {
self.bash_timeout = timeout;
self
}
pub fn max_output_bytes(mut self, bytes: usize) -> Self {
self.max_output_bytes = bytes;
self
}
#[must_use]
pub fn inherit_env(mut self, yes: bool) -> Self {
self.inherit_env = yes;
self
}
#[must_use]
pub fn env_allowlist<I, S>(mut self, keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.env_allowlist = keys.into_iter().map(Into::into).collect();
self
}
pub fn inherits_env(&self) -> bool {
self.inherit_env
}
pub fn bash_env(&self) -> Vec<(String, String)> {
if self.inherit_env {
return Vec::new();
}
self.env_allowlist
.iter()
.filter_map(|key| std::env::var(key).ok().map(|value| (key.clone(), value)))
.collect()
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn is_writable(&self) -> bool {
self.writable
}
pub fn bash_allowed(&self) -> bool {
self.allow_bash
}
pub fn bash_timeout_value(&self) -> Duration {
self.bash_timeout
}
pub fn max_output(&self) -> usize {
self.max_output_bytes
}
pub fn resolve(&self, path: &str) -> Result<PathBuf, DevToolError> {
let requested = Path::new(path);
let joined = if requested.is_absolute() {
requested.to_path_buf()
} else {
self.root.join(requested)
};
let normalized = normalize(&joined);
if !normalized.starts_with(&self.root) {
return Err(DevToolError::PathEscape(path.to_string()));
}
self.reject_symlink_escape(&normalized, path)?;
Ok(normalized)
}
fn reject_symlink_escape(
&self,
normalized: &Path,
requested: &str,
) -> Result<(), DevToolError> {
let mut existing = normalized;
loop {
match std::fs::canonicalize(existing) {
Ok(canonical) => {
if !canonical.starts_with(&self.root) {
return Err(DevToolError::PathEscape(requested.to_string()));
}
return Ok(());
}
Err(_) => match existing.parent() {
Some(parent) if parent.starts_with(&self.root) => existing = parent,
_ => return Ok(()),
},
}
}
}
pub fn display(&self, path: &Path) -> String {
path.strip_prefix(&self.root).unwrap_or(path).display().to_string()
}
pub(crate) fn mark_read(&self, path: &Path) {
if let Ok(mut set) = self.read_tracker.lock() {
set.insert(path.to_path_buf());
}
}
pub(crate) fn was_read(&self, path: &Path) -> bool {
self.read_tracker.lock().map(|set| set.contains(path)).unwrap_or(false)
}
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_escape() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::new(dir.path());
assert!(ws.resolve("../etc/passwd").is_err());
assert!(ws.resolve("ok/file.rs").is_ok());
}
#[test]
fn read_tracking() {
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::new(dir.path());
let p = ws.resolve("a.txt").unwrap();
assert!(!ws.was_read(&p));
ws.mark_read(&p);
assert!(ws.was_read(&p));
}
}