fix: close external workdir review gaps
This commit is contained in:
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use globset::Glob;
|
use globset::Glob;
|
||||||
use ignore::WalkBuilder;
|
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.
|
/// Execute a bounded glob entirely inside the provider process.
|
||||||
pub fn run_glob(
|
pub fn run_glob(
|
||||||
@@ -19,7 +19,9 @@ pub fn run_glob(
|
|||||||
path: PathBuf::from(request.path.as_str()),
|
path: PathBuf::from(request.path.as_str()),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
|
let base_resolved = access
|
||||||
|
.resolve_access_path(base)
|
||||||
|
.map_err(|error| FsError::Io {
|
||||||
path: PathBuf::from(request.path.as_str()),
|
path: PathBuf::from(request.path.as_str()),
|
||||||
source: error,
|
source: error,
|
||||||
})?;
|
})?;
|
||||||
@@ -32,8 +34,33 @@ pub fn run_glob(
|
|||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
let mut retained_path_bytes = 0_usize;
|
let mut retained_path_bytes = 0_usize;
|
||||||
let mut provider_truncated = false;
|
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);
|
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;
|
let mut visited = 0_usize;
|
||||||
for entry in walker.build().flatten() {
|
for entry in walker.build().flatten() {
|
||||||
access.check_cancelled().map_err(|source| FsError::Io {
|
access.check_cancelled().map_err(|source| FsError::Io {
|
||||||
@@ -47,19 +74,28 @@ pub fn run_glob(
|
|||||||
crate::MAX_TRAVERSAL_ENTRIES
|
crate::MAX_TRAVERSAL_ENTRIES
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let path = entry.path();
|
let walked_path = entry.path();
|
||||||
let readable = resolve_access_path(path)
|
let path = if traversal.is_some() {
|
||||||
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
|
||||||
if !path.is_file() || !readable {
|
FsError::InvalidArgument(
|
||||||
continue;
|
"descriptor traversal returned a path outside its root".to_string(),
|
||||||
}
|
)
|
||||||
let Ok(file) = access.open_read_file(path, path) else {
|
})?;
|
||||||
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
if !file.metadata().is_ok_and(|metadata| metadata.is_file()) {
|
if !file.metadata().is_ok_and(|metadata| metadata.is_file()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let relative = path.strip_prefix(base).unwrap_or(path);
|
let relative = path.strip_prefix(base).unwrap_or(&path);
|
||||||
if !matcher.is_match(relative) {
|
if !matcher.is_match(relative) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,14 @@ pub fn open_beneath_no_symlinks_at(
|
|||||||
) as RawFd
|
) as RawFd
|
||||||
};
|
};
|
||||||
if fd < 0 {
|
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.
|
// SAFETY: fd is a new owned descriptor returned by openat2.
|
||||||
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
|
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.
|
/// Provider-owned access policy used by local filesystem operations.
|
||||||
pub trait FsAccessPolicy: Send + Sync {
|
pub trait FsAccessPolicy: Send + Sync {
|
||||||
fn is_readable(&self, path: &Path) -> bool;
|
fn is_readable(&self, path: &Path) -> bool;
|
||||||
@@ -158,6 +185,23 @@ pub trait FsAccessPolicy: Send + Sync {
|
|||||||
Ok(())
|
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
|
/// Open an already-authorized readable file. Capability providers override
|
||||||
/// this to bind path resolution and open into one root-confined operation.
|
/// 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> {
|
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.
|
/// override this to prevent a path swap between authorization and stat.
|
||||||
fn read_metadata(
|
fn read_metadata(
|
||||||
&self,
|
&self,
|
||||||
logical: &Path,
|
_logical: &Path,
|
||||||
_resolved: &Path,
|
resolved: &Path,
|
||||||
) -> std::io::Result<std::fs::Metadata> {
|
) -> std::io::Result<std::fs::Metadata> {
|
||||||
std::fs::symlink_metadata(logical)
|
std::fs::metadata(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an already-authorized directory for bounded enumeration.
|
/// Open an already-authorized directory for bounded enumeration.
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ pub fn run_stat(
|
|||||||
.check_cancelled()
|
.check_cancelled()
|
||||||
.map_err(|error| map_io(&logical, error))?;
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
let path = resolve(root, &logical)?;
|
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) {
|
if !access.is_readable_paths(&path, &resolved) {
|
||||||
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
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 entry = entry.map_err(|error| map_io(&logical, error))?;
|
||||||
let logical_absolute = logical_base.join(entry.file_name());
|
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,
|
Ok(resolved) => resolved,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
@@ -380,7 +382,10 @@ fn require_access(
|
|||||||
write: bool,
|
write: bool,
|
||||||
allow_symlink_directory: bool,
|
allow_symlink_directory: bool,
|
||||||
) -> Result<PathBuf, FsError> {
|
) -> 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()
|
if let Some(info) = symlink.as_ref()
|
||||||
&& !info.target_exists
|
&& !info.target_exists
|
||||||
{
|
{
|
||||||
@@ -390,7 +395,6 @@ fn require_access(
|
|||||||
target: PathBuf::from("<provider-internal target>"),
|
target: PathBuf::from("<provider-internal target>"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
|
|
||||||
if let Some(info) = symlink {
|
if let Some(info) = symlink {
|
||||||
let allowed = if write {
|
let allowed = if write {
|
||||||
access.is_writable_paths(path, &resolved)
|
access.is_writable_paths(path, &resolved)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
use std::io::Read;
|
use std::io::{BufRead, BufReader, Read};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::FsAccessPolicy;
|
use crate::FsAccessPolicy;
|
||||||
@@ -8,12 +8,11 @@ use grep_regex::RegexMatcherBuilder;
|
|||||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||||
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
|
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||||
use ignore::overrides::{Override, OverrideBuilder};
|
use ignore::overrides::{Override, OverrideBuilder};
|
||||||
use ignore::types::{Types, TypesBuilder};
|
use ignore::types::{Types, TypesBuilder};
|
||||||
|
|
||||||
use crate::{
|
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
|
||||||
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
|
|
||||||
};
|
|
||||||
|
|
||||||
struct SourceBoundedReader<'a, R> {
|
struct SourceBoundedReader<'a, R> {
|
||||||
inner: 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())
|
&& !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 {
|
struct GrepParams {
|
||||||
pattern: String,
|
pattern: String,
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
@@ -272,7 +383,22 @@ pub fn run_grep(
|
|||||||
if !base.is_absolute() {
|
if !base.is_absolute() {
|
||||||
return Err(FsError::RelativePath(base));
|
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()
|
if let Some(info) = symlink.as_ref()
|
||||||
&& !info.target_exists
|
&& !info.target_exists
|
||||||
{
|
{
|
||||||
@@ -282,14 +408,14 @@ pub fn run_grep(
|
|||||||
target: info.resolved_path.clone(),
|
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) {
|
if !access.is_readable_paths(&base, &resolved_base) {
|
||||||
return Err(if let Some(info) = symlink.as_ref() {
|
return Err(if let Some(info) = symlink.as_ref() {
|
||||||
let link_parent_readable = info
|
let link_parent_readable = info
|
||||||
.link_path
|
.link_path
|
||||||
.parent()
|
.parent()
|
||||||
.and_then(|parent| {
|
.and_then(|parent| {
|
||||||
resolve_access_path(parent)
|
access
|
||||||
|
.resolve_access_path(parent)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|resolved| access.is_readable_paths(parent, &resolved))
|
.map(|resolved| access.is_readable_paths(parent, &resolved))
|
||||||
})
|
})
|
||||||
@@ -307,7 +433,9 @@ pub fn run_grep(
|
|||||||
FsError::OutOfScope(base.clone())
|
FsError::OutOfScope(base.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
|
let base_meta = access
|
||||||
|
.read_metadata(&base, &resolved_base)
|
||||||
|
.map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
||||||
_ => FsError::io(&base, e),
|
_ => FsError::io(&base, e),
|
||||||
})?;
|
})?;
|
||||||
@@ -358,7 +486,39 @@ pub fn run_grep(
|
|||||||
return Ok(report.into_result(root));
|
return Ok(report.into_result(root));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut walker = WalkBuilder::new(&base);
|
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
|
walker
|
||||||
.hidden(true)
|
.hidden(true)
|
||||||
.git_ignore(true)
|
.git_ignore(true)
|
||||||
@@ -367,15 +527,41 @@ pub fn run_grep(
|
|||||||
.ignore(true)
|
.ignore(true)
|
||||||
.parents(true)
|
.parents(true)
|
||||||
.follow_links(false);
|
.follow_links(false);
|
||||||
if let Some(types) = types {
|
if let Some(types) = types.clone() {
|
||||||
walker.types(types);
|
walker.types(types);
|
||||||
}
|
}
|
||||||
if let Some(overrides) = overrides {
|
if let Some(overrides) = overrides.clone() {
|
||||||
walker.overrides(overrides);
|
walker.overrides(overrides);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut visited = 0_usize;
|
let mut visited = 0_usize;
|
||||||
let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES;
|
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() {
|
for entry in walker.build().flatten() {
|
||||||
access
|
access
|
||||||
.check_cancelled()
|
.check_cancelled()
|
||||||
@@ -387,23 +573,50 @@ pub fn run_grep(
|
|||||||
crate::MAX_TRAVERSAL_ENTRIES
|
crate::MAX_TRAVERSAL_ENTRIES
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if !entry
|
let walked_path = entry.path();
|
||||||
.file_type()
|
let path = if traversal.is_some() {
|
||||||
.map(|kind| kind.is_file())
|
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
|
||||||
.unwrap_or(false)
|
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;
|
||||||
|
}
|
||||||
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let path = entry.path();
|
}
|
||||||
let readable = resolve_access_path(path)
|
let readable = access
|
||||||
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
.resolve_access_path(&path)
|
||||||
|
.is_ok_and(|resolved| access.is_readable_paths(&path, &resolved));
|
||||||
if !readable {
|
if !readable {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if scan_path(
|
if scan_path(
|
||||||
&mut searcher,
|
&mut searcher,
|
||||||
&matcher,
|
&matcher,
|
||||||
path,
|
&path,
|
||||||
mode,
|
mode,
|
||||||
&mut report,
|
&mut report,
|
||||||
&mut matching_files_seen,
|
&mut matching_files_seen,
|
||||||
@@ -434,8 +647,11 @@ fn scan_path(
|
|||||||
source_bytes_remaining: &mut u64,
|
source_bytes_remaining: &mut u64,
|
||||||
access: &dyn FsAccessPolicy,
|
access: &dyn FsAccessPolicy,
|
||||||
) -> Result<bool, FsError> {
|
) -> Result<bool, FsError> {
|
||||||
|
let resolved = access
|
||||||
|
.resolve_access_path(path)
|
||||||
|
.map_err(|error| FsError::io(path, error))?;
|
||||||
let reader = access
|
let reader = access
|
||||||
.open_read_file(path, path)
|
.open_read_file(path, &resolved)
|
||||||
.map_err(|error| FsError::io(path, error))?;
|
.map_err(|error| FsError::io(path, error))?;
|
||||||
let metadata = reader
|
let metadata = reader
|
||||||
.metadata()
|
.metadata()
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ use crate::http::{
|
|||||||
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
BoundedReadLimits, CommandRequest, EditRequest, ExternalWorkdirRoot, LocalWorkdirSession,
|
BoundedReadLimits, CommandRequest, EditRequest, ExternalWorkdirRoot, GlobRequest,
|
||||||
ReadRequest, Workdir, WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
|
GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, WorkdirError,
|
||||||
WorkdirSessionCapability, WriteRequest,
|
WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
|
||||||
|
WriteRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
|
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
|
||||||
@@ -206,10 +207,13 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(
|
assert!(
|
||||||
|
matches!(
|
||||||
error,
|
error,
|
||||||
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
|
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
|
||||||
));
|
),
|
||||||
|
"unexpected error for {path}: {error:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let parent = tempfile::tempdir().unwrap();
|
let parent = tempfile::tempdir().unwrap();
|
||||||
@@ -224,20 +228,103 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
|
|||||||
assert!(matches!(error, WorkdirError::Denied(_)));
|
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn external_grep_honors_gitignore_above_a_nested_search_root() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir(root.path().join("nested")).unwrap();
|
||||||
|
std::fs::write(root.path().join(".gitignore"), "nested/ignored.txt\n").unwrap();
|
||||||
|
std::fs::write(root.path().join("nested/ignored.txt"), "needle ignored\n").unwrap();
|
||||||
|
std::fs::write(root.path().join("nested/visible.txt"), "needle visible\n").unwrap();
|
||||||
|
let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap());
|
||||||
|
|
||||||
|
let result = WorkdirSession::grep(
|
||||||
|
&session,
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "needle".to_string(),
|
||||||
|
path: WorkdirPath::new("nested").unwrap(),
|
||||||
|
glob: Some("*.txt".to_string()),
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: GrepOutputMode::FilesWithMatches,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.output.contains("nested/visible.txt"));
|
||||||
|
assert!(!result.output.contains("nested/ignored.txt"));
|
||||||
|
|
||||||
|
std::fs::write(root.path().join(".gitignore"), "nested/\n").unwrap();
|
||||||
|
let explicit_ignored_root = WorkdirSession::grep(
|
||||||
|
&session,
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "needle".to_string(),
|
||||||
|
path: WorkdirPath::new("nested").unwrap(),
|
||||||
|
glob: Some("*.txt".to_string()),
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: GrepOutputMode::FilesWithMatches,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(explicit_ignored_root.output.contains("nested/visible.txt"));
|
||||||
|
assert!(explicit_ignored_root.output.contains("nested/ignored.txt"));
|
||||||
|
|
||||||
|
let outside = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(outside.path().join("ignore-rules"), "nested/visible.txt\n").unwrap();
|
||||||
|
std::fs::remove_file(root.path().join(".gitignore")).unwrap();
|
||||||
|
std::os::unix::fs::symlink(
|
||||||
|
outside.path().join("ignore-rules"),
|
||||||
|
root.path().join(".gitignore"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let symlink_error = WorkdirSession::grep(
|
||||||
|
&session,
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "needle".to_string(),
|
||||||
|
path: WorkdirPath::new("nested").unwrap(),
|
||||||
|
glob: Some("*.txt".to_string()),
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: GrepOutputMode::FilesWithMatches,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(symlink_error, WorkdirError::OutOfScope(_)));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn external_local_provider_keeps_pre_grant_root_when_path_changes_before_session_creation() {
|
async fn external_local_provider_keeps_pre_grant_root_when_path_changes_before_session_creation() {
|
||||||
let parent = tempfile::tempdir().unwrap();
|
let parent = tempfile::tempdir().unwrap();
|
||||||
let approved = parent.path().join("shared");
|
let approved = parent.path().join("shared");
|
||||||
std::fs::create_dir(&approved).unwrap();
|
std::fs::create_dir(&approved).unwrap();
|
||||||
std::fs::write(approved.join("item.txt"), "approved").unwrap();
|
std::fs::write(approved.join("approved.txt"), "approved needle").unwrap();
|
||||||
|
|
||||||
// This is the CLI's approval/open boundary. Remote grant creation happens
|
// This is the CLI's approval/open boundary. Remote grant creation happens
|
||||||
// only after this descriptor has been pinned.
|
// only after this descriptor has been pinned.
|
||||||
let pinned = ExternalWorkdirRoot::pin(&approved).unwrap();
|
let pinned = ExternalWorkdirRoot::pin(&approved).unwrap();
|
||||||
std::fs::rename(&approved, parent.path().join("approved-original")).unwrap();
|
std::fs::rename(&approved, parent.path().join("approved-original")).unwrap();
|
||||||
std::fs::create_dir(&approved).unwrap();
|
std::fs::create_dir(&approved).unwrap();
|
||||||
std::fs::write(approved.join("item.txt"), "replacement").unwrap();
|
std::fs::write(approved.join("replacement.txt"), "replacement needle").unwrap();
|
||||||
|
|
||||||
let session = LocalWorkdirSession::external_read_only_pinned(
|
let session = LocalWorkdirSession::external_read_only_pinned(
|
||||||
Workdir::new("external-workdir-pre-grant-pin"),
|
Workdir::new("external-workdir-pre-grant-pin"),
|
||||||
@@ -248,7 +335,7 @@ async fn external_local_provider_keeps_pre_grant_root_when_path_changes_before_s
|
|||||||
let read = WorkdirSession::read(
|
let read = WorkdirSession::read(
|
||||||
&session,
|
&session,
|
||||||
ReadRequest {
|
ReadRequest {
|
||||||
path: WorkdirPath::new("item.txt").unwrap(),
|
path: WorkdirPath::new("approved.txt").unwrap(),
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
max_bytes: 1024,
|
max_bytes: 1024,
|
||||||
@@ -256,7 +343,91 @@ async fn external_local_provider_keeps_pre_grant_root_when_path_changes_before_s
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(read.bytes, b"approved");
|
assert_eq!(read.bytes, b"approved needle");
|
||||||
|
|
||||||
|
let glob = WorkdirSession::glob(
|
||||||
|
&session,
|
||||||
|
GlobRequest {
|
||||||
|
pattern: "*.txt".to_string(),
|
||||||
|
path: WorkdirPath::new("").unwrap(),
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
glob.paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["approved.txt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let grep = WorkdirSession::grep(
|
||||||
|
&session,
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "needle".to_string(),
|
||||||
|
path: WorkdirPath::new("").unwrap(),
|
||||||
|
glob: Some("*.txt".to_string()),
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: GrepOutputMode::FilesWithMatches,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(grep.output.contains("approved.txt"));
|
||||||
|
assert!(!grep.output.contains("replacement.txt"));
|
||||||
|
|
||||||
|
let outside = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(outside.path().join("secret.txt"), "secret needle").unwrap();
|
||||||
|
std::fs::remove_file(approved.join("replacement.txt")).unwrap();
|
||||||
|
std::fs::remove_dir(&approved).unwrap();
|
||||||
|
std::os::unix::fs::symlink(outside.path(), &approved).unwrap();
|
||||||
|
|
||||||
|
let glob_after_symlink_swap = WorkdirSession::glob(
|
||||||
|
&session,
|
||||||
|
GlobRequest {
|
||||||
|
pattern: "*.txt".to_string(),
|
||||||
|
path: WorkdirPath::new("").unwrap(),
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
glob_after_symlink_swap
|
||||||
|
.paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["approved.txt"]
|
||||||
|
);
|
||||||
|
let grep_after_symlink_swap = WorkdirSession::grep(
|
||||||
|
&session,
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "needle".to_string(),
|
||||||
|
path: WorkdirPath::new("").unwrap(),
|
||||||
|
glob: Some("*.txt".to_string()),
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: GrepOutputMode::FilesWithMatches,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(grep_after_symlink_swap.output.contains("approved.txt"));
|
||||||
|
assert!(!grep_after_symlink_swap.output.contains("secret.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -246,9 +246,7 @@ impl ScopeAccess {
|
|||||||
|
|
||||||
fn path_is_confined(&self, logical: &Path, resolved: &Path) -> bool {
|
fn path_is_confined(&self, logical: &Path, resolved: &Path) -> bool {
|
||||||
!self.reject_symlinks
|
!self.reject_symlinks
|
||||||
|| (logical.starts_with(&self.root)
|
|| (logical.starts_with(&self.root) && resolved.starts_with(&self.root))
|
||||||
&& resolved.starts_with(&self.root)
|
|
||||||
&& fs_operation::first_symlink(logical).is_none())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +279,54 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_access_path(&self, logical: &Path) -> std::io::Result<PathBuf> {
|
||||||
|
if !self.reject_symlinks {
|
||||||
|
return fs_operation::resolve_access_path(logical);
|
||||||
|
}
|
||||||
|
if logical.starts_with(&self.root) {
|
||||||
|
Ok(logical.to_path_buf())
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"path is outside provider root",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_traversal_root(
|
||||||
|
&self,
|
||||||
|
_logical: &Path,
|
||||||
|
resolved: &Path,
|
||||||
|
) -> std::io::Result<Option<fs_operation::FsTraversalRoot>> {
|
||||||
|
if !self.reject_symlinks {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let directory = self.open_confined(resolved)?;
|
||||||
|
if !directory.metadata()?.is_dir() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotADirectory,
|
||||||
|
"path is not a directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let root = self
|
||||||
|
.pinned_root
|
||||||
|
.as_ref()
|
||||||
|
.expect("confined traversal requires a pinned root")
|
||||||
|
.try_clone()?;
|
||||||
|
let path = PathBuf::from(format!("/proc/self/fd/{}", root.as_raw_fd()));
|
||||||
|
return Ok(Some(fs_operation::FsTraversalRoot::new(path, root)));
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"descriptor-rooted traversal is unavailable on this platform",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||||
if self.reject_symlinks {
|
if self.reject_symlinks {
|
||||||
self.open_confined(resolved)
|
self.open_confined(resolved)
|
||||||
@@ -289,11 +335,15 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
|
fn read_metadata(
|
||||||
|
&self,
|
||||||
|
_logical: &Path,
|
||||||
|
resolved: &Path,
|
||||||
|
) -> std::io::Result<std::fs::Metadata> {
|
||||||
if self.reject_symlinks {
|
if self.reject_symlinks {
|
||||||
self.open_confined(resolved)?.metadata()
|
self.open_confined(resolved)?.metadata()
|
||||||
} else {
|
} else {
|
||||||
std::fs::symlink_metadata(logical)
|
std::fs::metadata(resolved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1367,6 +1417,11 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
|||||||
WorkdirError::ReadOnly(_) => WorkdirError::ReadOnly(path),
|
WorkdirError::ReadOnly(_) => WorkdirError::ReadOnly(path),
|
||||||
WorkdirError::IsDirectory(_) => WorkdirError::IsDirectory(path),
|
WorkdirError::IsDirectory(_) => WorkdirError::IsDirectory(path),
|
||||||
WorkdirError::NotFound(_) => WorkdirError::NotFound(path),
|
WorkdirError::NotFound(_) => WorkdirError::NotFound(path),
|
||||||
|
WorkdirError::Io { source, .. }
|
||||||
|
if source.kind() == std::io::ErrorKind::PermissionDenied =>
|
||||||
|
{
|
||||||
|
WorkdirError::OutOfScope(path)
|
||||||
|
}
|
||||||
WorkdirError::Io { source, .. } => WorkdirError::Unavailable(format!(
|
WorkdirError::Io { source, .. } => WorkdirError::Unavailable(format!(
|
||||||
"I/O operation failed for {logical}: {}",
|
"I/O operation failed for {logical}: {}",
|
||||||
source.kind()
|
source.kind()
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ struct ExternalProviderConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ExternalWorkdirExpiryTask {
|
struct ExternalWorkdirExpiryTask {
|
||||||
|
task_id: Uuid,
|
||||||
handle: tokio::task::JoinHandle<()>,
|
handle: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8991,7 +8992,7 @@ async fn open_current_worker_workdir_session_locked(
|
|||||||
})?;
|
})?;
|
||||||
if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source {
|
if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source {
|
||||||
close_current_worker_attachment_session_locked(api, worker).await?;
|
close_current_worker_attachment_session_locked(api, worker).await?;
|
||||||
let grant = api
|
let mut grant = api
|
||||||
.store
|
.store
|
||||||
.get_external_workdir_grant(&api.config.workspace_id, grant_id)?
|
.get_external_workdir_grant(&api.config.workspace_id, grant_id)?
|
||||||
.ok_or_else(|| Error::RuntimeOperationFailed {
|
.ok_or_else(|| Error::RuntimeOperationFailed {
|
||||||
@@ -9003,45 +9004,15 @@ async fn open_current_worker_workdir_session_locked(
|
|||||||
.map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now())
|
.map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now())
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if expired {
|
if expired {
|
||||||
let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
grant = fence_external_workdir_grant_expired(api, grant_id)
|
||||||
api.store.update_external_workdir_grant_state(
|
.await
|
||||||
&api.config.workspace_id,
|
.map_err(|error| error.error)?;
|
||||||
grant_id,
|
|
||||||
&grant.provider_instance_id,
|
|
||||||
grant.generation,
|
|
||||||
"expired",
|
|
||||||
&updated_at,
|
|
||||||
)?;
|
|
||||||
cancel_external_workdir_expiry(api, grant_id);
|
|
||||||
let connection = {
|
|
||||||
api.external_workdir_providers
|
|
||||||
.lock()
|
|
||||||
.expect("External Workdir provider registry poisoned")
|
|
||||||
.disconnect(grant_id, grant.generation)
|
|
||||||
};
|
|
||||||
if let Some(connection) = connection {
|
|
||||||
let _ = connection
|
|
||||||
.sender
|
|
||||||
.send(ExternalProviderCommand::Revoke {
|
|
||||||
reason: workdir::external::ExternalWorkdirRevokeReason::Expired,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
close_current_worker_command_sessions_for_alias_locked(api, worker, &link.alias)
|
if matches!(grant.status.as_str(), "revoked" | "expired") {
|
||||||
.await?;
|
cleanup_external_workdir_attachments_with_lock(api, &grant.workdir_id, Some(worker))
|
||||||
api.store.detach_worker_workdir(
|
.await
|
||||||
&api.config.workspace_id,
|
.map_err(|error| error.error)?;
|
||||||
worker,
|
cancel_external_workdir_expiry(api, grant_id);
|
||||||
Some(&link.workdir_id),
|
|
||||||
&updated_at,
|
|
||||||
)?;
|
|
||||||
sync_runtime_worker_workdir_attachments(api, worker).map_err(|error| error.error)?;
|
|
||||||
api.worker_projection.refresh(worker)?;
|
|
||||||
return Err(Error::RuntimeOperationFailed {
|
|
||||||
runtime_id: "workspace-backend".to_string(),
|
|
||||||
code: "external_workdir_provider_expired".to_string(),
|
|
||||||
message: "External Workdir grant has expired".to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if grant.status != "online" {
|
if grant.status != "online" {
|
||||||
return Err(Error::RuntimeOperationFailed {
|
return Err(Error::RuntimeOperationFailed {
|
||||||
@@ -11950,22 +11921,30 @@ fn schedule_external_workdir_expiry(
|
|||||||
let grant_id = grant.grant_id.clone();
|
let grant_id = grant.grant_id.clone();
|
||||||
let task_api = api.clone();
|
let task_api = api.clone();
|
||||||
let task_grant_id = grant_id.clone();
|
let task_grant_id = grant_id.clone();
|
||||||
|
let task_id = Uuid::now_v7();
|
||||||
|
let task_instance_id = task_id;
|
||||||
|
let (start_sender, start_receiver) = tokio::sync::oneshot::channel();
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
|
if start_receiver.await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let delay = (expires_at - Utc::now()).to_std().unwrap_or_default();
|
let delay = (expires_at - Utc::now()).to_std().unwrap_or_default();
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
loop {
|
loop {
|
||||||
match expire_external_workdir_grant(&task_api, &task_grant_id).await {
|
match expire_external_workdir_grant(&task_api, &task_grant_id).await {
|
||||||
Ok(()) => break,
|
Ok(true) => break,
|
||||||
|
Ok(false) => {
|
||||||
|
// Wall time may have moved backwards after the monotonic
|
||||||
|
// sleep was calculated. Keep ownership and recheck without
|
||||||
|
// detaching an active grant.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(grant_id = %task_grant_id, error = ?error, "Backend External Workdir expiry cleanup requires retry");
|
tracing::error!(grant_id = %task_grant_id, error = ?error, "Backend External Workdir expiry cleanup requires retry");
|
||||||
let retryable = task_api
|
let retryable = task_api
|
||||||
.store
|
.store
|
||||||
.get_external_workdir_grant(&task_api.config.workspace_id, &task_grant_id)
|
.get_external_workdir_grant(&task_api.config.workspace_id, &task_grant_id)
|
||||||
.ok()
|
.map_or(true, |grant| grant.is_some());
|
||||||
.flatten()
|
|
||||||
.is_some_and(|grant| {
|
|
||||||
matches!(grant.status.as_str(), "revoked" | "expired")
|
|
||||||
});
|
|
||||||
if !retryable {
|
if !retryable {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -11973,14 +11952,26 @@ fn schedule_external_workdir_expiry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut tasks = task_api
|
||||||
|
.external_workdir_expiry_tasks
|
||||||
|
.lock()
|
||||||
|
.expect("External Workdir expiry task registry poisoned");
|
||||||
|
if tasks
|
||||||
|
.get(&task_grant_id)
|
||||||
|
.is_some_and(|task| task.task_id == task_instance_id)
|
||||||
|
{
|
||||||
|
tasks.remove(&task_grant_id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let mut tasks = api
|
let mut tasks = api
|
||||||
.external_workdir_expiry_tasks
|
.external_workdir_expiry_tasks
|
||||||
.lock()
|
.lock()
|
||||||
.expect("External Workdir expiry task registry poisoned");
|
.expect("External Workdir expiry task registry poisoned");
|
||||||
if let Some(existing) = tasks.insert(grant_id, ExternalWorkdirExpiryTask { handle }) {
|
if let Some(existing) = tasks.insert(grant_id, ExternalWorkdirExpiryTask { task_id, handle }) {
|
||||||
existing.handle.abort();
|
existing.handle.abort();
|
||||||
}
|
}
|
||||||
|
drop(tasks);
|
||||||
|
let _ = start_sender.send(());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11995,20 +11986,23 @@ fn cancel_external_workdir_expiry(api: &WorkspaceApi, grant_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn expire_external_workdir_grant(api: &WorkspaceApi, grant_id: &str) -> ApiResult<()> {
|
async fn fence_external_workdir_grant_expired(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
grant_id: &str,
|
||||||
|
) -> ApiResult<ExternalWorkdirGrantRecord> {
|
||||||
loop {
|
loop {
|
||||||
let grant = api
|
let mut grant = api
|
||||||
.store
|
.store
|
||||||
.get_external_workdir_grant(&api.config.workspace_id, grant_id)?
|
.get_external_workdir_grant(&api.config.workspace_id, grant_id)?
|
||||||
.ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?;
|
.ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?;
|
||||||
if matches!(grant.status.as_str(), "revoked" | "expired") {
|
if matches!(grant.status.as_str(), "revoked" | "expired") {
|
||||||
break;
|
return Ok(grant);
|
||||||
}
|
}
|
||||||
let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||||
.map(|value| value.with_timezone(&Utc))
|
.map(|value| value.with_timezone(&Utc))
|
||||||
.map_err(|_| Error::Store("External Workdir grant has invalid expiry".to_string()))?;
|
.map_err(|_| Error::Store("External Workdir grant has invalid expiry".to_string()))?;
|
||||||
if expires_at > Utc::now() {
|
if expires_at > Utc::now() {
|
||||||
return Ok(());
|
return Ok(grant);
|
||||||
}
|
}
|
||||||
let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||||
if !api.store.update_external_workdir_grant_state(
|
if !api.store.update_external_workdir_grant_state(
|
||||||
@@ -12050,20 +12044,19 @@ async fn expire_external_workdir_grant(api: &WorkspaceApi, grant_id: &str) -> Ap
|
|||||||
),
|
),
|
||||||
&updated_at,
|
&updated_at,
|
||||||
)?;
|
)?;
|
||||||
break;
|
grant.status = "expired".to_string();
|
||||||
|
grant.updated_at = updated_at;
|
||||||
|
return Ok(grant);
|
||||||
}
|
}
|
||||||
cleanup_external_workdir_attachments_for_grant(api, grant_id).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_external_workdir_attachments_for_grant(
|
async fn expire_external_workdir_grant(api: &WorkspaceApi, grant_id: &str) -> ApiResult<bool> {
|
||||||
api: &WorkspaceApi,
|
let grant = fence_external_workdir_grant_expired(api, grant_id).await?;
|
||||||
grant_id: &str,
|
if !matches!(grant.status.as_str(), "revoked" | "expired") {
|
||||||
) -> ApiResult<()> {
|
return Ok(false);
|
||||||
let grant = api
|
}
|
||||||
.store
|
cleanup_external_workdir_attachments(api, &grant.workdir_id).await?;
|
||||||
.get_external_workdir_grant(&api.config.workspace_id, grant_id)?
|
Ok(true)
|
||||||
.ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?;
|
|
||||||
cleanup_external_workdir_attachments(api, &grant.workdir_id).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_create_external_workdir_grant(
|
async fn scoped_create_external_workdir_grant(
|
||||||
@@ -12166,7 +12159,7 @@ async fn scoped_external_workdir_provider_ws(
|
|||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
) -> ApiResult<Response> {
|
) -> ApiResult<Response> {
|
||||||
validate_workspace_scope(&api, &workspace_id)?;
|
validate_workspace_scope(&api, &workspace_id)?;
|
||||||
let grant = api
|
let mut grant = api
|
||||||
.store
|
.store
|
||||||
.get_external_workdir_grant(&workspace_id, &grant_id)?
|
.get_external_workdir_grant(&workspace_id, &grant_id)?
|
||||||
.ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?;
|
.ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?;
|
||||||
@@ -12180,17 +12173,14 @@ async fn scoped_external_workdir_provider_ws(
|
|||||||
.map(|expires_at| expires_at.with_timezone(&Utc) <= now)
|
.map(|expires_at| expires_at.with_timezone(&Utc) <= now)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if expired {
|
if expired {
|
||||||
api.store.update_external_workdir_grant_state(
|
grant = fence_external_workdir_grant_expired(&api, &grant_id).await?;
|
||||||
&workspace_id,
|
}
|
||||||
&grant_id,
|
if matches!(grant.status.as_str(), "revoked" | "expired") {
|
||||||
&grant.provider_instance_id,
|
|
||||||
grant.generation,
|
|
||||||
"expired",
|
|
||||||
&now.to_rfc3339_opts(SecondsFormat::Nanos, true),
|
|
||||||
)?;
|
|
||||||
cancel_external_workdir_expiry(&api, &grant_id);
|
|
||||||
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
||||||
return Err(Error::InvalidInput("External Workdir grant has expired".to_string()).into());
|
cancel_external_workdir_expiry(&api, &grant_id);
|
||||||
|
return Err(
|
||||||
|
Error::InvalidInput(format!("External Workdir grant is {}", grant.status)).into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(ws
|
Ok(ws
|
||||||
.max_message_size(workdir::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES)
|
.max_message_size(workdir::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES)
|
||||||
@@ -12551,24 +12541,13 @@ async fn scoped_get_external_workdir_grant(
|
|||||||
.map(|expires_at| expires_at.with_timezone(&Utc) <= now)
|
.map(|expires_at| expires_at.with_timezone(&Utc) <= now)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if expired {
|
if expired {
|
||||||
let updated_at = now.to_rfc3339_opts(SecondsFormat::Nanos, true);
|
grant = fence_external_workdir_grant_expired(&api, &grant_id).await?;
|
||||||
if api.store.update_external_workdir_grant_state(
|
|
||||||
&workspace_id,
|
|
||||||
&grant_id,
|
|
||||||
&grant.provider_instance_id,
|
|
||||||
grant.generation,
|
|
||||||
"expired",
|
|
||||||
&updated_at,
|
|
||||||
)? {
|
|
||||||
grant.status = "expired".to_string();
|
|
||||||
grant.updated_at = updated_at;
|
|
||||||
}
|
|
||||||
cancel_external_workdir_expiry(&api, &grant_id);
|
|
||||||
}
|
}
|
||||||
// Terminal cleanup is retryable: a prior expiry/revoke may have committed
|
// Terminal cleanup is retryable: a prior expiry/revoke may have committed
|
||||||
// its fence before Runtime/session projection completed.
|
// its fence before Runtime/session projection completed.
|
||||||
if matches!(grant.status.as_str(), "expired" | "revoked") {
|
if matches!(grant.status.as_str(), "expired" | "revoked") {
|
||||||
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
||||||
|
cancel_external_workdir_expiry(&api, &grant_id);
|
||||||
}
|
}
|
||||||
Ok(Json(external_workdir_grant_response(&grant)))
|
Ok(Json(external_workdir_grant_response(&grant)))
|
||||||
}
|
}
|
||||||
@@ -12615,7 +12594,6 @@ async fn scoped_revoke_external_workdir_grant(
|
|||||||
}
|
}
|
||||||
grant.status = "revoked".to_string();
|
grant.status = "revoked".to_string();
|
||||||
grant.updated_at = updated_at;
|
grant.updated_at = updated_at;
|
||||||
cancel_external_workdir_expiry(&api, &grant_id);
|
|
||||||
api.store.record_external_workdir_audit(
|
api.store.record_external_workdir_audit(
|
||||||
&workspace_id,
|
&workspace_id,
|
||||||
"account",
|
"account",
|
||||||
@@ -12634,12 +12612,21 @@ async fn scoped_revoke_external_workdir_grant(
|
|||||||
// Cleanup is deliberately retried for terminal grants. A prior revoke may
|
// Cleanup is deliberately retried for terminal grants. A prior revoke may
|
||||||
// have committed its fence before a Runtime/session projection failure.
|
// have committed its fence before a Runtime/session projection failure.
|
||||||
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
||||||
|
cancel_external_workdir_expiry(&api, &grant_id);
|
||||||
Ok(Json(external_workdir_grant_response(&grant)))
|
Ok(Json(external_workdir_grant_response(&grant)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_external_workdir_attachments(
|
async fn cleanup_external_workdir_attachments(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
workdir_id: &str,
|
workdir_id: &str,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
cleanup_external_workdir_attachments_with_lock(api, workdir_id, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_external_workdir_attachments_with_lock(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workdir_id: &str,
|
||||||
|
already_locked_worker: Option<&RuntimeWorkerRef>,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let workdir = api
|
let workdir = api
|
||||||
.store
|
.store
|
||||||
@@ -12657,7 +12644,7 @@ async fn cleanup_external_workdir_attachments(
|
|||||||
&now,
|
&now,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
match release_external_workdir_attachments(api, workdir_id).await {
|
match release_external_workdir_attachments(api, workdir_id, already_locked_worker).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let completed_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
let completed_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||||
api.store.update_external_workdir_cleanup_state(
|
api.store.update_external_workdir_cleanup_state(
|
||||||
@@ -12705,16 +12692,66 @@ async fn cleanup_external_workdir_attachments(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn release_external_workdir_attachments(
|
#[cfg(test)]
|
||||||
|
fn external_workdir_cleanup_failure_slot() -> &'static Mutex<Option<String>> {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static INJECTED_WORKDIR: OnceLock<Mutex<Option<String>>> = OnceLock::new();
|
||||||
|
INJECTED_WORKDIR.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn inject_external_workdir_cleanup_failure(workdir_id: &str) {
|
||||||
|
*external_workdir_cleanup_failure_slot()
|
||||||
|
.lock()
|
||||||
|
.expect("External cleanup injection lock poisoned") = Some(workdir_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn take_external_workdir_cleanup_failure(workdir_id: &str) -> bool {
|
||||||
|
let mut injected = external_workdir_cleanup_failure_slot()
|
||||||
|
.lock()
|
||||||
|
.expect("External cleanup injection lock poisoned");
|
||||||
|
if injected.as_deref() == Some(workdir_id) {
|
||||||
|
*injected = None;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn external_workdir_cleanup_reconciliations() -> &'static Mutex<HashMap<String, usize>> {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static RECONCILIATIONS: OnceLock<Mutex<HashMap<String, usize>>> = OnceLock::new();
|
||||||
|
RECONCILIATIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn record_external_workdir_cleanup_reconciliation(workdir_id: &str) {
|
||||||
|
let mut reconciliations = external_workdir_cleanup_reconciliations()
|
||||||
|
.lock()
|
||||||
|
.expect("External cleanup reconciliation lock poisoned");
|
||||||
|
*reconciliations.entry(workdir_id.to_string()).or_default() += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn external_workdir_cleanup_reconciliation_count(workdir_id: &str) -> usize {
|
||||||
|
external_workdir_cleanup_reconciliations()
|
||||||
|
.lock()
|
||||||
|
.expect("External cleanup reconciliation lock poisoned")
|
||||||
|
.get(workdir_id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_external_workdir_attachment_locked(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
workdir_id: &str,
|
workdir_id: &str,
|
||||||
|
link: &WorkerWorkdirLinkRecord,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let links = api
|
if link.unlinked_at.is_none() {
|
||||||
.store
|
|
||||||
.list_workdir_worker_links(&api.config.workspace_id, workdir_id)?;
|
|
||||||
for link in links.into_iter().filter(|link| link.unlinked_at.is_none()) {
|
|
||||||
let lock = current_worker_session_lock(api, &link.worker);
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
close_current_worker_attachment_session_locked(api, &link.worker).await?;
|
close_current_worker_attachment_session_locked(api, &link.worker).await?;
|
||||||
close_current_worker_command_sessions_for_alias_locked(api, &link.worker, &link.alias)
|
close_current_worker_command_sessions_for_alias_locked(api, &link.worker, &link.alias)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -12724,10 +12761,47 @@ async fn release_external_workdir_attachments(
|
|||||||
Some(workdir_id),
|
Some(workdir_id),
|
||||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||||
)?;
|
)?;
|
||||||
|
#[cfg(test)]
|
||||||
|
if take_external_workdir_cleanup_failure(workdir_id) {
|
||||||
|
return Err(Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: "workspace-backend".to_string(),
|
||||||
|
code: "injected_external_workdir_cleanup_failure".to_string(),
|
||||||
|
message: "injected External Workdir attachment cleanup failure".to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reconcile even an already-unlinked durable link. A previous cleanup may
|
||||||
|
// have committed detach before Runtime persistence or projection failed.
|
||||||
|
#[cfg(test)]
|
||||||
|
record_external_workdir_cleanup_reconciliation(workdir_id);
|
||||||
sync_runtime_worker_workdir_attachments(api, &link.worker)?;
|
sync_runtime_worker_workdir_attachments(api, &link.worker)?;
|
||||||
api.worker_projection
|
api.worker_projection
|
||||||
.refresh(&link.worker)
|
.refresh(&link.worker)
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_external_workdir_attachments(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workdir_id: &str,
|
||||||
|
already_locked_worker: Option<&RuntimeWorkerRef>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
let Some(link) = api
|
||||||
|
.store
|
||||||
|
.latest_workdir_worker_link(&api.config.workspace_id, workdir_id)?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// The newest link is the only possible active attachment and remains the
|
||||||
|
// durable reconciliation target if detach committed before Runtime update.
|
||||||
|
// Missing/ambiguous Runtime state remains a retryable cleanup failure.
|
||||||
|
if already_locked_worker == Some(&link.worker) {
|
||||||
|
release_external_workdir_attachment_locked(api, workdir_id, &link).await?;
|
||||||
|
} else {
|
||||||
|
let lock = current_worker_session_lock(api, &link.worker);
|
||||||
|
let _guard = lock.lock().await;
|
||||||
|
release_external_workdir_attachment_locked(api, workdir_id, &link).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -21226,7 +21300,13 @@ mod tests {
|
|||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
if cleanup_state == "completed" {
|
if cleanup_state == "completed"
|
||||||
|
&& !api
|
||||||
|
.external_workdir_expiry_tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&grant.grant_id)
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21237,6 +21317,219 @@ mod tests {
|
|||||||
.expect("Backend-owned expiry must run without a provider socket");
|
.expect("Backend-owned expiry must run without a provider socket");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_open_expiry_persists_cleanup_retry_without_deadlocking() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(dir.path());
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let Json(created_worker) = create_workspace_worker(
|
||||||
|
State(api.clone()),
|
||||||
|
HeaderMap::new(),
|
||||||
|
Json(CreateWorkspaceWorkerRequest {
|
||||||
|
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||||
|
display_name: "External expiry Worker".to_string(),
|
||||||
|
profile: Some("builtin:coder".to_string()),
|
||||||
|
ticket_assignment: None,
|
||||||
|
initial_submit: Vec::new(),
|
||||||
|
workdir_attachments: Vec::new(),
|
||||||
|
control_operation_id: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let worker = RuntimeWorkerRef::new(&created_worker.runtime_id, &created_worker.worker_id);
|
||||||
|
let now = Utc::now();
|
||||||
|
let created_at = now.to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||||
|
let grant = ExternalWorkdirGrantRecord {
|
||||||
|
grant_id: "session-open-expiry-grant".to_string(),
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
workdir_id: "session-open-expiry-workdir".to_string(),
|
||||||
|
provider_instance_id: "session-open-expiry-provider".to_string(),
|
||||||
|
display_name: "Session-open expiry".to_string(),
|
||||||
|
permissions: "read_only".to_string(),
|
||||||
|
created_by: test_browser_request_actor().account_id,
|
||||||
|
created_at: created_at.clone(),
|
||||||
|
expires_at: (now + Duration::minutes(5)).to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||||
|
generation: 1,
|
||||||
|
status: "online".to_string(),
|
||||||
|
updated_at: created_at.clone(),
|
||||||
|
};
|
||||||
|
api.store
|
||||||
|
.create_external_workdir_grant(
|
||||||
|
&grant,
|
||||||
|
&WorkdirRegistryRecord {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
workdir_id: grant.workdir_id.clone(),
|
||||||
|
display_name: Some(grant.display_name.clone()),
|
||||||
|
source: WorkdirRegistrySource::ExternalGrant {
|
||||||
|
grant_id: grant.grant_id.clone(),
|
||||||
|
},
|
||||||
|
creation_selector: None,
|
||||||
|
creation_ref: None,
|
||||||
|
creation_tree: None,
|
||||||
|
current_selector: None,
|
||||||
|
current_ref: None,
|
||||||
|
current_tree: None,
|
||||||
|
observed_at_epoch_seconds: None,
|
||||||
|
materialization_status: "present".to_string(),
|
||||||
|
cleanliness: "unknown".to_string(),
|
||||||
|
created_at: created_at.clone(),
|
||||||
|
updated_at: created_at.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
schedule_external_workdir_expiry(&api, &grant).unwrap();
|
||||||
|
let link = WorkerWorkdirLinkRecord {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
worker: worker.clone(),
|
||||||
|
workdir_id: grant.workdir_id.clone(),
|
||||||
|
alias: "expired-external".to_string(),
|
||||||
|
linked_at: created_at,
|
||||||
|
unlinked_at: None,
|
||||||
|
};
|
||||||
|
api.store.attach_worker_workdir(&link).unwrap();
|
||||||
|
sync_runtime_worker_workdir_attachments(&api, &worker).unwrap();
|
||||||
|
assert!(
|
||||||
|
!expire_external_workdir_grant(&api, &grant.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
"an early scheduler wake must retain ownership without cleanup"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.list_workdir_worker_links(TEST_WORKSPACE_ID, &grant.workdir_id)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|current| current.unlinked_at.is_none())
|
||||||
|
);
|
||||||
|
api.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE external_workdir_grants SET expires_at = ?1 WHERE workspace_id = ?2 AND grant_id = ?3",
|
||||||
|
rusqlite::params![
|
||||||
|
(now - Duration::seconds(1))
|
||||||
|
.to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
grant.grant_id,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
inject_external_workdir_cleanup_failure(&grant.workdir_id);
|
||||||
|
let session_lock = current_worker_session_lock(&api, &worker);
|
||||||
|
let session_guard = session_lock.lock().await;
|
||||||
|
let error = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
open_current_worker_workdir_session_locked(&api, &worker, &link),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("session-open expiry cleanup must not reacquire its Worker lock")
|
||||||
|
.expect_err("injected attachment cleanup must surface");
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
Error::RuntimeOperationFailed { ref code, .. }
|
||||||
|
if code == "injected_external_workdir_cleanup_failure"
|
||||||
|
));
|
||||||
|
let (status, cleanup_state, cleanup_error): (String, String, Option<String>) = api
|
||||||
|
.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT status, cleanup_state, cleanup_error FROM external_workdir_grants WHERE workspace_id = ?1 AND grant_id = ?2",
|
||||||
|
rusqlite::params![TEST_WORKSPACE_ID, grant.grant_id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
|
)
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status, "expired");
|
||||||
|
assert_eq!(cleanup_state, "retry_required");
|
||||||
|
assert_eq!(cleanup_error.as_deref(), Some("attachment_release_failed"));
|
||||||
|
assert!(
|
||||||
|
api.external_workdir_expiry_tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&grant.grant_id),
|
||||||
|
"retry ownership must remain until cleanup completes"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.list_workdir_worker_links(TEST_WORKSPACE_ID, &grant.workdir_id)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|current| current.unlinked_at.is_some()),
|
||||||
|
"durable detach commits before the injected Runtime reconciliation failure"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
external_workdir_cleanup_reconciliation_count(&grant.workdir_id),
|
||||||
|
0,
|
||||||
|
"injected failure occurs after durable detach but before Runtime reconciliation"
|
||||||
|
);
|
||||||
|
drop(session_guard);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
expire_external_workdir_grant(&api, &grant.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
cancel_external_workdir_expiry(&api, &grant.grant_id);
|
||||||
|
let (cleanup_state, cleanup_error): (String, Option<String>) = api
|
||||||
|
.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT cleanup_state, cleanup_error FROM external_workdir_grants WHERE workspace_id = ?1 AND grant_id = ?2",
|
||||||
|
rusqlite::params![TEST_WORKSPACE_ID, grant.grant_id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cleanup_state, "completed");
|
||||||
|
assert_eq!(cleanup_error, None);
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.list_workdir_worker_links(TEST_WORKSPACE_ID, &grant.workdir_id)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|current| current.unlinked_at.is_some())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
external_workdir_cleanup_reconciliation_count(&grant.workdir_id),
|
||||||
|
1,
|
||||||
|
"retry must reconcile an already-unlinked durable attachment"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
api.runtime
|
||||||
|
.worker(&worker)
|
||||||
|
.unwrap()
|
||||||
|
.workdir_attachments
|
||||||
|
.iter()
|
||||||
|
.all(
|
||||||
|
|attachment| attachment.working_directory.summary.working_directory_id
|
||||||
|
!= grant.workdir_id
|
||||||
|
),
|
||||||
|
"retry must reconcile Runtime state for an already-unlinked durable attachment"
|
||||||
|
);
|
||||||
|
let audit_actions: Vec<String> = api
|
||||||
|
.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT action FROM audit_events WHERE workspace_id = ?1 AND target_kind = 'external_workdir_grant' AND target_id = ?2 ORDER BY created_at",
|
||||||
|
)?;
|
||||||
|
statement
|
||||||
|
.query_map(rusqlite::params![TEST_WORKSPACE_ID, grant.grant_id], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(audit_actions.contains(&"external_workdir_grant_expired".to_string()));
|
||||||
|
assert!(audit_actions.contains(&"external_workdir_cleanup_attention_required".to_string()));
|
||||||
|
assert!(audit_actions.contains(&"external_workdir_cleanup_completed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn external_workdirs_are_launchable_with_real_multiple_attachments() {
|
async fn external_workdirs_are_launchable_with_real_multiple_attachments() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1743,6 +1743,11 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
workdir_id: &str,
|
workdir_id: &str,
|
||||||
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
||||||
|
fn latest_workdir_worker_link(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> Result<Option<WorkerWorkdirLinkRecord>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -8293,6 +8298,26 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn latest_workdir_worker_link(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
workdir_id: &str,
|
||||||
|
) -> Result<Option<WorkerWorkdirLinkRecord>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at
|
||||||
|
FROM worker_workdir_links
|
||||||
|
WHERE workspace_id = ?1 AND workdir_id = ?2
|
||||||
|
ORDER BY linked_at DESC, rowid DESC
|
||||||
|
LIMIT 1"#,
|
||||||
|
params![workspace_id, workdir_id],
|
||||||
|
read_worker_workdir_link_record,
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flow_source_name(path: &str) -> Result<String> {
|
fn flow_source_name(path: &str) -> Result<String> {
|
||||||
|
|||||||
@@ -77,11 +77,29 @@ impl Drop for OperationTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pin_selected_root(path: &std::path::Path) -> Result<ExternalWorkdirRoot, String> {
|
||||||
|
let cwd = std::env::current_dir()
|
||||||
|
.map_err(|error| format!("failed to resolve current directory: {error}"))?;
|
||||||
|
pin_selected_root_from(path, &cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pin_selected_root_from(
|
||||||
|
path: &std::path::Path,
|
||||||
|
cwd: &std::path::Path,
|
||||||
|
) -> Result<ExternalWorkdirRoot, String> {
|
||||||
|
let absolute = if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
cwd.join(path)
|
||||||
|
};
|
||||||
|
ExternalWorkdirRoot::pin(absolute).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||||
// Pin before creating remote authority: the Backend grant can never outlive
|
// Pin before creating remote authority: the Backend grant can never outlive
|
||||||
// a failed local approval/open race, and subsequent path replacement cannot
|
// a failed local approval/open race, and subsequent path replacement cannot
|
||||||
// redirect the provider.
|
// redirect the provider.
|
||||||
let pinned_root = ExternalWorkdirRoot::pin(&options.path).map_err(|error| error.to_string())?;
|
let pinned_root = pin_selected_root(&options.path)?;
|
||||||
|
|
||||||
let client = BackendApiClient::from_stored_token(&options.backend_url)
|
let client = BackendApiClient::from_stored_token(&options.backend_url)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
@@ -619,6 +637,16 @@ fn encode_path_segment(value: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_share_path_is_resolved_and_pinned_before_backend_access() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let pinned = pin_selected_root_from(std::path::Path::new("."), directory.path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pinned.canonical_path(),
|
||||||
|
directory.path().canonicalize().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn operation_shutdown_waits_until_executor_has_stopped() {
|
async fn operation_shutdown_waits_until_executor_has_stopped() {
|
||||||
let operation_id = ExternalWorkdirOperationId::new("op-cancel-test").unwrap();
|
let operation_id = ExternalWorkdirOperationId::new("op-cancel-test").unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user