workdir: add network-capable operation boundary
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
//! Workdir authority and local materialization provider.
|
||||
//!
|
||||
//! A Workdir is the host-owned execution context bound to one Worker. Tools
|
||||
//! consume this interface; they do not own Workdir identity, paths, scope, or
|
||||
//! lifecycle.
|
||||
|
||||
mod local;
|
||||
mod operation;
|
||||
mod search;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use local::{LocalWorkdir, SymlinkInfo, direct_symlink, first_symlink};
|
||||
pub use operation::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkdirCapability {
|
||||
Read,
|
||||
Write,
|
||||
Edit,
|
||||
Glob,
|
||||
Grep,
|
||||
Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkdirCapabilities {
|
||||
bits: u8,
|
||||
}
|
||||
|
||||
impl WorkdirCapabilities {
|
||||
const READ: u8 = 1 << 0;
|
||||
const WRITE: u8 = 1 << 1;
|
||||
const EDIT: u8 = 1 << 2;
|
||||
const GLOB: u8 = 1 << 3;
|
||||
const GREP: u8 = 1 << 4;
|
||||
const COMMAND: u8 = 1 << 5;
|
||||
|
||||
pub const EMPTY: Self = Self { bits: 0 };
|
||||
|
||||
pub fn from_capabilities(capabilities: impl IntoIterator<Item = WorkdirCapability>) -> Self {
|
||||
capabilities
|
||||
.into_iter()
|
||||
.fold(Self::EMPTY, |set, capability| set.with(capability))
|
||||
}
|
||||
|
||||
pub const fn with(mut self, capability: WorkdirCapability) -> Self {
|
||||
self.bits |= match capability {
|
||||
WorkdirCapability::Read => Self::READ,
|
||||
WorkdirCapability::Write => Self::WRITE,
|
||||
WorkdirCapability::Edit => Self::EDIT,
|
||||
WorkdirCapability::Glob => Self::GLOB,
|
||||
WorkdirCapability::Grep => Self::GREP,
|
||||
WorkdirCapability::Command => Self::COMMAND,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub const ALL: Self = Self {
|
||||
bits: Self::READ | Self::WRITE | Self::EDIT | Self::GLOB | Self::GREP | Self::COMMAND,
|
||||
};
|
||||
|
||||
pub const READ_ONLY: Self = Self {
|
||||
bits: Self::READ | Self::GLOB | Self::GREP,
|
||||
};
|
||||
|
||||
pub const fn supports(self, capability: WorkdirCapability) -> bool {
|
||||
let bit = match capability {
|
||||
WorkdirCapability::Read => Self::READ,
|
||||
WorkdirCapability::Write => Self::WRITE,
|
||||
WorkdirCapability::Edit => Self::EDIT,
|
||||
WorkdirCapability::Glob => Self::GLOB,
|
||||
WorkdirCapability::Grep => Self::GREP,
|
||||
WorkdirCapability::Command => Self::COMMAND,
|
||||
};
|
||||
self.bits & bit != 0
|
||||
}
|
||||
}
|
||||
|
||||
pub type WriteOutcome = WriteResult;
|
||||
|
||||
/// Network-capable operations available on one bound Workdir.
|
||||
///
|
||||
/// Implementations execute filesystem search and command work on the host
|
||||
/// that owns the materialization. Requests and results never contain the raw
|
||||
/// materialized root.
|
||||
#[async_trait]
|
||||
pub trait Workdir: std::fmt::Debug + Send + Sync {
|
||||
fn binding_id(&self) -> Option<&str>;
|
||||
fn capabilities(&self) -> WorkdirCapabilities;
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError>;
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError>;
|
||||
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError>;
|
||||
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError>;
|
||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError>;
|
||||
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError>;
|
||||
async fn command_output(
|
||||
&self,
|
||||
request: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError>;
|
||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError>;
|
||||
async fn shutdown(&self) -> Result<(), WorkdirError>;
|
||||
}
|
||||
|
||||
pub type WorkdirHandle = Arc<dyn Workdir>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkdirError {
|
||||
#[error("Workdir does not support {0:?}")]
|
||||
Unsupported(WorkdirCapability),
|
||||
|
||||
#[error("invalid Workdir path: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
#[error("Workdir provider is unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("Workdir content was modified externally before the operation could be applied: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("unknown Workdir command: {0}")]
|
||||
UnknownCommand(String),
|
||||
|
||||
#[error("path must be absolute: {}", .0.display())]
|
||||
RelativePath(PathBuf),
|
||||
|
||||
#[error("path is outside allowed scope: {}", .0.display())]
|
||||
OutOfScope(PathBuf),
|
||||
|
||||
#[error(
|
||||
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Worker {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkOutOfScope {
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
required_permission: &'static str,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"broken symlink while resolving {}: {} -> {} (target does not exist); recreate the symlink with an absolute target or a correct relative target",
|
||||
.path.display(),
|
||||
.link.display(),
|
||||
.target.display()
|
||||
)]
|
||||
BrokenSymlink {
|
||||
path: PathBuf,
|
||||
link: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"path resolves through a symlink to a directory, but this tool requires a file: {} -> {}; choose a file inside that directory",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf },
|
||||
|
||||
#[error("path is read-only: {}", .0.display())]
|
||||
ReadOnly(PathBuf),
|
||||
|
||||
#[error("expected file but path is a directory: {}", .0.display())]
|
||||
IsDirectory(PathBuf),
|
||||
|
||||
#[error("file not found: {}", .0.display())]
|
||||
NotFound(PathBuf),
|
||||
|
||||
#[error("invalid argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
|
||||
#[error("invalid glob pattern: {0}")]
|
||||
InvalidGlob(String),
|
||||
|
||||
#[error("invalid regex pattern: {0}")]
|
||||
InvalidRegex(String),
|
||||
|
||||
#[error("{tool} does not follow symlink directories: {} -> {}", .path.display(), .target.display())]
|
||||
SymlinkDirectoryNotTraversed {
|
||||
tool: &'static str,
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
|
||||
#[error("I/O error at {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkdirError {
|
||||
pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
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);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandRequest {
|
||||
pub command: String,
|
||||
pub timeout_secs: u64,
|
||||
pub output_limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandOutputRequest {
|
||||
pub handle: CommandHandle,
|
||||
pub cursor: usize,
|
||||
pub limit: usize,
|
||||
pub wait: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommandStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandOutput {
|
||||
pub status: CommandStatus,
|
||||
pub exit_code: Option<i32>,
|
||||
pub timed_out: bool,
|
||||
pub content: String,
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
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};
|
||||
|
||||
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(crate) fn run_grep(
|
||||
root: &Path,
|
||||
base: PathBuf,
|
||||
request: GrepRequest,
|
||||
scope: &Scope,
|
||||
) -> Result<GrepResult, WorkdirError> {
|
||||
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| WorkdirError::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(WorkdirError::RelativePath(base));
|
||||
}
|
||||
let symlink = direct_symlink(&base);
|
||||
if !scope.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))
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
WorkdirError::SymlinkOutOfScope {
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
required_permission: "read",
|
||||
}
|
||||
} else {
|
||||
WorkdirError::OutOfScope(base.clone())
|
||||
}
|
||||
} else {
|
||||
WorkdirError::OutOfScope(base.clone())
|
||||
});
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(WorkdirError::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 => WorkdirError::NotFound(base.clone()),
|
||||
_ => WorkdirError::io(&base, e),
|
||||
})?;
|
||||
if !base_meta.is_dir() {
|
||||
return Err(WorkdirError::InvalidArgument(format!(
|
||||
"grep search path is not a directory: {}",
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
return Err(WorkdirError::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| WorkdirError::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()))?;
|
||||
let ov = ob
|
||||
.build()
|
||||
.map_err(|e| WorkdirError::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 !scope.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| WorkdirError::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, WorkdirError> {
|
||||
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| WorkdirError::io(path, e))?;
|
||||
Ok(hit)
|
||||
}
|
||||
|
||||
fn scan_count(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<usize, WorkdirError> {
|
||||
let mut count = 0usize;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
count += 1;
|
||||
Ok(true)
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| WorkdirError::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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user