feat: project attached checkouts with scoped native AI operations
This commit is contained in:
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user