use agent_workspace_contract::WORKSPACE_MAX_PATH_BYTES;
use anyhow::{Context, Result};
use std::path::{Component, Path, PathBuf};
pub(crate) struct WorkspacePathResolver<'a> {
root: &'a Path,
}
impl<'a> WorkspacePathResolver<'a> {
pub(crate) fn new(root: &'a Path) -> Self {
Self { root }
}
pub(crate) fn lexical(&self, path: &str) -> Result<PathBuf> {
let trimmed = path.trim();
anyhow::ensure!(
trimmed.len() <= WORKSPACE_MAX_PATH_BYTES,
"workspace path exceeds {} bytes",
WORKSPACE_MAX_PATH_BYTES
);
if trimmed.is_empty() || trimmed == "." {
return Ok(self.root.to_path_buf());
}
let relative = Path::new(trimmed);
anyhow::ensure!(!relative.is_absolute(), "workspace path must be relative");
anyhow::ensure!(
!relative.components().any(|component| matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)),
"workspace path must not contain parent/root components: {trimmed}"
);
Ok(normalize_path(&self.root.join(relative)))
}
pub(crate) async fn canonical_root(&self) -> Result<PathBuf> {
tokio::fs::canonicalize(self.root)
.await
.with_context(|| format!("failed to resolve workspace root {}", self.root.display()))
}
async fn reject_symlink_components(&self, candidate: &Path) -> Result<()> {
let relative = candidate
.strip_prefix(self.root)
.context("workspace path escaped root")?;
let mut current = self.root.to_path_buf();
for component in relative.components() {
current.push(component.as_os_str());
match tokio::fs::symlink_metadata(¤t).await {
Ok(metadata) => anyhow::ensure!(
!metadata.file_type().is_symlink(),
"workspace paths may not traverse symlinks: {}",
current.display()
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
Err(error) => return Err(error.into()),
}
}
Ok(())
}
pub(crate) async fn existing(&self, path: &str) -> Result<PathBuf> {
let candidate = self.lexical(path)?;
self.reject_symlink_components(&candidate).await?;
let root = self.canonical_root().await?;
let target = tokio::fs::canonicalize(&candidate)
.await
.with_context(|| format!("failed to resolve {}", candidate.display()))?;
anyhow::ensure!(
target.starts_with(root),
"workspace path resolves outside root"
);
Ok(target)
}
#[cfg(not(unix))]
pub(crate) async fn for_write(&self, path: &str) -> Result<PathBuf> {
tokio::fs::create_dir_all(self.root).await?;
let candidate = self.lexical(path)?;
self.reject_symlink_components(&candidate).await?;
let root = self.canonical_root().await?;
let mut ancestor = candidate.as_path();
while tokio::fs::symlink_metadata(ancestor).await.is_err() {
ancestor = ancestor
.parent()
.context("workspace write path has no existing ancestor")?;
}
let canonical_ancestor = tokio::fs::canonicalize(ancestor).await?;
anyhow::ensure!(
canonical_ancestor.starts_with(root),
"workspace write path resolves outside root"
);
Ok(candidate)
}
}
pub(crate) fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}