feat: project attached checkouts with scoped native AI operations

This commit is contained in:
2026-10-06 03:37:07 +09:00
parent 3deef49897
commit 149853bb0e
43 changed files with 8706 additions and 346 deletions
+143
View File
@@ -0,0 +1,143 @@
# Request-time checkout provider boundary
`workdir::checkout` (also crate-root reexports) exposes `CheckoutObservation`,
`CheckoutRequest`, `CheckoutOperation`, `CheckoutOutput`, and `CheckoutResult`.
`WorkdirSession::checkout_observe(path)` defaults to unsupported; execution also
fails closed by default. Observation is metadata-only, with no recursive tree
walk. Directory discovery/search remains the ordinary bounded List/Glob/Grep
provider engine. GrepResult.paths carries exact typed provider paths appearing
in the returned report, respecting its offset/limit/truncation; filenames are
never parsed from rendered text. List/Glob/Grep skip unrepresentable non-UTF8
paths, preserving valid Unicode exactly without normalization. Native callers supply their own attachment generation/namespace
fence; this provider validator is not a substitute for that fence.
* Read: line `offset`, `limit`, `max_bytes`; same `ReadResult` (bytes, start line,
total lines, full-source content hash, truncation) as normal Tools. Formatting
line numbers and recording the returned hash in caller read-history are caller
responsibilities. The validator is **not** permission to bypass read-before-write.
* Write: replace existing file with `content` and required `expected_hash`.
* Edit: existing UTF-8 file, unique `old_string` unless `replace_all`, required
`expected_hash`; same match/replacement/result-size checks as Tools.
* Create: target must be an observed directory; `path` is **checkout-root
relative**, strictly below target, not target-relative. No existing file can
be overwritten. Intermediate directories and destination must be writable.
* Every result observation describes the bound request target. Create returns
the updated **parent directory** observation from the retained pinned target,
not the created destination. Read returns the read state's observation.
## Shared implementation
`fs_operation::CheckedTarget` is an `FsAccessPolicy` adapter around pinned target,
save-parent, and provider-root descriptors. It calls the ordinary
`run_read_bounded`, `run_edit`, and `run_write` functions, including full-source
hashing, UTF-8/string matching, bounds, and atomic temp-save primitive; there is
no second content-operation engine. Shared text reads now check descriptor
metadata before/after reading. Validator bytes encode device/inode/size/mode/
mtime/ctime with nanoseconds, excluding atime and host paths; same-content inode
replacement is distinguishable. Successful native Read rechecks the named target
and pinned descriptor before returning hash and observation.
Local sessions serialize checked observe/execute and ordinary trait Read/Write/
Edit using one nonqueueing session lock. Expected validator comparison happens
inside that boundary. The adapter retains handles across reading and save,
rechecks named target + save-parent identity at save boundary, and writes a new
temporary inode then atomically renames it, isolating existing hardlink aliases.
External sessions retain the approved descriptor root after pathname rename or
replacement. Checkout resolution rejects symlinks, special files, mount crossings,
absolute/traversal paths and unavailable descriptor primitives, rather than
weakening confinement. This currently requires Linux `openat2` and `/proc/self/fd`.
Ordinary Tools preserve their existing explicit symlink-policy behavior.
Scoped sessions use their existing scope mutex, current validity/source
capability checks, provider authorization and write leases; Create also checks
intermediate paths. Observation capabilities are attenuated per path and live
write leases, never broadened to the whole attachment. ReadOnly removes mutation
capabilities and rejects mutations. Routed operations use existing active-op
accounting and detach fencing. Remote HTTP forwards exact operation/result types
and validates returned operation/path pairing. External DTOs validate checkout
path/validator/content/result bounds. No provider endpoint or host root is added
to the observation.
## Effects and limits
Absent paths during checkout observation return `NotFound` (including a missing
ancestor or a non-directory ancestor); native discovery can return no route.
An execution against a previously observed target that disappeared instead
returns `Conflict`, consistently at pinning, shared-engine, and final-check
boundaries. Conflict means stale validator/hash/name or existing Create destination before
save effects. `WorkdirError::OutcomeUnknown` has a separate sanitized wire code
`outcome_unknown` (HTTP 500), never an automatic retry. Partial parent creation,
possible commit failure, or failed post-save observation is outcome unknown.
Remote lost/malformed/mismatched mutation responses are also outcome unknown.
Create failures conservatively report unknown once parent creation may begin,
even if a particular failure happened without a persisted effect.
Bounds: path <=4096 UTF-8 bytes/128 components, validator <=256 bytes; Read source
64 MiB, retained response <=1 MiB (or stricter provider read limits), Write/Edit
preimage and result <=8 MiB (or stricter provider limit), Edit replacements
<=10,000 (or stricter provider limit). One checked operation per Local session,
with bounded retained handles and no waiting queue. Cancellation/deadline checks
use the existing provider guard, or a 30-second cooperative guard for Local
checkout operations. Blocking kernel/filesystem calls are not preempted; this
is **not** a hard wall-time guarantee for hung storage. Transport request-body
bounds before serde allocation remain the hosting transport's responsibility.
### External-editor limitations (not a magic advisory-lock guarantee)
No advisory `flock` is claimed to exclude arbitrary editors. Provider-session
serialization does not lock other sessions/processes. Metadata checks detect
concurrent reads/edits up to each check, but are not a transactional snapshot
against privileged actors forging metadata. Linux offers no atomic
compare-identity-and-replace-by-name primitive here: an uncooperative external
rename/change between the final validator check and rename can still race the
save. The pinned handles prevent following replacements/symlinks into arbitrary
objects, and deterministic substitution before the save boundary is rejected,
but the final syscall gap is an explicit limitation. Callers must not interpret
this as linearizability against all external filesystem mutations. Likewise,
post-save observation is the then-current named state, not an exclusion of an
external edit after save. Crash durability of parent directories is not claimed.
Host generation reuse, native permission identity, read-history updates, model
Operation publication, transport hosting (Runtime/Backend/External), and durable
Ticket/MR review remain the parent integration's responsibilities.
## Scoped checkout search boundary
`CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))`
uses the current session's logical root as its result frame. The typed result is
`CheckoutSearchResult::{List, Glob, Grep}`. The request's internal context fields
are `scope_layers: Vec<Vec<WorkdirToolScopeRule>>` and `output_root: WorkdirPath`.
Rules union within each layer; layers intersect with each other, the current
provider authority, and the output-root boundary. Wrappers contribute their own
scope; caller-supplied layers can only narrow, never replace it. Each wrapper
translates all incoming coordinates once and forwards the composed output root.
Provider-rendered Grep text and every typed result path are already relative to
that final root; wrappers must not prefix cwd onto returned paths again.
Local/External use the same shared `run_list`, `run_glob`, `run_grep` engines.
`SearchAccess` gates metadata/content opens and enumeration. Shared List checks
`can_enumerate_directory`; Glob/Grep use the shared bounded descriptor walk and
owned `traversal_filter` policy to prune before descending/opening directories.
The walk anchors at the actual search directory, not at a broader provider
root. It counts rejected entries too, checks cancellation while scanning them,
and limits descent to 128 levels (bounded retained directory handles). Existing
provider-owned ignore loading, glob/type selection, grep context/offset/limit,
rendering, and source/result limits remain in their shared engines. Unreadable
ignore files are not opened to discover out-of-scope contents.
Scoped ordinary trait List/Glob/Grep use this boundary as well, returning
session-logical coordinates. Unscoped normal Tools continue using their original
trait/provider behavior. A provider without checkout search support returns
UnsupportedOperation; there is no broad traversal fallback. Native/scoped
searches intentionally fail closed on symlink paths, including logical-policy
aliases which normal unscoped Tools may follow. A visible symlink may therefore
cause a native List failure rather than expanding authority. Descriptor support
is currently Linux-only. Search remains request-time traversal, not a snapshot
or transactional exclusion of external editors.
HTTP operation/result variant: `CheckoutSearch`. External DTO validation rejects
absolute/out-of-root paths, empty scope layers, oversized paths/patterns/context/
results and invalid result paths. Generic request ceilings are 16 layers and 256
aggregate rules; External transport further limits aggregate rules to 64. Search
result limits follow the existing shared List/Glob/Grep wire bounds. Runtime and
Backend forwarding outside this crate remain parent integration work.
File diff suppressed because it is too large Load Diff
+418
View File
@@ -0,0 +1,418 @@
#![cfg(target_os = "linux")]
use crate::*;
use std::sync::Arc;
fn path(value: &str) -> WorkdirPath {
WorkdirPath::new(value).unwrap()
}
fn rule(value: &str, recursive: bool) -> WorkdirToolScopeRule {
WorkdirToolScopeRule {
target: path(value),
recursive,
permission: WorkdirToolScopePermission::Read,
symlink_policy: Default::default(),
}
}
fn scope(rules: Vec<WorkdirToolScopeRule>, cwd: &str) -> WorkdirToolScope {
WorkdirToolScope {
rules,
cwd: path(cwd),
command: false,
}
}
fn grep(mode: GrepOutputMode) -> GrepRequest {
GrepRequest {
path: WorkdirPath::root(),
pattern: "needle".into(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: mode,
offset: 0,
limit: 100,
}
}
fn fixture() -> tempfile::TempDir {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("child/keep")).unwrap();
std::fs::create_dir_all(root.path().join("child/denied/deep")).unwrap();
std::fs::write(root.path().join("child/a.txt"), "needle allowed direct").unwrap();
std::fs::write(
root.path().join("child/keep/yes.txt"),
"needle allowed subtree",
)
.unwrap();
std::fs::write(
root.path().join("child/denied/deep/secret.txt"),
"needle secret",
)
.unwrap();
// Opening this out-of-scope source would exhaust the shared grep source bound.
std::fs::File::create(root.path().join("child/denied/huge.txt"))
.unwrap()
.set_len(65 * 1024 * 1024)
.unwrap();
std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap();
root
}
async fn verify_sparse(session: &dyn WorkdirSession) {
let listed = session
.list(ListRequest {
path: WorkdirPath::root(),
limit: 100,
})
.await
.unwrap();
assert_eq!(
listed
.entries
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>(),
vec![path("denied"), path("keep"), path("a.txt")]
);
let empty = session
.list(ListRequest {
path: path("denied"),
limit: 100,
})
.await
.unwrap();
assert!(empty.entries.is_empty());
assert_eq!(empty.total_entries, 0);
let globbed = session
.glob(GlobRequest {
path: WorkdirPath::root(),
pattern: "**/*.txt".into(),
limit: 100,
})
.await
.unwrap();
assert_eq!(globbed.paths, vec![path("a.txt"), path("keep/yes.txt")]);
for mode in [
GrepOutputMode::Content,
GrepOutputMode::Count,
GrepOutputMode::FilesWithMatches,
] {
let result = session.grep(grep(mode)).await.unwrap();
let mut paths = result.paths;
paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));
assert_eq!(paths, vec![path("a.txt"), path("keep/yes.txt")]);
assert!(!result.output.contains("child/"));
assert!(!result.output.contains("secret"));
assert!(!result.output.contains("outside"));
assert_eq!(result.matched_files, 2);
}
let CheckoutSearchResult::List(native) = session
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
ListRequest {
path: WorkdirPath::root(),
limit: 100,
},
)))
.await
.unwrap()
else {
panic!()
};
assert_eq!(listed, native);
}
#[tokio::test]
async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_content() {
let root = fixture();
let source = Arc::new(LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
));
let parent = WorkdirToolBroker::new(source);
let first = parent
.scope(scope(vec![rule("child", true)], "child"))
.await
.unwrap();
let nested = first
.scope(scope(
vec![rule("child", false), rule("child/keep", true)],
"child",
))
.await
.unwrap();
verify_sparse(&*nested.tool_session()).await;
// Nested incoming layers cannot replace the session's own sparse layer.
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest {
path: WorkdirPath::root(),
pattern: "**/*.txt".into(),
limit: 100,
}));
request.scope_layers.push(vec![rule("", true)]);
let CheckoutSearchResult::Glob(result) = nested
.tool_session()
.checkout_search(request)
.await
.unwrap()
else {
panic!()
};
assert_eq!(result.paths, vec![path("a.txt"), path("keep/yes.txt")]);
nested.close().await.unwrap();
assert!(matches!(
nested
.tool_session()
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
ListRequest {
path: WorkdirPath::root(),
limit: 100
}
)))
.await,
Err(WorkdirError::SessionClosed)
));
}
#[tokio::test]
async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
let root = fixture();
let parent = WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
)));
let first = parent
.scope(scope(vec![rule("child", true)], "child"))
.await
.unwrap();
// Two broker wrappers, not only leases that share a source.
let stacked = WorkdirToolBroker::new(first.tool_session());
let mut request =
CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(grep(GrepOutputMode::Content)));
*request.operation.path_mut() = path("keep");
request.output_root = path("keep");
request.scope_layers = vec![vec![rule("keep", false)]];
let CheckoutSearchResult::Grep(result) = stacked.checkout_search(request).await.unwrap() else {
panic!()
};
assert_eq!(result.paths, vec![path("yes.txt")]);
assert!(result.output.starts_with("yes.txt\n"));
// The outer context is relative to the inner session; no duplicated child/.
let CheckoutSearchResult::List(result) = stacked
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
ListRequest {
path: path("keep"),
limit: 100,
},
)))
.await
.unwrap()
else {
panic!()
};
assert_eq!(result.entries[0].path, path("keep/yes.txt"));
// A second actual scoped wrapper has its own cwd and nonrecursive layer.
// Logical rules avoid requesting resolved authorization from a wrapper
// that conservatively does not expose host-side resolution authority.
std::fs::create_dir(root.path().join("child/keep/deep")).unwrap();
std::fs::write(
root.path().join("child/keep/deep/secret"),
"needle hidden nested",
)
.unwrap();
let mut own_rule = rule("keep", false);
own_rule.symlink_policy = manifest::SymlinkPolicy::Logical;
let second = stacked.scope(scope(vec![own_rule], "keep")).await.unwrap();
let result = second
.tool_session()
.grep(grep(GrepOutputMode::Content))
.await
.unwrap();
assert_eq!(result.paths, vec![path("yes.txt")]);
assert!(result.output.starts_with("yes.txt\n"));
assert!(!result.output.contains("hidden nested"));
let entries = second
.tool_session()
.list(ListRequest {
path: WorkdirPath::root(),
limit: 100,
})
.await
.unwrap()
.entries;
assert_eq!(
entries
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>(),
vec![path("deep"), path("yes.txt")]
);
assert!(
second
.tool_session()
.list(ListRequest {
path: path("deep"),
limit: 100
})
.await
.unwrap()
.entries
.is_empty()
);
}
#[tokio::test]
async fn checkout_search_external_keeps_approved_root_and_sparse_authority() {
let root = fixture();
let external = LocalWorkdirSession::external_read_only(
Workdir::new("external-search"),
root.path(),
BoundedReadLimits::EXTERNAL_DEFAULT,
)
.unwrap();
let broker = WorkdirToolBroker::new(Arc::new(external));
let lease = broker
.scope(scope(
vec![rule("child", false), rule("child/keep", true)],
"child",
))
.await
.unwrap();
let parent = tempfile::tempdir().unwrap();
let approved = parent.path().join("approved");
std::fs::rename(root.path(), &approved).unwrap();
std::fs::create_dir(root.path()).unwrap();
std::fs::create_dir(root.path().join("child")).unwrap();
std::fs::write(
root.path().join("child/replacement.txt"),
"needle replacement",
)
.unwrap();
verify_sparse(&*lease.tool_session()).await;
}
#[tokio::test]
async fn checkout_search_scope_layers_intersect_before_list_and_grep() {
let root = fixture();
let source = LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
);
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
path: path("child"),
limit: 100,
}));
request.output_root = path("child");
request.scope_layers = vec![
vec![rule("child", false), rule("child/keep", true)],
vec![rule("child", false)],
];
let CheckoutSearchResult::List(result) = source.checkout_search(request.clone()).await.unwrap()
else {
panic!()
};
assert_eq!(result.entries.len(), 3);
request.operation = CheckoutSearchOperation::Grep(GrepRequest {
path: path("child"),
..grep(GrepOutputMode::Content)
});
let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else {
panic!()
};
assert_eq!(result.paths, vec![path("a.txt")]);
assert!(!result.output.contains("subtree"));
}
#[tokio::test]
async fn checkout_search_wire_dto_bounds_and_provider_capabilities() {
use crate::external::{ExternalWorkdirOperation, ExternalWorkdirOperationResult};
use crate::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output};
let root = fixture();
let source = LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
);
let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
path: path("child/keep"),
limit: 100,
}));
let wire = Op::CheckoutSearch(request.clone());
assert!(ExternalWorkdirOperation::try_from(wire.clone()).is_ok());
assert_eq!(
serde_json::from_value::<Op>(serde_json::to_value(&wire).unwrap()).unwrap(),
wire
);
let result = crate::http::dispatch_workdir_session_operation(&source, wire)
.await
.unwrap();
assert!(matches!(
&result,
Output::CheckoutSearch(CheckoutSearchResult::List(_))
));
assert!(ExternalWorkdirOperationResult::try_from(result.clone()).is_ok());
assert_eq!(
serde_json::from_value::<Output>(serde_json::to_value(&result).unwrap()).unwrap(),
result
);
let mut bad = request.clone();
bad.output_root = WorkdirPath::new_scoped("/host").unwrap();
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let mut bad = request.clone();
bad.scope_layers = vec![vec![rule("", true)]; 17];
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let mut bad = request.clone();
bad.scope_layers = vec![vec![rule("", true); 257]];
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let mut bad = request.clone();
bad.scope_layers = vec![vec![rule("", true); 65]];
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let mut bad = request;
bad.scope_layers = vec![Vec::new()];
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let bad_result = Output::CheckoutSearch(CheckoutSearchResult::Glob(GlobResult {
paths: vec![WorkdirPath::new_scoped("/host").unwrap()],
truncated: false,
}));
assert!(ExternalWorkdirOperationResult::try_from(bad_result).is_err());
}
#[tokio::test]
async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_escape() {
let root = fixture();
let current = manifest::Scope::from_config(&manifest::ScopeConfig {
allow: vec![manifest::ScopeRule {
target: root.path().join("child"),
permission: manifest::Permission::Read,
recursive: false,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
})
.unwrap();
let source = LocalWorkdirSession::new(current, root.path().to_path_buf());
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest {
path: path("child"),
..grep(GrepOutputMode::Content)
}));
request.output_root = path("child");
// A broad caller layer cannot enlarge the provider's nonrecursive authority.
request.scope_layers = vec![vec![rule("", true)]];
let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else {
panic!()
};
assert_eq!(result.paths, vec![path("a.txt")]);
let CheckoutSearchResult::List(result) = source
.checkout_search(CheckoutSearchRequest {
operation: CheckoutSearchOperation::List(ListRequest {
path: path("child/denied"),
limit: 100,
}),
output_root: path("child"),
scope_layers: Vec::new(),
})
.await
.unwrap()
else {
panic!()
};
assert!(result.entries.is_empty());
std::os::unix::fs::symlink("../outside.txt", root.path().join("child/link.txt")).unwrap();
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest {
path: path("child/link.txt"),
..grep(GrepOutputMode::Content)
}));
request.output_root = path("child");
assert!(source.checkout_search(request).await.is_err());
}
+103
View File
@@ -149,6 +149,56 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
is_root_relative(&request.left.target) && is_root_relative(&request.right.target)
}
WorkdirSessionOperation::CheckoutSearch(request) => {
request.validate().is_ok()
&& request.scope_layers.iter().map(Vec::len).sum::<usize>()
<= MAX_EXTERNAL_SCOPE_RULES
&& validate_external_operation(&match &request.operation {
crate::CheckoutSearchOperation::List(r) => {
WorkdirSessionOperation::List(r.clone())
}
crate::CheckoutSearchOperation::Glob(r) => {
WorkdirSessionOperation::Glob(r.clone())
}
crate::CheckoutSearchOperation::Grep(r) => {
WorkdirSessionOperation::Grep(r.clone())
}
})
.is_ok()
}
WorkdirSessionOperation::CheckoutObserve(path) => {
is_root_relative(path) && crate::checkout::validate_path(path).is_ok()
}
WorkdirSessionOperation::CheckoutExecute(request) => {
is_root_relative(&request.target)
&& crate::checkout::validate_path(&request.target).is_ok()
&& !request.validator.is_empty()
&& request.validator.len() <= 256
&& match &request.operation {
crate::CheckoutOperation::Read {
limit, max_bytes, ..
} => {
*limit <= 1_000_000
&& *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
}
crate::CheckoutOperation::Write { content, .. } => {
content.len() <= MAX_EXTERNAL_WRITE_BYTES
}
crate::CheckoutOperation::Edit {
old_string,
new_string,
..
} => old_string
.len()
.checked_add(new_string.len())
.is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES),
crate::CheckoutOperation::Create { path, content } => {
is_root_relative(path)
&& crate::checkout::create_relative_path(&request.target, path).is_ok()
&& content.len() <= MAX_EXTERNAL_WRITE_BYTES
}
}
}
WorkdirSessionOperation::Stat(request) => is_root_relative(&request.path),
WorkdirSessionOperation::Read(request) => {
is_root_relative(&request.path)
@@ -272,10 +322,59 @@ fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> boo
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
}
fn valid_checkout_observation(observation: &crate::CheckoutObservation) -> bool {
is_root_relative(&observation.path)
&& !observation.validator.is_empty()
&& observation.validator.len() <= 256
&& matches!(
observation.kind,
crate::EntryKind::File | crate::EntryKind::Directory
)
&& (observation.kind == crate::EntryKind::Directory
|| observation.size <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes)
}
fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
let result_is_valid = match result {
WorkdirSessionOperationResult::AuthorizeScope
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
WorkdirSessionOperationResult::CheckoutSearch(result) => {
validate_external_result(&match result {
crate::CheckoutSearchResult::List(r) => {
WorkdirSessionOperationResult::List(r.clone())
}
crate::CheckoutSearchResult::Glob(r) => {
WorkdirSessionOperationResult::Glob(r.clone())
}
crate::CheckoutSearchResult::Grep(r) => {
WorkdirSessionOperationResult::Grep(r.clone())
}
})
.is_ok()
}
WorkdirSessionOperationResult::CheckoutObserve(observation) => {
valid_checkout_observation(observation)
}
WorkdirSessionOperationResult::CheckoutExecute(result) => {
valid_checkout_observation(&result.observation)
&& match &result.output {
crate::CheckoutOutput::Read(read) => {
read.path == result.observation.path
&& validate_external_result(&WorkdirSessionOperationResult::Read(
read.clone(),
))
.is_ok()
}
crate::CheckoutOutput::Write(write) => validate_external_result(
&WorkdirSessionOperationResult::Write(write.clone()),
)
.is_ok(),
crate::CheckoutOutput::Edit(edit) => {
validate_external_result(&WorkdirSessionOperationResult::Edit(edit.clone()))
.is_ok()
}
}
}
WorkdirSessionOperationResult::Stat(result) => is_root_relative(&result.path),
WorkdirSessionOperationResult::Read(result) => {
is_root_relative(&result.path)
@@ -307,6 +406,9 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
result.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
&& result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS
&& result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
&& result.paths.iter().all(is_root_relative)
&& result_paths_fit(&result.paths)
}
WorkdirSessionOperationResult::Write(result) => {
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
@@ -821,6 +923,7 @@ mod tests {
assert!(ExternalWorkdirOperation::try_from(unbounded).is_err());
let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult {
paths: Vec::new(),
output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1),
match_count: 1,
matched_files: 1,
+136 -1
View File
@@ -64,6 +64,9 @@ pub struct OpenWorkdirSessionResponse {
pub enum WorkdirSessionOperation {
AuthorizeScope(WorkdirScopeAuthorizationRequest),
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
CheckoutSearch(crate::CheckoutSearchRequest),
CheckoutObserve(crate::WorkdirPath),
CheckoutExecute(crate::CheckoutRequest),
Stat(StatRequest),
Read(ReadRequest),
ReadBytes(ReadBytesRequest),
@@ -96,6 +99,9 @@ pub struct WorkdirSessionOperationRequest {
pub enum WorkdirSessionOperationResult {
AuthorizeScope,
ScopeRulesOverlap { overlaps: bool },
CheckoutSearch(crate::CheckoutSearchResult),
CheckoutObserve(crate::CheckoutObservation),
CheckoutExecute(crate::CheckoutResult),
Stat(StatResult),
Read(ReadResult),
ReadBytes(ReadBytesResult),
@@ -129,6 +135,18 @@ pub async fn dispatch_workdir_session_operation(
.scope_rules_overlap(request)
.await
.map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }),
WorkdirSessionOperation::CheckoutSearch(request) => session
.checkout_search(request)
.await
.map(WorkdirSessionOperationResult::CheckoutSearch),
WorkdirSessionOperation::CheckoutObserve(path) => session
.checkout_observe(path)
.await
.map(WorkdirSessionOperationResult::CheckoutObserve),
WorkdirSessionOperation::CheckoutExecute(request) => session
.checkout_execute(request)
.await
.map(WorkdirSessionOperationResult::CheckoutExecute),
WorkdirSessionOperation::Stat(request) => session
.stat(request)
.await
@@ -186,6 +204,7 @@ pub async fn dispatch_workdir_session_operation(
pub enum WorkdirTransportErrorCode {
NotFound,
Conflict,
OutcomeUnknown,
Unsupported,
InvalidRequest,
Denied,
@@ -208,6 +227,7 @@ impl WorkdirTransportErrorCode {
match self {
Self::NotFound => "not_found",
Self::Conflict => "conflict",
Self::OutcomeUnknown => "outcome_unknown",
Self::Unsupported => "unsupported",
Self::InvalidRequest => "invalid_request",
Self::Denied => "denied",
@@ -239,7 +259,7 @@ impl WorkdirTransportErrorCode {
| Self::IsDirectory
| Self::SymlinkDirectoryNotTraversed => 400,
Self::Unavailable => 503,
Self::Io | Self::Internal => 500,
Self::Io | Self::Internal | Self::OutcomeUnknown => 500,
Self::Transport => 502,
}
}
@@ -256,6 +276,10 @@ impl WorkdirTransportError {
pub fn from_workdir_error(error: &WorkdirError) -> Self {
use WorkdirTransportErrorCode as Code;
let (code, message) = match error {
WorkdirError::OutcomeUnknown(_) => (
Code::OutcomeUnknown,
"Workdir operation may have effects; inspect before retrying",
),
WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
WorkdirError::Conflict(_) => (
Code::Conflict,
@@ -330,6 +354,9 @@ impl WorkdirTransportError {
match self.code {
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
Code::Conflict => WorkdirError::Conflict(self.message),
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
"Workdir operation may have effects; inspect before retrying".into(),
),
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
Code::Denied => WorkdirError::Denied(self.message),
@@ -564,6 +591,114 @@ mod client {
}
}
async fn checkout_search(
&self,
request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
request.validate()?;
let expected = request.operation.capability();
match self
.operate(WorkdirSessionOperation::CheckoutSearch(request))
.await?
{
WorkdirSessionOperationResult::CheckoutSearch(result)
if matches!(
(&result, expected),
(
crate::CheckoutSearchResult::List(_),
crate::WorkdirSessionCapability::Read
) | (
crate::CheckoutSearchResult::Glob(_),
crate::WorkdirSessionCapability::Glob
) | (
crate::CheckoutSearchResult::Grep(_),
crate::WorkdirSessionCapability::Grep
)
) =>
{
crate::external::ExternalWorkdirOperationResult::try_from(
WorkdirSessionOperationResult::CheckoutSearch(result.clone()),
)
.map_err(|_| Self::mismatch("bounded checkout_search"))?;
Ok(result)
}
_ => Err(Self::mismatch("checkout_search")),
}
}
async fn checkout_observe(
&self,
path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
match self
.operate(WorkdirSessionOperation::CheckoutObserve(path.clone()))
.await?
{
WorkdirSessionOperationResult::CheckoutObserve(mut result)
if result.path == path
&& !result.validator.is_empty()
&& result.validator.len() <= 256 =>
{
result.capabilities = result.capabilities.intersection(self.capabilities());
Ok(result)
}
_ => Err(Self::mismatch("checkout_observe")),
}
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read;
let expected_path = request.target.clone();
let expected_output = request.operation.capability();
match self
.operate(WorkdirSessionOperation::CheckoutExecute(request))
.await
{
Ok(WorkdirSessionOperationResult::CheckoutExecute(mut result))
if result.observation.path == expected_path
&& !result.observation.validator.is_empty()
&& result.observation.validator.len() <= 256
&& match (&result.output, expected_output) {
(
crate::CheckoutOutput::Read(read),
crate::WorkdirSessionCapability::Read,
) => {
read.path == expected_path
&& read.bytes.len()
<= crate::BoundedReadLimits::EXTERNAL_DEFAULT
.max_response_bytes
}
(
crate::CheckoutOutput::Write(_),
crate::WorkdirSessionCapability::Write,
)
| (
crate::CheckoutOutput::Edit(_),
crate::WorkdirSessionCapability::Edit,
) => true,
_ => false,
} =>
{
result.observation.capabilities = result
.observation
.capabilities
.intersection(self.capabilities());
Ok(result)
}
Ok(_) if mutation => Err(WorkdirError::OutcomeUnknown(
"mismatched response after checkout mutation".into(),
)),
Ok(_) => Err(Self::mismatch("checkout_execute")),
Err(WorkdirError::Unavailable(_) | WorkdirError::Transport(_)) if mutation => {
Err(WorkdirError::OutcomeUnknown(
"checkout transport failed; effects may have occurred".into(),
))
}
Err(error) => Err(error),
}
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
WorkdirSessionOperationResult::Stat(result) => Ok(result),
+36
View File
@@ -5,6 +5,11 @@
//! bound to one Worker. Tools consume sessions; they do not own Workdir
//! materialization or cleanup.
pub mod checkout;
pub use checkout::{
CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult,
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult,
};
pub mod external;
pub mod http;
mod local;
@@ -13,6 +18,8 @@ mod router;
mod scope;
pub mod workspace;
#[cfg(test)]
mod checkout_search_tests;
#[cfg(test)]
mod external_local_tests;
@@ -219,6 +226,31 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
))
}
async fn checkout_search(
&self,
_request: CheckoutSearchRequest,
) -> Result<CheckoutSearchResult, WorkdirError> {
Err(WorkdirError::UnsupportedOperation(
"scoped checkout traversal".into(),
))
}
async fn checkout_observe(
&self,
_path: WorkdirPath,
) -> Result<CheckoutObservation, WorkdirError> {
Err(WorkdirError::UnsupportedOperation(
"checkout observation".into(),
))
}
async fn checkout_execute(
&self,
_request: CheckoutRequest,
) -> Result<CheckoutResult, WorkdirError> {
Err(WorkdirError::UnsupportedOperation(
"checked checkout execution".into(),
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
async fn read_bytes(
@@ -289,6 +321,10 @@ pub enum WorkdirError {
#[error("{0}")]
Conflict(String),
/// Effects may have happened. Do not automatically retry the operation.
#[error("Workdir operation outcome is unknown: {0}")]
OutcomeUnknown(String),
#[error("unknown Workdir session command: {0}")]
UnknownCommand(String),
+527 -8
View File
@@ -204,7 +204,7 @@ impl CommandTelemetry {
}
}
#[derive(Debug)]
#[derive(Debug, Clone)]
struct ScopeAccess {
scope: Arc<Scope>,
root: PathBuf,
@@ -314,13 +314,12 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
"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)));
let path = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
return Ok(Some(fs_operation::FsTraversalRoot::new_at(
path,
directory,
resolved.to_path_buf(),
)));
}
#[cfg(not(target_os = "linux"))]
Err(std::io::Error::new(
@@ -443,6 +442,118 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
}
}
/// A read-only, conjunctively narrowed descriptor policy for native searches.
#[derive(Debug, Clone)]
struct SearchAccess {
inner: ScopeAccess,
layers: Vec<Vec<crate::WorkdirToolScopeRule>>,
output_root: PathBuf,
source_allows: Arc<Vec<manifest::ScopeRule>>,
}
impl SearchAccess {
fn logical_path(&self, path: &Path) -> Option<WorkdirPath> {
WorkdirPath::new(path.strip_prefix(&self.inner.root).ok()?.to_str()?).ok()
}
fn read_allowed(&self, logical: &Path, resolved: &Path) -> bool {
use fs_operation::FsAccessPolicy;
let Some(path) = self.logical_path(logical) else {
return false;
};
logical.starts_with(&self.output_root)
&& self.inner.is_readable_paths(logical, resolved)
&& self.layers.iter().all(|layer| {
layer.iter().any(|rule| {
crate::scope::rule_allows_path(rule, &path, WorkdirToolScopePermission::Read)
})
})
}
fn enumeration_allowed(&self, logical: &Path, resolved: &Path) -> bool {
self.read_allowed(logical, resolved)
&& self.source_allows.iter().any(|rule| {
if rule.recursive {
logical.starts_with(&rule.target) || rule.target.starts_with(logical)
} else {
logical == rule.target || rule.target.starts_with(logical)
}
})
&& self.layers.iter().all(|layer| {
layer.iter().any(|rule| {
let target = self.inner.root.join(rule.target.as_str());
if rule.recursive {
logical.starts_with(&target) || target.starts_with(logical)
} else {
logical == target || target.starts_with(logical)
}
})
})
}
fn require_read(&self, logical: &Path, resolved: &Path) -> std::io::Result<()> {
if self.read_allowed(logical, resolved) {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"checkout search path denied",
))
}
}
}
impl fs_operation::FsAccessPolicy for SearchAccess {
fn is_readable(&self, path: &Path) -> bool {
self.read_allowed(path, path)
}
fn is_writable(&self, _: &Path) -> bool {
false
}
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
self.read_allowed(logical, resolved)
}
fn can_enumerate_directory(&self, logical: &Path, resolved: &Path) -> bool {
self.enumeration_allowed(logical, resolved)
}
fn traversal_filter(&self) -> Option<fs_operation::FsTraversalFilter> {
let policy = self.clone();
Some(Arc::new(move |path, directory| {
if directory {
policy.enumeration_allowed(path, path)
} else {
policy.read_allowed(path, path)
}
}))
}
fn check_cancelled(&self) -> std::io::Result<()> {
self.inner.check_cancelled()
}
fn resolve_access_path(&self, logical: &Path) -> std::io::Result<PathBuf> {
self.inner.resolve_access_path(logical)
}
fn open_traversal_root(
&self,
logical: &Path,
resolved: &Path,
) -> std::io::Result<Option<fs_operation::FsTraversalRoot>> {
self.require_read(logical, resolved)?;
self.inner.open_traversal_root(logical, resolved)
}
fn open_read_file(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
self.require_read(logical, resolved)?;
self.inner.open_read_file(logical, resolved)
}
fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
self.require_read(logical, resolved)?;
self.inner.read_metadata(logical, resolved)
}
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
if !self.enumeration_allowed(logical, resolved) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"checkout directory enumeration denied",
));
}
self.inner.open_read_dir(logical, resolved)
}
}
fn path_sets_overlap(
left: &Path,
left_recursive: bool,
@@ -491,6 +602,7 @@ struct LocalWorkdirSessionInner {
reject_symlinks: bool,
closed: AtomicBool,
close_lock: Mutex<()>,
checkout_lock: StdMutex<()>,
next_command_id: AtomicU64,
commands: Mutex<HashMap<String, LocalCommand>>,
command_telemetry: CommandTelemetry,
@@ -911,6 +1023,7 @@ impl LocalWorkdirSession {
reject_symlinks,
closed: AtomicBool::new(false),
close_lock: Mutex::new(()),
checkout_lock: StdMutex::new(()),
next_command_id: AtomicU64::new(1),
commands: Mutex::new(HashMap::new()),
command_telemetry: CommandTelemetry::new(),
@@ -1129,6 +1242,69 @@ impl LocalWorkdirSession {
Ok(())
}
fn checkout_access(&self) -> ScopeAccess {
let guard = self
.operation_guard
.clone()
.unwrap_or_else(|| OperationGuard {
cancelled: Arc::new(AtomicBool::new(false)),
deadline: Instant::now() + Duration::from_secs(30),
});
ScopeAccess::new(
self.inner.scope.snapshot(),
&self.inner.root,
self.inner.pinned_root.clone(),
self.inner.reject_symlinks,
Some(guard),
)
}
fn checkout_root(&self) -> Result<Arc<std::fs::File>, WorkdirError> {
match &self.inner.pinned_root {
Some(root) => Ok(root.clone()),
None => fs_operation::open_root_no_symlinks(&self.inner.root)
.map(Arc::new)
.map_err(|e| WorkdirError::io(Path::new("<checkout-root>"), e)),
}
}
fn checkout_observation(
&self,
path: WorkdirPath,
pinned: &fs_operation::CheckedTarget<'_>,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let metadata = pinned
.metadata()
.map_err(|e| WorkdirError::io(Path::new(path.as_str()), e))?;
if fs_operation::identity_validator(&metadata).map_err(|e| checkout_io(&path, e))?
!= pinned.validator()
{
return Err(WorkdirError::Conflict(
"checkout changed during observation".into(),
));
}
let access = self.scope_access();
let absolute = self.resolve(&path);
let capabilities = if manifest::Scope::is_writable(&access.scope, &absolute) {
self.capabilities()
.intersection(WorkdirSessionCapabilities::READ_WRITE)
} else {
self.capabilities()
.intersection(WorkdirSessionCapabilities::READ_ONLY)
};
Ok(crate::CheckoutObservation {
path,
kind: if metadata.is_dir() {
crate::EntryKind::Directory
} else {
crate::EntryKind::File
},
size: metadata.len(),
validator: pinned.validator().to_vec(),
capabilities,
})
}
fn scope_access(&self) -> ScopeAccess {
ScopeAccess::new(
self.inner.scope.snapshot(),
@@ -1236,6 +1412,255 @@ impl WorkdirSession for LocalWorkdirSession {
))
}
async fn checkout_search(
&self,
mut request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
use crate::{CheckoutSearchOperation as Op, CheckoutSearchResult as Output};
self.ensure_capability(request.operation.capability())?;
request.validate()?;
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
let provider_root = self.checkout_root()?;
let mut inner = self.checkout_access();
inner.pinned_root = Some(provider_root);
inner.reject_symlinks = true;
let output_root = self.resolve(&request.output_root);
let logical = request.operation.path().clone();
let relative = Path::new(logical.as_str())
.strip_prefix(request.output_root.as_str())
.map_err(|_| WorkdirError::Denied("checkout search escaped output root".into()))?;
*request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| {
WorkdirError::InvalidPath("checkout search path is not UTF8".into())
})?)?;
let source_allows = Arc::new(inner.scope.allow_rules());
let access = SearchAccess {
inner,
layers: request.scope_layers,
output_root: output_root.clone(),
source_allows,
};
let result = match request.operation {
Op::List(r) => fs_operation::run_list(&output_root, r, &access).map(Output::List),
Op::Glob(r) => {
fs_operation::run_glob(&output_root, &self.resolve(&logical), r, &access)
.map(Output::Glob)
}
Op::Grep(r) => fs_operation::run_grep(&output_root, self.resolve(&logical), r, &access)
.map(Output::Grep),
};
result
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
}
async fn checkout_observe(
&self,
path: WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
crate::checkout::validate_path(&path)?;
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
let root = self.checkout_root().map_err(|error| match error {
WorkdirError::Io { source, .. } => checkout_observe_io(&path, source),
error => error,
})?;
let access = self.checkout_access();
let pinned = fs_operation::CheckedTarget::pin(
&root,
Path::new(path.as_str()),
&self.resolve(&path),
&access,
false,
)
.map_err(|e| checkout_observe_io(&path, e))?;
self.checkout_observation(path.clone(), &pinned)
.map_err(|error| match error {
WorkdirError::Io { source, .. } => checkout_observe_io(&path, source),
error => error,
})
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
use crate::{CheckoutOperation as Op, CheckoutOutput as Output};
self.ensure_capability(request.operation.capability())?;
crate::checkout::validate_path(&request.target)?;
if request.validator.is_empty() || request.validator.len() > 256 {
return Err(WorkdirError::InvalidArgument(
"invalid checkout validator".into(),
));
}
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
let path = request.target;
let root = self
.checkout_root()
.map_err(|error| checkout_execute_error(&path, error))?;
let access = self.checkout_access();
let mut pinned = fs_operation::CheckedTarget::pin(
&root,
Path::new(path.as_str()),
&self.resolve(&path),
&access,
request.operation.capability() != WorkdirSessionCapability::Read,
)
.map_err(|e| checkout_io(&path, e))?;
if pinned.validator() != request.validator {
return Err(WorkdirError::Conflict(
"checkout observation changed; observe and read again".into(),
));
}
let is_create = matches!(&request.operation, Op::Create { .. });
let output = match request.operation {
Op::Read {
offset,
limit,
max_bytes,
} => {
if limit > 1_000_000
|| max_bytes > BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
{
return Err(WorkdirError::InvalidArgument(
"checkout Read exceeds provider bounds".into(),
));
}
fs_operation::run_read_bounded(
&self.inner.root,
ReadRequest {
path: path.clone(),
offset,
limit,
max_bytes,
},
&pinned,
self.inner
.read_limits
.unwrap_or(BoundedReadLimits::EXTERNAL_DEFAULT),
)
.map(Output::Read)
}
Op::Write {
content,
expected_hash,
} => fs_operation::run_write(
&self.inner.root,
WriteRequest {
path: path.clone(),
content,
expected_hash: Some(expected_hash),
},
&pinned,
)
.map(Output::Write),
Op::Edit {
old_string,
new_string,
replace_all,
expected_hash,
} => {
if old_string.len().saturating_add(new_string.len()) > 8 * 1024 * 1024 {
return Err(WorkdirError::InvalidArgument(
"checkout Edit exceeds provider bounds".into(),
));
}
fs_operation::run_edit(
&self.inner.root,
EditRequest {
path: path.clone(),
old_string,
new_string,
replace_all,
expected_hash,
},
&pinned,
)
.map(Output::Edit)
}
Op::Create {
path: destination,
content,
} => {
let suffix = crate::checkout::create_relative_path(&path, &destination)?;
let create = pinned.create_access(suffix, self.resolve(&destination));
fs_operation::run_write(
&self.inner.root,
WriteRequest {
path: destination,
content,
expected_hash: None,
},
&create,
)
.map(Output::Write)
}
};
let output = output.map_err(|error| {
if pinned.effects_possible() {
WorkdirError::OutcomeUnknown(
"checkout save or parent creation may have effects; inspect before retry"
.into(),
)
} else {
checkout_execute_error(&path, WorkdirError::from(error))
}
})?;
if matches!(&output, Output::Read(_)) {
pinned.confirm().map_err(|e| checkout_io(&path, e))?;
return Ok(crate::CheckoutResult {
observation: self
.checkout_observation(path.clone(), &pinned)
.map_err(|error| checkout_execute_error(&path, error))?,
output,
});
}
if is_create {
pinned.refresh_directory_observation().map_err(|_| {
WorkdirError::OutcomeUnknown(
"checkout Create may have effects but parent observation failed".into(),
)
})?;
let observation = self.checkout_observation(path, &pinned).map_err(|_| {
WorkdirError::OutcomeUnknown(
"checkout Create may have effects but parent observation failed".into(),
)
})?;
return Ok(crate::CheckoutResult {
observation,
output,
});
}
let after = fs_operation::CheckedTarget::pin(
&root,
Path::new(path.as_str()),
&self.resolve(&path),
&access,
true,
)
.map_err(|_| {
WorkdirError::OutcomeUnknown("checkout saved but post-save observation failed".into())
})?;
let observation = self.checkout_observation(path, &after).map_err(|_| {
WorkdirError::OutcomeUnknown("checkout saved but post-save observation failed".into())
})?;
Ok(crate::CheckoutResult {
observation,
output,
})
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
@@ -1247,6 +1672,11 @@ impl WorkdirSession for LocalWorkdirSession {
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
self.validate_operation_path(&request.path)?;
@@ -1277,6 +1707,11 @@ impl WorkdirSession for LocalWorkdirSession {
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
self.ensure_capability(WorkdirSessionCapability::Write)?;
let logical = request.path.clone();
self.validate_operation_path(&request.path)?;
@@ -1287,6 +1722,11 @@ impl WorkdirSession for LocalWorkdirSession {
}
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
self.ensure_capability(WorkdirSessionCapability::Edit)?;
let logical = request.path.clone();
self.validate_operation_path(&request.path)?;
@@ -1562,6 +2002,44 @@ fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> C
}
}
// Discovery of an absent route is not a stale-validator execution attempt.
fn checkout_observe_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError {
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) {
WorkdirError::NotFound(PathBuf::from(path.as_str()))
} else {
checkout_io(path, error)
}
}
// All pre-effect execution stages preserve the same stale-name classification,
// including FsError::NotFound produced by the shared engine after pinning.
fn checkout_execute_error(path: &WorkdirPath, error: WorkdirError) -> WorkdirError {
match error {
WorkdirError::NotFound(_) => {
WorkdirError::Conflict("checkout observation changed; observe and read again".into())
}
WorkdirError::Io { source, .. } => checkout_io(path, source),
error => sanitize_error(error, path),
}
}
fn checkout_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError {
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::NotFound
| std::io::ErrorKind::NotADirectory
| std::io::ErrorKind::AlreadyExists
) {
WorkdirError::Conflict("checkout observation changed; observe and read again".into())
} else {
sanitize_error(WorkdirError::io(Path::new(path.as_str()), error), path)
}
}
fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
let path = PathBuf::from(logical.as_str());
match error {
@@ -2083,6 +2561,47 @@ mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
let path = WorkdirPath::new("missing").unwrap();
for kind in [
std::io::ErrorKind::NotFound,
std::io::ErrorKind::NotADirectory,
] {
assert!(
matches!(checkout_observe_io(&path, std::io::Error::from(kind)), WorkdirError::NotFound(p) if p == Path::new("missing"))
);
assert!(matches!(
checkout_io(&path, std::io::Error::from(kind)),
WorkdirError::Conflict(_)
));
assert!(matches!(
checkout_execute_error(
&path,
WorkdirError::io(Path::new("<provider-root>"), std::io::Error::from(kind))
),
WorkdirError::Conflict(_)
));
}
let shared_error =
WorkdirError::from(fs_operation::FsError::NotFound(PathBuf::from("missing")));
assert!(matches!(
checkout_execute_error(&path, shared_error),
WorkdirError::Conflict(_)
));
assert!(matches!(
checkout_observe_io(&path, std::io::Error::from(std::io::ErrorKind::WouldBlock)),
WorkdirError::Conflict(_)
));
assert!(matches!(
checkout_execute_error(
&path,
WorkdirError::OutcomeUnknown("possible save effects".into())
),
WorkdirError::OutcomeUnknown(_)
));
}
fn make_fs(dir: &TempDir) -> LocalWorkdirSession {
LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
+59
View File
@@ -248,6 +248,29 @@ impl WorkdirSession for RoutedWorkdirSession {
self.state.session.scope_rules_overlap(request).await
}
async fn checkout_search(
&self,
request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_search(request).await
}
async fn checkout_observe(
&self,
path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_observe(path).await
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_execute(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.stat(request).await
@@ -650,6 +673,42 @@ mod tests {
))
}
#[tokio::test]
async fn checkout_routes_use_active_operation_detach_fence_and_old_handles_stay_closed() {
let root = TempDir::new().unwrap();
std::fs::write(root.path().join("a"), "contents").unwrap();
let router = WorkdirSessionRouter::new();
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
router.attach(alias.clone(), session("wd", &root)).unwrap();
let old = router.resolve(Some("checkout")).unwrap().session;
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
.await
.unwrap();
let state = router.sessions.read().unwrap().get(&alias).unwrap().clone();
let active = state.enter().unwrap();
assert!(router.begin_detach(&alias).await.is_err());
drop(active);
router.detach(&alias).await.unwrap();
assert!(
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
.await
.is_err()
);
router.attach(alias, session("wd-new", &root)).unwrap();
router
.resolve(Some("checkout"))
.unwrap()
.session
.checkout_observe(crate::WorkdirPath::new("a").unwrap())
.await
.unwrap();
assert!(
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
.await
.is_err()
);
}
#[tokio::test]
async fn router_holds_multiple_aliases_without_implicit_primary() {
let left = TempDir::new().unwrap();
+256
View File
@@ -994,6 +994,138 @@ impl WorkdirSession for ScopedWorkdirSession {
self.capabilities
}
async fn checkout_search(
&self,
mut request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
request.validate()?;
let path = self
.resolve_operation_path(request.operation.path(), WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, request.operation.capability())?;
*request.operation.path_mut() = path;
request.output_root = self.resolve_path(&request.output_root)?;
for layer in &mut request.scope_layers {
for rule in layer {
rule.target = self.resolve_path(&rule.target)?;
}
}
if let Some(rules) = &self.scope {
request.scope_layers.push(rules.clone());
}
request.validate()?;
// Source results already use the composed output_root, not this source's
// cwd. Rewriting them here would duplicate cwd through nested wrappers.
let result = self.source.checkout_search(request).await?;
self.ensure_active()?;
Ok(result)
}
async fn checkout_observe(
&self,
caller_path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
let path = self
.resolve_operation_path(&caller_path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
let mut observation = self.source.checkout_observe(path.clone()).await?;
observation.capabilities = observation.capabilities.intersection(self.capabilities());
// Capability bits must not advertise authority over a read-only path or
// a path whose write authority is currently leased to a child.
if self
.resolve_operation_path(&caller_path, WorkdirToolScopePermission::Write)
.await
.is_err()
|| self
.ensure_write(&path, WorkdirSessionCapability::Write)
.is_err()
{
observation.capabilities = observation
.capabilities
.intersection(WorkdirSessionCapabilities::READ_ONLY);
}
observation.path = caller_path;
Ok(observation)
}
async fn checkout_execute(
&self,
mut request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
let caller_path = request.target.clone();
let capability = request.operation.capability();
let permission = if capability == WorkdirSessionCapability::Read {
WorkdirToolScopePermission::Read
} else {
WorkdirToolScopePermission::Write
};
let path = self
.resolve_operation_path(&request.target, permission)
.await?;
if permission == WorkdirToolScopePermission::Read {
self.ensure_read(&path, capability)?;
} else {
self.ensure_write(&path, capability)?;
}
let result_path = caller_path;
if let crate::CheckoutOperation::Create {
path: destination, ..
} = &mut request.operation
{
let suffix = crate::checkout::create_relative_path(&request.target, destination)?;
let mut intermediate = std::path::PathBuf::from(request.target.as_str());
let mut components = suffix.components().peekable();
while let Some(component) = components.next() {
intermediate.push(component.as_os_str());
if components.peek().is_some() {
let intermediate =
crate::WorkdirPath::new(intermediate.to_str().ok_or_else(|| {
WorkdirError::InvalidPath("non-UTF8 Create path".into())
})?)?;
let resolved = self
.resolve_operation_path(&intermediate, WorkdirToolScopePermission::Write)
.await?;
self.ensure_write(&resolved, WorkdirSessionCapability::Write)?;
}
}
let resolved = self
.resolve_operation_path(destination, WorkdirToolScopePermission::Write)
.await?;
self.ensure_write(&resolved, WorkdirSessionCapability::Write)?;
*destination = resolved;
}
request.target = path;
let mut result = self.source.checkout_execute(request).await?;
result.observation.path = result_path.clone();
result.observation.capabilities = result
.observation
.capabilities
.intersection(self.capabilities());
if self
.resolve_operation_path(&result_path, WorkdirToolScopePermission::Write)
.await
.is_err()
|| self
.ensure_write(
&self.resolve_path(&result_path)?,
WorkdirSessionCapability::Write,
)
.is_err()
{
result.observation.capabilities = result
.observation
.capabilities
.intersection(WorkdirSessionCapabilities::READ_ONLY);
}
if let crate::CheckoutOutput::Read(read) = &mut result.output {
read.path = result_path;
}
Ok(result)
}
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
@@ -1048,6 +1180,19 @@ impl WorkdirSession for ScopedWorkdirSession {
}
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
if self.scope.is_some() || !self.cwd.is_root() {
return match self
.checkout_search(crate::CheckoutSearchRequest::new(
crate::CheckoutSearchOperation::List(request),
))
.await?
{
crate::CheckoutSearchResult::List(result) => Ok(result),
_ => Err(WorkdirError::Unavailable(
"mismatched checkout search result".into(),
)),
};
}
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
@@ -1057,6 +1202,19 @@ impl WorkdirSession for ScopedWorkdirSession {
}
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
if self.scope.is_some() || !self.cwd.is_root() {
return match self
.checkout_search(crate::CheckoutSearchRequest::new(
crate::CheckoutSearchOperation::Glob(request),
))
.await?
{
crate::CheckoutSearchResult::Glob(result) => Ok(result),
_ => Err(WorkdirError::Unavailable(
"mismatched checkout search result".into(),
)),
};
}
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
@@ -1066,6 +1224,19 @@ impl WorkdirSession for ScopedWorkdirSession {
}
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
if self.scope.is_some() || !self.cwd.is_root() {
return match self
.checkout_search(crate::CheckoutSearchRequest::new(
crate::CheckoutSearchOperation::Grep(request),
))
.await?
{
crate::CheckoutSearchResult::Grep(result) => Ok(result),
_ => Err(WorkdirError::Unavailable(
"mismatched checkout search result".into(),
)),
};
}
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
@@ -1284,6 +1455,44 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
self.inner.scope_rules_overlap(request).await
}
async fn checkout_search(
&self,
request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
if !self
.capabilities()
.intersection(self.inner.capabilities())
.supports(request.operation.capability())
{
return Err(WorkdirError::Unsupported(request.operation.capability()));
}
self.inner.checkout_search(request).await
}
async fn checkout_observe(
&self,
path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let mut observation = self.inner.checkout_observe(path).await?;
observation.capabilities = observation
.capabilities
.intersection(WorkdirSessionCapabilities::READ_ONLY);
Ok(observation)
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
if request.operation.capability() != WorkdirSessionCapability::Read {
return Err(WorkdirError::Unsupported(request.operation.capability()));
}
let mut result = self.inner.checkout_execute(request).await?;
result.observation.capabilities = result
.observation
.capabilities
.intersection(WorkdirSessionCapabilities::READ_ONLY);
Ok(result)
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.inner.stat(request).await
}
@@ -1654,6 +1863,53 @@ mod tests {
(WorkdirToolBroker::new(source), receiver, release)
}
#[tokio::test]
async fn checkout_search_unsupported_source_never_falls_back_to_broad_traversal() {
let root = TempDir::new().unwrap();
fs::create_dir(root.path().join("child")).unwrap();
fs::write(root.path().join("child/a"), "needle").unwrap();
let (parent, _, _) = blocking_session(root.path());
// Unscoped normal Tools still use their established provider contract.
assert_eq!(
parent
.list(ListRequest {
path: fs_path("child"),
limit: 100
})
.await
.unwrap()
.entries
.len(),
1
);
let child = parent
.scope(request("child", WorkdirToolScopePermission::Read))
.await
.unwrap();
assert!(matches!(
child
.tool_session()
.list(ListRequest {
path: FsPath::root(),
limit: 100
})
.await,
Err(WorkdirError::UnsupportedOperation(_))
));
assert!(matches!(
child
.tool_session()
.checkout_search(crate::CheckoutSearchRequest::new(
crate::CheckoutSearchOperation::List(ListRequest {
path: FsPath::root(),
limit: 100
})
))
.await,
Err(WorkdirError::UnsupportedOperation(_))
));
}
fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope {
WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {