fs: extract provider operations into shared crate

This commit is contained in:
2026-08-04 02:03:22 +09:00
parent e5f0c4168f
commit e6a2da548f
14 changed files with 1057 additions and 501 deletions
+56
View File
@@ -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<GlobResult, FsError> {
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("<provider-internal target>"),
});
}
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,
})
}
+282
View File
@@ -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(&current).ok()?;
if !metadata.file_type().is_symlink() {
continue;
}
let raw_target = std::fs::read_link(&current).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"));
}
}
+330
View File
@@ -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<StatResult, FsError> {
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<ReadResult, FsError> {
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::<Vec<_>>();
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::<Vec<_>>();
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<WriteResult, FsError> {
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<EditResult, FsError> {
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<ListResult, FsError> {
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<PathBuf, FsError> {
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<PathBuf, FsError> {
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("<provider-internal target>"),
});
}
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("<provider-internal target>"),
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("<provider-internal target>"),
});
}
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,
},
}
}
+212
View File
@@ -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<D>(deserializer: D) -> Result<Self, D::Error>
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<str>) -> Result<Self, FsError> {
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<u8>,
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<u8>,
pub expected_hash: Option<ContentHash>,
}
#[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<ListEntry>,
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<FsPath>,
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<String>,
pub file_type: Option<String>,
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,
}
+404
View File
@@ -0,0 +1,404 @@
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 crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
struct ContentLine {
path: PathBuf,
line_number: Option<u64>,
text: String,
is_match: bool,
}
struct GrepReport {
mode: GrepOutputMode,
show_line_numbers: bool,
files: Vec<PathBuf>,
counts: Vec<(PathBuf, usize)>,
lines: Vec<ContentLine>,
truncated: bool,
}
impl GrepReport {
fn into_result(self, root: &Path) -> GrepResult {
let (match_count, matched_files) = match self.mode {
GrepOutputMode::FilesWithMatches => (self.files.len(), self.files.len()),
GrepOutputMode::Count => (
self.counts.iter().map(|(_, count)| *count).sum(),
self.counts.len(),
),
GrepOutputMode::Content => (
self.lines.iter().filter(|line| line.is_match).count(),
self.lines
.iter()
.map(|line| line.path.as_path())
.collect::<std::collections::BTreeSet<_>>()
.len(),
),
};
let mut output = String::new();
match self.mode {
GrepOutputMode::FilesWithMatches => {
for path in &self.files {
output.push_str(&logical_display(root, path));
output.push('\n');
}
}
GrepOutputMode::Count => {
for (path, count) in &self.counts {
output.push_str(&format!("{}:{count}\n", logical_display(root, path)));
}
}
GrepOutputMode::Content => {
for line in &self.lines {
let separator = if line.is_match { ':' } else { '-' };
let path = logical_display(root, &line.path);
if self.show_line_numbers
&& let Some(number) = line.line_number
{
output.push_str(&format!(
"{path}{separator}{number}{separator}{}\n",
line.text
));
} else {
output.push_str(&format!("{path}{separator}{}\n", line.text));
}
}
}
}
GrepResult {
output,
match_count,
matched_files,
truncated: self.truncated,
}
}
}
fn logical_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
const DEFAULT_HEAD_LIMIT: usize = 250;
struct GrepParams {
pattern: String,
path: Option<PathBuf>,
glob: Option<String>,
file_type: Option<String>,
case_insensitive: bool,
before: Option<usize>,
after: Option<usize>,
context: Option<usize>,
line_numbers: Option<bool>,
multiline: bool,
output_mode: Option<GrepOutputMode>,
head_limit: Option<usize>,
offset: Option<usize>,
}
pub fn run_grep(
root: &Path,
base: PathBuf,
request: GrepRequest,
access: &dyn FsAccessPolicy,
) -> Result<GrepResult, FsError> {
let p = GrepParams {
pattern: request.pattern,
path: Some(base.clone()),
glob: request.glob,
file_type: request.file_type,
case_insensitive: request.case_insensitive,
before: Some(request.before_context),
after: Some(request.after_context),
context: None,
line_numbers: Some(true),
multiline: request.multiline,
output_mode: Some(request.output_mode),
head_limit: Some(request.limit),
offset: Some(request.offset),
};
let matcher = RegexMatcherBuilder::new()
.case_insensitive(p.case_insensitive)
.multi_line(p.multiline)
.dot_matches_new_line(p.multiline)
.build(&p.pattern)
.map_err(|e| FsError::InvalidRegex(e.to_string()))?;
let (before, after) = match (p.before, p.after, p.context) {
(_, _, Some(c)) => (c, c),
(b, a, None) => (b.unwrap_or(0), a.unwrap_or(0)),
};
let mut sb = SearcherBuilder::new();
sb.binary_detection(BinaryDetection::quit(b'\x00'))
.line_number(p.line_numbers.unwrap_or(true))
.multi_line(p.multiline)
.before_context(before)
.after_context(after);
let mut searcher = sb.build();
let base = p.path.unwrap_or(base);
if !base.is_absolute() {
return Err(FsError::RelativePath(base));
}
let symlink = direct_symlink(&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| access.is_readable(parent))
.unwrap_or(false);
if info.target_exists && link_parent_readable {
FsError::SymlinkOutOfScope {
path: base.clone(),
target: info.resolved_path.clone(),
required_permission: "read",
}
} else {
FsError::OutOfScope(base.clone())
}
} else {
FsError::OutOfScope(base.clone())
});
}
if let Some(info) = symlink.as_ref() {
if !info.target_exists {
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path.clone(),
target: info.target_path.clone(),
});
}
}
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e),
})?;
if !base_meta.is_dir() {
return Err(FsError::InvalidArgument(format!(
"grep search path is not a directory: {}",
base.display()
)));
}
if let Some(info) = symlink.as_ref() {
return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Grep",
path: base.clone(),
target: info.resolved_path.clone(),
});
}
let mut wb = WalkBuilder::new(&base);
wb.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.ignore(true)
.parents(true)
.follow_links(false);
if let Some(t) = p.file_type.as_deref() {
let mut tb = TypesBuilder::new();
tb.add_defaults();
tb.select(t);
let types = tb
.build()
.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| FsError::InvalidGlob(e.to_string()))?;
let ov = ob
.build()
.map_err(|e| FsError::InvalidGlob(e.to_string()))?;
wb.overrides(ov);
}
let mode = p.output_mode.unwrap_or_default();
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
let offset = p.offset.unwrap_or(0);
let show_line_numbers = p.line_numbers.unwrap_or(true);
let mut report = GrepReport {
mode,
show_line_numbers,
files: Vec::new(),
counts: Vec::new(),
lines: Vec::new(),
truncated: false,
};
// Per-mode walker state.
let mut matching_files_seen: usize = 0;
let mut matches_seen: usize = 0;
'walker: for entry in wb.build().flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
if !access.is_readable(path) {
continue;
}
match mode {
GrepOutputMode::FilesWithMatches => {
let hit = scan_any_match(&mut searcher, &matcher, path)?;
if !hit {
continue;
}
if matching_files_seen >= offset {
report.files.push(path.to_path_buf());
if report.files.len() >= head_limit {
report.truncated = true;
break 'walker;
}
}
matching_files_seen += 1;
}
GrepOutputMode::Count => {
let count = scan_count(&mut searcher, &matcher, path)?;
if count == 0 {
continue;
}
if matching_files_seen >= offset {
report.counts.push((path.to_path_buf(), count));
if report.counts.len() >= head_limit {
report.truncated = true;
break 'walker;
}
}
matching_files_seen += 1;
}
GrepOutputMode::Content => {
let before_count = matches_seen;
let mut sink = ContentSink {
path: path.to_path_buf(),
lines: &mut report.lines,
matches_seen: &mut matches_seen,
offset,
head_limit,
};
searcher
.search_path(&matcher, path, &mut sink)
.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
{
report.truncated = true;
break 'walker;
}
}
}
}
Ok(report.into_result(root))
}
fn scan_any_match(
searcher: &mut Searcher,
matcher: &grep_regex::RegexMatcher,
path: &Path,
) -> Result<bool, FsError> {
let mut hit = false;
let sink = UTF8Sink(|_, _| {
hit = true;
Ok(false) // stop searching this file immediately
});
searcher
.search_path(matcher, path, sink)
.map_err(|e| FsError::io(path, e))?;
Ok(hit)
}
fn scan_count(
searcher: &mut Searcher,
matcher: &grep_regex::RegexMatcher,
path: &Path,
) -> Result<usize, FsError> {
let mut count = 0usize;
let sink = UTF8Sink(|_, _| {
count += 1;
Ok(true)
});
searcher
.search_path(matcher, path, sink)
.map_err(|e| FsError::io(path, e))?;
Ok(count)
}
struct ContentSink<'a> {
path: PathBuf,
lines: &'a mut Vec<ContentLine>,
matches_seen: &'a mut usize,
offset: usize,
head_limit: usize,
}
impl Sink for ContentSink<'_> {
type Error = std::io::Error;
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
let idx = *self.matches_seen;
*self.matches_seen += 1;
// Skip matches before offset.
if idx < self.offset {
return Ok(true);
}
// Stop searching this file once we've filled the head_limit.
if idx >= self.offset.saturating_add(self.head_limit) {
return Ok(false);
}
let text = String::from_utf8_lossy(mat.bytes())
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
self.lines.push(ContentLine {
path: self.path.clone(),
line_number: mat.line_number(),
text,
is_match: true,
});
Ok(true)
}
fn context(
&mut self,
_searcher: &Searcher,
ctx: &SinkContext<'_>,
) -> Result<bool, Self::Error> {
let seen = *self.matches_seen;
if seen < self.offset {
return Ok(true);
}
if seen >= self.offset.saturating_add(self.head_limit) {
return Ok(false);
}
let text = String::from_utf8_lossy(ctx.bytes())
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
self.lines.push(ContentLine {
path: self.path.clone(),
line_number: ctx.line_number(),
text,
is_match: false,
});
Ok(true)
}
}