fs: extract provider operations into shared crate
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<usize>,
|
||||
}
|
||||
|
||||
pub(crate) fn run_grep(
|
||||
pub fn run_grep(
|
||||
root: &Path,
|
||||
base: PathBuf,
|
||||
request: GrepRequest,
|
||||
scope: &Scope,
|
||||
) -> Result<GrepResult, WorkdirError> {
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<GrepResult, FsError> {
|
||||
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<bool, WorkdirError> {
|
||||
) -> Result<bool, FsError> {
|
||||
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<usize, WorkdirError> {
|
||||
) -> Result<usize, FsError> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<ToolsError> 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 { .. }
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<fs_operation::FsError> 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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+60
-225
@@ -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<Scope>);
|
||||
|
||||
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<Vec<u8>, 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<WriteOutcome, WorkdirError> {
|
||||
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<WorkdirPath, WorkdirError> {
|
||||
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<StatResult, WorkdirError> {
|
||||
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<ReadResult, WorkdirError> {
|
||||
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::<Vec<_>>();
|
||||
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::<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: 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<WriteResult, WorkdirError> {
|
||||
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<EditResult, WorkdirError> {
|
||||
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<ListResult, WorkdirError> {
|
||||
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<GlobResult, WorkdirError> {
|
||||
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("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
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<GrepResult, WorkdirError> {
|
||||
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<CommandHandle, WorkdirError> {
|
||||
@@ -948,6 +781,7 @@ pub fn direct_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
|
||||
@@ -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<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 WorkdirPath {
|
||||
pub fn root() -> Self {
|
||||
Self(String::new())
|
||||
}
|
||||
|
||||
pub fn new(value: impl AsRef<str>) -> Result<Self, WorkdirError> {
|
||||
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<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: WorkdirPath,
|
||||
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: 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<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: WorkdirPath,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GlobResult {
|
||||
pub paths: Vec<WorkdirPath>,
|
||||
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<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,
|
||||
}
|
||||
|
||||
#[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<usize>,
|
||||
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::<WorkdirPath>(r#""../secret""#).unwrap_err();
|
||||
assert!(error.to_string().contains("invalid Workdir path"));
|
||||
|
||||
let path = serde_json::from_str::<WorkdirPath>(r#""docs/item.md""#).unwrap();
|
||||
assert_eq!(path.as_str(), "docs/item.md");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user