fix: fence resolved Workdir lease aliases

This commit is contained in:
2026-09-14 20:05:27 +09:00
parent 3eca380bd8
commit e448073b98
7 changed files with 292 additions and 86 deletions
+17 -1
View File
@@ -11,7 +11,8 @@ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
WorkdirScopeAuthorizationRequest, WorkdirSessionCapabilities, WriteRequest, WriteResult,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
WriteRequest, WriteResult,
};
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
@@ -56,6 +57,7 @@ pub struct OpenWorkdirSessionResponse {
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
pub enum WorkdirSessionOperation {
AuthorizeScope(WorkdirScopeAuthorizationRequest),
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
Stat(StatRequest),
Read(ReadRequest),
Write(WriteRequest),
@@ -81,6 +83,7 @@ pub struct WorkdirSessionOperationRequest {
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
pub enum WorkdirSessionOperationResult {
AuthorizeScope,
ScopeRulesOverlap { overlaps: bool },
Stat(StatResult),
Read(ReadResult),
Write(WriteResult),
@@ -462,6 +465,19 @@ mod client {
}
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
match self
.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
.await?
{
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
_ => Err(Self::mismatch("scope_rules_overlap")),
}
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
WorkdirSessionOperationResult::Stat(result) => Ok(result),
+12 -2
View File
@@ -28,8 +28,9 @@ pub use local::{
};
pub use operation::*;
pub use scope::{
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease, WorkdirToolBroker,
WorkdirToolScope, WorkdirToolScopePermission, WorkdirToolScopeRule,
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
WorkdirToolScopeRule,
};
/// Persistent, opaque identity of one materialized Workdir.
@@ -166,6 +167,15 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
}
}
async fn scope_rules_overlap(
&self,
_request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
Err(WorkdirError::Denied(
"Workdir provider cannot compare resolved scope authority".to_string(),
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
+60 -2
View File
@@ -29,8 +29,9 @@ use crate::{
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
WorkdirScopeAuthorizationRequest, WorkdirSession, WorkdirSessionCapabilities,
WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest, WriteResult,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
WriteResult,
};
#[cfg(test)]
use crate::{EntryKind, WriteOutcome};
@@ -225,6 +226,41 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
}
}
fn path_sets_overlap(
left: &Path,
left_recursive: bool,
right: &Path,
right_recursive: bool,
) -> bool {
match (left_recursive, right_recursive) {
(true, true) => left.starts_with(right) || right.starts_with(left),
(true, false) => {
right.starts_with(left)
|| left == right
|| left.parent().is_some_and(|parent| parent == right)
}
(false, true) => {
left.starts_with(right)
|| left == right
|| right.parent().is_some_and(|parent| parent == left)
}
(false, false) => {
left == right
|| left.parent().is_some_and(|parent| parent == right)
|| right.parent().is_some_and(|parent| parent == left)
}
}
}
fn rule_targets(
root: &Path,
rule: &crate::WorkdirToolScopeRule,
) -> std::io::Result<(PathBuf, PathBuf)> {
let logical = root.join(rule.target.as_str());
let resolved = fs_operation::resolve_access_path(&logical)?;
Ok((logical, resolved))
}
#[derive(Debug)]
struct LocalWorkdirSessionInner {
workdir: Workdir,
@@ -626,6 +662,28 @@ impl WorkdirSession for LocalWorkdirSession {
}
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.ensure_open()?;
let (left_logical, left_resolved) = rule_targets(&self.inner.root, &request.left)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
let (right_logical, right_resolved) = rule_targets(&self.inner.root, &request.right)
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
Ok(path_sets_overlap(
&left_logical,
request.left.recursive,
&right_logical,
request.right.recursive,
) || path_sets_overlap(
&left_resolved,
request.left.recursive,
&right_resolved,
request.right.recursive,
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
+155 -79
View File
@@ -45,6 +45,14 @@ pub struct WorkdirScopeAuthorizationRequest {
pub permission: WorkdirToolScopePermission,
}
/// Provider-side overlap comparison that keeps resolved host paths private.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirScopeOverlapRequest {
pub left: WorkdirToolScopeRule,
pub right: WorkdirToolScopeRule,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirToolScope {
@@ -82,6 +90,7 @@ impl WorkdirToolBroker {
capabilities,
validity: SessionValidity::root(),
child_write_leases: Mutex::new(HashMap::new()),
scope_lock: tokio::sync::Mutex::new(()),
next_lease_id: AtomicU64::new(1),
close_lock: Arc::new(tokio::sync::Mutex::new(())),
owned_commands: Arc::new(Mutex::new(HashSet::new())),
@@ -322,6 +331,7 @@ struct ScopedWorkdirSession {
capabilities: WorkdirSessionCapabilities,
validity: Arc<SessionValidity>,
child_write_leases: Mutex<HashMap<u64, ActiveWriteLease>>,
scope_lock: tokio::sync::Mutex<()>,
next_lease_id: AtomicU64,
close_lock: Arc<tokio::sync::Mutex<()>>,
owned_commands: Arc<Mutex<HashSet<String>>>,
@@ -386,9 +396,6 @@ impl ScopedWorkdirSession {
)));
}
}
if permission == WorkdirToolScopePermission::Write {
self.ensure_parent_write_available(path)?;
}
Ok(())
}
@@ -475,33 +482,48 @@ impl ScopedWorkdirSession {
});
}
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
let mut leases = self
.child_write_leases
.lock()
.expect("Workdir tool scope lease mutex poisoned");
leases.retain(|_, lease| {
lease
.validity
.upgrade()
.is_some_and(|validity| validity.is_active())
|| lease
.cleanup_pending
async fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
let active_write_rules = {
let mut leases = self
.child_write_leases
.lock()
.expect("Workdir tool scope lease mutex poisoned");
leases.retain(|_, lease| {
lease
.validity
.upgrade()
.is_some_and(|pending| pending.load(Ordering::Acquire))
});
if leases.values().any(|lease| {
lease.rules.iter().any(|rule| {
rule.permission == WorkdirToolScopePermission::Write
&& rule_allows_path(rule, path, WorkdirToolScopePermission::Write)
})
}) {
Err(WorkdirError::Denied(format!(
"logical workdir path `{path}` is leased to child Workdir tools"
)))
} else {
Ok(())
.is_some_and(|validity| validity.is_active())
|| lease
.cleanup_pending
.upgrade()
.is_some_and(|pending| pending.load(Ordering::Acquire))
});
leases
.values()
.flat_map(|lease| lease.rules.iter().cloned())
.collect::<Vec<_>>()
};
let requested = WorkdirToolScopeRule {
target: path.clone(),
permission: WorkdirToolScopePermission::Write,
recursive: false,
symlink_policy: SymlinkPolicy::Resolved,
};
for active in active_write_rules {
if self
.source
.scope_rules_overlap(WorkdirScopeOverlapRequest {
left: active,
right: requested.clone(),
})
.await?
{
return Err(WorkdirError::Denied(format!(
"path `{path}` is leased to child Workdir tools"
)));
}
}
Ok(())
}
async fn ensure_scope_targets_are_authorized(
@@ -527,6 +549,9 @@ impl ScopedWorkdirSession {
) -> Result<FsPath, WorkdirError> {
self.ensure_active()?;
let resolved = self.resolve_path(path)?;
if permission == WorkdirToolScopePermission::Write {
self.ensure_parent_write_available(&resolved).await?;
}
if let Some(rules) = self.scope.as_ref() {
self.source
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
@@ -613,6 +638,7 @@ impl ScopedWorkdirSession {
self: &Arc<Self>,
request: WorkdirToolScope,
) -> Result<WorkdirScopeLease, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
let capabilities = self.validate_scope(&request.rules, request.command)?;
if !request
.rules
@@ -629,50 +655,61 @@ impl ScopedWorkdirSession {
let validity = SessionValidity::child(self.validity.clone());
let cleanup_pending = Arc::new(AtomicBool::new(true));
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
if request
let write_rules = request
.rules
.iter()
.any(|rule| rule.permission == WorkdirToolScopePermission::Write)
{
let mut leases = self
.child_write_leases
.lock()
.expect("Workdir tool scope lease mutex poisoned");
leases.retain(|_, lease| {
lease
.validity
.upgrade()
.is_some_and(|validity| validity.is_active())
|| lease
.cleanup_pending
.upgrade()
.is_some_and(|pending| pending.load(Ordering::Acquire))
});
let requested_write_rules = request
.rules
.iter()
.filter(|rule| rule.permission == WorkdirToolScopePermission::Write);
for requested in requested_write_rules {
if leases.values().any(|lease| {
.filter(|rule| rule.permission == WorkdirToolScopePermission::Write)
.cloned()
.collect::<Vec<_>>();
if !write_rules.is_empty() {
let active_write_rules = {
let mut leases = self
.child_write_leases
.lock()
.expect("Workdir tool scope lease mutex poisoned");
leases.retain(|_, lease| {
lease
.rules
.iter()
.any(|active| rules_overlap(active, requested))
}) {
return Err(WorkdirError::Denied(format!(
"scoped write path `{}` overlaps an active child scope",
requested.target
)));
.validity
.upgrade()
.is_some_and(|validity| validity.is_active())
|| lease
.cleanup_pending
.upgrade()
.is_some_and(|pending| pending.load(Ordering::Acquire))
});
leases
.values()
.flat_map(|lease| lease.rules.iter().cloned())
.collect::<Vec<_>>()
};
for requested in &write_rules {
for active in &active_write_rules {
if self
.source
.scope_rules_overlap(WorkdirScopeOverlapRequest {
left: active.clone(),
right: requested.clone(),
})
.await?
{
return Err(WorkdirError::Denied(format!(
"scoped write path `{}` overlaps an active child scope after provider resolution",
requested.target
)));
}
}
}
leases.insert(
id,
ActiveWriteLease {
validity: Arc::downgrade(&validity),
cleanup_pending: Arc::downgrade(&cleanup_pending),
rules: request.rules.clone(),
},
);
self.child_write_leases
.lock()
.expect("Workdir tool scope lease mutex poisoned")
.insert(
id,
ActiveWriteLease {
validity: Arc::downgrade(&validity),
cleanup_pending: Arc::downgrade(&cleanup_pending),
rules: request.rules.clone(),
},
);
}
let owned_commands = Arc::new(Mutex::new(HashSet::new()));
let pending_command_events = Arc::new(Mutex::new(HashMap::new()));
@@ -698,6 +735,7 @@ impl ScopedWorkdirSession {
capabilities,
validity: validity.clone(),
child_write_leases: Mutex::new(HashMap::new()),
scope_lock: tokio::sync::Mutex::new(()),
next_lease_id: AtomicU64::new(1),
close_lock: close_lock.clone(),
owned_commands,
@@ -1011,6 +1049,13 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
self.inner.authorize_scope_path(request).await
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.inner.scope_rules_overlap(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.inner.stat(request).await
}
@@ -1167,13 +1212,6 @@ fn unix_timestamp_ms() -> u64 {
.min(u128::from(u64::MAX)) as u64
}
fn rules_overlap(left: &WorkdirToolScopeRule, right: &WorkdirToolScopeRule) -> bool {
left.permission == WorkdirToolScopePermission::Write
&& right.permission == WorkdirToolScopePermission::Write
&& (rule_allows_path(left, &right.target, WorkdirToolScopePermission::Write)
|| rule_allows_path(right, &left.target, WorkdirToolScopePermission::Write))
}
pub(crate) fn rule_allows_path(
rule: &WorkdirToolScopeRule,
path: &FsPath,
@@ -1578,6 +1616,41 @@ mod tests {
));
}
#[cfg(unix)]
#[tokio::test]
async fn sibling_write_scopes_reject_distinct_aliases_to_same_resolved_target() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("target")).unwrap();
symlink("target", root.path().join("alias-a")).unwrap();
symlink("target", root.path().join("alias-b")).unwrap();
let parent = session(root.path());
let _first = parent
.scope(request("alias-a", WorkdirToolScopePermission::Write))
.await
.unwrap();
assert!(matches!(
parent
.scope(request("alias-b", WorkdirToolScopePermission::Write))
.await,
Err(WorkdirError::Denied(message))
if message.contains("overlaps an active child scope after provider resolution")
));
assert!(matches!(
parent
.write(WriteRequest {
path: FsPath::new("target/from-parent").unwrap(),
content: b"blocked".to_vec(),
expected_hash: None,
})
.await,
Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools")
));
}
#[tokio::test]
async fn nested_scope_cannot_expand_resolved_policy_to_logical() {
let root = TempDir::new().unwrap();
@@ -1641,7 +1714,7 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
async fn write_delegation_leases_the_logical_symlink_path() {
async fn write_delegation_leases_logical_alias_and_resolved_target() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
@@ -1657,10 +1730,13 @@ mod tests {
.write(write("from-child", "child-authoritative"))
.await
.unwrap();
parent
.write(write("secret/parent", "still-authoritative"))
.await
.unwrap();
assert!(matches!(
parent
.write(write("secret/parent", "must-be-blocked"))
.await,
Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools")
));
assert_eq!(
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
"child-authoritative"