fix: close external workdir review gaps

This commit is contained in:
2026-09-22 18:57:47 +09:00
parent b5ee6b5804
commit 757432dac4
9 changed files with 1046 additions and 174 deletions
+50 -14
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use globset::Glob;
use ignore::WalkBuilder;
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult};
/// Execute a bounded glob entirely inside the provider process.
pub fn run_glob(
@@ -19,10 +19,12 @@ pub fn run_glob(
path: PathBuf::from(request.path.as_str()),
source,
})?;
let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
path: PathBuf::from(request.path.as_str()),
source: error,
})?;
let base_resolved = access
.resolve_access_path(base)
.map_err(|error| FsError::Io {
path: PathBuf::from(request.path.as_str()),
source: error,
})?;
if !access.is_readable_paths(base, &base_resolved) {
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
}
@@ -32,8 +34,33 @@ pub fn run_glob(
let mut matches = Vec::new();
let mut retained_path_bytes = 0_usize;
let mut provider_truncated = false;
let mut walker = WalkBuilder::new(base);
let traversal = access
.open_traversal_root(base, &base_resolved)
.map_err(|source| FsError::Io {
path: PathBuf::from(request.path.as_str()),
source,
})?;
let walker_root = traversal
.as_ref()
.map(|traversal| traversal.path())
.unwrap_or(base);
let mut walker = WalkBuilder::new(walker_root);
walker.hidden(false).follow_links(false);
if traversal.is_some() {
let search_relative = base.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("glob base is outside its provider root".to_string())
})?;
let search_relative = search_relative.to_path_buf();
let filter_root = walker_root.to_path_buf();
walker.filter_entry(move |entry| {
entry
.path()
.strip_prefix(&filter_root)
.is_ok_and(|relative| {
relative.starts_with(&search_relative) || search_relative.starts_with(relative)
})
});
}
let mut visited = 0_usize;
for entry in walker.build().flatten() {
access.check_cancelled().map_err(|source| FsError::Io {
@@ -47,19 +74,28 @@ pub fn run_glob(
crate::MAX_TRAVERSAL_ENTRIES
)));
}
let path = entry.path();
let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !path.is_file() || !readable {
continue;
}
let Ok(file) = access.open_read_file(path, path) else {
let walked_path = entry.path();
let path = if traversal.is_some() {
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
FsError::InvalidArgument(
"descriptor traversal returned a path outside its root".to_string(),
)
})?;
root.join(relative)
} else {
walked_path.to_path_buf()
};
let resolved = match access.resolve_access_path(&path) {
Ok(resolved) if access.is_readable_paths(&path, &resolved) => resolved,
_ => continue,
};
let Ok(file) = access.open_read_file(&path, &resolved) else {
continue;
};
if !file.metadata().is_ok_and(|metadata| metadata.is_file()) {
continue;
}
let relative = path.strip_prefix(base).unwrap_or(path);
let relative = path.strip_prefix(base).unwrap_or(&path);
if !matcher.is_match(relative) {
continue;
}
+48 -4
View File
@@ -110,7 +110,14 @@ pub fn open_beneath_no_symlinks_at(
) as RawFd
};
if fd < 0 {
return Err(std::io::Error::last_os_error());
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ELOOP) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"symbolic links are not permitted by this provider",
));
}
return Err(error);
}
// SAFETY: fd is a new owned descriptor returned by openat2.
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
@@ -147,6 +154,26 @@ pub fn open_beneath_no_symlinks(_root: &Path, _path: &Path) -> std::io::Result<s
))
}
/// Keeps a descriptor-backed traversal path alive for the duration of a walk.
#[derive(Debug)]
pub struct FsTraversalRoot {
path: PathBuf,
_directory: std::fs::File,
}
impl FsTraversalRoot {
pub fn new(path: PathBuf, directory: std::fs::File) -> Self {
Self {
path,
_directory: directory,
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
/// Provider-owned access policy used by local filesystem operations.
pub trait FsAccessPolicy: Send + Sync {
fn is_readable(&self, path: &Path) -> bool;
@@ -158,6 +185,23 @@ pub trait FsAccessPolicy: Send + Sync {
Ok(())
}
/// Resolve a logical path for authorization. Descriptor-confined providers
/// may retain the lexical path because the subsequent open is the authority.
fn resolve_access_path(&self, logical: &Path) -> std::io::Result<PathBuf> {
resolve_access_path(logical)
}
/// Return a descriptor-backed traversal root when pathname walking would
/// violate provider confinement. The retained directory keeps procfs-style
/// descriptor paths valid for the complete walk.
fn open_traversal_root(
&self,
_logical: &Path,
_resolved: &Path,
) -> std::io::Result<Option<FsTraversalRoot>> {
Ok(None)
}
/// Open an already-authorized readable file. Capability providers override
/// this to bind path resolution and open into one root-confined operation.
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
@@ -168,10 +212,10 @@ pub trait FsAccessPolicy: Send + Sync {
/// override this to prevent a path swap between authorization and stat.
fn read_metadata(
&self,
logical: &Path,
_resolved: &Path,
_logical: &Path,
resolved: &Path,
) -> std::io::Result<std::fs::Metadata> {
std::fs::symlink_metadata(logical)
std::fs::metadata(resolved)
}
/// Open an already-authorized directory for bounded enumeration.
+8 -4
View File
@@ -24,7 +24,9 @@ pub fn run_stat(
.check_cancelled()
.map_err(|error| map_io(&logical, error))?;
let path = resolve(root, &logical)?;
let resolved = resolve_access_path(&path).map_err(|error| map_io(&logical, error))?;
let resolved = access
.resolve_access_path(&path)
.map_err(|error| map_io(&logical, error))?;
if !access.is_readable_paths(&path, &resolved) {
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
}
@@ -308,7 +310,7 @@ pub fn run_list(
}
let entry = entry.map_err(|error| map_io(&logical, error))?;
let logical_absolute = logical_base.join(entry.file_name());
let resolved = match resolve_access_path(&logical_absolute) {
let resolved = match access.resolve_access_path(&logical_absolute) {
Ok(resolved) => resolved,
Err(_) => continue,
};
@@ -380,7 +382,10 @@ fn require_access(
write: bool,
allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> {
let symlink = direct_symlink(path);
let resolved = access
.resolve_access_path(path)
.map_err(|error| map_io(logical, error))?;
let symlink = (resolved != path).then(|| direct_symlink(path)).flatten();
if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
@@ -390,7 +395,6 @@ fn require_access(
target: PathBuf::from("<provider-internal target>"),
});
}
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
if let Some(info) = symlink {
let allowed = if write {
access.is_writable_paths(path, &resolved)
+252 -36
View File
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use std::fmt::Write as _;
use std::io::Read;
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use crate::FsAccessPolicy;
@@ -8,12 +8,11 @@ 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::gitignore::{Gitignore, GitignoreBuilder};
use ignore::overrides::{Override, OverrideBuilder};
use ignore::types::{Types, TypesBuilder};
use crate::{
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
};
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
struct SourceBoundedReader<'a, R> {
inner: R,
@@ -208,6 +207,118 @@ fn direct_file_selected(path: &Path, overrides: Option<&Override>, types: Option
&& !types.is_some_and(|filter| filter.matched(path, false).is_ignore())
}
#[derive(Default)]
struct DescriptorIgnoreMatchers {
loaded_directories: HashSet<PathBuf>,
by_directory: BTreeMap<PathBuf, Gitignore>,
}
impl DescriptorIgnoreMatchers {
fn load_directory(
&mut self,
directory: &Path,
explicit_base: &Path,
source_bytes_remaining: &mut u64,
access: &dyn FsAccessPolicy,
) -> Result<(), FsError> {
if !self.loaded_directories.insert(directory.to_path_buf()) {
return Ok(());
}
let mut builder = GitignoreBuilder::new(directory);
let mut has_patterns = false;
for name in [".gitignore", ".ignore"] {
let path = directory.join(name);
let resolved = access
.resolve_access_path(&path)
.map_err(|error| FsError::io(&path, error))?;
if !access.is_readable_paths(&path, &resolved) {
continue;
}
let file = match access.open_read_file(&path, &resolved) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(FsError::io(&path, error)),
};
if !file
.metadata()
.map_err(|error| FsError::io(&path, error))?
.is_file()
{
continue;
}
let bounded = SourceBoundedReader {
inner: file,
remaining: source_bytes_remaining,
access,
};
let mut reader = BufReader::new(bounded);
let mut line = String::new();
let mut line_number = 0_u64;
loop {
line.clear();
let read = reader
.read_line(&mut line)
.map_err(|error| FsError::io(&path, error))?;
if read == 0 {
break;
}
line_number = line_number.saturating_add(1);
if line.len() > 1024 * 1024 {
return Err(FsError::InvalidArgument(format!(
"ignore pattern line {line_number} exceeds provider limit"
)));
}
let line = line.trim_end_matches(['\r', '\n']);
// Match the ignore crate's partial-error behavior: one invalid
// pattern does not discard the remaining valid lines.
if builder.add_line(Some(path.clone()), line).is_ok() {
has_patterns = true;
}
}
}
if !has_patterns {
return Ok(());
}
let matcher = builder.build().map_err(|_| {
FsError::InvalidArgument("ignore patterns could not be compiled".to_string())
})?;
// An explicitly selected search root is traversed even when an ancestor
// ignores that directory, matching path-backed Grep behavior.
if directory != explicit_base
&& matcher
.matched_path_or_any_parents(explicit_base, true)
.is_ignore()
{
return Ok(());
}
self.by_directory.insert(directory.to_path_buf(), matcher);
Ok(())
}
fn is_ignored(&self, root: &Path, path: &Path) -> bool {
let mut directories = path
.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|directory| directory.starts_with(root))
.collect::<Vec<_>>();
directories.reverse();
let mut ignored = false;
for directory in directories {
let Some(matcher) = self.by_directory.get(directory) else {
continue;
};
let matched = matcher.matched_path_or_any_parents(path, false);
if matched.is_ignore() {
ignored = true;
} else if matched.is_whitelist() {
ignored = false;
}
}
ignored
}
}
struct GrepParams {
pattern: String,
path: Option<PathBuf>,
@@ -272,7 +383,22 @@ pub fn run_grep(
if !base.is_absolute() {
return Err(FsError::RelativePath(base));
}
let symlink = direct_symlink(&base);
let resolved_base = match access.resolve_access_path(&base) {
Ok(resolved) => resolved,
Err(error) => {
if let Some(info) = direct_symlink(&base).filter(|info| !info.target_exists) {
return Err(FsError::BrokenSymlink {
path: base.clone(),
link: info.link_path,
target: info.resolved_path,
});
}
return Err(FsError::io(&base, error));
}
};
let symlink = (resolved_base != base)
.then(|| direct_symlink(&base))
.flatten();
if let Some(info) = symlink.as_ref()
&& !info.target_exists
{
@@ -282,14 +408,14 @@ pub fn run_grep(
target: info.resolved_path.clone(),
});
}
let resolved_base = resolve_access_path(&base).map_err(|error| FsError::io(&base, error))?;
if !access.is_readable_paths(&base, &resolved_base) {
return Err(if let Some(info) = symlink.as_ref() {
let link_parent_readable = info
.link_path
.parent()
.and_then(|parent| {
resolve_access_path(parent)
access
.resolve_access_path(parent)
.ok()
.map(|resolved| access.is_readable_paths(parent, &resolved))
})
@@ -307,10 +433,12 @@ pub fn run_grep(
FsError::OutOfScope(base.clone())
});
}
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e),
})?;
let base_meta = access
.read_metadata(&base, &resolved_base)
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e),
})?;
if !base_meta.is_file() && !base_meta.is_dir() {
return Err(FsError::InvalidArgument(format!(
"grep search path must be a regular file or directory: {}",
@@ -358,24 +486,82 @@ pub fn run_grep(
return Ok(report.into_result(root));
}
let mut walker = WalkBuilder::new(&base);
walker
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.ignore(true)
.parents(true)
.follow_links(false);
if let Some(types) = types {
walker.types(types);
}
if let Some(overrides) = overrides {
walker.overrides(overrides);
let traversal = access
.open_traversal_root(&base, &resolved_base)
.map_err(|error| FsError::io(&base, error))?;
let walker_root = traversal
.as_ref()
.map(|traversal| traversal.path())
.unwrap_or(&base);
let mut walker = WalkBuilder::new(walker_root);
if traversal.is_some() {
walker
.hidden(false)
// Path-backed ignore discovery may follow an ignore-file symlink.
// Descriptor providers load ignore files through `access` below.
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.ignore(false)
.parents(false)
.follow_links(false);
let search_relative = base.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("grep base is outside its provider root".to_string())
})?;
let search_relative = search_relative.to_path_buf();
let filter_root = walker_root.to_path_buf();
walker.filter_entry(move |entry| {
entry
.path()
.strip_prefix(&filter_root)
.is_ok_and(|relative| {
relative.starts_with(&search_relative) || search_relative.starts_with(relative)
})
});
} else {
walker
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.ignore(true)
.parents(true)
.follow_links(false);
if let Some(types) = types.clone() {
walker.types(types);
}
if let Some(overrides) = overrides.clone() {
walker.overrides(overrides);
}
}
let mut visited = 0_usize;
let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES;
let mut descriptor_ignores = DescriptorIgnoreMatchers::default();
if traversal.is_some() {
let mut directory = root.to_path_buf();
descriptor_ignores.load_directory(
&directory,
&base,
&mut source_bytes_remaining,
access,
)?;
for component in base
.strip_prefix(root)
.map_err(|_| {
FsError::InvalidArgument("grep base is outside its provider root".to_string())
})?
.components()
{
directory.push(component);
descriptor_ignores.load_directory(
&directory,
&base,
&mut source_bytes_remaining,
access,
)?;
}
}
for entry in walker.build().flatten() {
access
.check_cancelled()
@@ -387,23 +573,50 @@ pub fn run_grep(
crate::MAX_TRAVERSAL_ENTRIES
)));
}
if !entry
.file_type()
.map(|kind| kind.is_file())
.unwrap_or(false)
{
let walked_path = entry.path();
let path = if traversal.is_some() {
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
FsError::InvalidArgument(
"descriptor traversal returned a path outside its root".to_string(),
)
})?;
root.join(relative)
} else {
walked_path.to_path_buf()
};
let file_type = entry.file_type();
if traversal.is_some() && file_type.as_ref().is_some_and(|kind| kind.is_dir()) {
descriptor_ignores.load_directory(&path, &base, &mut source_bytes_remaining, access)?;
continue;
}
let path = entry.path();
let readable = resolve_access_path(path)
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
if !file_type.map(|kind| kind.is_file()).unwrap_or(false) {
continue;
}
if traversal.is_some() {
let relative = path.strip_prefix(&base).map_err(|_| {
FsError::InvalidArgument(
"descriptor traversal returned a path outside its search base".to_string(),
)
})?;
if relative
.components()
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
|| descriptor_ignores.is_ignored(root, &path)
|| !direct_file_selected(&path, overrides.as_ref(), types.as_ref())
{
continue;
}
}
let readable = access
.resolve_access_path(&path)
.is_ok_and(|resolved| access.is_readable_paths(&path, &resolved));
if !readable {
continue;
}
if scan_path(
&mut searcher,
&matcher,
path,
&path,
mode,
&mut report,
&mut matching_files_seen,
@@ -434,8 +647,11 @@ fn scan_path(
source_bytes_remaining: &mut u64,
access: &dyn FsAccessPolicy,
) -> Result<bool, FsError> {
let resolved = access
.resolve_access_path(path)
.map_err(|error| FsError::io(path, error))?;
let reader = access
.open_read_file(path, path)
.open_read_file(path, &resolved)
.map_err(|error| FsError::io(path, error))?;
let metadata = reader
.metadata()