Files
buzz/crates/sprout-dev-mcp/src/paths.rs
tlongwell-block 92c859043c dev-mcp: add view_image tool (#602)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
2026-05-15 16:21:16 -04:00

65 lines
2.4 KiB
Rust

//! Path resolution shared across dev-mcp tools.
//!
//! `resolve_within` canonicalises a user-supplied path against a workspace
//! root and rejects any result that escapes the root (e.g. via `..`, absolute
//! paths, or symlinks). All tools that touch the filesystem must funnel
//! through this helper so the escape policy stays consistent.
use std::path::{Path, PathBuf};
/// Resolve `path` (absolute or relative) against `root` and require the
/// canonicalised result to live under the canonicalised `root`. Returns an
/// error string suitable for `ErrorData::invalid_params` on rejection.
pub(crate) fn resolve_within(root: &Path, path: &str) -> Result<PathBuf, String> {
let raw = Path::new(path);
let candidate: PathBuf = if raw.is_absolute() {
raw.to_path_buf()
} else {
root.join(raw)
};
let root_canon = std::fs::canonicalize(root)
.map_err(|e| format!("workdir not accessible: {} ({e})", root.display()))?;
let resolved = std::fs::canonicalize(&candidate)
.map_err(|e| format!("path not accessible: {} ({e})", candidate.display()))?;
if !resolved.starts_with(&root_canon) {
return Err(format!(
"path escapes workspace: {} not within {}",
resolved.display(),
root_canon.display()
));
}
Ok(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn resolve_within_rejects_escape() {
let dir = tempdir().expect("tempdir");
let inside = dir.path().join("file.txt");
fs::write(&inside, b"x").expect("write");
// Symlink targeting outside the dir should be rejected.
#[cfg(unix)]
{
let outside = std::env::temp_dir().join("sprout-mcp-paths-escape-target");
let _ = fs::remove_file(&outside);
fs::write(&outside, b"y").expect("write outside");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&outside, &link).expect("symlink");
let err = resolve_within(dir.path(), "link.txt").unwrap_err();
assert!(err.contains("escapes workspace"), "got: {err}");
let _ = fs::remove_file(&outside);
}
// Resolves a normal path inside.
let p = resolve_within(dir.path(), "file.txt").expect("resolve");
assert!(p.ends_with("file.txt"));
}
}