Additive fixes surfaced by building a full CMS on PiCloud
(examples/cms-poc/). One migration (0077_apps_cors.sql); no destructive
changes.
Security:
- Close the 502 info-leak on user routes: an uncaught script error (incl.
a throw) leaked the app UUID + script fn names + source line/col. The
user-route (inbox) path now shares scrub_runtime_detail with the
execute-by-id path — raw detail is logged under a correlation id and the
client sees only "script execution error (ref: <uuid>)".
Added:
- docs::find $contains operator (array membership via JSONB @>), per-app
and group-shared — the inverse of $in.
- App-user role management from API/CLI: pic users add-role / rm-role,
backed by the apps/{id}/users/{user_id}/roles endpoints (AppUsersAdmin).
- Per-app CORS: pic apps cors set, apps.cors_allowed_origins; the
orchestrator echoes an allowed Origin and answers OPTIONS preflight.
- Non-JSON request bodies: form-urlencoded -> object, text/* -> string,
other -> base64; ctx.request.content_type added. Malformed JSON still 400s.
- Binary responses via #{ body_base64 } with a script-set Content-Type.
- workflow::run_status(run_id) SDK (F-038): the starting script can poll a
run — #{ status, output, error, steps } or () when no such run belongs to
the app (app_id is the isolation boundary); same AppInvoke gate as start.
Fixed:
- pic apply "app not found" is now actionable (points at pic apps create).
- Interceptor- (F-021) and workflow-step-bound (F-037) scripts no longer
warn "no route or trigger" at plan time — both count as reachability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1192 lines
43 KiB
Rust
1192 lines
43 KiB
Rust
//! Data-plane HTTP surface. Mounted by the `picloud` all-in-one binary
|
|
//! under `/api` (so the path becomes `/api/execute/:id`) and by the
|
|
//! future split `picloud-orchestrator` binary at its own root.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use axum::{
|
|
body::Bytes,
|
|
extract::{Path, Request, State},
|
|
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
routing::post,
|
|
Extension, Json, Router,
|
|
};
|
|
use base64::Engine as _;
|
|
use chrono::Utc;
|
|
use picloud_executor_core::{
|
|
build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType,
|
|
};
|
|
use picloud_shared::{
|
|
AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionSource,
|
|
ExecutionStatus, HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox,
|
|
OutboxWriter, Principal, RequestId, ScriptId,
|
|
};
|
|
use serde_json::Value as Json_;
|
|
use uuid::Uuid;
|
|
|
|
use crate::client::ExecutorClient;
|
|
use crate::inbox::InboxRegistry;
|
|
use crate::resolver::{ResolverError, ScriptResolver};
|
|
use crate::routing::{AppDomainTable, RouteTable};
|
|
|
|
/// State shared by data-plane handlers.
|
|
pub struct DataPlaneState<E, R> {
|
|
pub executor: Arc<E>,
|
|
pub resolver: Arc<R>,
|
|
pub log_sink: Arc<dyn ExecutionLogSink>,
|
|
/// Host → app_id resolver. Run before `routes` to filter to the
|
|
/// owning app's slice. Shared with the manager (writes invalidate
|
|
/// the cache by replacing the table).
|
|
pub app_domains: Arc<AppDomainTable>,
|
|
/// Routing table for user-defined paths, partitioned per app.
|
|
/// Shared with the manager (admin router writes; this side reads).
|
|
pub routes: Arc<RouteTable>,
|
|
/// NATS-style inbox registry (v1.1.1). Used by sync HTTP via
|
|
/// outbox to await the dispatcher's delivery on a oneshot
|
|
/// channel.
|
|
pub inbox: Arc<InboxRegistry>,
|
|
/// Writer for the universal trigger outbox (v1.1.1). The sync
|
|
/// HTTP path inserts a row with `reply_to = inbox_id`; the async
|
|
/// path inserts with `reply_to = None` and returns 202.
|
|
pub outbox: Arc<dyn OutboxWriter>,
|
|
}
|
|
|
|
impl<E, R> Clone for DataPlaneState<E, R> {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
executor: self.executor.clone(),
|
|
resolver: self.resolver.clone(),
|
|
log_sink: self.log_sink.clone(),
|
|
app_domains: self.app_domains.clone(),
|
|
routes: self.routes.clone(),
|
|
inbox: self.inbox.clone(),
|
|
outbox: self.outbox.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build the data-plane router. Handles `POST /execute/:id` — the
|
|
/// always-available ID-based bypass.
|
|
///
|
|
/// Handlers expect an `Extension<Option<Principal>>` to be attached by
|
|
/// upstream middleware (`manager-core::attach_principal_if_present`);
|
|
/// requests without that extension panic at extraction time. The
|
|
/// picloud binary wires this in `build_app`.
|
|
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
|
where
|
|
E: ExecutorClient + 'static,
|
|
R: ScriptResolver + 'static,
|
|
{
|
|
Router::new()
|
|
.route("/execute/{id}", post(execute_by_id::<E, R>))
|
|
.with_state(state)
|
|
}
|
|
|
|
/// Build a router that handles ALL paths via the user-defined routing
|
|
/// table. Intended to be merged into the picloud app router as a
|
|
/// fallback (after the system routes are mounted).
|
|
///
|
|
/// Same middleware expectation as `data_plane_router` — wrap with
|
|
/// `attach_principal_if_present` so handlers can extract
|
|
/// `Extension<Option<Principal>>`.
|
|
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
|
where
|
|
E: ExecutorClient + 'static,
|
|
R: ScriptResolver + 'static,
|
|
{
|
|
Router::new()
|
|
.fallback(user_route_handler::<E, R>)
|
|
.with_state(state)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Handlers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn execute_by_id<E, R>(
|
|
State(state): State<DataPlaneState<E, R>>,
|
|
Path(id): Path<ScriptId>,
|
|
Extension(principal): Extension<Option<Principal>>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<Response, ApiError>
|
|
where
|
|
E: ExecutorClient + 'static,
|
|
R: ScriptResolver + 'static,
|
|
{
|
|
let script = state
|
|
.resolver
|
|
.resolve(id)
|
|
.await?
|
|
.ok_or(ApiError::NotFound(id))?;
|
|
// A disabled script (§4.3) is not invocable — 404, indistinguishable
|
|
// from an absent one (no info leak that the id exists but is off).
|
|
if !script.enabled {
|
|
return Err(ApiError::NotFound(id));
|
|
}
|
|
// The direct-execute bypass runs a script under its *owning app's*
|
|
// context. A group-owned script (Phase 4) is a template with no single
|
|
// app, so it cannot be invoked through this id-addressed bypass — it
|
|
// runs only via a descendant app's route/trigger, which supplies the
|
|
// execution-context app. Fail closed (404) rather than guess an app.
|
|
let app_id = script.app_id.ok_or(ApiError::NotFound(id))?;
|
|
|
|
let mut req = build_exec_request(id, &script.name, &headers, &body, app_id, principal)?;
|
|
req.sandbox_overrides = script.sandbox;
|
|
req.script_owner = script.owner();
|
|
let request_id = req.request_id;
|
|
let request_path = req.path.clone();
|
|
let request_headers = req.headers.clone();
|
|
let request_body = req.body.clone();
|
|
|
|
let timeout = Duration::from_secs(u64::from(script.timeout_seconds));
|
|
let started = Utc::now();
|
|
let identity = crate::client::ScriptIdentity {
|
|
script_id: script.id,
|
|
updated_at: script.updated_at,
|
|
};
|
|
let outcome = state
|
|
.executor
|
|
.execute_with_identity(identity, &script.source, req, timeout)
|
|
.await;
|
|
let finished = Utc::now();
|
|
|
|
// Build and dispatch the audit log regardless of outcome. We await
|
|
// the sink — recording the trail is part of correctness for an
|
|
// audit-visible platform — but a sink failure must not mask the
|
|
// user-facing result, so we only log a warning if it fails.
|
|
let log = build_execution_log(
|
|
app_id,
|
|
id,
|
|
request_id,
|
|
request_path,
|
|
request_headers,
|
|
request_body,
|
|
ExecutionSource::Http,
|
|
&outcome,
|
|
started,
|
|
finished,
|
|
);
|
|
if let Err(e) = state.log_sink.record(log).await {
|
|
tracing::warn!(error = %e, script_id = %id, "failed to persist execution log");
|
|
}
|
|
|
|
Ok(exec_response_to_http(outcome?))
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
/// Fallback entry for every user route. Wraps the actual dispatch with F-030
|
|
/// per-app CORS: resolve Host→app to know the policy, answer a CORS preflight,
|
|
/// then tag the dispatched response's `Access-Control-Allow-Origin`. CORS is
|
|
/// the ONE cross-cutting concern applied to every outcome (success, 404,
|
|
/// error), so it lives here at the single chokepoint rather than in each arm.
|
|
async fn user_route_handler<E, R>(
|
|
State(state): State<DataPlaneState<E, R>>,
|
|
Extension(principal): Extension<Option<Principal>>,
|
|
request: Request,
|
|
) -> Response
|
|
where
|
|
E: ExecutorClient + 'static,
|
|
R: ScriptResolver + 'static,
|
|
{
|
|
let method = request.method().as_str().to_ascii_uppercase();
|
|
let host = request
|
|
.headers()
|
|
.get("host")
|
|
.and_then(|h| h.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let origin = request
|
|
.headers()
|
|
.get("origin")
|
|
.and_then(|h| h.to_str().ok())
|
|
.map(str::to_string);
|
|
|
|
// Resolve Host → app first so we know which CORS policy applies. No app
|
|
// claims this host → flat 404 (no policy to tag against).
|
|
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": format!("no app claims host {host:?}")
|
|
})),
|
|
)
|
|
.into_response();
|
|
};
|
|
|
|
let allowed = state.app_domains.cors_for(app_id);
|
|
let allow_origin = pick_allowed_origin(origin.as_deref(), &allowed);
|
|
|
|
// A browser CORS preflight is `OPTIONS` carrying `Origin` +
|
|
// `Access-Control-Request-Method`. Answer it here — the real request's
|
|
// method/path need not have a registered OPTIONS route.
|
|
if method == "OPTIONS"
|
|
&& request
|
|
.headers()
|
|
.contains_key("access-control-request-method")
|
|
{
|
|
return preflight_response(allow_origin.as_deref(), request.headers());
|
|
}
|
|
|
|
let mut response = match dispatch_user_route(&state, app_id, request, principal).await {
|
|
Ok(r) => r,
|
|
Err(e) => e.into_response(),
|
|
};
|
|
add_cors_headers(response.headers_mut(), allow_origin.as_deref());
|
|
response
|
|
}
|
|
|
|
/// Two-phase dispatch (blueprint §11.5) minus the Host→app resolution the CORS
|
|
/// wrapper already did: run the matcher on the app's slice and execute. CORS
|
|
/// headers are added by the caller.
|
|
async fn dispatch_user_route<E, R>(
|
|
state: &DataPlaneState<E, R>,
|
|
app_id: AppId,
|
|
request: Request,
|
|
principal: Option<Principal>,
|
|
) -> Result<Response, ApiError>
|
|
where
|
|
E: ExecutorClient + 'static,
|
|
R: ScriptResolver + 'static,
|
|
{
|
|
// Uppercased so `ctx.request.method` honors its documented contract
|
|
// (extension methods arrive in their original case). Route matching is
|
|
// case-insensitive (see `routing::matcher::method_matches`), so this
|
|
// only normalizes the value surfaced to scripts / persisted in the
|
|
// async-dispatch payload.
|
|
let method = request.method().as_str().to_ascii_uppercase();
|
|
let uri = request.uri().clone();
|
|
let path = uri.path().to_string();
|
|
let query_str = uri.query().unwrap_or("").to_string();
|
|
let host = request
|
|
.headers()
|
|
.get("host")
|
|
.and_then(|h| h.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let headers = request.headers().clone();
|
|
|
|
let Some(matched) = state
|
|
.routes
|
|
.match_request_for_app(app_id, &host, &method, &path)
|
|
else {
|
|
return Ok((
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": format!("no route matches {method} {path}")
|
|
})),
|
|
)
|
|
.into_response());
|
|
};
|
|
|
|
let script = state
|
|
.resolver
|
|
.resolve(matched.matched.script_id)
|
|
.await?
|
|
.ok_or(ApiError::NotFound(matched.matched.script_id))?;
|
|
// An enabled route bound to a disabled script (§4.3, §4.7) is unreachable.
|
|
// Return the SAME flat "no route matches" 404 as the unmatched case — not
|
|
// `NotFound(script_id)`, which would both leak the internal script id to an
|
|
// anonymous caller and be distinguishable from absent.
|
|
if !script.enabled {
|
|
return Ok((
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": format!("no route matches {method} {path}")
|
|
})),
|
|
)
|
|
.into_response());
|
|
}
|
|
|
|
// Drain the body now that we know we'll execute. 10 MiB cap matches
|
|
// the conservative default response/request size in the blueprint.
|
|
let body_bytes = match axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024).await {
|
|
Ok(b) => b,
|
|
Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))),
|
|
};
|
|
|
|
let body_json = body_value_from_bytes(&body_bytes, content_type_of(&headers))?;
|
|
let header_map: BTreeMap<String, String> = headers
|
|
.iter()
|
|
.filter_map(|(k, v)| {
|
|
v.to_str()
|
|
.ok()
|
|
.map(|s| (k.as_str().to_string(), s.to_string()))
|
|
})
|
|
.collect();
|
|
let query = parse_query_string(&query_str);
|
|
let rest = matched.rest.clone().unwrap_or_default();
|
|
|
|
match matched.matched.dispatch_mode {
|
|
DispatchMode::Async => {
|
|
handle_async_route(
|
|
state,
|
|
app_id,
|
|
matched.matched.route_id,
|
|
matched.matched.script_id,
|
|
&script.name,
|
|
path,
|
|
method,
|
|
header_map,
|
|
body_json,
|
|
matched.params,
|
|
query,
|
|
rest,
|
|
script.timeout_seconds,
|
|
principal,
|
|
)
|
|
.await
|
|
}
|
|
DispatchMode::Sync => {
|
|
handle_sync_route(
|
|
state,
|
|
app_id,
|
|
matched.matched.route_id,
|
|
matched.matched.script_id,
|
|
&script.name,
|
|
path,
|
|
method,
|
|
header_map,
|
|
body_json,
|
|
matched.params,
|
|
query,
|
|
rest,
|
|
script.timeout_seconds,
|
|
principal,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn handle_async_route<E, R>(
|
|
state: &DataPlaneState<E, R>,
|
|
app_id: AppId,
|
|
route_id: Uuid,
|
|
script_id: ScriptId,
|
|
script_name: &str,
|
|
path: String,
|
|
method: String,
|
|
headers: BTreeMap<String, String>,
|
|
body: Json_,
|
|
params: BTreeMap<String, String>,
|
|
query: BTreeMap<String, String>,
|
|
rest: String,
|
|
timeout_seconds: u32,
|
|
principal: Option<Principal>,
|
|
) -> Result<Response, ApiError>
|
|
where
|
|
E: ExecutorClient + 'static,
|
|
R: ScriptResolver + 'static,
|
|
{
|
|
let payload = HttpDispatchPayload {
|
|
script_name: script_name.to_string(),
|
|
path,
|
|
method,
|
|
headers,
|
|
body,
|
|
params,
|
|
query,
|
|
rest,
|
|
timeout_seconds,
|
|
};
|
|
let payload_value = serde_json::to_value(&payload)
|
|
.map_err(|e| ApiError::BadRequest(format!("payload serialize: {e}")))?;
|
|
let execution_id = ExecutionId::new();
|
|
state
|
|
.outbox
|
|
.enqueue_http(NewHttpOutbox {
|
|
app_id,
|
|
route_id,
|
|
script_id,
|
|
reply_to: None,
|
|
payload: payload_value,
|
|
origin_principal: principal.map(|p| p.user_id),
|
|
trigger_depth: 0,
|
|
root_execution_id: Some(execution_id),
|
|
})
|
|
.await
|
|
.map_err(|e| ApiError::OutboxWrite(e.to_string()))?;
|
|
Ok((
|
|
StatusCode::ACCEPTED,
|
|
Json(serde_json::json!({
|
|
"accepted_at": Utc::now().to_rfc3339(),
|
|
"execution_id": execution_id.to_string(),
|
|
})),
|
|
)
|
|
.into_response())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn handle_sync_route<E, R>(
|
|
state: &DataPlaneState<E, R>,
|
|
app_id: AppId,
|
|
route_id: Uuid,
|
|
script_id: ScriptId,
|
|
script_name: &str,
|
|
path: String,
|
|
method: String,
|
|
headers: BTreeMap<String, String>,
|
|
body: Json_,
|
|
params: BTreeMap<String, String>,
|
|
query: BTreeMap<String, String>,
|
|
rest: String,
|
|
timeout_seconds: u32,
|
|
principal: Option<Principal>,
|
|
) -> Result<Response, ApiError>
|
|
where
|
|
E: ExecutorClient + 'static,
|
|
R: ScriptResolver + 'static,
|
|
{
|
|
let payload = HttpDispatchPayload {
|
|
script_name: script_name.to_string(),
|
|
path: path.clone(),
|
|
method,
|
|
headers: headers.clone(),
|
|
body: body.clone(),
|
|
params,
|
|
query,
|
|
rest,
|
|
timeout_seconds,
|
|
};
|
|
let payload_value = serde_json::to_value(&payload)
|
|
.map_err(|e| ApiError::BadRequest(format!("payload serialize: {e}")))?;
|
|
|
|
// Register the inbox before writing the outbox row so the
|
|
// dispatcher can't race-deliver before the orchestrator is
|
|
// listening.
|
|
let (inbox_id, rx) = state.inbox.register();
|
|
|
|
let execution_id = ExecutionId::new();
|
|
let outbox_id = state
|
|
.outbox
|
|
.enqueue_http(NewHttpOutbox {
|
|
app_id,
|
|
route_id,
|
|
script_id,
|
|
reply_to: Some(inbox_id),
|
|
payload: payload_value,
|
|
origin_principal: principal.map(|p| p.user_id),
|
|
trigger_depth: 0,
|
|
root_execution_id: Some(execution_id),
|
|
})
|
|
.await
|
|
.map_err(|e| {
|
|
// Failed outbox write — abandon the inbox so the dispatcher
|
|
// can never deliver to a stale entry.
|
|
state.inbox.cancel(inbox_id);
|
|
ApiError::OutboxWrite(e.to_string())
|
|
})?;
|
|
|
|
// Wait for the dispatcher's delivery. Outer timeout = script
|
|
// wall-clock + a small buffer to cover dispatcher latency.
|
|
let wait_budget = Duration::from_secs(u64::from(timeout_seconds)) + Duration::from_secs(2);
|
|
let request_id = RequestId::new();
|
|
let started = Utc::now();
|
|
let result = tokio::time::timeout(wait_budget, rx).await;
|
|
let finished = Utc::now();
|
|
|
|
// Tear down the receiver if it's still alive. `inbox.cancel` is a
|
|
// no-op when the dispatcher already delivered.
|
|
let _ = state.inbox.cancel(inbox_id);
|
|
|
|
let response = match result {
|
|
Ok(Ok(InboxResult::Success(summary))) => http_response_from_summary(summary),
|
|
Ok(Ok(InboxResult::Failure { kind, message })) => failure_to_response(kind, &message),
|
|
Ok(Err(_recv)) => {
|
|
// Channel was closed without a value — dispatcher dropped
|
|
// the sender. Treat as platform failure.
|
|
tracing::warn!(
|
|
outbox_id = %outbox_id,
|
|
"inbox channel closed without delivery"
|
|
);
|
|
failure_to_response(
|
|
InboxFailureKind::Platform,
|
|
"dispatcher closed inbox without delivery",
|
|
)
|
|
}
|
|
Err(_elapsed) => {
|
|
// Outer timeout — either the script was too slow or the
|
|
// dispatcher is wedged. Returns 504 by default.
|
|
failure_to_response(InboxFailureKind::Timeout, "request timed out")
|
|
}
|
|
};
|
|
|
|
let log = build_inbox_execution_log(
|
|
app_id,
|
|
script_id,
|
|
request_id,
|
|
path,
|
|
headers,
|
|
body,
|
|
response.status().as_u16(),
|
|
started,
|
|
finished,
|
|
);
|
|
if let Err(e) = state.log_sink.record(log).await {
|
|
tracing::warn!(
|
|
error = %e,
|
|
%script_id,
|
|
"failed to persist execution log"
|
|
);
|
|
}
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// F-030 per-app CORS
|
|
// ----------------------------------------------------------------------------
|
|
|
|
/// Decide which `Access-Control-Allow-Origin` value to emit for a request
|
|
/// `Origin` given an app's allow-list. Returns the string to echo, or `None`
|
|
/// when the origin isn't allowed (⇒ no CORS headers ⇒ the browser blocks).
|
|
fn pick_allowed_origin(origin: Option<&str>, allowed: &[String]) -> Option<String> {
|
|
// `["*"]` opts into any origin. The app-user auth model is bearer-token
|
|
// (no cookies), so a wildcard ACAO carries no CSRF/credentials risk.
|
|
if allowed.iter().any(|o| o == "*") {
|
|
return Some("*".to_string());
|
|
}
|
|
let origin = origin?;
|
|
allowed
|
|
.iter()
|
|
.find(|o| o.eq_ignore_ascii_case(origin))
|
|
.map(|_| origin.to_string())
|
|
}
|
|
|
|
/// Append CORS response headers when the origin is allowed. No-op otherwise, so
|
|
/// an app that hasn't opted in keeps the exact pre-CORS behaviour (no header).
|
|
fn add_cors_headers(headers: &mut HeaderMap, allow_origin: Option<&str>) {
|
|
let Some(origin) = allow_origin else {
|
|
return;
|
|
};
|
|
if let Ok(v) = HeaderValue::from_str(origin) {
|
|
headers.insert(HeaderName::from_static("access-control-allow-origin"), v);
|
|
// A concrete (non-`*`) ACAO varies by request Origin — mark it so a
|
|
// shared cache keys per-origin. Harmless for `*`.
|
|
headers.insert(
|
|
HeaderName::from_static("vary"),
|
|
HeaderValue::from_static("Origin"),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Answer a CORS preflight (`OPTIONS` + `Access-Control-Request-Method`): a 204
|
|
/// with the allow headers when the origin is permitted, else a bare 204 with no
|
|
/// CORS headers (the browser then blocks the real request).
|
|
fn preflight_response(allow_origin: Option<&str>, request_headers: &HeaderMap) -> Response {
|
|
let mut headers = HeaderMap::new();
|
|
if allow_origin.is_some() {
|
|
add_cors_headers(&mut headers, allow_origin);
|
|
headers.insert(
|
|
HeaderName::from_static("access-control-allow-methods"),
|
|
HeaderValue::from_static("GET, POST, PUT, PATCH, DELETE, OPTIONS"),
|
|
);
|
|
// Reflect the browser's requested headers, else a sensible default.
|
|
let allow_headers = request_headers
|
|
.get("access-control-request-headers")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| HeaderValue::from_str(s).ok())
|
|
.unwrap_or_else(|| HeaderValue::from_static("content-type, authorization"));
|
|
headers.insert(
|
|
HeaderName::from_static("access-control-allow-headers"),
|
|
allow_headers,
|
|
);
|
|
headers.insert(
|
|
HeaderName::from_static("access-control-max-age"),
|
|
HeaderValue::from_static("600"),
|
|
);
|
|
}
|
|
(StatusCode::NO_CONTENT, headers).into_response()
|
|
}
|
|
|
|
fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> Response {
|
|
let status =
|
|
StatusCode::from_u16(summary.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
// F-026: a `body_base64` envelope returns raw bytes with the script's
|
|
// Content-Type, bypassing JSON encoding.
|
|
if let Some(b64) = &summary.body_base64 {
|
|
return binary_response(status, &summary.headers, b64);
|
|
}
|
|
let mut http_headers = HeaderMap::new();
|
|
for (k, v) in summary.headers {
|
|
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
|
|
http_headers.insert(name, value);
|
|
}
|
|
}
|
|
http_headers
|
|
.entry(axum::http::header::CONTENT_TYPE)
|
|
.or_insert_with(|| HeaderValue::from_static("application/json"));
|
|
(status, http_headers, Json(summary.body)).into_response()
|
|
}
|
|
|
|
/// F-026: build an HTTP response whose body is the decoded `body_base64` bytes.
|
|
/// The script's `Content-Type` header wins; absent, defaults to
|
|
/// `application/octet-stream`. Invalid base64 is an author error → opaque 500.
|
|
fn binary_response(
|
|
status: StatusCode,
|
|
script_headers: &BTreeMap<String, String>,
|
|
body_base64: &str,
|
|
) -> Response {
|
|
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(body_base64.trim()) else {
|
|
let correlation_id = Uuid::new_v4();
|
|
tracing::warn!(%correlation_id, "script returned invalid body_base64");
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": format!("script execution error (ref: {correlation_id})")
|
|
})),
|
|
)
|
|
.into_response();
|
|
};
|
|
// `Vec<u8>` defaults the Content-Type to application/octet-stream; the
|
|
// script's own headers (inserted below, overriding) replace it if set.
|
|
let mut resp = (status, bytes).into_response();
|
|
let headers = resp.headers_mut();
|
|
for (k, v) in script_headers {
|
|
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
|
|
headers.insert(name, value);
|
|
}
|
|
}
|
|
resp
|
|
}
|
|
|
|
/// Scrub a runtime / SDK error string before it reaches an anonymous
|
|
/// data-plane caller. The raw detail leaks internals (the app UUID, script
|
|
/// function names, source line/col, filesystem paths, SSRF-policy hints) and
|
|
/// reflects attacker-thrown strings (`throw "..."`) back into the response;
|
|
/// log the full text server-side under a correlation id and return only a
|
|
/// stable generic message + the id.
|
|
///
|
|
/// Shared by both anonymous data-plane paths — the execute-by-id path
|
|
/// (`ApiError::into_response`) and the user-route inbox path
|
|
/// (`failure_to_response`) — so the two can't drift again. (The user-route
|
|
/// path previously leaked the raw detail: the 2026-06-11 audit scrub landed
|
|
/// only on execute-by-id.)
|
|
fn scrub_runtime_detail(detail: &str) -> String {
|
|
let correlation_id = Uuid::new_v4();
|
|
tracing::warn!(
|
|
%correlation_id,
|
|
detail = %detail,
|
|
"script runtime error (detail scrubbed from response)"
|
|
);
|
|
format!("script execution error (ref: {correlation_id})")
|
|
}
|
|
|
|
/// Map `InboxFailureKind` onto the design-notes §3 status-code table.
|
|
fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response {
|
|
let status = match kind {
|
|
InboxFailureKind::Validation => StatusCode::UNPROCESSABLE_ENTITY,
|
|
InboxFailureKind::Runtime => StatusCode::BAD_GATEWAY,
|
|
InboxFailureKind::Overloaded => StatusCode::SERVICE_UNAVAILABLE,
|
|
InboxFailureKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
|
InboxFailureKind::OperationBudget => StatusCode::INSUFFICIENT_STORAGE,
|
|
InboxFailureKind::Platform => StatusCode::INTERNAL_SERVER_ERROR,
|
|
};
|
|
// A Runtime failure carries the raw Rhai/SDK error text (app UUID, fn
|
|
// names, line/col, attacker-thrown strings) — scrub it exactly as the
|
|
// execute-by-id path does. Other kinds are platform-generated and safe.
|
|
let error_text = if matches!(kind, InboxFailureKind::Runtime) {
|
|
scrub_runtime_detail(message)
|
|
} else {
|
|
message.to_string()
|
|
};
|
|
let body = Json(serde_json::json!({ "error": error_text }));
|
|
if matches!(kind, InboxFailureKind::Overloaded) {
|
|
return (status, [(axum::http::header::RETRY_AFTER, "1")], body).into_response();
|
|
}
|
|
(status, body).into_response()
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_inbox_execution_log(
|
|
app_id: AppId,
|
|
script_id: ScriptId,
|
|
request_id: RequestId,
|
|
request_path: String,
|
|
request_headers: BTreeMap<String, String>,
|
|
request_body: Json_,
|
|
response_code: u16,
|
|
started: chrono::DateTime<Utc>,
|
|
finished: chrono::DateTime<Utc>,
|
|
) -> ExecutionLog {
|
|
let duration_ms = u64::try_from(
|
|
finished
|
|
.signed_duration_since(started)
|
|
.num_milliseconds()
|
|
.max(0),
|
|
)
|
|
.unwrap_or(0);
|
|
let status = if (200..400).contains(&response_code) {
|
|
ExecutionStatus::Success
|
|
} else {
|
|
ExecutionStatus::Error
|
|
};
|
|
ExecutionLog {
|
|
id: Uuid::new_v4(),
|
|
app_id,
|
|
script_id,
|
|
request_id,
|
|
request_path,
|
|
request_headers,
|
|
request_body,
|
|
response_code: Some(response_code),
|
|
response_body: None,
|
|
script_logs: Json_::Array(vec![]),
|
|
duration_ms,
|
|
status,
|
|
source: ExecutionSource::Http,
|
|
created_at: started,
|
|
}
|
|
}
|
|
|
|
fn parse_query_string(s: &str) -> BTreeMap<String, String> {
|
|
let mut out = BTreeMap::new();
|
|
if s.is_empty() {
|
|
return out;
|
|
}
|
|
for pair in s.split('&') {
|
|
let (k, v) = match pair.split_once('=') {
|
|
Some((k, v)) => (k, v),
|
|
None => (pair, ""),
|
|
};
|
|
let key = urlencoding::decode(k)
|
|
.map(std::borrow::Cow::into_owned)
|
|
.unwrap_or_default();
|
|
let val = urlencoding::decode(v)
|
|
.map(std::borrow::Cow::into_owned)
|
|
.unwrap_or_default();
|
|
out.insert(key, val);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// F-026: interpret a request body per its `Content-Type`, surfaced to scripts
|
|
/// as `ctx.request.body`. JSON (or an unspecified type) parses as before — a
|
|
/// malformed JSON body still 400s. Non-JSON bodies are no longer rejected:
|
|
/// `application/x-www-form-urlencoded` → an object of string fields, `text/*` →
|
|
/// the raw string, any other type → a base64 string of the raw bytes. The
|
|
/// script disambiguates via `ctx.request.headers["content-type"]`. This keeps
|
|
/// the existing `body: Value` field (no execution-DTO change) while lifting the
|
|
/// former JSON-only restriction.
|
|
fn body_value_from_bytes(bytes: &[u8], content_type: Option<&str>) -> Result<Json_, ApiError> {
|
|
if bytes.is_empty() {
|
|
return Ok(Json_::Null);
|
|
}
|
|
let mime = content_type
|
|
.unwrap_or("")
|
|
.split(';')
|
|
.next()
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_ascii_lowercase();
|
|
|
|
// JSON (default when unspecified — preserves the historical contract).
|
|
if mime.is_empty() || mime == "application/json" || mime.ends_with("+json") {
|
|
return serde_json::from_slice(bytes)
|
|
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")));
|
|
}
|
|
// Form-urlencoded → an object of string fields (the classic HTML form POST).
|
|
if mime == "application/x-www-form-urlencoded" {
|
|
let s = std::str::from_utf8(bytes)
|
|
.map_err(|_| ApiError::BadRequest("form body is not valid UTF-8".into()))?;
|
|
let obj: serde_json::Map<String, Json_> = parse_query_string(s)
|
|
.into_iter()
|
|
.map(|(k, v)| (k, Json_::String(v)))
|
|
.collect();
|
|
return Ok(Json_::Object(obj));
|
|
}
|
|
// Text → the raw string.
|
|
if mime.starts_with("text/") {
|
|
let s = std::str::from_utf8(bytes)
|
|
.map_err(|_| ApiError::BadRequest("text body is not valid UTF-8".into()))?;
|
|
return Ok(Json_::String(s.to_string()));
|
|
}
|
|
// Anything else (binary) → base64 of the raw bytes.
|
|
Ok(Json_::String(
|
|
base64::engine::general_purpose::STANDARD.encode(bytes),
|
|
))
|
|
}
|
|
|
|
fn content_type_of(headers: &HeaderMap) -> Option<&str> {
|
|
headers
|
|
.get(axum::http::header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Marshalling
|
|
// ----------------------------------------------------------------------------
|
|
|
|
fn build_exec_request(
|
|
id: ScriptId,
|
|
name: &str,
|
|
headers: &HeaderMap,
|
|
body: &Bytes,
|
|
app_id: AppId,
|
|
principal: Option<Principal>,
|
|
) -> Result<ExecRequest, ApiError> {
|
|
let mut hmap = BTreeMap::new();
|
|
for (k, v) in headers {
|
|
if let Ok(s) = v.to_str() {
|
|
hmap.insert(k.as_str().to_string(), s.to_string());
|
|
}
|
|
}
|
|
|
|
let body_json = body_value_from_bytes(body, content_type_of(headers))?;
|
|
|
|
let execution_id = ExecutionId::new();
|
|
Ok(ExecRequest {
|
|
execution_id,
|
|
request_id: RequestId::new(),
|
|
script_id: id,
|
|
script_name: name.to_string(),
|
|
invocation_type: InvocationType::Http,
|
|
path: format!("/api/execute/{id}"),
|
|
// The `/api/v1/execute/{id}` bypass is POST-only.
|
|
method: "POST".into(),
|
|
headers: hmap,
|
|
body: body_json,
|
|
params: BTreeMap::new(),
|
|
query: BTreeMap::new(),
|
|
rest: String::new(),
|
|
// Overwritten by the handler after the script is resolved.
|
|
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
|
app_id,
|
|
// Set by the handler from the resolved script's owner. The
|
|
// id-bypass runs app-owned scripts only (group scripts 404 here),
|
|
// so this is always `App(app_id)`; `None` falls back to that.
|
|
script_owner: None,
|
|
principal,
|
|
// Direct invocations are at depth 0 with a self-referential
|
|
// root. The triggers framework (v1.1.1) increments depth and
|
|
// preserves the original root for chained executions.
|
|
trigger_depth: 0,
|
|
root_execution_id: execution_id,
|
|
// Direct invocations are never DL handlers — that flag is only
|
|
// set by the dispatcher when it picks a dead_letter trigger row.
|
|
is_dead_letter_handler: false,
|
|
// No originating trigger event for direct ingress.
|
|
event: None,
|
|
})
|
|
}
|
|
|
|
fn exec_response_to_http(resp: ExecResponse) -> Response {
|
|
let status =
|
|
StatusCode::from_u16(resp.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
|
|
// F-026: binary response via `body_base64` (same as the user-route path).
|
|
if let Some(b64) = &resp.body_base64 {
|
|
return binary_response(status, &resp.headers, b64);
|
|
}
|
|
|
|
let mut http_headers = HeaderMap::new();
|
|
for (k, v) in resp.headers {
|
|
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
|
|
http_headers.insert(name, value);
|
|
}
|
|
}
|
|
http_headers
|
|
.entry(axum::http::header::CONTENT_TYPE)
|
|
.or_insert_with(|| HeaderValue::from_static("application/json"));
|
|
|
|
(status, http_headers, Json(resp.body)).into_response()
|
|
}
|
|
|
|
// `build_execution_log` moved to `picloud_executor_core` so the manager's
|
|
// trigger dispatcher can build identically-shaped rows (G1 — trigger
|
|
// executions are now logged with their `ExecutionSource`).
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Errors
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ApiError {
|
|
#[error("script not found: {0}")]
|
|
NotFound(ScriptId),
|
|
|
|
#[error("bad request: {0}")]
|
|
BadRequest(String),
|
|
|
|
#[error("resolver error: {0}")]
|
|
Resolver(#[from] ResolverError),
|
|
|
|
#[error("execution error: {0}")]
|
|
Exec(#[from] ExecError),
|
|
|
|
#[error("outbox write failed: {0}")]
|
|
OutboxWrite(String),
|
|
}
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
// Overloaded is the only variant that needs to attach an HTTP
|
|
// header (Retry-After), so it short-circuits the (status, body)
|
|
// reduction below. Axum's tuple builder makes per-arm header
|
|
// injection awkward otherwise.
|
|
use ApiError as E;
|
|
if let E::Exec(ExecError::Overloaded { retry_after_secs }) = &self {
|
|
let retry = retry_after_secs.to_string();
|
|
let body = Json(serde_json::json!({ "error": self.to_string() }));
|
|
return (
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
[(axum::http::header::RETRY_AFTER, retry)],
|
|
body,
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let (status, message) = match &self {
|
|
E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
|
E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
|
E::OutboxWrite(e) => {
|
|
tracing::error!(error = %e, "outbox write failed");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"internal error".to_string(),
|
|
)
|
|
}
|
|
E::Resolver(e) => {
|
|
tracing::error!(error = %e, "resolver failure");
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"internal error".to_string(),
|
|
)
|
|
}
|
|
E::Exec(e) => match e {
|
|
ExecError::Parse(_) | ExecError::InvalidResponse(_) => {
|
|
(StatusCode::UNPROCESSABLE_ENTITY, e.to_string())
|
|
}
|
|
ExecError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, e.to_string()),
|
|
ExecError::OperationBudgetExceeded => {
|
|
(StatusCode::INSUFFICIENT_STORAGE, e.to_string())
|
|
}
|
|
ExecError::Runtime(detail) => {
|
|
// Audit 2026-06-11 (F-SE-H-04) — runtime / SDK error
|
|
// strings leak internal detail to the public data
|
|
// plane: filesystem paths, "blocked by SSRF policy:
|
|
// link-local" (confirms cloud-metadata reachability),
|
|
// pool-exhaustion timing, and panic/backtrace
|
|
// fragments. They also reflect attacker-thrown
|
|
// strings (`throw "..."`) back into operator-facing
|
|
// JSON. Both anonymous data-plane paths (execute-by-id
|
|
// here + user routes via `failure_to_response`) scrub
|
|
// through `scrub_runtime_detail`; the authenticated
|
|
// script-test path lives in manager-core and keeps
|
|
// verbose errors.
|
|
(StatusCode::BAD_GATEWAY, scrub_runtime_detail(detail))
|
|
}
|
|
ExecError::Overloaded { .. } => unreachable!("handled above"),
|
|
},
|
|
};
|
|
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use base64::Engine as _;
|
|
|
|
use super::*;
|
|
|
|
async fn body_string(resp: Response) -> String {
|
|
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
|
|
.await
|
|
.unwrap();
|
|
String::from_utf8(bytes.to_vec()).unwrap()
|
|
}
|
|
|
|
// The user-route (inbox) path must scrub a Runtime failure: the raw Rhai
|
|
// error (app UUID, function names, source line/col, attacker-thrown text)
|
|
// must never reach the anonymous data-plane caller. Regression guard for
|
|
// the 502 info-leak — the 2026-06-11 audit scrub had only covered the
|
|
// execute-by-id path.
|
|
#[tokio::test]
|
|
async fn runtime_failure_is_scrubbed_on_the_inbox_path() {
|
|
let leaky = "#{\"statusCode\": 401} @ 'app:2cced4ba-e20b-44c9-9080-d698cc2d4cca' \
|
|
(line 43, position 18)\nin call to function 'require_user'";
|
|
let resp = failure_to_response(InboxFailureKind::Runtime, leaky);
|
|
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
|
|
let body = body_string(resp).await;
|
|
assert!(
|
|
!body.contains("app:2cced4ba"),
|
|
"app UUID leaked in body: {body}"
|
|
);
|
|
assert!(
|
|
!body.contains("require_user"),
|
|
"function name leaked in body: {body}"
|
|
);
|
|
assert!(!body.contains("line 43"), "source position leaked: {body}");
|
|
assert!(
|
|
body.contains("script execution error (ref: "),
|
|
"expected scrubbed generic message, got: {body}"
|
|
);
|
|
}
|
|
|
|
// Non-Runtime failures are platform-generated (not attacker-influenced)
|
|
// and stay descriptive so operators/clients see the real reason.
|
|
#[tokio::test]
|
|
async fn non_runtime_failures_pass_through() {
|
|
let resp = failure_to_response(InboxFailureKind::Timeout, "request timed out");
|
|
assert_eq!(resp.status(), StatusCode::GATEWAY_TIMEOUT);
|
|
let body = body_string(resp).await;
|
|
assert!(body.contains("request timed out"), "got: {body}");
|
|
}
|
|
|
|
#[test]
|
|
fn scrub_hides_detail_but_keeps_a_reference() {
|
|
let scrubbed = scrub_runtime_detail("secret-marker-98765 in call to 'admin_guard'");
|
|
assert!(!scrubbed.contains("secret-marker-98765"));
|
|
assert!(!scrubbed.contains("admin_guard"));
|
|
assert!(scrubbed.starts_with("script execution error (ref: "));
|
|
}
|
|
|
|
// --- F-030 CORS ---
|
|
|
|
#[test]
|
|
fn cors_origin_selection() {
|
|
let allow = vec!["http://localhost:5173".to_string()];
|
|
// Exact match echoes the origin.
|
|
assert_eq!(
|
|
pick_allowed_origin(Some("http://localhost:5173"), &allow).as_deref(),
|
|
Some("http://localhost:5173")
|
|
);
|
|
// Case-insensitive on the scheme/host.
|
|
assert_eq!(
|
|
pick_allowed_origin(Some("HTTP://LOCALHOST:5173"), &allow).as_deref(),
|
|
Some("HTTP://LOCALHOST:5173")
|
|
);
|
|
// Non-listed origin → not allowed.
|
|
assert_eq!(pick_allowed_origin(Some("https://evil.test"), &allow), None);
|
|
// No allow-list → CORS off.
|
|
assert_eq!(
|
|
pick_allowed_origin(Some("http://localhost:5173"), &[]),
|
|
None
|
|
);
|
|
// Wildcard allows any origin (and even a missing Origin echoes `*`).
|
|
let star = vec!["*".to_string()];
|
|
assert_eq!(
|
|
pick_allowed_origin(Some("https://anything.test"), &star).as_deref(),
|
|
Some("*")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cors_headers_added_only_when_allowed() {
|
|
let mut h = HeaderMap::new();
|
|
add_cors_headers(&mut h, Some("http://localhost:5173"));
|
|
assert_eq!(
|
|
h.get("access-control-allow-origin").unwrap(),
|
|
"http://localhost:5173"
|
|
);
|
|
assert_eq!(h.get("vary").unwrap(), "Origin");
|
|
|
|
let mut none = HeaderMap::new();
|
|
add_cors_headers(&mut none, None);
|
|
assert!(none.get("access-control-allow-origin").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn preflight_allows_permitted_origin() {
|
|
let mut req_headers = HeaderMap::new();
|
|
req_headers.insert(
|
|
"access-control-request-headers",
|
|
HeaderValue::from_static("content-type, authorization"),
|
|
);
|
|
let resp = preflight_response(Some("http://localhost:5173"), &req_headers);
|
|
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
|
let h = resp.headers();
|
|
assert_eq!(
|
|
h.get("access-control-allow-origin").unwrap(),
|
|
"http://localhost:5173"
|
|
);
|
|
assert!(h.get("access-control-allow-methods").is_some());
|
|
assert_eq!(
|
|
h.get("access-control-allow-headers").unwrap(),
|
|
"content-type, authorization"
|
|
);
|
|
|
|
// A disallowed origin gets a bare 204 with no CORS headers.
|
|
let blocked = preflight_response(None, &HeaderMap::new());
|
|
assert_eq!(blocked.status(), StatusCode::NO_CONTENT);
|
|
assert!(blocked
|
|
.headers()
|
|
.get("access-control-allow-origin")
|
|
.is_none());
|
|
}
|
|
|
|
// --- F-026 non-JSON request bodies ---
|
|
|
|
#[test]
|
|
fn body_json_is_parsed() {
|
|
let v = body_value_from_bytes(br#"{"a":1}"#, Some("application/json")).unwrap();
|
|
assert_eq!(v, serde_json::json!({ "a": 1 }));
|
|
}
|
|
|
|
#[test]
|
|
fn body_unspecified_type_still_requires_json() {
|
|
// Back-compat: no Content-Type ⇒ JSON, and malformed JSON still 400s.
|
|
assert!(body_value_from_bytes(b"not json", None).is_err());
|
|
let empty = body_value_from_bytes(b"", None).unwrap();
|
|
assert_eq!(empty, Json_::Null);
|
|
}
|
|
|
|
#[test]
|
|
fn body_form_urlencoded_becomes_object() {
|
|
let v = body_value_from_bytes(
|
|
b"name=ada&lang=rust",
|
|
Some("application/x-www-form-urlencoded"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(v, serde_json::json!({ "name": "ada", "lang": "rust" }));
|
|
}
|
|
|
|
#[test]
|
|
fn body_text_becomes_string() {
|
|
let v = body_value_from_bytes(b"hello world", Some("text/plain; charset=utf-8")).unwrap();
|
|
assert_eq!(v, Json_::String("hello world".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn body_binary_becomes_base64() {
|
|
// A tiny PNG-ish byte sequence (not valid UTF-8) → base64 string.
|
|
let raw = [0x89u8, 0x50, 0x4E, 0x47, 0x00, 0xFF];
|
|
let v = body_value_from_bytes(&raw, Some("application/octet-stream")).unwrap();
|
|
let expected = base64::engine::general_purpose::STANDARD.encode(raw);
|
|
assert_eq!(v, Json_::String(expected));
|
|
}
|
|
|
|
// --- F-026 binary responses ---
|
|
|
|
#[tokio::test]
|
|
async fn binary_response_returns_raw_bytes_with_content_type() {
|
|
let raw = [0x89u8, 0x50, 0x4E, 0x47];
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(raw);
|
|
let mut headers = BTreeMap::new();
|
|
headers.insert("content-type".to_string(), "image/png".to_string());
|
|
let resp = binary_response(StatusCode::OK, &headers, &b64);
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(resp.headers().get("content-type").unwrap(), "image/png");
|
|
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
|
|
assert_eq!(bytes.as_ref(), raw);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn binary_response_defaults_octet_stream_and_rejects_bad_base64() {
|
|
// No Content-Type set → octet-stream default.
|
|
let ok = binary_response(StatusCode::OK, &BTreeMap::new(), "aGk="); // "hi"
|
|
assert_eq!(
|
|
ok.headers().get("content-type").unwrap(),
|
|
"application/octet-stream"
|
|
);
|
|
// Invalid base64 → opaque 500 (author error), not a panic.
|
|
let bad = binary_response(StatusCode::OK, &BTreeMap::new(), "!!!not base64!!!");
|
|
assert_eq!(bad.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|