//! 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 { 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 { 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, 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::(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")); } }