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
-237
View File
@@ -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");
}
}