chore(ticket): incorporate integrated Drive and structured WIP contracts
# Conflicts: # openapi/server-api.json # web/workspace/src/lib/generated/companion-api.ts # web/workspace/src/lib/generated/repository-api.ts # web/workspace/src/lib/generated/runtime-api.ts # web/workspace/src/lib/generated/ticket-api.ts # web/workspace/src/lib/generated/worker-launch-api.ts
This commit is contained in:
+82
-28
@@ -3199,6 +3199,55 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Correct one candidate actually sent in the rejected request. Commit the
|
||||
/// durable correction before changing either in-memory projection or retrying.
|
||||
fn recover_rejected_image(
|
||||
&self,
|
||||
history: &mut History<A>,
|
||||
request: &mut Request,
|
||||
) -> Result<bool, EngineError> {
|
||||
let Some((history_index, request_index)) =
|
||||
crate::image_recovery::largest_candidate(history, request)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let entry = &history.entries()[history_index];
|
||||
let replacement = crate::image_recovery::rejected_result(&entry.item);
|
||||
if let Some(commit) = &self.image_rejection_handler {
|
||||
commit(entry, &replacement).map_err(EngineError::HistoryAppend)?;
|
||||
}
|
||||
history.entries_mut()[history_index].item = replacement.clone();
|
||||
// Retain the original request's prune/interceptor decisions.
|
||||
request.items[request_index] = replacement;
|
||||
self.emit_warning(crate::image_recovery::REJECTION_MESSAGE);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Opening a corrected request can itself yield an HTTP image rejection.
|
||||
/// Each successful correction removes one candidate, bounding this loop.
|
||||
async fn open_stream_with_image_recovery(
|
||||
&mut self,
|
||||
request: &mut Request,
|
||||
turn: usize,
|
||||
llm_call: usize,
|
||||
history: &mut History<A>,
|
||||
) -> Result<ResponseStream, EngineError> {
|
||||
loop {
|
||||
match self
|
||||
.open_stream_with_retry(request.clone(), turn, llm_call)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(EngineError::Client(error)) if error.is_image_size_rejection() => {
|
||||
if !self.recover_rejected_image(history, request)? {
|
||||
return Err(EngineError::Client(error));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a stream, dispatch all events to the timeline, handle cancellation.
|
||||
async fn stream_response(
|
||||
&mut self,
|
||||
@@ -3217,37 +3266,14 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
);
|
||||
|
||||
let mut request = request;
|
||||
let stream = loop {
|
||||
match self
|
||||
.open_stream_with_retry(request.clone(), turn, llm_call)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => break stream,
|
||||
Err(EngineError::Client(error)) if error.is_image_size_rejection() => {
|
||||
let Some((history_index, request_index)) =
|
||||
crate::image_recovery::largest_candidate(history, &request)
|
||||
else {
|
||||
return Err(EngineError::Client(error));
|
||||
};
|
||||
let entry = &history.entries()[history_index];
|
||||
let replacement = crate::image_recovery::rejected_result(&entry.item);
|
||||
if let Some(commit) = &self.image_rejection_handler {
|
||||
commit(entry, &replacement).map_err(EngineError::HistoryAppend)?;
|
||||
}
|
||||
history.entries_mut()[history_index].item = replacement.clone();
|
||||
// Rebuild the provider request from the corrected projection,
|
||||
// retaining prior prune/interceptor decisions. No tool runs
|
||||
// or assistant output have occurred for this failed request.
|
||||
request.items[request_index] = replacement;
|
||||
self.emit_warning(crate::image_recovery::REJECTION_MESSAGE);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
};
|
||||
let stream = self
|
||||
.open_stream_with_image_recovery(&mut request, turn, llm_call, history)
|
||||
.await?;
|
||||
self.tool_call_collector.begin_response();
|
||||
let mut stream = ResponseStreamPump::start(stream);
|
||||
let mut early_tools: Option<EarlyToolExecutionBatch> = None;
|
||||
let mut response_completed = false;
|
||||
let mut response_progress = false;
|
||||
|
||||
let mut event_count: usize = 0;
|
||||
loop {
|
||||
@@ -3285,6 +3311,35 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
self.emit_stream_event(turn, llm_call, &event);
|
||||
// Only pre-output provider rejection can safely replay the
|
||||
// original request. Even an empty block start closes this
|
||||
// window, before collectors or early tool admission run.
|
||||
// Unknown events are conservatively not replayable either.
|
||||
match &event {
|
||||
Event::Ping(_) | Event::Usage(_) | Event::Error(_) => {}
|
||||
Event::Status(StatusEvent {
|
||||
status: crate::llm_client::event::ResponseStatus::Started,
|
||||
}) => {}
|
||||
_ => response_progress = true,
|
||||
}
|
||||
if let Event::Error(error) = &event {
|
||||
if !response_progress && error.is_image_size_rejection() {
|
||||
self.timeline.flush_usage();
|
||||
if self.recover_rejected_image(history, &mut request)? {
|
||||
// Drop the old pump (including queued failed status)
|
||||
// before opening the corrected request. No collector or
|
||||
// tool state exists for this pre-output attempt.
|
||||
drop(stream);
|
||||
stream = ResponseStreamPump::start(self.open_stream_with_image_recovery(
|
||||
&mut request, turn, llm_call, history,
|
||||
).await?);
|
||||
self.tool_call_collector.begin_response();
|
||||
event_count = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
&event,
|
||||
Event::Status(StatusEvent {
|
||||
@@ -3293,7 +3348,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
) {
|
||||
response_completed = true;
|
||||
}
|
||||
self.emit_stream_event(turn, llm_call, &event);
|
||||
self.timeline.dispatch(&event);
|
||||
if early_dispatch {
|
||||
let collected = self.tool_call_collector.take_collected_with_index();
|
||||
|
||||
@@ -103,20 +103,7 @@ impl ClientError {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if matches!(
|
||||
code.as_deref(),
|
||||
Some("image_too_large" | "image_size_exceeded")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
let message = message.to_ascii_lowercase();
|
||||
message.contains("image")
|
||||
&& ((message.contains("patches")
|
||||
&& message.contains("exceed")
|
||||
&& message.contains("limit"))
|
||||
|| message.contains("image is too large")
|
||||
|| message.contains("image too large")
|
||||
|| (message.contains("image dimensions") && message.contains("exceed")))
|
||||
is_image_size_diagnostic(code.as_deref(), message)
|
||||
}
|
||||
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
@@ -127,6 +114,29 @@ impl ClientError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared diagnostic policy for HTTP rejections and provider Error events.
|
||||
/// The caller must establish the rejection path and whether replay is safe;
|
||||
/// this predicate alone never authorizes a retry.
|
||||
pub(crate) fn is_image_size_diagnostic(code: Option<&str>, message: &str) -> bool {
|
||||
if matches!(code, Some("image_too_large" | "image_size_exceeded")) {
|
||||
return true;
|
||||
}
|
||||
// OpenAI Responses appends structured diagnostic details for observation.
|
||||
// Extra fields (including echoed input) are not the error message itself.
|
||||
let message = message
|
||||
.split(" | diagnostic=")
|
||||
.next()
|
||||
.unwrap_or(message)
|
||||
.to_ascii_lowercase();
|
||||
message.contains("image")
|
||||
&& ((message.contains("patches")
|
||||
&& message.contains("exceed")
|
||||
&& message.contains("limit"))
|
||||
|| message.contains("image is too large")
|
||||
|| message.contains("image too large")
|
||||
|| (message.contains("image dimensions") && message.contains("exceed")))
|
||||
}
|
||||
|
||||
/// transient な失敗としてリトライ対象になるかを判定する。
|
||||
///
|
||||
/// 対象:
|
||||
|
||||
@@ -109,6 +109,15 @@ pub struct ErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ErrorEvent {
|
||||
/// A provider Error event has no HTTP status. Classify its diagnostic
|
||||
/// explicitly, rather than making every status-less ClientError eligible.
|
||||
/// The engine must separately establish that no response output was seen.
|
||||
pub(crate) fn is_image_size_rejection(&self) -> bool {
|
||||
super::error::is_image_size_diagnostic(self.code.as_deref(), &self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 未対応 SSE イベントの観測用メタイベント。
|
||||
///
|
||||
/// `data_preview` は provider から受け取った raw SSE data の bounded preview、
|
||||
|
||||
@@ -753,17 +753,31 @@ pub(crate) fn parse_sse(
|
||||
}
|
||||
|
||||
"error" => {
|
||||
let ev = from_json::<TopLevelErrorEnvelope>(data).unwrap_or_else(|_| {
|
||||
TopLevelErrorEnvelope {
|
||||
// Responses documents flat error frames (code/message/param),
|
||||
// while providers also send nested error envelopes. Normalize both
|
||||
// structurally so classification never scans arbitrary raw JSON.
|
||||
let ev = from_json::<TopLevelErrorEnvelope>(data)
|
||||
.or_else(|_| {
|
||||
from_json::<TopLevelError>(data).map(|error| TopLevelErrorEnvelope {
|
||||
error,
|
||||
extra: BTreeMap::new(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|_| TopLevelErrorEnvelope {
|
||||
error: TopLevelError {
|
||||
message: Some(data.to_string()),
|
||||
// Raw data is observation, not a provider diagnostic that
|
||||
// can authorize request recovery. Keep it out of the base
|
||||
// message even when it contains image-size wording.
|
||||
message: Some("OpenAI Responses error with unparseable data".to_string()),
|
||||
error_type: None,
|
||||
code: None,
|
||||
extra: BTreeMap::new(),
|
||||
extra: BTreeMap::from([(
|
||||
"raw_data".to_string(),
|
||||
Value::String(data.to_string()),
|
||||
)]),
|
||||
},
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
});
|
||||
});
|
||||
let (code, message) = top_level_error_diagnostic(ev);
|
||||
Ok(vec![Event::Error(ErrorEvent { code, message })])
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::scheme::{Scheme, openai_responses::OpenAIResponsesScheme};
|
||||
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
||||
use agen::tool::{Attachment, ImageAttachment};
|
||||
use agen::{Engine, EngineRunExit, History, Item, ToolResultDisposition};
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn rejection() -> ClientError {
|
||||
@@ -48,11 +51,28 @@ fn images(request: &Request) -> Vec<String> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum RejectionPath {
|
||||
Http,
|
||||
SseError,
|
||||
SseNestedError,
|
||||
ResponseFailed,
|
||||
}
|
||||
const STREAM_PATHS: [RejectionPath; 3] = [
|
||||
RejectionPath::SseError,
|
||||
RejectionPath::SseNestedError,
|
||||
RejectionPath::ResponseFailed,
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Client {
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
failures: usize,
|
||||
other_error: bool,
|
||||
path: RejectionPath,
|
||||
prefix: Vec<(&'static str, Value)>,
|
||||
wait_before_error: Option<Arc<tokio::sync::Notify>>,
|
||||
rejection_data: Option<&'static str>,
|
||||
}
|
||||
impl Client {
|
||||
fn new(failures: usize) -> Self {
|
||||
@@ -60,11 +80,69 @@ impl Client {
|
||||
requests: Arc::default(),
|
||||
failures,
|
||||
other_error: false,
|
||||
path: RejectionPath::Http,
|
||||
prefix: Vec::new(),
|
||||
wait_before_error: None,
|
||||
rejection_data: None,
|
||||
}
|
||||
}
|
||||
fn streamed(failures: usize, path: RejectionPath) -> Self {
|
||||
Self {
|
||||
path,
|
||||
..Self::new(failures)
|
||||
}
|
||||
}
|
||||
fn reject(&self, error: ClientError) -> Result<ResponseStream, ClientError> {
|
||||
if matches!(self.path, RejectionPath::Http) {
|
||||
return Err(error);
|
||||
}
|
||||
let ClientError::Api { code, message, .. } = error else {
|
||||
unreachable!()
|
||||
};
|
||||
let error = json!({"type":"invalid_request_error", "code":code, "message":message, "param":"input"});
|
||||
let (kind, data) = match self.path {
|
||||
RejectionPath::SseError => (
|
||||
"error",
|
||||
json!({"type":"error", "code":code, "message":message, "param":"input", "error_type":"invalid_request_error"}),
|
||||
),
|
||||
RejectionPath::SseNestedError => ("error", json!({"type":"error", "error":error})),
|
||||
RejectionPath::ResponseFailed => (
|
||||
"response.failed",
|
||||
json!({"type":"response.failed", "response":{"id":"rejected", "error":error}}),
|
||||
),
|
||||
RejectionPath::Http => unreachable!(),
|
||||
};
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let mut events = scheme.parse_sse("response.created", r#"{"response":{}}"#, &mut state)?;
|
||||
for (kind, data) in &self.prefix {
|
||||
events.extend(scheme.parse_sse(kind, &data.to_string(), &mut state)?);
|
||||
}
|
||||
let encoded = data.to_string();
|
||||
events.extend(scheme.parse_sse(
|
||||
kind,
|
||||
self.rejection_data.unwrap_or(&encoded),
|
||||
&mut state,
|
||||
)?);
|
||||
let wait = self.wait_before_error.clone();
|
||||
Ok(Box::pin(futures::stream::iter(events).then(move |event| {
|
||||
let wait = wait.clone();
|
||||
async move {
|
||||
if matches!(event, Event::Error(_))
|
||||
&& let Some(wait) = wait
|
||||
{
|
||||
wait.notified().await;
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
})))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl LlmClient for Client {
|
||||
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
|
||||
agen::llm_client::ToolCallCompletionSupport::PerBlock
|
||||
}
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
@@ -72,10 +150,10 @@ impl LlmClient for Client {
|
||||
let mut requests = self.requests.lock().unwrap();
|
||||
requests.push(request);
|
||||
if requests.len() <= self.failures {
|
||||
return Err(rejection());
|
||||
return self.reject(rejection());
|
||||
}
|
||||
if self.other_error {
|
||||
return Err(ClientError::Api {
|
||||
return self.reject(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: Some("invalid_value".into()),
|
||||
message: "invalid tool arguments".into(),
|
||||
@@ -226,6 +304,7 @@ async fn early_results_remain_candidates_despite_later_text_in_same_response() {
|
||||
struct CountingTool {
|
||||
image: bool,
|
||||
count: Arc<std::sync::atomic::AtomicUsize>,
|
||||
executed: Option<Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl agen::tool::Tool for CountingTool {
|
||||
@@ -235,6 +314,9 @@ impl agen::tool::Tool for CountingTool {
|
||||
_: agen::tool::ToolExecutionContext,
|
||||
) -> Result<agen::tool::ToolOutput, agen::tool::ToolError> {
|
||||
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(executed) = &self.executed {
|
||||
executed.notify_one();
|
||||
}
|
||||
let attachments = if self.image {
|
||||
let Item::ToolResult { attachments, .. } = image_result("image", 1600, 33349, 0) else {
|
||||
unreachable!()
|
||||
@@ -297,6 +379,7 @@ async fn fresh_image_recovery_does_not_repeat_tools_in_either_dispatch_mode() {
|
||||
let tool = CountingTool {
|
||||
image,
|
||||
count: Arc::default(),
|
||||
executed: None,
|
||||
};
|
||||
counts.push(tool.count.clone());
|
||||
engine.register_tool(Arc::new(move || {
|
||||
@@ -348,3 +431,308 @@ fn image_error_classifier_is_not_a_generic_400_retry() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_image_rejections_correct_largest_candidate_before_next_request() {
|
||||
for path in STREAM_PATHS {
|
||||
let client = Client::streamed(1, path);
|
||||
let mut history = history();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let commits = committed.clone();
|
||||
engine.set_image_rejection_handler(move |original, replacement| {
|
||||
commits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((original.item.clone(), replacement.clone()));
|
||||
Ok(())
|
||||
});
|
||||
let out = engine.run(&mut history, "continue").await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Finished),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(images(&requests[0]), ["small", "large"]);
|
||||
assert_eq!(images(&requests[1]), ["small"]);
|
||||
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("Resize or crop") && summary.contains("retry ViewImage"))));
|
||||
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolCall { call_id, name, .. } if call_id == "large" && name == "ViewImage")));
|
||||
assert_eq!(committed.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "large")
|
||||
)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_rejections_stop_when_images_are_absent_or_candidates_exhausted() {
|
||||
for path in STREAM_PATHS {
|
||||
for (mut history, expected_requests) in [(History::new(), 1), (history(), 3)] {
|
||||
let client = Client::streamed(99, path);
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), expected_requests);
|
||||
assert!(images(requests.last().unwrap()).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_streamed_invalid_value_preserves_images_and_diagnostic() {
|
||||
for path in STREAM_PATHS {
|
||||
let mut client = Client::streamed(0, path);
|
||||
client.other_error = true;
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
let EngineRunExit::Interrupted(reason) = out.result else {
|
||||
panic!("{:?}", out.result)
|
||||
};
|
||||
let reason = format!("{reason:?}");
|
||||
assert!(
|
||||
reason.contains("invalid_value")
|
||||
&& reason.contains("invalid tool arguments")
|
||||
&& reason.contains("diagnostic=")
|
||||
&& reason.contains("input"),
|
||||
"{reason}"
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_correction_commit_failure_does_not_mutate_or_resend() {
|
||||
for path in STREAM_PATHS {
|
||||
let client = Client::streamed(99, path);
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
engine.set_image_rejection_handler(|_, _| Err("disk full".into()));
|
||||
let out = engine.run(&mut history, "continue").await;
|
||||
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_rejection_after_output_never_replays_or_corrects_input() {
|
||||
for path in STREAM_PATHS {
|
||||
// Start alone closes the replay window, as do partial/completed text,
|
||||
// reasoning and unrecognized events. None may change accepted input.
|
||||
for prefix in [
|
||||
vec![(
|
||||
"response.content_part.added",
|
||||
json!({"output_index":0,"content_index":0,"part":{"type":"output_text"}}),
|
||||
)],
|
||||
vec![(
|
||||
"response.output_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"partial"}),
|
||||
)],
|
||||
vec![
|
||||
(
|
||||
"response.output_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"completed"}),
|
||||
),
|
||||
(
|
||||
"response.content_part.done",
|
||||
json!({"output_index":0,"content_index":0,"part":{"type":"output_text"}}),
|
||||
),
|
||||
],
|
||||
vec![(
|
||||
"response.reasoning_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"thinking"}),
|
||||
)],
|
||||
vec![("future.unknown", json!({"value":"unknown semantics"}))],
|
||||
] {
|
||||
let mut client = Client::streamed(1, path);
|
||||
client.prefix = prefix;
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statusless_api_errors_are_not_image_retry_authority() {
|
||||
let ClientError::Api { code, message, .. } = rejection() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
!ClientError::Api {
|
||||
status: None,
|
||||
code,
|
||||
message,
|
||||
retry_after: None
|
||||
}
|
||||
.is_image_size_rejection()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_rejection_after_tool_completion_never_reexecutes_side_effects() {
|
||||
for path in STREAM_PATHS {
|
||||
for mode in [
|
||||
agen::ToolCallDispatchMode::AfterResponse,
|
||||
agen::ToolCallDispatchMode::OnToolCallComplete,
|
||||
] {
|
||||
let executed = Arc::new(tokio::sync::Notify::new());
|
||||
let mut client = Client::streamed(1, path);
|
||||
client.prefix = vec![
|
||||
(
|
||||
"response.output_item.added",
|
||||
json!({"output_index":0,"item":{"type":"function_call","call_id":"side-effect","name":"Bash","arguments":"{}"}}),
|
||||
),
|
||||
(
|
||||
"response.output_item.done",
|
||||
json!({"output_index":0,"item":{"type":"function_call","call_id":"side-effect","name":"Bash","arguments":"{}"}}),
|
||||
),
|
||||
];
|
||||
if mode == agen::ToolCallDispatchMode::OnToolCallComplete {
|
||||
client.wait_before_error = Some(executed.clone());
|
||||
}
|
||||
let tool = CountingTool {
|
||||
image: false,
|
||||
count: Arc::default(),
|
||||
executed: Some(executed),
|
||||
};
|
||||
let count = tool.count.clone();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
engine.set_tool_call_dispatch_mode(mode);
|
||||
engine.register_tool(Arc::new(move || {
|
||||
(
|
||||
agen::tool::ToolMeta::new("Bash")
|
||||
.description("side effect")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(tool.clone()),
|
||||
)
|
||||
}));
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
engine.run(&mut history, "continue"),
|
||||
)
|
||||
.await
|
||||
.expect("tool admission/error cleanup must terminate");
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
if mode == agen::ToolCallDispatchMode::OnToolCallComplete {
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "side-effect")).count(), 1);
|
||||
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "side-effect")).count(), 1);
|
||||
} else {
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert!(!history.items().any(|item| matches!(item, Item::ToolCall { call_id, .. } | Item::ToolResult { call_id, .. } if call_id == "side-effect")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_error_extra_fields_do_not_turn_unrelated_message_into_image_rejection() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let events = scheme.parse_sse("error", r#"{"type":"error","code":"invalid_value","message":"invalid tool arguments","param":"input","input":"image too large"}"#, &mut state).unwrap();
|
||||
let Event::Error(error) = &events[0] else {
|
||||
panic!("{events:?}")
|
||||
};
|
||||
assert_eq!(error.code.as_deref(), Some("invalid_value"));
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.starts_with("invalid tool arguments | diagnostic=")
|
||||
);
|
||||
// HTTP uses the same diagnostic policy, but still requires status authority.
|
||||
assert!(
|
||||
!ClientError::Api {
|
||||
status: Some(400),
|
||||
code: error.code.clone(),
|
||||
message: error.message.clone(),
|
||||
retry_after: None
|
||||
}
|
||||
.is_image_size_rejection()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unparseable_stream_error_data_preserves_images_and_terminates_with_diagnostic() {
|
||||
for data in [
|
||||
r#"{"type":"error","code":"invalid_value","message":123,"input":"image too large"}"#,
|
||||
"unparseable provider error with echoed input: image too large",
|
||||
] {
|
||||
let mut client = Client::streamed(1, RejectionPath::SseError);
|
||||
client.rejection_data = Some(data);
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
let EngineRunExit::Interrupted(agen::RunInterruptionReason::Unexpected(
|
||||
agen::EngineError::Client(ClientError::Api { code, message, .. }),
|
||||
)) = out.result
|
||||
else {
|
||||
panic!(
|
||||
"malformed provider error must terminate, got {:?}",
|
||||
out.result
|
||||
);
|
||||
};
|
||||
assert_eq!(code, None);
|
||||
let (base, diagnostic) = message
|
||||
.split_once(" | diagnostic=")
|
||||
.expect("raw data remains diagnostic-only");
|
||||
assert!(!base.contains("image"));
|
||||
let diagnostic: Value = serde_json::from_str(diagnostic).unwrap();
|
||||
assert_eq!(diagnostic["error_extra"]["raw_data"], data);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1534,6 +1534,13 @@ fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_binary_body_type(ty: &Type) -> bool {
|
||||
matches!(ty, Type::Path(path)
|
||||
if path.qself.is_none()
|
||||
&& path.path.segments.last().is_some_and(|last|
|
||||
last.ident == "BinaryBody" && matches!(last.arguments, PathArguments::None)))
|
||||
}
|
||||
|
||||
fn validate_binary_body_type(ty: &Type) -> syn::Result<()> {
|
||||
let Type::Path(path) = ty else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
@@ -1738,6 +1745,9 @@ fn reqwest_adapter_tokens(
|
||||
}
|
||||
});
|
||||
let body_parser = match response.body.as_ref() {
|
||||
Some(ty) if is_binary_body_type(ty) => quote! {
|
||||
let body = __response.decode_binary();
|
||||
},
|
||||
Some(ty) => quote! {
|
||||
let body = __response
|
||||
.decode_json::<#ty>(#api_crate::reqwest::DecodeKind::Success)
|
||||
@@ -1775,7 +1785,11 @@ fn reqwest_adapter_tokens(
|
||||
.alternate_status
|
||||
.map(|alternate| quote!(__actual == #response_status || __actual == #alternate))
|
||||
.unwrap_or_else(|| quote!(__actual == #response_status));
|
||||
let success = if let Some(response) = operation.response_body.as_ref() {
|
||||
let success = if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote! {
|
||||
return ::core::result::Result::Ok(__response.decode_binary());
|
||||
}
|
||||
} else if let Some(response) = operation.response_body.as_ref() {
|
||||
quote! {
|
||||
return __response
|
||||
.decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success)
|
||||
@@ -2133,7 +2147,9 @@ fn axum_adapter_tokens(
|
||||
let variant_ident = format_ident!("Status{}", status);
|
||||
let body_pattern = response.body.as_ref().map(|_| quote!(body,));
|
||||
let header_fields = response.headers.iter().map(|header| &header.field_ident);
|
||||
let base_response = if response.body.is_some() {
|
||||
let base_response = if response.body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response(#api_crate::axum::status(#status), body))
|
||||
} else if response.body.is_some() {
|
||||
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#status), body))
|
||||
} else {
|
||||
quote!(#api_crate::axum::empty_response(#api_crate::axum::status(#status)))
|
||||
@@ -2170,14 +2186,21 @@ fn axum_adapter_tokens(
|
||||
}
|
||||
}
|
||||
} else if let Some(alternate_status) = operation.alternate_status {
|
||||
let response_encoder = if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response)
|
||||
} else {
|
||||
quote!(#api_crate::axum::json_response)
|
||||
};
|
||||
quote!({
|
||||
let status = #api_crate::HttpSuccess::status_code(&value);
|
||||
if status == #response_status || status == #alternate_status {
|
||||
#api_crate::axum::json_response(#api_crate::axum::status(status), value)
|
||||
#response_encoder(#api_crate::axum::status(status), value)
|
||||
} else {
|
||||
#api_crate::axum::empty_response(#api_crate::axum::status(500))
|
||||
}
|
||||
})
|
||||
} else if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response(#api_crate::axum::status(#response_status), value))
|
||||
} else if operation.response_body.is_some() {
|
||||
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value))
|
||||
} else {
|
||||
@@ -2323,6 +2346,9 @@ fn openapi_adapter_tokens(
|
||||
"Alternate successful response"
|
||||
};
|
||||
let body = match response.body.as_ref() {
|
||||
Some(ty) if is_binary_body_type(ty) => quote! {
|
||||
operation.binary_response(#status, #description)?;
|
||||
},
|
||||
Some(ty) => quote! {
|
||||
operation.response::<#ty>(#status, "application/json", #description)?;
|
||||
},
|
||||
@@ -2620,7 +2646,9 @@ fn metadata_tokens(
|
||||
};
|
||||
let success_responses = operation.success_responses.iter().map(|response| {
|
||||
let status = response.status;
|
||||
let wire_kind = if response.body.is_some() {
|
||||
let wire_kind = if response.body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::WireKind::Binary)
|
||||
} else if response.body.is_some() {
|
||||
quote!(#api_crate::WireKind::Json)
|
||||
} else {
|
||||
quote!(#api_crate::WireKind::Empty)
|
||||
|
||||
@@ -77,6 +77,16 @@ pub fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
|
||||
(status, Json(value)).into_response()
|
||||
}
|
||||
|
||||
/// Emit an exact binary response with the contract status and octet-stream content type.
|
||||
pub fn binary_response(status: StatusCode, value: crate::BinaryBody) -> Response {
|
||||
(
|
||||
status,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
|
||||
value.into_bytes(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Emit an empty response with the contract status.
|
||||
pub fn empty_response(status: StatusCode) -> Response {
|
||||
status.into_response()
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
//! `#[header("x-request-id")]`. Exactly one JSON or binary body is allowed. JSON request, response,
|
||||
//! and error bodies must be named Rust types; tuples, references, arrays, and other anonymous
|
||||
//! structural types are rejected. Binary request bodies use [`BinaryBody`] and remain byte-exact.
|
||||
//! A named success response whose final type identifier is `BinaryBody` is likewise byte-exact,
|
||||
//! uses `application/octet-stream`, and may include declared response headers. Generated clients
|
||||
//! collect binary responses under the same configured response byte limit as JSON responses.
|
||||
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
|
||||
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
|
||||
//!
|
||||
@@ -96,7 +99,7 @@
|
||||
//! deterministic `Operation<utf8-hex>` fallback. The `ApiContract::OPERATIONS` inventory is
|
||||
//! sorted by operation ID, so its ordering is independent of source method order.
|
||||
//!
|
||||
//! Empty, JSON, and bounded binary request bodies are supported. [`WireKind`] reserves explicit
|
||||
//! Empty, JSON, and bounded binary request and success response bodies are supported. [`WireKind`] reserves explicit
|
||||
//! variants for other future transport work; accepting one requires a deliberate macro and adapter
|
||||
//! change rather than silently treating it as JSON.
|
||||
//!
|
||||
@@ -148,7 +151,7 @@ pub mod openapi;
|
||||
#[cfg(feature = "reqwest")]
|
||||
pub mod reqwest;
|
||||
|
||||
/// Framework-neutral owned bytes used by explicit `#[binary]` request bodies.
|
||||
/// Framework-neutral owned bytes used by explicit `#[binary]` requests and binary success responses.
|
||||
///
|
||||
/// The wrapper preserves the exact byte sequence while keeping payload content out of `Debug`
|
||||
/// output. Its `bytes::Bytes` storage lets generated Axum adapters transfer an extracted body
|
||||
@@ -314,7 +317,7 @@ pub trait Operation {
|
||||
type Parameters;
|
||||
/// Typed JSON or binary request body, or [`NoBody`].
|
||||
type RequestBody;
|
||||
/// JSON success response body type, generated declared-response result, or [`NoBody`].
|
||||
/// JSON or binary success body type, generated declared-response result, or [`NoBody`].
|
||||
type ResponseBody;
|
||||
/// JSON public error body type, or [`NoBody`].
|
||||
type ErrorBody;
|
||||
|
||||
@@ -419,6 +419,25 @@ impl OpenApiOperation<'_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Describe an octet-stream response inline, without registering a JSON schema component.
|
||||
pub fn binary_response(
|
||||
&mut self,
|
||||
status: u16,
|
||||
description: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
self.insert_response(
|
||||
status,
|
||||
json!({
|
||||
"description": description,
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": { "type": "string", "format": "binary" }
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn empty_response(
|
||||
&mut self,
|
||||
status: u16,
|
||||
|
||||
@@ -516,6 +516,11 @@ impl ReceivedResponse {
|
||||
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
|
||||
}
|
||||
|
||||
/// Move the exact response bytes, already collected under the configured limit, into a body.
|
||||
pub fn decode_binary(self) -> crate::BinaryBody {
|
||||
self.bytes.into()
|
||||
}
|
||||
|
||||
pub fn require_empty(&self) -> Result<(), ClientFailure> {
|
||||
if self.bytes.is_empty() {
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
#![cfg(all(feature = "reqwest", feature = "axum"))]
|
||||
|
||||
use api_macros::{
|
||||
BinaryBody, Operation, api,
|
||||
axum::framework::{Body, Response, Router, get},
|
||||
reqwest::{ClientError, ClientFailure},
|
||||
};
|
||||
#[cfg(feature = "openapi")]
|
||||
use api_macros::{
|
||||
WireKind,
|
||||
openapi::{OpenApiInfo, OpenApiSchema},
|
||||
};
|
||||
#[cfg(feature = "openapi")]
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "openapi")]
|
||||
use serde_json::json;
|
||||
|
||||
const BYTES: &[u8] = b"\0\xff\x80\r\n{\"not\":\"a JSON response\"}";
|
||||
const ETAG: &str = "\"binary-v1\"";
|
||||
const DISPOSITION: &str = "attachment; filename=fixture.bin";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
|
||||
pub struct PublicError {
|
||||
message: String,
|
||||
}
|
||||
#[cfg(feature = "openapi")]
|
||||
impl OpenApiSchema for PublicError {}
|
||||
impl api_macros::HttpError for PublicError {
|
||||
fn status_code(&self) -> u16 {
|
||||
404
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "openapi", api(reqwest, axum, openapi))]
|
||||
#[cfg_attr(not(feature = "openapi"), api(reqwest, axum))]
|
||||
pub trait DownloadApi {
|
||||
#[get("/plain/{mode}", error_status = 404)]
|
||||
async fn plain(&self, mode: String) -> Result<api_macros::BinaryBody, PublicError>;
|
||||
|
||||
#[get(
|
||||
"/download/{mode}",
|
||||
error_status = 404,
|
||||
responses = [
|
||||
(status = 200, body = BinaryBody, headers = [
|
||||
("x-byte-length", u32), ("content-disposition", String), ("etag", String)
|
||||
]),
|
||||
(status = 304, headers = [("etag", String)])
|
||||
]
|
||||
)]
|
||||
async fn download(&self, mode: String)
|
||||
-> Result<download_api_responses::Download, PublicError>;
|
||||
}
|
||||
|
||||
struct DownloadService;
|
||||
impl DownloadApi for DownloadService {
|
||||
async fn plain(&self, mode: String) -> Result<BinaryBody, PublicError> {
|
||||
if mode == "missing" {
|
||||
return Err(PublicError {
|
||||
message: "not found".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(if mode == "empty" {
|
||||
b"".as_slice()
|
||||
} else {
|
||||
BYTES
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn download(
|
||||
&self,
|
||||
mode: String,
|
||||
) -> Result<download_api_responses::Download, PublicError> {
|
||||
if mode == "cached" {
|
||||
return Ok(download_api_responses::Download::Status304 {
|
||||
header_etag: ETAG.to_owned(),
|
||||
});
|
||||
}
|
||||
let body = self.plain(mode).await?;
|
||||
Ok(download_api_responses::Download::Status200 {
|
||||
header_x_byte_length: body.len() as u32,
|
||||
header_content_disposition: DISPOSITION.to_owned(),
|
||||
header_etag: ETAG.to_owned(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Server {
|
||||
base_url: String,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Server {
|
||||
async fn start(router: Router) -> Self {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}/", listener.local_addr().unwrap());
|
||||
let task = tokio::spawn(async move {
|
||||
api_macros::axum::framework::serve(listener, router)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
Self { base_url, task }
|
||||
}
|
||||
|
||||
fn client(&self, limit: usize) -> DownloadApiClient {
|
||||
DownloadApiClient::builder(&self.base_url)
|
||||
.unwrap()
|
||||
.response_body_limit(limit)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_success_preserves_exact_bytes_content_type_and_declared_headers() {
|
||||
let server = Server::start(DownloadApiAxum::router(DownloadService)).await;
|
||||
let client = server.client(BYTES.len());
|
||||
for (mode, expected) in [("full", BYTES), ("empty", b"".as_slice())] {
|
||||
let response = reqwest::get(format!("{}plain/{mode}", server.base_url))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"application/octet-stream"
|
||||
);
|
||||
assert_eq!(response.bytes().await.unwrap().as_ref(), expected);
|
||||
assert_eq!(
|
||||
client.plain(mode.to_owned()).await.unwrap().as_ref(),
|
||||
expected
|
||||
);
|
||||
|
||||
let response = client.download(mode.to_owned()).await.unwrap();
|
||||
match response {
|
||||
download_api_responses::Download::Status200 {
|
||||
body,
|
||||
header_x_byte_length,
|
||||
header_content_disposition,
|
||||
header_etag,
|
||||
} => {
|
||||
assert_eq!(body.as_ref(), expected);
|
||||
assert_eq!(header_x_byte_length as usize, expected.len());
|
||||
assert_eq!(header_content_disposition, DISPOSITION);
|
||||
assert_eq!(header_etag, ETAG);
|
||||
}
|
||||
other => panic!("expected binary success, received {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
matches!(client.download("cached".to_owned()).await.unwrap(),
|
||||
download_api_responses::Download::Status304 { header_etag } if header_etag == ETAG)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "openapi")]
|
||||
#[test]
|
||||
fn binary_success_metadata_and_openapi_use_inline_binary_schema_with_headers() {
|
||||
let document = download_api_openapi(OpenApiInfo {
|
||||
title: "Download fixture",
|
||||
version: "1",
|
||||
source_digest: "fixture",
|
||||
})
|
||||
.unwrap();
|
||||
let value = document.as_value();
|
||||
for (path, metadata) in [
|
||||
("/plain/{mode}", download_api_operations::Plain::METADATA),
|
||||
(
|
||||
"/download/{mode}",
|
||||
download_api_operations::Download::METADATA,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
metadata.success_responses[0].body.wire_kind,
|
||||
WireKind::Binary
|
||||
);
|
||||
assert_eq!(metadata.error_responses[0].body.wire_kind, WireKind::Json);
|
||||
let responses = &value["paths"][path]["get"]["responses"];
|
||||
assert_eq!(
|
||||
responses["200"]["content"],
|
||||
json!({
|
||||
"application/octet-stream": { "schema": { "type": "string", "format": "binary" } }
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
responses["404"]["content"]["application/json"]["schema"]["$ref"],
|
||||
"#/components/schemas/PublicError"
|
||||
);
|
||||
}
|
||||
let metadata = download_api_operations::Download::METADATA;
|
||||
assert_eq!(
|
||||
metadata.success_responses[1].body.wire_kind,
|
||||
WireKind::Empty
|
||||
);
|
||||
let responses = &value["paths"]["/download/{mode}"]["get"]["responses"];
|
||||
for header in metadata.success_responses[0].headers {
|
||||
assert!(responses["200"]["headers"][header.wire_name]["schema"].is_object());
|
||||
}
|
||||
assert!(responses["304"].get("content").is_none());
|
||||
assert!(responses["304"]["headers"]["etag"]["schema"].is_object());
|
||||
assert!(
|
||||
value["components"]["schemas"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.all(|name| !name.contains("BinaryBody"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_success_keeps_json_public_errors_and_rejects_unexpected_statuses() {
|
||||
let server = Server::start(DownloadApiAxum::router(DownloadService)).await;
|
||||
let client = server.client(1024);
|
||||
for result in [
|
||||
client.plain("missing".to_owned()).await.map(|_| ()),
|
||||
client.download("missing".to_owned()).await.map(|_| ()),
|
||||
] {
|
||||
match result.unwrap_err() {
|
||||
ClientError::Public { status, error } => {
|
||||
assert_eq!(status.as_u16(), 404);
|
||||
assert_eq!(error.message, "not found");
|
||||
}
|
||||
other => panic!("expected JSON public error, received {other:?}"),
|
||||
}
|
||||
}
|
||||
for (status, expected) in [
|
||||
(
|
||||
201,
|
||||
ClientFailure::UnexpectedStatus {
|
||||
expected: 200,
|
||||
actual: 201,
|
||||
},
|
||||
),
|
||||
(404, ClientFailure::ErrorResponseDecode { status: 404 }),
|
||||
] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Plain::METADATA.path,
|
||||
get(move || async move {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.body(Body::from(BYTES))
|
||||
.unwrap()
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert!(matches!(server.client(1024).plain("full".to_owned()).await,
|
||||
Err(ClientError::Failure(actual)) if actual == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_declared_headers_reject_missing_invalid_and_duplicate_values() {
|
||||
for (values, expected) in [
|
||||
(
|
||||
vec![],
|
||||
ClientFailure::MissingResponseHeader { name: "etag" },
|
||||
),
|
||||
(
|
||||
vec![b"\xff".as_slice()],
|
||||
ClientFailure::InvalidResponseHeader { name: "etag" },
|
||||
),
|
||||
(
|
||||
vec![b"one".as_slice(), b"two".as_slice()],
|
||||
ClientFailure::DuplicateResponseHeader { name: "etag" },
|
||||
),
|
||||
] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Download::METADATA.path,
|
||||
get(move || {
|
||||
let values = values.clone();
|
||||
async move {
|
||||
let mut response = Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/octet-stream")
|
||||
.header("x-byte-length", BYTES.len())
|
||||
.header("content-disposition", DISPOSITION);
|
||||
for value in values {
|
||||
response = response.header("etag", value);
|
||||
}
|
||||
response.body(Body::from(BYTES)).unwrap()
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
matches!(server.client(1024).download("full".to_owned()).await,
|
||||
Err(ClientError::Failure(actual)) if actual == expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_responses_enforce_the_same_limit_with_and_without_content_length() {
|
||||
for chunked in [false, true] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Plain::METADATA.path,
|
||||
get(move || async move {
|
||||
if chunked {
|
||||
let chunks = [BYTES[..3].to_vec(), BYTES[3..].to_vec()];
|
||||
let stream = futures::stream::iter(chunks.map(Ok::<_, std::io::Error>));
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
} else {
|
||||
api_macros::axum::binary_response(api_macros::axum::status(200), BYTES.into())
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert_eq!(
|
||||
server
|
||||
.client(BYTES.len())
|
||||
.plain("full".to_owned())
|
||||
.await
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
BYTES
|
||||
);
|
||||
assert!(
|
||||
matches!(server.client(BYTES.len() - 1).plain("full".to_owned()).await,
|
||||
Err(ClientError::Failure(ClientFailure::ResponseTooLarge { limit })) if limit == BYTES.len() - 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,7 @@ required-features = ["typescript"]
|
||||
[[example]]
|
||||
name = "generate_repository_access_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
[[example]]
|
||||
name = "generate_drive_api_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::drive_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
//! Workspace Drive HTTP contracts. Entries are logical, workspace-bound resources,
|
||||
//! never host paths. Worker identity and grants are resolved by the service from
|
||||
//! trusted request context, not supplied by ordinary operation DTOs.
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
pub const DRIVE_TEXT_MAX_BYTES: usize = 64 * 1024;
|
||||
pub const DRIVE_CHUNK_MAX_BYTES: u32 = 64 * 1024;
|
||||
pub const DRIVE_PAGE_MAX_LIMIT: u32 = 200;
|
||||
pub const DRIVE_SEARCH_MAX_LIMIT: u32 = 128;
|
||||
pub const DRIVE_FILE_MAX_BYTES: u32 = 16 * 1024 * 1024;
|
||||
|
||||
fn decimal<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if !canonical_positive_decimal(&value) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"expected a canonical positive decimal string",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn optional_decimal<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<String>, D::Error> {
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
if value
|
||||
.as_deref()
|
||||
.is_some_and(|value| !canonical_positive_decimal(value))
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"expected a canonical positive decimal string",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn canonical_positive_decimal(value: &str) -> bool {
|
||||
value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|first| matches!(first, b'1'..=b'9'))
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& value.parse::<i64>().is_ok_and(|value| value > 0)
|
||||
}
|
||||
fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.len() > DRIVE_TEXT_MAX_BYTES {
|
||||
return Err(serde::de::Error::custom("Drive text exceeds 64 KiB"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn bounded_chunk<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||
let value = u32::deserialize(deserializer)?;
|
||||
if value == 0 || value > DRIVE_CHUNK_MAX_BYTES {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Drive read size must be between 1 and 65536 bytes",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn optional_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<u32>, D::Error> {
|
||||
let value = Option::<u32>::deserialize(deserializer)?;
|
||||
if value.is_some_and(|value| value == 0 || value > DRIVE_PAGE_MAX_LIMIT) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Drive page limit must be between 1 and 200",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
const fn default_read_size() -> u32 {
|
||||
DRIVE_CHUNK_MAX_BYTES
|
||||
}
|
||||
fn optional_search_limit<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<u32>, D::Error> {
|
||||
let value = Option::<u32>::deserialize(deserializer)?;
|
||||
if value.is_some_and(|value| value == 0 || value > DRIVE_SEARCH_MAX_LIMIT) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Drive search limit must be between 1 and 128",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Numeric node IDs alone are not public references: the referenced Workspace
|
||||
/// must match the route Workspace before the service resolves the node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveEntryRef {
|
||||
pub workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveEntryKind {
|
||||
Folder,
|
||||
File,
|
||||
}
|
||||
|
||||
/// 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))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveEntry {
|
||||
pub entry: DriveEntryRef,
|
||||
pub parent: Option<DriveEntryRef>,
|
||||
pub name: String,
|
||||
pub kind: DriveEntryKind,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub revision: String,
|
||||
/// File length in bytes; absent for folders.
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub size: Option<u32>,
|
||||
pub content_type: Option<String>,
|
||||
pub updated_by: String,
|
||||
/// Timestamp projected from storage's updated_at_ms; not a creation time.
|
||||
pub updated_at: String,
|
||||
/// Stable relative authenticated URL in this same Workspace. No bearer token
|
||||
/// or cross-Workspace link is embedded in this metadata.
|
||||
pub latest_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveEntryQuery {
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveListQuery {
|
||||
pub entry_workspace_id: String,
|
||||
/// Folder to list.
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "optional_limit")]
|
||||
#[schemars(range(min = 1, max = 200))]
|
||||
pub limit: Option<u32>,
|
||||
/// Opaque service-issued page cursor, not a bare node ID.
|
||||
#[serde(default)]
|
||||
pub after: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveSearchQuery {
|
||||
pub query: String,
|
||||
/// Search file text as well as names only when explicitly requested.
|
||||
#[serde(default)]
|
||||
pub include_text: bool,
|
||||
#[serde(default, deserialize_with = "optional_search_limit")]
|
||||
#[schemars(range(min = 1, max = 128))]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub after: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveListResponse {
|
||||
pub entries: Vec<DriveEntry>,
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveReadTextQuery {
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
|
||||
#[schemars(range(min = 1, max = 65536))]
|
||||
pub max_bytes: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveReadTextResponse {
|
||||
pub entry: DriveEntry,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
pub text: String,
|
||||
pub truncated: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveReadChunkQuery {
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
/// Bind a multi-chunk read to the same file revision.
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub expected_revision: String,
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub offset: u32,
|
||||
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
|
||||
#[schemars(range(min = 1, max = 65536))]
|
||||
pub length: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveDownloadQuery {
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub expected_revision: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveMutationRequest {
|
||||
pub request_id: String,
|
||||
pub mutation: DriveMutation,
|
||||
}
|
||||
/// JSON can mutate bounded UTF-8 TEXT only. Arbitrary file bytes use drive_upload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum DriveMutation {
|
||||
CreateFolder {
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
},
|
||||
CreateText {
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
text: String,
|
||||
content_type: String,
|
||||
},
|
||||
UpdateText {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
text: String,
|
||||
content_type: String,
|
||||
},
|
||||
Relocate {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
},
|
||||
Delete {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveMutationResponse {
|
||||
pub request_id: String,
|
||||
/// A deleted entry has no live metadata.
|
||||
pub entry: Option<DriveEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveUploadOperation {
|
||||
Create,
|
||||
Update,
|
||||
}
|
||||
/// Flat query metadata accompanies the binary body. The service rejects invalid
|
||||
/// combinations: create requires parent_id/name and forbids id/expected_revision;
|
||||
/// update requires id/expected_revision and forbids parent_id/name. Both targets
|
||||
/// are bound by entry_workspace_id; declared size and SHA256 must match the body.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveUploadQuery {
|
||||
pub operation: DriveUploadOperation,
|
||||
pub request_id: String,
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub parent_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub expected_revision: Option<String>,
|
||||
pub content_type: String,
|
||||
/// Values above the file limit still decode as u32 so the service returns
|
||||
/// typed Limit rather than a generic request-decoding error.
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub size: u32,
|
||||
pub sha256: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveRequestState {
|
||||
/// No commit record exists at the observation snapshot. An in-flight request
|
||||
/// can still commit afterward; this is not proof of a final failed outcome.
|
||||
Uncommitted,
|
||||
Committed,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveRequestStatusResponse {
|
||||
pub request_id: String,
|
||||
pub state: DriveRequestState,
|
||||
pub response: Option<DriveMutationResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveAccess {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveGrantCreateRequest {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub access: DriveAccess,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveGrantResponse {
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub grant_id: String,
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub access: DriveAccess,
|
||||
pub revoked: bool,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub revoked_by: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveGrantListQuery {
|
||||
#[serde(default, deserialize_with = "optional_limit")]
|
||||
#[schemars(range(min = 1, max = 200))]
|
||||
pub limit: Option<u32>,
|
||||
/// Canonical grant ID cursor within this Workspace.
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub after: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveGrantListResponse {
|
||||
pub grants: Vec<DriveGrantResponse>,
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveApiErrorCode {
|
||||
Denied,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Invalid,
|
||||
Limit,
|
||||
StorageUnavailable,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveFailureClassification {
|
||||
NotCommitted,
|
||||
Unknown,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveApiError {
|
||||
pub code: DriveApiErrorCode,
|
||||
pub classification: DriveFailureClassification,
|
||||
pub message: String,
|
||||
}
|
||||
impl DriveApiError {
|
||||
/// Never include provider errors, paths, SQL, or caller-supplied messages.
|
||||
pub fn new(code: DriveApiErrorCode) -> Self {
|
||||
use DriveApiErrorCode::*;
|
||||
Self {
|
||||
code,
|
||||
classification: if code == OutcomeUnknown {
|
||||
DriveFailureClassification::Unknown
|
||||
} else {
|
||||
DriveFailureClassification::NotCommitted
|
||||
},
|
||||
message: match code {
|
||||
Denied => "Drive access denied",
|
||||
NotFound => "Drive resource not found",
|
||||
Conflict => "Drive revision or name conflict",
|
||||
Invalid => "Invalid Drive request",
|
||||
Limit => "Drive request exceeds a limit",
|
||||
StorageUnavailable => "Drive storage unavailable",
|
||||
OutcomeUnknown => "Drive mutation outcome unknown",
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl api_macros::HttpError for DriveApiError {
|
||||
fn status_code(&self) -> u16 {
|
||||
use DriveApiErrorCode::*;
|
||||
match self.code {
|
||||
Denied => 403,
|
||||
NotFound => 404,
|
||||
Conflict => 409,
|
||||
Invalid => 400,
|
||||
Limit => 413,
|
||||
StorageUnavailable | OutcomeUnknown => 503,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl api_macros::HttpRequestError for DriveApiError {
|
||||
fn from_request_rejection(status: u16, _message: String) -> Self {
|
||||
Self::new(if status == 413 {
|
||||
DriveApiErrorCode::Limit
|
||||
} else {
|
||||
DriveApiErrorCode::Invalid
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl api_macros::openapi::OpenApiSchema for DriveApiError {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveEntry {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveListQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveListResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveReadTextQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveReadTextResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveReadChunkQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveDownloadQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveMutationRequest {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveMutationResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveUploadQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveRequestStatusResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveGrantCreateRequest {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveGrantResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveGrantListQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveGrantListResponse {}
|
||||
|
||||
/// Browser wire declarations are derived from the transport DTOs, not copied
|
||||
/// from storage models or maintained independently in the Web client.
|
||||
#[cfg(feature = "typescript")]
|
||||
pub fn drive_api_typescript() -> String {
|
||||
use ts_rs::TS;
|
||||
let config = ts_rs::Config::default();
|
||||
let declarations = [
|
||||
DriveEntryRef::decl(&config),
|
||||
DriveEntryKind::decl(&config),
|
||||
DriveEntry::decl(&config),
|
||||
DriveEntryQuery::decl(&config),
|
||||
DriveListQuery::decl(&config),
|
||||
DriveSearchQuery::decl(&config),
|
||||
DriveListResponse::decl(&config),
|
||||
DriveReadTextQuery::decl(&config),
|
||||
DriveReadTextResponse::decl(&config),
|
||||
DriveReadChunkQuery::decl(&config),
|
||||
DriveDownloadQuery::decl(&config),
|
||||
DriveMutation::decl(&config),
|
||||
DriveMutationRequest::decl(&config),
|
||||
DriveMutationResponse::decl(&config),
|
||||
DriveUploadOperation::decl(&config),
|
||||
DriveUploadQuery::decl(&config),
|
||||
DriveRequestState::decl(&config),
|
||||
DriveRequestStatusResponse::decl(&config),
|
||||
DriveAccess::decl(&config),
|
||||
DriveGrantCreateRequest::decl(&config),
|
||||
DriveGrantResponse::decl(&config),
|
||||
DriveGrantListQuery::decl(&config),
|
||||
DriveGrantListResponse::decl(&config),
|
||||
DriveApiErrorCode::decl(&config),
|
||||
DriveFailureClassification::decl(&config),
|
||||
DriveApiError::decl(&config),
|
||||
];
|
||||
format!(
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "typescript"))]
|
||||
mod typescript_tests {
|
||||
#[test]
|
||||
fn generated_drive_api_contract_is_current() {
|
||||
let expected = super::drive_api_typescript();
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../web/workspace/src/lib/generated/drive-api.ts");
|
||||
let actual = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||
assert_eq!(
|
||||
normalize(&actual),
|
||||
normalize(&expected),
|
||||
"regenerate Drive API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts` and format the generated file"
|
||||
);
|
||||
}
|
||||
fn normalize(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter_map(|character| match character {
|
||||
character if character.is_whitespace() => None,
|
||||
',' => Some(';'),
|
||||
character => Some(character),
|
||||
})
|
||||
.collect::<String>()
|
||||
.replace("=|", "=")
|
||||
.replace(";}", "}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ApiContract, HttpMethod, ServerApiMetadata, canonical_openapi_document};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn drive_entry_references_reject_unbound_or_noncanonical_numeric_ids() {
|
||||
let valid = json!({"workspace_id":"workspace-a", "node_id":"9007199254740993"});
|
||||
let entry: DriveEntryRef = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(entry).unwrap(), valid);
|
||||
for invalid in [
|
||||
json!("7"),
|
||||
json!(7),
|
||||
json!({"node_id":"7"}),
|
||||
json!({"workspace_id":"a", "node_id":7}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<DriveEntryRef>(invalid).is_err());
|
||||
}
|
||||
for id in [
|
||||
"",
|
||||
"0",
|
||||
"01",
|
||||
"-1",
|
||||
"+1",
|
||||
"1.0",
|
||||
"1e3",
|
||||
" 1",
|
||||
"١",
|
||||
"9223372036854775808",
|
||||
"18446744073709551616",
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<DriveEntryRef>(json!({"workspace_id":"a", "node_id":id}))
|
||||
.is_err(),
|
||||
"accepted {id:?}"
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<DriveEntryQuery>(
|
||||
json!({"entry_workspace_id":"a", "id":id})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<DriveEntryRef>(
|
||||
json!({"workspace_id":"a","node_id":"9223372036854775807"})
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(serde_json::from_value::<DriveEntryQuery>(json!({"id":"7"})).is_err());
|
||||
assert!(serde_json::from_value::<DriveListQuery>(json!({"id":"7"})).is_err());
|
||||
assert!(serde_json::from_value::<DriveReadTextQuery>(json!({"id":"7"})).is_err());
|
||||
assert!(serde_json::from_value::<DriveDownloadQuery>(json!({"id":"7"})).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<DriveEntryQuery>(
|
||||
json!({"entry_workspace_id":"a", "id":"7", "worker_id":"spoofed"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_metadata_exposes_only_stored_update_provenance_and_same_workspace_url() {
|
||||
let value = json!({
|
||||
"entry":{"workspace_id":"a","node_id":"7"},
|
||||
"parent":{"workspace_id":"a","node_id":"1"},
|
||||
"name":"file.txt","kind":"file","revision":"2","size":5,
|
||||
"content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z",
|
||||
"latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7"
|
||||
});
|
||||
let entry: DriveEntry = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(entry).unwrap(), value);
|
||||
for field in ["sha256", "created_at"] {
|
||||
let mut fabricated = value.clone();
|
||||
fabricated[field] = json!("not stored");
|
||||
assert!(serde_json::from_value::<DriveEntry>(fabricated).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_search_text_is_opt_in_and_work_budget_is_bounded() {
|
||||
let query: DriveSearchQuery = serde_json::from_value(json!({"query":"name"})).unwrap();
|
||||
assert!(!query.include_text);
|
||||
let query: DriveSearchQuery =
|
||||
serde_json::from_value(json!({"query":"text","include_text":true,"limit":128}))
|
||||
.unwrap();
|
||||
assert!(query.include_text);
|
||||
for limit in [0, 129, u32::MAX] {
|
||||
assert!(
|
||||
serde_json::from_value::<DriveSearchQuery>(json!({"query":"name","limit":limit}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_request_status_is_a_commit_snapshot_not_a_fictitious_lifecycle() {
|
||||
for state in ["uncommitted", "committed"] {
|
||||
let value = json!({"request_id":"r","state":state,"response":null});
|
||||
let response: DriveRequestStatusResponse =
|
||||
serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||
}
|
||||
for state in ["pending", "unknown", "not_committed"] {
|
||||
assert!(
|
||||
serde_json::from_value::<DriveRequestStatusResponse>(
|
||||
json!({"request_id":"r","state":state,"response":null})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() {
|
||||
let valid = json!({"request_id":"request-a", "mutation":{
|
||||
"operation":"update_text", "id":{"workspace_id":"a","node_id":"7"},
|
||||
"expected_revision":"9007199254740993", "text":"hello", "content_type":"text/plain"
|
||||
}});
|
||||
let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), valid);
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["bytes"] = json!([0, 255]);
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["id"] = json!("7");
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["expected_revision"] = json!("01");
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
// UTF-8 byte count, not character count, determines the text bound.
|
||||
let mut boundary = valid;
|
||||
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2));
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(boundary.clone()).is_ok());
|
||||
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2 + 1));
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(boundary).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_reads_reject_zero_or_over_64kib_requests() {
|
||||
for size in [0, 65537, u32::MAX] {
|
||||
assert!(
|
||||
serde_json::from_value::<DriveReadTextQuery>(
|
||||
json!({"entry_workspace_id":"a","id":"7","max_bytes":size})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":size})).is_err());
|
||||
}
|
||||
let default: DriveReadTextQuery =
|
||||
serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap();
|
||||
assert_eq!(default.max_bytes, 65536);
|
||||
let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":65536})).unwrap();
|
||||
assert_eq!(chunk.length, 65536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_upload_requires_binding_declared_integrity_and_request_identity() {
|
||||
let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_revision":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)});
|
||||
assert!(serde_json::from_value::<DriveUploadQuery>(valid.clone()).is_ok());
|
||||
for field in ["entry_workspace_id", "size", "sha256", "request_id"] {
|
||||
let mut missing = valid.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<DriveUploadQuery>(missing).is_err(),
|
||||
"accepted missing {field}"
|
||||
);
|
||||
}
|
||||
for size in [DRIVE_FILE_MAX_BYTES, DRIVE_FILE_MAX_BYTES + 1, u32::MAX] {
|
||||
let mut oversized = valid.clone();
|
||||
oversized["size"] = json!(size);
|
||||
let query: DriveUploadQuery = serde_json::from_value(oversized).unwrap();
|
||||
assert_eq!(
|
||||
query.size, size,
|
||||
"service must receive oversized declared sizes for typed Limit classification"
|
||||
);
|
||||
}
|
||||
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok());
|
||||
let mut invalid = valid;
|
||||
invalid["bytes"] = json!([0, 255]);
|
||||
assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_grants_reject_command_access_and_unbounded_pages() {
|
||||
let grant = json!({"runtime_id":"r","worker_id":"w","access":"read_write"});
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
serde_json::from_value::<DriveGrantCreateRequest>(grant.clone()).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
grant
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<DriveGrantCreateRequest>(
|
||||
json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_value::<DriveGrantCreateRequest>(json!({"runtime_id":"r","worker_id":"w","access":"read_only","workspace_id":"spoofed"})).is_err());
|
||||
for limit in [0, 201, u32::MAX] {
|
||||
assert!(serde_json::from_value::<DriveGrantListQuery>(json!({"limit":limit})).is_err());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<DriveGrantListQuery>(json!({"limit":200,"after":"7"})).is_ok()
|
||||
);
|
||||
assert!(serde_json::from_value::<DriveGrantListQuery>(json!({"after":"01"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_errors_have_fixed_safe_messages_and_typed_outcome_classification() {
|
||||
use DriveApiErrorCode::*;
|
||||
for (code, wire, status, message) in [
|
||||
(Denied, "denied", 403, "Drive access denied"),
|
||||
(NotFound, "not_found", 404, "Drive resource not found"),
|
||||
(Conflict, "conflict", 409, "Drive revision or name conflict"),
|
||||
(Invalid, "invalid", 400, "Invalid Drive request"),
|
||||
(Limit, "limit", 413, "Drive request exceeds a limit"),
|
||||
(
|
||||
StorageUnavailable,
|
||||
"storage_unavailable",
|
||||
503,
|
||||
"Drive storage unavailable",
|
||||
),
|
||||
(
|
||||
OutcomeUnknown,
|
||||
"outcome_unknown",
|
||||
503,
|
||||
"Drive mutation outcome unknown",
|
||||
),
|
||||
] {
|
||||
let error = DriveApiError::new(code);
|
||||
assert_eq!(api_macros::HttpError::status_code(&error), status);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&error).unwrap(),
|
||||
json!({"code":wire,"classification":if code == OutcomeUnknown {"unknown"} else {"not_committed"},"message":message})
|
||||
);
|
||||
}
|
||||
for status in [400, 413, 415, 422] {
|
||||
let error = <DriveApiError as api_macros::HttpRequestError>::from_request_rejection(
|
||||
status,
|
||||
"/private/path: SQL failed".into(),
|
||||
);
|
||||
assert_eq!(
|
||||
error,
|
||||
DriveApiError::new(if status == 413 { Limit } else { Invalid })
|
||||
);
|
||||
}
|
||||
assert!(serde_json::from_value::<DriveApiError>(json!({"code":"invalid","classification":"not_committed","message":"Invalid Drive request","provider_error":"secret"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_routes_are_workspace_scoped_authenticated_and_binary_is_not_json() {
|
||||
let document = canonical_openapi_document().unwrap();
|
||||
let document: serde_json::Value =
|
||||
serde_json::from_str(&document.to_json().unwrap()).unwrap();
|
||||
let schemas = &document["components"]["schemas"];
|
||||
assert_eq!(
|
||||
schemas["DriveEntry"]["properties"]["size"]["maximum"],
|
||||
DRIVE_FILE_MAX_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["DriveReadChunkQuery"]["properties"]["offset"]["maximum"],
|
||||
DRIVE_FILE_MAX_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["DriveUploadQuery"]["properties"]["size"]["maximum"],
|
||||
DRIVE_FILE_MAX_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["DriveSearchQuery"]["properties"]["limit"]["maximum"],
|
||||
DRIVE_SEARCH_MAX_LIMIT
|
||||
);
|
||||
for (name, method, suffix, wire_method) in [
|
||||
("drive_root", HttpMethod::Get, "/root", "get"),
|
||||
("drive_metadata", HttpMethod::Get, "/metadata", "get"),
|
||||
("drive_list", HttpMethod::Get, "/list", "get"),
|
||||
("drive_search", HttpMethod::Get, "/search", "get"),
|
||||
("drive_read_text", HttpMethod::Get, "/read-text", "get"),
|
||||
("drive_read_chunk", HttpMethod::Get, "/read-chunk", "get"),
|
||||
("drive_download", HttpMethod::Get, "/download", "get"),
|
||||
("drive_mutate", HttpMethod::Post, "/mutate", "post"),
|
||||
("drive_upload", HttpMethod::Put, "/upload", "put"),
|
||||
(
|
||||
"drive_request_status",
|
||||
HttpMethod::Get,
|
||||
"/requests/{request_id}",
|
||||
"get",
|
||||
),
|
||||
("drive_grant_create", HttpMethod::Post, "/grants", "post"),
|
||||
(
|
||||
"drive_grant_revoke",
|
||||
HttpMethod::Delete,
|
||||
"/grants/{grant_id}",
|
||||
"delete",
|
||||
),
|
||||
("drive_grant_list", HttpMethod::Get, "/grants", "get"),
|
||||
] {
|
||||
let operation = ServerApiMetadata::OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.operation_id == name)
|
||||
.unwrap();
|
||||
let path = format!("/api/w/{{workspace_id}}/drive{suffix}");
|
||||
assert_eq!(operation.method, method);
|
||||
assert_eq!(operation.path, path);
|
||||
let documented = &document["paths"][&path][wire_method];
|
||||
assert_eq!(documented["operationId"], name);
|
||||
assert_eq!(
|
||||
documented["security"],
|
||||
json!([{"bearerAuth":[]},{"browserSession":[]}])
|
||||
);
|
||||
// Server extensions must not become caller-controlled parameters.
|
||||
assert!(
|
||||
documented["parameters"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|parameter| parameter["name"] != "context")
|
||||
);
|
||||
if matches!(name, "drive_read_chunk" | "drive_download") {
|
||||
let content = &documented["responses"]["200"]["content"];
|
||||
assert!(content["application/octet-stream"].is_object());
|
||||
assert!(content["application/json"].is_null());
|
||||
}
|
||||
if name == "drive_download" {
|
||||
let headers = documented["responses"]["200"]["headers"]
|
||||
.as_object()
|
||||
.unwrap();
|
||||
assert_eq!(headers.len(), 5);
|
||||
for header in [
|
||||
"content-disposition",
|
||||
"cache-control",
|
||||
"etag",
|
||||
"x-content-type-options",
|
||||
"content-security-policy",
|
||||
] {
|
||||
assert_eq!(
|
||||
headers[header]["schema"]["$ref"],
|
||||
"#/components/schemas/string"
|
||||
);
|
||||
}
|
||||
assert!(!headers.contains_key("content-type"));
|
||||
assert!(!headers.contains_key("content-length"));
|
||||
}
|
||||
if name == "drive_upload" {
|
||||
let content = &documented["requestBody"]["content"];
|
||||
assert!(content["application/octet-stream"].is_object());
|
||||
assert!(content["application/json"].is_null());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ use api_macros::api;
|
||||
pub use api_macros::axum as server_support;
|
||||
pub use api_macros::reqwest as client_support;
|
||||
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||
mod drive;
|
||||
pub mod repository_openapi_typescript;
|
||||
pub use drive::*;
|
||||
mod workspace_config;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -2603,6 +2605,116 @@ pub trait ServerApi {
|
||||
#[path] working_directory_id: String,
|
||||
#[body] request: WorkingDirectoryRemovalRequest,
|
||||
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
||||
// Ordinary operations accept member browser sessions or trusted Worker request
|
||||
// context. The service resolves Worker grants; no operation accepts an actor or
|
||||
// grant ID from the caller. Grant management is Workspace-owner-only.
|
||||
#[get("/api/w/{workspace_id}/drive/root", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_root(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<DriveEntry, 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,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveEntryQuery,
|
||||
) -> Result<DriveEntry, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/list", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_list(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveListQuery,
|
||||
) -> Result<DriveListResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/search", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_search(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveSearchQuery,
|
||||
) -> Result<DriveListResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/read-text", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_read_text(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveReadTextQuery,
|
||||
) -> Result<DriveReadTextResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/read-chunk", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_read_chunk(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveReadChunkQuery,
|
||||
) -> Result<BinaryBody, DriveApiError>;
|
||||
// Download safety/cache headers are service-produced typed response metadata;
|
||||
// Content-Type/Content-Length remain controlled by the binary transport.
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/drive/download",
|
||||
responses = [(status = 200, body = BinaryBody, headers = [
|
||||
("content-disposition", String),
|
||||
("cache-control", String),
|
||||
("etag", String),
|
||||
("x-content-type-options", String),
|
||||
("content-security-policy", String)
|
||||
])],
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401,403,404,409,413,503],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn drive_download(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveDownloadQuery,
|
||||
) -> Result<server_api_responses::DriveDownload, DriveApiError>;
|
||||
#[post("/api/w/{workspace_id}/drive/mutate", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn drive_mutate(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: DriveMutationRequest,
|
||||
) -> Result<DriveMutationResponse, DriveApiError>;
|
||||
#[put("/api/w/{workspace_id}/drive/upload", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn drive_upload(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveUploadQuery,
|
||||
#[binary] body: BinaryBody,
|
||||
) -> Result<DriveMutationResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/requests/{request_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_request_status(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] request_id: String,
|
||||
) -> Result<DriveRequestStatusResponse, DriveApiError>;
|
||||
#[post("/api/w/{workspace_id}/drive/grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn drive_grant_create(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: DriveGrantCreateRequest,
|
||||
) -> Result<DriveGrantResponse, DriveApiError>;
|
||||
#[delete("/api/w/{workspace_id}/drive/grants/{grant_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_grant_revoke(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] grant_id: String,
|
||||
) -> Result<DriveGrantResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_grant_list(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveGrantListQuery,
|
||||
) -> Result<DriveGrantListResponse, DriveApiError>;
|
||||
|
||||
#[get("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_get(
|
||||
&self,
|
||||
|
||||
@@ -54,6 +54,7 @@ base64 = { workspace = true }
|
||||
wip-client = { workspace = true }
|
||||
wip-http = { workspace = true }
|
||||
wip-protocol = { workspace = true }
|
||||
wip-text-view = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
axum = { workspace = true }
|
||||
|
||||
@@ -90,7 +90,7 @@ manifests of already-running Workers.
|
||||
|
||||
Public integration seams: `WorkspaceConfigFeature::for_workspace`,
|
||||
`workspace_config::wip::mount_workspace_config_wip`, and
|
||||
`WipRuntime::from_mounts` plus `discover`/`inspect`/`call`. Worker tests exercise the
|
||||
`WipRuntime::from_mounts` plus `tree`/`inspect`/`invoke`. Worker tests exercise the
|
||||
real Feature registry, native mount and WIP Client/Host through a typed in-process
|
||||
contract router: deep discovery, slash/native attach, read/edit/write,
|
||||
create/delete, atomic multi-source updates, grant restrictions/revocation,
|
||||
|
||||
@@ -23,8 +23,8 @@ use crate::feature::builtin::manage_workdir::wip::{decode_identity, encode_ident
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipSubtreeMount, WipSubtreeProvider,
|
||||
json_to_wip, wip_to_json,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
|
||||
WipSubtreeProvider, json_to_wip, wip_to_json,
|
||||
};
|
||||
|
||||
const ROOT: &str = "/checkouts";
|
||||
@@ -146,8 +146,10 @@ impl Provider {
|
||||
kind: &str,
|
||||
handler: Arc<dyn WipOperationHandler>,
|
||||
) -> WipProjection {
|
||||
let encoded: String = path.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let interface = format!("yoi.checkout/{}/{kind}/v1/@/{encoded}", self.incarnation);
|
||||
let interface = crate::wip::contextual_reference(
|
||||
&format!("yoi.checkout/{}/{kind}/v1", self.incarnation),
|
||||
path,
|
||||
);
|
||||
let interface_validator = Sha256::digest(format!("{descriptor:?}")).to_vec();
|
||||
WipProjection {
|
||||
route: path.into(),
|
||||
@@ -286,7 +288,7 @@ impl Provider {
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for Arc<Provider> {
|
||||
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
@@ -304,6 +306,7 @@ impl WipSubtreeProvider for Arc<Provider> {
|
||||
"checkout observation deadline",
|
||||
)
|
||||
})?
|
||||
.and_then(|projection| projection.map(WipPublication::self_scoped).transpose())
|
||||
}
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
|
||||
@@ -320,8 +320,8 @@ fn native_runtime(remote: Arc<RemoteWorkdirSession>) -> (WipRuntime, Arc<Workdir
|
||||
(runtime, router)
|
||||
}
|
||||
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||
runtime.discover(path.into(), 0, true).await.unwrap();
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::InterfaceReference {
|
||||
runtime.tree(path.into(), 0, true).await.unwrap();
|
||||
// Select from the real Client's Known Space, not a direct handler invocation.
|
||||
let interface = {
|
||||
let state = runtime.state.lock().unwrap();
|
||||
@@ -337,7 +337,7 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||
.interfaces[0]
|
||||
.clone()
|
||||
};
|
||||
runtime.inspect(interface.clone(), true).await.unwrap();
|
||||
runtime.inspect(path.into(), true).await.unwrap();
|
||||
interface
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@ fn runtime(
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
async fn interface(r: &WipRuntime, path: &str) -> String {
|
||||
r.discover(path.into(), 0, true).await.unwrap();
|
||||
async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReference {
|
||||
r.tree(path.into(), 0, true).await.unwrap();
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
r.inspect(p.interface.clone(), true).await.unwrap();
|
||||
r.inspect(path.into(), true).await.unwrap();
|
||||
p.interface
|
||||
}
|
||||
async fn call(
|
||||
|
||||
@@ -101,7 +101,8 @@ pub fn mount_workspace_workdir_wip(
|
||||
for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] {
|
||||
let namespace =
|
||||
registry.allocate_namespace(domain.owner(), domain.route().trim_start_matches('/'))?;
|
||||
let interface = format!("yoi.{}/collection/v1", domain.owner());
|
||||
let interface =
|
||||
crate::wip::root_reference(&format!("yoi.{}/collection/v1", domain.owner()));
|
||||
let handler = Arc::new(CollectionHandler {
|
||||
provider: provider.clone(),
|
||||
domain,
|
||||
@@ -130,7 +131,7 @@ pub fn mount_workspace_workdir_wip(
|
||||
handler,
|
||||
})?;
|
||||
}
|
||||
let item_interface = format!("yoi.{}/item/v1", domain.owner());
|
||||
let item_interface = crate::wip::root_reference(&format!("yoi.{}/item/v1", domain.owner()));
|
||||
let resolver = Arc::new(ItemResolver {
|
||||
provider: provider.clone(),
|
||||
domain,
|
||||
@@ -668,7 +669,7 @@ impl WipDynamicItemResolver for ItemResolver {
|
||||
}
|
||||
let raw = self.provider.raw_item(self.domain, &id).ok()?;
|
||||
let projected = self.provider.projected(self.domain, &raw).ok()?;
|
||||
let interface = format!("yoi.{}/item/v1", self.domain.owner());
|
||||
let interface = crate::wip::root_reference(&format!("yoi.{}/item/v1", self.domain.owner()));
|
||||
Some(WipDynamicItem {
|
||||
object: object(&item_path(self.domain, &id), &id, &interface, &projected),
|
||||
handler: Arc::new(ItemHandler {
|
||||
@@ -836,7 +837,12 @@ impl WipOperationHandler for ItemHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn object(path: &str, identity: &str, interface: &str, value: &Json) -> Object {
|
||||
fn object(
|
||||
path: &str,
|
||||
identity: &str,
|
||||
interface: &wip_protocol::InterfaceReference,
|
||||
value: &Json,
|
||||
) -> Object {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(path);
|
||||
digest.update([0]);
|
||||
@@ -844,7 +850,7 @@ fn object(path: &str, identity: &str, interface: &str, value: &Json) -> Object {
|
||||
Object {
|
||||
name: path.rsplit('/').next().unwrap_or_default().into(),
|
||||
description: Some(format!("Workspace authority Object: {identity}")),
|
||||
interfaces: vec![interface.into()],
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(identity.into()),
|
||||
validator: Some(digest.finalize().to_vec()),
|
||||
}
|
||||
@@ -1658,7 +1664,7 @@ pub(crate) mod tests {
|
||||
registry.contribute_operations(WipOperationContribution {
|
||||
route: WORKDIRS.into(),
|
||||
contributor: "another-feature".into(),
|
||||
interface: "yoi.workdir/collection/v1".into(),
|
||||
interface: crate::wip::root_reference("yoi.workdir/collection/v1"),
|
||||
descriptor: collection_descriptor(Domain::Workdir, true),
|
||||
handler: Arc::new(CollectionHandler {
|
||||
provider: provider(Arc::new(CatalogClient::default()), true, true, None),
|
||||
|
||||
@@ -558,14 +558,16 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
description: Some(
|
||||
"Authoritative Merge Request collection through scoped Backend authority".into(),
|
||||
),
|
||||
interfaces: vec![MERGE_REQUEST_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(
|
||||
MERGE_REQUEST_COLLECTION_INTERFACE,
|
||||
)],
|
||||
r#ref: Some("merge-request:collection".into()),
|
||||
validator: Some(merge_request_route_validator(
|
||||
&collection_route,
|
||||
"collection",
|
||||
)),
|
||||
},
|
||||
interface: MERGE_REQUEST_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(MERGE_REQUEST_COLLECTION_INTERFACE),
|
||||
interface_validator: Some(merge_request_descriptor_validator(&collection_descriptor)),
|
||||
descriptor: collection_descriptor,
|
||||
handler: Arc::new(MergeRequestCollectionWipHandler {
|
||||
@@ -584,7 +586,7 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "merge-request:item".into(),
|
||||
interface: MERGE_REQUEST_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE),
|
||||
interface_validator: Some(merge_request_descriptor_validator(&item_descriptor)),
|
||||
descriptor: item_descriptor,
|
||||
resolver: Arc::new(MergeRequestItemResolver {
|
||||
@@ -682,7 +684,7 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.into(),
|
||||
description: Some("Authoritative Merge Request bound to this object route".into()),
|
||||
interfaces: vec![MERGE_REQUEST_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("merge-request:{item_reference}")),
|
||||
validator: Some(merge_request_route_validator(&route, &revision)),
|
||||
},
|
||||
|
||||
@@ -578,11 +578,11 @@ pub fn mount_workspace_http_objective_wip(
|
||||
"Authoritative Objective collection; search and create through Backend authority"
|
||||
.into(),
|
||||
),
|
||||
interfaces: vec![OBJECTIVE_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(OBJECTIVE_COLLECTION_INTERFACE)],
|
||||
r#ref: Some("objective:collection".into()),
|
||||
validator: Some(route_validator(&collection_route, "collection")),
|
||||
},
|
||||
interface: OBJECTIVE_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(OBJECTIVE_COLLECTION_INTERFACE),
|
||||
descriptor: collection_descriptor,
|
||||
interface_validator: Some(collection_validator),
|
||||
handler: collection_handler,
|
||||
@@ -593,7 +593,7 @@ pub fn mount_workspace_http_objective_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "objective:item".into(),
|
||||
interface: OBJECTIVE_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE),
|
||||
descriptor: item_descriptor,
|
||||
interface_validator: Some(item_validator),
|
||||
resolver: Arc::new(ObjectiveItemResolver {
|
||||
@@ -744,7 +744,7 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.to_string(),
|
||||
description: Some("Authoritative Objective bound to this object route".into()),
|
||||
interfaces: vec![OBJECTIVE_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("objective:{item_reference}")),
|
||||
validator: Some(route_validator(&route, &revision)),
|
||||
},
|
||||
|
||||
@@ -1813,11 +1813,11 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
description: Some(
|
||||
"Authoritative Ticket collection through scoped Backend authority".into(),
|
||||
),
|
||||
interfaces: vec![TICKET_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(TICKET_COLLECTION_INTERFACE)],
|
||||
r#ref: Some("ticket:collection".into()),
|
||||
validator: Some(route_validator(&collection_route, "collection")),
|
||||
},
|
||||
interface: TICKET_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(TICKET_COLLECTION_INTERFACE),
|
||||
interface_validator: Some(descriptor_validator(&collection_descriptor)),
|
||||
descriptor: collection_descriptor,
|
||||
handler: Arc::new(TicketCollectionWipHandler {
|
||||
@@ -1836,7 +1836,7 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "ticket:item".into(),
|
||||
interface: TICKET_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(TICKET_ITEM_INTERFACE),
|
||||
interface_validator: Some(descriptor_validator(&item_descriptor)),
|
||||
descriptor: item_descriptor,
|
||||
resolver: Arc::new(TicketItemResolver {
|
||||
@@ -1922,7 +1922,7 @@ impl WipDynamicItemResolver for TicketItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.into(),
|
||||
description: Some("Authoritative Ticket bound to this object route".into()),
|
||||
interfaces: vec![TICKET_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("ticket:{item_reference}")),
|
||||
validator: Some(route_validator(&route, &revision)),
|
||||
},
|
||||
|
||||
@@ -318,7 +318,7 @@ impl FeatureInvocationHandler for WorkspaceConfigFeature {
|
||||
result.access.as_str()
|
||||
),
|
||||
context: Some(format!(
|
||||
"Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Discover structure through WIP; read and change bodies through published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.",
|
||||
"Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Use Tree for indexable structure, Inspect with a known Object path for its complete signatures, and Invoke for published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.",
|
||||
result.access.as_str()
|
||||
)),
|
||||
})
|
||||
|
||||
@@ -185,16 +185,8 @@ async fn observe(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ConfigProvider {
|
||||
fn max_depth(&self) -> u32 {
|
||||
MAX_DEPTH
|
||||
}
|
||||
fn max_nodes(&self) -> usize {
|
||||
MAX_NODES
|
||||
}
|
||||
|
||||
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
impl ConfigProvider {
|
||||
async fn projection_at(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
let source = source_path(path).map_err(protocol)?;
|
||||
let Some(attachment) = visible_metadata(self.backend.current_async().await)? else {
|
||||
return Ok(None);
|
||||
@@ -205,7 +197,7 @@ impl WipSubtreeProvider for ConfigProvider {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut projection = super::wip::attach_projection(&self.feature);
|
||||
projection.interface = contextual_reference(&projection.interface, path);
|
||||
projection.interface = contextual_reference(&projection.interface.name, path);
|
||||
projection.object.interfaces = vec![projection.interface.clone()];
|
||||
return Ok(Some(projection));
|
||||
};
|
||||
@@ -259,6 +251,26 @@ impl WipSubtreeProvider for ConfigProvider {
|
||||
}),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ConfigProvider {
|
||||
fn max_depth(&self) -> u32 {
|
||||
MAX_DEPTH
|
||||
}
|
||||
fn max_nodes(&self) -> usize {
|
||||
MAX_NODES
|
||||
}
|
||||
|
||||
async fn publication(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<crate::wip::WipPublication>, ProtocolError> {
|
||||
self.projection_at(path)
|
||||
.await?
|
||||
.map(crate::wip::WipPublication::self_scoped)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let source = source_path(path).map_err(protocol)?;
|
||||
|
||||
@@ -428,7 +428,7 @@ fn runtime(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeature, WipRunt
|
||||
let runtime = WipRuntime::from_mounts(mounts, "worker-1@workspace-1".into()).unwrap();
|
||||
(feature, runtime)
|
||||
}
|
||||
fn interface(path: &str, attached: bool) -> String {
|
||||
fn interface(path: &str, attached: bool) -> wip_protocol::InterfaceReference {
|
||||
crate::wip::contextual_reference(
|
||||
if attached {
|
||||
"yoi.workspace-config/node/v1"
|
||||
@@ -438,12 +438,9 @@ fn interface(path: &str, attached: bool) -> String {
|
||||
path,
|
||||
)
|
||||
}
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str, attached: bool) {
|
||||
runtime.discover(path.into(), 0, true).await.unwrap();
|
||||
runtime
|
||||
.inspect(interface(path, attached), true)
|
||||
.await
|
||||
.unwrap();
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str, _attached: bool) {
|
||||
runtime.tree(path.into(), 0, true).await.unwrap();
|
||||
runtime.inspect(path.into(), true).await.unwrap();
|
||||
}
|
||||
async fn call(
|
||||
runtime: &WipRuntime,
|
||||
@@ -497,7 +494,7 @@ async fn workspace_config_production_slash_deep_discover_read_edit_and_atomic_im
|
||||
.starts_with("Already attached")
|
||||
);
|
||||
let discovered = runtime
|
||||
.discover(CONTENT_ROOT.into(), 5, true)
|
||||
.tree(CONTENT_ROOT.into(), 5, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
@@ -606,7 +603,7 @@ async fn workspace_config_production_native_attach_create_delete_and_detach_end_
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.discover("/workspace-config/main.dcdl".into(), 0, true)
|
||||
.tree("/workspace-config/main.dcdl".into(), 0, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -633,19 +630,15 @@ async fn workspace_config_production_revocation_ro_cross_workspace_and_cache_iso
|
||||
);
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let descriptor = runtime
|
||||
.inspect(interface(path, true), true)
|
||||
.inspect(path.into(), true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
let descriptor: Json = serde_json::from_str(&descriptor).unwrap();
|
||||
let names = descriptor["descriptor"]["operations"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|op| op["name"].as_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, ["read"]);
|
||||
let signature = descriptor["interfaces"][0]["signature"].as_str().unwrap();
|
||||
assert!(signature.contains("operation read("));
|
||||
assert!(!signature.contains("operation write("));
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"DENIED"}))
|
||||
.await
|
||||
@@ -657,7 +650,7 @@ async fn workspace_config_production_revocation_ro_cross_workspace_and_cache_iso
|
||||
});
|
||||
let (feature, other) = runtime_for_cross(cross);
|
||||
assert!(feature.attach("cross", None).await.is_err());
|
||||
assert!(other.discover(path.into(), 0, false).await.is_err());
|
||||
assert!(other.tree(path.into(), 0, false).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
fn runtime_for_cross(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeature, WipRuntime) {
|
||||
@@ -760,21 +753,16 @@ async fn workspace_config_production_bounds_and_traversal_reject_before_content_
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("initial", None).await.unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.discover(CONTENT_ROOT.into(), 9, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(runtime.tree(CONTENT_ROOT.into(), 9, true).await.is_err());
|
||||
for path in [
|
||||
"/workspace-config/../secret",
|
||||
"/workspace-config//main.dcdl",
|
||||
"/workspace-config/./main.dcdl",
|
||||
] {
|
||||
assert!(runtime.discover(path.into(), 0, true).await.is_err());
|
||||
assert!(runtime.tree(path.into(), 0, true).await.is_err());
|
||||
}
|
||||
let path = format!("/workspace-config/{}", "a".repeat(513));
|
||||
assert!(runtime.discover(path, 0, true).await.is_err());
|
||||
assert!(runtime.tree(path, 0, true).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
@@ -837,7 +825,7 @@ async fn workspace_config_production_cumulative_node_limit_and_edit_growth_are_b
|
||||
// No individual depth-1 response exceeds the cap; the whole Host traversal
|
||||
// must nevertheless stop before exceeding 256 Objects.
|
||||
let error = runtime
|
||||
.discover(CONTENT_ROOT.into(), 4, true)
|
||||
.tree(CONTENT_ROOT.into(), 4, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("ResourceLimitExceeded"));
|
||||
@@ -849,7 +837,7 @@ async fn workspace_config_production_backend_failure_is_not_hidden_as_unauthoriz
|
||||
client.state.lock().unwrap().fail_current = true;
|
||||
let (_, runtime) = runtime(client);
|
||||
let error = runtime
|
||||
.discover(CONTENT_ROOT.into(), 0, true)
|
||||
.tree(CONTENT_ROOT.into(), 0, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let message = error.to_string();
|
||||
@@ -907,16 +895,11 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d
|
||||
let client = Router::new();
|
||||
client.state.lock().unwrap().granted = false;
|
||||
let (_, runtime) = runtime(client.clone());
|
||||
let discovery = runtime.discover("/".into(), 2, true).await.unwrap();
|
||||
let discovery = runtime.tree("/".into(), 2, true).await.unwrap();
|
||||
let discovery: Json = serde_json::from_str(discovery.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(discovery["known_space"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(discovery["known_space"][0]["path"], "/");
|
||||
assert!(
|
||||
runtime
|
||||
.discover(CONTENT_ROOT.into(), 0, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(discovery["tree"]["children"], json!([]));
|
||||
assert_eq!(discovery["tree"]["path"], "/");
|
||||
assert!(runtime.tree(CONTENT_ROOT.into(), 0, true).await.is_err());
|
||||
assert!(
|
||||
client
|
||||
.state
|
||||
|
||||
@@ -93,13 +93,13 @@ pub(super) fn attach_projection(feature: &WorkspaceConfigFeature) -> WipProjecti
|
||||
object: Object {
|
||||
name: ATTACHMENT_ALIAS.into(),
|
||||
description: Some("Logical Workspace configuration attachment entrance".into()),
|
||||
interfaces: vec![interface.into()],
|
||||
interfaces: vec![crate::wip::root_reference(interface)],
|
||||
r#ref: None,
|
||||
// Attaching does not modify config content. The content subtree will
|
||||
// supply its own Backend validators for file/directory Operations.
|
||||
validator: None,
|
||||
},
|
||||
interface: interface.into(),
|
||||
interface: crate::wip::root_reference(interface),
|
||||
descriptor,
|
||||
interface_validator,
|
||||
handler: Arc::new(AttachHandler(feature.clone())),
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
//! Recovery tests use the normal Worker execution and append-log commit gate.
|
||||
use super::*;
|
||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::scheme::{Scheme, openai_responses::OpenAIResponsesScheme};
|
||||
use agen::llm_client::{ClientError, Request, ResponseStream};
|
||||
use agen::tool::{Attachment, ImageAttachment};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
enum RejectionRoute {
|
||||
#[default]
|
||||
Http,
|
||||
SseError,
|
||||
ResponseFailed,
|
||||
}
|
||||
|
||||
const REJECTION_ROUTES: [RejectionRoute; 3] = [
|
||||
RejectionRoute::Http,
|
||||
RejectionRoute::SseError,
|
||||
RejectionRoute::ResponseFailed,
|
||||
];
|
||||
|
||||
struct RetryObservation {
|
||||
store: session_store::FsStore,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
committed: Option<Vec<LogEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RejectImages {
|
||||
route: RejectionRoute,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
retry_observation: Arc<Mutex<Option<RetryObservation>>>,
|
||||
cancel_on_retry: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -21,12 +45,49 @@ impl LlmClient for RejectImages {
|
||||
);
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if has_image {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: Some("invalid_value".into()),
|
||||
message: "The image requires 52150 patches, exceeding the limit of 30000.".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
if matches!(self.route, RejectionRoute::Http) {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: Some("invalid_value".into()),
|
||||
message: "The image requires 52150 patches, exceeding the limit of 30000."
|
||||
.into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
// Exercise the public Responses parser, not a hand-built ErrorEvent.
|
||||
// Started is metadata; no generated block precedes this rejection.
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let mut events = scheme.parse_sse(
|
||||
"response.created",
|
||||
r#"{"response":{"id":"image-response"}}"#,
|
||||
&mut state,
|
||||
)?;
|
||||
let (event_type, data) = match self.route {
|
||||
RejectionRoute::Http => unreachable!(),
|
||||
RejectionRoute::SseError => (
|
||||
"error",
|
||||
r#"{"type":"error","code":"invalid_value","message":"The image requires 52150 patches, exceeding the limit of 30000."}"#,
|
||||
),
|
||||
RejectionRoute::ResponseFailed => (
|
||||
"response.failed",
|
||||
r#"{"type":"response.failed","response":{"id":"image-response","status":"failed","error":{"code":"invalid_value","message":"The image requires 52150 patches, exceeding the limit of 30000."}}}"#,
|
||||
),
|
||||
};
|
||||
events.extend(scheme.parse_sse(event_type, data, &mut state)?);
|
||||
return Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))));
|
||||
}
|
||||
// Capture committed filesystem state at the retry boundary, before any
|
||||
// successful output or cancellation can cause a later append.
|
||||
if let Some(observation) = self.retry_observation.lock().unwrap().as_mut()
|
||||
&& observation.committed.is_none()
|
||||
{
|
||||
observation.committed = Some(
|
||||
observation
|
||||
.store
|
||||
.read_all(observation.session_id, observation.segment_id)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
if let Some(cancel) = self.cancel_on_retry.lock().unwrap().take() {
|
||||
cancel.try_send(()).unwrap();
|
||||
@@ -43,7 +104,9 @@ impl LlmClient for RejectImages {
|
||||
}
|
||||
}
|
||||
|
||||
async fn image_worker() -> (
|
||||
async fn image_worker(
|
||||
route: RejectionRoute,
|
||||
) -> (
|
||||
tempfile::TempDir,
|
||||
Worker<RejectImages, session_store::FsStore>,
|
||||
RejectImages,
|
||||
@@ -63,7 +126,10 @@ allow = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let client = RejectImages::default();
|
||||
let client = RejectImages {
|
||||
route,
|
||||
..RejectImages::default()
|
||||
};
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()),
|
||||
@@ -90,24 +156,81 @@ allow = []
|
||||
),
|
||||
]);
|
||||
worker.wire_history_persistence();
|
||||
*client.retry_observation.lock().unwrap() = Some(RetryObservation {
|
||||
// Reopen the store so this observes filesystem records, not engine state.
|
||||
store: session_store::FsStore::new(worker.store.root_dir()).unwrap(),
|
||||
session_id: worker.session_id(),
|
||||
segment_id: worker.segment_id(),
|
||||
committed: None,
|
||||
});
|
||||
(dir, worker, client)
|
||||
}
|
||||
fn rejected(item: &Item) -> bool {
|
||||
matches!(item, Item::ToolResult { call_id, is_error: true, attachments, .. } if call_id == "image-call" && attachments.is_empty())
|
||||
}
|
||||
|
||||
fn assert_correction_committed_before_retry(client: &RejectImages) {
|
||||
let observation = client.retry_observation.lock().unwrap();
|
||||
let observation = observation.as_ref().unwrap();
|
||||
let raw = observation
|
||||
.committed
|
||||
.as_ref()
|
||||
.expect("the retry must observe the committed correction");
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{:?}: correction must already be appended at retry",
|
||||
client.route
|
||||
);
|
||||
let restored =
|
||||
restore_history_entries(observation.session_id, observation.segment_id, raw).unwrap();
|
||||
let corrected = restored
|
||||
.iter()
|
||||
.find(|entry| rejected(&entry.item))
|
||||
.expect("committed retry history must contain the attachment-free error");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2, "{:?}", client.route);
|
||||
assert!(requests[0].items.iter().any(
|
||||
|item| matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty())
|
||||
));
|
||||
assert_eq!(
|
||||
requests[1].items.iter().find(|item| rejected(item)),
|
||||
Some(&corrected.item),
|
||||
"{:?}: retry must use the durable corrected result",
|
||||
client.route
|
||||
);
|
||||
assert!(requests[1].items.iter().all(
|
||||
|item| !matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty())
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_correction_is_committed_before_retry_and_survives_restore_and_reseed() {
|
||||
let (_dir, mut worker, client) = image_worker().await;
|
||||
for route in REJECTION_ROUTES {
|
||||
assert_image_correction_survives_restore_and_reseed(route).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_image_correction_survives_restore_and_reseed(route: RejectionRoute) {
|
||||
let (_dir, mut worker, client) = image_worker(route).await;
|
||||
assert_eq!(
|
||||
worker.run_text("continue").await.unwrap(),
|
||||
WorkerRunResult::Finished
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
assert_correction_committed_before_retry(&client);
|
||||
let raw = worker
|
||||
.store
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{route:?}: recovery must append exactly one correction"
|
||||
);
|
||||
let correction = raw
|
||||
.iter()
|
||||
.find(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
@@ -177,8 +300,17 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let reseeded_segment_id = session_store::new_segment_id();
|
||||
worker
|
||||
.store
|
||||
.create_segment(worker.session_id(), reseeded_segment_id, &[seed])
|
||||
.unwrap();
|
||||
let reopened_store = session_store::FsStore::new(worker.store.root_dir()).unwrap();
|
||||
let reseeded_raw = reopened_store
|
||||
.read_all(worker.session_id(), reseeded_segment_id)
|
||||
.unwrap();
|
||||
let restored_again =
|
||||
restore_history_entries(worker.session_id(), worker.segment_id(), &[seed]).unwrap();
|
||||
restore_history_entries(worker.session_id(), reseeded_segment_id, &reseeded_raw).unwrap();
|
||||
assert_eq!(
|
||||
restored_again
|
||||
.iter()
|
||||
@@ -186,6 +318,12 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let effective_correction = restored_again
|
||||
.iter()
|
||||
.find(|entry| rejected(&entry.item))
|
||||
.unwrap();
|
||||
assert_eq!(effective_correction.annotation, original.metadata);
|
||||
let expected_result = effective_correction.item.clone();
|
||||
let mut history = History::from_entries(restored_again);
|
||||
let out = Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone())
|
||||
.run_with_annotation(&mut history, "continue after restart", &mut |_| {
|
||||
@@ -193,22 +331,41 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(out.result, EngineRunExit::Finished));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 3);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3, "{route:?}");
|
||||
assert_eq!(
|
||||
requests[2].items.iter().find(|item| rejected(item)),
|
||||
Some(&expected_result),
|
||||
"{route:?}: next request must retain the restored correction"
|
||||
);
|
||||
assert!(requests[2].items.iter().all(
|
||||
|item| !matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty())
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_after_image_correction_does_not_rollback_the_correction() {
|
||||
let (_dir, mut worker, client) = image_worker().await;
|
||||
for route in REJECTION_ROUTES {
|
||||
assert_cancellation_preserves_image_correction(route).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_cancellation_preserves_image_correction(route: RejectionRoute) {
|
||||
let (_dir, mut worker, client) = image_worker(route).await;
|
||||
*client.cancel_on_retry.lock().unwrap() = Some(worker.engine().cancel_sender());
|
||||
let result = worker.run_text("continue").await.unwrap();
|
||||
assert_eq!(result, WorkerRunResult::Cancelled);
|
||||
assert_eq!(result, WorkerRunResult::Cancelled, "{route:?}");
|
||||
assert_correction_committed_before_retry(&client);
|
||||
let raw = worker
|
||||
.store
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
assert!(
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.any(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{route:?}: cancellation must preserve exactly one correction"
|
||||
);
|
||||
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
+656
-809
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
//! Regression coverage for MR P1_CURRENT_ANCESTOR_SCOPE, through real adapters.
|
||||
use super::*;
|
||||
|
||||
struct State {
|
||||
visible: bool,
|
||||
identity: Option<String>,
|
||||
name: String,
|
||||
generation: usize,
|
||||
target_validator: Option<Vec<u8>>,
|
||||
lookups: Vec<String>,
|
||||
}
|
||||
|
||||
struct AncestorProvider {
|
||||
scope_path: String,
|
||||
target_path: String,
|
||||
state: Arc<Mutex<State>>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
blocked: bool,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl AncestorProvider {
|
||||
fn scope_projection(&self, state: &State) -> WipProjection {
|
||||
let mut p = projection(
|
||||
&self.scope_path,
|
||||
&self.scope_path,
|
||||
&state.name,
|
||||
self.calls.clone(),
|
||||
);
|
||||
p.object.r#ref = state.identity.clone();
|
||||
self.version(p, state.generation)
|
||||
}
|
||||
|
||||
fn version(&self, mut p: WipProjection, generation: usize) -> WipProjection {
|
||||
p.descriptor.documentation = Some(Documentation {
|
||||
summary: format!("Published generation {generation}"),
|
||||
details: None,
|
||||
});
|
||||
if self.blocked {
|
||||
p.interface_validator = Some(vec![generation as u8]);
|
||||
if generation != 0 {
|
||||
p.descriptor.operations[0].returns.r#type = TypeExpr::Integer;
|
||||
}
|
||||
p.handler = Arc::new(FrozenResultHandler {
|
||||
generation,
|
||||
started: self.started.clone(),
|
||||
release: self.release.clone(),
|
||||
calls: self.calls.clone(),
|
||||
});
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for AncestorProvider {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
// One lock captures target, scope lifetime, descriptor/validator and
|
||||
// handler. No separate scope lookup can splice new identity onto old data.
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.lookups.push(path.into());
|
||||
if path == self.scope_path {
|
||||
return if state.visible {
|
||||
WipPublication::self_scoped(self.scope_projection(&state)).map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
}
|
||||
if path == "/github" && self.scope_path != path {
|
||||
return if state.visible {
|
||||
WipPublication::self_scoped(projection(path, path, "root", self.calls.clone()))
|
||||
.map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
}
|
||||
if path != self.target_path {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut target = projection(path, &self.scope_path, &state.name, self.calls.clone());
|
||||
target.object.validator = state.target_validator.clone();
|
||||
let target = self.version(target, state.generation);
|
||||
// Retain a target artifact after deleting the scope to exercise Host
|
||||
// fail-closed behavior, not merely a provider that already hides orphans.
|
||||
let scope = state.visible.then(|| self.scope_projection(&state).object);
|
||||
Ok(Some(WipPublication {
|
||||
projection: target,
|
||||
scope,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
if !state.visible {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if path == self.scope_path {
|
||||
return Ok(vec![self.target_path.clone()]);
|
||||
}
|
||||
if path == "/github" && path != self.scope_path {
|
||||
return Ok(vec![self.scope_path.clone()]);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(scope: &str, with_ref: bool, blocked: bool) -> (WipRuntime, Arc<AncestorProvider>) {
|
||||
let provider = Arc::new(AncestorProvider {
|
||||
scope_path: scope.into(),
|
||||
target_path: if scope == "/" {
|
||||
"/item".into()
|
||||
} else {
|
||||
format!("{scope}/item")
|
||||
},
|
||||
state: Arc::new(Mutex::new(State {
|
||||
visible: true,
|
||||
identity: with_ref.then(|| "live-scope".into()),
|
||||
name: "read".into(),
|
||||
generation: 0,
|
||||
target_validator: None,
|
||||
lookups: vec![],
|
||||
})),
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
blocked,
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
release: Arc::new(tokio::sync::Notify::new()),
|
||||
});
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
mounts.allocate_namespace("scope", "github").unwrap();
|
||||
let mount_root = if scope == "/" { "/" } else { "/github" };
|
||||
let mut placeholder = projection(mount_root, mount_root, "read", provider.calls.clone());
|
||||
placeholder.object.r#ref = Some("registration-placeholder".into());
|
||||
mounts.mount(placeholder).unwrap();
|
||||
mounts
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: mount_root.into(),
|
||||
provider: provider.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
(
|
||||
WipRuntime::from_mounts(mounts, "ancestor-test".into()).unwrap(),
|
||||
provider,
|
||||
)
|
||||
}
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_root_and_intermediate_ancestors_use_one_current_publication_not_placeholder() {
|
||||
for scope in ["/", "/github", "/github/team"] {
|
||||
for with_ref in [true, false] {
|
||||
let (runtime, provider) = fixture(scope, with_ref, false);
|
||||
let selected = reference(scope, "read");
|
||||
let fetched = runtime.host.fetch_interface_live(&selected).await.unwrap();
|
||||
assert_eq!(fetched.scope_ref, with_ref.then(|| "live-scope".into()));
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
provider.state.lock().unwrap().lookups.clear();
|
||||
runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
provider.state.lock().unwrap().lookups,
|
||||
[provider.target_path.clone()]
|
||||
);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let call = state
|
||||
.client
|
||||
.call_history(&state.session)
|
||||
.unwrap()
|
||||
.back()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
call.context.request().interface.scope_ref,
|
||||
fetched.scope_ref
|
||||
);
|
||||
drop(state);
|
||||
// A specified ref must still match even when the current scope has none.
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
&provider.target_path,
|
||||
json!({"scope":scope,"name":"read"}),
|
||||
None,
|
||||
Some("wrong")
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletion_without_prior_scope_inspect_rejects_cached_invoke_and_orphan_observation() {
|
||||
for scope in ["/github", "/github/team"] {
|
||||
for with_ref in [true, false] {
|
||||
let (runtime, provider) = fixture(scope, with_ref, false);
|
||||
let selected = reference(scope, "read");
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
provider.state.lock().unwrap().visible = false;
|
||||
let failed = runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(failed.to_string().contains("InterfaceMismatch"), "{failed}");
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
assert!(
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.tree(provider.target_path.clone(), 0, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&selected)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let root = runtime.tree("/".into(), 3, true).await.unwrap();
|
||||
assert!(!root.content.unwrap().contains("/github"));
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn republishing_scope_never_restores_old_registration_even_without_refs() {
|
||||
for with_ref in [true, false] {
|
||||
let (runtime, provider) = fixture("/github", with_ref, false);
|
||||
let old = reference("/github", "read");
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
provider.state.lock().unwrap().visible = false;
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
{
|
||||
let mut state = provider.state.lock().unwrap();
|
||||
state.visible = true;
|
||||
state.identity = with_ref.then(|| "replacement".into());
|
||||
state.name = "new".into();
|
||||
state.generation = 1;
|
||||
}
|
||||
// The old root registration and cached old descriptor both still exist.
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
old,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
let new = reference("/github", "new");
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&new)
|
||||
.await
|
||||
.unwrap()
|
||||
.scope_ref,
|
||||
with_ref.then(|| "replacement".into())
|
||||
);
|
||||
runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
new,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_ancestor_preserves_wire_shape_target_and_object_validator_precedence() {
|
||||
let (runtime, provider) = fixture("/github", false, false);
|
||||
{
|
||||
let mut state = provider.state.lock().unwrap();
|
||||
state.visible = false;
|
||||
state.target_validator = Some(vec![1]);
|
||||
}
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request("/missing", json!("old"), None, None)
|
||||
)
|
||||
.await,
|
||||
"invalid_request"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/missing",
|
||||
json!({"scope":"/github","name":"read"}),
|
||||
Some("AA=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"not_found"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
&provider.target_path,
|
||||
json!({"scope":"/github","name":"read"}),
|
||||
Some("AA=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"validator_mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
&provider.target_path,
|
||||
json!({"scope":"/github","name":"read"}),
|
||||
Some("AQ=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ancestor_replacement_during_in_flight_call_keeps_the_original_descriptor_and_handler() {
|
||||
let (runtime, provider) = fixture("/github/team", true, true);
|
||||
let selected = reference("/github/team", "read");
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let pending = runtime.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
);
|
||||
let update = async {
|
||||
provider.started.notified().await;
|
||||
{
|
||||
let mut state = provider.state.lock().unwrap();
|
||||
state.identity = Some("replacement".into());
|
||||
state.generation = 1;
|
||||
}
|
||||
let updated = runtime
|
||||
.inspect(provider.target_path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(updated.content.unwrap().contains("integer"));
|
||||
provider.release.notify_one();
|
||||
};
|
||||
let (old, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
tokio::join!(pending, update)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old.unwrap().content.as_deref(), Some("\"old result\""));
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let history = state.client.call_history(&state.session).unwrap();
|
||||
let old = history.back().unwrap();
|
||||
assert_eq!(
|
||||
old.context.request().interface.scope_ref.as_deref(),
|
||||
Some("live-scope")
|
||||
);
|
||||
assert_eq!(
|
||||
old.context.descriptor().operations[0].returns.r#type,
|
||||
TypeExpr::String
|
||||
);
|
||||
drop(state);
|
||||
let new = runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(new.content.as_deref(), Some("2"));
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_name_scope_replacement_or_loss_of_ref_rejects_old_metadata_without_replay() {
|
||||
for identity in [Some("replacement"), None] {
|
||||
let (runtime, provider) = fixture("/github/team", true, false);
|
||||
let selected = reference("/github/team", "read");
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let mut state = provider.state.lock().unwrap();
|
||||
state.identity = identity.map(Into::into);
|
||||
if identity.is_some() {
|
||||
state.generation = 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
&provider.target_path,
|
||||
json!({"scope":"/github/team","name":"read"}),
|
||||
None,
|
||||
Some("live-scope")
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
let failed = runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(failed.to_string().contains("InterfaceMismatch"), "{failed}");
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
|
||||
let fetched = runtime.host.fetch_interface_live(&selected).await.unwrap();
|
||||
assert_eq!(fetched.scope_ref.as_deref(), identity);
|
||||
assert_eq!(
|
||||
fetched.descriptor.documentation.unwrap().summary,
|
||||
if identity.is_some() {
|
||||
"Published generation 1"
|
||||
} else {
|
||||
"Published generation 0"
|
||||
}
|
||||
);
|
||||
runtime
|
||||
.inspect(provider.target_path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
runtime
|
||||
.invoke(
|
||||
provider.target_path.clone(),
|
||||
selected,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let call = state
|
||||
.client
|
||||
.call_history(&state.session)
|
||||
.unwrap()
|
||||
.back()
|
||||
.unwrap();
|
||||
// Omitted scope_ref is never filled from the placeholder or other observations.
|
||||
assert_eq!(
|
||||
call.context.request().interface.scope_ref.as_deref(),
|
||||
identity
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
||||
//! Scope publication and precondition tests through the actual Yoi HTTP adapter.
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
|
||||
#[path = "ancestor_tests.rs"]
|
||||
mod ancestor_tests;
|
||||
|
||||
struct ReadHandler(Arc<AtomicUsize>);
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for ReadHandler {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(WipOperationOutput::native(Value::String("value".into())))
|
||||
}
|
||||
}
|
||||
|
||||
fn projection(path: &str, scope: &str, name: &str, calls: Arc<AtomicUsize>) -> WipProjection {
|
||||
let interface = InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
};
|
||||
WipProjection {
|
||||
route: path.into(),
|
||||
capability: "scope:object".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
description: None,
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(format!("object:{path}")),
|
||||
validator: None,
|
||||
},
|
||||
interface,
|
||||
descriptor: InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "read".into(),
|
||||
documentation: None,
|
||||
parameters: vec![],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
},
|
||||
}],
|
||||
},
|
||||
interface_validator: None,
|
||||
handler: Arc::new(ReadHandler(calls)),
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_request(
|
||||
path: &str,
|
||||
reference: Json,
|
||||
object_validator: Option<&str>,
|
||||
scope_ref: Option<&str>,
|
||||
) -> Request<Vec<u8>> {
|
||||
let mut target = json!({"path": path});
|
||||
if let Some(v) = object_validator {
|
||||
target["validator"] = json!(v);
|
||||
}
|
||||
let mut interface = json!({"reference": reference});
|
||||
if let Some(v) = scope_ref {
|
||||
interface["scope_ref"] = json!(v);
|
||||
}
|
||||
Request::builder()
|
||||
.body(
|
||||
serde_json::to_vec(
|
||||
&json!({"target":target,"interface":interface,"operation":"read","arguments":{}}),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn rejected_code(runtime: &WipRuntime, request: Request<Vec<u8>>) -> String {
|
||||
let (response, passthrough, outcome) = runtime
|
||||
.dispatch_call(&request, ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(passthrough.is_none());
|
||||
assert_eq!(outcome, WipAuditOutcome::Rejected);
|
||||
let json: Json = serde_json::from_slice(response.body()).unwrap();
|
||||
json["error"]["code"].as_str().unwrap().into()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adapter_preconditions_preserve_shape_target_validator_and_scope_precedence() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
let mut p = projection("/github/item", "/github/item", "read", calls.clone());
|
||||
p.object.validator = Some(vec![1]);
|
||||
registry.mount(p).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request("/missing", json!("legacy"), None, None)
|
||||
)
|
||||
.await,
|
||||
"invalid_request"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/missing",
|
||||
json!({"scope":"/sibling","name":"read"}),
|
||||
Some("AA=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"not_found"
|
||||
);
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/github/item",
|
||||
json!({"scope":"/sibling","name":"read"}),
|
||||
Some("AA=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"validator_mismatch"
|
||||
);
|
||||
for scope in ["/sibling", "/github/item/child", "/github-extra"] {
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/github/item",
|
||||
json!({"scope":scope,"name":"read"}),
|
||||
Some("AQ=="),
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/github/item",
|
||||
json!({"scope":"/github/item","name":"read"}),
|
||||
Some("AQ=="),
|
||||
Some("old")
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_provider_root_and_self_scope_inspect_invoke_preserve_optional_refs() {
|
||||
for path in ["/", "/github"] {
|
||||
for with_ref in [true, false] {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
let mut p = projection(path, path, "read", calls.clone());
|
||||
if !with_ref {
|
||||
p.object.r#ref = None;
|
||||
}
|
||||
let reference = p.interface.clone();
|
||||
registry.mount(p).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
let fetched = runtime.host.fetch_interface_live(&reference).await.unwrap();
|
||||
assert_eq!(
|
||||
fetched.scope_ref,
|
||||
with_ref.then(|| format!("object:{path}"))
|
||||
);
|
||||
runtime.inspect(path.into(), false).await.unwrap();
|
||||
runtime
|
||||
.invoke(
|
||||
path.into(),
|
||||
reference.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let history = state.client.call_history(&state.session).unwrap();
|
||||
assert_eq!(
|
||||
history
|
||||
.back()
|
||||
.unwrap()
|
||||
.context
|
||||
.request()
|
||||
.interface
|
||||
.scope_ref,
|
||||
fetched.scope_ref
|
||||
);
|
||||
drop(state);
|
||||
if !with_ref {
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
path,
|
||||
json!({"scope":path,"name":"read"}),
|
||||
None,
|
||||
Some("invented")
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_interfaces_same_operation_are_inspected_and_dispatched_without_flattening() {
|
||||
let a = Arc::new(AtomicUsize::new(0));
|
||||
let b = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
let owner = projection("/github", "/", "same", a.clone());
|
||||
let other = projection("/github", "/github", "same", b.clone());
|
||||
let refs = [owner.interface.clone(), other.interface.clone()];
|
||||
registry.mount(owner).unwrap();
|
||||
registry.mount_interface(other).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
let inspected = runtime.inspect("/github".into(), false).await.unwrap();
|
||||
let inspected: Json = serde_json::from_str(inspected.content.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(inspected["interfaces"].as_array().unwrap().len(), 2);
|
||||
for reference in refs {
|
||||
runtime
|
||||
.invoke(
|
||||
"/github".into(),
|
||||
reference,
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(a.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(b.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
struct Publication {
|
||||
visible: Arc<AtomicBool>,
|
||||
identity: Arc<Mutex<Option<String>>>,
|
||||
validator: Arc<Mutex<Option<Vec<u8>>>>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
impl WipDynamicItemResolver for Publication {
|
||||
fn contextual_interface(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn resolve(&self, item: &str) -> Option<WipDynamicItem> {
|
||||
if item != "item" || !self.visible.load(Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
let mut p = projection("/github/item", "/", "read", self.calls.clone());
|
||||
p.object.r#ref = self.identity.lock().unwrap().clone();
|
||||
p.object.validator = self.validator.lock().unwrap().clone();
|
||||
Some(WipDynamicItem {
|
||||
object: p.object,
|
||||
handler: p.handler,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_self_scope_fetch_does_not_attach_a_new_ref_to_an_old_target_snapshot() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let visible = Arc::new(AtomicBool::new(true));
|
||||
let identity = Arc::new(Mutex::new(Some("first".into())));
|
||||
let validator = Arc::new(Mutex::new(None));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
registry
|
||||
.mount(projection("/github", "/", "collection", calls.clone()))
|
||||
.unwrap();
|
||||
let p = projection("/github/item", "/", "read", calls.clone());
|
||||
registry
|
||||
.mount_dynamic(WipDynamicMount {
|
||||
collection_route: "/github".into(),
|
||||
capability: "scope:item".into(),
|
||||
interface: p.interface,
|
||||
descriptor: p.descriptor,
|
||||
interface_validator: None,
|
||||
resolver: Arc::new(Publication {
|
||||
visible: visible.clone(),
|
||||
identity: identity.clone(),
|
||||
validator: validator.clone(),
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
let reference = contextual_reference("read", "/github/item");
|
||||
runtime.inspect("/github/item".into(), false).await.unwrap();
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&reference)
|
||||
.await
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("first")
|
||||
);
|
||||
// Ordinary content-state updates do not change the scope Object identity.
|
||||
*validator.lock().unwrap() = Some(vec![2]);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.host
|
||||
.fetch_interface_live(&reference)
|
||||
.await
|
||||
.unwrap()
|
||||
.scope_ref
|
||||
.as_deref(),
|
||||
Some("first")
|
||||
);
|
||||
*validator.lock().unwrap() = None;
|
||||
*identity.lock().unwrap() = Some("second".into());
|
||||
let error = runtime
|
||||
.invoke(
|
||||
"/github/item".into(),
|
||||
reference.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("InterfaceMismatch"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
// Both target and Interface are invalidated; retrievals are not reexecution.
|
||||
let state = runtime.state.lock().unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.client
|
||||
.object(&state.session, "/github/item")
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.client
|
||||
.interface(&state.session, &reference)
|
||||
.unwrap()
|
||||
.state,
|
||||
ObservationState::Stale
|
||||
);
|
||||
drop(state);
|
||||
runtime
|
||||
.invoke(
|
||||
"/github/item".into(),
|
||||
reference.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
visible.store(false, Ordering::SeqCst);
|
||||
assert!(runtime.host.fetch_interface_live(&reference).await.is_err());
|
||||
assert!(runtime.inspect("/github/item".into(), true).await.is_err());
|
||||
let state = runtime.state.lock().unwrap();
|
||||
assert!(state.client.interface(&state.session, &reference).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supplied_validators_require_current_values_and_required_object_validator_is_late() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
registry
|
||||
.mount(projection("/github", "/github", "read", calls.clone()))
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
let reference = json!({"scope":"/github","name":"read"});
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request("/github", reference.clone(), Some("AQ=="), None)
|
||||
)
|
||||
.await,
|
||||
"validator_mismatch"
|
||||
);
|
||||
let mut request = metadata_request("/github", reference, None, None);
|
||||
let mut body: Json = serde_json::from_slice(request.body()).unwrap();
|
||||
body["interface"]["validator"] = json!("AQ==");
|
||||
*request.body_mut() = serde_json::to_vec(&body).unwrap();
|
||||
assert_eq!(
|
||||
rejected_code(&runtime, request).await,
|
||||
"interface_validator_mismatch"
|
||||
);
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
let mut p = projection("/github", "/github", "read", calls.clone());
|
||||
p.object.validator = Some(vec![1]);
|
||||
registry.mount(p).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
assert_eq!(
|
||||
rejected_code(
|
||||
&runtime,
|
||||
metadata_request(
|
||||
"/github",
|
||||
json!({"scope":"/wrong","name":"read"}),
|
||||
None,
|
||||
None
|
||||
)
|
||||
)
|
||||
.await,
|
||||
"interface_mismatch"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
struct ChangingProvider {
|
||||
generation: Arc<AtomicUsize>,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
struct FrozenResultHandler {
|
||||
generation: usize,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for FrozenResultHandler {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.generation == 0 {
|
||||
self.started.notify_one();
|
||||
self.release.notified().await;
|
||||
Ok(WipOperationOutput::native(Value::String(
|
||||
"old result".into(),
|
||||
)))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(Value::Integer(2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ChangingProvider {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
if path != "/github" {
|
||||
return Ok(None);
|
||||
}
|
||||
let generation = self.generation.load(Ordering::SeqCst);
|
||||
let mut p = projection(path, path, "read", self.calls.clone());
|
||||
p.interface_validator = Some(vec![generation as u8]);
|
||||
if generation != 0 {
|
||||
p.descriptor.operations[0].returns.r#type = TypeExpr::Integer;
|
||||
}
|
||||
p.handler = Arc::new(FrozenResultHandler {
|
||||
generation,
|
||||
started: self.started.clone(),
|
||||
release: self.release.clone(),
|
||||
calls: self.calls.clone(),
|
||||
});
|
||||
WipPublication::self_scoped(p).map(Some)
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_flight_result_uses_frozen_host_and_client_descriptors_after_refresh() {
|
||||
let generation = Arc::new(AtomicUsize::new(0));
|
||||
let started = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("scope", "github").unwrap();
|
||||
registry
|
||||
.mount(projection("/github", "/github", "read", calls.clone()))
|
||||
.unwrap();
|
||||
registry
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: "/github".into(),
|
||||
provider: Arc::new(ChangingProvider {
|
||||
generation: generation.clone(),
|
||||
started: started.clone(),
|
||||
release: release.clone(),
|
||||
calls: calls.clone(),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
|
||||
runtime.inspect("/github".into(), false).await.unwrap();
|
||||
let pending = runtime.invoke(
|
||||
"/github".into(),
|
||||
contextual_reference("read", "/github"),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
);
|
||||
let update = async {
|
||||
started.notified().await;
|
||||
generation.store(1, Ordering::SeqCst);
|
||||
let output = runtime.inspect("/github".into(), true).await.unwrap();
|
||||
assert!(output.content.unwrap().contains("integer"));
|
||||
release.notify_one();
|
||||
};
|
||||
let (old_result, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
tokio::join!(pending, update)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
old_result.unwrap().content.as_deref(),
|
||||
Some("\"old result\"")
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let record = state
|
||||
.client
|
||||
.call_history(&state.session)
|
||||
.unwrap()
|
||||
.back()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.context.descriptor().operations[0].returns.r#type,
|
||||
TypeExpr::String
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.client
|
||||
.interface(&state.session, &contextual_reference("read", "/github"))
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.operations[0]
|
||||
.returns
|
||||
.r#type,
|
||||
TypeExpr::Integer
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,8 @@ pub enum Error {
|
||||
NotFound,
|
||||
#[error("Drive revision, name, or request identity conflict")]
|
||||
Conflict,
|
||||
#[error("Drive access denied")]
|
||||
Denied,
|
||||
#[error("Workspace Drive is fenced for deletion")]
|
||||
Fenced,
|
||||
#[error("Drive storage failure: {0}")]
|
||||
@@ -174,8 +176,11 @@ pub struct CollectionPage {
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
type Authorizer = dyn Fn(&Connection, bool) -> Result<()> + Send + Sync;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Drive {
|
||||
authorizer: Option<Arc<Authorizer>>,
|
||||
database: FeatureDatabase,
|
||||
blobs: Arc<blob::BlobStore>,
|
||||
workspace_id: String,
|
||||
@@ -232,6 +237,7 @@ impl Drive {
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Self {
|
||||
authorizer: None,
|
||||
database,
|
||||
blobs,
|
||||
workspace_id,
|
||||
@@ -278,8 +284,36 @@ impl Drive {
|
||||
})?;
|
||||
Ok(drive)
|
||||
}
|
||||
/// Bind immutable caller authority to this handle and its clones. The callback
|
||||
/// runs within every client operation's IMMEDIATE transaction, never at bind
|
||||
/// time; attached authority stays locked through blob I/O and publication.
|
||||
/// Host maintenance must use an unbound handle.
|
||||
pub fn with_authorizer<F>(&self, authorizer: F) -> Self
|
||||
where
|
||||
F: Fn(&Connection, bool) -> Result<()> + Send + Sync + 'static,
|
||||
{
|
||||
Self {
|
||||
authorizer: Some(Arc::new(authorizer)),
|
||||
..self.clone()
|
||||
}
|
||||
}
|
||||
fn authorize(&self, connection: &Connection, write: bool) -> Result<()> {
|
||||
match &self.authorizer {
|
||||
Some(authorizer) => authorizer(connection, write),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
/// Early admission check, not a lease: every subsequent operation still
|
||||
/// invokes its authorizer in that operation's own transaction.
|
||||
pub fn check_authorized(&self, write: bool) -> Result<()> {
|
||||
self.database.try_transaction(|tx| {
|
||||
self.authorize(tx, write)?;
|
||||
accepting(tx)
|
||||
})
|
||||
}
|
||||
pub fn root(&self) -> Result<Node> {
|
||||
self.database.try_with_connection(|c| {
|
||||
self.database.try_transaction(|c| {
|
||||
self.authorize(c, false)?;
|
||||
let id = c.query_row(
|
||||
"SELECT id FROM drive_nodes WHERE parent_id IS NULL",
|
||||
[],
|
||||
@@ -289,7 +323,10 @@ impl Drive {
|
||||
})
|
||||
}
|
||||
pub fn metadata(&self, id: NodeId) -> Result<Node> {
|
||||
self.database.try_with_connection(|c| node(c, id))
|
||||
self.database.try_transaction(|c| {
|
||||
self.authorize(c, false)?;
|
||||
node(c, id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Keyset pages are ordered by stable ID, not name. Concurrent mutations may
|
||||
@@ -297,6 +334,7 @@ impl Drive {
|
||||
pub fn list(&self, parent: NodeId, after: Option<NodeId>, limit: usize) -> Result<Page> {
|
||||
validate_limit(limit, MAX_PAGE)?;
|
||||
self.database.try_transaction(|tx| {
|
||||
self.authorize(tx, false)?;
|
||||
directory(tx, parent)?;
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT id FROM drive_nodes WHERE parent_id=?1 AND id>?2 ORDER BY id LIMIT ?3",
|
||||
@@ -320,7 +358,8 @@ impl Drive {
|
||||
|
||||
pub fn request_status(&self, request_id: &str) -> Result<RequestStatus> {
|
||||
validate_identity(request_id, "request_id", 128)?;
|
||||
self.database.try_with_connection(|c| {
|
||||
self.database.try_transaction(|c| {
|
||||
self.authorize(c, false)?;
|
||||
let result = c
|
||||
.query_row(
|
||||
"SELECT result_json FROM drive_receipts WHERE request_id=?1",
|
||||
@@ -354,6 +393,7 @@ impl Drive {
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
self.database.try_transaction(|tx| {
|
||||
self.authorize(tx, true)?;
|
||||
if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1",
|
||||
[request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? {
|
||||
if old_fingerprint != fingerprint { return Err(Error::Conflict); }
|
||||
@@ -419,6 +459,7 @@ impl Drive {
|
||||
) -> Result<ReadChunk> {
|
||||
validate_limit(length, MAX_READ_BYTES)?;
|
||||
self.database.try_transaction(|tx| {
|
||||
self.authorize(tx, false)?;
|
||||
accepting(tx)?;
|
||||
let selected = node(tx, id)?;
|
||||
if selected.revision != revision {
|
||||
@@ -488,6 +529,7 @@ impl Drive {
|
||||
validate_identity(query, "query", 256)?;
|
||||
validate_limit(budget, MAX_SEARCH_NODES)?;
|
||||
self.database.try_transaction(|tx| {
|
||||
self.authorize(tx, false)?;
|
||||
accepting(tx)?;
|
||||
let mut stmt = tx.prepare("SELECT id FROM drive_nodes WHERE parent_id IS NOT NULL AND id>?1 ORDER BY id LIMIT ?2")?;
|
||||
let ids = stmt.query_map(params![after.map_or(0,|v|v.0),budget+1],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||
|
||||
@@ -299,3 +299,183 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_fixture(t: &tempfile::TempDir) -> (Drive, Connection) {
|
||||
let authority = t.path().join("server.db");
|
||||
let server = Connection::open(&authority).unwrap();
|
||||
feature_storage::configure_connection(&server).unwrap();
|
||||
server
|
||||
.execute_batch(
|
||||
"CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY,state TEXT NOT NULL);
|
||||
INSERT INTO workspaces VALUES ('ws','active');
|
||||
CREATE TABLE grants(write_access INTEGER NOT NULL,revoked INTEGER NOT NULL);
|
||||
INSERT INTO grants VALUES (1,0);",
|
||||
)
|
||||
.unwrap();
|
||||
let storage = feature_storage::FeatureStorage::new(t.path().join("metadata"))
|
||||
.workspace("ws")
|
||||
.unwrap();
|
||||
let registration = Drive::register(&storage).unwrap();
|
||||
(
|
||||
Drive::open_with_workspace_authority(
|
||||
&storage,
|
||||
®istration,
|
||||
&t.path().join("blobs"),
|
||||
&authority,
|
||||
)
|
||||
.unwrap(),
|
||||
server,
|
||||
)
|
||||
}
|
||||
fn grant_authorizer(conn: &Connection, write: bool) -> Result<()> {
|
||||
assert!(
|
||||
!conn.is_autocommit(),
|
||||
"authority must be checked in the operation transaction"
|
||||
);
|
||||
let allowed: bool = conn.query_row(
|
||||
"SELECT revoked=0 AND (?1=0 OR write_access=1) FROM yoi_workspace_authority.grants",
|
||||
[write],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if allowed { Ok(()) } else { Err(Error::Denied) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let (raw, server) = grant_fixture(&t);
|
||||
let root = raw.root().unwrap();
|
||||
let bound = raw.with_authorizer(grant_authorizer);
|
||||
let mutation = create(root.id, "file");
|
||||
let result = bound.mutate("request", "worker", mutation.clone()).unwrap();
|
||||
bound.check_authorized(true).unwrap();
|
||||
server
|
||||
.execute("UPDATE grants SET write_access=0", [])
|
||||
.unwrap();
|
||||
assert!(bound.check_authorized(false).is_ok());
|
||||
assert!(matches!(bound.check_authorized(true), Err(Error::Denied)));
|
||||
assert!(bound.root().is_ok());
|
||||
assert!(bound.metadata(result.node.id).is_ok());
|
||||
assert!(bound.list(root.id, None, 10).is_ok());
|
||||
assert!(bound.search("file", true, None, 10).is_ok());
|
||||
assert!(
|
||||
bound
|
||||
.read(result.node.id, result.node.revision, 0, 100)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
bound
|
||||
.read_text(result.node.id, result.node.revision, 0, 100)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(bound.request_status("request").is_ok());
|
||||
assert!(
|
||||
matches!(
|
||||
bound.mutate("request", "worker", mutation.clone()),
|
||||
Err(Error::Denied)
|
||||
),
|
||||
"read-only receipt replay is a write request"
|
||||
);
|
||||
let clone = bound.clone();
|
||||
server.execute("UPDATE grants SET revoked=1", []).unwrap();
|
||||
assert!(matches!(clone.check_authorized(false), Err(Error::Denied)));
|
||||
assert!(matches!(clone.check_authorized(true), Err(Error::Denied)));
|
||||
assert!(matches!(clone.root(), Err(Error::Denied)));
|
||||
assert!(matches!(clone.metadata(result.node.id), Err(Error::Denied)));
|
||||
assert!(matches!(clone.list(root.id, None, 10), Err(Error::Denied)));
|
||||
assert!(matches!(
|
||||
clone.search("file", true, None, 10),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
clone.read(result.node.id, result.node.revision, 0, 100),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
clone.read_text(result.node.id, result.node.revision, 0, 100),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
clone.request_status("request"),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
clone.mutate("request", "worker", mutation),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
clone.mutate("denied", "worker", create(root.id, "denied")),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert_eq!(
|
||||
raw.blobs.list(None, 10).unwrap().len(),
|
||||
1,
|
||||
"denied write must not publish a blob"
|
||||
);
|
||||
assert_eq!(
|
||||
raw.request_status("denied").unwrap(),
|
||||
RequestStatus::Uncommitted
|
||||
);
|
||||
assert_eq!(raw.list(root.id, None, 10).unwrap().nodes.len(), 1);
|
||||
raw.collect(None, 10).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_serializes_with_publication_and_cached_handle_denies_after_revoke_commit() {
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let (raw, mut server) = grant_fixture(&t);
|
||||
let root = raw.root().unwrap();
|
||||
let (entered_tx, entered_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let release_rx = Mutex::new(release_rx);
|
||||
let publishing = raw.with_authorizer(move |conn, write| {
|
||||
grant_authorizer(conn, write)?;
|
||||
entered_tx.send(()).unwrap();
|
||||
release_rx
|
||||
.lock()
|
||||
.unwrap()
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap();
|
||||
Ok(())
|
||||
});
|
||||
let cached = raw.with_authorizer(grant_authorizer);
|
||||
let writer = std::thread::spawn(move || {
|
||||
publishing.mutate("publishing", "worker", create(root.id, "published"))
|
||||
});
|
||||
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
server.busy_timeout(Duration::ZERO).unwrap();
|
||||
assert!(
|
||||
matches!(server.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate),
|
||||
Err(rusqlite::Error::SqliteFailure(e,_)) if e.code==rusqlite::ErrorCode::DatabaseBusy),
|
||||
"revoke cannot acquire authority while mutation publishes"
|
||||
);
|
||||
release_tx.send(()).unwrap();
|
||||
let published = writer.join().unwrap().unwrap();
|
||||
let revoke = server
|
||||
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
|
||||
.unwrap();
|
||||
revoke.execute("UPDATE grants SET revoked=1", []).unwrap();
|
||||
revoke.commit().unwrap();
|
||||
assert_eq!(raw.metadata(published.node.id).unwrap(), published.node);
|
||||
assert_eq!(
|
||||
raw.request_status("publishing").unwrap(),
|
||||
RequestStatus::Committed {
|
||||
result: published.clone()
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
cached.request_status("publishing"),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
cached.mutate("publishing", "worker", create(root.id, "published")),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert!(matches!(
|
||||
cached.mutate("after-revoke", "worker", create(root.id, "denied")),
|
||||
Err(Error::Denied)
|
||||
));
|
||||
assert_eq!(raw.blobs.list(None, 10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ name = "yoi-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["axum"] }
|
||||
async-trait.workspace = true
|
||||
axum = { workspace = true, features = ["ws"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
@@ -1629,3 +1629,28 @@ CREATE TABLE workspace_config_grants (
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_config_grants_active_worker
|
||||
ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0;
|
||||
|
||||
CREATE TABLE workspace_drive_grants (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
|
||||
revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_by TEXT,
|
||||
revoked_at TEXT,
|
||||
CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL)
|
||||
OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_drive_grants_active_worker
|
||||
ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0;
|
||||
-- Retain grant audit across Worker deletion, but never carry its authority into
|
||||
-- a later lifetime reusing the same Runtime/Worker identity.
|
||||
CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN
|
||||
UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal',
|
||||
revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id
|
||||
AND worker_id=OLD.worker_id AND revoked=0;
|
||||
END;
|
||||
|
||||
@@ -6,6 +6,10 @@ pub(crate) use worker_operations::{
|
||||
WorkerOperationContext, WorkspaceWorker, WorkspaceWorkerMethodSender,
|
||||
};
|
||||
|
||||
#[path = "server_drive.rs"]
|
||||
mod drive;
|
||||
#[path = "server_drive_grants.rs"]
|
||||
mod drive_grants;
|
||||
#[path = "server_workspace_config.rs"]
|
||||
mod workspace_config;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -2601,6 +2605,9 @@ fn signed_request_target(uri: &Uri) -> &str {
|
||||
}
|
||||
|
||||
fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response {
|
||||
if path.starts_with("/api/w/") && path.contains("/drive/") {
|
||||
return (status, Json(drive::failure(status.as_u16()))).into_response();
|
||||
}
|
||||
if path == "/api/repositories"
|
||||
|| path.starts_with("/api/repositories/")
|
||||
|| (path.starts_with("/api/w/") && path.contains("/repositories"))
|
||||
@@ -6303,6 +6310,7 @@ fn generated_workspace_catalog_contract_router(service: ServerApiContractService
|
||||
fn generated_workspace_contract_router(service: ServerApiContractService) -> Router {
|
||||
let service = Arc::new(service);
|
||||
Router::new()
|
||||
.merge(drive::router(service.clone()))
|
||||
.merge(server_api::server_api_axum::workspace_current(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -11112,6 +11120,128 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn drive_root(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
) -> std::result::Result<server_api::DriveEntry, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::root(api, &context, &workspace_id).await
|
||||
}
|
||||
async fn drive_metadata(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::DriveEntryQuery,
|
||||
) -> std::result::Result<server_api::DriveEntry, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::metadata(api, &context, &workspace_id, query).await
|
||||
}
|
||||
async fn drive_list(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::DriveListQuery,
|
||||
) -> std::result::Result<server_api::DriveListResponse, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::list(api, &context, &workspace_id, query).await
|
||||
}
|
||||
async fn drive_search(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::DriveSearchQuery,
|
||||
) -> std::result::Result<server_api::DriveListResponse, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::search(api, &context, &workspace_id, query).await
|
||||
}
|
||||
async fn drive_read_text(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::DriveReadTextQuery,
|
||||
) -> std::result::Result<server_api::DriveReadTextResponse, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::read_text(api, &context, &workspace_id, query).await
|
||||
}
|
||||
async fn drive_read_chunk(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::DriveReadChunkQuery,
|
||||
) -> std::result::Result<server_api::BinaryBody, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::read_chunk(api, &context, &workspace_id, query).await
|
||||
}
|
||||
async fn drive_download(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::DriveDownloadQuery,
|
||||
) -> std::result::Result<
|
||||
server_api::server_api_responses::DriveDownload,
|
||||
server_api::DriveApiError,
|
||||
> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::download(api, &context, &workspace_id, query).await
|
||||
}
|
||||
async fn drive_mutate(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: server_api::DriveMutationRequest,
|
||||
) -> std::result::Result<server_api::DriveMutationResponse, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::mutate(api, &context, &workspace_id, request).await
|
||||
}
|
||||
async fn drive_upload(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::DriveUploadQuery,
|
||||
body: server_api::BinaryBody,
|
||||
) -> std::result::Result<server_api::DriveMutationResponse, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::upload(api, &context, &workspace_id, query, body).await
|
||||
}
|
||||
async fn drive_request_status(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request_id: String,
|
||||
) -> std::result::Result<server_api::DriveRequestStatusResponse, server_api::DriveApiError>
|
||||
{
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::request_status(api, &context, &workspace_id, request_id).await
|
||||
}
|
||||
async fn drive_grant_create(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: server_api::DriveGrantCreateRequest,
|
||||
) -> std::result::Result<server_api::DriveGrantResponse, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive_grants::create_grant(api, &context, &workspace_id, request).await
|
||||
}
|
||||
async fn drive_grant_revoke(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
grant_id: String,
|
||||
) -> std::result::Result<server_api::DriveGrantResponse, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive_grants::revoke_grant(api, &context, &workspace_id, &grant_id).await
|
||||
}
|
||||
async fn drive_grant_list(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::DriveGrantListQuery,
|
||||
) -> std::result::Result<server_api::DriveGrantListResponse, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive_grants::list_grants(api, &context, &workspace_id, query).await
|
||||
}
|
||||
|
||||
async fn current_worker_workspace_config_get(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -32591,6 +32721,7 @@ impl IntoResponse for ApiError {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod auth_logging_tests;
|
||||
mod drive_api_tests;
|
||||
mod drive_tests;
|
||||
mod subject_spawn_tests;
|
||||
mod subjektiv_jobs_tests;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,736 @@
|
||||
//! Authorized Workspace document API. Storage, CAS and receipts stay in workspace-drive.
|
||||
//! HTTP upload consumes frames with a hard bound; DB publication occurs only after validation.
|
||||
use super::*;
|
||||
use futures::StreamExt;
|
||||
use server_api::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use workspace_drive::{Drive, Mutation, NodeId, Revision};
|
||||
|
||||
fn mutation_actor(context: &ServerRequestContext) -> DriveResult<String> {
|
||||
if let Some(source) = &context.runtime_source {
|
||||
let worker = source.worker_id.as_ref().ok_or_else(|| failure(403))?;
|
||||
Ok(format!(
|
||||
"worker:{}",
|
||||
serde_json::to_string(&(&source.runtime_id, worker)).map_err(|_| failure(400))?
|
||||
))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"user:{}",
|
||||
context.actor.as_ref().ok_or_else(|| failure(403))?.user_id
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
type DriveResult<T> = std::result::Result<T, DriveApiError>;
|
||||
const TRANSFER_CHUNK: usize = 64 * 1024;
|
||||
|
||||
pub(super) fn failure(status: u16) -> DriveApiError {
|
||||
let code = match status {
|
||||
401 | 403 => DriveApiErrorCode::Denied,
|
||||
404 => DriveApiErrorCode::NotFound,
|
||||
409 => DriveApiErrorCode::Conflict,
|
||||
413 => DriveApiErrorCode::Limit,
|
||||
500..=599 => DriveApiErrorCode::StorageUnavailable,
|
||||
_ => DriveApiErrorCode::Invalid,
|
||||
};
|
||||
DriveApiError::new(code)
|
||||
}
|
||||
fn domain_error(error: workspace_drive::Error, mutation: bool) -> DriveApiError {
|
||||
use workspace_drive::Error;
|
||||
match error {
|
||||
Error::Denied => failure(403),
|
||||
Error::NotFound => failure(404),
|
||||
Error::Conflict | Error::Fenced => failure(409),
|
||||
Error::Invalid(_) => failure(400),
|
||||
Error::Blob(_) => failure(503),
|
||||
_ if mutation => DriveApiError::new(DriveApiErrorCode::OutcomeUnknown),
|
||||
_ => failure(503),
|
||||
}
|
||||
}
|
||||
async fn blocking<T: Send + 'static>(
|
||||
mutation: bool,
|
||||
action: impl FnOnce() -> workspace_drive::Result<T> + Send + 'static,
|
||||
) -> DriveResult<T> {
|
||||
tokio::task::spawn_blocking(action)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if mutation {
|
||||
DriveApiError::new(DriveApiErrorCode::OutcomeUnknown)
|
||||
} else {
|
||||
failure(503)
|
||||
}
|
||||
})?
|
||||
.map_err(|error| domain_error(error, mutation))
|
||||
}
|
||||
fn node_id(value: &str) -> DriveResult<NodeId> {
|
||||
value.to_owned().try_into().map_err(|_| failure(400))
|
||||
}
|
||||
fn revision(value: &str) -> DriveResult<Revision> {
|
||||
value.to_owned().try_into().map_err(|_| failure(400))
|
||||
}
|
||||
fn entry(workspace: &str, reference: &DriveEntryRef) -> DriveResult<NodeId> {
|
||||
if reference.workspace_id != workspace {
|
||||
return Err(failure(403));
|
||||
}
|
||||
node_id(&reference.node_id)
|
||||
}
|
||||
fn scoped_id(workspace: &str, entry_workspace: &str, id: &str) -> DriveResult<NodeId> {
|
||||
if workspace != entry_workspace {
|
||||
return Err(failure(403));
|
||||
}
|
||||
node_id(id)
|
||||
}
|
||||
fn safe_content_type(value: &str) -> DriveResult<String> {
|
||||
// Deliberately accept a bare MIME type only, not attacker-controlled header parameters.
|
||||
if value.len() > 128
|
||||
|| !value.contains('/')
|
||||
|| value.matches('/').count() != 1
|
||||
|| value.split('/').any(str::is_empty)
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"!#$&^_.+-/".contains(&b))
|
||||
{
|
||||
return Err(failure(400));
|
||||
}
|
||||
Ok(value.to_ascii_lowercase())
|
||||
}
|
||||
fn disposition(name: &str) -> String {
|
||||
// ASCII fallback plus RFC 5987 UTF-8 filename. Never place raw names in headers.
|
||||
let encoded: String = name
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
if b.is_ascii_alphanumeric() || b"-._".contains(b) {
|
||||
(*b as char).to_string()
|
||||
} else {
|
||||
format!("%{b:02X}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("attachment; filename=\"download\"; filename*=UTF-8''{encoded}")
|
||||
}
|
||||
fn error_response(error: DriveApiError) -> Response {
|
||||
let status = match error.code {
|
||||
DriveApiErrorCode::Denied => 403,
|
||||
DriveApiErrorCode::NotFound => 404,
|
||||
DriveApiErrorCode::Conflict => 409,
|
||||
DriveApiErrorCode::Limit => 413,
|
||||
DriveApiErrorCode::Invalid => 400,
|
||||
DriveApiErrorCode::StorageUnavailable | DriveApiErrorCode::OutcomeUnknown => 503,
|
||||
};
|
||||
(StatusCode::from_u16(status).unwrap(), Json(error)).into_response()
|
||||
}
|
||||
// No raw storage diagnostic is ever placed in a public error or stream failure.
|
||||
fn stream_error() -> std::io::Error {
|
||||
std::io::Error::other("Drive transfer unavailable")
|
||||
}
|
||||
|
||||
/// Stream HTTP frames while enforcing both announced and observed limits. Storage currently
|
||||
/// accepts a bounded whole-file Vec; this is binary buffering, never JSON/base64 expansion.
|
||||
async fn receive(body: axum::body::Body, declared: u64, digest: &str) -> DriveResult<Vec<u8>> {
|
||||
if declared > workspace_drive::MAX_FILE_BYTES as u64 {
|
||||
return Err(failure(413));
|
||||
}
|
||||
if digest.len() != 64
|
||||
|| !digest
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
return Err(failure(400));
|
||||
}
|
||||
let mut stream = body.into_data_stream();
|
||||
let mut bytes = Vec::new();
|
||||
let mut hasher = Sha256::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|_| failure(400))?;
|
||||
let size = bytes
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.ok_or_else(|| failure(413))?;
|
||||
if size > workspace_drive::MAX_FILE_BYTES {
|
||||
return Err(failure(413));
|
||||
}
|
||||
if size > declared as usize {
|
||||
return Err(failure(400));
|
||||
}
|
||||
hasher.update(&chunk);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if bytes.len() != declared as usize
|
||||
|| hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
!= digest
|
||||
{
|
||||
return Err(failure(400));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn public_node(node: workspace_drive::Node) -> DriveEntry {
|
||||
let workspace_id = node.workspace_id.clone();
|
||||
let time = chrono::DateTime::from_timestamp_millis(node.updated_at_ms)
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_default();
|
||||
DriveEntry {
|
||||
entry: DriveEntryRef {
|
||||
workspace_id: workspace_id.clone(),
|
||||
node_id: node.id.get().to_string(),
|
||||
},
|
||||
parent: node.parent_id.map(|id| DriveEntryRef {
|
||||
workspace_id,
|
||||
node_id: id.get().to_string(),
|
||||
}),
|
||||
name: node.name,
|
||||
kind: match node.kind {
|
||||
workspace_drive::Kind::File => DriveEntryKind::File,
|
||||
workspace_drive::Kind::Directory => DriveEntryKind::Folder,
|
||||
},
|
||||
revision: node.revision.get().to_string(),
|
||||
size: (node.kind == workspace_drive::Kind::File).then_some(node.size as u32),
|
||||
content_type: node.content_type,
|
||||
updated_by: node.updated_by,
|
||||
updated_at: time,
|
||||
latest_url: format!(
|
||||
"/api/w/{}/drive/{}?entry_workspace_id={}&id={}",
|
||||
node.workspace_id,
|
||||
if node.kind == workspace_drive::Kind::File {
|
||||
"download"
|
||||
} else {
|
||||
"metadata"
|
||||
},
|
||||
node.workspace_id,
|
||||
node.id.get()
|
||||
),
|
||||
}
|
||||
}
|
||||
fn cursor(workspace: &str, id: Option<NodeId>) -> Option<String> {
|
||||
id.map(|id| format!("{workspace}:{}", id.get()))
|
||||
}
|
||||
fn after(workspace: &str, value: Option<String>) -> DriveResult<Option<NodeId>> {
|
||||
value
|
||||
.map(|value| {
|
||||
let (scope, id) = value.rsplit_once(':').ok_or_else(|| failure(400))?;
|
||||
scoped_id(workspace, scope, id)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
fn limit(value: Option<u32>, maximum: usize) -> DriveResult<usize> {
|
||||
let limit = value.unwrap_or(maximum as u32) as usize;
|
||||
if limit == 0 || limit > maximum {
|
||||
return Err(failure(413));
|
||||
}
|
||||
Ok(limit)
|
||||
}
|
||||
pub(super) async fn root(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
) -> DriveResult<DriveEntry> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
blocking(false, move || drive.root()).await.map(public_node)
|
||||
}
|
||||
pub(super) async fn metadata(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
query: DriveEntryQuery,
|
||||
) -> DriveResult<DriveEntry> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||
blocking(false, move || drive.metadata(id))
|
||||
.await
|
||||
.map(public_node)
|
||||
}
|
||||
pub(super) async fn list(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
query: DriveListQuery,
|
||||
) -> DriveResult<DriveListResponse> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||
let after = after(workspace, query.after)?;
|
||||
let limit = limit(query.limit, workspace_drive::MAX_PAGE)?;
|
||||
let page = blocking(false, move || drive.list(id, after, limit)).await?;
|
||||
Ok(DriveListResponse {
|
||||
entries: page.nodes.into_iter().map(public_node).collect(),
|
||||
next_after: cursor(workspace, page.next_after),
|
||||
})
|
||||
}
|
||||
pub(super) async fn search(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
query: DriveSearchQuery,
|
||||
) -> DriveResult<DriveListResponse> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let after = after(workspace, query.after)?;
|
||||
let limit = limit(query.limit, workspace_drive::MAX_SEARCH_NODES)?;
|
||||
let page = blocking(false, move || {
|
||||
drive.search(&query.query, query.include_text, after, limit)
|
||||
})
|
||||
.await?;
|
||||
Ok(DriveListResponse {
|
||||
entries: page.nodes.into_iter().map(public_node).collect(),
|
||||
next_after: cursor(workspace, page.next_after),
|
||||
})
|
||||
}
|
||||
pub(super) async fn read_text(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
query: DriveReadTextQuery,
|
||||
) -> DriveResult<DriveReadTextResponse> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||
let max = query.max_bytes as usize;
|
||||
if max == 0 || max > DRIVE_TEXT_MAX_BYTES {
|
||||
return Err(failure(413));
|
||||
}
|
||||
let (node, chunk) = blocking(false, move || {
|
||||
let node = drive.metadata(id)?;
|
||||
let chunk = drive.read(id, node.revision, 0, max)?;
|
||||
Ok((node, chunk))
|
||||
})
|
||||
.await?;
|
||||
let text = match std::str::from_utf8(&chunk.bytes) {
|
||||
Ok(text) => text,
|
||||
Err(error) if !chunk.eof && error.error_len().is_none() => {
|
||||
std::str::from_utf8(&chunk.bytes[..error.valid_up_to()]).map_err(|_| failure(400))?
|
||||
}
|
||||
Err(_) => return Err(failure(400)),
|
||||
};
|
||||
Ok(DriveReadTextResponse {
|
||||
entry: public_node(node),
|
||||
text: text.into(),
|
||||
truncated: !chunk.eof,
|
||||
})
|
||||
}
|
||||
pub(super) async fn read_chunk(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
query: DriveReadChunkQuery,
|
||||
) -> DriveResult<BinaryBody> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||
let revision = revision(&query.expected_revision)?;
|
||||
if query.length == 0 || query.length > DRIVE_CHUNK_MAX_BYTES {
|
||||
return Err(failure(413));
|
||||
}
|
||||
let chunk = blocking(false, move || {
|
||||
drive.read(id, revision, query.offset.into(), query.length as usize)
|
||||
})
|
||||
.await?;
|
||||
Ok(chunk.bytes.into())
|
||||
}
|
||||
fn text_bytes(text: String) -> DriveResult<Vec<u8>> {
|
||||
if text.len() > DRIVE_TEXT_MAX_BYTES {
|
||||
return Err(failure(413));
|
||||
}
|
||||
Ok(text.into_bytes())
|
||||
}
|
||||
fn mutation(workspace: &str, mutation: DriveMutation) -> DriveResult<Mutation> {
|
||||
Ok(match mutation {
|
||||
DriveMutation::CreateFolder { parent, name } => Mutation::CreateFolder {
|
||||
parent: entry(workspace, &parent)?,
|
||||
name,
|
||||
},
|
||||
DriveMutation::CreateText {
|
||||
parent,
|
||||
name,
|
||||
text,
|
||||
content_type,
|
||||
} => Mutation::CreateFile {
|
||||
parent: entry(workspace, &parent)?,
|
||||
name,
|
||||
content_type: safe_content_type(&content_type)?,
|
||||
bytes: text_bytes(text)?,
|
||||
},
|
||||
DriveMutation::UpdateText {
|
||||
id,
|
||||
expected_revision,
|
||||
text,
|
||||
content_type,
|
||||
} => Mutation::Update {
|
||||
id: entry(workspace, &id)?,
|
||||
expected_revision: revision(&expected_revision)?,
|
||||
content_type: safe_content_type(&content_type)?,
|
||||
bytes: text_bytes(text)?,
|
||||
},
|
||||
DriveMutation::Relocate {
|
||||
id,
|
||||
expected_revision,
|
||||
parent,
|
||||
name,
|
||||
} => Mutation::Relocate {
|
||||
id: entry(workspace, &id)?,
|
||||
expected_revision: revision(&expected_revision)?,
|
||||
parent: entry(workspace, &parent)?,
|
||||
name,
|
||||
},
|
||||
DriveMutation::Delete {
|
||||
id,
|
||||
expected_revision,
|
||||
} => Mutation::Delete {
|
||||
id: entry(workspace, &id)?,
|
||||
expected_revision: revision(&expected_revision)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
fn result(request_id: String, result: workspace_drive::MutationResult) -> DriveMutationResponse {
|
||||
DriveMutationResponse {
|
||||
request_id,
|
||||
entry: (!result.deleted).then(|| public_node(result.node)),
|
||||
}
|
||||
}
|
||||
pub(super) async fn mutate(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
request: DriveMutationRequest,
|
||||
) -> DriveResult<DriveMutationResponse> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, true).await?;
|
||||
let actor = mutation_actor(context)?;
|
||||
let operation = mutation(workspace, request.mutation)?;
|
||||
let request_id = request.request_id.clone();
|
||||
let mutated = blocking(true, move || {
|
||||
drive.mutate(&request.request_id, &actor, operation)
|
||||
})
|
||||
.await?;
|
||||
Ok(result(request_id, mutated))
|
||||
}
|
||||
fn upload_mutation(
|
||||
workspace: &str,
|
||||
query: &DriveUploadQuery,
|
||||
bytes: Vec<u8>,
|
||||
) -> DriveResult<Mutation> {
|
||||
if query.entry_workspace_id != workspace {
|
||||
return Err(failure(403));
|
||||
}
|
||||
let content_type = safe_content_type(&query.content_type)?;
|
||||
match query.operation {
|
||||
DriveUploadOperation::Create if query.id.is_none() && query.expected_revision.is_none() => {
|
||||
Ok(Mutation::CreateFile {
|
||||
parent: node_id(query.parent_id.as_deref().ok_or_else(|| failure(400))?)?,
|
||||
name: query.name.clone().ok_or_else(|| failure(400))?,
|
||||
content_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
DriveUploadOperation::Update if query.parent_id.is_none() && query.name.is_none() => {
|
||||
Ok(Mutation::Update {
|
||||
id: node_id(query.id.as_deref().ok_or_else(|| failure(400))?)?,
|
||||
expected_revision: revision(
|
||||
query
|
||||
.expected_revision
|
||||
.as_deref()
|
||||
.ok_or_else(|| failure(400))?,
|
||||
)?,
|
||||
content_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
_ => Err(failure(400)),
|
||||
}
|
||||
}
|
||||
pub(super) async fn upload(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
query: DriveUploadQuery,
|
||||
body: BinaryBody,
|
||||
) -> DriveResult<DriveMutationResponse> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, true).await?;
|
||||
let actor = mutation_actor(context)?;
|
||||
let bytes = receive(
|
||||
axum::body::Body::from(body.into_bytes()),
|
||||
query.size.into(),
|
||||
&query.sha256,
|
||||
)
|
||||
.await?;
|
||||
publish_upload(drive, actor, workspace, query, bytes).await
|
||||
}
|
||||
async fn publish_upload(
|
||||
drive: Drive,
|
||||
actor: String,
|
||||
workspace: &str,
|
||||
query: DriveUploadQuery,
|
||||
bytes: Vec<u8>,
|
||||
) -> DriveResult<DriveMutationResponse> {
|
||||
let mutation = upload_mutation(workspace, &query, bytes)?;
|
||||
let request_id = query.request_id.clone();
|
||||
let mutated = blocking(true, move || {
|
||||
drive.mutate(&query.request_id, &actor, mutation)
|
||||
})
|
||||
.await?;
|
||||
Ok(result(request_id, mutated))
|
||||
}
|
||||
pub(super) async fn request_status(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
request_id: String,
|
||||
) -> DriveResult<DriveRequestStatusResponse> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let id = request_id.clone();
|
||||
let status = blocking(false, move || drive.request_status(&id)).await?;
|
||||
Ok(match status {
|
||||
workspace_drive::RequestStatus::Uncommitted => DriveRequestStatusResponse {
|
||||
request_id,
|
||||
state: DriveRequestState::Uncommitted,
|
||||
response: None,
|
||||
},
|
||||
workspace_drive::RequestStatus::Committed { result: mutation } => {
|
||||
DriveRequestStatusResponse {
|
||||
request_id: request_id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(result(request_id, mutation)),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn select_download(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
query: DriveDownloadQuery,
|
||||
) -> DriveResult<(Drive, workspace_drive::Node)> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||
let for_node = drive.clone();
|
||||
let node = blocking(false, move || for_node.metadata(id)).await?;
|
||||
if node.kind != workspace_drive::Kind::File {
|
||||
return Err(failure(400));
|
||||
}
|
||||
if query
|
||||
.expected_revision
|
||||
.as_deref()
|
||||
.is_some_and(|v| v != node.revision.get().to_string())
|
||||
{
|
||||
return Err(failure(409));
|
||||
}
|
||||
safe_content_type(node.content_type.as_deref().unwrap_or("")).map_err(|_| failure(503))?;
|
||||
Ok((drive, node))
|
||||
}
|
||||
pub(super) async fn download(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
query: DriveDownloadQuery,
|
||||
) -> DriveResult<server_api::server_api_responses::DriveDownload> {
|
||||
let (drive, node) = select_download(api, context, workspace, query).await?;
|
||||
let mut bytes = Vec::new();
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let copy = drive.clone();
|
||||
let chunk = blocking(false, move || {
|
||||
copy.read(node.id, node.revision, offset, TRANSFER_CHUNK)
|
||||
})
|
||||
.await?;
|
||||
offset += chunk.bytes.len() as u64;
|
||||
bytes.extend_from_slice(&chunk.bytes);
|
||||
if chunk.eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(server_api::server_api_responses::DriveDownload::Status200 {
|
||||
body: bytes.into(),
|
||||
header_content_disposition: disposition(&node.name),
|
||||
header_cache_control: "private, no-store".into(),
|
||||
header_etag: validator(&node),
|
||||
header_x_content_type_options: "nosniff".into(),
|
||||
header_content_security_policy: "sandbox; default-src 'none'".into(),
|
||||
})
|
||||
}
|
||||
fn validator(node: &workspace_drive::Node) -> String {
|
||||
format!(
|
||||
"\"drive-{}-{}-{}\"",
|
||||
node.workspace_id,
|
||||
node.id.get(),
|
||||
node.revision.get()
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||
Router::new()
|
||||
.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_list(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_search(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_read_text(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::drive_read_chunk(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::drive_mutate(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_request_status(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::drive_grant_create(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::drive_grant_revoke(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::drive_grant_list(
|
||||
service.clone(),
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(512 * 1024))
|
||||
.layer(middleware::from_fn(normalize_rejections))
|
||||
}
|
||||
async fn normalize_rejections(request: Request, next: Next) -> Response {
|
||||
let response = next.run(request).await;
|
||||
if matches!(response.status().as_u16(), 415 | 422) {
|
||||
error_response(failure(400))
|
||||
} else if matches!(response.status().as_u16(), 400 | 413)
|
||||
&& response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.is_none_or(|value| value != "application/json")
|
||||
{
|
||||
error_response(failure(response.status().as_u16()))
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
fn streaming_router(service: Arc<ServerApiContractService>) -> Router {
|
||||
// The operation inventory is the route authority, not duplicated string literals.
|
||||
use api_macros::Operation;
|
||||
use axum::routing::put;
|
||||
use server_api::server_api_operations::{DriveDownload, DriveUpload};
|
||||
Router::new()
|
||||
.route(DriveUpload::METADATA.path, put(streaming_upload))
|
||||
.route(DriveDownload::METADATA.path, get(streaming_download))
|
||||
.with_state(service)
|
||||
}
|
||||
async fn streaming_upload(
|
||||
State(service): State<Arc<ServerApiContractService>>,
|
||||
AxumPath(workspace): AxumPath<String>,
|
||||
Extension(context): Extension<ServerRequestContext>,
|
||||
query: std::result::Result<Query<DriveUploadQuery>, axum::extract::rejection::QueryRejection>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let result = async {
|
||||
let api = service.workspace_api().map_err(|_| failure(404))?;
|
||||
let drive = drive_grants::authorized_drive(api, &context, &workspace, true).await?;
|
||||
let actor = mutation_actor(&context)?;
|
||||
let Query(query) = query.map_err(|_| failure(400))?;
|
||||
// Reject malformed metadata, foreign refs and over-limit declarations before reading.
|
||||
upload_mutation(&workspace, &query, Vec::new())?;
|
||||
if query.size as usize > workspace_drive::MAX_FILE_BYTES {
|
||||
return Err(failure(413));
|
||||
}
|
||||
if let Some(length) = request.headers().get(axum::http::header::CONTENT_LENGTH) {
|
||||
let length: u64 = length
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or_else(|| failure(400))?;
|
||||
if length > workspace_drive::MAX_FILE_BYTES as u64 {
|
||||
return Err(failure(413));
|
||||
}
|
||||
if length != u64::from(query.size) {
|
||||
return Err(failure(400));
|
||||
}
|
||||
}
|
||||
let bytes = receive(request.into_body(), query.size.into(), &query.sha256).await?;
|
||||
// The bound Drive authorizer runs again under attached-authority IMMEDIATE,
|
||||
// so a grant revoked during reception cannot publish or replay a receipt.
|
||||
publish_upload(drive, actor, &workspace, query, bytes).await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(result) => Json(result).into_response(),
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
async fn streaming_download(
|
||||
State(service): State<Arc<ServerApiContractService>>,
|
||||
AxumPath(workspace): AxumPath<String>,
|
||||
Extension(context): Extension<ServerRequestContext>,
|
||||
query: std::result::Result<Query<DriveDownloadQuery>, axum::extract::rejection::QueryRejection>,
|
||||
) -> Response {
|
||||
let result = async {
|
||||
let api = service.workspace_api().map_err(|_| failure(404))?;
|
||||
let Query(query) = query.map_err(|_| failure(400))?;
|
||||
let (drive, node) = select_download(api, &context, &workspace, query).await?;
|
||||
// Validate the referenced blob before HTTP success, including a zero-byte file.
|
||||
let first_drive = drive.clone();
|
||||
let first = blocking(false, move || {
|
||||
first_drive.read(node.id, node.revision, 0, TRANSFER_CHUNK)
|
||||
})
|
||||
.await?;
|
||||
let id = node.id;
|
||||
let revision = node.revision;
|
||||
let state = (drive, id, revision, Some(first), 0_u64, false);
|
||||
let stream = futures::stream::try_unfold(
|
||||
state,
|
||||
|(drive, id, revision, first, offset, done)| async move {
|
||||
if done {
|
||||
return Ok(None);
|
||||
}
|
||||
let chunk = match first {
|
||||
Some(chunk) => chunk,
|
||||
None => {
|
||||
let copy = drive.clone();
|
||||
blocking(false, move || {
|
||||
copy.read(id, revision, offset, TRANSFER_CHUNK)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| stream_error())?
|
||||
}
|
||||
};
|
||||
let next = offset + chunk.bytes.len() as u64;
|
||||
let eof = chunk.eof;
|
||||
Ok::<_, std::io::Error>(Some((
|
||||
axum::body::Bytes::from(chunk.bytes),
|
||||
(drive, id, revision, None, next, eof),
|
||||
)))
|
||||
},
|
||||
);
|
||||
let mut response = axum::body::Body::from_stream(stream).into_response();
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_str(&safe_content_type(
|
||||
node.content_type.as_deref().unwrap_or(""),
|
||||
)?)
|
||||
.map_err(|_| failure(503))?,
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&node.size.to_string()).map_err(|_| failure(503))?,
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&disposition(&node.name)).map_err(|_| failure(503))?,
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::ETAG,
|
||||
HeaderValue::from_str(&validator(&node)).map_err(|_| failure(503))?,
|
||||
);
|
||||
headers.insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store"));
|
||||
headers.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("sandbox; default-src 'none'"),
|
||||
);
|
||||
Ok::<_, DriveApiError>(response)
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Drive authority is rechecked inside the metadata transaction against the
|
||||
//! attached Server DB. A bound handle is not an authorization cache or lease.
|
||||
use super::*;
|
||||
use rusqlite::params;
|
||||
use server_api::{
|
||||
DriveApiError, DriveApiErrorCode, DriveGrantCreateRequest, DriveGrantListQuery,
|
||||
DriveGrantListResponse, DriveGrantResponse, ServerRequestContext,
|
||||
};
|
||||
|
||||
type DriveResult<T> = std::result::Result<T, DriveApiError>;
|
||||
fn fail(code: DriveApiErrorCode) -> DriveApiError {
|
||||
DriveApiError::new(code)
|
||||
}
|
||||
|
||||
pub(super) fn pre_error(error: impl Into<ApiError>) -> DriveApiError {
|
||||
let error = error.into();
|
||||
fail(match api_error_status(&error.error).as_u16() {
|
||||
401 | 403 => DriveApiErrorCode::Denied,
|
||||
404 => DriveApiErrorCode::NotFound,
|
||||
409 => DriveApiErrorCode::Conflict,
|
||||
413 => DriveApiErrorCode::Limit,
|
||||
500..=599 => DriveApiErrorCode::StorageUnavailable,
|
||||
_ => DriveApiErrorCode::Invalid,
|
||||
})
|
||||
}
|
||||
fn mutation_error(error: Error) -> DriveApiError {
|
||||
if matches!(error, Error::Sqlite(_) | Error::Store(_)) {
|
||||
fail(DriveApiErrorCode::OutcomeUnknown)
|
||||
} else {
|
||||
pre_error(error)
|
||||
}
|
||||
}
|
||||
fn worker_request(context: &ServerRequestContext) -> bool {
|
||||
context.runtime_source.is_some()
|
||||
|| context.worker_source.is_some()
|
||||
|| context.transport_headers.iter().any(|(name, _)| {
|
||||
name.eq_ignore_ascii_case("x-yoi-runtime-id")
|
||||
|| name.eq_ignore_ascii_case("x-yoi-worker-id")
|
||||
})
|
||||
}
|
||||
fn browser_actor(context: &ServerRequestContext) -> DriveResult<&RequestActor> {
|
||||
if worker_request(context) {
|
||||
return Err(fail(DriveApiErrorCode::Denied));
|
||||
}
|
||||
context
|
||||
.actor
|
||||
.as_ref()
|
||||
.ok_or_else(|| fail(DriveApiErrorCode::Denied))
|
||||
}
|
||||
|
||||
pub(super) async fn authorized_drive(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace_id: &str,
|
||||
write: bool,
|
||||
) -> DriveResult<workspace_drive::Drive> {
|
||||
validate_workspace_scope(api, workspace_id).map_err(pre_error)?;
|
||||
let workspace = workspace_id.to_owned();
|
||||
let drive = if worker_request(context) {
|
||||
// Never fall back to an actor when a Worker/Runtime source is present,
|
||||
// incomplete, or inconsistent with its transport identity.
|
||||
let headers = current_worker_contract_headers(context)
|
||||
.map_err(|_| fail(DriveApiErrorCode::Denied))?;
|
||||
let worker = current_worker_identity(api, workspace_id, &headers)
|
||||
.map_err(|_| fail(DriveApiErrorCode::Denied))?;
|
||||
if context.worker_source.as_ref().is_some_and(|source| {
|
||||
source.runtime_id != worker.runtime_id || source.worker_id != worker.worker_id
|
||||
}) {
|
||||
return Err(fail(DriveApiErrorCode::Denied));
|
||||
}
|
||||
let drive = api.drive().await.map_err(pre_error)?;
|
||||
drive.with_authorizer(move |conn, write| {
|
||||
if !crate::store::drive_worker_is_live(conn, true, &workspace, &worker)? {
|
||||
return Err(workspace_drive::Error::Denied);
|
||||
}
|
||||
let granted: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM yoi_workspace_authority.workspace_drive_grants
|
||||
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0 AND (?4=0 OR access='read_write'))",
|
||||
params![workspace,worker.runtime_id,worker.worker_id,write],|row|row.get(0))?;
|
||||
if !granted { return Err(workspace_drive::Error::Denied); }
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
let actor = browser_actor(context)?;
|
||||
let account = actor.account_id.clone();
|
||||
let user = actor.user_id.clone();
|
||||
let drive = api.drive().await.map_err(pre_error)?;
|
||||
// Ordinary Workspace use is member-facing: the current Server policy
|
||||
// admits persisted authenticated users, not only the Workspace owner.
|
||||
drive.with_authorizer(move |conn, _write| {
|
||||
let member: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM yoi_workspace_authority.workspaces ws
|
||||
CROSS JOIN yoi_workspace_authority.users u
|
||||
WHERE ws.workspace_id=?1 AND ws.state='active' AND u.account_id=?2 AND u.user_id=?3)",
|
||||
params![workspace,account,user],|row|row.get(0))?;
|
||||
if !member { return Err(workspace_drive::Error::Denied); }
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
// Reject missing/read-only grants before upload body reception. This check
|
||||
// is admission only: every later operation rechecks under its own lock.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
drive.check_authorized(write)?;
|
||||
Ok::<_, workspace_drive::Error>(drive)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))?
|
||||
.map_err(|error| {
|
||||
fail(match error {
|
||||
workspace_drive::Error::Denied => DriveApiErrorCode::Denied,
|
||||
workspace_drive::Error::Fenced | workspace_drive::Error::Conflict => {
|
||||
DriveApiErrorCode::Conflict
|
||||
}
|
||||
workspace_drive::Error::NotFound => DriveApiErrorCode::NotFound,
|
||||
workspace_drive::Error::Invalid(_) => DriveApiErrorCode::Invalid,
|
||||
_ => DriveApiErrorCode::StorageUnavailable,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn create_grant(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace_id: &str,
|
||||
request: DriveGrantCreateRequest,
|
||||
) -> DriveResult<DriveGrantResponse> {
|
||||
let actor = browser_actor(context)?;
|
||||
require_workspace_owner(api, workspace_id, actor, "Drive grants")
|
||||
.await
|
||||
.map_err(pre_error)?;
|
||||
for identity in [&request.runtime_id, &request.worker_id] {
|
||||
if identity.is_empty() || identity.len() > 256 || identity.chars().any(char::is_control) {
|
||||
return Err(fail(DriveApiErrorCode::Invalid));
|
||||
}
|
||||
}
|
||||
let worker = RuntimeWorkerRef::new(&request.runtime_id, &request.worker_id);
|
||||
let summary = api
|
||||
.runtime
|
||||
.worker(&worker)
|
||||
.map_err(|_| fail(DriveApiErrorCode::NotFound))?;
|
||||
if summary.workspace.workspace_id.as_deref() != Some(workspace_id) {
|
||||
return Err(fail(DriveApiErrorCode::Denied));
|
||||
}
|
||||
let grant = DriveGrantResponse {
|
||||
grant_id: String::new(),
|
||||
workspace_id: workspace_id.into(),
|
||||
runtime_id: request.runtime_id,
|
||||
worker_id: request.worker_id,
|
||||
access: request.access,
|
||||
revoked: false,
|
||||
created_by: actor.account_id.clone(),
|
||||
created_at: String::new(),
|
||||
revoked_by: None,
|
||||
revoked_at: None,
|
||||
};
|
||||
let store = api.store.clone();
|
||||
tokio::task::spawn_blocking(move || store.create_workspace_drive_grant(&grant))
|
||||
.await
|
||||
.map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))?
|
||||
.map_err(mutation_error)
|
||||
}
|
||||
|
||||
pub(super) async fn revoke_grant(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
) -> DriveResult<DriveGrantResponse> {
|
||||
let actor = browser_actor(context)?;
|
||||
require_workspace_owner(api, workspace_id, actor, "Drive grants")
|
||||
.await
|
||||
.map_err(pre_error)?;
|
||||
validate_grant_id(grant_id)?;
|
||||
let (workspace, grant, actor) = (
|
||||
workspace_id.to_owned(),
|
||||
grant_id.to_owned(),
|
||||
actor.account_id.clone(),
|
||||
);
|
||||
let store = api.store.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
store.revoke_workspace_drive_grant(&workspace, &grant, &actor)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))?
|
||||
.map_err(mutation_error)?
|
||||
.ok_or_else(|| fail(DriveApiErrorCode::NotFound))
|
||||
}
|
||||
|
||||
fn validate_grant_id(id: &str) -> DriveResult<()> {
|
||||
if !id
|
||||
.parse::<i64>()
|
||||
.is_ok_and(|n| n > 0 && n.to_string() == id)
|
||||
{
|
||||
return Err(fail(DriveApiErrorCode::Invalid));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) async fn list_grants(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace_id: &str,
|
||||
query: DriveGrantListQuery,
|
||||
) -> DriveResult<DriveGrantListResponse> {
|
||||
let actor = browser_actor(context)?;
|
||||
require_workspace_owner(api, workspace_id, actor, "Drive grants")
|
||||
.await
|
||||
.map_err(pre_error)?;
|
||||
let limit = query.limit.unwrap_or(100) as usize;
|
||||
if limit == 0 || limit > 200 {
|
||||
return Err(fail(DriveApiErrorCode::Invalid));
|
||||
}
|
||||
if let Some(after) = &query.after {
|
||||
validate_grant_id(after)?;
|
||||
}
|
||||
let workspace = workspace_id.to_owned();
|
||||
let store = api.store.clone();
|
||||
let mut grants = tokio::task::spawn_blocking(move || {
|
||||
store.list_workspace_drive_grants(&workspace, limit + 1, query.after.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))?
|
||||
.map_err(pre_error)?;
|
||||
let more = grants.len() > limit;
|
||||
grants.truncate(limit);
|
||||
let next_after = if more {
|
||||
grants.last().map(|g| g.grant_id.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(DriveGrantListResponse { grants, next_after })
|
||||
}
|
||||
@@ -28,7 +28,8 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 83;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 84;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
@@ -293,6 +294,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Generic Ticket Worker roles and durable claims",
|
||||
apply: migrate_ticket_worker_v82_to_v83,
|
||||
},
|
||||
Migration {
|
||||
version: 84,
|
||||
name: WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME,
|
||||
apply: migrate_workspace_drive_grants_v83_to_v84,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -2005,6 +2011,28 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
) -> Result<WorkdirCatalogPage>;
|
||||
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
|
||||
|
||||
fn create_workspace_drive_grant(
|
||||
&self,
|
||||
grant: &server_api::DriveGrantResponse,
|
||||
) -> Result<server_api::DriveGrantResponse>;
|
||||
fn get_workspace_drive_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<Option<server_api::DriveGrantResponse>>;
|
||||
fn list_workspace_drive_grants(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
after: Option<&str>,
|
||||
) -> Result<Vec<server_api::DriveGrantResponse>>;
|
||||
fn revoke_workspace_drive_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
actor: &str,
|
||||
) -> Result<Option<server_api::DriveGrantResponse>>;
|
||||
|
||||
fn create_workspace_config_grant(
|
||||
&self,
|
||||
grant: &server_api::WorkspaceConfigGrantResponse,
|
||||
@@ -9505,6 +9533,81 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_workspace_drive_grant(
|
||||
&self,
|
||||
grant: &server_api::DriveGrantResponse,
|
||||
) -> Result<server_api::DriveGrantResponse> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
require_drive_grant_owner(&tx, &grant.workspace_id, &grant.created_by)?;
|
||||
let worker = RuntimeWorkerRef::new(&grant.runtime_id, &grant.worker_id);
|
||||
if !drive_worker_is_live(&tx, false, &grant.workspace_id, &worker)? {
|
||||
return Err(Error::WorkspacePermissionDenied("Worker is not live in this Workspace".into()));
|
||||
}
|
||||
let existing = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0"),
|
||||
params![grant.workspace_id, grant.runtime_id, grant.worker_id], read_drive_grant).optional()?;
|
||||
if let Some(existing) = existing {
|
||||
if existing.access != grant.access {
|
||||
return Err(Error::WorkspaceConfigConflict("Drive grant exists; revoke it first".into()));
|
||||
}
|
||||
return Ok(existing);
|
||||
}
|
||||
let access = match grant.access { server_api::DriveAccess::ReadOnly => "read_only", server_api::DriveAccess::ReadWrite => "read_write" };
|
||||
tx.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,revoked,created_by,created_at) VALUES(?1,?2,?3,?4,0,?5,?6)",
|
||||
params![grant.workspace_id,grant.runtime_id,grant.worker_id,access,grant.created_by,chrono::Utc::now().to_rfc3339()])?;
|
||||
let stored = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"),params![grant.workspace_id,tx.last_insert_rowid()],read_drive_grant)?;
|
||||
tx.commit()?;
|
||||
Ok(stored)
|
||||
})
|
||||
}
|
||||
fn get_workspace_drive_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<Option<server_api::DriveGrantResponse>> {
|
||||
self.with_conn(|conn| {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"),
|
||||
params![workspace_id, grant_id],
|
||||
read_drive_grant,
|
||||
)
|
||||
.optional()?)
|
||||
})
|
||||
}
|
||||
fn list_workspace_drive_grants(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
limit: usize,
|
||||
after: Option<&str>,
|
||||
) -> Result<Vec<server_api::DriveGrantResponse>> {
|
||||
if limit == 0 || limit > 201 {
|
||||
return Err(Error::WorkspaceConfigConflict(
|
||||
"Invalid Drive grant page limit".into(),
|
||||
));
|
||||
}
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id>?2 ORDER BY grant_id LIMIT ?3"))?;
|
||||
Ok(stmt.query_map(params![workspace_id,after.unwrap_or("0").parse::<i64>().map_err(|_| Error::WorkspaceConfigConflict("Invalid Drive grant cursor".into()))?,limit],read_drive_grant)?.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
})
|
||||
}
|
||||
fn revoke_workspace_drive_grant(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
grant_id: &str,
|
||||
actor: &str,
|
||||
) -> Result<Option<server_api::DriveGrantResponse>> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
require_drive_grant_owner(&tx, workspace_id, actor)?;
|
||||
tx.execute("UPDATE workspace_drive_grants SET revoked=1,revoked_by=?3,revoked_at=?4 WHERE workspace_id=?1 AND grant_id=?2 AND revoked=0",
|
||||
params![workspace_id,grant_id,actor,chrono::Utc::now().to_rfc3339()])?;
|
||||
let stored = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"),params![workspace_id,grant_id],read_drive_grant).optional()?;
|
||||
tx.commit()?;
|
||||
Ok(stored)
|
||||
})
|
||||
}
|
||||
|
||||
fn create_workspace_config_grant(
|
||||
&self,
|
||||
grant: &server_api::WorkspaceConfigGrantResponse,
|
||||
@@ -11906,6 +12009,12 @@ pub(crate) fn commit_worker_catalog_removal(
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Result<WorkerRegistryProjectionCommit> {
|
||||
conn.execute(
|
||||
"UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal',
|
||||
revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0",
|
||||
params![workspace_id, worker.runtime_id, worker.worker_id],
|
||||
)?;
|
||||
let existing = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3",
|
||||
@@ -11925,6 +12034,56 @@ pub(crate) fn commit_worker_catalog_removal(
|
||||
})
|
||||
}
|
||||
|
||||
const DRIVE_GRANT_SELECT: &str = "SELECT grant_id,workspace_id,runtime_id,worker_id,access,revoked,created_by,created_at,revoked_by,revoked_at FROM workspace_drive_grants";
|
||||
fn read_drive_grant(row: &rusqlite::Row<'_>) -> rusqlite::Result<server_api::DriveGrantResponse> {
|
||||
Ok(server_api::DriveGrantResponse {
|
||||
grant_id: row.get::<_, i64>(0)?.to_string(),
|
||||
workspace_id: row.get(1)?,
|
||||
runtime_id: row.get(2)?,
|
||||
worker_id: row.get(3)?,
|
||||
access: match row.get::<_, String>(4)?.as_str() {
|
||||
"read_only" => server_api::DriveAccess::ReadOnly,
|
||||
"read_write" => server_api::DriveAccess::ReadWrite,
|
||||
_ => return Err(rusqlite::Error::InvalidQuery),
|
||||
},
|
||||
revoked: row.get(5)?,
|
||||
created_by: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
revoked_by: row.get(8)?,
|
||||
revoked_at: row.get(9)?,
|
||||
})
|
||||
}
|
||||
fn require_drive_grant_owner(conn: &Connection, workspace: &str, actor: &str) -> Result<()> {
|
||||
let allowed: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')",params![workspace,actor],|r|r.get(0))?;
|
||||
if !allowed {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Drive grants require the active Workspace owner".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The prefix is selected only by trusted Host code, never request data. Uses
|
||||
/// the durable Runtime/Worker identity and removal authority, not observations.
|
||||
pub(crate) fn drive_worker_is_live(
|
||||
conn: &Connection,
|
||||
attached: bool,
|
||||
workspace: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let a = if attached {
|
||||
"yoi_workspace_authority."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
conn.query_row(&format!("SELECT EXISTS(SELECT 1 FROM {a}worker_registry wr JOIN {a}workspaces ws ON ws.workspace_id=wr.workspace_id
|
||||
WHERE wr.workspace_id=?1 AND wr.runtime_id=?2 AND wr.worker_id=?3 AND ws.state='active'
|
||||
AND (wr.runtime_id=?4 OR EXISTS(SELECT 1 FROM {a}workspace_runtime_bindings rb WHERE rb.workspace_id=?1 AND rb.runtime_id=?2 AND rb.state='verified' AND rb.revoked_at IS NULL))
|
||||
AND NOT EXISTS(SELECT 1 FROM {a}worker_registry_projection_removals rm WHERE rm.workspace_id=?1 AND rm.runtime_id=?2 AND rm.worker_id=?3)
|
||||
AND NOT EXISTS(SELECT 1 FROM {a}worker_tombstones wt WHERE wt.workspace_id=?1 AND wt.runtime_id=?2 AND wt.worker_id=?3)
|
||||
AND NOT EXISTS(SELECT 1 FROM {a}worker_removal_operations ro WHERE ro.workspace_id=?1 AND ro.runtime_id=?2 AND ro.worker_id=?3 AND ro.state IN ('executing','failed','succeeded')))") ,
|
||||
params![workspace,worker.runtime_id,worker.worker_id,crate::hosts::EMBEDDED_RUNTIME_ID],|r|r.get(0))
|
||||
}
|
||||
|
||||
fn read_worker_control_grant_record(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<WorkerControlGrantRecord> {
|
||||
@@ -12595,6 +12754,22 @@ fn read_workdir_registry_record(
|
||||
})
|
||||
}
|
||||
|
||||
fn migrate_workspace_drive_grants_v83_to_v84(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 83 {
|
||||
return Err(Error::Store(
|
||||
"Drive grants migration requires schema 83".into(),
|
||||
));
|
||||
}
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch(include_str!("workspace_drive_grants.sql"))?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)",
|
||||
params![84_i64, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_worker_restore_intents_v79_to_v80(conn: &Connection) -> Result<()> {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch("CREATE TABLE worker_restore_intents (
|
||||
@@ -16816,6 +16991,193 @@ fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
include!("store_workspace_config_tests.rs");
|
||||
|
||||
fn drive_grant_fixture(store: &SqliteWorkspaceStore) -> server_api::DriveGrantResponse {
|
||||
store.with_conn(|c| {
|
||||
c.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('drive-owner','user','drive-owner','Owner','1','1');
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('drive-ws','drive-owner','Workspace','active','1','1'),('drive-other','drive-owner','Other','active','1','1');")?;
|
||||
for workspace in ["drive-ws", "drive-other"] {
|
||||
c.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,private_material_ref,revision,state,created_at,updated_at) VALUES(?1,?1,'ed25519',?1,1,'pending_provisioning','1','1')", [workspace])?;
|
||||
c.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES(?1,?2,'worker','Worker','normal','1','1')", params![workspace, crate::hosts::EMBEDDED_RUNTIME_ID])?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
server_api::DriveGrantResponse {
|
||||
grant_id: String::new(),
|
||||
workspace_id: "drive-ws".into(),
|
||||
runtime_id: crate::hosts::EMBEDDED_RUNTIME_ID.into(),
|
||||
worker_id: "worker".into(),
|
||||
access: server_api::DriveAccess::ReadOnly,
|
||||
revoked: false,
|
||||
created_by: "drive-owner".into(),
|
||||
created_at: String::new(),
|
||||
revoked_by: None,
|
||||
revoked_at: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let path = t.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let mut proposed = drive_grant_fixture(&store);
|
||||
store.with_conn(|c| {
|
||||
c.execute_batch("DROP TRIGGER workspace_drive_grants_worker_deleted; DROP TABLE workspace_drive_grants; UPDATE __yoi_schema_migrations SET version=83 WHERE version=84;")?;
|
||||
assert_eq!(current_schema_version(c)?, 83);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
drop(store);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store
|
||||
.with_conn(|c| {
|
||||
assert_eq!(current_schema_version(c)?, 84);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let first = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||
assert_eq!(first.grant_id, "1");
|
||||
assert_eq!(first.created_by, "drive-owner");
|
||||
assert!(!first.created_at.is_empty());
|
||||
assert_eq!(
|
||||
store.create_workspace_drive_grant(&proposed).unwrap(),
|
||||
first
|
||||
);
|
||||
proposed.access = server_api::DriveAccess::ReadWrite;
|
||||
assert!(store.create_workspace_drive_grant(&proposed).is_err());
|
||||
proposed.workspace_id = "drive-other".into();
|
||||
let other = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.get_workspace_drive_grant("drive-other", &first.grant_id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.revoke_workspace_drive_grant("drive-other", &first.grant_id, "drive-owner")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.revoke_workspace_drive_grant("drive-ws", &first.grant_id, "stranger")
|
||||
.is_err()
|
||||
);
|
||||
let revoked = store
|
||||
.revoke_workspace_drive_grant("drive-ws", &first.grant_id, "drive-owner")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(revoked.revoked);
|
||||
assert_eq!(revoked.revoked_by.as_deref(), Some("drive-owner"));
|
||||
assert!(revoked.revoked_at.is_some());
|
||||
assert_eq!(
|
||||
store
|
||||
.revoke_workspace_drive_grant("drive-ws", &first.grant_id, "drive-owner")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
revoked
|
||||
);
|
||||
proposed.workspace_id = "drive-ws".into();
|
||||
let second = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||
assert_ne!(second.grant_id, first.grant_id);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_workspace_drive_grants("drive-ws", 1, None)
|
||||
.unwrap(),
|
||||
vec![revoked.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_workspace_drive_grants("drive-ws", 1, Some(&first.grant_id))
|
||||
.unwrap(),
|
||||
vec![second.clone()]
|
||||
);
|
||||
store
|
||||
.delete_worker_registry(
|
||||
"drive-ws",
|
||||
&RuntimeWorkerRef::new(crate::hosts::EMBEDDED_RUNTIME_ID, "worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let retired = store
|
||||
.get_workspace_drive_grant("drive-ws", &second.grant_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(retired.revoked);
|
||||
assert_eq!(retired.revoked_by.as_deref(), Some("server:worker-removal"));
|
||||
assert_eq!(retired.created_at, second.created_at);
|
||||
assert!(store.create_workspace_drive_grant(&proposed).is_err());
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_drive_grant("drive-other", &other.grant_id)
|
||||
.unwrap(),
|
||||
Some(other)
|
||||
);
|
||||
drop(store);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_drive_grant("drive-ws", &first.grant_id)
|
||||
.unwrap(),
|
||||
Some(revoked)
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get_workspace_drive_grant("drive-ws", &second.grant_id)
|
||||
.unwrap(),
|
||||
Some(retired)
|
||||
);
|
||||
store
|
||||
.with_conn(|c| {
|
||||
c.execute("DELETE FROM workspaces WHERE workspace_id='drive-ws'", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.list_workspace_drive_grants("drive-ws", 10, None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_workspace_drive_grants("drive-other", 10, None)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn drive_grants_baseline_constraints_and_worker_lifetime_fail_closed() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let proposed = drive_grant_fixture(&store);
|
||||
let first = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||
store.with_conn(|c| {
|
||||
assert_eq!(current_schema_version(c)?, 84);
|
||||
assert!(c.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,created_by,created_at) VALUES('drive-ws',?1,'worker','read_write','drive-owner','now')", [crate::hosts::EMBEDDED_RUNTIME_ID]).is_err());
|
||||
assert!(c.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,created_by,created_at) VALUES('drive-ws','r','w','command','drive-owner','now')", []).is_err());
|
||||
assert!(c.execute("UPDATE workspace_drive_grants SET revoked=1 WHERE grant_id=?1", [&first.grant_id]).is_err());
|
||||
c.execute("INSERT INTO worker_registry_projection_removals(workspace_id,runtime_id,worker_id) VALUES('drive-ws',?1,'worker')", [crate::hosts::EMBEDDED_RUNTIME_ID])?;
|
||||
assert!(!drive_worker_is_live(c, false, "drive-ws", &RuntimeWorkerRef::new(crate::hosts::EMBEDDED_RUNTIME_ID, "worker"))?);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(store.create_workspace_drive_grant(&proposed).is_err());
|
||||
store
|
||||
.with_conn(|c| {
|
||||
c.execute(
|
||||
"DELETE FROM worker_registry WHERE workspace_id='drive-ws'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.get_workspace_drive_grant("drive-ws", &first.grant_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.revoked
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -18340,6 +18702,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
}
|
||||
|
||||
fn downgrade_ticket_worker_schema(conn: &Connection, restore_worker_fk: bool) {
|
||||
// Older-generation fixtures must not retain the new Drive authority.
|
||||
conn.execute_batch("DROP TRIGGER IF EXISTS workspace_drive_grants_worker_deleted; DROP TABLE IF EXISTS workspace_drive_grants;").unwrap();
|
||||
// Restore the actual old role and FK contracts, not just the old
|
||||
// unique index, so migration tests exercise real coder rows.
|
||||
for table in [
|
||||
@@ -18428,7 +18792,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
.iter()
|
||||
.map(|s| s.version)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![83]
|
||||
vec![83, 84]
|
||||
);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let assignment = store
|
||||
@@ -18454,7 +18818,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
);
|
||||
}
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?,83);
|
||||
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(conn.query_row("SELECT role FROM ticket_worker_assignments WHERE assignment_id='assignment'", [], |r| r.get::<_,String>(0))?,"worker");
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get::<_,i64>(0))?,0);
|
||||
Ok(())
|
||||
@@ -18739,6 +19103,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
version: 83,
|
||||
name: "Generic Ticket Worker roles and durable claims".to_string()
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 84,
|
||||
name: WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME.to_string()
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -18832,6 +19200,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(81, "Backend Job immutable resource serialization".to_string()),
|
||||
(82, "Ticket responsibility and unfinished work separation".to_string()),
|
||||
(83, "Generic Ticket Worker roles and durable claims".to_string()),
|
||||
(84, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME.to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -19183,7 +19552,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||
73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83
|
||||
73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -19192,7 +19561,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 34);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 35);
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||
|
||||
@@ -87,6 +87,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"worker_workdir_links",
|
||||
"workspace_config_entries",
|
||||
"workspace_config_grants",
|
||||
"workspace_drive_grants",
|
||||
"workspace_config_tree_revisions",
|
||||
"workspace_config_trees",
|
||||
"workspace_create_operations",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE workspace_drive_grants (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
|
||||
revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_by TEXT,
|
||||
revoked_at TEXT,
|
||||
CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL)
|
||||
OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX workspace_drive_grants_active_worker
|
||||
ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0;
|
||||
-- Retain grant audit across Worker deletion, but never carry its authority into
|
||||
-- a later lifetime reusing the same Runtime/Worker identity.
|
||||
CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN
|
||||
UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal',
|
||||
revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id
|
||||
AND worker_id=OLD.worker_id AND revoked=0;
|
||||
END;
|
||||
Reference in New Issue
Block a user