From 103fee0ba43a031f18022f080db46ba45d8502bd Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 18:34:29 +0900 Subject: [PATCH] Complete repository contract error coverage --- crates/api-macros-impl/src/lib.rs | 157 ++++++- crates/api-macros/src/axum.rs | 2 +- crates/api-macros/src/lib.rs | 11 +- crates/api-macros/src/openapi.rs | 62 ++- crates/client/src/backend_workspace.rs | 114 ++++- crates/server-api/examples/export_openapi.rs | 60 ++- crates/server-api/src/lib.rs | 121 +++++- crates/workspace-server/src/server.rs | 424 ++++++++++++------- openapi/server-api.json | 293 ++++++++++++- 9 files changed, 1032 insertions(+), 212 deletions(-) diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 52ba4beb2..2395767ef 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -106,6 +106,10 @@ struct RouteArgs { status: Option, alternate_status: Option, error_status: Option, + additional_error_statuses: Vec, + bearer_auth: Option, + browser_auth: Option, + normalize_body_errors: Option, openapi: Option, } @@ -118,6 +122,10 @@ impl Parse for RouteArgs { status: None, alternate_status: None, error_status: None, + additional_error_statuses: Vec::new(), + bearer_auth: None, + browser_auth: None, + normalize_body_errors: None, openapi: None, }; @@ -148,6 +156,40 @@ impl Parse for RouteArgs { &key, "error_status", )?, + "additional_error_statuses" => { + if !result.additional_error_statuses.is_empty() { + return Err(syn::Error::new( + key.span(), + "duplicate `additional_error_statuses` route option", + )); + } + let content; + syn::bracketed!(content in input); + while !content.is_empty() { + result.additional_error_statuses.push(content.parse()?); + if !content.is_empty() { + content.parse::()?; + } + } + } + "bearer_auth" => set_once( + &mut result.bearer_auth, + input.parse::()?, + &key, + "bearer_auth", + )?, + "browser_auth" => set_once( + &mut result.browser_auth, + input.parse::()?, + &key, + "browser_auth", + )?, + "normalize_body_errors" => set_once( + &mut result.normalize_body_errors, + input.parse::()?, + &key, + "normalize_body_errors", + )?, "openapi" => set_once( &mut result.openapi, input.parse::()?, @@ -223,6 +265,10 @@ struct Operation { response_status: u16, alternate_status: Option, error_status: Option, + additional_error_statuses: Vec, + bearer_auth: bool, + browser_auth: bool, + normalize_body_errors: bool, openapi_skip: bool, } @@ -445,6 +491,34 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { )); } let error_status = error_body.as_ref().map(|_| error_status.unwrap_or(400)); + let mut additional_error_statuses = Vec::new(); + for status in &route.additional_error_statuses { + let value = status.base10_parse::().map_err(|_| { + syn::Error::new( + status.span(), + "HTTP status must be an unsigned 16-bit integer", + ) + })?; + if !(400..=599).contains(&value) { + return Err(syn::Error::new( + status.span(), + "additional error statuses must be between 400 and 599", + )); + } + if error_status == Some(value) || additional_error_statuses.contains(&value) { + return Err(syn::Error::new( + status.span(), + "additional error statuses must be distinct", + )); + } + additional_error_statuses.push(value); + } + if !additional_error_statuses.is_empty() && error_body.is_none() { + return Err(syn::Error::new_spanned( + &route.path, + "additional_error_statuses require a public error response type", + )); + } if http_method == Method::Head && response_body.is_some() { return Err(syn::Error::new_spanned( @@ -585,6 +659,13 @@ fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { response_status, alternate_status, error_status, + additional_error_statuses, + bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value), + browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value), + normalize_body_errors: route + .normalize_body_errors + .as_ref() + .is_some_and(|value| value.value), openapi_skip: route.openapi.as_ref().is_some_and(|value| !value.value), }) } @@ -1245,14 +1326,49 @@ fn axum_adapter_tokens( let ty = ¶meter.ty; Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,)) }); - let body_extractor = operation.parameters.iter().find_map(|parameter| { - if !matches!(parameter.location, Location::Body) { - return None; - } - let ident = ¶meter.rust_ident; - let ty = ¶meter.ty; - Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)) - }); + let (body_extractor, body_rejection) = operation + .parameters + .iter() + .find(|parameter| matches!(parameter.location, Location::Body)) + .map(|parameter| { + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + if !operation.normalize_body_errors { + return ( + Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)), + None, + ); + } + let error = operation + .error_body + .as_ref() + .expect("body operations require an error type"); + ( + Some(quote!( + __body: ::core::result::Result< + #api_crate::axum::framework::Json<#ty>, + #api_crate::axum::framework::JsonRejection, + >, + )), + Some(quote! { + let #ident = match __body { + ::core::result::Result::Ok(#api_crate::axum::framework::Json(value)) => value, + ::core::result::Result::Err(rejection) => { + let status = rejection.status().as_u16(); + let error = <#error as #api_crate::HttpRequestError>::from_request_rejection( + status, + rejection.body_text(), + ); + return #api_crate::axum::json_response( + #api_crate::axum::status(status), + error, + ); + } + }; + }), + ) + }) + .unwrap_or((None, None)); let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident); let response_status = operation.response_status; let success = if let Some(alternate_status) = operation.alternate_status { @@ -1312,6 +1428,7 @@ fn axum_adapter_tokens( S: super::#trait_ident + Send + Sync + 'static, { #(#header_parsers)* + #body_rejection #mapped } } @@ -1415,15 +1532,39 @@ fn openapi_adapter_tokens( operation.response::<#ty>(#status, "application/json", "Error response")?; } }); + let additional_errors = operation + .error_body + .as_ref() + .map(|ty| { + operation + .additional_error_statuses + .iter() + .map(|status| { + quote! { + operation.response::<#ty>(#status, "application/json", "Error response")?; + } + }) + .collect::>() + }) + .unwrap_or_default(); + let bearer_auth = operation.bearer_auth.then(|| { + quote! { operation.bearer_authentication(); } + }); + let browser_auth = operation.browser_auth.then(|| { + quote! { operation.browser_authentication(); } + }); quote! { { let mut operation = builder.operation(#method, #path, #operation_id)?; #(#parameters)* + #bearer_auth + #browser_auth #request #response #alternate_response #error + #(#additional_errors)* operation.finish()?; } } diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs index 73fbc7e3b..aeaa3bfc7 100644 --- a/crates/api-macros/src/axum.rs +++ b/crates/api-macros/src/axum.rs @@ -52,7 +52,7 @@ pub fn status(code: u16) -> StatusCode { pub mod framework { pub use axum::{ Json, Router, - extract::{Extension, Path, Query, State}, + extract::{Extension, Path, Query, State, rejection::JsonRejection}, http::{HeaderMap, StatusCode}, response::Response, routing::{delete, get, head, options, patch, post, put}, diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index 1a56b408e..318bad6ef 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -39,7 +39,11 @@ //! method name but should be set explicitly for contracts which must remain stable while Rust //! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to //! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP -//! outcomes share one schema. A public error inferred from `Result` defaults to status `400`. +//! outcomes share one schema. A public error inferred from `Result` defaults to status `400`; +//! `additional_error_statuses` publishes the same typed error schema for other declared outcomes. +//! `bearer_auth = true` and `browser_auth = true` attach standard bearer and browser-session cookie +//! security schemes. Body operations may opt into typed Axum rejection normalization with +//! typed Axum rejection normalization with `normalize_body_errors = true` and [`HttpRequestError`]. //! //! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`. //! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them. @@ -211,6 +215,11 @@ pub trait HttpSuccess { fn status_code(&self) -> u16; } +/// Public error type capable of normalizing Axum request-body rejections. +pub trait HttpRequestError: HttpError { + fn from_request_rejection(status: u16, message: String) -> Self; +} + /// Marker used when an operation has no request, response, or public error body. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum NoBody {} diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs index 6b6795003..4f5158289 100644 --- a/crates/api-macros/src/openapi.rs +++ b/crates/api-macros/src/openapi.rs @@ -131,6 +131,7 @@ pub struct OpenApiBuilder { schemas: BTreeMap, operation_ids: BTreeSet, uses_bearer_auth: bool, + uses_browser_auth: bool, } impl OpenApiBuilder { @@ -157,6 +158,7 @@ impl OpenApiBuilder { schemas: BTreeMap::new(), operation_ids: BTreeSet::new(), uses_bearer_auth: false, + uses_browser_auth: false, }) } @@ -199,6 +201,7 @@ impl OpenApiBuilder { request_body: None, responses: BTreeMap::new(), bearer_security_required: None, + browser_security_required: false, }) } @@ -214,15 +217,30 @@ impl OpenApiBuilder { let schemas = self.schemas.into_iter().collect::>(); let mut components = Map::new(); components.insert("schemas".to_owned(), Value::Object(schemas)); + let mut security_schemes = Map::new(); if self.uses_bearer_auth { + security_schemes.insert( + "bearerAuth".to_owned(), + json!({ + "type": "http", + "scheme": "bearer" + }), + ); + } + if self.uses_browser_auth { + security_schemes.insert( + "browserSession".to_owned(), + json!({ + "type": "apiKey", + "in": "cookie", + "name": "yoi_workspace_session" + }), + ); + } + if !security_schemes.is_empty() { components.insert( "securitySchemes".to_owned(), - json!({ - "bearerAuth": { - "type": "http", - "scheme": "bearer" - } - }), + Value::Object(security_schemes), ); } @@ -295,6 +313,7 @@ pub struct OpenApiOperation<'a> { request_body: Option, responses: BTreeMap, bearer_security_required: Option, + browser_security_required: bool, } impl OpenApiOperation<'_> { @@ -338,6 +357,16 @@ impl OpenApiOperation<'_> { Ok(()) } + pub fn bearer_authentication(&mut self) { + self.parent.uses_bearer_auth = true; + self.bearer_security_required = Some(true); + } + + pub fn browser_authentication(&mut self) { + self.parent.uses_browser_auth = true; + self.browser_security_required = true; + } + pub fn request_body( &mut self, content_type: &'static str, @@ -418,15 +447,18 @@ impl OpenApiOperation<'_> { if let Some(request_body) = self.request_body { operation.insert("requestBody".to_owned(), request_body); } - if let Some(required) = self.bearer_security_required { - operation.insert( - "security".to_owned(), - if required { - json!([{ "bearerAuth": [] }]) - } else { - json!([{}, { "bearerAuth": [] }]) - }, - ); + let mut security = Vec::new(); + if self.bearer_security_required == Some(false) { + security.push(json!({})); + } + if self.bearer_security_required.is_some() { + security.push(json!({ "bearerAuth": [] })); + } + if self.browser_security_required { + security.push(json!({ "browserSession": [] })); + } + if !security.is_empty() { + operation.insert("security".to_owned(), Value::Array(security)); } self.parent .paths diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index c61dde11a..edd6cc75a 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -69,6 +69,29 @@ impl From, +) -> BackendWorkspaceClientError { + let status = match &error { + server_api::client_support::ClientError::Public { status, .. } => Some(*status), + server_api::client_support::ClientError::Failure(_) => None, + }; + match status { + Some(reqwest::StatusCode::UNAUTHORIZED) => { + BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { + origin: backend.origin().clone(), + }) + } + Some(reqwest::StatusCode::FORBIDDEN) => { + BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { + origin: backend.origin().clone(), + }) + } + _ => BackendWorkspaceClientError::RepositoryApi(error), + } +} + #[derive(Clone, Debug)] struct RepositoryBearerAuthorizer { authorization: String, @@ -144,7 +167,10 @@ async fn list_backend_workspace_repositories_with_client( .response_body_limit(REPOSITORY_RESPONSE_LIMIT) .build() .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; - let response = client.repository_list(workspace_id.to_owned()).await?; + let response = client + .repository_list(workspace_id.to_owned()) + .await + .map_err(|error| repository_client_error(backend, error))?; if response.workspace_id != workspace_id { return Err(BackendWorkspaceClientError::InvalidTarget( "Repository catalog response does not match the requested Workspace".to_string(), @@ -255,6 +281,92 @@ mod tests { handle.join().unwrap(); } + #[tokio::test] + async fn repository_catalog_enforces_generated_response_limit() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0; 4096]; + let _ = stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + REPOSITORY_RESPONSE_LIMIT + 1, + ) + .unwrap(); + stream.flush().unwrap(); + let chunk = [b' '; 8192]; + let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1; + while remaining > 0 { + let length = remaining.min(chunk.len()); + if stream.write_all(&chunk[..length]).is_err() { + break; + } + remaining -= length; + } + }); + let client = + BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap(); + + let error = list_backend_workspace_repositories_with_client(&client, "workspace-test") + .await + .unwrap_err(); + assert!( + matches!( + &error, + BackendWorkspaceClientError::RepositoryApi( + server_api::client_support::ClientError::Failure( + server_api::client_support::ClientFailure::ResponseTooLarge { + limit: REPOSITORY_RESPONSE_LIMIT + } + ) + ) + ), + "unexpected error: {error:?}" + ); + handle.join().unwrap(); + } + + #[test] + fn repository_client_preserves_authentication_error_taxonomy() { + let client = + BackendApiClient::from_access_token_for_test("https://backend.example.test", "token") + .unwrap(); + let unauthorized = repository_client_error( + &client, + server_api::client_support::ClientError::public( + reqwest::StatusCode::UNAUTHORIZED, + server_api::RepositoryApiError::new( + 401, + "Unauthorized", + "authentication required", + Vec::new(), + ), + ), + ); + assert!(matches!( + unauthorized, + BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. }) + )); + let forbidden = repository_client_error( + &client, + server_api::client_support::ClientError::public( + reqwest::StatusCode::FORBIDDEN, + server_api::RepositoryApiError::new( + 403, + "Forbidden", + "repository access denied", + Vec::new(), + ), + ), + ); + assert!(matches!( + forbidden, + BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { .. }) + )); + } + #[test] fn create_request_keeps_operation_key_for_exact_retry() { let request = CreateBackendWorkspaceRequest { diff --git a/crates/server-api/examples/export_openapi.rs b/crates/server-api/examples/export_openapi.rs index 43721866f..714c4c1d4 100644 --- a/crates/server-api/examples/export_openapi.rs +++ b/crates/server-api/examples/export_openapi.rs @@ -1,16 +1,54 @@ -use std::{env, fs, path::PathBuf}; +use std::{env, fs, io, path::PathBuf}; + +const DEFAULT_OUTPUT: &str = "openapi/server-api.json"; fn main() -> Result<(), Box> { - let output = env::args_os() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("openapi/server-api.json")); - let document = server_api::canonical_openapi_document()?; - let json = document.to_json()?; - if let Some(parent) = output.parent() { - fs::create_dir_all(parent)?; + let mut arguments = env::args_os().skip(1); + let first = arguments.next(); + let (mode, output) = match first.as_deref().and_then(|value| value.to_str()) { + Some("--check") => ("check", arguments.next().map(PathBuf::from)), + Some("--stdout") => ("stdout", None), + Some(_) => ("write", first.map(PathBuf::from)), + None => ("write", None), + }; + if arguments.next().is_some() { + return Err("usage: export_openapi [--check [PATH] | --stdout | PATH]".into()); + } + + let output = output.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT)); + let json = server_api::canonical_openapi_document()?.to_json()?; + match mode { + "stdout" => print!("{json}"), + "check" => { + let checked_in = fs::read_to_string(&output)?; + if checked_in != json { + return Err(format!( + "{} is stale; regenerate with `cargo run -p server-api --example export_openapi -- {}`", + output.display(), + output.display() + ) + .into()); + } + println!("{} is current", output.display()); + } + "write" => { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + let mut temporary = output.clone(); + let extension = output + .extension() + .and_then(|value| value.to_str()) + .map_or_else(|| "tmp".to_owned(), |value| format!("{value}.tmp")); + temporary.set_extension(extension); + fs::write(&temporary, json)?; + fs::rename(&temporary, &output).or_else(|error| { + let _ = fs::remove_file(&temporary); + Err::<(), io::Error>(error) + })?; + println!("wrote {}", output.display()); + } + _ => unreachable!(), } - fs::write(&output, json)?; - println!("wrote {}", output.display()); Ok(()) } diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index b90eec39a..8b241e663 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -56,6 +56,18 @@ impl api_macros::HttpError for RepositoryApiError { } } +impl api_macros::HttpRequestError for RepositoryApiError { + fn from_request_rejection(status: u16, message: String) -> Self { + let reason = match status { + 413 => "Payload Too Large", + 415 => "Unsupported Media Type", + 422 => "Unprocessable Entity", + _ => "Bad Request", + }; + Self::new(status, reason, message, Vec::new()) + } +} + macro_rules! impl_openapi_schema { ($($ty:ty),+ $(,)?) => { $(impl api_macros::openapi::OpenApiSchema for $ty {})+ @@ -92,19 +104,36 @@ pub trait ServerApi { #[path] worker_id: String, ) -> Result; - #[get("/api/w/{workspace_id}/repositories", status = 200, error_status = 400)] + #[get( + "/api/w/{workspace_id}/repositories", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] async fn repository_list( &self, #[path] workspace_id: String, ) -> Result; - #[get("/api/repositories", status = 200, error_status = 400)] + #[get( + "/api/repositories", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] async fn repository_list_alias(&self) -> Result; #[get( "/api/w/{workspace_id}/repositories/{repository_key}", status = 200, - error_status = 404 + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true )] async fn repository_detail( &self, @@ -112,7 +141,14 @@ pub trait ServerApi { #[path] repository_key: String, ) -> Result; - #[get("/api/repositories/{repository_key}", status = 200, error_status = 404)] + #[get( + "/api/repositories/{repository_key}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] async fn repository_detail_alias( &self, #[path] repository_key: String, @@ -122,7 +158,11 @@ pub trait ServerApi { "/api/w/{workspace_id}/repositories", status = 201, alternate_status = 200, - error_status = 400 + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 413, 422, 500], + bearer_auth = true, + browser_auth = true, + normalize_body_errors = true )] async fn repository_create( &self, @@ -132,9 +172,19 @@ pub trait ServerApi { ) -> Result; } -/// Digest of the authoritative ServerApi Rust contract source used for artifact provenance. +/// Digest of every authoritative input used to derive the canonical OpenAPI artifact. pub fn canonical_openapi_source_digest() -> String { - let digest = Sha256::digest(include_bytes!("lib.rs")); + let mut hasher = Sha256::new(); + for source in [ + include_bytes!("lib.rs").as_slice(), + include_bytes!("../../api-macros-impl/src/lib.rs").as_slice(), + include_bytes!("../../api-macros/src/lib.rs").as_slice(), + include_bytes!("../../api-macros/src/openapi.rs").as_slice(), + ] { + hasher.update((source.len() as u64).to_le_bytes()); + hasher.update(source); + } + let digest = hasher.finalize(); let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2); encoded.push_str("sha256:"); for byte in digest { @@ -5154,13 +5204,58 @@ mod openapi_artifact_tests { ); assert!(value["paths"]["/api/repositories"]["get"].is_object()); assert!(value["paths"]["/api/repositories/{repository_key}"]["get"].is_object()); - assert!( - value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["200"] - .is_object() + for (path, method) in [ + ("/api/w/{workspace_id}/repositories", "get"), + ("/api/w/{workspace_id}/repositories", "post"), + ("/api/w/{workspace_id}/repositories/{repository_key}", "get"), + ("/api/repositories", "get"), + ("/api/repositories/{repository_key}", "get"), + ] { + let operation = &value["paths"][path][method]; + assert_eq!( + operation["security"][0]["bearerAuth"], + serde_json::json!([]) + ); + assert_eq!( + operation["security"][1]["browserSession"], + serde_json::json!([]) + ); + for status in ["401", "403", "500"] { + assert!( + operation["responses"][status].is_object(), + "missing {method} {path} response {status}" + ); + } + } + let repository_collection = &value["paths"]["/api/w/{workspace_id}/repositories"]; + let repository_create = &repository_collection["post"]; + assert_eq!( + repository_create["security"][0]["bearerAuth"], + serde_json::json!([]) ); - assert!( - value["paths"]["/api/w/{workspace_id}/repositories"]["post"]["responses"]["201"] - .is_object() + assert_eq!( + repository_create["security"][1]["browserSession"], + serde_json::json!([]) + ); + for status in [ + "200", "201", "400", "401", "403", "404", "409", "413", "422", "500", + ] { + assert!( + repository_create["responses"][status].is_object(), + "missing repository-create response {status}" + ); + } + assert_eq!( + repository_create["responses"]["422"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/RepositoryApiError" + ); + assert_eq!( + value["components"]["securitySchemes"]["bearerAuth"]["scheme"], + "bearer" + ); + assert_eq!( + value["components"]["securitySchemes"]["browserSession"]["in"], + "cookie" ); assert!(value["paths"]["/api/w/{workspace_id}/repositories/{repository_key}"] ["get"]["responses"]["404"] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 5fe926370..74fb99708 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1604,11 +1604,31 @@ fn signed_request_target(uri: &Uri) -> &str { .unwrap_or_else(|| uri.path()) } +fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response { + if path == "/api/repositories" + || path.starts_with("/api/repositories/") + || (path.starts_with("/api/w/") && path.contains("/repositories")) + { + return ( + status, + Json(server_api::RepositoryApiError::new( + status.as_u16(), + status.canonical_reason().unwrap_or("error"), + message, + Vec::new(), + )), + ) + .into_response(); + } + status.into_response() +} + async fn authorize_scoped_workspace_request( api: &WorkspaceServerApi, workspace_id: &str, request: &mut Request, ) -> std::result::Result<(), Response> { + let request_path = request.uri().path().to_owned(); let proof = request .headers() .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) @@ -1650,7 +1670,13 @@ async fn authorize_scoped_workspace_request( &digest, ) .await - .map_err(|_| StatusCode::UNAUTHORIZED.into_response())?; + .map_err(|_| { + repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "invalid runtime request proof", + ) + })?; request.extensions_mut().insert(source); if !matches!( *request.method(), @@ -1674,7 +1700,13 @@ async fn authorize_scoped_workspace_request( let actor = resolve_server_actor(api, request.headers()) .await .map_err(server_error_response)? - .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; + .ok_or_else(|| { + repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "authentication required", + ) + })?; let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let mutating = !matches!( @@ -1690,7 +1722,11 @@ async fn authorize_scoped_workspace_request( .and_then(|value| value.to_str().ok()) .unwrap_or_default() { - return Err(StatusCode::FORBIDDEN.into_response()); + return Err(repository_api_rejection( + &request_path, + StatusCode::FORBIDDEN, + "cross-origin browser mutation denied", + )); } } request.extensions_mut().insert(actor); @@ -1719,6 +1755,7 @@ async fn authorize_workspace_api_request( if !request.uri().path().starts_with("/api/") { return next.run(request).await; } + let request_path = request.uri().path().to_owned(); let public_server_api = is_server_global_forward(request.uri().path()); let workspace_id = api.workspace_id().to_owned(); let proof = request @@ -1762,7 +1799,11 @@ async fn authorize_workspace_api_request( ) .await else { - return StatusCode::UNAUTHORIZED.into_response(); + return repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "invalid runtime request proof", + ); }; request.extensions_mut().insert(source); if !matches!( @@ -1795,7 +1836,13 @@ async fn authorize_workspace_api_request( { Ok(Some(actor)) => actor, Ok(None) if public_server_api => return next.run(request).await, - Ok(None) | Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + Ok(None) | Err(_) => { + return repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "authentication required", + ); + } }; let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let mutating = !matches!( @@ -1811,7 +1858,11 @@ async fn authorize_workspace_api_request( .and_then(|value| value.to_str().ok()) .unwrap_or_default() { - return StatusCode::FORBIDDEN.into_response(); + return repository_api_rejection( + &request_path, + StatusCode::FORBIDDEN, + "cross-origin browser mutation denied", + ); } } request.extensions_mut().insert(actor); @@ -1939,7 +1990,7 @@ async fn dispatch_workspace_request( } }; let Some(router) = router else { - return StatusCode::NOT_FOUND.into_response(); + return repository_api_rejection(&path, StatusCode::NOT_FOUND, "workspace was not found"); }; match router.oneshot(request).await { Ok(response) => response, @@ -18844,38 +18895,8 @@ impl ApiError { } fn into_repository_api_error(self) -> server_api::RepositoryApiError { - let status = match &self.error { - Error::WorkspacePermissionDenied(_) | Error::BrowserReopenConfirmationRequired => { - StatusCode::FORBIDDEN - } - Error::RepositoryConflict(_) => StatusCode::CONFLICT, - Error::InvalidInput(_) - | Error::WorkerSourceIdentity(_) - | Error::InvalidRuntimeIdentifier { .. } - | Error::ReservedWorkerName(_) => StatusCode::BAD_REQUEST, - Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { - StatusCode::NOT_FOUND - } - Error::RuntimeOperationFailed { code, .. } - if code == "repository_provider_unsupported" => - { - StatusCode::BAD_REQUEST - } - Error::InvalidRecordId(_) - | Error::MissingFrontmatter(_) - | Error::UnknownRepository(_) - | Error::UnknownRuntime(_) - | Error::UnknownWorker { .. } - | Error::RuntimeBindingNotFound { .. } - | Error::UnknownHost(_) - | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - let message = if status == StatusCode::INTERNAL_SERVER_ERROR { - "internal server error".to_owned() - } else { - self.error.to_string() - }; + let status = api_error_status(&self.error); + let message = api_error_response_message(&self.error); server_api::RepositoryApiError::new( status.as_u16(), status.canonical_reason().unwrap_or("error"), @@ -18885,125 +18906,129 @@ impl ApiError { } } +fn api_error_status(error: &Error) -> StatusCode { + match error { + Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { + StatusCode::FORBIDDEN + } + Error::TicketAssignmentConflict(_) + | Error::WorkdirAttachmentConflict(_) + | Error::WorkspaceConfigConflict(_) + | Error::RuntimeBindingConflict(_) + | Error::RuntimeBindingRevisionConflict { .. } + | Error::RuntimeBindingFingerprintConflict { .. } + | Error::RepositoryConflict(_) => StatusCode::CONFLICT, + Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, + Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { + StatusCode::BAD_REQUEST + } + Error::Ticket(ticket::TicketError::NotFound(_)) + | Error::MergeRequest(merge_request::MergeRequestError::NotFound) => StatusCode::NOT_FOUND, + Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => { + StatusCode::BAD_REQUEST + } + Error::MergeRequest(_) => StatusCode::CONFLICT, + Error::Ticket( + ticket::TicketError::Ambiguous { .. } + | ticket::TicketError::Locked { .. } + | ticket::TicketError::Conflict(_), + ) => StatusCode::CONFLICT, + Error::Ticket( + ticket::TicketError::InvalidPathComponent(_) + | ticket::TicketError::PathEscapesRoot { .. }, + ) => StatusCode::BAD_REQUEST, + Error::InvalidRecordId(_) + | Error::MissingFrontmatter(_) + | Error::UnknownHost(_) + | Error::UnknownRuntime(_) + | Error::UnknownWorker { .. } + | Error::UnknownRepository(_) + | Error::RuntimeBindingNotFound { .. } + | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, + Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { + StatusCode::NOT_FOUND + } + Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, + Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } if code == "repository_provider_unsupported" => { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => { + StatusCode::UNAUTHORIZED + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => { + StatusCode::GATEWAY_TIMEOUT + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { + StatusCode::NOT_IMPLEMENTED + } + Error::RuntimeOperationFailed { code, .. } + if code == "profile_registry_revision_conflict" + || code == "profile_source_revision_conflict" + || code == "workspace_metadata_revision_conflict" + || code == "workspace_cleanup_plan_stale" + || code == "workspace_cleanup_worker_blocked" + || code == "workspace_cleanup_workdir_blocked" + || code == "workspace_cleanup_worker_pinned" => + { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code == "unknown_profile_source" + || code == "unknown_profile_selector" + || code == "unknown_objective" => + { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } + if code == "workspace_display_name_invalid" || code.starts_with("profile_") => + { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code.starts_with("workspace_settings_") + || code.starts_with("invalid_") + || code.starts_with("unsupported_worker_profile") + || code.starts_with("working_directory_") + || code.starts_with("workspace_cleanup_") + || code == "default_runtime_not_configured" + || code == "workspace_worker_workdir_required" + || code.ends_with("_already_exists") + || code.ends_with("_not_config_managed") + || code.ends_with("_unsupported") => + { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } + if code == "runtime_capacity_unavailable" + || code == "runtime_workdir_capacity_unavailable" => + { + StatusCode::SERVICE_UNAVAILABLE + } + Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, + Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +fn api_error_response_message(error: &Error) -> String { + match error { + Error::RuntimeOperationFailed { code, message, .. } => { + format!("{code}: {}", sanitize_backend_error(message)) + } + _ => sanitize_backend_error(&error.to_string()), + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { - let status = match &self.error { - Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { - StatusCode::FORBIDDEN - } - Error::TicketAssignmentConflict(_) - | Error::WorkdirAttachmentConflict(_) - | Error::WorkspaceConfigConflict(_) - | Error::RuntimeBindingConflict(_) - | Error::RuntimeBindingRevisionConflict { .. } - | Error::RuntimeBindingFingerprintConflict { .. } - | Error::RepositoryConflict(_) => StatusCode::CONFLICT, - Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, - Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { - StatusCode::BAD_REQUEST - } - Error::Ticket(ticket::TicketError::NotFound(_)) - | Error::MergeRequest(merge_request::MergeRequestError::NotFound) => { - StatusCode::NOT_FOUND - } - Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => { - StatusCode::BAD_REQUEST - } - Error::MergeRequest(_) => StatusCode::CONFLICT, - Error::Ticket( - ticket::TicketError::Ambiguous { .. } - | ticket::TicketError::Locked { .. } - | ticket::TicketError::Conflict(_), - ) => StatusCode::CONFLICT, - Error::Ticket( - ticket::TicketError::InvalidPathComponent(_) - | ticket::TicketError::PathEscapesRoot { .. }, - ) => StatusCode::BAD_REQUEST, - Error::InvalidRecordId(_) - | Error::MissingFrontmatter(_) - | Error::UnknownHost(_) - | Error::UnknownRuntime(_) - | Error::UnknownWorker { .. } - | Error::UnknownRepository(_) - | Error::RuntimeBindingNotFound { .. } - | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, - Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { - StatusCode::NOT_FOUND - } - Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, - Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { - StatusCode::NOT_FOUND - } - Error::RuntimeOperationFailed { code, .. } - if code == "repository_provider_unsupported" => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => { - StatusCode::UNAUTHORIZED - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => { - StatusCode::GATEWAY_TIMEOUT - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { - StatusCode::NOT_IMPLEMENTED - } - Error::RuntimeOperationFailed { code, .. } - if code == "profile_registry_revision_conflict" - || code == "profile_source_revision_conflict" - || code == "workspace_metadata_revision_conflict" - || code == "workspace_cleanup_plan_stale" - || code == "workspace_cleanup_worker_blocked" - || code == "workspace_cleanup_workdir_blocked" - || code == "workspace_cleanup_worker_pinned" => - { - StatusCode::CONFLICT - } - Error::RuntimeOperationFailed { code, .. } - if code == "unknown_profile_source" - || code == "unknown_profile_selector" - || code == "unknown_objective" => - { - StatusCode::NOT_FOUND - } - Error::RuntimeOperationFailed { code, .. } - if code == "workspace_display_name_invalid" || code.starts_with("profile_") => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { - StatusCode::CONFLICT - } - Error::RuntimeOperationFailed { code, .. } - if code.starts_with("workspace_settings_") - || code.starts_with("invalid_") - || code.starts_with("unsupported_worker_profile") - || code.starts_with("working_directory_") - || code.starts_with("workspace_cleanup_") - || code == "default_runtime_not_configured" - || code == "workspace_worker_workdir_required" - || code.ends_with("_already_exists") - || code.ends_with("_not_config_managed") - || code.ends_with("_unsupported") => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } - if code == "runtime_capacity_unavailable" - || code == "runtime_workdir_capacity_unavailable" => - { - StatusCode::SERVICE_UNAVAILABLE - } - Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, - Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - let response_message = match &self.error { - Error::RuntimeOperationFailed { code, message, .. } => { - format!("{code}: {}", sanitize_backend_error(message)) - } - _ => sanitize_backend_error(&self.error.to_string()), - }; + let status = api_error_status(&self.error); + let response_message = api_error_response_message(&self.error); let log = ApiErrorLog { kind: self .diagnostics @@ -22881,6 +22906,93 @@ mod tests { .body(Body::from(body.to_string())) .unwrap() }; + let anonymous_repository = app + .clone() + .oneshot( + Request::builder() + .uri(&repositories_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous_repository.status(), StatusCode::UNAUTHORIZED); + let anonymous_body: Value = serde_json::from_slice( + &to_bytes(anonymous_repository.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(anonymous_body["error"], "Unauthorized"); + + let unknown_field = app + .clone() + .oneshot(create_repository(serde_json::json!({ + "repository_key": "unknown-field", + "source": temp.path().join("unknown-field").display().to_string(), + "unexpected": true + }))) + .await + .unwrap(); + assert_eq!(unknown_field.status(), StatusCode::UNPROCESSABLE_ENTITY); + let unknown_field_body: Value = serde_json::from_slice( + &to_bytes(unknown_field.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(unknown_field_body["error"], "Unprocessable Entity"); + + let malformed = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + assert!( + serde_json::from_slice::( + &to_bytes(malformed.into_body(), usize::MAX).await.unwrap() + ) + .is_ok() + ); + + let oversized = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(vec![b' '; 5 * 1024 * 1024 + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert!( + serde_json::from_slice::( + &to_bytes(oversized.into_body(), usize::MAX).await.unwrap() + ) + .is_ok() + ); + let created = app .clone() .oneshot(create_repository(repository_request.clone())) diff --git a/openapi/server-api.json b/openapi/server-api.json index c970e99e0..3d0ea2456 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -337,12 +337,23 @@ "string": { "type": "string" } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + }, + "browserSession": { + "in": "cookie", + "name": "yoi_workspace_session", + "type": "apiKey" + } } }, "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:317cc65b39674bd229abc364d2daea3e909215471da5828313b3cf3ee2142600" + "x-yoi-source-digest": "sha256:9ba281f960b7a6bf8f1e8e29c1a5821ab5d8808f53a7b0f98abe76832e658189" }, "openapi": "3.1.0", "paths": { @@ -369,8 +380,56 @@ } }, "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } }, "/api/repositories/{repository_key}": { @@ -399,6 +458,36 @@ }, "description": "Successful response" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "404": { "content": { "application/json": { @@ -408,8 +497,26 @@ } }, "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } }, "/api/w/{workspace_id}/repositories": { @@ -447,8 +554,56 @@ } }, "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] }, "post": { "operationId": "repository_create", @@ -504,8 +659,86 @@ } }, "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } }, "/api/w/{workspace_id}/repositories/{repository_key}": { @@ -544,6 +777,36 @@ }, "description": "Successful response" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "404": { "content": { "application/json": { @@ -553,8 +816,26 @@ } }, "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] } } }