fs: extract provider operations into shared crate
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user