feat: share virtual file contracts and checked binary byte ranges
This commit is contained in:
@@ -134,6 +134,14 @@ pub enum DriveEntryKind {
|
||||
File,
|
||||
}
|
||||
|
||||
/// Current caller's advisory publication ceiling, never a grant or execution credential.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveCapabilities {
|
||||
pub write: bool,
|
||||
}
|
||||
|
||||
/// Metadata only: content is returned through bounded text or binary routes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -491,6 +499,7 @@ impl api_macros::HttpRequestError for DriveApiError {
|
||||
|
||||
impl api_macros::openapi::OpenApiSchema for DriveApiError {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveEntry {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveCapabilities {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveListQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {}
|
||||
@@ -518,6 +527,7 @@ pub fn drive_api_typescript() -> String {
|
||||
DriveEntryRef::decl(&config),
|
||||
DriveEntryKind::decl(&config),
|
||||
DriveEntry::decl(&config),
|
||||
DriveCapabilities::decl(&config),
|
||||
DriveEntryQuery::decl(&config),
|
||||
DriveListQuery::decl(&config),
|
||||
DriveSearchQuery::decl(&config),
|
||||
|
||||
@@ -2762,6 +2762,12 @@ pub trait ServerApi {
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<DriveEntry, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/capabilities", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_capabilities(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<DriveCapabilities, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/metadata", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_metadata(
|
||||
&self,
|
||||
|
||||
@@ -436,3 +436,38 @@ pub async fn read_checkout_text(
|
||||
}
|
||||
Ok((result, validator))
|
||||
}
|
||||
|
||||
/// Binary-safe checked byte window. This does not establish a text prior-Read observation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn read_checkout_bytes(
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
path: WorkdirPath,
|
||||
validator: Vec<u8>,
|
||||
offset: u64,
|
||||
max_bytes: usize,
|
||||
) -> Result<(workdir::ReadBytesResult, Vec<u8>), ToolError> {
|
||||
let selected =
|
||||
crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?;
|
||||
if selected.generation != generation {
|
||||
return Err(stale_checkout("checkout connection changed"));
|
||||
}
|
||||
let result = selected
|
||||
.session
|
||||
.checkout_execute(workdir::CheckoutRequest {
|
||||
target: path.clone(),
|
||||
validator,
|
||||
operation: workdir::CheckoutOperation::ReadBytes { offset, max_bytes },
|
||||
})
|
||||
.await
|
||||
.map_err(crate::file_target::checked_error)?;
|
||||
match result.output {
|
||||
workdir::CheckoutOutput::ReadBytes(read)
|
||||
if read.path == path && read.offset == offset && read.bytes.len() <= max_bytes =>
|
||||
{
|
||||
Ok((read, result.observation.validator))
|
||||
}
|
||||
_ => Err(crate::file_target::readonly_result_error("ReadBytes")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ pub struct CheckoutRequest {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum CheckoutOperation {
|
||||
ReadBytes {
|
||||
offset: u64,
|
||||
max_bytes: usize,
|
||||
},
|
||||
Read {
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
@@ -71,7 +75,7 @@ pub enum CheckoutOperation {
|
||||
impl CheckoutOperation {
|
||||
pub fn capability(&self) -> WorkdirSessionCapability {
|
||||
match self {
|
||||
Self::Read { .. } => WorkdirSessionCapability::Read,
|
||||
Self::Read { .. } | Self::ReadBytes { .. } => WorkdirSessionCapability::Read,
|
||||
Self::Edit { .. } => WorkdirSessionCapability::Edit,
|
||||
Self::Write { .. } | Self::Create { .. } => WorkdirSessionCapability::Write,
|
||||
}
|
||||
@@ -86,6 +90,7 @@ impl CheckoutOperation {
|
||||
)]
|
||||
pub enum CheckoutOutput {
|
||||
Read(ReadResult),
|
||||
ReadBytes(crate::ReadBytesResult),
|
||||
Write(WriteResult),
|
||||
Edit(EditResult),
|
||||
}
|
||||
|
||||
@@ -185,6 +185,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
&& !request.validator.is_empty()
|
||||
&& request.validator.len() <= 256
|
||||
&& match &request.operation {
|
||||
crate::CheckoutOperation::ReadBytes { offset, max_bytes } => {
|
||||
*offset <= 9_007_199_254_740_991
|
||||
&& *max_bytes > 0
|
||||
&& *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
}
|
||||
crate::CheckoutOperation::Read {
|
||||
limit, max_bytes, ..
|
||||
} => {
|
||||
@@ -375,6 +380,13 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
||||
WorkdirSessionOperationResult::CheckoutExecute(result) => {
|
||||
valid_checkout_observation(&result.observation)
|
||||
&& match &result.output {
|
||||
crate::CheckoutOutput::ReadBytes(read) => {
|
||||
read.path == result.observation.path
|
||||
&& validate_external_result(&WorkdirSessionOperationResult::ReadBytes(
|
||||
read.clone(),
|
||||
))
|
||||
.is_ok()
|
||||
}
|
||||
crate::CheckoutOutput::Read(read) => {
|
||||
read.path == result.observation.path
|
||||
&& validate_external_result(&WorkdirSessionOperationResult::Read(
|
||||
|
||||
@@ -693,6 +693,12 @@ mod client {
|
||||
let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read;
|
||||
let expected_path = request.target.clone();
|
||||
let expected_output = request.operation.capability();
|
||||
let byte_range = match &request.operation {
|
||||
crate::CheckoutOperation::ReadBytes { offset, max_bytes } => {
|
||||
Some((*offset, *max_bytes))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CheckoutExecute(request))
|
||||
.await
|
||||
@@ -702,11 +708,30 @@ mod client {
|
||||
&& !result.observation.validator.is_empty()
|
||||
&& result.observation.validator.len() <= 256
|
||||
&& match (&result.output, expected_output) {
|
||||
(
|
||||
crate::CheckoutOutput::ReadBytes(read),
|
||||
crate::WorkdirSessionCapability::Read,
|
||||
) => byte_range.is_some_and(|(offset, max)| {
|
||||
read.path == expected_path
|
||||
&& read.offset == offset
|
||||
&& read.bytes.len() <= max
|
||||
&& read
|
||||
.offset
|
||||
.checked_add(read.bytes.len() as u64)
|
||||
.is_some_and(|end| {
|
||||
end <= read.total_bytes
|
||||
|| (read.bytes.is_empty() && read.eof)
|
||||
})
|
||||
&& read.eof
|
||||
== (read.offset.saturating_add(read.bytes.len() as u64)
|
||||
>= read.total_bytes)
|
||||
}),
|
||||
(
|
||||
crate::CheckoutOutput::Read(read),
|
||||
crate::WorkdirSessionCapability::Read,
|
||||
) => {
|
||||
read.path == expected_path
|
||||
byte_range.is_none()
|
||||
&& read.path == expected_path
|
||||
&& read.bytes.len()
|
||||
<= crate::BoundedReadLimits::EXTERNAL_DEFAULT
|
||||
.max_response_bytes
|
||||
|
||||
@@ -1587,6 +1587,20 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
}
|
||||
let is_create = matches!(&request.operation, Op::Create { .. });
|
||||
let output = match request.operation {
|
||||
Op::ReadBytes { offset, max_bytes } => fs_operation::run_read_bytes_bounded(
|
||||
&self.inner.root,
|
||||
ReadBytesRequest {
|
||||
path: path.clone(),
|
||||
offset,
|
||||
max_bytes,
|
||||
expected_hash: None,
|
||||
},
|
||||
&pinned,
|
||||
self.inner
|
||||
.read_limits
|
||||
.unwrap_or(BoundedReadLimits::EXTERNAL_DEFAULT),
|
||||
)
|
||||
.map(Output::ReadBytes),
|
||||
Op::Read {
|
||||
offset,
|
||||
limit,
|
||||
@@ -1679,7 +1693,7 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
checkout_execute_error(&path, WorkdirError::from(error))
|
||||
}
|
||||
})?;
|
||||
if matches!(&output, Output::Read(_)) {
|
||||
if matches!(&output, Output::Read(_) | Output::ReadBytes(_)) {
|
||||
pinned.confirm().map_err(|e| checkout_io(&path, e))?;
|
||||
return Ok(crate::CheckoutResult {
|
||||
observation: self
|
||||
|
||||
@@ -1222,6 +1222,9 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
.intersection(WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
if let crate::CheckoutOutput::Read(read) = &mut result.output {
|
||||
read.path = result_path.clone();
|
||||
}
|
||||
if let crate::CheckoutOutput::ReadBytes(read) = &mut result.output {
|
||||
read.path = result_path;
|
||||
}
|
||||
Ok(result)
|
||||
|
||||
@@ -270,7 +270,7 @@ impl Provider {
|
||||
let mut contracts = Vec::new();
|
||||
let kind = if observation.kind == EntryKind::File {
|
||||
if has(Cap::Read, "Read") {
|
||||
contracts.extend([C::ReadLines, C::SearchText]);
|
||||
contracts.extend([C::ReadLines, C::SearchText, C::ReadBytes]);
|
||||
}
|
||||
if has(Cap::Edit, "Edit") {
|
||||
contracts.push(C::Edit);
|
||||
@@ -541,6 +541,52 @@ impl WipOperationHandler for FileHandler {
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if operation == "read_bytes" {
|
||||
let (offset, limit) = crate::file_operation::byte_range(arguments)?;
|
||||
if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) {return Err(protocol(ProtocolErrorCode::PermissionDenied,"byte read denied"));}
|
||||
let _permit =
|
||||
tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"byte read capacity deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| protocol(ProtocolErrorCode::Internal, "byte read unavailable"))?;
|
||||
let (read, raw) = tokio::time::timeout(
|
||||
self.provider.deadline,
|
||||
tools::checkout::read_checkout_bytes(
|
||||
self.provider.router.clone(),
|
||||
&self.alias,
|
||||
self.generation,
|
||||
self.target.clone(),
|
||||
self.observation.validator.clone(),
|
||||
offset,
|
||||
limit,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"byte read deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(map_tool_error)?;
|
||||
self.check_connection()?;
|
||||
let value = crate::file_operation::bytes_result(arguments, &read.bytes, read.eof)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider.wrap_validator(
|
||||
&self.alias,
|
||||
self.generation,
|
||||
&self.workdir,
|
||||
&self.target,
|
||||
&raw,
|
||||
),
|
||||
));
|
||||
}
|
||||
if matches!(operation, "read_lines" | "search_text") {
|
||||
let (offset, limit) =
|
||||
crate::file_operation::window(arguments, operation == "search_text")?;
|
||||
|
||||
@@ -138,6 +138,9 @@ impl DriveBackend {
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn capabilities(&self) -> Result<DriveCapabilities, DriveError> {
|
||||
self.get("/capabilities", &serde_json::json!({})).await
|
||||
}
|
||||
pub async fn root(&self) -> Result<DriveEntry, DriveError> {
|
||||
let entry: DriveEntry = self
|
||||
.json(
|
||||
|
||||
@@ -7,12 +7,14 @@ use std::collections::VecDeque;
|
||||
struct Client {
|
||||
responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
capabilities: Mutex<WorkspaceResponse>,
|
||||
}
|
||||
impl Client {
|
||||
fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
responses: Mutex::new(responses.into_iter().map(Ok).collect()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
capabilities: Mutex::new(response(DriveCapabilities { write: false })),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,7 +32,15 @@ impl WorkspaceClient for Client {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let capabilities = request.path == "/api/w/workspace/drive/capabilities";
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if capabilities {
|
||||
let response = self.capabilities.lock().unwrap();
|
||||
return Ok(WorkspaceResponse {
|
||||
status: response.status,
|
||||
body: response.body.clone(),
|
||||
});
|
||||
}
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -479,7 +489,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
let feature = feature(client.clone());
|
||||
let mut mounts = crate::wip::WipMountRegistry::new();
|
||||
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
|
||||
let provider = wip::DriveProvider(feature);
|
||||
let provider = wip::DriveProvider::new(feature);
|
||||
let path = entry_path(&entry.entry);
|
||||
let publication = provider.publication(&path).await.unwrap().unwrap();
|
||||
assert_eq!(publication.projection.route, path);
|
||||
@@ -487,7 +497,8 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
publication.scope.as_ref().unwrap().r#ref,
|
||||
publication.projection.object.r#ref
|
||||
);
|
||||
assert_eq!(publication.projection.object.validator, Some(b"3".to_vec()));
|
||||
assert!(publication.projection.object.validator.is_some());
|
||||
assert_ne!(publication.projection.object.validator, Some(b"3".to_vec()));
|
||||
assert!(provider.children("/drive").await.unwrap().is_empty());
|
||||
assert!(
|
||||
provider
|
||||
@@ -504,7 +515,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
.flat_map(|op| &op.parameters)
|
||||
.any(|parameter| parameter.name == "expected_mutation_id")
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -656,3 +667,662 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
|
||||
);
|
||||
assert_eq!(client.json.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn native_context() -> crate::wip::WipCallContext {
|
||||
crate::wip::WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "drive-test".into(),
|
||||
}
|
||||
}
|
||||
fn native_args(value: Value) -> std::collections::BTreeMap<String, wip_protocol::Value> {
|
||||
let wip_protocol::Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else {
|
||||
panic!("named arguments required")
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_reads_use_bounded_complete_source_and_literal_window_search() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let text = "α\r\nneedle.*\nNeedle.*\nlast\n";
|
||||
let mut entry = entry("2", "3");
|
||||
entry.size = Some(text.len() as u32);
|
||||
let read = DriveReadTextResponse {
|
||||
entry: entry.clone(),
|
||||
text: text.into(),
|
||||
truncated: false,
|
||||
};
|
||||
let client = Client::new(vec![
|
||||
response(entry.clone()),
|
||||
response(&read),
|
||||
response(&read),
|
||||
]);
|
||||
let feature = feature(client.clone());
|
||||
let publication = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut projection = publication.projection;
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::ReadLines.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::SearchText.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.any(|op| op.name == "read" && op.parameters.iter().any(|p| p.name == "max_bytes"))
|
||||
);
|
||||
let original_handler = projection.handler.clone();
|
||||
assert!(crate::file_operation::select(
|
||||
&mut projection,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
let read = projection
|
||||
.handler
|
||||
.call(
|
||||
"read",
|
||||
&native_args(json!({"offset":1,"limit":2})),
|
||||
native_context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("common read failed"));
|
||||
let value = crate::wip::wip_to_json(&read.value).unwrap();
|
||||
assert_eq!(value["next_offset"], 3);
|
||||
assert_eq!(value["truncated"], true);
|
||||
assert!(value["content"].as_str().unwrap().contains("needle.*"));
|
||||
// The common selector wraps a provider handler once; reuse the original
|
||||
// publication for the independent SearchText contract.
|
||||
projection.handler = original_handler;
|
||||
let request = client.requests.lock().unwrap().last().unwrap().clone();
|
||||
assert!(request.path.contains("max_bytes=65536"));
|
||||
assert!(crate::file_operation::select(
|
||||
&mut projection,
|
||||
&Contract::SearchText.reference()
|
||||
));
|
||||
let search = projection
|
||||
.handler
|
||||
.call(
|
||||
"search",
|
||||
&native_args(json!({"pattern":"needle.*","limit":3})),
|
||||
native_context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("common search failed"));
|
||||
let value = crate::wip::wip_to_json(&search.value).unwrap();
|
||||
assert_eq!(value["matches"], json!([{"line":2,"text":"needle.*"}]));
|
||||
assert_eq!(value["next_offset"], 3);
|
||||
assert_eq!(value["truncated"], true);
|
||||
}
|
||||
|
||||
fn read_response(entry: &DriveEntry, text: &str) -> DriveReadTextResponse {
|
||||
DriveReadTextResponse {
|
||||
entry: entry.clone(),
|
||||
text: text.into(),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_refuses_truncated_mismatched_stale_and_denied_reads() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
use wip_protocol::ProtocolErrorCode;
|
||||
for (case, expected) in [
|
||||
("truncated", ProtocolErrorCode::ResourceLimitExceeded),
|
||||
("size", ProtocolErrorCode::ResourceLimitExceeded),
|
||||
("stale", ProtocolErrorCode::ValidatorMismatch),
|
||||
("denied", ProtocolErrorCode::PermissionDenied),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let mut read = read_response(&entry, "hello");
|
||||
match case {
|
||||
"truncated" => read.truncated = true,
|
||||
"size" => read.text = "hell".into(),
|
||||
"stale" => read.entry.last_mutation_id = "4".into(),
|
||||
_ => {}
|
||||
}
|
||||
let read = if case == "denied" {
|
||||
WorkspaceResponse {
|
||||
status: 403,
|
||||
body: "denied".into(),
|
||||
}
|
||||
} else {
|
||||
response(read)
|
||||
};
|
||||
let client = Client::new(vec![response(entry.clone()), read]);
|
||||
let projection = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let result = projection
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(ref error)) if error.code == expected),
|
||||
"{case}"
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3, "{case}: no retries");
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| request.method == crate::worker::WorkspaceRequestMethod::Get)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_rejects_oversized_source_before_read_and_remount_changes_object_validator()
|
||||
{
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let mut entry = entry("2", "3");
|
||||
entry.size = Some(server_api::DRIVE_CHUNK_MAX_BYTES + 1);
|
||||
let client = Client::new(vec![response(entry.clone()), response(entry.clone())]);
|
||||
let first = feature(client.clone());
|
||||
let second = first.clone();
|
||||
let path = entry_path(&entry.entry);
|
||||
let first = wip::DriveProvider::new(first)
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let second = wip::DriveProvider::new(second)
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_ne!(first.object.validator, second.object.validator);
|
||||
assert_eq!(first.object.interfaces, second.object.interfaces);
|
||||
let result = first
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::ResourceLimitExceeded)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_reads_keep_tool_permission_denial_before_backend_read() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
let feature = feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action: manifest::ToolPermissionAction::Deny,
|
||||
rules: Vec::new(),
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let result = projection
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::PermissionDenied)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_read_only_capability_omits_file_and_folder_mutation_contracts() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (kind, root) in [
|
||||
(DriveEntryKind::File, false),
|
||||
(DriveEntryKind::Folder, false),
|
||||
(DriveEntryKind::Folder, true),
|
||||
] {
|
||||
let mut entry = entry("2", "3");
|
||||
entry.kind = kind;
|
||||
if root {
|
||||
entry.parent = None;
|
||||
}
|
||||
let path = if root {
|
||||
"/drive".into()
|
||||
} else {
|
||||
entry_path(&entry.entry)
|
||||
};
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
let projection = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(!projection.descriptor.operations.iter().any(|op| matches!(
|
||||
op.name.as_str(),
|
||||
"write"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "relocate"
|
||||
| "create_folder"
|
||||
| "create_text"
|
||||
| "save_workdir"
|
||||
)));
|
||||
let result = projection
|
||||
.handler
|
||||
.call(
|
||||
"write",
|
||||
&native_args(json!({"content":"denied"})),
|
||||
native_context(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::OperationNotFound)
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].path, "/api/w/workspace/drive/capabilities");
|
||||
assert_eq!(
|
||||
requests[1].method,
|
||||
crate::worker::WorkspaceRequestMethod::Get
|
||||
);
|
||||
assert!(requests[1].body.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_capability_failure_never_publishes_an_assumed_write_ceiling() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (status, body) in [
|
||||
(403, "denied"),
|
||||
(500, "unavailable"),
|
||||
(200, "{}"),
|
||||
(200, "{\"write\":true,\"grant_id\":\"spoof\"}"),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = WorkspaceResponse {
|
||||
status,
|
||||
body: body.into(),
|
||||
};
|
||||
let result = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await;
|
||||
assert!(!matches!(result, Ok(Some(_))), "{status}: {body}");
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_mutation_publication_intersects_tool_permission_ceiling() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true });
|
||||
let feature = feature(client).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action: manifest::ToolPermissionAction::Allow,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "DriveWrite".into(),
|
||||
pattern: "*".into(),
|
||||
action: manifest::ToolPermissionAction::Deny,
|
||||
}],
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.any(|op| matches!(op.name.as_str(), "write" | "edit"))
|
||||
);
|
||||
}
|
||||
|
||||
/// Narrow transport fixture for native mutation chaining. The real provider,
|
||||
/// Client/Host, permission checks and Backend CAS adapter remain in the path;
|
||||
/// only canonical Drive persistence is replaced by an entry and text value.
|
||||
#[derive(Debug)]
|
||||
struct NativeMutations(Mutex<(DriveEntry, String, Vec<DriveMutationRequest>)>);
|
||||
impl WorkspaceClient for NativeMutations {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-native-mutations"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
let suffix = request
|
||||
.path
|
||||
.strip_prefix("/api/w/workspace/drive/")
|
||||
.unwrap();
|
||||
Ok(if suffix == "capabilities" {
|
||||
response(DriveCapabilities { write: true })
|
||||
} else if suffix.starts_with("metadata?") {
|
||||
response(&state.0)
|
||||
} else if suffix.starts_with("read-text?") {
|
||||
response(read_response(&state.0, &state.1))
|
||||
} else if suffix == "mutate" {
|
||||
let request: DriveMutationRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
let DriveMutation::UpdateText {
|
||||
id,
|
||||
expected_mutation_id,
|
||||
text,
|
||||
..
|
||||
} = &request.mutation
|
||||
else {
|
||||
panic!("unexpected mutation")
|
||||
};
|
||||
assert_eq!(id, &state.0.entry);
|
||||
assert_eq!(expected_mutation_id, &state.0.last_mutation_id);
|
||||
state.1 = text.clone();
|
||||
state.0.size = Some(text.len() as u32);
|
||||
state.0.last_mutation_id = request.request_id.clone();
|
||||
let output = response(DriveMutationResponse {
|
||||
request_id: request.request_id.clone(),
|
||||
entry: Some(state.0.clone()),
|
||||
});
|
||||
state.2.push(request);
|
||||
output
|
||||
} else {
|
||||
panic!("unexpected request: {}", request.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_edit_then_write_reuses_exact_post_operation_object_validator() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::{WipMountRegistry, WipRuntime};
|
||||
let entry = entry("2", "3");
|
||||
let path = entry_path(&entry.entry);
|
||||
let client = Arc::new(NativeMutations(Mutex::new((
|
||||
entry,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
))));
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(mounts, "drive-test".into()).unwrap();
|
||||
runtime
|
||||
.prepare_call_observations(path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
let edit = runtime
|
||||
.call(
|
||||
path.clone(),
|
||||
Contract::Edit.reference(),
|
||||
"edit".into(),
|
||||
json!({"old_string":"hello","new_string":"updated"}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let edit: Value = serde_json::from_str(edit.content.as_deref().unwrap()).unwrap();
|
||||
assert!(edit["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string());
|
||||
assert_eq!(client.0.lock().unwrap().1, "updated");
|
||||
// No intervening Inspect/prepare_call_observations: success depends on the
|
||||
// exact nonce-bound post-commit validator returned by the first operation.
|
||||
let write = runtime
|
||||
.call(
|
||||
path,
|
||||
Contract::Write.reference(),
|
||||
"write".into(),
|
||||
json!({"content":"final"}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let write: Value = serde_json::from_str(write.content.as_deref().unwrap()).unwrap();
|
||||
assert!(write["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string());
|
||||
let state = client.0.lock().unwrap();
|
||||
assert_eq!(state.1, "final");
|
||||
assert_eq!(state.2.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_argument_dependent_write_policy_is_rechecked_and_ask_is_not_published() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (default_action, pattern, action, published, code) in [
|
||||
(
|
||||
manifest::ToolPermissionAction::Deny,
|
||||
"*permitted*",
|
||||
manifest::ToolPermissionAction::Allow,
|
||||
true,
|
||||
wip_protocol::ProtocolErrorCode::PermissionDenied,
|
||||
),
|
||||
(
|
||||
manifest::ToolPermissionAction::Allow,
|
||||
"*",
|
||||
manifest::ToolPermissionAction::Ask,
|
||||
false,
|
||||
wip_protocol::ProtocolErrorCode::OperationNotFound,
|
||||
),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true });
|
||||
let feature =
|
||||
feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "DriveWrite".into(),
|
||||
pattern: pattern.into(),
|
||||
action,
|
||||
}],
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_eq!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference()),
|
||||
published
|
||||
);
|
||||
let result = projection
|
||||
.handler
|
||||
.call(
|
||||
"write",
|
||||
&native_args(json!({"content":"denied"})),
|
||||
native_context(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == code)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
// A single retained Object observation models a restored Client cache. All later
|
||||
// publication and execution use the real new-mount provider; the new Host supplies
|
||||
// the common contract's root scope independently, without replaying its old ref.
|
||||
struct RetainedObjectOnce {
|
||||
current: wip::DriveProvider,
|
||||
retained: Mutex<Option<wip_protocol::Object>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl crate::wip::WipSubtreeProvider for RetainedObjectOnce {
|
||||
async fn publication(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<crate::wip::WipPublication>, wip_protocol::ProtocolError> {
|
||||
let Some(mut publication) = self.current.publication(path).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(retained) = self.retained.lock().unwrap().take() {
|
||||
publication.projection.object = retained;
|
||||
publication = crate::wip::WipPublication::self_scoped(publication.projection)?;
|
||||
}
|
||||
Ok(Some(publication))
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, wip_protocol::ProtocolError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_same_feature_remount_rejects_retained_object_with_fresh_common_scope_then_reobservation_succeeds()
|
||||
{
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::{WipMountRegistry, WipRuntime, WipSubtreeMount, WipSubtreeProvider};
|
||||
let entry = entry("2", "3");
|
||||
let path = entry_path(&entry.entry);
|
||||
let client = Arc::new(NativeMutations(Mutex::new((
|
||||
entry,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
))));
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
// mount_drive_wip constructs exactly one of these providers per mount. Reuse
|
||||
// the same Feature, whose mutation request identity deliberately does not rotate.
|
||||
let first = wip::DriveProvider::new(feature.clone());
|
||||
let old = first.publication(&path).await.unwrap().unwrap().projection;
|
||||
let cloned = first
|
||||
.clone()
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_eq!(old.object.validator, cloned.object.validator);
|
||||
let second = wip::DriveProvider::new(feature.clone());
|
||||
let current = second.publication(&path).await.unwrap().unwrap().projection;
|
||||
assert_ne!(old.object.validator, current.object.validator);
|
||||
let mut old_contract = old.clone();
|
||||
let mut current_contract = current.clone();
|
||||
assert!(crate::file_operation::select(
|
||||
&mut old_contract,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
assert!(crate::file_operation::select(
|
||||
&mut current_contract,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
assert_eq!(old_contract.interface, current_contract.interface);
|
||||
assert_eq!(old_contract.descriptor, current_contract.descriptor);
|
||||
assert_eq!(
|
||||
old_contract.interface_validator,
|
||||
current_contract.interface_validator
|
||||
);
|
||||
|
||||
// Mount only the selected entry for this cache-restoration fixture. The real
|
||||
// Host still selects the root common contract and validates target membership,
|
||||
// scope_ref and Object/Interface validators before running the Drive handler.
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
mounts.allocate_namespace("drive", "drive").unwrap();
|
||||
mounts.mount(current).unwrap();
|
||||
mounts
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: path.clone(),
|
||||
provider: Arc::new(RetainedObjectOnce {
|
||||
current: second,
|
||||
retained: Mutex::new(Some(old.object)),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(mounts, "drive-remount".into()).unwrap();
|
||||
runtime.inspect(path.clone(), true).await.unwrap();
|
||||
let interface_path = format!(
|
||||
"{}::{}",
|
||||
serde_json::to_string("/").unwrap(),
|
||||
serde_json::to_string(&Contract::ReadLines.reference().name).unwrap()
|
||||
);
|
||||
// This fetch obtains the *new* Host's root scope_ref and the unchanged common
|
||||
// Interface validator; only the retained target observation remains stale.
|
||||
runtime.inspect(interface_path, true).await.unwrap();
|
||||
let error = runtime
|
||||
.call(
|
||||
path.clone(),
|
||||
Contract::ReadLines.reference(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ValidatorMismatch"), "{error}");
|
||||
assert!(!error.contains("InterfaceMismatch"), "{error}");
|
||||
runtime.inspect(path.clone(), true).await.unwrap();
|
||||
let result = runtime
|
||||
.call(
|
||||
path,
|
||||
Contract::ReadLines.reference(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap();
|
||||
assert!(result["content"].as_str().unwrap().contains("hello"));
|
||||
assert!(client.0.lock().unwrap().2.is_empty());
|
||||
}
|
||||
|
||||
@@ -40,7 +40,11 @@ pub fn mount_drive_wip(
|
||||
registry.allocate_namespace("drive", "drive")?;
|
||||
// Registration is only a namespace entrance. The subtree provides the
|
||||
// current target/scope/descriptor/handler in a single self-scoped snapshot.
|
||||
let names = root_specs();
|
||||
// This unresolved entrance has no live capability evidence.
|
||||
let names: Vec<_> = root_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| !mutation(spec))
|
||||
.collect();
|
||||
let descriptor = descriptor(&names, true);
|
||||
registry.mount(WipProjection {
|
||||
route: ROOT.into(),
|
||||
@@ -63,7 +67,7 @@ pub fn mount_drive_wip(
|
||||
})?;
|
||||
registry.mount_subtree(WipSubtreeMount {
|
||||
root: ROOT.into(),
|
||||
provider: Arc::new(DriveProvider(feature.clone())),
|
||||
provider: Arc::new(DriveProvider::new(feature.clone())),
|
||||
})?;
|
||||
registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?;
|
||||
Ok(())
|
||||
@@ -80,7 +84,19 @@ impl WipOperationHandler for Unresolved {
|
||||
Err(map_error(DriveError::Denied))
|
||||
}
|
||||
}
|
||||
pub(super) struct DriveProvider(pub(super) DriveFeature);
|
||||
#[derive(Clone)]
|
||||
pub(super) struct DriveProvider {
|
||||
feature: DriveFeature,
|
||||
observation_nonce: Arc<str>,
|
||||
}
|
||||
impl DriveProvider {
|
||||
pub(super) fn new(feature: DriveFeature) -> Self {
|
||||
Self {
|
||||
feature,
|
||||
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for DriveProvider {
|
||||
// Arbitrary tree depth is safe: there are deliberately no indexable entries.
|
||||
@@ -92,27 +108,50 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let result = if path == ROOT {
|
||||
self.0.backend.root().await
|
||||
self.feature.backend.root().await
|
||||
} else {
|
||||
let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else {
|
||||
let Some(entry) = parse_entry(&self.feature, path).map_err(protocol)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.0.backend.metadata(&entry).await
|
||||
self.feature.backend.metadata(&entry).await
|
||||
};
|
||||
let entry = match result {
|
||||
Ok(entry) => entry,
|
||||
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
|
||||
Err(error) => return Err(protocol(error)),
|
||||
};
|
||||
let capabilities = match self.feature.backend.capabilities().await {
|
||||
Ok(capabilities) => capabilities,
|
||||
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
|
||||
Err(error) => return Err(protocol(error)),
|
||||
};
|
||||
let root = path == ROOT;
|
||||
let specs = if root {
|
||||
let specs: Vec<_> = if root {
|
||||
root_specs()
|
||||
} else {
|
||||
entry_specs(&entry)
|
||||
};
|
||||
}
|
||||
.into_iter()
|
||||
.filter(|spec| {
|
||||
!mutation(spec) || (capabilities.write && publication_may_allow(&self.feature, spec))
|
||||
})
|
||||
.collect();
|
||||
let common_mutations: Vec<_> = specs
|
||||
.iter()
|
||||
.filter_map(|spec| match spec.operation {
|
||||
"edit" => Some(crate::file_operation::Contract::Edit),
|
||||
"write" => Some(crate::file_operation::Contract::Write),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let descriptor = descriptor(&specs, root);
|
||||
let reference = contextual_reference(INTERFACE, path);
|
||||
let validator = entry.last_mutation_id.as_bytes().to_vec();
|
||||
let validator = object_validator(
|
||||
&self.observation_nonce,
|
||||
&entry,
|
||||
&entry.last_mutation_id,
|
||||
&specs,
|
||||
);
|
||||
let object = Object {
|
||||
name: if root {
|
||||
"drive".into()
|
||||
@@ -130,7 +169,8 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
)),
|
||||
validator: Some(validator),
|
||||
};
|
||||
let projection = WipProjection {
|
||||
let common_read = entry.kind == DriveEntryKind::File;
|
||||
let mut projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "drive:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
@@ -139,18 +179,80 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
|
||||
descriptor,
|
||||
handler: Arc::new(DriveHandler {
|
||||
feature: self.0.clone(),
|
||||
feature: self.feature.clone(),
|
||||
observation_nonce: self.observation_nonce.clone(),
|
||||
entry,
|
||||
specs,
|
||||
root,
|
||||
}),
|
||||
};
|
||||
// This is publication evidence only. Backend execution rechecks current
|
||||
// authority and the captured commit precondition on every mutation.
|
||||
crate::file_operation::expose(&mut projection, common_mutations);
|
||||
if common_read {
|
||||
crate::file_operation::expose(
|
||||
&mut projection,
|
||||
[
|
||||
crate::file_operation::Contract::ReadLines,
|
||||
crate::file_operation::Contract::SearchText,
|
||||
],
|
||||
);
|
||||
}
|
||||
WipPublication::self_scoped(projection).map(Some)
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
fn object_validator(
|
||||
observation_nonce: &str,
|
||||
entry: &DriveEntry,
|
||||
mutation: &str,
|
||||
specs: &[Spec],
|
||||
) -> Vec<u8> {
|
||||
// Object observations belong to one mount, even when a restored Host reuses
|
||||
// the same Feature instance. Feature.nonce separately identifies mutations.
|
||||
// Current membership is part of Object representation, not the shared Interface lifetime.
|
||||
let operations: Vec<_> = specs.iter().map(|spec| spec.operation).collect();
|
||||
digest(
|
||||
format!(
|
||||
"{observation_nonce:?}:{:?}:{:?}:{mutation:?}:{operations:?}",
|
||||
entry.entry.workspace_id, entry.entry.node_id
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn mutation(spec: &Spec) -> bool {
|
||||
matches!(
|
||||
spec.operation,
|
||||
"write" | "edit" | "delete" | "relocate" | "create_folder" | "create_text" | "save_workdir"
|
||||
)
|
||||
}
|
||||
|
||||
fn publication_may_allow(feature: &DriveFeature, spec: &Spec) -> bool {
|
||||
let Some(permissions) = &feature.permissions else {
|
||||
return true;
|
||||
};
|
||||
// Like checkout publication, retain potentially allowed argument-dependent
|
||||
// operations but hide an unconditional Deny/Ask ceiling. This is not an
|
||||
// authorization decision: exact arguments are still checked at execution.
|
||||
for rule in permissions
|
||||
.rules
|
||||
.iter()
|
||||
.filter(|rule| rule.tool.eq_ignore_ascii_case(spec.name))
|
||||
{
|
||||
if rule.action == manifest::ToolPermissionAction::Allow {
|
||||
return true;
|
||||
}
|
||||
if rule.pattern == "*" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
permissions.default_action == manifest::ToolPermissionAction::Allow
|
||||
}
|
||||
|
||||
fn root_specs() -> Vec<Spec> {
|
||||
SPECS
|
||||
.iter()
|
||||
@@ -194,11 +296,10 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
|
||||
}
|
||||
}
|
||||
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().filter(|spec| !matches!(spec.operation,"edit"|"write")).map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
|
||||
"write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"),
|
||||
"create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
|
||||
"create_folder"=>vec![parameter("name",true,TypeExpr::String)],
|
||||
"relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)],
|
||||
@@ -209,6 +310,7 @@ fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
}
|
||||
struct DriveHandler {
|
||||
feature: DriveFeature,
|
||||
observation_nonce: Arc<str>,
|
||||
entry: DriveEntry,
|
||||
specs: Vec<Spec>,
|
||||
root: bool,
|
||||
@@ -221,21 +323,34 @@ impl WipOperationHandler for DriveHandler {
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let common_text = matches!(operation, "read_lines" | "search_text");
|
||||
let backend_operation = if common_text { "read" } else { operation };
|
||||
let spec = self
|
||||
.specs
|
||||
.iter()
|
||||
.find(|spec| spec.operation == operation)
|
||||
.find(|spec| spec.operation == backend_operation)
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"Drive operation is not published",
|
||||
)
|
||||
})?;
|
||||
let declaration = descriptor(&self.specs, self.root)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.unwrap();
|
||||
let contract = match operation {
|
||||
"read_lines" => Some(crate::file_operation::Contract::ReadLines),
|
||||
"search_text" => Some(crate::file_operation::Contract::SearchText),
|
||||
"edit" => Some(crate::file_operation::Contract::Edit),
|
||||
"write" => Some(crate::file_operation::Contract::Write),
|
||||
_ => None,
|
||||
};
|
||||
let declaration = if let Some(contract) = contract {
|
||||
contract.descriptor().operations.into_iter().next().unwrap()
|
||||
} else {
|
||||
descriptor(&self.specs, self.root)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.unwrap()
|
||||
};
|
||||
if arguments
|
||||
.keys()
|
||||
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
|
||||
@@ -244,8 +359,22 @@ impl WipOperationHandler for DriveHandler {
|
||||
"unrecognized arguments; committed-request observations are Client-managed".into(),
|
||||
)));
|
||||
}
|
||||
let mut input = wip_to_json(&Value::Record(arguments.clone()))
|
||||
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?;
|
||||
let mut input = if common_text {
|
||||
// Validate the common input before performing any source read. Its
|
||||
// line window is evaluated only after the complete bounded read.
|
||||
crate::file_operation::text_result(operation, arguments, "")?;
|
||||
if self
|
||||
.entry
|
||||
.size
|
||||
.is_some_and(|size| size > server_api::DRIVE_CHUNK_MAX_BYTES)
|
||||
{
|
||||
return Err(map_error(DriveError::Limit));
|
||||
}
|
||||
json!({"entry": self.entry.entry, "max_bytes": server_api::DRIVE_CHUNK_MAX_BYTES})
|
||||
} else {
|
||||
wip_to_json(&Value::Record(arguments.clone()))
|
||||
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?
|
||||
};
|
||||
let fields = input
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?;
|
||||
@@ -298,6 +427,19 @@ impl WipOperationHandler for DriveHandler {
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
if common_text {
|
||||
let content = complete_text(&output.value)?;
|
||||
let value = crate::file_operation::text_result(operation, arguments, content)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.entry,
|
||||
&self.entry.last_mutation_id,
|
||||
&self.specs,
|
||||
),
|
||||
));
|
||||
}
|
||||
let value = json_to_wip(&output.value).map_err(|_| {
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"Completed Drive result unavailable; query request_status before another mutation"
|
||||
@@ -314,13 +456,39 @@ impl WipOperationHandler for DriveHandler {
|
||||
"Committed Drive request identity unavailable".into(),
|
||||
)
|
||||
})?;
|
||||
WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec())
|
||||
WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.entry,
|
||||
last_mutation_id,
|
||||
&self.specs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
WipOperationOutput::native(value)
|
||||
};
|
||||
Ok(result.with_attachments(output.attachments))
|
||||
}
|
||||
}
|
||||
/// A bounded line/search contract cannot claim that a partial blob is the full
|
||||
/// source. Retain Drive's separate whole-text read with its truncation flag.
|
||||
fn complete_text(value: &serde_json::Value) -> Result<&str, WipOperationError> {
|
||||
let content = value["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| map_error(DriveError::Unavailable))?;
|
||||
let size = value["entry"]["metadata"]["size"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| map_error(DriveError::Unavailable))?;
|
||||
if value["truncated"].as_bool() != Some(false)
|
||||
|| size != content.len() as u64
|
||||
|| content.len() > server_api::DRIVE_CHUNK_MAX_BYTES as usize
|
||||
{
|
||||
return Err(map_error(DriveError::Limit));
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn parse_entry(feature: &DriveFeature, path: &str) -> Result<Option<DriveEntryRef>, DriveError> {
|
||||
let workspace = feature.backend.workspace()?;
|
||||
let prefix = format!(
|
||||
|
||||
@@ -47,6 +47,12 @@ use crate::wip::{
|
||||
pub(super) struct ConfigProvider {
|
||||
pub feature: WorkspaceConfigFeature,
|
||||
pub backend: Arc<WorkspaceConfigBackend>,
|
||||
// A fresh mount after restore invalidates observations from its predecessor.
|
||||
pub observation_nonce: Arc<str>,
|
||||
}
|
||||
|
||||
fn object_validator(nonce: &str, connection: &str, validator: &str) -> Vec<u8> {
|
||||
Sha256::digest(format!("{nonce}:{connection}:{validator}")).to_vec()
|
||||
}
|
||||
|
||||
/// Backend tree root is empty; source names are canonical relative config paths.
|
||||
@@ -240,7 +246,7 @@ impl ConfigProvider {
|
||||
let descriptor = descriptor(&operations);
|
||||
let interface = contextual_reference("yoi.workspace-config/node/v1", path);
|
||||
let interface_validator = Some(Sha256::digest(format!("{descriptor:?}")).to_vec());
|
||||
Ok(Some(WipProjection {
|
||||
let mut projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "workspace-config:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
@@ -252,7 +258,11 @@ impl ConfigProvider {
|
||||
)),
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: None,
|
||||
validator: Some(node.validator.as_bytes().to_vec()),
|
||||
validator: Some(object_validator(
|
||||
&self.observation_nonce,
|
||||
&attachment.connection_id,
|
||||
&node.validator,
|
||||
)),
|
||||
},
|
||||
interface,
|
||||
descriptor,
|
||||
@@ -263,9 +273,12 @@ impl ConfigProvider {
|
||||
attachment,
|
||||
snapshot,
|
||||
node,
|
||||
operations,
|
||||
operations: operations.clone(),
|
||||
observation_nonce: self.observation_nonce.clone(),
|
||||
}),
|
||||
}))
|
||||
};
|
||||
crate::file_operation::expose(&mut projection, common_contracts(&operations));
|
||||
Ok(Some(projection))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,12 +348,28 @@ fn operations(
|
||||
names.push(name.into());
|
||||
}
|
||||
}
|
||||
if names.iter().any(|name| name == "read") {
|
||||
names.extend(["read_lines".into(), "search_text".into()]);
|
||||
}
|
||||
if root {
|
||||
names.push("attach".into());
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn common_contracts(names: &[String]) -> Vec<crate::file_operation::Contract> {
|
||||
use crate::file_operation::Contract;
|
||||
[
|
||||
("read_lines", Contract::ReadLines),
|
||||
("search_text", Contract::SearchText),
|
||||
("edit", Contract::Edit),
|
||||
("write", Contract::Write),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, contract)| names.iter().any(|n| n == name).then_some(contract))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: name.into(),
|
||||
@@ -356,11 +385,11 @@ fn descriptor(names: &[String]) -> InterfaceDescriptor {
|
||||
summary: "Logical Workspace configuration file operations".into(),
|
||||
details: Some("Backend grants, semantic validation, atomic whole-tree CAS and activation apply. Validators and change digests are managed automatically. apply_changes atomically accepts create/update/delete/rename changes without validators or expected_digest fields. Saving updates configuration for future operation boundaries, not already-running Worker manifests. No OS or command access.".into()),
|
||||
}),
|
||||
operations: names.iter().map(|name| OperationDeclaration {
|
||||
operations: names.iter().filter(|name| !matches!(name.as_str(), "read_lines" | "search_text" | "edit" | "write")).map(|name| OperationDeclaration {
|
||||
name: name.clone(), documentation: None,
|
||||
parameters: match name.as_str() {
|
||||
"attach" => vec![parameter("access", false, TypeExpr::String)],
|
||||
"read" | "write" | "edit" => crate::file_operation::parameters(name, false).unwrap(),
|
||||
"read" => crate::file_operation::parameters(name, false).unwrap(),
|
||||
"delete" => vec![],
|
||||
"create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)],
|
||||
"apply_changes" => vec![parameter("changes", true, TypeExpr::Json)],
|
||||
@@ -378,6 +407,7 @@ struct ConfigHandler {
|
||||
snapshot: WorkspaceConfigObserveResponse,
|
||||
node: WorkspaceConfigNode,
|
||||
operations: Vec<String>,
|
||||
observation_nonce: Arc<str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -476,13 +506,18 @@ impl ConfigHandler {
|
||||
|| response.validator != self.node.validator
|
||||
|| Some(&response.digest) != self.node.digest.as_ref()
|
||||
|| Some(&response.content_type) != self.node.content_type.as_ref()
|
||||
|| response.content.len() > MAX_TEXT_BYTES
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"Invalid Workspace config read response",
|
||||
));
|
||||
}
|
||||
if response.content.len() > MAX_TEXT_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config source exceeds text limit",
|
||||
));
|
||||
}
|
||||
let mut response = response;
|
||||
response.content = fs_operation::text::read(response.content, text_limits())
|
||||
.map_err(text_error)?
|
||||
@@ -628,11 +663,33 @@ impl ConfigHandler {
|
||||
));
|
||||
}
|
||||
// Direct handler calls also enforce parameter shape; no model validators accepted.
|
||||
let declaration = descriptor(&self.operations)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.ok_or_else(invalid)?;
|
||||
let declaration = match operation {
|
||||
"read_lines" => crate::file_operation::Contract::ReadLines
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
"search_text" => crate::file_operation::Contract::SearchText
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
"edit" => crate::file_operation::Contract::Edit
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
"write" => crate::file_operation::Contract::Write
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
_ => descriptor(&self.operations)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation),
|
||||
}
|
||||
.ok_or_else(invalid)?;
|
||||
if arguments
|
||||
.keys()
|
||||
.any(|name| !declaration.parameters.iter().any(|p| p.name == *name))
|
||||
@@ -646,6 +703,19 @@ impl ConfigHandler {
|
||||
.call(operation, arguments, context)
|
||||
.await;
|
||||
}
|
||||
if matches!(operation, "read_lines" | "search_text") {
|
||||
crate::file_operation::text_result(operation, arguments, "")?;
|
||||
let result = self.read().await?;
|
||||
let value = crate::file_operation::text_result(operation, arguments, &result.content)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&result.validator,
|
||||
),
|
||||
));
|
||||
}
|
||||
if operation == "read" {
|
||||
let _: fs_operation::text::ReadArgs = decode_text(arguments)?;
|
||||
let result = self.read().await?;
|
||||
@@ -657,7 +727,11 @@ impl ConfigHandler {
|
||||
.map_err(|_| invalid())?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.node.validator.as_bytes().to_vec(),
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&self.node.validator,
|
||||
),
|
||||
));
|
||||
}
|
||||
if self.attachment.access != WorkspaceConfigAccess::ReadWrite {
|
||||
@@ -762,7 +836,11 @@ impl ConfigHandler {
|
||||
"activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?;
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
node.validator.as_bytes().to_vec(),
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&node.validator,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,8 +466,19 @@ async fn call(
|
||||
runtime
|
||||
.call(
|
||||
path.into(),
|
||||
interface(path, true),
|
||||
op.into(),
|
||||
match op {
|
||||
"edit" => crate::file_operation::Contract::Edit.reference(),
|
||||
"write" => crate::file_operation::Contract::Write.reference(),
|
||||
"read_lines" => crate::file_operation::Contract::ReadLines.reference(),
|
||||
"search_text" => crate::file_operation::Contract::SearchText.reference(),
|
||||
_ => interface(path, true),
|
||||
},
|
||||
match op {
|
||||
"read_lines" => "read",
|
||||
"search_text" => "search",
|
||||
_ => op,
|
||||
}
|
||||
.into(),
|
||||
args,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
@@ -825,11 +836,11 @@ async fn workspace_config_production_reattach_new_lifetime_rejects_old_same_byte
|
||||
client.state.lock().unwrap().active = None;
|
||||
let second = feature.attach("second", None).await.unwrap();
|
||||
assert_ne!(first.connection_id, second.connection_id);
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"bad"}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let error = call(&runtime, path, "write", json!({"content":"bad"}))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ValidatorMismatch"), "{error}");
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
observe_interface(&runtime, path, true).await;
|
||||
call(&runtime, path, "write", json!({"content":"new-lifetime"}))
|
||||
@@ -958,3 +969,189 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d
|
||||
.all(|r| r.method == WorkspaceRequestMethod::Get)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_read_only_text_windows_search_and_rejections_preserve_backend_authority() {
|
||||
let client = Router::new();
|
||||
{
|
||||
let mut state = client.state.lock().unwrap();
|
||||
state.read_only = true;
|
||||
state.entries.get_mut("main.dcdl").unwrap().1 = "α\r\nneedle.*\nNeedle.*\nlast\n".into();
|
||||
}
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("read-only", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let object = runtime
|
||||
.inspect(path.into(), true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
assert!(object.contains(&crate::file_operation::Contract::ReadLines.reference().name));
|
||||
assert!(object.contains(&crate::file_operation::Contract::SearchText.reference().name));
|
||||
assert!(!object.contains(&crate::file_operation::Contract::Write.reference().name));
|
||||
assert!(!object.contains(&crate::file_operation::Contract::Edit.reference().name));
|
||||
let read = call(&runtime, path, "read_lines", json!({"offset":1,"limit":2}))
|
||||
.await
|
||||
.unwrap();
|
||||
let read: Json = serde_json::from_str(read.content.as_deref().unwrap()).unwrap();
|
||||
assert!(read["content"].as_str().unwrap().contains("needle.*"));
|
||||
assert!(read["content"].as_str().unwrap().contains("Needle.*"));
|
||||
assert!(!read["content"].as_str().unwrap().contains('α'));
|
||||
assert_eq!(read["next_offset"], 3);
|
||||
assert_eq!(read["truncated"], true);
|
||||
let search = call(
|
||||
&runtime,
|
||||
path,
|
||||
"search_text",
|
||||
json!({"pattern":"needle.*","limit":3}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let search: Json = serde_json::from_str(search.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(search["matches"], json!([{"line":2,"text":"needle.*"}]));
|
||||
assert_eq!(search["next_offset"], 3);
|
||||
assert_eq!(search["truncated"], true);
|
||||
for (op, arguments) in [
|
||||
("read_lines", json!({"limit":0})),
|
||||
("read_lines", json!({"limit":2001})),
|
||||
("search_text", json!({"pattern":""})),
|
||||
(
|
||||
"search_text",
|
||||
json!({"pattern":"x","case_insensitive":true}),
|
||||
),
|
||||
] {
|
||||
let before = client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.path.ends_with("/read"))
|
||||
.count();
|
||||
assert!(call(&runtime, path, op, arguments).await.is_err());
|
||||
let after = client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.path.ends_with("/read"))
|
||||
.count();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"denied"}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
client.state.lock().unwrap().granted = false;
|
||||
assert!(call(&runtime, path, "read_lines", json!({})).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_text_read_refuses_oversized_rendered_window() {
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("bounded", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut("main.dcdl")
|
||||
.unwrap()
|
||||
.1 = "x".repeat(64 * 1024);
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let error = call(&runtime, path, "read_lines", json!({}))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ResourceLimitExceeded"), "{error}");
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_backend_validators_reject_path_and_connection_transplant_even_for_identical_sources()
|
||||
{
|
||||
use crate::wip::WipOperationError;
|
||||
use wip_protocol::ProtocolErrorCode;
|
||||
let client = Router::new();
|
||||
let path = "profiles/team/deep/model.dcdl";
|
||||
client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut(path)
|
||||
.unwrap()
|
||||
.1 = "model = old".into();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
let first = feature.attach("path-binding", None).await.unwrap();
|
||||
let backend = backend::WorkspaceConfigBackend::new(client.clone());
|
||||
let (main_validator, deep_validator) = {
|
||||
let state = client.state.lock().unwrap();
|
||||
(state.validator("main.dcdl"), state.validator(path))
|
||||
};
|
||||
assert_ne!(main_validator, deep_validator);
|
||||
let read = |connection_id: String, validator: String| WorkspaceConfigReadRequest {
|
||||
connection_id,
|
||||
path: path.into(),
|
||||
validator,
|
||||
};
|
||||
let result = backend
|
||||
.post::<WorkspaceConfigReadResponse, _>(
|
||||
"/read",
|
||||
&read(first.connection_id.clone(), main_validator),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
let result: WorkspaceConfigReadResponse = backend
|
||||
.post(
|
||||
"/read",
|
||||
&read(first.connection_id.clone(), deep_validator.clone()),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("correct path validator rejected"));
|
||||
assert_eq!(result.content, "model = old");
|
||||
assert_eq!(result.validator, deep_validator);
|
||||
client.state.lock().unwrap().active = None;
|
||||
let second = feature.attach("connection-binding", None).await.unwrap();
|
||||
assert_ne!(first.connection_id, second.connection_id);
|
||||
for connection in [first.connection_id, second.connection_id.clone()] {
|
||||
let result = backend
|
||||
.post::<WorkspaceConfigReadResponse, _>(
|
||||
"/read",
|
||||
&read(connection, deep_validator.clone()),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
}
|
||||
// Fresh path/connection observation restores native common mutation access,
|
||||
// and the returned exact post-operation validator also supports common reads.
|
||||
let route = format!("/workspace-config/{path}");
|
||||
observe_interface(&runtime, &route, true).await;
|
||||
call(
|
||||
&runtime,
|
||||
&route,
|
||||
"write",
|
||||
json!({"content":"model = updated"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = call(&runtime, &route, "read_lines", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.content.unwrap().contains("model = updated"));
|
||||
assert_eq!(client.state.lock().unwrap().commits.len(), 1);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ pub fn mount_workspace_config_wip(
|
||||
provider: Arc::new(super::content::ConfigProvider {
|
||||
feature: feature.clone(),
|
||||
backend: backend.clone(),
|
||||
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
|
||||
}),
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) const MAX_BYTES: usize = 64 * 1024;
|
||||
/// Contracts have independent capability membership and no target-dependent descriptor fields.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Contract {
|
||||
ReadBytes,
|
||||
ReadLines,
|
||||
SearchText,
|
||||
Edit,
|
||||
@@ -72,6 +73,7 @@ impl Contract {
|
||||
pub(crate) fn reference(self) -> InterfaceReference {
|
||||
crate::wip::root_reference(match self {
|
||||
Self::ReadLines => "yoi.file/read-lines/v1",
|
||||
Self::ReadBytes => "yoi.file/read-bytes/v1",
|
||||
Self::SearchText => "yoi.file/search-text/v1",
|
||||
Self::Edit => "yoi.file/edit-text/v1",
|
||||
Self::Write => "yoi.file/write-text/v1",
|
||||
@@ -84,6 +86,7 @@ impl Contract {
|
||||
pub(crate) fn from_reference(r: &InterfaceReference) -> Option<Self> {
|
||||
[
|
||||
Self::ReadLines,
|
||||
Self::ReadBytes,
|
||||
Self::SearchText,
|
||||
Self::Edit,
|
||||
Self::Write,
|
||||
@@ -97,7 +100,7 @@ impl Contract {
|
||||
}
|
||||
pub(crate) fn operation(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadLines => "read",
|
||||
Self::ReadLines | Self::ReadBytes => "read",
|
||||
Self::SearchText => "search",
|
||||
Self::Edit => "edit",
|
||||
Self::Write => "write",
|
||||
@@ -110,12 +113,29 @@ impl Contract {
|
||||
pub(crate) fn handler_operation(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadLines => "read_lines",
|
||||
Self::ReadBytes => "read_bytes",
|
||||
Self::SearchText => "search_text",
|
||||
_ => self.operation(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn descriptor(self) -> InterfaceDescriptor {
|
||||
let (summary, parameters, result) = match self {
|
||||
Self::ReadBytes => (
|
||||
"Read a bounded binary byte range",
|
||||
vec![
|
||||
parameter("offset", false, TypeExpr::Integer),
|
||||
parameter("limit", false, TypeExpr::Integer),
|
||||
],
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("encoding", true, TypeExpr::String),
|
||||
field("data", true, TypeExpr::String),
|
||||
field("offset", true, TypeExpr::Integer),
|
||||
field("next_offset", true, TypeExpr::Integer),
|
||||
field("eof", true, TypeExpr::Boolean),
|
||||
],
|
||||
},
|
||||
),
|
||||
Self::ReadLines => (
|
||||
"Read a bounded text line window",
|
||||
parameters("read", true).unwrap(),
|
||||
@@ -166,6 +186,7 @@ impl Contract {
|
||||
),
|
||||
};
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),types:vec![],documentation:Some(Documentation {summary:summary.into(),details:Some(match self {
|
||||
Self::ReadBytes=>"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry.",
|
||||
Self::ReadLines|Self::SearchText=>"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry.",
|
||||
Self::Edit|Self::Write|Self::CreateFile=>"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry.",
|
||||
_=>"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry.",
|
||||
@@ -552,3 +573,76 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds reserve room for base64 expansion, JSON encoding, and exact lossless integer offsets.
|
||||
pub(crate) fn byte_range(
|
||||
args: &BTreeMap<String, Value>,
|
||||
) -> Result<(u64, usize), WipOperationError> {
|
||||
if args
|
||||
.keys()
|
||||
.any(|k| !["offset", "limit"].contains(&k.as_str()))
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"unknown byte argument",
|
||||
));
|
||||
}
|
||||
let integer = |name: &str, default: u64| match args.get(name) {
|
||||
None => Ok(default),
|
||||
Some(Value::Integer(i)) => u64::try_from(*i)
|
||||
.map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative byte range")),
|
||||
_ => Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid byte range",
|
||||
)),
|
||||
};
|
||||
let offset = integer("offset", 0)?;
|
||||
let limit = integer("limit", 16384)?;
|
||||
if offset > 9_007_199_254_708_223 || !(1..=32768).contains(&limit) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"byte range exceeds bounds",
|
||||
));
|
||||
}
|
||||
Ok((offset, limit as usize))
|
||||
}
|
||||
pub(crate) fn bytes_result(
|
||||
args: &BTreeMap<String, Value>,
|
||||
bytes: &[u8],
|
||||
eof: bool,
|
||||
) -> Result<Value, WipOperationError> {
|
||||
use base64::Engine as _;
|
||||
let (offset, limit) = byte_range(args)?;
|
||||
if bytes.len() > limit || (bytes.is_empty() && !eof) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"provider returned an invalid byte range",
|
||||
));
|
||||
}
|
||||
let value = Value::Record(BTreeMap::from([
|
||||
("encoding".into(), Value::String("base64".into())),
|
||||
(
|
||||
"data".into(),
|
||||
Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
),
|
||||
("offset".into(), Value::Integer(offset as i64)),
|
||||
(
|
||||
"next_offset".into(),
|
||||
Value::Integer((offset + bytes.len() as u64) as i64),
|
||||
),
|
||||
("eof".into(), Value::Boolean(eof)),
|
||||
]));
|
||||
let json = crate::wip::wip_to_json(&value)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?;
|
||||
if serde_json::to_vec_pretty(&json)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?
|
||||
.len()
|
||||
> MAX_BYTES
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"encoded byte result exceeds bound",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
@@ -1026,6 +1026,10 @@ impl WipMountRegistry {
|
||||
descriptor: &InterfaceDescriptor,
|
||||
validator: Option<&[u8]>,
|
||||
) -> bool {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(interface) {
|
||||
return *descriptor != contract.descriptor()
|
||||
|| validator != Some(contract.validator().as_slice());
|
||||
}
|
||||
self.mounts.values().any(|mounted| {
|
||||
mounted.projection.interface == *interface
|
||||
&& (mounted.projection.descriptor != *descriptor
|
||||
@@ -1091,6 +1095,10 @@ impl WipMountRegistry {
|
||||
validator: Option<&[u8]>,
|
||||
replacing_route: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(interface) {
|
||||
return *descriptor != contract.descriptor()
|
||||
|| validator != Some(contract.validator().as_slice());
|
||||
}
|
||||
self.additional_interfaces.iter().any(|(route, all)| {
|
||||
Some(route.as_str()) != replacing_route
|
||||
&& all.iter().any(|p| {
|
||||
@@ -1604,6 +1612,18 @@ impl WipHost {
|
||||
if let Some(validator) = projection.handler.object_validator() {
|
||||
projection.object.validator = Some(validator);
|
||||
}
|
||||
if let Some(contract) =
|
||||
crate::file_operation::Contract::from_reference(&projection.interface)
|
||||
{
|
||||
if !projection
|
||||
.handler
|
||||
.operation_available(contract.handler_operation())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Common descriptor identity never varies with subject or capability filtering.
|
||||
return Some(projection);
|
||||
}
|
||||
let contextual = contextual_family || projection.handler.contextual_interface();
|
||||
let original_len = projection.descriptor.operations.len();
|
||||
projection
|
||||
|
||||
@@ -12534,6 +12534,14 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::root(api, &context, &workspace_id).await
|
||||
}
|
||||
async fn drive_capabilities(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
) -> std::result::Result<server_api::DriveCapabilities, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::capabilities(api, &context, &workspace_id).await
|
||||
}
|
||||
async fn drive_metadata(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
|
||||
@@ -238,6 +238,25 @@ pub(super) async fn root(
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
blocking(false, move || drive.root()).await.map(public_node)
|
||||
}
|
||||
/// Query only the authenticated caller's current ceiling using the operation authorizer.
|
||||
/// This is admission/publication evidence, never authority retained for a later mutation.
|
||||
pub(super) async fn capabilities(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
) -> DriveResult<DriveCapabilities> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let write = blocking(false, move || match drive.check_authorized(true) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(workspace_drive::Error::Denied) => {
|
||||
drive.check_authorized(false)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
})
|
||||
.await?;
|
||||
Ok(DriveCapabilities { write })
|
||||
}
|
||||
pub(super) async fn metadata(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
@@ -581,6 +600,9 @@ pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||
.merge(streaming_router(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_root(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_metadata(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_capabilities(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::drive_list(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_search(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_read_text(
|
||||
|
||||
+121
-1
@@ -1951,6 +1951,19 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"DriveCapabilities": {
|
||||
"additionalProperties": false,
|
||||
"description": "Current caller's advisory publication ceiling, never a grant or execution credential.",
|
||||
"properties": {
|
||||
"write": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"write"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DriveDownloadQuery": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -17831,7 +17844,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d"
|
||||
"x-yoi-source-digest": "sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -22228,6 +22241,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/drive/capabilities": {
|
||||
"get": {
|
||||
"operationId": "drive_capabilities",
|
||||
"parameters": [
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "workspace_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveCapabilities"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/drive/download": {
|
||||
"get": {
|
||||
"operationId": "drive_download",
|
||||
|
||||
@@ -31,6 +31,8 @@ export type DriveEntry = {
|
||||
latest_url: string;
|
||||
};
|
||||
|
||||
export type DriveCapabilities = { write: boolean };
|
||||
|
||||
export type DriveEntryQuery = { entry_workspace_id: string; id: string };
|
||||
|
||||
export type DriveListQuery = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
Reference in New Issue
Block a user