diff --git a/Cargo.lock b/Cargo.lock index 3a7b90bd..abd0d015 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1285,6 +1285,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-operation" +version = "0.1.0" +dependencies = [ + "globset", + "grep-matcher", + "grep-regex", + "grep-searcher", + "ignore", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "fs4" version = "0.13.1" @@ -4494,6 +4510,7 @@ version = "0.1.0" dependencies = [ "async-trait", "filetime", + "fs-operation", "html5ever", "llm-engine", "manifest", @@ -5924,11 +5941,7 @@ name = "workdir" version = "0.1.0" dependencies = [ "async-trait", - "globset", - "grep-matcher", - "grep-regex", - "grep-searcher", - "ignore", + "fs-operation", "manifest", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index f816a5fd..a30071dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/session-analytics", "crates/lint-common", "crates/tools", + "crates/fs-operation", "crates/workdir", "crates/tui", "crates/memory", @@ -42,6 +43,7 @@ default-members = [ "crates/session-analytics", "crates/lint-common", "crates/tools", + "crates/fs-operation", "crates/workdir", "crates/tui", "crates/memory", @@ -75,6 +77,7 @@ session-analytics = { path = "crates/session-analytics" } session-store = { path = "crates/session-store" } secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } +fs-operation = { path = "crates/fs-operation" } workdir = { path = "crates/workdir" } tui = { path = "crates/tui" } yoi-workspace-server = { path = "crates/workspace-server" } diff --git a/crates/fs-operation/Cargo.toml b/crates/fs-operation/Cargo.toml new file mode 100644 index 00000000..51181b75 --- /dev/null +++ b/crates/fs-operation/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "fs-operation" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +globset = "0.4.18" +grep-matcher = "0.1.8" +grep-regex = "0.1.14" +grep-searcher = "0.1.16" +ignore = "0.4.25" +serde = { workspace = true, features = ["derive"] } +sha2 = "0.10.9" +tempfile.workspace = true +thiserror.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/fs-operation/src/glob.rs b/crates/fs-operation/src/glob.rs new file mode 100644 index 00000000..c4f3ed65 --- /dev/null +++ b/crates/fs-operation/src/glob.rs @@ -0,0 +1,56 @@ +use std::path::{Path, PathBuf}; + +use globset::Glob; +use ignore::WalkBuilder; + +use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink}; + +/// Execute a bounded glob entirely inside the provider process. +pub fn run_glob( + root: &Path, + base: &Path, + request: GlobRequest, + access: &dyn FsAccessPolicy, +) -> Result { + if !root.is_absolute() { + return Err(FsError::RelativePath(root.to_path_buf())); + } + if !access.is_readable(base) { + return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str()))); + } + if let Some(info) = direct_symlink(base) + && info.target_exists + && info.resolved_path.is_dir() + { + return Err(FsError::SymlinkDirectoryNotTraversed { + tool: "Glob", + path: PathBuf::from(request.path.as_str()), + target: PathBuf::from(""), + }); + } + let matcher = Glob::new(&request.pattern) + .map_err(|error| FsError::InvalidGlob(error.to_string()))? + .compile_matcher(); + let mut matches = Vec::new(); + for entry in WalkBuilder::new(base).hidden(false).build().flatten() { + let path = entry.path(); + if !path.is_file() || !access.is_readable(path) { + continue; + } + let relative = path.strip_prefix(base).unwrap_or(path); + if !matcher.is_match(relative) { + continue; + } + let logical = path.strip_prefix(root).map_err(|_| { + FsError::InvalidArgument("provider returned a path outside its root".to_string()) + })?; + matches.push(FsPath::new(logical.to_string_lossy())?); + } + matches.sort_by(|left, right| left.as_str().cmp(right.as_str())); + let truncated = matches.len() > request.limit; + matches.truncate(request.limit); + Ok(GlobResult { + paths: matches, + truncated, + }) +} diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs new file mode 100644 index 00000000..f63a1ce3 --- /dev/null +++ b/crates/fs-operation/src/lib.rs @@ -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 { + 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")); + } +} diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs new file mode 100644 index 00000000..b554d8bb --- /dev/null +++ b/crates/fs-operation/src/local.rs @@ -0,0 +1,330 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::{ + ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError, FsPath, ListEntry, + ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, + WriteResult, direct_symlink, +}; + +/// Execute stat while keeping host paths inside the provider boundary. +pub fn run_stat( + root: &Path, + request: StatRequest, + access: &dyn FsAccessPolicy, +) -> Result { + let logical = request.path; + let path = resolve(root, &logical)?; + if !access.is_readable(&path) { + return Err(FsError::OutOfScope(PathBuf::from(logical.as_str()))); + } + let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?; + let kind = if metadata.file_type().is_symlink() { + EntryKind::Symlink + } else if metadata.is_file() { + EntryKind::File + } else if metadata.is_dir() { + EntryKind::Directory + } else { + EntryKind::Other + }; + Ok(StatResult { + path: logical, + kind, + size: metadata.len(), + }) +} + +pub fn run_read( + root: &Path, + request: ReadRequest, + access: &dyn FsAccessPolicy, +) -> Result { + let logical = request.path; + let path = resolve(root, &logical)?; + let path = require_access(&path, &logical, access, false)?; + let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; + if metadata.is_dir() { + return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); + } + let bytes = fs::read(&path).map_err(|error| map_io(&logical, error))?; + let content_hash = hash_bytes(&bytes); + let lines = bytes + .split_inclusive(|byte| *byte == b'\n') + .collect::>(); + let total_lines = lines.len(); + if request.offset > total_lines && request.offset != 0 { + return Err(FsError::InvalidArgument(format!( + "offset {} exceeds file length {total_lines}", + request.offset + ))); + } + let end = request + .offset + .saturating_add(request.limit) + .min(total_lines); + let mut selected = lines[request.offset.min(total_lines)..end] + .iter() + .flat_map(|line| line.iter().copied()) + .collect::>(); + let byte_truncated = selected.len() > request.max_bytes; + if byte_truncated { + let mut byte_end = request.max_bytes; + if let Ok(text) = std::str::from_utf8(&selected) { + while byte_end > 0 && !text.is_char_boundary(byte_end) { + byte_end -= 1; + } + } + selected.truncate(byte_end); + } + Ok(ReadResult { + path: logical, + bytes: selected, + start_line: request.offset, + total_lines, + content_hash, + truncated: end < total_lines || byte_truncated, + }) +} + +pub fn run_write( + root: &Path, + request: WriteRequest, + access: &dyn FsAccessPolicy, +) -> Result { + let logical = request.path; + let path = resolve(root, &logical)?; + let created = !path.exists(); + if path.exists() { + let target = require_access(&path, &logical, access, true)?; + let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?; + if metadata.is_dir() { + return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); + } + let actual = hash_bytes(&fs::read(&target).map_err(|error| map_io(&logical, error))?); + if request.expected_hash != Some(actual) { + return Err(FsError::Conflict(logical.as_str().to_string())); + } + atomic_write(&target, &request.content, &logical)?; + } else { + if request.expected_hash.is_some() { + return Err(FsError::Conflict(logical.as_str().to_string())); + } + let parent = path.parent().ok_or_else(|| { + FsError::InvalidArgument(format!("{} has no parent", logical.as_str())) + })?; + let parent_logical = logical_parent(&logical); + require_access(parent, &parent_logical, access, true)?; + atomic_write(&path, &request.content, &logical)?; + } + Ok(WriteResult { + bytes_written: request.content.len(), + created, + }) +} + +pub fn run_edit( + root: &Path, + request: EditRequest, + access: &dyn FsAccessPolicy, +) -> Result { + let logical = request.path; + let path = resolve(root, &logical)?; + let target = require_access(&path, &logical, access, true)?; + let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?; + let actual_hash = hash_bytes(&bytes); + if actual_hash != request.expected_hash { + return Err(FsError::Conflict(logical.as_str().to_string())); + } + let content = String::from_utf8(bytes).map_err(|_| { + FsError::InvalidArgument(format!("{} is not valid UTF-8", logical.as_str())) + })?; + let occurrences = content.matches(&request.old_string).count(); + if occurrences == 0 { + return Err(FsError::InvalidArgument( + "old_string was not found".to_string(), + )); + } + if !request.replace_all && occurrences != 1 { + return Err(FsError::InvalidArgument(format!( + "old_string matched {occurrences} times; set replace_all=true or provide a unique string" + ))); + } + let edited = if request.replace_all { + content.replace(&request.old_string, &request.new_string) + } else { + content.replacen(&request.old_string, &request.new_string, 1) + }; + atomic_write(&target, edited.as_bytes(), &logical)?; + Ok(EditResult { + replacements: if request.replace_all { occurrences } else { 1 }, + bytes_written: edited.len(), + content_hash: hash_bytes(edited.as_bytes()), + }) +} + +pub fn run_list( + root: &Path, + request: ListRequest, + access: &dyn FsAccessPolicy, +) -> Result { + let logical = request.path; + let path = resolve(root, &logical)?; + let path = require_access(&path, &logical, access, false)?; + let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; + if !metadata.is_dir() { + return Err(FsError::NotDirectory(PathBuf::from(logical.as_str()))); + } + let mut entries = Vec::new(); + let read_dir = fs::read_dir(&path).map_err(|error| map_io(&logical, error))?; + for entry in read_dir { + let entry = entry.map_err(|error| map_io(&logical, error))?; + let absolute = entry.path(); + if !access.is_readable(&absolute) { + continue; + } + let link_metadata = + fs::symlink_metadata(&absolute).map_err(|error| map_io(&logical, error))?; + let is_symlink = link_metadata.file_type().is_symlink(); + let metadata = if is_symlink { + link_metadata + } else { + entry.metadata().map_err(|error| map_io(&logical, error))? + }; + let kind = if is_symlink { + EntryKind::Symlink + } else if metadata.is_file() { + EntryKind::File + } else if metadata.is_dir() { + EntryKind::Directory + } else { + EntryKind::Other + }; + let relative = absolute.strip_prefix(root).map_err(|_| { + FsError::InvalidArgument("provider returned a path outside its root".to_string()) + })?; + entries.push(ListEntry { + path: FsPath::new(relative.to_string_lossy())?, + kind, + size: metadata.len(), + }); + } + entries.sort_by(|left, right| { + let left_dir = left.kind == EntryKind::Directory; + let right_dir = right.kind == EntryKind::Directory; + right_dir + .cmp(&left_dir) + .then_with(|| left.path.as_str().cmp(right.path.as_str())) + }); + let total_entries = entries.len(); + let total_bytes = entries.iter().map(|entry| entry.size).sum(); + let truncated = entries.len() > request.limit; + entries.truncate(request.limit); + Ok(ListResult { + entries, + total_entries, + total_bytes, + truncated, + }) +} + +fn resolve(root: &Path, logical: &FsPath) -> Result { + if !root.is_absolute() { + return Err(FsError::RelativePath(root.to_path_buf())); + } + Ok(if logical.as_str().is_empty() { + root.to_path_buf() + } else { + root.join(logical.as_str()) + }) +} + +fn require_access( + path: &Path, + logical: &FsPath, + access: &dyn FsAccessPolicy, + write: bool, +) -> Result { + if let Some(info) = direct_symlink(path) { + if !info.target_exists { + return Err(FsError::BrokenSymlink { + path: PathBuf::from(logical.as_str()), + link: PathBuf::from(logical.as_str()), + target: PathBuf::from(""), + }); + } + let allowed = if write { + access.is_writable(&info.resolved_path) + } else { + access.is_readable(&info.resolved_path) + }; + if !allowed { + return Err(FsError::SymlinkOutOfScope { + path: PathBuf::from(logical.as_str()), + target: PathBuf::from(""), + required_permission: if write { "write" } else { "read" }, + }); + } + if write && info.resolved_path.is_dir() { + return Err(FsError::SymlinkTargetIsDirectory { + path: PathBuf::from(logical.as_str()), + target: PathBuf::from(""), + }); + } + return Ok(info.resolved_path); + } + let allowed = if write { + access.is_writable(path) + } else { + access.is_readable(path) + }; + if allowed { + Ok(path.to_path_buf()) + } else if write { + Err(FsError::ReadOnly(PathBuf::from(logical.as_str()))) + } else { + Err(FsError::OutOfScope(PathBuf::from(logical.as_str()))) + } +} + +fn logical_parent(path: &FsPath) -> FsPath { + let parent = Path::new(path.as_str()) + .parent() + .unwrap_or_else(|| Path::new("")) + .to_string_lossy(); + FsPath::new(parent).unwrap_or_else(|_| FsPath::root()) +} + +fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> { + let parent = path + .parent() + .ok_or_else(|| FsError::InvalidArgument(format!("{} has no parent", logical.as_str())))?; + fs::create_dir_all(parent).map_err(|error| map_io(logical, error))?; + let mut temporary = + tempfile::NamedTempFile::new_in(parent).map_err(|error| map_io(logical, error))?; + temporary + .write_all(content) + .map_err(|error| map_io(logical, error))?; + temporary.flush().map_err(|error| map_io(logical, error))?; + temporary + .persist(path) + .map_err(|error| map_io(logical, error.error))?; + Ok(()) +} + +fn hash_bytes(content: &[u8]) -> ContentHash { + Sha256::digest(content).into() +} + +fn map_io(logical: &FsPath, error: std::io::Error) -> FsError { + match error.kind() { + std::io::ErrorKind::NotFound => FsError::NotFound(PathBuf::from(logical.as_str())), + _ => FsError::Io { + path: PathBuf::from(logical.as_str()), + source: error, + }, + } +} diff --git a/crates/fs-operation/src/operation.rs b/crates/fs-operation/src/operation.rs new file mode 100644 index 00000000..b69ce30b --- /dev/null +++ b/crates/fs-operation/src/operation.rs @@ -0,0 +1,212 @@ +use std::fmt; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::FsError; + +/// Logical path relative to the bound Workdir root. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct FsPath(String); + +impl<'de> Deserialize<'de> for FsPath { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(&value).map_err(serde::de::Error::custom) + } +} + +impl FsPath { + pub fn root() -> Self { + Self(String::new()) + } + + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref(); + if value.is_empty() || value == "." { + return Ok(Self::root()); + } + let path = Path::new(value); + if path.is_absolute() || value.contains('\\') { + return Err(FsError::InvalidPath(value.to_owned())); + } + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => normalized.push(part), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(FsError::InvalidPath(value.to_owned())); + } + } + } + let value = normalized.to_string_lossy().replace('\\', "/"); + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_root(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Display for FsPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.is_empty() { + f.write_str(".") + } else { + f.write_str(&self.0) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StatRequest { + pub path: FsPath, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StatResult { + pub path: FsPath, + pub kind: EntryKind, + pub size: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntryKind { + File, + Directory, + Symlink, + Other, +} + +pub type ContentHash = [u8; 32]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadRequest { + pub path: FsPath, + pub offset: usize, + pub limit: usize, + pub max_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadResult { + pub path: FsPath, + pub bytes: Vec, + pub start_line: usize, + pub total_lines: usize, + pub content_hash: ContentHash, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WriteRequest { + pub path: FsPath, + pub content: Vec, + pub expected_hash: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct WriteResult { + pub bytes_written: usize, + pub created: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditRequest { + pub path: FsPath, + pub old_string: String, + pub new_string: String, + pub replace_all: bool, + pub expected_hash: ContentHash, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditResult { + pub replacements: usize, + pub bytes_written: usize, + pub content_hash: ContentHash, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListRequest { + pub path: FsPath, + pub limit: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListEntry { + pub path: FsPath, + pub kind: EntryKind, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListResult { + pub entries: Vec, + pub total_entries: usize, + pub total_bytes: u64, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GlobRequest { + pub pattern: String, + pub path: FsPath, + pub limit: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GlobResult { + pub paths: Vec, + pub truncated: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GrepOutputMode { + Content, + FilesWithMatches, + Count, +} + +impl Default for GrepOutputMode { + fn default() -> Self { + Self::FilesWithMatches + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GrepRequest { + pub pattern: String, + pub path: FsPath, + pub glob: Option, + pub file_type: Option, + pub case_insensitive: bool, + pub before_context: usize, + pub after_context: usize, + pub multiline: bool, + pub output_mode: GrepOutputMode, + pub limit: usize, + pub offset: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GrepResult { + /// Provider-rendered bounded grep report. Keeping rendering here avoids + /// transferring candidate files across a remote provider boundary. + pub output: String, + pub match_count: usize, + pub matched_files: usize, + pub truncated: bool, +} diff --git a/crates/workdir/src/search.rs b/crates/fs-operation/src/search.rs similarity index 89% rename from crates/workdir/src/search.rs rename to crates/fs-operation/src/search.rs index bdb52d96..74a66d27 100644 --- a/crates/workdir/src/search.rs +++ b/crates/fs-operation/src/search.rs @@ -1,14 +1,14 @@ use std::path::{Path, PathBuf}; +use crate::FsAccessPolicy; use grep_regex::RegexMatcherBuilder; use grep_searcher::sinks::UTF8 as UTF8Sink; use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch}; use ignore::WalkBuilder; use ignore::overrides::OverrideBuilder; use ignore::types::TypesBuilder; -use manifest::Scope; -use crate::{GrepOutputMode, GrepRequest, GrepResult, WorkdirError, direct_symlink}; +use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink}; struct ContentLine { path: PathBuf, @@ -107,12 +107,12 @@ struct GrepParams { offset: Option, } -pub(crate) fn run_grep( +pub fn run_grep( root: &Path, base: PathBuf, request: GrepRequest, - scope: &Scope, -) -> Result { + access: &dyn FsAccessPolicy, +) -> Result { let p = GrepParams { pattern: request.pattern, path: Some(base.clone()), @@ -133,7 +133,7 @@ pub(crate) fn run_grep( .multi_line(p.multiline) .dot_matches_new_line(p.multiline) .build(&p.pattern) - .map_err(|e| WorkdirError::InvalidRegex(e.to_string()))?; + .map_err(|e| FsError::InvalidRegex(e.to_string()))?; let (before, after) = match (p.before, p.after, p.context) { (_, _, Some(c)) => (c, c), @@ -150,32 +150,32 @@ pub(crate) fn run_grep( let base = p.path.unwrap_or(base); if !base.is_absolute() { - return Err(WorkdirError::RelativePath(base)); + return Err(FsError::RelativePath(base)); } let symlink = direct_symlink(&base); - if !scope.is_readable(&base) { + if !access.is_readable(&base) { return Err(if let Some(info) = symlink.as_ref() { let link_parent_readable = info .link_path .parent() - .map(|parent| scope.is_readable(parent)) + .map(|parent| access.is_readable(parent)) .unwrap_or(false); if info.target_exists && link_parent_readable { - WorkdirError::SymlinkOutOfScope { + FsError::SymlinkOutOfScope { path: base.clone(), target: info.resolved_path.clone(), required_permission: "read", } } else { - WorkdirError::OutOfScope(base.clone()) + FsError::OutOfScope(base.clone()) } } else { - WorkdirError::OutOfScope(base.clone()) + FsError::OutOfScope(base.clone()) }); } if let Some(info) = symlink.as_ref() { if !info.target_exists { - return Err(WorkdirError::BrokenSymlink { + return Err(FsError::BrokenSymlink { path: base.clone(), link: info.link_path.clone(), target: info.target_path.clone(), @@ -183,17 +183,17 @@ pub(crate) fn run_grep( } } let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => WorkdirError::NotFound(base.clone()), - _ => WorkdirError::io(&base, e), + std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()), + _ => FsError::io(&base, e), })?; if !base_meta.is_dir() { - return Err(WorkdirError::InvalidArgument(format!( + return Err(FsError::InvalidArgument(format!( "grep search path is not a directory: {}", base.display() ))); } if let Some(info) = symlink.as_ref() { - return Err(WorkdirError::SymlinkDirectoryNotTraversed { + return Err(FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path: base.clone(), target: info.resolved_path.clone(), @@ -215,16 +215,15 @@ pub(crate) fn run_grep( tb.select(t); let types = tb .build() - .map_err(|e| WorkdirError::InvalidArgument(format!("invalid type {t}: {e}")))?; + .map_err(|e| FsError::InvalidArgument(format!("invalid type {t}: {e}")))?; wb.types(types); } if let Some(g) = p.glob.as_deref() { let mut ob = OverrideBuilder::new(&base); - ob.add(g) - .map_err(|e| WorkdirError::InvalidGlob(e.to_string()))?; + ob.add(g).map_err(|e| FsError::InvalidGlob(e.to_string()))?; let ov = ob .build() - .map_err(|e| WorkdirError::InvalidGlob(e.to_string()))?; + .map_err(|e| FsError::InvalidGlob(e.to_string()))?; wb.overrides(ov); } @@ -251,7 +250,7 @@ pub(crate) fn run_grep( continue; } let path = entry.path(); - if !scope.is_readable(path) { + if !access.is_readable(path) { continue; } @@ -295,7 +294,7 @@ pub(crate) fn run_grep( }; searcher .search_path(&matcher, path, &mut sink) - .map_err(|e| WorkdirError::io(path, e))?; + .map_err(|e| FsError::io(path, e))?; // If we hit head_limit during this file, stop walking. if matches_seen >= offset.saturating_add(head_limit) && matches_seen > before_count { @@ -313,7 +312,7 @@ fn scan_any_match( searcher: &mut Searcher, matcher: &grep_regex::RegexMatcher, path: &Path, -) -> Result { +) -> Result { let mut hit = false; let sink = UTF8Sink(|_, _| { hit = true; @@ -321,7 +320,7 @@ fn scan_any_match( }); searcher .search_path(matcher, path, sink) - .map_err(|e| WorkdirError::io(path, e))?; + .map_err(|e| FsError::io(path, e))?; Ok(hit) } @@ -329,7 +328,7 @@ fn scan_count( searcher: &mut Searcher, matcher: &grep_regex::RegexMatcher, path: &Path, -) -> Result { +) -> Result { let mut count = 0usize; let sink = UTF8Sink(|_, _| { count += 1; @@ -337,7 +336,7 @@ fn scan_count( }); searcher .search_path(matcher, path, sink) - .map_err(|e| WorkdirError::io(path, e))?; + .map_err(|e| FsError::io(path, e))?; Ok(count) } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index abbadef9..9cb3ad2e 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] async-trait = { workspace = true } +fs-operation.workspace = true html5ever = "0.26" llm-engine = { workspace = true } manifest = { workspace = true } diff --git a/crates/tools/src/error.rs b/crates/tools/src/error.rs index e6f5f84d..49ec6d00 100644 --- a/crates/tools/src/error.rs +++ b/crates/tools/src/error.rs @@ -10,6 +10,9 @@ use llm_engine::tool::ToolError; #[derive(Debug, thiserror::Error)] pub enum ToolsError { + #[error(transparent)] + FileSystem(#[from] fs_operation::FsError), + #[error(transparent)] WorkdirSession(#[from] workdir::WorkdirError), @@ -40,7 +43,8 @@ impl From for ToolError { | workdir::WorkdirError::Io { .. } | workdir::WorkdirError::Unavailable(_), ) => ToolError::ExecutionFailed(err.to_string()), - ToolsError::WorkdirSession(_) + ToolsError::FileSystem(_) + | ToolsError::WorkdirSession(_) | ToolsError::NotRead(_) | ToolsError::ExternallyModified(_) | ToolsError::StringNotFound { .. } diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index ec8687f7..db2527c9 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -10,11 +10,7 @@ http-client = ["dep:reqwest"] [dependencies] async-trait.workspace = true -globset = "0.4.18" -grep-matcher = "0.1.8" -grep-regex = "0.1.14" -grep-searcher = "0.1.16" -ignore = "0.4.25" +fs-operation.workspace = true manifest.workspace = true reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index 5a576651..e5c42928 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -8,7 +8,6 @@ pub mod http; mod local; mod operation; -mod search; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -16,6 +15,11 @@ use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; +pub use fs_operation::{ + ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest, + GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult, + ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, +}; pub use local::{LocalWorkdirSession, SymlinkInfo, direct_symlink, first_symlink}; pub use operation::*; @@ -248,3 +252,42 @@ impl WorkdirError { } } } + +impl From for WorkdirError { + fn from(error: fs_operation::FsError) -> Self { + match error { + fs_operation::FsError::InvalidPath(message) => Self::InvalidPath(message), + fs_operation::FsError::RelativePath(path) => Self::RelativePath(path), + fs_operation::FsError::OutOfScope(path) => Self::OutOfScope(path), + fs_operation::FsError::NotFound(path) => Self::NotFound(path), + fs_operation::FsError::BrokenSymlink { path, link, target } => { + Self::BrokenSymlink { path, link, target } + } + fs_operation::FsError::SymlinkOutOfScope { + path, + target, + required_permission, + } => Self::SymlinkOutOfScope { + path, + target, + required_permission, + }, + fs_operation::FsError::SymlinkDirectoryNotTraversed { tool, path, target } => { + Self::SymlinkDirectoryNotTraversed { tool, path, target } + } + fs_operation::FsError::ReadOnly(path) => Self::ReadOnly(path), + fs_operation::FsError::IsDirectory(path) => Self::IsDirectory(path), + fs_operation::FsError::NotDirectory(path) => { + Self::InvalidArgument(format!("path is not a directory: {}", path.display())) + } + fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => { + Self::SymlinkTargetIsDirectory { path, target } + } + fs_operation::FsError::Conflict(message) => Self::Conflict(message), + fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message), + fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message), + fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message), + fs_operation::FsError::Io { path, source } => Self::Io { path, source }, + } + } +} diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index bb3a228e..dd36b997 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -9,7 +9,9 @@ //! state, such as read-before-edit tracking, remains owned by the tool layer. use std::collections::HashMap; -use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; +#[cfg(test)] +use std::io::Write as _; +use std::io::{Read as _, Seek as _, SeekFrom}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; @@ -17,8 +19,6 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; use async_trait::async_trait; -use globset::Glob; -use ignore::WalkBuilder; use manifest::{Scope, SharedScope}; use sha2::{Digest, Sha256}; use tokio::process::Command; @@ -27,11 +27,13 @@ use tokio::task::JoinHandle; use crate::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, - EditResult, EntryKind, GlobRequest, GlobResult, GrepRequest, GrepResult, ListEntry, - ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, - WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, - WorkdirSessionCapability, WriteOutcome, WriteRequest, WriteResult, + EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, + ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, + WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability, WriteRequest, + WriteResult, }; +#[cfg(test)] +use crate::{EntryKind, WriteOutcome}; #[derive(Debug)] enum LocalCommand { @@ -42,6 +44,19 @@ enum LocalCommand { Completed(CommandOutput), } +#[derive(Debug)] +struct ScopeAccess(Arc); + +impl fs_operation::FsAccessPolicy for ScopeAccess { + fn is_readable(&self, path: &Path) -> bool { + self.0.is_readable(path) + } + + fn is_writable(&self, path: &Path) -> bool { + self.0.is_writable(path) + } +} + #[derive(Debug)] struct LocalWorkdirSessionInner { workdir: Workdir, @@ -191,6 +206,7 @@ impl LocalWorkdirSession { /// /// Follows symlinks. Rejects directories, relative paths, paths not /// readable by the scope, and missing files. + #[cfg(test)] pub(crate) fn read_bytes(&self, path: &Path) -> Result, WorkdirError> { if !path.is_absolute() { return Err(WorkdirError::RelativePath(path.to_path_buf())); @@ -241,6 +257,7 @@ impl LocalWorkdirSession { /// target file transitions atomically between states. /// /// This method does **not** consult tool-specific read history. + #[cfg(test)] pub(crate) fn write(&self, path: &Path, content: &[u8]) -> Result { if !path.is_absolute() { return Err(WorkdirError::RelativePath(path.to_path_buf())); @@ -342,13 +359,6 @@ impl LocalWorkdirSession { self.inner.root.join(path.as_str()) } } - - fn logical_path(&self, path: &Path) -> Result { - let relative = path - .strip_prefix(&self.inner.root) - .map_err(|_| WorkdirError::InvalidPath("path escaped Workdir root".into()))?; - WorkdirPath::new(relative.to_string_lossy()) - } } #[async_trait] @@ -363,244 +373,67 @@ impl WorkdirSession for LocalWorkdirSession { async fn stat(&self, request: StatRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Read)?; - let path = self.resolve(&request.path); - let metadata = std::fs::symlink_metadata(&path).map_err(|error| { - let error = match error.kind() { - std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.clone()), - _ => WorkdirError::io(&path, error), - }; - sanitize_error(error, &request.path) - })?; - let kind = if metadata.file_type().is_symlink() { - EntryKind::Symlink - } else if metadata.is_file() { - EntryKind::File - } else if metadata.is_dir() { - EntryKind::Directory - } else { - EntryKind::Other - }; - Ok(StatResult { - path: request.path, - kind, - size: metadata.len(), - }) + let logical = request.path.clone(); + let access = ScopeAccess(self.inner.scope.snapshot()); + fs_operation::run_stat(&self.inner.root, request, &access) + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) } async fn read(&self, request: ReadRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Read)?; - let path = self.resolve(&request.path); - let bytes = LocalWorkdirSession::read_bytes(self, &path) - .map_err(|error| sanitize_error(error, &request.path))?; - let content_hash = Sha256::digest(&bytes).into(); - let lines = bytes - .split_inclusive(|byte| *byte == b'\n') - .collect::>(); - let total_lines = lines.len(); - if request.offset > total_lines && request.offset != 0 { - return Err(WorkdirError::InvalidArgument(format!( - "offset {} exceeds file length {total_lines}", - request.offset - ))); - } - let end = request - .offset - .saturating_add(request.limit) - .min(total_lines); - let mut selected = lines[request.offset.min(total_lines)..end] - .iter() - .flat_map(|line| line.iter().copied()) - .collect::>(); - let byte_truncated = selected.len() > request.max_bytes; - if byte_truncated { - let mut byte_end = request.max_bytes; - if let Ok(text) = std::str::from_utf8(&selected) { - while byte_end > 0 && !text.is_char_boundary(byte_end) { - byte_end -= 1; - } - } - selected.truncate(byte_end); - } - Ok(ReadResult { - path: request.path, - bytes: selected, - start_line: request.offset, - total_lines, - content_hash, - truncated: end < total_lines || byte_truncated, - }) + let logical = request.path.clone(); + let access = ScopeAccess(self.inner.scope.snapshot()); + fs_operation::run_read(&self.inner.root, request, &access) + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) } async fn write(&self, request: WriteRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Write)?; - let path = self.resolve(&request.path); - if path.exists() { - let current = LocalWorkdirSession::read_bytes(self, &path) - .map_err(|error| sanitize_error(error, &request.path))?; - let current_hash: [u8; 32] = Sha256::digest(¤t).into(); - if request.expected_hash != Some(current_hash) { - return Err(WorkdirError::Conflict(request.path.to_string())); - } - } else if request.expected_hash.is_some() { - return Err(WorkdirError::Conflict(request.path.to_string())); - } - LocalWorkdirSession::write(self, &path, &request.content) - .map_err(|error| sanitize_error(error, &request.path)) + let logical = request.path.clone(); + let access = ScopeAccess(self.inner.scope.snapshot()); + fs_operation::run_write(&self.inner.root, request, &access) + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) } async fn edit(&self, request: EditRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Edit)?; - let path = self.resolve(&request.path); - let bytes = LocalWorkdirSession::read_bytes(self, &path) - .map_err(|error| sanitize_error(error, &request.path))?; - let current_hash: [u8; 32] = Sha256::digest(&bytes).into(); - if current_hash != request.expected_hash { - return Err(WorkdirError::Conflict(request.path.to_string())); - } - let text = String::from_utf8(bytes).map_err(|_| { - WorkdirError::InvalidArgument(format!("file is not UTF-8: {}", request.path)) - })?; - let occurrences = text.matches(&request.old_string).count(); - if occurrences == 0 { - return Err(WorkdirError::InvalidArgument("old_string not found".into())); - } - if !request.replace_all && occurrences != 1 { - return Err(WorkdirError::InvalidArgument(format!( - "old_string occurs {occurrences} times; set replace_all or provide more context" - ))); - } - let replacements = if request.replace_all { occurrences } else { 1 }; - let edited = if request.replace_all { - text.replace(&request.old_string, &request.new_string) - } else { - text.replacen(&request.old_string, &request.new_string, 1) - }; - let outcome = LocalWorkdirSession::write(self, &path, edited.as_bytes()) - .map_err(|error| sanitize_error(error, &request.path))?; - let content_hash = Sha256::digest(edited.as_bytes()).into(); - Ok(EditResult { - replacements, - bytes_written: outcome.bytes_written, - content_hash, - }) + let logical = request.path.clone(); + let access = ScopeAccess(self.inner.scope.snapshot()); + fs_operation::run_edit(&self.inner.root, request, &access) + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) } async fn list(&self, request: ListRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Read)?; - let base = self.resolve(&request.path); - let scope = self.inner.scope.snapshot(); - if !scope.is_readable(&base) { - return Err(WorkdirError::OutOfScope(PathBuf::from( - request.path.as_str(), - ))); - } - let mut entries = Vec::new(); - for entry in std::fs::read_dir(&base) - .map_err(|error| sanitize_error(WorkdirError::io(&base, error), &request.path))? - { - let entry = entry - .map_err(|error| sanitize_error(WorkdirError::io(&base, error), &request.path))?; - let path = entry.path(); - if !scope.is_readable(&path) { - continue; - } - let link_metadata = std::fs::symlink_metadata(&path) - .map_err(|error| sanitize_error(WorkdirError::io(&path, error), &request.path))?; - let is_symlink = link_metadata.file_type().is_symlink(); - let metadata = if is_symlink { - link_metadata - } else { - entry.metadata().map_err(|error| { - sanitize_error(WorkdirError::io(&path, error), &request.path) - })? - }; - let kind = if is_symlink { - EntryKind::Symlink - } else if metadata.is_file() { - EntryKind::File - } else if metadata.is_dir() { - EntryKind::Directory - } else { - EntryKind::Other - }; - entries.push(ListEntry { - path: self.logical_path(&path)?, - kind, - size: metadata.len(), - }); - } - entries.sort_by(|left, right| { - let left_dir = left.kind == EntryKind::Directory; - let right_dir = right.kind == EntryKind::Directory; - right_dir - .cmp(&left_dir) - .then_with(|| left.path.as_str().cmp(right.path.as_str())) - }); - let total_entries = entries.len(); - let total_bytes = entries.iter().map(|entry| entry.size).sum(); - let truncated = total_entries > request.limit; - entries.truncate(request.limit); - Ok(ListResult { - entries, - total_entries, - total_bytes, - truncated, - }) + let logical = request.path.clone(); + let access = ScopeAccess(self.inner.scope.snapshot()); + fs_operation::run_list(&self.inner.root, request, &access) + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) } async fn glob(&self, request: GlobRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Glob)?; + let logical = request.path.clone(); let base = self.resolve(&request.path); - if let Some(info) = direct_symlink(&base) - && info.target_exists - && info.resolved_path.is_dir() - { - return Err(WorkdirError::SymlinkDirectoryNotTraversed { - tool: "Glob", - path: PathBuf::from(request.path.as_str()), - target: PathBuf::from(""), - }); - } - let matcher = Glob::new(&request.pattern) - .map_err(|error| WorkdirError::InvalidGlob(error.to_string()))? - .compile_matcher(); - let scope = self.inner.scope.snapshot(); - if !scope.is_readable(&base) { - return Err(WorkdirError::OutOfScope(PathBuf::from( - request.path.as_str(), - ))); - } - let mut matches = Vec::new(); - for entry in WalkBuilder::new(&base).hidden(false).build().flatten() { - let path = entry.path(); - if !path.is_file() || !scope.is_readable(path) { - continue; - } - let relative = path.strip_prefix(&base).unwrap_or(path); - if matcher.is_match(relative) { - matches.push(self.logical_path(path)?); - } - } - matches.sort_by(|left, right| left.as_str().cmp(right.as_str())); - let truncated = matches.len() > request.limit; - matches.truncate(request.limit); - Ok(GlobResult { - paths: matches, - truncated, - }) + let access = ScopeAccess(self.inner.scope.snapshot()); + fs_operation::run_glob(&self.inner.root, &base, request, &access) + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) } async fn grep(&self, request: GrepRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Grep)?; let base = self.resolve(&request.path); let logical = request.path.clone(); - crate::search::run_grep( - &self.inner.root, - base, - request, - &self.inner.scope.snapshot(), - ) - .map_err(|error| sanitize_error(error, &logical)) + let access = ScopeAccess(self.inner.scope.snapshot()); + fs_operation::run_grep(&self.inner.root, base, request, &access) + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) } async fn start_command(&self, request: CommandRequest) -> Result { @@ -948,6 +781,7 @@ pub fn direct_symlink(path: &Path) -> Option { } } +#[cfg(test)] fn symlink_out_of_scope_or_plain( path: &Path, symlink: Option<&SymlinkInfo>, @@ -971,6 +805,7 @@ fn symlink_out_of_scope_or_plain( WorkdirError::OutOfScope(path.to_path_buf()) } +#[cfg(test)] fn broken_symlink_error(path: &Path, info: &SymlinkInfo) -> WorkdirError { WorkdirError::BrokenSymlink { path: path.to_path_buf(), diff --git a/crates/workdir/src/operation.rs b/crates/workdir/src/operation.rs index 60ccca93..e1e0d15e 100644 --- a/crates/workdir/src/operation.rs +++ b/crates/workdir/src/operation.rs @@ -1,216 +1,5 @@ -use std::fmt; -use std::path::{Component, Path, PathBuf}; - use serde::{Deserialize, Serialize}; -use crate::WorkdirError; - -/// Logical path relative to the bound Workdir root. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] -#[serde(transparent)] -pub struct WorkdirPath(String); - -impl<'de> Deserialize<'de> for WorkdirPath { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(&value).map_err(serde::de::Error::custom) - } -} - -impl WorkdirPath { - pub fn root() -> Self { - Self(String::new()) - } - - pub fn new(value: impl AsRef) -> Result { - let value = value.as_ref(); - if value.is_empty() || value == "." { - return Ok(Self::root()); - } - let path = Path::new(value); - if path.is_absolute() || value.contains('\\') { - return Err(WorkdirError::InvalidPath(value.to_owned())); - } - - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::Normal(part) => normalized.push(part), - Component::CurDir => {} - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err(WorkdirError::InvalidPath(value.to_owned())); - } - } - } - let value = normalized.to_string_lossy().replace('\\', "/"); - Ok(Self(value)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn is_root(&self) -> bool { - self.0.is_empty() - } -} - -impl fmt::Display for WorkdirPath { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.0.is_empty() { - f.write_str(".") - } else { - f.write_str(&self.0) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct StatRequest { - pub path: WorkdirPath, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct StatResult { - pub path: WorkdirPath, - pub kind: EntryKind, - pub size: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EntryKind { - File, - Directory, - Symlink, - Other, -} - -pub type ContentHash = [u8; 32]; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadRequest { - pub path: WorkdirPath, - pub offset: usize, - pub limit: usize, - pub max_bytes: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadResult { - pub path: WorkdirPath, - pub bytes: Vec, - pub start_line: usize, - pub total_lines: usize, - pub content_hash: ContentHash, - pub truncated: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WriteRequest { - pub path: WorkdirPath, - pub content: Vec, - pub expected_hash: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct WriteResult { - pub bytes_written: usize, - pub created: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EditRequest { - pub path: WorkdirPath, - pub old_string: String, - pub new_string: String, - pub replace_all: bool, - pub expected_hash: ContentHash, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EditResult { - pub replacements: usize, - pub bytes_written: usize, - pub content_hash: ContentHash, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListRequest { - pub path: WorkdirPath, - pub limit: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListEntry { - pub path: WorkdirPath, - pub kind: EntryKind, - pub size: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListResult { - pub entries: Vec, - pub total_entries: usize, - pub total_bytes: u64, - pub truncated: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GlobRequest { - pub pattern: String, - pub path: WorkdirPath, - pub limit: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GlobResult { - pub paths: Vec, - pub truncated: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum GrepOutputMode { - Content, - FilesWithMatches, - Count, -} - -impl Default for GrepOutputMode { - fn default() -> Self { - Self::FilesWithMatches - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GrepRequest { - pub pattern: String, - pub path: WorkdirPath, - pub glob: Option, - pub file_type: Option, - pub case_insensitive: bool, - pub before_context: usize, - pub after_context: usize, - pub multiline: bool, - pub output_mode: GrepOutputMode, - pub limit: usize, - pub offset: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GrepResult { - /// Provider-rendered bounded grep report. Keeping rendering here avoids - /// transferring candidate files across a remote provider boundary. - pub output: String, - pub match_count: usize, - pub matched_files: usize, - pub truncated: bool, -} - #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct CommandHandle(pub String); @@ -248,29 +37,3 @@ pub struct CommandOutput { pub next_cursor: Option, pub truncated: bool, } - -#[cfg(test)] -mod tests { - use super::WorkdirPath; - - #[test] - fn logical_paths_normalize_only_safe_root_relative_components() { - assert_eq!( - WorkdirPath::new("./docs//item.md").unwrap().as_str(), - "docs/item.md" - ); - assert!(WorkdirPath::new("../secret").is_err()); - assert!(WorkdirPath::new("docs/../secret").is_err()); - assert!(WorkdirPath::new("/absolute").is_err()); - assert!(WorkdirPath::new(r"..\secret").is_err()); - } - - #[test] - fn deserialization_cannot_bypass_logical_path_validation() { - let error = serde_json::from_str::(r#""../secret""#).unwrap_err(); - assert!(error.to_string().contains("invalid Workdir path")); - - let path = serde_json::from_str::(r#""docs/item.md""#).unwrap(); - assert_eq!(path.as_str(), "docs/item.md"); - } -}