use std::collections::HashMap;
use std::sync::Arc;
use alkcall::client::AdapterError;
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
use alkcall::registry::context::OperationContext;
use alkcall::registry::registration::ResponseStream;
use futures::stream;
use futures::StreamExt;
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use reqwest::Method;
use serde_json::Value;
use url::Url;
use crate::adapters::input_validation::bounded_join;
use crate::adapters::input_validation::CompiledInputSchema;
use crate::client::SharedHttpClient;
pub(crate) const RESPONSE_BODY_CAP: usize = 16 * 1024 * 1024;
pub(crate) const ERROR_BODY_ECHO_CAP: usize = 4096;
const STATUS_BODY_DRAIN: usize = 64 * 1024;
pub(crate) const GATEWAY_BODY_KEY: &str = "body";
pub(crate) const HEADER_PARAM_IN_MARKER: &str = "wire";
pub(crate) const HEADER_PARAM_MARKER_VALUE: &str = "header";
pub(crate) fn validate_path_template(path_template: &str) -> Result<(), AdapterError> {
let mut rest = path_template;
while let Some(start) = rest.find('{') {
let Some(end_rel) = rest[start..].find('}') else {
return Err(AdapterError::SchemaParse {
message: format!("path template `{path_template}` has an unterminated placeholder"),
});
};
if rest[start + 1..start + end_rel].is_empty() {
return Err(AdapterError::SchemaParse {
message: format!("path template `{path_template}` has an empty placeholder name"),
});
}
rest = &rest[start + end_rel + 1..];
}
if path_template.contains('}') && !path_template.contains('{') {
return Err(AdapterError::SchemaParse {
message: format!("path template `{path_template}` has `}}` without a matching `{{`"),
});
}
Ok(())
}
#[derive(Clone)]
pub enum HttpAuthScheme {
Bearer,
ApiKey {
header_name: String,
},
Basic,
}
pub struct HttpServiceConfig {
pub namespace: String,
pub base_url: String,
pub auth: Option<HttpAuthScheme>,
pub default_headers: HashMap<String, String>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_request(
base_url: &str,
path_template: &str,
method: &str,
auth_scheme: &Option<HttpAuthScheme>,
default_headers: &HashMap<String, String>,
namespace: &str,
input_schema: &Value,
input_validator: Option<&CompiledInputSchema>,
input: &Value,
context: &OperationContext,
) -> Result<(Method, Url, Option<Value>, HeaderMap), CallError> {
let inputs = input.as_object().ok_or_else(|| {
CallError::invalid_input(format!(
"input must be a JSON object (got {}); adapter operations take a named-key input",
type_name_of(input)
))
})?;
enforce_input_schema(input_schema, inputs)?;
if let Some(validator) = input_validator {
validator.validate(input)?;
}
let mut query_params: Vec<(String, String)> = Vec::new();
let mut header_params: Vec<(String, String)> = Vec::new();
let param_locations = param_locations(input_schema);
let mut body: Option<Value> = None;
for (key, value) in inputs {
if is_path_placeholder(key, path_template) {
continue;
}
if key == GATEWAY_BODY_KEY {
body = Some(value.clone());
continue;
}
if param_locations.get(key.as_str()) == Some(&ParamLocation::Header) {
header_params.push((key.clone(), value_to_query(value)));
} else {
query_params.push((key.clone(), value_to_query(value)));
}
}
let rendered_path = render_path_template(path_template, Some(inputs))?;
let mut url = assemble_request_url(base_url, &rendered_path)?;
if !query_params.is_empty() {
let mut pairs = url.query_pairs_mut();
for (k, v) in &query_params {
pairs.append_pair(k, v);
}
}
let mut headers = HeaderMap::new();
for (k, v) in &header_params {
let name = HeaderName::try_from(k.as_str()).map_err(|_| {
CallError::internal(format!(
"declared header parameter `{k}` is not a valid HTTP header name; refusing to build a request that silently drops it"
))
})?;
let value = HeaderValue::try_from(v.as_str()).map_err(|_| {
CallError::internal(format!(
"declared header parameter `{k}` has an invalid value (control or non-ASCII bytes are not permitted); refusing to build a request that silently drops it"
))
})?;
headers.insert(name, value);
}
for (k, v) in default_headers {
let name = HeaderName::try_from(k.as_str()).map_err(|_| {
CallError::internal(format!(
"default header `{k}` is not a valid HTTP header name; refusing to build a request that silently drops it"
))
})?;
let value = HeaderValue::try_from(v.as_str()).map_err(|_| {
CallError::internal(format!(
"default header `{k}` has an invalid value (control or non-ASCII bytes are not permitted); refusing to build a request that silently drops it"
))
})?;
headers.insert(name, value);
}
if body.is_some() {
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
}
if let Some(scheme) = auth_scheme {
let secret = context.capabilities.get(namespace).ok_or_else(|| {
CallError::internal(format!(
"capability for namespace `{namespace}` is absent (the registry holds neither `api_key:{namespace}` nor `http_token:{namespace}`); refusing to send the request unauthenticated"
))
})?;
let credential = secret.expose_secret().clone();
match scheme {
HttpAuthScheme::Bearer => {
let value =
HeaderValue::try_from(format!("Bearer {credential}")).map_err(|_| {
CallError::internal(format!(
"credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated"
))
})?;
headers.insert(AUTHORIZATION, value);
}
HttpAuthScheme::ApiKey { header_name } => {
let name =
HeaderName::try_from(header_name.as_str()).map_err(|_| {
CallError::internal(format!(
"API-key auth for namespace `{namespace}` declares invalid header name `{header_name}`; refusing to send the request unauthenticated"
))
})?;
let value = HeaderValue::try_from(credential.as_str()).map_err(|_| {
CallError::internal(format!(
"credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated"
))
})?;
headers.insert(name, value);
}
HttpAuthScheme::Basic => {
let value =
HeaderValue::try_from(format!("Basic {credential}")).map_err(|_| {
CallError::internal(format!(
"credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated"
))
})?;
headers.insert(AUTHORIZATION, value);
}
}
}
let http_method = Method::from_bytes(method.as_bytes())
.map_err(|_| CallError::internal(format!("invalid HTTP method `{method}`")))?;
Ok((http_method, url, body, headers))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ParamLocation {
Query,
Header,
}
fn param_locations(input_schema: &Value) -> HashMap<&str, ParamLocation> {
let mut out = HashMap::new();
let Some(properties) = input_schema.get("properties").and_then(|p| p.as_object()) else {
return out;
};
for (name, schema) in properties {
let is_header = schema
.get(HEADER_PARAM_IN_MARKER)
.and_then(|w| w.as_str())
.is_some_and(|w| w == HEADER_PARAM_MARKER_VALUE);
out.insert(
name.as_str(),
if is_header {
ParamLocation::Header
} else {
ParamLocation::Query
},
);
}
out
}
fn type_name_of(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
fn enforce_input_schema(
input_schema: &Value,
inputs: &serde_json::Map<String, Value>,
) -> Result<(), CallError> {
let declared = input_schema
.get("properties")
.and_then(|p| p.as_object())
.map(|p| p.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let catch_all = input_schema.get("additionalProperties") == Some(&Value::Bool(true));
let unknown: Vec<String> = inputs
.keys()
.filter(|key| !catch_all && !declared.iter().any(|d| d == *key))
.cloned()
.collect();
if let Some(first) = unknown.first() {
let declared_list = if declared.is_empty() {
"none".to_string()
} else {
bounded_join(&declared)
};
return Err(CallError::invalid_input(format!(
"input key `{first}` is not declared by the operation's input schema \
(declared: {declared_list}); undeclared keys are rejected so peer \
input cannot shape the upstream request beyond the advertised contract"
)));
}
Ok(())
}
const PATH_VALUE_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'`')
.add(b'#')
.add(b'?')
.add(b'{')
.add(b'}')
.add(b'/')
.add(b'%')
.add(b'\\');
const PATH_AFTER_PERCENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'`')
.add(b'#')
.add(b'?')
.add(b'{')
.add(b'}')
.add(b'/')
.add(b'\\');
fn scalar_value_to_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
pub(crate) fn value_to_path_segment(value: &Value) -> Result<String, CallError> {
let raw = match value {
Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => {
scalar_value_to_string(value)
}
other => {
return Err(CallError::invalid_input(format!(
"path placeholder value must be a scalar (string, number, boolean, or null); got {}: structural values have no faithful single-segment rendering",
type_name_of(other)
)))
}
};
reject_lone_dot_value(value, &raw)?;
Ok(utf8_percent_encode(&raw, PATH_VALUE_ENCODE_SET).to_string())
}
fn reject_lone_dot_value(value: &Value, raw: &str) -> Result<(), CallError> {
let type_label = type_name_of(value);
let is_lone_dot = matches!(raw, "." | "..")
|| raw.eq_ignore_ascii_case("%2e")
|| raw.eq_ignore_ascii_case("%2e%2e");
if is_lone_dot {
return Err(CallError::invalid_input(format!(
"path placeholder value of type {type_label} decodes to a lone dot segment, which cannot appear in a rendered path (Url::set_path would silently normalize it away); pass a concrete non-dot value"
)));
}
Ok(())
}
fn value_to_query(value: &Value) -> String {
scalar_value_to_string(value)
}
pub(crate) fn render_path_template(
template: &str,
inputs: Option<&serde_json::Map<String, Value>>,
) -> Result<String, CallError> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
let mut unresolved: Vec<String> = Vec::new();
while let Some(start) = rest.find('{') {
let (head, tail) = rest.split_at(start);
out.push_str(head);
let Some(end) = tail.find('}') else {
return Err(CallError::internal(format!(
"invalid path template `{template}`: unterminated placeholder"
)));
};
let name = &tail[1..end];
let raw_value = match inputs.and_then(|map| map.get(name)) {
Some(v) => v,
None => {
unresolved.push(format!("{{{name}}}"));
rest = &tail[end + 1..];
continue;
}
};
out.push_str(&value_to_path_segment(raw_value)?);
rest = &tail[end + 1..];
}
out.push_str(rest);
if !unresolved.is_empty() {
return Err(CallError::internal(format!(
"path template `{template}` references unbound placeholder(s): {}",
bounded_join(&unresolved)
)));
}
Ok(out)
}
pub(crate) fn is_path_placeholder(input_name: &str, template: &str) -> bool {
let placeholder = format!("{{{input_name}}}");
template.contains(&placeholder)
}
fn parse_base_url(base_url: &str) -> Result<Url, CallError> {
let parsed = Url::parse(base_url)
.map_err(|e| CallError::internal(format!("invalid base_url `{base_url}`: {e}")))?;
let scheme = parsed.scheme();
if scheme != "https" && scheme != "http" {
return Err(CallError::internal(format!(
"base_url `{base_url}` must use https (or http for plain non-TLS origins); `{scheme}` is not an HTTP scheme"
)));
}
let host = parsed.host_str().unwrap_or_default();
if host.is_empty() {
return Err(CallError::internal(format!(
"base_url `{base_url}` must include an explicit host"
)));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(CallError::internal(format!(
"base_url `{base_url}` must not embed userinfo; credentials are injected per-operation from Capabilities"
)));
}
Ok(parsed)
}
fn request_path(rendered_path: &str) -> Result<String, CallError> {
let trimmed = rendered_path.trim_matches('/');
if trimmed.is_empty() {
return Err(CallError::internal(
"path template resolves to an empty request path; at least one segment is required",
));
}
let mut path = String::new();
for segment in trimmed.split('/') {
path.push('/');
let mut pieces = segment.split('%');
utf8_percent_encode_into(&mut path, pieces.next().unwrap_or_default());
for piece in pieces {
path.push('%');
utf8_percent_encode_into(&mut path, piece);
}
}
Ok(path)
}
fn utf8_percent_encode_into(out: &mut String, text: &str) {
for piece in utf8_percent_encode(text, PATH_AFTER_PERCENT_ENCODE_SET) {
out.push_str(piece);
}
}
fn assemble_request_url(base_url: &str, rendered_path: &str) -> Result<Url, CallError> {
let base = parse_base_url(base_url)?;
let request_path = request_path(rendered_path)?;
let base_path = base.path();
let base_dir = match base_path.strip_suffix('/') {
Some(stripped) => stripped,
None => base_path,
};
let mut full_path = String::with_capacity(base_dir.len() + request_path.len() + 1);
full_path.push_str(base_dir);
full_path.push_str(&request_path);
let mut url = base.clone();
url.set_path(&full_path);
assert_untouched_by_normalization(&url, base_dir, rendered_path, base_url)?;
let same_origin = url.scheme() == base.scheme()
&& url.host() == base.host()
&& url.port_or_known_default() == base.port_or_known_default();
if !same_origin {
return Err(CallError::internal(format!(
"request path `{rendered_path}` resolved against `{base_url}` changed the target origin: {} != {}",
url.origin().ascii_serialization(),
base.origin().ascii_serialization()
)));
}
Ok(url)
}
fn assert_untouched_by_normalization(
url: &Url,
base_dir: &str,
rendered_path: &str,
base_url: &str,
) -> Result<(), CallError> {
let rendered_segments = rendered_path
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned());
let base_segments: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect();
let expected: Vec<String> = base_segments
.iter()
.copied()
.map(str::to_string)
.chain(rendered_segments)
.collect();
let actual: Vec<String> = url
.path()
.split('/')
.filter(|s| !s.is_empty())
.map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
.collect();
let matches = actual.len() == expected.len()
&& std::iter::Iterator::zip(actual.iter(), expected.iter()).all(|(a, e)| a == e);
if matches {
Ok(())
} else {
Err(CallError::internal(format!(
"request path `{rendered_path}` resolved against `{base_url}` was rewritten by URL normalization: expected segments {expected:?}, got {actual:?}"
)))
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum BodyReadError {
#[error("upstream response body exceeds the {RESPONSE_BODY_CAP}-byte response cap")]
TooLarge,
#[error("transport error reading response body: {0}")]
Transport(reqwest::Error),
#[error("malformed response body: {0}")]
Decode(serde_json::Error),
}
pub(crate) fn is_json_content_type(content_type: &str) -> bool {
let essence = content_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match essence.split_once('/') {
Some(("application", subtype)) => subtype == "json" || subtype.ends_with("+json"),
_ => false,
}
}
fn bounded_error_body(bytes: bytes::Bytes) -> Option<String> {
if bytes.is_empty() {
return None;
}
let truncated = bytes.len() >= ERROR_BODY_ECHO_CAP;
let text = String::from_utf8_lossy(&bytes);
let printable: String = text
.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
.take(ERROR_BODY_ECHO_CAP)
.collect();
if printable.is_empty() {
None
} else if truncated {
Some(format!("{printable}\n[truncated]"))
} else {
Some(printable)
}
}
async fn read_body_capped(
response: reqwest::Response,
cap: usize,
) -> Result<bytes::Bytes, BodyReadError> {
let mut stream = response.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(BodyReadError::Transport)?;
if buf.len().saturating_add(chunk.len()) > cap {
return Err(BodyReadError::TooLarge);
}
buf.extend_from_slice(&chunk);
}
Ok(buf.into())
}
async fn success_envelope(response: reqwest::Response, request_id: &str) -> ResponseEnvelope {
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v: &reqwest::header::HeaderValue| v.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
let essence = content_type.split(';').next().unwrap_or_default().trim();
let read = if is_json_content_type(&content_type) {
read_body_capped(response, RESPONSE_BODY_CAP)
.await
.and_then(|bytes| {
serde_json::from_slice::<Value>(&bytes)
.map(|v| ResponseEnvelope::ok(request_id, v))
.map_err(BodyReadError::Decode)
})
} else if essence.starts_with("text/") {
read_body_capped(response, RESPONSE_BODY_CAP)
.await
.map(|bytes| {
ResponseEnvelope::ok(
request_id,
Value::String(String::from_utf8_lossy(&bytes).into_owned()),
)
})
} else {
read_body_capped(response, RESPONSE_BODY_CAP)
.await
.map(|bytes| {
let arr: Vec<Value> = bytes
.iter()
.map(|byte| Value::Number((*byte).into()))
.collect();
ResponseEnvelope::ok(request_id, Value::Array(arr))
})
};
match read {
Ok(envelope) => envelope,
Err(BodyReadError::TooLarge) => ResponseEnvelope::error(
request_id,
CallError::new(
"HTTP_413",
format!("upstream response body exceeds the {RESPONSE_BODY_CAP}-byte response cap"),
false,
),
),
Err(err) => ResponseEnvelope::error(
request_id,
CallError::internal(format!("failed to decode response body: {err}")),
),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn forward(
http_client: &Arc<SharedHttpClient>,
base_url: &str,
path_template: &str,
method: &str,
auth_scheme: &Option<HttpAuthScheme>,
default_headers: &HashMap<String, String>,
namespace: &str,
input_schema: &Value,
error_status_codes: &[(u16, String)],
input_validator: Option<&CompiledInputSchema>,
input: Value,
context: OperationContext,
) -> ResponseEnvelope {
let request_id = context.request_id.clone();
let (http_method, url, body, headers) = match build_request(
base_url,
path_template,
method,
auth_scheme,
default_headers,
namespace,
input_schema,
input_validator,
&input,
&context,
) {
Ok(parts) => parts,
Err(err) => return ResponseEnvelope::error(request_id, err),
};
let http_client = http_client.client();
let request_builder = http_client
.request(http_method, url.as_str())
.headers(headers)
.header(ACCEPT, "*/*");
let request_builder = match body.as_ref() {
Some(b) => {
let serialized = match serde_json::to_string(b) {
Ok(s) => s,
Err(err) => {
return ResponseEnvelope::error(
request_id,
CallError::internal(format!("failed to serialize request body: {err}")),
);
}
};
request_builder.body(serialized)
}
None => request_builder,
};
let response: reqwest::Response = match request_builder.send().await {
Ok(r) => r,
Err(err) => {
return ResponseEnvelope::error(
request_id,
CallError::internal(format!("HTTP request failed: {err}")),
);
}
};
if !response.status().is_success() {
return error_envelope(response, &request_id, error_status_codes).await;
}
success_envelope(response, &request_id).await
}
async fn error_envelope(
response: reqwest::Response,
request_id: &str,
error_status_codes: &[(u16, String)],
) -> ResponseEnvelope {
let status = response.status();
let code = error_status_codes
.iter()
.find(|(s, _)| *s == status.as_u16())
.map(|(_, c)| c.clone())
.unwrap_or_else(|| format!("HTTP_{}", status.as_u16()));
let mut message = format!(
"HTTP {}: {}",
status.as_u16(),
status.canonical_reason().unwrap_or("")
);
match read_body_capped(response, STATUS_BODY_DRAIN).await {
Ok(bytes) => {
if let Some(echo) = bounded_error_body(bytes) {
message.push_str(": ");
message.push_str(&echo);
}
}
Err(BodyReadError::TooLarge) => {
message.push_str(": [error body too large to echo]");
}
Err(_) => {}
}
ResponseEnvelope::error(request_id, CallError::new(code, message, false))
}
fn sse_event_envelope(event: SseEvent, request_id: &str) -> ResponseEnvelope {
let parsed = if event.data.trim().is_empty() {
Value::Null
} else {
match serde_json::from_str::<Value>(&event.data) {
Ok(value) => value,
Err(_) => serde_json::json!({
"data": event.data,
"event": event.event,
}),
}
};
ResponseEnvelope::ok(request_id, parsed)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn forward_stream(
http_client: &Arc<SharedHttpClient>,
base_url: &str,
path_template: &str,
method: &str,
auth_scheme: &Option<HttpAuthScheme>,
default_headers: &HashMap<String, String>,
namespace: &str,
input_schema: &Value,
error_status_codes: &[(u16, String)],
input_validator: Option<&CompiledInputSchema>,
input: Value,
context: OperationContext,
) -> ResponseStream {
let request_id = context.request_id.clone();
let (http_method, url, body, headers) = match build_request(
base_url,
path_template,
method,
auth_scheme,
default_headers,
namespace,
input_schema,
input_validator,
&input,
&context,
) {
Ok(parts) => parts,
Err(err) => {
return Box::pin(stream::once(async move {
ResponseEnvelope::error(request_id, err)
}));
}
};
let http_client = Arc::clone(http_client);
let error_status_codes = error_status_codes.to_vec();
let request_id_stream = request_id.clone();
let error_status_codes_stream = error_status_codes.clone();
let stream_byte_cap = http_client.config().stream_total_byte_cap;
let init = async move {
let request_builder = http_client
.stream_client()
.request(http_method, url.as_str())
.headers(headers)
.header(ACCEPT, "text/event-stream");
let request_builder = match body.as_ref() {
Some(b) => match serde_json::to_string(b) {
Ok(serialized) => request_builder.body(serialized),
Err(err) => {
return Err(CallError::internal(format!(
"failed to serialize request body: {err}"
)));
}
},
None => request_builder,
};
request_builder
.send()
.await
.map_err(|err| CallError::internal(format!("HTTP request failed: {err}")))
};
let sse = stream::once(init).flat_map(move |result| {
let request_id = request_id_stream.clone();
let error_status_codes = error_status_codes_stream.clone();
match result {
Err(err) => Box::pin(stream::once(async move {
ResponseEnvelope::error(request_id, err)
})) as ResponseStream,
Ok(response) => {
let status = response.status();
if !status.is_success() {
let request_id = request_id.clone();
Box::pin(stream::once(async move {
error_envelope(response, &request_id, &error_status_codes).await
})) as ResponseStream
} else {
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v: &reqwest::header::HeaderValue| v.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
if !is_sse_content_type(&content_type) {
let message = format!(
"upstream returned Content-Type `{content_type}` on a subscription operation; expected `text/event-stream`"
);
return Box::pin(stream::once(async move {
ResponseEnvelope::error(
request_id,
CallError::new("INVALID_RESPONSE_TYPE", message, false),
)
})) as ResponseStream;
}
let request_id_inner = request_id.clone();
Box::pin(
stream::unfold(
(
response.bytes_stream(),
SseParser::new(),
false,
0u64,
),
move |(mut bytes, mut parser, broken, mut total_bytes)| {
let request_id = request_id_inner.clone();
async move {
if broken {
return None;
}
match bytes.next().await {
Some(Ok(chunk)) => {
let chunk_len = chunk.len() as u64;
if stream_byte_cap > 0
&& total_bytes.saturating_add(chunk_len)
> stream_byte_cap
{
let error = CallError::new(
"HTTP_413",
format!(
"upstream SSE stream exceeded the {stream_byte_cap}-byte total streamed-bytes cap on a subscription operation"
),
false,
);
return Some((
vec![ResponseEnvelope::error(
request_id, error,
)],
(bytes, parser, true, total_bytes),
));
}
total_bytes = total_bytes.saturating_add(chunk_len);
match parser.feed(&chunk, false) {
Ok(events) => {
let envelopes: Vec<ResponseEnvelope> =
events
.into_iter()
.map(|e| {
sse_event_envelope(
e, &request_id,
)
})
.collect();
Some((
envelopes,
(bytes, parser, false, total_bytes),
))
}
Err(err) => {
let error = CallError::internal(format!(
"SSE parse error: {err}"
));
Some((
vec![ResponseEnvelope::error(
request_id, error,
)],
(bytes, parser, true, total_bytes),
))
}
}
},
Some(Err(err)) => {
let error = CallError::internal(format!(
"SSE stream error: {err}"
));
Some((
vec![ResponseEnvelope::error(request_id, error)],
(bytes, parser, true, total_bytes),
))
}
None => match parser.feed(&[], true) {
Ok(events) if !events.is_empty() => {
let envelopes: Vec<ResponseEnvelope> = events
.into_iter()
.map(|e| sse_event_envelope(e, &request_id))
.collect();
Some((
envelopes,
(bytes, parser, true, total_bytes),
))
}
_ => None,
},
}
}
},
)
.flat_map(stream::iter),
) as ResponseStream
}
}
}
});
Box::pin(sse)
}
fn is_sse_content_type(content_type: &str) -> bool {
content_type.split(';').next().unwrap_or_default().trim() == "text/event-stream"
}
pub(crate) struct SseEvent {
pub(crate) data: String,
pub(crate) event: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum SseParseError {
#[error("SSE event buffer exceeded {SSE_EVENT_BUFFER_CAP} bytes without a complete event")]
BufferOverflow,
}
pub(crate) const SSE_EVENT_BUFFER_CAP: usize = 1024 * 1024;
pub(crate) struct SseParser {
buf: Vec<u8>,
data_lines: Vec<String>,
data_seen: bool,
event_name: Option<String>,
bom_stripped: bool,
}
impl SseParser {
pub(crate) fn new() -> Self {
Self {
buf: Vec::new(),
data_lines: Vec::new(),
data_seen: false,
event_name: None,
bom_stripped: false,
}
}
pub(crate) fn feed(&mut self, chunk: &[u8], eof: bool) -> Result<Vec<SseEvent>, SseParseError> {
if self.buf.len().saturating_add(chunk.len()) > SSE_EVENT_BUFFER_CAP {
return Err(SseParseError::BufferOverflow);
}
self.buf.extend_from_slice(chunk);
let mut events = Vec::new();
let mut start = 0usize;
while let Some(nl) = self.buf[start..].iter().position(|&b| b == b'\n') {
let end = start + nl;
let line_end = if end > start && self.buf[end - 1] == b'\r' {
end - 1
} else {
end
};
let line = self.buf[start..line_end].to_vec();
if let Some(event) = self.parse_line(&line) {
events.push(event);
}
start = end + 1;
}
if eof {
if start < self.buf.len() {
let line = self.buf[start..].to_vec();
if let Some(event) = self.parse_line(&line) {
events.push(event);
}
}
if self.data_seen {
if let Some(event) = self.complete_event() {
events.push(event);
}
}
self.buf.clear();
} else {
self.buf.drain(..start);
}
Ok(events)
}
fn parse_line(&mut self, line: &[u8]) -> Option<SseEvent> {
if !self.bom_stripped {
self.bom_stripped = true;
let bom = b"\xef\xbb\xbf";
let line = if line.starts_with(bom) {
&line[bom.len()..]
} else {
line
};
return self.parse_line(line);
}
let text = match std::str::from_utf8(line) {
Ok(t) => t,
Err(_) => return None,
};
if text.is_empty() {
return if self.data_seen {
self.complete_event()
} else {
self.discard_event();
None
};
}
if text.starts_with(':') {
return None;
}
let (field, value) = match text.split_once(':') {
Some((f, v)) => (f, v.strip_prefix(' ').unwrap_or(v)),
None => (text, ""),
};
if field == "data" {
self.data_lines.push(value.to_string());
self.data_seen = true;
} else if field == "event" && !value.is_empty() {
self.event_name = Some(value.to_string());
}
None
}
fn complete_event(&mut self) -> Option<SseEvent> {
self.data_seen = false;
Some(SseEvent {
data: std::mem::take(&mut self.data_lines).join("\n"),
event: self.event_name.take(),
})
}
fn discard_event(&mut self) {
self.data_seen = false;
self.event_name = None;
self.data_lines.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use alkcall::core::types::Capabilities;
use alkcall::registry::context::{AbortPolicy, ScopedPeerEnv};
use serde_json::json;
use std::collections::HashMap as TestHashMap;
use std::sync::Arc as TestArc;
use std::time::Duration;
struct CapturedRequest {
#[allow(dead_code)]
method: String,
#[allow(dead_code)]
target: String,
#[allow(dead_code)]
headers: TestHashMap<String, String>,
}
fn noop_context() -> OperationContext {
struct NoopEnv;
#[async_trait::async_trait]
impl alkcall::registry::env::OperationEnv for NoopEnv {
async fn invoke_with_policy(
&self,
_ns: &str,
_op: &str,
_input: Value,
parent: &OperationContext,
_policy: AbortPolicy,
) -> ResponseEnvelope {
ResponseEnvelope::ok(parent.request_id.clone(), Value::Null)
}
fn contains(&self, _name: &str) -> bool {
false
}
}
OperationContext {
request_id: "req-fwd".to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: TestHashMap::new(),
scoped_env: ScopedPeerEnv::empty(),
env: TestArc::new(NoopEnv),
abort_policy: AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: true,
ownership: None,
}
}
fn request_url(base_url: &str, template: &str, input: Value) -> Result<url::Url, CallError> {
let ctx = noop_context();
let (_, url, _, _) = build_request(
base_url,
template,
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object", "additionalProperties": true}),
None,
&input,
&ctx,
)?;
Ok(url)
}
#[test]
fn traversal_value_cannot_escape_template_path() {
let url = request_url(
"https://api.example.com",
"/repos/{owner}/{repo}/issues",
json!({"owner": "../../admin", "repo": "x"}),
)
.expect("request builds");
assert_eq!(url.host_str(), Some("api.example.com"));
assert_eq!(url.path(), "/repos/..%2F..%2Fadmin/x/issues");
assert!(!url.path().contains("/admin"));
}
#[test]
fn structural_characters_cannot_split_or_inject_url_parts() {
let url = request_url(
"https://api.example.com",
"/files/{name}",
json!({"name": "a?via=query#frag"}),
)
.expect("request builds");
assert_eq!(url.query(), None, "`?` in a value must be encoded");
assert_eq!(url.fragment(), None, "`#` in a value must be encoded");
assert_eq!(url.path(), "/files/a%3Fvia=query%23frag");
let url = request_url(
"https://api.example.com",
"/files/{name}",
json!({"name": "a b/c\\d"}),
)
.expect("request builds");
assert_eq!(url.path_segments().map(|s| s.count()), Some(2));
assert_eq!(url.path(), "/files/a%20b%2Fc%5Cd");
let url = request_url(
"https://api.example.com",
"/files/{name}",
json!({"name": "héllo→世界"}),
)
.expect("request builds");
assert_eq!(url.path(), "/files/h%C3%A9llo%E2%86%92%E4%B8%96%E7%95%8C");
}
#[test]
fn rendering_is_single_pass_and_never_re_substitutes() {
let url = request_url(
"https://api.example.com",
"/x/{a}/{b}",
json!({"a": "{b}", "b": "second"}),
)
.expect("request builds");
assert_eq!(url.path(), "/x/%7Bb%7D/second");
let rendered = render_path_template(
"/x/{a}",
Some(&json!({"a": "../../{b}"}).as_object().unwrap().clone()),
)
.expect("renders");
assert_eq!(rendered, "/x/..%2F..%2F%7Bb%7D");
}
#[test]
fn base_path_prefix_is_preserved() {
let url = request_url("https://api.openai.com/v1", "/chat/completions", json!({}))
.expect("request builds");
assert_eq!(url.path(), "/v1/chat/completions");
let url =
request_url("https://api.example.com", "/data", json!({})).expect("request builds");
assert_eq!(url.path(), "/data");
}
#[test]
fn absolute_url_in_input_cannot_change_origin_or_hop_paths() {
let url = request_url(
"https://api.example.com",
"/fetch/{url}",
json!({"url": "http://169.254.169.254/latest/meta-data"}),
)
.expect("absolute URL in a path value stays an encoded segment");
assert_eq!(url.host_str(), Some("api.example.com"));
assert_eq!(
url.path(),
"/fetch/http:%2F%2F169.254.169.254%2Flatest%2Fmeta-data"
);
let url = request_url(
"https://api.example.com",
"/fetch/{target}",
json!({"target": "https://evil.example.com/x"}),
)
.expect("https absolute URL also stays an encoded segment");
assert_eq!(url.host_str(), Some("api.example.com"));
assert_eq!(url.path(), "/fetch/https:%2F%2Fevil.example.com%2Fx");
}
#[test]
fn unbound_and_malformed_templates_error_loudly() {
let err = request_url("https://api.example.com", "/x/{missing}", json!({}))
.expect_err("unbound placeholder must error");
assert!(err.message.contains("unbound placeholder"));
let err = request_url("https://api.example.com", "/x/{open", json!({}))
.expect_err("unterminated placeholder must error");
assert!(err.message.contains("unterminated"));
let err = request_url("https://api.example.com", "/x{missing}/a/b", json!({}))
.expect_err("partial render without the placeholder is still loud");
assert!(err.message.contains("unbound placeholder"));
}
#[test]
fn base_url_validation_rejects_bad_inputs() {
let err = request_url("ftp://api.example.com", "/x", json!({}))
.expect_err("non-http scheme must be rejected");
assert!(err.message.contains("not an HTTP scheme"));
let err = request_url("https://u:p@api.example.com", "/x", json!({}))
.expect_err("userinfo must be rejected");
assert!(err.message.contains("userinfo"));
let err = request_url("not a url at all", "/x", json!({}))
.expect_err("unparseable base must be rejected");
assert!(err.message.contains("invalid base_url"));
}
#[test]
fn query_values_remain_encoded_via_query_pairs_mut() {
let url = request_url(
"https://api.example.com",
"/search",
json!({"q": "a&b=c d", "lang": "en"}),
)
.expect("request builds");
assert_eq!(url.query(), Some("lang=en&q=a%26b%3Dc+d"));
}
#[test]
fn undeclared_input_keys_are_rejected_not_sent_upstream() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"owner": {"type": "string"}, "body": {"type": "object"}},
});
for input in [
json!({"owner": "a", "debug": "true"}),
json!({"owner": "a", "impersonate_id": "x"}),
json!({"debug": "true"}),
] {
let err = build_request(
"https://api.example.com",
"/x/{owner}",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
None,
&input,
&ctx,
)
.expect_err("undeclared peer input must be rejected");
assert_eq!(err.code, "INVALID_INPUT", "input was: {input}");
assert!(err.message.contains("not declared"), "input was: {input}");
}
}
#[test]
fn compiled_input_schema_enforces_leaf_constraints_as_invalid_input() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {
"id": {"type": "string"},
"level": {"enum": ["low", "high"]},
"n": {"minimum": 1},
"tag": {"pattern": "^[a-z]+$"},
},
"required": ["id"],
});
let validator = Some(CompiledInputSchema::compile(&schema).expect("schema compiles"));
for (input, keyword) in [
(json!({}), "required"),
(json!({"id": 7}), "type"),
(json!({"id": "x", "level": "medium"}), "enum"),
(json!({"id": "x", "n": 0}), "minimum"),
(json!({"id": "x", "tag": "UPPER"}), "pattern"),
] {
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
validator.as_ref(),
&input,
&ctx,
)
.expect_err("violated leaf constraint must be rejected");
assert_eq!(err.code, "INVALID_INPUT", "input was: {input}");
assert!(
err.message.contains(&format!("[keyword: {keyword}")),
"input {input} must name the violated keyword `{keyword}`: {}",
err.message
);
}
}
#[test]
fn without_a_compiled_validator_enforcement_stays_allowlist_only() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
});
let (_, url, _, _) = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
None,
&json!({}),
&ctx,
)
.expect("allowlist-only enforcement accepts the empty input");
assert_eq!(url.path(), "/x");
}
#[test]
fn catch_all_opt_in_keeps_working_under_the_compiled_validator() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"q": {"type": "string"}},
"additionalProperties": true,
});
let validator = Some(CompiledInputSchema::compile(&schema).expect("schema compiles"));
build_request(
"https://api.example.com",
"/search",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
validator.as_ref(),
&json!({"debug": true}),
&ctx,
)
.expect("the catch-all still accepts undeclared keys");
let err = build_request(
"https://api.example.com",
"/search",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
validator.as_ref(),
&json!({"q": {"object": "value"}}),
&ctx,
)
.expect_err("a declared property's type still binds under the catch-all");
assert_eq!(err.code, "INVALID_INPUT");
assert!(err.message.contains("[keyword: type"), "{}", err.message);
}
#[test]
fn declared_body_and_header_params_route_off_the_query_string() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {
"q": {"type": "string"},
"X-Trace-Id": {"type": "string", "wire": "header"},
"body": {"type": "object"},
},
});
let (_, url, body, headers) = build_request(
"https://api.example.com",
"/search",
"POST",
&None,
&TestHashMap::new(),
"svc",
&schema,
None,
&json!({"q": "rust", "X-Trace-Id": "t-1", "body": {"page": 2}}),
&ctx,
)
.expect("declared input builds");
assert_eq!(url.query(), Some("q=rust"));
assert_eq!(
headers
.get("x-trace-id")
.expect("header param sent as header")
.to_str()
.expect("ascii header"),
"t-1"
);
assert_eq!(body, Some(json!({"page": 2})));
}
#[test]
fn non_object_input_is_rejected() {
let ctx = noop_context();
let schema = json!({"type": "object", "properties": {"q": {"type": "string"}}});
for input in [json!(null), json!([1]), json!("str"), json!(42)] {
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
None,
&input,
&ctx,
)
.expect_err("non-object input must be rejected");
assert_eq!(err.code, "INVALID_INPUT");
}
}
#[test]
fn additional_properties_true_opts_into_catch_all_input() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"q": {"type": "string"}},
"additionalProperties": true,
});
let (_, url, _, _) = build_request(
"https://api.example.com",
"/search",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
None,
&json!({"q": "rust", "debug": "true"}),
&ctx,
)
.expect("catch-all input builds");
let q = url.query().expect("query present");
assert!(q.contains("q=rust") && q.contains("debug=true"), "{q}");
}
#[test]
fn placeholder_keys_never_double_route_as_query_params() {
let url = request_url(
"https://api.example.com",
"/repos/{owner}",
json!({"owner": "octocat", "extra": "q-val"}),
)
.expect("scalar placeholder builds");
assert_eq!(url.path(), "/repos/octocat");
assert_eq!(
url.query(),
Some("extra=q-val"),
"only the non-placeholder key routes to query"
);
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"id": {"type": "string"}},
});
let err = build_request(
"https://api.example.com",
"/items/{id}",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
None,
&json!({"id": "x", "undeclared": "v"}),
&ctx,
)
.expect_err("undeclared key still rejected with a placeholder present");
assert_eq!(err.code, "INVALID_INPUT");
}
#[test]
fn object_or_array_path_values_error_instead_of_splicing_json_into_the_path() {
for value in [json!({"a": 1}), json!([1, 2])] {
let err = request_url(
"https://api.example.com",
"/things/{id}",
json!({"id": value}),
)
.expect_err("structural path values must be rejected");
assert_eq!(err.code, "INVALID_INPUT", "value was: {value}");
assert!(
err.message.contains("must be a scalar"),
"value was: {value}"
);
assert!(
!err.message.contains("{\"a\":1}") && !err.message.contains("[1,2]"),
"error must not echo the structural value: {value}"
);
}
for value in [json!("text"), json!(42), json!(true)] {
let url = request_url(
"https://api.example.com",
"/things/{id}",
json!({ "id": value }),
)
.expect("scalar path values still render");
assert!(
url.path().starts_with("/things/"),
"scalar {value} renders into the path"
);
}
}
#[test]
fn percent_in_template_text_survives_and_percent_in_values_are_always_encoded() {
let url = request_url(
"https://api.example.com",
"/s3%2Fkeys/{name}",
json!({"name": "x"}),
)
.expect("template text may carry literal percent escapes");
assert_eq!(url.path(), "/s3%2Fkeys/x");
let url = request_url(
"https://api.example.com",
"/files/{name}",
json!({"name": "a%2Fb"}),
)
.expect("value percent is encoded, not preserved");
assert_eq!(url.path(), "/files/a%252Fb");
}
#[test]
fn url_set_path_normalizes_lone_dot_and_dot_dot_path_values() {
let mut url = Url::parse("https://api.example.com").expect("parses");
for (spliced, normalized) in [
("/tenants/../resources", "/resources"),
("/tenants/./resources", "/tenants/resources"),
("/files/..", "/"),
("/repos/%2E%2E/x", "/x"),
] {
url.set_path(spliced);
assert_eq!(
url.path(),
normalized,
"set_path silently normalized `{spliced}`"
);
}
}
#[test]
fn lone_dot_dot_path_value_is_rejected() {
let err = request_url(
"https://api.example.com",
"/tenants/{tenant}/resources",
json!({"tenant": ".."}),
)
.expect_err("lone `..` value must be rejected");
assert_eq!(err.code, "INVALID_INPUT");
assert!(
err.message.contains("lone dot segment"),
"message must explain the lone-dot rejection: {}",
err.message
);
assert!(
!err.message.contains(".."),
"error must not echo the raw value: {}",
err.message
);
}
#[test]
fn lone_dot_path_value_is_rejected() {
let err = request_url(
"https://api.example.com",
"/tenants/{tenant}/resources",
json!({"tenant": "."}),
)
.expect_err("lone `.` value must be rejected");
assert_eq!(err.code, "INVALID_INPUT");
}
#[test]
fn percent_escapes_spellings_of_lone_dots_are_rejected() {
for value in ["\u{2e}\u{2e}", "%2e%2e", "%2E%2e", "%2e%2E"] {
let err = request_url(
"https://api.example.com",
"/tenants/{tenant}/resources",
json!({ "tenant": value }),
)
.expect_err("escaped lone-dot spellings must be rejected");
assert_eq!(err.code, "INVALID_INPUT", "value was: {value}");
}
for value in ["%2e", "%2E"] {
let err = request_url(
"https://api.example.com",
"/tenants/{tenant}/resources",
json!({ "tenant": value }),
)
.expect_err("escaped lone-dot spellings must be rejected");
assert_eq!(err.code, "INVALID_INPUT", "value was: {value}");
}
}
#[test]
fn dotted_but_not_lone_dot_values_still_render() {
for value in [
"v1.2.3",
".hidden-file",
"..hidden",
"hidden..",
"a..b",
".a.b.",
"...",
] {
let url = request_url(
"https://api.example.com",
"/files/{name}",
json!({ "name": value }),
)
.unwrap_or_else(|e| panic!("value `{value}` must render: {e:?}"));
assert!(
url.path().starts_with("/files/"),
"value `{value}` rendered into the path"
);
}
}
#[test]
fn post_set_path_invariant_holds_across_dot_percent_binary_corpus() {
let corpus = [
"v1.2.3",
".hidden-file",
"..",
".",
"%2e",
"%2E",
"%2e%2e",
"%2E%2E",
"%252e",
"hidden..",
"a..b",
"...",
"a%2Fb",
"a b",
"h\\éllo→世界",
"line\nbreak",
"tab\tchar",
"\u{7f}\u{1f600}",
];
for value in corpus {
let outcome = request_url(
"https://api.example.com/v1",
"/files/{name}",
json!({ "name": value }),
);
match outcome {
Ok(url) => {
assert_eq!(url.host_str(), Some("api.example.com"));
let segments: Vec<String> = url
.path_segments()
.map(|s| {
s.map(|seg| percent_decode_str(seg).decode_utf8_lossy().into_owned())
.collect()
})
.unwrap_or_default();
assert_eq!(
segments.len(),
3,
"value `{value:?}` must render as one literal segment under /v1/files/"
);
assert_eq!(
segments.get(2).map(String::as_str),
Some(value),
"value `{value:?}` must survive byte-identical as the final segment"
);
assert!(
!segments.iter().any(|s| s == "." || s == ".."),
"value `{value:?}` must not leave lone dot segments: {segments:?}"
);
}
Err(err) => {
assert_eq!(
err.code, "INVALID_INPUT",
"value `{value:?}` may only fail as INVALID_INPUT"
);
}
}
}
}
fn ctx_with_capability(namespace: &str, value: String) -> OperationContext {
let mut ctx = noop_context();
ctx.capabilities = Capabilities::new().with_http_token(namespace, value);
ctx
}
fn minimal_client() -> TestArc<SharedHttpClient> {
TestArc::new(
SharedHttpClient::new(crate::client::HttpClientConfig::default())
.expect("client builds"),
)
}
type ServerResponder = Arc<dyn Fn(&CapturedRequest) -> http::Response<Vec<u8>> + Send + Sync>;
async fn spawn_responder(responder: ServerResponder) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("local addr");
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
let mut buf = vec![0u8; 8192];
let mut n = 0;
loop {
let read = sock.read(&mut buf[n..]).await.unwrap_or(0);
if read == 0 {
break;
}
n += read;
if String::from_utf8_lossy(&buf[..n]).contains("\r\n\r\n") {
break;
}
}
let request = String::from_utf8_lossy(&buf[..n]);
let mut lines = request.lines();
let request_line = lines.next().unwrap_or_default();
let mut parts_iter = request_line.split_whitespace();
let method = parts_iter.next().unwrap_or("GET").to_string();
let target = parts_iter.next().unwrap_or("/").to_string();
let mut headers = TestHashMap::new();
for line in lines {
if line.is_empty() {
break;
}
if let Some((k, v)) = line.split_once(':') {
headers.insert(k.trim().to_lowercase(), v.trim().to_string());
}
}
let response = (responder)(&CapturedRequest {
method,
target,
headers,
});
let mut head = format!("HTTP/1.1 {}\r\n", response.status());
for (name, value) in response.headers() {
head.push_str(&format!(
"{}: {}\r\n",
name,
value.to_str().unwrap_or_default()
));
}
head.push_str(&format!(
"content-length: {}\r\n\r\n",
response.body().len()
));
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(response.body()).await;
let _ = sock.flush().await;
let _ = sock.shutdown().await;
}
});
format!("http://{addr}")
}
async fn collect_stream(mut stream: ResponseStream) -> Vec<ResponseEnvelope> {
let mut out = Vec::new();
while let Some(envelope) = stream.next().await {
out.push(envelope);
}
out
}
fn http_response(status: u16, content_type: &str, body: Vec<u8>) -> http::Response<Vec<u8>> {
let mut builder = http::Response::builder().status(status);
if !content_type.is_empty() {
builder = builder.header("content-type", content_type);
}
builder.body(body).expect("static response builds")
}
async fn spawn_sse_responder_with_writer<F, Fut>(head: &str, writer: F) -> String
where
F: FnOnce(tokio::net::tcp::OwnedWriteHalf) -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + Send,
{
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("local addr");
let head = head.to_string();
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let mut buf = vec![0u8; 8192];
loop {
let read = sock.read(&mut buf).await.unwrap_or(0);
if read == 0 || String::from_utf8_lossy(&buf).contains("\r\n\r\n") {
break;
}
}
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.flush().await;
let (_, write_half) = sock.into_split();
writer(write_half).await;
});
format!("http://{addr}")
}
fn streaming_client(total_byte_cap: u64) -> TestArc<SharedHttpClient> {
TestArc::new(
SharedHttpClient::new(crate::client::HttpClientConfig {
stream_total_byte_cap: total_byte_cap,
..crate::client::HttpClientConfig::default()
})
.expect("client builds"),
)
}
fn client_with_timeout(timeout: Duration) -> TestArc<SharedHttpClient> {
TestArc::new(
SharedHttpClient::new(crate::client::HttpClientConfig {
request_timeout: Some(timeout),
connect_timeout: Some(Duration::from_secs(5)),
read_timeout: Some(timeout),
..crate::client::HttpClientConfig::default()
})
.expect("client builds"),
)
}
async fn call_forward(base_url: &str, ctx: OperationContext) -> ResponseEnvelope {
call_forward_authed(base_url, ctx, &None).await
}
async fn call_forward_authed(
base_url: &str,
ctx: OperationContext,
auth_scheme: &Option<HttpAuthScheme>,
) -> ResponseEnvelope {
forward(
&minimal_client(),
base_url,
"/x",
"GET",
auth_scheme,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
ctx,
)
.await
}
#[tokio::test]
async fn vendor_json_content_types_decode_as_json() {
for content_type in [
"application/vnd.api+json",
"application/problem+json",
"application/hal+json; charset=utf-8",
"application/json",
"APPLICATION/JSON",
] {
let base = spawn_responder(TestArc::new(move |_parts| {
http_response(200, content_type, br#"{"ok":true}"#.to_vec())
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Ok(Value::Object(map)) => assert_eq!(map["ok"], json!(true), "{content_type}"),
other => panic!("{content_type}: expected JSON object, got {other:?}"),
}
}
}
#[tokio::test]
async fn oversized_json_body_trips_the_response_cap() {
let response = http_response(200, "application/json", vec![b'['; RESPONSE_BODY_CAP + 1]);
let base = spawn_responder(TestArc::new(move |_| response.clone())).await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "HTTP_413");
assert!(err.message.contains("response cap"));
}
other => panic!("expected cap error, got {other:?}"),
}
}
#[tokio::test]
async fn oversized_text_body_trips_the_response_cap() {
let response = http_response(200, "text/plain", vec![b'a'; RESPONSE_BODY_CAP + 1]);
let base = spawn_responder(TestArc::new(move |_| response.clone())).await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => assert_eq!(err.code, "HTTP_413"),
other => panic!("expected cap error, got {other:?}"),
}
}
#[tokio::test]
async fn oversized_binary_body_trips_the_response_cap() {
let response = http_response(
200,
"application/octet-stream",
vec![0u8; RESPONSE_BODY_CAP + 1],
);
let base = spawn_responder(TestArc::new(move |_| response.clone())).await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => assert_eq!(err.code, "HTTP_413"),
other => panic!("expected cap error, got {other:?}"),
}
}
#[tokio::test]
async fn non_sse_content_type_on_a_sub_stream_errors_loudly() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(200, "text/html", b"<html>hello</html>".to_vec())
}))
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(
envelopes.len(),
1,
"exactly one loud error, not an empty stream"
);
match &envelopes[0].result {
Err(err) => {
assert_eq!(err.code, "INVALID_RESPONSE_TYPE");
assert!(err.message.contains("text/html"));
assert!(err.message.contains("text/event-stream"));
}
other => panic!("expected content-type error, got {other:?}"),
}
}
#[tokio::test]
async fn sse_content_type_still_streams() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(
200,
"text/event-stream; charset=utf-8",
b"data: {\"n\":1}\n\ndata: done\n\n".to_vec(),
)
}))
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(envelopes.len(), 2);
assert!(envelopes[0].result.is_ok());
assert!(envelopes[1].result.is_ok());
}
#[tokio::test]
async fn sse_payload_contract_decodes_json_and_wraps_non_json() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(
200,
"text/event-stream",
b"data: 123\n\ndata: \"123\"\n\ndata: plain text\n\ndata: {\"n\":1}\n\n".to_vec(),
)
}))
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(envelopes.len(), 4);
assert_eq!(envelopes[0].result.clone().unwrap(), json!(123));
assert_eq!(envelopes[1].result.clone().unwrap(), json!("123"));
assert_eq!(
envelopes[2].result.clone().unwrap(),
json!({"data": "plain text", "event": null})
);
assert_eq!(envelopes[3].result.clone().unwrap(), json!({"n": 1}));
}
#[test]
fn sse_parser_crlf_split_across_chunks_frames_one_line() {
let mut parser = SseParser::new();
let first = parser.feed(b"data: {\"n\":1}\r", false).expect("chunk 1");
assert!(first.is_empty(), "the bare \\r does not dispatch");
let second = parser
.feed(b"\ndata: {\"n\":2}\r\n\r\n", false)
.expect("chunk 2");
assert_eq!(second.len(), 1, "the joined CRLF framed exactly one event");
assert_eq!(
second[0].data, "{\"n\":1}\n{\"n\":2}",
"the \\r held as line content until the arriving \\n framed it, \
joining both data lines per WHATWG accumulation"
);
let rest = parser.feed(b"data: {\"n\":2}\n\n", true).expect("tail");
assert_eq!(rest.len(), 1);
assert_eq!(rest[0].data, "{\"n\":2}", "the second frame is unaffected");
}
#[test]
fn sse_parser_drops_invalid_utf8_lines_and_keeps_framing() {
let mut parser = SseParser::new();
let events = parser
.feed(b"data: \xff\xfe\xfd\n\ndata: {\"ok\":true}\n\n", false)
.expect("ascii framing is valid");
assert_eq!(events.len(), 1, "the invalid-UTF8 data line was dropped");
assert_eq!(events[0].data, "{\"ok\":true}");
assert_eq!(events[0].event, None);
let mut parser = SseParser::new();
let events = parser
.feed(b"data: good\r\ndata: \xf0\x28\x8c\x28\r\n\r\n", false)
.expect("CRLF framing is valid");
assert_eq!(events.len(), 1);
assert_eq!(
events[0].data, "good",
"the invalid line contributes nothing to the joined data"
);
}
#[tokio::test]
async fn sse_event_field_carrys_on_the_non_json_wrapper_and_resets() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(
200,
"text/event-stream",
b"event: error\ndata: upstream exploded\n\nevent: custom\ndata: {\"n\":1}\n\ndata: after\n\n"
.to_vec(),
)
}))
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(envelopes.len(), 3);
assert_eq!(
envelopes[0].result.clone().unwrap(),
json!({"data": "upstream exploded", "event": "error"})
);
assert_eq!(envelopes[1].result.clone().unwrap(), json!({"n": 1}));
assert_eq!(
envelopes[2].result.clone().unwrap(),
json!({"data": "after", "event": null}),
"the name must not leak across frames"
);
}
#[test]
fn sse_parser_last_event_wins_and_name_does_not_leak_across_frames() {
let mut parser = SseParser::new();
let events = parser
.feed(b"event: a\nevent: b\ndata: x\n\n", false)
.expect("ascii only");
assert_eq!(events.len(), 1);
assert_eq!(events[0].event.as_deref(), Some("b"));
assert_eq!(events[0].data, "x");
let events = parser
.feed(b"event: orphan\n\n", false)
.expect("ascii only");
assert!(events.is_empty(), "no data field, nothing dispatched");
let events = parser.feed(b"data: next\n\n", false).expect("ascii only");
assert_eq!(events.len(), 1);
assert_eq!(
events[0].event.as_deref(),
None,
"an event:-only frame must not leak its name"
);
}
#[tokio::test]
async fn bearer_credential_with_control_character_fails_loudly() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(200, "application/json", b"{}".to_vec())
}))
.await;
let ctx = ctx_with_capability("svc", "tok\u{0007}en-secret-marker".to_string());
let envelope = call_forward_authed(&base, ctx, &Some(HttpAuthScheme::Bearer)).await;
match envelope.result {
Err(err) => {
assert!(err
.message
.contains("refusing to send the request unauthenticated"));
assert!(
!err.message.contains("secret-marker"),
"error must not echo credential material"
);
assert!(
!err.message.contains("tok\u{0007}en"),
"error must not echo credential material"
);
}
other => panic!("expected loud credential error, got {other:?}"),
}
}
#[tokio::test]
async fn default_header_with_invalid_name_fails_loudly() {
let mut defaults = TestHashMap::new();
defaults.insert("bad header".to_string(), "v".to_string());
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&defaults,
"svc",
&serde_json::json!({"type": "object"}),
None,
&json!({}),
&noop_context(),
)
.expect_err("invalid default-header name must fail loudly");
assert!(err.message.contains("bad header"));
}
#[tokio::test]
async fn api_key_with_invalid_header_name_fails_loudly() {
let base = "https://api.example.com".to_string();
let ctx = ctx_with_capability("svc", "key-value".to_string());
let result = build_request(
&base,
"/x",
"GET",
&Some(HttpAuthScheme::ApiKey {
header_name: "bad header name".to_string(),
}),
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
None,
&json!({}),
&ctx,
);
match result {
Ok(_) => panic!("invalid API-key header name must fail loudly"),
Err(err) => {
assert!(err.message.contains("invalid header name"));
assert!(!err.message.contains("key-value"));
}
}
}
#[test]
fn authed_op_with_absent_capability_fails_loudly_in_build_request() {
for scheme in [
HttpAuthScheme::Bearer,
HttpAuthScheme::ApiKey {
header_name: "x-api-key".to_string(),
},
HttpAuthScheme::Basic,
] {
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&Some(scheme),
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
None,
&json!({}),
&noop_context(),
)
.expect_err("absent capability must fail loudly");
assert_eq!(err.code, "INTERNAL");
assert!(
err.message
.contains("capability for namespace `svc` is absent"),
"message was: {}",
err.message
);
assert!(
err.message.contains("api_key:svc") && err.message.contains("http_token:svc"),
"message must name the missing capability keys: {}",
err.message
);
assert!(
err.message
.contains("refusing to send the request unauthenticated"),
"message was: {}",
err.message
);
}
}
#[test]
fn unauthed_op_with_empty_capabilities_is_unchanged() {
let (_, _, _, headers) = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
None,
&json!({}),
&noop_context(),
)
.expect("unauthed op builds without capabilities");
assert!(headers.get(AUTHORIZATION).is_none());
}
#[tokio::test]
async fn authed_op_with_empty_capabilities_sends_zero_requests() {
let upstream_hit = TestArc::new(std::sync::atomic::AtomicBool::new(false));
let flag = TestArc::clone(&upstream_hit);
let base = spawn_responder(TestArc::new(move |_| {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
http_response(200, "application/json", b"{}".to_vec())
}))
.await;
let envelope =
call_forward_authed(&base, noop_context(), &Some(HttpAuthScheme::Bearer)).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message
.contains("capability for namespace `svc` is absent"),
"message was: {}",
err.message
);
assert!(
!err.message.to_lowercase().contains("token")
|| err.message.contains("http_token:svc"),
"error must carry no credential material, only key names: {}",
err.message
);
}
other => panic!("expected loud missing-capability error, got {other:?}"),
}
assert!(
!upstream_hit.load(std::sync::atomic::Ordering::SeqCst),
"upstream must receive zero requests when the capability is absent"
);
}
#[tokio::test]
async fn default_header_with_invalid_value_fails_loudly() {
let mut defaults = TestHashMap::new();
defaults.insert("X-Trace".to_string(), "bad\u{0000}value".to_string());
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&defaults,
"svc",
&serde_json::json!({"type": "object"}),
None,
&json!({}),
&noop_context(),
)
.expect_err("invalid default-header value must fail loudly");
assert!(err.message.contains("X-Trace"));
assert!(err.message.contains("invalid value"));
}
#[tokio::test]
async fn error_body_is_echoed_bounded_in_the_error_envelope() {
let body = vec![b'x'; ERROR_BODY_ECHO_CAP * 3];
let base = spawn_responder(TestArc::new(move |_| {
http_response(429, "text/plain", body.clone())
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "HTTP_429", "message was: {}", err.message);
assert!(
err.message.contains("HTTP 429: Too Many Requests"),
"message was: {}",
err.message
);
assert!(
err.message.contains("[truncated]"),
"message was: {}",
err.message
);
assert!(
err.message.len() < ERROR_BODY_ECHO_CAP * 2,
"echo must stay bounded, was {}",
err.message.len()
);
}
other => panic!("expected HTTP_429 error, got {other:?}"),
}
}
#[tokio::test]
async fn small_error_body_is_echoed_in_full() {
let base = spawn_responder(TestArc::new(|_| {
http_response(404, "text/plain", b"no such widget: id=42".to_vec())
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "HTTP_404");
assert!(err.message.contains("no such widget: id=42"));
}
other => panic!("expected HTTP_404, got {other:?}"),
}
}
#[test]
fn json_detection_covers_vendor_suffix_and_rejects_lookalikes() {
for positive in [
"application/json",
"application/json; charset=utf-8",
"application/vnd.api+json",
"application/problem+json",
"Application/Vnd.Api+JSON",
] {
assert!(is_json_content_type(positive), "{positive} must be JSON");
}
for negative in [
"text/json",
"application/jsonx",
"xapplication/json",
"text/html",
] {
assert!(
!is_json_content_type(negative),
"{negative} must not be JSON"
);
}
}
#[test]
fn sse_detection_requires_exact_essence() {
assert!(is_sse_content_type("text/event-stream"));
assert!(is_sse_content_type("text/event-stream; charset=utf-8"));
assert!(!is_sse_content_type("text/html"));
assert!(!is_sse_content_type("text/event-streamx"));
}
#[tokio::test]
async fn oversized_non2xx_error_body_is_not_echoed_unbounded() {
let body = vec![b'e'; STATUS_BODY_DRAIN + 1];
let base = spawn_responder(TestArc::new(move |_| {
http_response(500, "text/plain", body.clone())
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "HTTP_500");
assert!(
err.message.len() < STATUS_BODY_DRAIN,
"echo must stay far below the drain budget"
);
assert!(err.message.contains("too large to echo"));
}
other => panic!("expected HTTP_500, got {other:?}"),
}
}
#[tokio::test]
async fn stream_survives_past_the_total_request_timeout() {
let timeout = Duration::from_millis(500);
let keepalive = Duration::from_millis(200);
let total_keepalives = 6u32;
let start = std::time::Instant::now();
let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n";
let base = spawn_sse_responder_with_writer(head, move |mut sock| async move {
use tokio::io::AsyncWriteExt;
for _ in 0..total_keepalives {
tokio::time::sleep(keepalive).await;
let _ = sock.write_all(b": keepalive\n\n").await;
let _ = sock.flush().await;
}
let _ = sock.write_all(b"data: {\"late\":true}\n\n").await;
let _ = sock.flush().await;
let _ = sock.shutdown().await;
})
.await;
let client = client_with_timeout(timeout);
let stream = forward_stream(
&client,
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
tokio::pin!(stream);
let crossed = tokio::time::timeout(Duration::from_secs(5), stream.next())
.await
.expect("the stream must deliver within the test budget")
.expect("stream must not end without the data event");
assert!(
start.elapsed() > timeout,
"the event must arrive after the old total-request deadline (elapsed {:?}, timeout {:?})",
start.elapsed(),
timeout
);
match crossed.result {
Ok(value) => assert_eq!(value, json!({"late": true})),
other => panic!("expected the post-deadline data event, got {other:?}"),
}
assert!(
tokio::time::timeout(Duration::from_millis(500), stream.next())
.await
.unwrap_or(None)
.is_none(),
"responder closed after the data event; stream must end"
);
}
#[tokio::test]
async fn stream_exceeding_total_byte_cap_terminates_with_one_error() {
let cap = 4096u64;
let chunk = vec![b'a'; 1024];
let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n";
let base = spawn_sse_responder_with_writer(head, move |mut sock| async move {
use tokio::io::AsyncWriteExt;
for _ in 0..64 {
let _ = sock.write_all(b"data: ").await;
let _ = sock.write_all(&chunk).await;
let _ = sock.write_all(b"\n\n").await;
let _ = sock.flush().await;
}
let _ = sock.shutdown().await;
})
.await;
let client = streaming_client(cap);
let stream = forward_stream(
&client,
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert!(!envelopes.is_empty(), "events before the cap still flow");
assert!(
envelopes[..envelopes.len() - 1]
.iter()
.all(|e| e.result.is_ok()),
"every envelope before the terminal one is an event"
);
let terminal = envelopes.last().expect("terminal envelope present");
match &terminal.result {
Err(err) => {
assert_eq!(err.code, "HTTP_413");
assert!(err.message.contains("total streamed-bytes cap"));
}
other => panic!("expected the terminal cap error, got {other:?}"),
}
let ok_count = envelopes[..envelopes.len() - 1].len();
assert_eq!(
envelopes.len(),
ok_count + 1,
"exactly one terminal error envelope after the last event"
);
}
#[test]
fn line_cap_trips_before_the_buffer_takes_the_overshooting_chunk() {
let mut parser = SseParser::new();
let seed = vec![b'x'; SSE_EVENT_BUFFER_CAP + 1];
let oversized = parser.feed(&seed, false);
assert!(
matches!(oversized, Err(SseParseError::BufferOverflow)),
"a single over-cap line trips at the pre-extend check"
);
let mut parser = SseParser::new();
let half = vec![b'x'; SSE_EVENT_BUFFER_CAP / 2];
let first = parser.feed(&half, false);
assert!(first.is_ok(), "partial line under the cap buffers fine");
let second = parser.feed(&seed, false);
assert!(
matches!(second, Err(SseParseError::BufferOverflow)),
"the chunk that would push past the cap is rejected before extend"
);
let mut parser = SseParser::new();
let at_cap = vec![b'x'; SSE_EVENT_BUFFER_CAP - 2];
let ok = parser.feed(&at_cap, false);
assert!(ok.is_ok(), "a partial line under the cap is legal");
let framing = parser.feed(b"\n\n", false);
assert!(
framing.is_ok(),
"the newline pair completes the event without tripping the cap"
);
let next = parser.feed(&vec![b'y'; SSE_EVENT_BUFFER_CAP], false);
assert!(
next.is_ok(),
"the dispatched event drained the buffer; a fresh full-cap line is legal again"
);
let over = parser.feed(b"z", false);
assert!(
matches!(over, Err(SseParseError::BufferOverflow)),
"one byte past a full buffer still trips before extend"
);
}
#[tokio::test]
async fn malformed_json_body_200_decodes_to_an_internal_error_envelope() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(200, "application/json", b"{not json".to_vec())
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("failed to decode response body"),
"message was: {}",
err.message
);
}
other => panic!("expected INTERNAL decode envelope, got {other:?}"),
}
}
#[tokio::test]
async fn binary_octet_stream_200_surfaces_as_a_byte_array_envelope() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(
200,
"application/octet-stream",
vec![0x00, 0xFF, 0x10, 0x42],
)
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Ok(value) => assert_eq!(value, json!([0, 255, 16, 66])),
other => panic!("expected a byte-array envelope, got {other:?}"),
}
}
#[tokio::test]
async fn oversized_upstream_sse_line_terminates_with_one_internal_envelope() {
let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n";
let base = spawn_sse_responder_with_writer(head, move |mut sock| async move {
use tokio::io::AsyncWriteExt;
let line = vec![b'a'; SSE_EVENT_BUFFER_CAP + 1];
let _ = sock.write_all(b"data: ").await;
let _ = sock.write_all(&line).await;
let _ = sock.write_all(b"\n\n").await;
let _ = sock.flush().await;
})
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(
envelopes.len(),
1,
"the parse-overflow terminal envelope is the only output"
);
match &envelopes[0].result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("SSE parse error"),
"message was: {}",
err.message
);
}
other => panic!("expected one INTERNAL terminal envelope, got {other:?}"),
}
}
#[tokio::test]
async fn stream_build_error_yields_one_invalid_input_envelope_with_zero_upstream_contact() {
let base = spawn_responder(TestArc::new(|_parts| {
panic!("the rejected invocation must never reach the upstream");
}))
.await;
for input in [
json!({"debug": true}),
json!({"id": {"deeply": {"nested": "object"}}}),
] {
let stream = forward_stream(
&minimal_client(),
&base,
"/x/{id}",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({
"type": "object",
"properties": {"id": {"type": "string"}}
}),
&[],
None,
input,
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(envelopes.len(), 1, "one envelope, then the stream ends");
match &envelopes[0].result {
Err(err) => {
assert_eq!(err.code, "INVALID_INPUT", "message was: {}", err.message);
}
other => panic!("expected one INVALID_INPUT envelope, got {other:?}"),
}
}
}
#[tokio::test]
async fn aborted_socket_mid_stream_emits_a_terminal_error_envelope() {
let head =
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: 512\r\n\r\n";
let gate = TestArc::new(tokio::sync::Notify::new());
let notify = TestArc::clone(&gate);
let base = spawn_sse_responder_with_writer(head, move |mut sock| async move {
use tokio::io::AsyncWriteExt;
let _ = sock.write_all(b"data: {\"n\":1}\n\n").await;
let _ = sock.flush().await;
notify.notified().await;
drop(sock);
})
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
tokio::pin!(stream);
let first = tokio::time::timeout(Duration::from_secs(5), stream.next())
.await
.expect("the complete frame must deliver within the test budget")
.expect("stream must deliver the frame before the abort");
assert_eq!(first.result.clone().unwrap(), json!({"n": 1}));
gate.notify_one();
let terminal = tokio::time::timeout(Duration::from_secs(5), stream.next())
.await
.expect("the terminal envelope must follow within the test budget")
.expect("the stream must end with the terminal envelope");
match terminal.result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("SSE stream error"),
"message was: {}",
err.message
);
}
other => panic!("expected the terminal error envelope, got {other:?}"),
}
let ended = tokio::time::timeout(Duration::from_secs(1), stream.next()).await;
assert!(
matches!(ended, Ok(None) | Err(_)),
"the stream ends after the terminal envelope"
);
}
#[tokio::test]
async fn pending_event_flushes_at_eof_without_a_trailing_blank_line() {
let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n";
let base = spawn_sse_responder_with_writer(head, move |mut sock| async move {
use tokio::io::AsyncWriteExt;
let _ = sock
.write_all(b"data: {\"n\":1}\n\ndata: {\"final\":true}\n")
.await;
let _ = sock.flush().await;
let _ = sock.shutdown().await;
})
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(envelopes.len(), 2);
assert_eq!(envelopes[0].result.clone().unwrap(), json!({"n": 1}));
assert_eq!(
envelopes[1].result.clone().unwrap(),
json!({"final": true}),
"EOF-flush dispatches the pending event"
);
}
#[tokio::test]
async fn dead_port_forward_yields_an_internal_envelope() {
let envelope = forward(
&minimal_client(),
"http://127.0.0.1:9",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
)
.await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("HTTP request failed"),
"message was: {}",
err.message
);
}
other => panic!("expected INTERNAL transport envelope, got {other:?}"),
}
}
#[tokio::test]
async fn dead_port_forward_stream_yields_a_terminal_internal_envelope() {
let stream = forward_stream(
&minimal_client(),
"http://127.0.0.1:9",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
None,
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(
envelopes.len(),
1,
"exactly one terminal envelope, then the stream ends"
);
match &envelopes[0].result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("HTTP request failed"),
"message was: {}",
err.message
);
}
other => panic!("expected terminal INTERNAL envelope, got {other:?}"),
}
}
#[tokio::test]
async fn api_key_with_invalid_value_fails_loudly_without_echoing_secrets() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(200, "application/json", b"{}".to_vec())
}))
.await;
let ctx = ctx_with_capability("svc", "key\u{0003}-secret-marker".to_string());
let envelope = call_forward_authed(
&base,
ctx,
&Some(HttpAuthScheme::ApiKey {
header_name: "x-api-key".to_string(),
}),
)
.await;
match envelope.result {
Err(err) => {
assert!(
err.message
.contains("refusing to send the request unauthenticated"),
"message was: {}",
err.message
);
assert!(
!err.message.contains("secret-marker") && !err.message.contains("key\u{0003}"),
"error must not echo credential material: {}",
err.message
);
}
other => panic!("expected loud credential error, got {other:?}"),
}
}
#[tokio::test]
async fn basic_credential_with_invalid_value_fails_loudly_without_echoing_secrets() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(200, "application/json", b"{}".to_vec())
}))
.await;
let ctx = ctx_with_capability("svc", "basic\u{0001}-secret-marker".to_string());
let envelope = call_forward_authed(&base, ctx, &Some(HttpAuthScheme::Basic)).await;
match envelope.result {
Err(err) => {
assert!(
err.message
.contains("refusing to send the request unauthenticated"),
"message was: {}",
err.message
);
assert!(
!err.message.contains("secret-marker")
&& !err.message.contains("basic\u{0001}"),
"error must not echo credential material: {}",
err.message
);
}
other => panic!("expected loud credential error, got {other:?}"),
}
}
#[test]
fn declared_header_param_with_invalid_value_is_rejected() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"X-Trace": {"type": "string", "wire": "header"}},
});
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
None,
&json!({"X-Trace": "bad\u{0000}value"}),
&ctx,
)
.expect_err("invalid header-param value must fail loudly");
assert!(
err.message.contains("X-Trace") && err.message.contains("invalid value"),
"message was: {}",
err.message
);
}
#[test]
fn declared_header_param_with_invalid_name_is_rejected() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"bad header": {"type": "string", "wire": "header"}},
});
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
None,
&json!({"bad header": "v"}),
&ctx,
)
.expect_err("invalid header-param name must fail loudly");
assert!(
err.message.contains("valid HTTP header name"),
"message was: {}",
err.message
);
}
#[tokio::test]
async fn same_host_redirect_is_followed_with_credential_forwarding() {
let hits = TestArc::new(std::sync::atomic::AtomicU32::new(0));
let hit_counter = TestArc::clone(&hits);
let base = spawn_responder(TestArc::new(move |parts| {
hit_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if parts.target == "/x" {
http::Response::builder()
.status(302)
.header("location", "/final")
.body(Vec::new())
.expect("redirect response builds")
} else {
let auth = parts
.headers
.get("authorization")
.cloned()
.unwrap_or_default();
let body = format!(r#"{{"auth":"{auth}"}}"#);
http_response(200, "application/json", body.into_bytes())
}
}))
.await;
let ctx = ctx_with_capability("svc", "tok-secret-marker".to_string());
let envelope = call_forward_authed(&base, ctx, &Some(HttpAuthScheme::Bearer)).await;
match envelope.result {
Ok(value) => assert_eq!(
value,
json!({"auth": "Bearer tok-secret-marker"}),
"the followed hop must receive the credential header"
),
other => panic!("same-host redirect must be followed to success, got {other:?}"),
}
assert_eq!(
hits.load(std::sync::atomic::Ordering::SeqCst),
2,
"origin request plus exactly one followed hop"
);
}
#[tokio::test]
async fn cross_host_redirect_is_surfaced_and_the_target_receives_zero_requests() {
let attacker_hits = TestArc::new(std::sync::atomic::AtomicU32::new(0));
let attacker_counter = TestArc::clone(&attacker_hits);
let attacker = spawn_responder(TestArc::new(move |_| {
attacker_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
http_response(200, "text/plain", b"stolen".to_vec())
}))
.await;
let base = spawn_responder(TestArc::new(move |_| {
http::Response::builder()
.status(302)
.header("location", format!("{attacker}/steal"))
.body(Vec::new())
.expect("redirect response builds")
}))
.await;
let ctx = ctx_with_capability("svc", "tok-secret-marker".to_string());
let envelope = call_forward_authed(&base, ctx, &Some(HttpAuthScheme::Bearer)).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "HTTP_302", "message was: {}", err.message);
assert!(
!err.message.contains("tok-secret-marker"),
"error must carry no credential material: {}",
err.message
);
}
other => panic!("cross-host redirect must surface the 3xx, got {other:?}"),
}
assert_eq!(
attacker_hits.load(std::sync::atomic::Ordering::SeqCst),
0,
"the cross-host target must receive zero requests"
);
}
#[tokio::test]
async fn redirect_hop_cap_errors_loudly() {
let hits = TestArc::new(std::sync::atomic::AtomicU32::new(0));
let hit_counter = TestArc::clone(&hits);
let base = spawn_responder(TestArc::new(move |_| {
hit_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
http::Response::builder()
.status(302)
.header("location", "/next")
.body(Vec::new())
.expect("redirect response builds")
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("HTTP request failed"),
"message was: {}",
err.message
);
assert!(
err.message.contains("redirect"),
"message was: {}",
err.message
);
}
other => panic!("hop-cap redirect must error loudly, got {other:?}"),
}
let hit_count = hits.load(std::sync::atomic::Ordering::SeqCst);
assert!(
hit_count >= 2,
"the chain followed before capping: {hit_count}"
);
assert!(
hit_count <= 12,
"the hop cap bounded the redirect chain: {hit_count}"
);
}
}