use rmcp::model::ResourceContents;
use std::path::Path;
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
#[derive(Debug)]
pub(super) enum FileResourceError {
NotAFilePath,
NotFound(String),
TooLarge(String, u64),
NotUtf8(String),
Io(String, std::io::Error),
OutsideJail(String),
}
impl std::fmt::Display for FileResourceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotAFilePath => write!(f, "Not a file path"),
Self::NotFound(p) => write!(f, "File not found: {p}"),
Self::TooLarge(p, sz) => write!(
f,
"File too large for resource read ({sz} bytes, limit {MAX_FILE_BYTES}): {p}",
),
Self::NotUtf8(p) => write!(
f,
"File is not valid UTF-8 (binary?): {p}. Use ctx_read for binary-safe reads."
),
Self::Io(p, e) => write!(f, "I/O error reading {p}: {e}"),
Self::OutsideJail(p) => write!(
f,
"Path outside project root: {p}. \
Add the parent directory to extra_roots in config.toml if needed."
),
}
}
}
pub(super) fn read_file_resource(
uri: &str,
project_root: Option<&str>,
extra_roots: &[String],
) -> Result<Vec<ResourceContents>, FileResourceError> {
let path_str = resolve_path(uri)?;
let path = Path::new(&path_str);
if let Some(root) = project_root {
validate_jail(path, root, extra_roots)?;
}
if !path.is_file() {
return Err(FileResourceError::NotFound(path_str));
}
let meta = std::fs::metadata(path).map_err(|e| FileResourceError::Io(path_str.clone(), e))?;
if meta.len() > MAX_FILE_BYTES {
return Err(FileResourceError::TooLarge(path_str, meta.len()));
}
let content = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::InvalidData {
FileResourceError::NotUtf8(path_str.clone())
} else {
FileResourceError::Io(path_str.clone(), e)
}
})?;
Ok(vec![ResourceContents::text(content, uri)])
}
fn resolve_path(uri: &str) -> Result<String, FileResourceError> {
if uri.starts_with("file://") {
super::roots::uri_to_path(uri).ok_or(FileResourceError::NotAFilePath)
} else if is_absolute_path(uri) {
Ok(uri.to_string())
} else {
Err(FileResourceError::NotAFilePath)
}
}
fn is_absolute_path(s: &str) -> bool {
s.starts_with('/')
|| (cfg!(windows)
&& s.len() > 2
&& s.as_bytes()[0].is_ascii_alphabetic()
&& s.as_bytes()[1] == b':')
}
fn validate_jail(
path: &Path,
project_root: &str,
extra_roots: &[String],
) -> Result<(), FileResourceError> {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let root_canon = Path::new(project_root)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(project_root));
if canon.starts_with(&root_canon) {
return Ok(());
}
for extra in extra_roots {
if let Ok(extra_canon) = Path::new(extra.as_str()).canonicalize() {
if canon.starts_with(&extra_canon) {
return Ok(());
}
}
}
Err(FileResourceError::OutsideJail(
path.to_string_lossy().to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn path_to_file_uri(p: &std::path::Path) -> String {
let s = p.to_string_lossy().replace('\\', "/");
if s.starts_with('/') {
format!("file://{s}")
} else {
format!("file:///{s}")
}
}
#[cfg(unix)]
#[test]
fn resolve_file_uri() {
let path = resolve_path("file:///tmp/test.txt").unwrap();
assert_eq!(path, "/tmp/test.txt");
}
#[cfg(unix)]
#[test]
fn resolve_raw_absolute_path() {
let path = resolve_path("/home/user/file.rs").unwrap();
assert_eq!(path, "/home/user/file.rs");
}
#[test]
fn resolve_relative_path_returns_not_a_file() {
assert!(matches!(
resolve_path("relative/file.rs"),
Err(FileResourceError::NotAFilePath)
));
}
#[test]
fn resolve_lean_ctx_uri_returns_not_a_file() {
assert!(matches!(
resolve_path("lean-ctx://context/summary"),
Err(FileResourceError::NotAFilePath)
));
}
#[test]
fn read_nonexistent_file() {
let dir = tempfile::tempdir().unwrap();
let nonexistent = dir.path().join("does_not_exist.txt");
let uri = path_to_file_uri(&nonexistent);
let result = read_file_resource(&uri, None, &[]);
assert!(matches!(result, Err(FileResourceError::NotFound(_))));
}
#[test]
fn read_existing_file() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "hello world").unwrap();
let uri = path_to_file_uri(tmp.path());
let result = read_file_resource(&uri, None, &[]).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn read_file_outside_jail() {
let jail = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
std::fs::write(outside.path(), "secret").unwrap();
let uri = path_to_file_uri(outside.path());
let result = read_file_resource(&uri, Some(&jail.path().to_string_lossy()), &[]);
assert!(matches!(result, Err(FileResourceError::OutsideJail(_))));
}
#[test]
fn read_file_inside_jail() {
let jail = tempfile::tempdir().unwrap();
let file = jail.path().join("test.txt");
std::fs::write(&file, "allowed").unwrap();
let uri = path_to_file_uri(&file);
let result = read_file_resource(&uri, Some(&jail.path().to_string_lossy()), &[]);
assert!(result.is_ok());
}
#[test]
fn non_file_uri_is_not_a_file_path() {
assert!(matches!(
read_file_resource("https://example.com", None, &[]),
Err(FileResourceError::NotAFilePath)
));
}
}