Content directories always have no indexable children; direct authorized paths remain resolvable. Add List using provider Read/enumeration authority and separate List permission, dirs-first lexical live continuation, bounded retention/path bytes, scoped result coordinates and External DTO validation. List/Glob/Grep return Descriptor-typed entries without eager acquisition. Bind content Interfaces coherently to the live entrance scope. Validation: root cargo check; FS 58 unit+6 pagination, Workdir 125+5 (default and http-client), Tools 84 unit+39 integration+1 doctest, Worker 901 unit+110 integration, Server 691 unit+11 CLI, Server API 83 tests; exact crates.io WIP 0.2.0 resolution; format/diff checks. Focused model/local/remote/provider/race/scope checks passed. Commands and semantics in docs/development/wip-integration-validation.md. Publication/review paused: continuation ShowTicket returned HTTP 401. No Backend bypass, source push, MR approval, target integration or live environment update is claimed.
268 lines
9.4 KiB
Rust
268 lines
9.4 KiB
Rust
#![cfg(target_os = "linux")]
|
|
use std::sync::Arc;
|
|
use workdir::external::{
|
|
ExternalWorkdirOperation, ExternalWorkdirOperationResult, MAX_EXTERNAL_RESULT_ITEMS,
|
|
};
|
|
use workdir::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output};
|
|
use workdir::*;
|
|
|
|
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: manifest::SymlinkPolicy::Logical,
|
|
}
|
|
}
|
|
fn scope(rules: Vec<WorkdirToolScopeRule>, cwd: &str) -> WorkdirToolScope {
|
|
WorkdirToolScope {
|
|
rules,
|
|
cwd: path(cwd),
|
|
command: false,
|
|
}
|
|
}
|
|
fn request(value: &str, limit: usize, after: Option<ListCursor>) -> ListRequest {
|
|
ListRequest {
|
|
path: path(value),
|
|
limit,
|
|
after,
|
|
}
|
|
}
|
|
fn names(result: &ListResult) -> Vec<&str> {
|
|
result
|
|
.entries
|
|
.iter()
|
|
.map(|entry| entry.path.as_str())
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_scoped_external_pages_rebase_cursor_and_preserve_sparse_authority() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(root.path().join("child/deep")).unwrap();
|
|
for name in ["child/a", "child/z", "child/deep/secret", "outside"] {
|
|
std::fs::write(root.path().join(name), b"x").unwrap();
|
|
}
|
|
let source = LocalWorkdirSession::external_read_only(
|
|
Workdir::new("pagination"),
|
|
root.path(),
|
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
|
)
|
|
.unwrap();
|
|
let broker = WorkdirToolBroker::new(Arc::new(source));
|
|
let lease = broker
|
|
.scope(scope(vec![rule("child", false)], "child"))
|
|
.await
|
|
.unwrap();
|
|
// Two actual wrappers must not duplicate child/ in the continuation.
|
|
let stacked = WorkdirToolBroker::new(lease.tool_session());
|
|
let first = stacked.list(request("", 1, None)).await.unwrap();
|
|
assert_eq!(names(&first), ["deep"]);
|
|
assert_eq!(first.next_after.as_ref().unwrap().path, path("deep"));
|
|
assert_eq!(
|
|
first.next_after.as_ref().unwrap().kind,
|
|
EntryKind::Directory
|
|
);
|
|
assert_eq!(first.total_entries, 3);
|
|
assert!(
|
|
stacked
|
|
.list(request("deep", 1, None))
|
|
.await
|
|
.unwrap()
|
|
.entries
|
|
.is_empty()
|
|
);
|
|
let second = stacked
|
|
.list(request("", 1, first.next_after))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(names(&second), ["a"]);
|
|
std::fs::remove_file(root.path().join("child/a")).unwrap();
|
|
std::fs::write(root.path().join("child/b"), b"xx").unwrap();
|
|
let third = stacked
|
|
.list(request("", 10, second.next_after))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(names(&third), ["b", "z"]);
|
|
assert!(!third.truncated);
|
|
assert!(third.next_after.is_none());
|
|
assert_eq!(third.total_entries, 3);
|
|
lease.close().await.unwrap();
|
|
assert!(matches!(
|
|
stacked.list(request("", 1, None)).await,
|
|
Err(WorkdirError::SessionClosed)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_checkout_search_cursor_uses_output_root_coordinates_across_nested_wrappers() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(root.path().join("child/keep")).unwrap();
|
|
for name in ["a", "b", "c"] {
|
|
std::fs::write(root.path().join("child/keep").join(name), b"x").unwrap();
|
|
}
|
|
let source = LocalWorkdirSession::external_read_only(
|
|
Workdir::new("rebased-pagination"),
|
|
root.path(),
|
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
|
)
|
|
.unwrap();
|
|
let broker = WorkdirToolBroker::new(Arc::new(source));
|
|
let lease = broker
|
|
.scope(scope(vec![rule("child", true)], "child"))
|
|
.await
|
|
.unwrap();
|
|
let stacked = WorkdirToolBroker::new(lease.tool_session());
|
|
let mut search =
|
|
CheckoutSearchRequest::new(CheckoutSearchOperation::List(request("keep", 1, None)));
|
|
search.output_root = path("keep");
|
|
let CheckoutSearchResult::List(first) = stacked.checkout_search(search.clone()).await.unwrap()
|
|
else {
|
|
panic!()
|
|
};
|
|
assert_eq!(names(&first), ["a"]);
|
|
if let CheckoutSearchOperation::List(list) = &mut search.operation {
|
|
list.after = first.next_after;
|
|
}
|
|
let CheckoutSearchResult::List(second) = stacked.checkout_search(search).await.unwrap() else {
|
|
panic!()
|
|
};
|
|
assert_eq!(names(&second), ["b"]);
|
|
assert_eq!(second.next_after.unwrap().path, path("b"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_read_capability_suffices_without_glob_and_missing_read_is_denied() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
std::fs::write(root.path().join("a"), b"x").unwrap();
|
|
let source = LocalWorkdirSession::materialized(
|
|
root.path().to_path_buf(),
|
|
root.path().to_path_buf(),
|
|
manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()),
|
|
WorkdirSessionCapabilities::from_capabilities([WorkdirSessionCapability::Read]),
|
|
);
|
|
let broker = WorkdirToolBroker::new(Arc::new(source));
|
|
let lease = broker.scope(scope(vec![rule("", true)], "")).await.unwrap();
|
|
assert_eq!(
|
|
names(
|
|
&lease
|
|
.tool_session()
|
|
.list(request("", 1, None))
|
|
.await
|
|
.unwrap()
|
|
),
|
|
["a"]
|
|
);
|
|
let denied = LocalWorkdirSession::external_with_capabilities_pinned(
|
|
Workdir::new("no-read"),
|
|
ExternalWorkdirRoot::pin(root.path()).unwrap(),
|
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
|
WorkdirSessionCapabilities::COMMAND_ONLY,
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
denied.list(request("", 1, None)).await,
|
|
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Read))
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_external_wire_preserves_continuation_and_rejects_unbounded_cursor() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir(root.path().join("child")).unwrap();
|
|
for name in ["a", "b"] {
|
|
std::fs::write(root.path().join("child").join(name), b"x").unwrap();
|
|
}
|
|
let source = LocalWorkdirSession::external_read_only(
|
|
Workdir::new("wire-list"),
|
|
root.path(),
|
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
|
)
|
|
.unwrap();
|
|
let first = source.list(request("child", 1, None)).await.unwrap();
|
|
let wire = serde_json::to_value(
|
|
ExternalWorkdirOperationResult::try_from(Output::List(first.clone())).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(wire["result"]["next_after"]["path"], "child/a");
|
|
let decoded: ExternalWorkdirOperationResult = serde_json::from_value(wire.clone()).unwrap();
|
|
assert_eq!(decoded.into_inner(), Output::List(first.clone()));
|
|
let operation =
|
|
ExternalWorkdirOperation::try_from(Op::List(request("child", 1, first.next_after)))
|
|
.unwrap();
|
|
let decoded: ExternalWorkdirOperation =
|
|
serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap();
|
|
let Output::List(next) = dispatch_workdir_session_operation(&source, decoded.into_inner())
|
|
.await
|
|
.unwrap()
|
|
else {
|
|
panic!()
|
|
};
|
|
assert_eq!(names(&next), ["child/b"]);
|
|
assert!(next.next_after.is_none());
|
|
for bad_path in ["/host".to_owned(), "x".repeat(4097), "outside/a".to_owned()] {
|
|
let mut bad = serde_json::to_value(Op::List(request("child", 1, None))).unwrap();
|
|
bad["request"]["after"] = serde_json::json!({"kind": "file", "path": bad_path});
|
|
assert!(serde_json::from_value::<ExternalWorkdirOperation>(bad).is_err());
|
|
let mut bad_result = wire.clone();
|
|
bad_result["result"]["next_after"]["path"] = serde_json::json!(bad_path);
|
|
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(bad_result).is_err());
|
|
}
|
|
assert!(
|
|
ExternalWorkdirOperation::try_from(Op::List(request(
|
|
"child",
|
|
MAX_EXTERNAL_RESULT_ITEMS + 1,
|
|
None
|
|
)))
|
|
.is_err()
|
|
);
|
|
// Search cursors use result coordinates, not provider path coordinates.
|
|
let mut search = CheckoutSearchRequest::new(CheckoutSearchOperation::List(request(
|
|
"child",
|
|
1,
|
|
Some(ListCursor {
|
|
kind: EntryKind::File,
|
|
path: path("a"),
|
|
}),
|
|
)));
|
|
search.output_root = path("child");
|
|
let bounded = ExternalWorkdirOperation::try_from(Op::CheckoutSearch(search)).unwrap();
|
|
let Output::CheckoutSearch(CheckoutSearchResult::List(next)) =
|
|
dispatch_workdir_session_operation(&source, bounded.into_inner())
|
|
.await
|
|
.unwrap()
|
|
else {
|
|
panic!()
|
|
};
|
|
assert_eq!(names(&next), ["b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn list_external_result_counts_cursor_toward_aggregate_path_bound() {
|
|
let entries: Vec<_> = (0..252)
|
|
.map(|n| ListEntry {
|
|
path: path(&format!("{n:04}{}", "x".repeat(4092))),
|
|
kind: EntryKind::File,
|
|
size: 1,
|
|
})
|
|
.collect();
|
|
let after = ListCursor {
|
|
kind: EntryKind::File,
|
|
path: entries.last().unwrap().path.clone(),
|
|
};
|
|
let result = ListResult {
|
|
entries,
|
|
total_entries: 253,
|
|
total_bytes: 253,
|
|
truncated: true,
|
|
next_after: None,
|
|
};
|
|
let mut wire = serde_json::to_value(Output::List(result)).unwrap();
|
|
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(wire.clone()).is_ok());
|
|
wire["result"]["next_after"] = serde_json::to_value(after).unwrap();
|
|
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(wire).is_err());
|
|
}
|