feat: authorize scoped symlink paths lexically

This commit is contained in:
2026-09-09 00:51:21 +09:00
parent 18fd6a1f5e
commit fcc7d79d80
7 changed files with 194 additions and 167 deletions
+4 -12
View File
@@ -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, direct_symlink}; 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(
@@ -18,21 +18,13 @@ pub fn run_glob(
if !access.is_readable(base) { if !access.is_readable(base) {
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str()))); return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
} }
if let Some(info) = direct_symlink(base)
&& info.target_exists
&& info.resolved_path.is_dir()
{
return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Glob",
path: PathBuf::from(request.path.as_str()),
target: PathBuf::from("<provider-internal target>"),
});
}
let matcher = Glob::new(&request.pattern) let matcher = Glob::new(&request.pattern)
.map_err(|error| FsError::InvalidGlob(error.to_string()))? .map_err(|error| FsError::InvalidGlob(error.to_string()))?
.compile_matcher(); .compile_matcher();
let mut matches = Vec::new(); let mut matches = Vec::new();
for entry in WalkBuilder::new(base).hidden(false).build().flatten() { let mut walker = WalkBuilder::new(base);
walker.hidden(false).follow_links(false);
for entry in walker.build().flatten() {
let path = entry.path(); let path = entry.path();
if !path.is_file() || !access.is_readable(path) { if !path.is_file() || !access.is_readable(path) {
continue; continue;
+26 -8
View File
@@ -477,13 +477,14 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() { fn grep_traverses_a_direct_symlink_directory_and_rejects_a_broken_path() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap(); let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone()); let readable = RootAccess(root.clone());
std::fs::create_dir(root.join("target-dir")).unwrap(); std::fs::create_dir(root.join("target-dir")).unwrap();
std::fs::write(root.join("target-dir/nested.rs"), "needle nested\n").unwrap();
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap(); std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap(); symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
symlink(root.join("target-dir"), root.join("directory-link")).unwrap(); symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
@@ -501,18 +502,35 @@ mod tests {
assert_eq!(file_result.match_count, 1); assert_eq!(file_result.match_count, 1);
assert!(file_result.output.starts_with("file-link.rs\n")); assert!(file_result.output.starts_with("file-link.rs\n"));
let directory_error = run_grep( let directory_result = run_grep(
&root, &root,
root.join("directory-link"), root.join("directory-link"),
request("directory-link"), request("directory-link"),
&readable, &readable,
) )
.unwrap_err(); .unwrap();
assert!(matches!( assert_eq!(directory_result.match_count, 1);
directory_error, assert!(
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. } directory_result
if path == root.join("directory-link") .output
)); .starts_with("directory-link/nested.rs\n")
);
let glob_result = run_glob(
&root,
&root.join("directory-link"),
GlobRequest {
pattern: "**/*.rs".to_string(),
path: FsPath::new("directory-link").unwrap(),
limit: 10,
},
&readable,
)
.unwrap();
assert_eq!(
glob_result.paths,
vec![FsPath::new("directory-link/nested.rs").unwrap()]
);
let broken_error = run_grep( let broken_error = run_grep(
&root, &root,
+9 -8
View File
@@ -45,7 +45,7 @@ pub fn run_read(
) -> Result<ReadResult, FsError> { ) -> Result<ReadResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false)?; let path = require_access(&path, &logical, access, false, false)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() { if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -99,7 +99,7 @@ pub fn run_write(
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let created = !path.exists(); let created = !path.exists();
if path.exists() { if path.exists() {
let target = require_access(&path, &logical, access, true)?; let target = require_access(&path, &logical, access, true, false)?;
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?; let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() { if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -117,7 +117,7 @@ pub fn run_write(
FsError::InvalidArgument(format!("{} has no parent", logical.as_str())) FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
})?; })?;
let parent_logical = logical_parent(&logical); let parent_logical = logical_parent(&logical);
require_access(parent, &parent_logical, access, true)?; require_access(parent, &parent_logical, access, true, true)?;
atomic_write(&path, &request.content, &logical)?; atomic_write(&path, &request.content, &logical)?;
} }
Ok(WriteResult { Ok(WriteResult {
@@ -133,7 +133,7 @@ pub fn run_edit(
) -> Result<EditResult, FsError> { ) -> Result<EditResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let target = require_access(&path, &logical, access, true)?; let target = require_access(&path, &logical, access, true, false)?;
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?; let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
let actual_hash = hash_bytes(&bytes); let actual_hash = hash_bytes(&bytes);
if actual_hash != request.expected_hash { if actual_hash != request.expected_hash {
@@ -173,7 +173,7 @@ pub fn run_list(
) -> Result<ListResult, FsError> { ) -> Result<ListResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false)?; let path = require_access(&path, &logical, access, false, true)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if !metadata.is_dir() { if !metadata.is_dir() {
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
@@ -247,6 +247,7 @@ fn require_access(
logical: &FsPath, logical: &FsPath,
access: &dyn FsAccessPolicy, access: &dyn FsAccessPolicy,
write: bool, write: bool,
allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> { ) -> Result<PathBuf, FsError> {
if let Some(info) = direct_symlink(path) { if let Some(info) = direct_symlink(path) {
if !info.target_exists { if !info.target_exists {
@@ -257,9 +258,9 @@ fn require_access(
}); });
} }
let allowed = if write { let allowed = if write {
access.is_writable(&info.resolved_path) access.is_writable(path)
} else { } else {
access.is_readable(&info.resolved_path) access.is_readable(path)
}; };
if !allowed { if !allowed {
return Err(FsError::SymlinkOutOfScope { return Err(FsError::SymlinkOutOfScope {
@@ -268,7 +269,7 @@ fn require_access(
required_permission: if write { "write" } else { "read" }, required_permission: if write { "write" } else { "read" },
}); });
} }
if write && info.resolved_path.is_dir() { if !allow_symlink_directory && info.resolved_path.is_dir() {
return Err(FsError::SymlinkTargetIsDirectory { return Err(FsError::SymlinkTargetIsDirectory {
path: PathBuf::from(logical.as_str()), path: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"), target: PathBuf::from("<provider-internal target>"),
-10
View File
@@ -259,16 +259,6 @@ pub fn run_grep(
base.display() base.display()
))); )));
} }
if base_meta.is_dir()
&& let Some(info) = symlink.as_ref()
{
return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Grep",
path: base.clone(),
target: info.resolved_path.clone(),
});
}
let filter_base = if base_meta.is_file() { root } else { &base }; let filter_base = if base_meta.is_file() { root } else { &base };
let types = build_types(p.file_type.as_deref())?; let types = build_types(p.file_type.as_deref())?;
let overrides = build_overrides(filter_base, p.glob.as_deref())?; let overrides = build_overrides(filter_base, p.glob.as_deref())?;
+47 -36
View File
@@ -3,11 +3,11 @@
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every //! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
//! rule `target` must already be an absolute path — per-layer path //! rule `target` must already be an absolute path — per-layer path
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`]. //! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
//! All rule `target` paths inside the [`Scope`] are canonicalised (where //! All rule `target` paths inside the [`Scope`] are normalized lexically so
//! possible) so access checks are pure path comparisons. //! access authority follows the path presented through the Workdir, not a
//! symbolic-link target outside that logical tree.
use std::ffi::OsString; use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use arc_swap::{ArcSwap, Guard}; use arc_swap::{ArcSwap, Guard};
@@ -26,7 +26,7 @@ pub struct Scope {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedRule { struct ResolvedRule {
/// Absolute, canonicalized-or-normalized target directory/file. /// Absolute, lexically normalized target directory/file.
target: PathBuf, target: PathBuf,
permission: Permission, permission: Permission,
recursive: bool, recursive: bool,
@@ -201,9 +201,14 @@ impl Scope {
} }
/// Convenience constructor for tests and simple setups: a single /// Convenience constructor for tests and simple setups: a single
/// recursive `allow(Write)` rule rooted at `root`. /// recursive `allow(Write)` rule rooted at the lexical path `root`.
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> { pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
let root = root.as_ref().canonicalize()?; let root = normalize_path(root.as_ref()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"scope root must be an absolute path without root traversal",
)
})?;
Ok(Self { Ok(Self {
allow: vec![ResolvedRule { allow: vec![ResolvedRule {
target: root, target: root,
@@ -214,8 +219,7 @@ impl Scope {
}) })
} }
/// Resolve one rule target with the same symlink and missing-tail semantics /// Return one rule's lexically normalized target without resolving symlinks.
/// used by scope matching.
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> { pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
Ok(resolve_rule(rule)?.target) Ok(resolve_rule(rule)?.target)
} }
@@ -244,7 +248,7 @@ impl Scope {
/// Returns `None` when `path` is outside every allow rule, or when /// Returns `None` when `path` is outside every allow rule, or when
/// deny rules have knocked it below `Read`. /// deny rules have knocked it below `Read`.
pub fn permission_at(&self, path: &Path) -> Option<Permission> { pub fn permission_at(&self, path: &Path) -> Option<Permission> {
let resolved = resolve_path(path)?; let resolved = normalize_path(path)?;
let mut effective: Option<Permission> = None; let mut effective: Option<Permission> = None;
for rule in &self.allow { for rule in &self.allow {
if rule.matches(&resolved) { if rule.matches(&resolved) {
@@ -523,7 +527,7 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
if !rule.target.is_absolute() { if !rule.target.is_absolute() {
return Err(ScopeError::RelativeTarget(rule.target.clone())); return Err(ScopeError::RelativeTarget(rule.target.clone()));
} }
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget { let target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
path: rule.target.clone(), path: rule.target.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"), source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
})?; })?;
@@ -534,37 +538,27 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
}) })
} }
/// Convert `path` to an absolute form suitable for prefix comparison. /// Normalize an absolute path for lexical scope comparison without consulting
/// /// filesystem metadata or resolving symbolic links.
/// Tries `canonicalize` on the full path first (resolves symlinks). If fn normalize_path(path: &Path) -> Option<PathBuf> {
/// the path doesn't exist yet, climbs to the closest existing ancestor,
/// canonicalizes it, then rejoins the missing tail. Returns `None` for
/// relative inputs that have no existing ancestor to anchor against.
fn resolve_path(path: &Path) -> Option<PathBuf> {
if !path.is_absolute() { if !path.is_absolute() {
return None; return None;
} }
if let Ok(canonical) = path.canonicalize() { let mut normalized = PathBuf::new();
return Some(canonical); for component in path.components() {
} match component {
let mut tail: Vec<OsString> = Vec::new(); Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
let mut cur = path.to_path_buf(); Component::RootDir => normalized.push(component.as_os_str()),
loop { Component::CurDir => {}
if let Ok(canonical) = cur.canonicalize() { Component::ParentDir => {
let mut out = canonical; if !normalized.pop() {
for segment in tail.iter().rev() { return None;
out.push(segment); }
} }
return Some(out); Component::Normal(part) => normalized.push(part),
} }
let name = cur.file_name()?.to_os_string();
tail.push(name);
let parent = cur.parent()?.to_path_buf();
if parent == cur {
return None;
}
cur = parent;
} }
normalized.is_absolute().then_some(normalized)
} }
#[cfg(test)] #[cfg(test)]
@@ -805,6 +799,23 @@ mod tests {
assert!(!scope.is_readable(&traversal)); assert!(!scope.is_readable(&traversal));
} }
#[cfg(unix)]
#[test]
fn scope_authorizes_symlink_paths_lexically_without_authorizing_targets() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap();
symlink(outside.path(), dir.path().join("external")).unwrap();
let scope = Scope::writable(dir.path()).unwrap();
assert!(scope.is_readable(&dir.path().join("external/outside.txt")));
assert!(scope.is_writable(&dir.path().join("external/new.txt")));
assert!(!scope.is_readable(&outside.path().join("outside.txt")));
assert!(!scope.is_writable(&outside.path().join("new.txt")));
}
#[test] #[test]
fn summary_lists_readable_and_writable() { fn summary_lists_readable_and_writable() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
+73 -18
View File
@@ -1635,7 +1635,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn read_bytes_reports_symlink_target_outside_scope() { fn read_bytes_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1646,15 +1646,7 @@ mod tests {
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_fs(&dir);
let err = fs.read_bytes(&link).unwrap_err(); assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
assert!(
matches!(
err,
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
if path == &link && err_target == &target.canonicalize().unwrap()
),
"expected symlink out-of-scope diagnostic, got {err:?}"
);
} }
#[cfg(unix)] #[cfg(unix)]
@@ -1746,7 +1738,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn write_reports_symlink_target_outside_scope() { fn write_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1757,14 +1749,13 @@ mod tests {
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_fs(&dir);
let err = fs.write(&link, b"new").unwrap_err(); fs.write(&link, b"new").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert!( assert!(
matches!( fs::symlink_metadata(&link)
err, .unwrap()
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" } .file_type()
if path == &link && err_target == &target.canonicalize().unwrap() .is_symlink()
),
"expected write symlink out-of-scope diagnostic, got {err:?}"
); );
} }
@@ -1942,6 +1933,70 @@ mod tests {
)); ));
} }
#[cfg(unix)]
#[tokio::test]
async fn provider_uses_logical_paths_through_symlinked_directories() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap();
symlink(outside.path(), dir.path().join("yoi.local")).unwrap();
let workdir = make_fs(&dir);
let read = WorkdirSession::read(
&workdir,
ReadRequest {
path: WorkdirPath::new("yoi.local/worker.json").unwrap(),
offset: 0,
limit: 100,
max_bytes: 1024,
},
)
.await
.unwrap();
assert_eq!(read.bytes, b"scope-needle\n");
let glob = WorkdirSession::glob(
&workdir,
GlobRequest {
pattern: "**/*.json".into(),
path: WorkdirPath::new("yoi.local").unwrap(),
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
glob.paths,
[WorkdirPath::new("yoi.local/worker.json").unwrap()]
);
let grep = WorkdirSession::grep(
&workdir,
GrepRequest {
pattern: "scope-needle".into(),
path: WorkdirPath::new("yoi.local").unwrap(),
glob: Some("*.json".into()),
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: crate::GrepOutputMode::Content,
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(grep.match_count, 1);
assert!(grep.output.contains("yoi.local/worker.json"));
assert!(
!workdir
.scope()
.is_readable(&outside.path().join("worker.json"))
);
}
#[tokio::test] #[tokio::test]
async fn provider_executes_glob_grep_and_command_at_the_materialization() { async fn provider_executes_glob_grep_and_command_at_the_materialization() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
+35 -75
View File
@@ -492,51 +492,9 @@ impl ScopedWorkdirSession {
} }
} }
async fn ensure_source_path_has_no_symlink(&self, path: &FsPath) -> Result<(), WorkdirError> { fn resolve_operation_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
let mut current = String::new();
for component in Path::new(path.as_str()).components() {
let component = component.as_os_str().to_string_lossy();
if component.is_empty() || component == "." {
continue;
}
if !current.is_empty() {
current.push('/');
}
current.push_str(&component);
let current = FsPath::new(&current).map_err(|error| {
WorkdirError::Denied(format!("invalid scoped Workdir path: {error}"))
})?;
match self.source.stat(StatRequest { path: current }).await {
Ok(result) if result.kind == fs_operation::EntryKind::Symlink => {
return Err(WorkdirError::Denied(format!(
"scoped Workdir path `{path}` traverses a symlink"
)));
}
Ok(_) => {}
Err(WorkdirError::NotFound(_)) => break,
Err(error) => return Err(error),
}
}
Ok(())
}
async fn ensure_scope_targets_do_not_traverse_symlinks(
&self,
rules: &[WorkdirToolScopeRule],
) -> Result<(), WorkdirError> {
for rule in rules {
self.ensure_source_path_has_no_symlink(&rule.target).await?;
}
Ok(())
}
async fn resolve_operation_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
self.ensure_active()?; self.ensure_active()?;
let resolved = self.resolve_path(path)?; self.resolve_path(path)
if self.scope.is_some() {
self.ensure_source_path_has_no_symlink(&resolved).await?;
}
Ok(resolved)
} }
fn validate_scope( fn validate_scope(
@@ -624,8 +582,6 @@ impl ScopedWorkdirSession {
request.cwd request.cwd
))); )));
} }
self.ensure_scope_targets_do_not_traverse_symlinks(&request.rules)
.await?;
let validity = SessionValidity::child(self.validity.clone()); let validity = SessionValidity::child(self.validity.clone());
let cleanup_pending = Arc::new(AtomicBool::new(true)); let cleanup_pending = Arc::new(AtomicBool::new(true));
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
@@ -736,49 +692,49 @@ impl WorkdirSession for ScopedWorkdirSession {
} }
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> { async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?; let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.stat(request).await self.source.stat(request).await
} }
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> { async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?; let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.read(request).await self.source.read(request).await
} }
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> { async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?; let path = self.resolve_operation_path(&request.path)?;
self.ensure_write(&path, WorkdirSessionCapability::Write)?; self.ensure_write(&path, WorkdirSessionCapability::Write)?;
request.path = path; request.path = path;
self.source.write(request).await self.source.write(request).await
} }
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> { async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?; let path = self.resolve_operation_path(&request.path)?;
self.ensure_write(&path, WorkdirSessionCapability::Edit)?; self.ensure_write(&path, WorkdirSessionCapability::Edit)?;
request.path = path; request.path = path;
self.source.edit(request).await self.source.edit(request).await
} }
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> { async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?; let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.list(request).await self.source.list(request).await
} }
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> { async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?; let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Glob)?; self.ensure_read(&path, WorkdirSessionCapability::Glob)?;
request.path = path; request.path = path;
self.source.glob(request).await self.source.glob(request).await
} }
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> { async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?; let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Grep)?; self.ensure_read(&path, WorkdirSessionCapability::Grep)?;
request.path = path; request.path = path;
self.source.grep(request).await self.source.grep(request).await
@@ -1487,7 +1443,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn provider_scope_denies_read_through_symlink_outside_grant() { async fn provider_scope_allows_read_through_its_logical_symlink_path() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1501,16 +1457,12 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let result = child.read(read("link")).await; assert_eq!(child.read(read("link")).await.unwrap().bytes, b"hidden");
assert!(
result.is_err(),
"symlink read escaped provider scope: {result:?}"
);
} }
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn provider_scope_denies_write_through_symlink_outside_grant() { async fn provider_scope_allows_write_through_its_logical_symlink_path() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1523,17 +1475,19 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let result = child.write(write("outside/new", "forbidden")).await; child
assert!( .write(write("outside/new", "through-logical-path"))
result.is_err(), .await
"symlink write escaped provider scope: {result:?}" .unwrap();
assert_eq!(
fs::read_to_string(root.path().join("secret/new")).unwrap(),
"through-logical-path"
); );
assert!(!root.path().join("secret/new").exists());
} }
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn write_delegation_rejects_symlink_target_before_lease() { async fn write_delegation_leases_the_logical_symlink_path() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1542,19 +1496,25 @@ mod tests {
symlink("../secret", root.path().join("granted/outside")).unwrap(); symlink("../secret", root.path().join("granted/outside")).unwrap();
let parent = session(root.path()); let parent = session(root.path());
assert!(matches!( let child = parent
parent .scope(request(
.scope(request( "granted/outside",
"granted/outside", WorkdirToolScopePermission::Write,
WorkdirToolScopePermission::Write ))
)) .await
.await, .unwrap();
Err(WorkdirError::Denied(_)) child
)); .write(write("from-child", "child-authoritative"))
.await
.unwrap();
parent parent
.write(write("secret/parent", "still-authoritative")) .write(write("secret/parent", "still-authoritative"))
.await .await
.unwrap(); .unwrap();
assert_eq!(
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
"child-authoritative"
);
} }
#[tokio::test] #[tokio::test]