283 lines
8.3 KiB
Rust
283 lines
8.3 KiB
Rust
//! Foundational filesystem operation contracts and provider-side search.
|
|
//!
|
|
//! This crate deliberately has no dependency on Workdir identity, Runtime
|
|
//! transport, or LLM Tool implementations. Paths are logical and root-relative;
|
|
//! providers supply host roots and access policy.
|
|
|
|
mod glob;
|
|
mod local;
|
|
mod operation;
|
|
mod search;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use thiserror::Error;
|
|
|
|
pub use glob::run_glob;
|
|
pub use local::{run_edit, run_list, run_read, run_stat, run_write};
|
|
pub use operation::*;
|
|
pub use search::run_grep;
|
|
|
|
/// Provider-owned access policy used by local filesystem operations.
|
|
pub trait FsAccessPolicy: Send + Sync {
|
|
fn is_readable(&self, path: &Path) -> bool;
|
|
fn is_writable(&self, path: &Path) -> bool;
|
|
}
|
|
|
|
/// First symlink encountered while resolving a provider path.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SymlinkInfo {
|
|
pub link_path: PathBuf,
|
|
pub target_path: PathBuf,
|
|
pub resolved_path: PathBuf,
|
|
pub target_exists: bool,
|
|
}
|
|
|
|
pub fn first_symlink(path: &Path) -> Option<SymlinkInfo> {
|
|
if !path.is_absolute() {
|
|
return None;
|
|
}
|
|
let mut current = PathBuf::new();
|
|
let mut components = path.components().peekable();
|
|
while let Some(component) = components.next() {
|
|
current.push(component.as_os_str());
|
|
let metadata = std::fs::symlink_metadata(¤t).ok()?;
|
|
if !metadata.file_type().is_symlink() {
|
|
continue;
|
|
}
|
|
let raw_target = std::fs::read_link(¤t).ok()?;
|
|
let target_path = if raw_target.is_absolute() {
|
|
raw_target
|
|
} else {
|
|
current
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new("/"))
|
|
.join(raw_target)
|
|
};
|
|
let target_exists = target_path.exists();
|
|
let mut resolved_path = target_path
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| target_path.clone());
|
|
for remaining in components {
|
|
resolved_path.push(remaining.as_os_str());
|
|
}
|
|
return Some(SymlinkInfo {
|
|
link_path: current,
|
|
target_path,
|
|
resolved_path,
|
|
target_exists,
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn direct_symlink(path: &Path) -> Option<SymlinkInfo> {
|
|
let metadata = std::fs::symlink_metadata(path).ok()?;
|
|
metadata
|
|
.file_type()
|
|
.is_symlink()
|
|
.then(|| first_symlink(path))
|
|
.flatten()
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum FsError {
|
|
#[error("invalid logical filesystem path: {0}")]
|
|
InvalidPath(String),
|
|
#[error("operation requires an absolute provider path, got {0}")]
|
|
RelativePath(PathBuf),
|
|
#[error("path is outside readable provider scope: {0}")]
|
|
OutOfScope(PathBuf),
|
|
#[error("path not found: {0}")]
|
|
NotFound(PathBuf),
|
|
#[error("broken symbolic link {link}: {target}")]
|
|
BrokenSymlink {
|
|
path: PathBuf,
|
|
link: PathBuf,
|
|
target: PathBuf,
|
|
},
|
|
#[error("symbolic-link target is outside {required_permission} scope: {path} -> {target}")]
|
|
SymlinkOutOfScope {
|
|
path: PathBuf,
|
|
target: PathBuf,
|
|
required_permission: &'static str,
|
|
},
|
|
#[error("symbolic-link directories are not traversed by {tool}: {path} -> {target}")]
|
|
SymlinkDirectoryNotTraversed {
|
|
tool: &'static str,
|
|
path: PathBuf,
|
|
target: PathBuf,
|
|
},
|
|
#[error("path is read-only: {0}")]
|
|
ReadOnly(PathBuf),
|
|
#[error("path is a directory: {0}")]
|
|
IsDirectory(PathBuf),
|
|
#[error("path is not a directory: {0}")]
|
|
NotDirectory(PathBuf),
|
|
#[error("symbolic-link target is a directory: {path} -> {target}")]
|
|
SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf },
|
|
#[error("filesystem content conflict: {0}")]
|
|
Conflict(String),
|
|
#[error("invalid glob: {0}")]
|
|
InvalidGlob(String),
|
|
#[error("invalid regular expression: {0}")]
|
|
InvalidRegex(String),
|
|
#[error("invalid filesystem operation argument: {0}")]
|
|
InvalidArgument(String),
|
|
#[error("filesystem operation failed for {path}: {source}")]
|
|
Io {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: std::io::Error,
|
|
},
|
|
}
|
|
|
|
impl FsError {
|
|
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
|
Self::Io {
|
|
path: path.into(),
|
|
source,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
struct RootAccess(PathBuf);
|
|
|
|
impl FsAccessPolicy for RootAccess {
|
|
fn is_readable(&self, path: &Path) -> bool {
|
|
path.starts_with(&self.0)
|
|
}
|
|
|
|
fn is_writable(&self, path: &Path) -> bool {
|
|
path.starts_with(&self.0)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
|
assert!(FsPath::new("src/lib.rs").is_ok());
|
|
assert!(FsPath::new("/tmp/file").is_err());
|
|
assert!(FsPath::new("../file").is_err());
|
|
assert!(FsPath::new("src\\lib.rs").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn deserialization_cannot_bypass_logical_path_validation() {
|
|
assert!(serde_json::from_str::<FsPath>(r#""../secret""#).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn direct_operations_cover_stat_read_write_edit_and_list() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let root = temp.path().canonicalize().unwrap();
|
|
let access = RootAccess(root.clone());
|
|
let path = FsPath::new("notes/item.txt").unwrap();
|
|
|
|
let written = run_write(
|
|
&root,
|
|
WriteRequest {
|
|
path: path.clone(),
|
|
content: b"alpha\nbeta\n".to_vec(),
|
|
expected_hash: None,
|
|
},
|
|
&access,
|
|
)
|
|
.unwrap();
|
|
assert!(written.created);
|
|
|
|
let read = run_read(
|
|
&root,
|
|
ReadRequest {
|
|
path: path.clone(),
|
|
offset: 0,
|
|
limit: 10,
|
|
max_bytes: 1024,
|
|
},
|
|
&access,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(read.bytes, b"alpha\nbeta\n");
|
|
|
|
let edited = run_edit(
|
|
&root,
|
|
EditRequest {
|
|
path: path.clone(),
|
|
old_string: "beta".to_string(),
|
|
new_string: "gamma".to_string(),
|
|
replace_all: false,
|
|
expected_hash: read.content_hash,
|
|
},
|
|
&access,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(edited.replacements, 1);
|
|
|
|
let stat = run_stat(&root, StatRequest { path: path.clone() }, &access).unwrap();
|
|
assert_eq!(stat.kind, EntryKind::File);
|
|
|
|
let listed = run_list(
|
|
&root,
|
|
ListRequest {
|
|
path: FsPath::new("notes").unwrap(),
|
|
limit: 10,
|
|
},
|
|
&access,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(listed.total_entries, 1);
|
|
assert_eq!(listed.entries[0].path, path);
|
|
}
|
|
|
|
#[test]
|
|
fn glob_and_grep_execute_as_bounded_provider_side_operations() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(temp.path().join("src")).unwrap();
|
|
std::fs::write(temp.path().join("src/a.rs"), "needle one\n").unwrap();
|
|
std::fs::write(temp.path().join("src/b.rs"), "needle two\n").unwrap();
|
|
std::fs::write(temp.path().join("src/c.txt"), "needle hidden\n").unwrap();
|
|
let root = temp.path().canonicalize().unwrap();
|
|
let readable = RootAccess(root.clone());
|
|
|
|
let glob = run_glob(
|
|
&root,
|
|
&root,
|
|
GlobRequest {
|
|
pattern: "**/*.rs".to_string(),
|
|
path: FsPath::root(),
|
|
limit: 1,
|
|
},
|
|
&readable,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(glob.paths, vec![FsPath::new("src/a.rs").unwrap()]);
|
|
assert!(glob.truncated);
|
|
|
|
let grep = run_grep(
|
|
&root,
|
|
root.clone(),
|
|
GrepRequest {
|
|
pattern: "needle".to_string(),
|
|
path: FsPath::root(),
|
|
glob: Some("**/*.rs".to_string()),
|
|
output_mode: GrepOutputMode::Count,
|
|
case_insensitive: false,
|
|
before_context: 0,
|
|
after_context: 0,
|
|
multiline: false,
|
|
file_type: None,
|
|
limit: 10,
|
|
offset: 0,
|
|
},
|
|
&readable,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(grep.match_count, 2);
|
|
assert_eq!(grep.matched_files, 2);
|
|
assert!(!grep.output.contains("c.txt"));
|
|
}
|
|
}
|