Skip to main content

vgi_rpc/
http.rs

1//! HTTP transport. Implements the vgi-rpc protocol over HTTP:
2//!   `POST /{method}`            unary
3//!   `POST /{method}/init`       stream init (producer or exchange)
4//!   `POST /{method}/exchange`   stream continuation
5//!
6//! Streaming is stateless on the wire: the full `StreamStateKind` is
7//! sealed into an XChaCha20-Poly1305 AEAD token (v4 wire format) carried
8//! in the `vgi_rpc.stream_state#b64` metadata key. Any worker with the
9//! same token key can resume any continuation request — no server-side
10//! session map, no reaper, no cross-worker affinity. The token contents
11//! are confidential as well as authenticated: only the server can read
12//! the serialized state.
13
14use std::sync::Arc;
15
16use arrow_array::RecordBatch;
17use arrow_schema::{Schema, SchemaRef};
18use axum::{
19    body::Bytes,
20    extract::{Path, State},
21    http::{header, HeaderMap, HeaderValue, StatusCode},
22    response::{IntoResponse, Response},
23    routing::post,
24    Router,
25};
26use base64::Engine;
27use rand::RngCore;
28
29use crate::errors::{Result, RpcError};
30use std::collections::HashMap;
31
32use crate::metadata::{CALL_STATE_KEY, CANCEL_KEY, REQUEST_ID_KEY, STATE_KEY};
33use crate::server::{
34    build_error_metadata, build_log_metadata, cast_batch, validate_parameter_batch,
35    validate_protocol_version, CallContext, MethodType, Request, RpcServer,
36};
37use crate::stream::{empty_schema, Emitted, OutputCollector, StreamResult, StreamStateKind};
38use crate::unauthorized::{AuthReason, AUTH_PROXY_REQUIRED_HEADER, AUTH_REASON_HEADER};
39use crate::wire::{bytes_to_hex, empty_batch, md_get, Metadata, StreamReader, StreamWriter};
40use axum::http::HeaderName;
41
42pub const ARROW_CONTENT_TYPE: &str = "application/vnd.apache.arrow.stream";
43
44/// The shared, self-contained VGI landing page, vendored byte-identically
45/// from `vgi-web-frontend/public/landing.html`. Served at `GET {prefix}/`
46/// for browsers; it reads the worker's catalogs by speaking the VGI protocol
47/// through [`CLIENT_BUNDLE`], imported same-origin from
48/// `{prefix}/vgi-client.js`.
49const LANDING_HTML: &str = include_str!("landing.html");
50
51/// Browser build of the `@query-farm/vgi` JS client, vendored from
52/// `vgi-web-frontend` (`bun run build:landing-client`) and served at
53/// `GET {prefix}/vgi-client.js`.
54///
55/// The worker serves it rather than the page importing it from a CDN: the page
56/// is same-origin with an authenticated worker and carries its session cookie,
57/// so third-party script there would run with full access to that origin — and
58/// a CDN dependency would break air-gapped deployments, which today need
59/// nothing but the worker. Vendored and released as a pair with the page.
60const CLIENT_BUNDLE: &str = include_str!("vgi-client.js");
61
62/// Worker identity for the standardized VGI landing surface.
63///
64/// The shared `landing.html` reads catalog metadata by speaking the VGI
65/// protocol through the client bundle the worker serves beside it, so nothing
66/// about the catalog belongs here. What the protocol has no method for — which
67/// worker this is, what it is called, what version it runs — rides on the JSON
68/// status document at `GET {prefix}/?format=json`.
69#[derive(Clone, Debug, Default)]
70pub struct LandingInfo {
71    /// Worker name shown as the page heading.
72    pub name: String,
73    /// One-line description shown under the heading.
74    pub doc: String,
75    /// Worker version string shown in the footer.
76    pub version: String,
77}
78
79// Sticky-session header conventions (HTTP-only). Header names are
80// compared case-insensitively by axum's `HeaderMap`, so the lowercase
81// forms here match the canonical `VGI-Session` etc. on the wire.
82const SESSION_HEADER: &str = "vgi-session";
83const SESSION_ACCEPT_HEADER: &str = "vgi-session-accept";
84const SESSION_CLOSE_HEADER: &str = "vgi-session-close";
85const ECHO_HEADER_PREFIX: &str = "vgi-echo-";
86const STICKY_ENABLED_HEADER: &str = "vgi-sticky-enabled";
87const STICKY_DEFAULT_TTL_HEADER: &str = "vgi-sticky-default-ttl";
88const STICKY_ECHO_HEADERS_HEADER: &str = "vgi-sticky-echo-headers";
89/// Framework-managed sticky session teardown endpoint path segment.
90const SESSION_ENDPOINT: &str = "__session__";
91
92/// Response bodies smaller than this are never zstd-compressed: below it the
93/// frame overhead dominates and often enlarges the payload, so the CPU and
94/// allocation cost of compressing isn't repaid.
95///
96/// Private, so the public `HttpStateBuilder::response_compression_level` docs
97/// spell the number out rather than link here (a public->private intra-doc
98/// link is a rustdoc warning). Keep the two in sync if this changes.
99const MIN_ZSTD_COMPRESS_BYTES: usize = 1024;
100
101/// zstd level used for response compression when the builder is not told
102/// otherwise. Response compression is **on by default**, matching the Python
103/// SDK's `compression_level=1`.
104///
105/// Level 1 rather than the zstd default of 3 is not a size/speed trade: on an
106/// 8.41 MB Arrow payload level 1 measured 4.7x faster than level 3 *and*
107/// produced the smaller body. Arrow IPC is already dictionary/run-length
108/// friendly, so the extra search effort at higher levels buys nothing here.
109pub const DEFAULT_RESPONSE_COMPRESSION_LEVEL: i32 = 1;
110
111/// VGI's own response-codec preference header. Clients that cannot set the
112/// standard `Accept-Encoding` state their preference here instead — notably
113/// browser `fetch()`, for which `Accept-Encoding` is a forbidden header name.
114/// That is the WASM path, so ignoring this header would mean the browser
115/// always gets identity-encoded responses.
116const VGI_ACCEPT_ENCODING_HEADER: &str = "x-vgi-accept-encoding";
117/// Response counterpart of [`VGI_ACCEPT_ENCODING_HEADER`]. Stamped instead of
118/// the standard `Content-Encoding` when the winning codec was offered *only*
119/// via the custom request header: such a client's fetch/proxy layer would
120/// auto-decode or mangle a standard `Content-Encoding`, so the response must
121/// not claim one.
122const VGI_CONTENT_ENCODING_HEADER: &str = "x-vgi-content-encoding";
123/// Marks a 200 response whose Arrow body carries an RPC error rather than a
124/// result. Browser clients must be able to read it, so it is CORS-exposed.
125const RPC_ERROR_HEADER: &str = "x-vgi-rpc-error";
126
127/// A response content coding vgi-rpc knows how to negotiate.
128///
129/// Knowing a codec is not the same as being able to *produce* it — see
130/// [`ResponseEncoding::producible`]. `identity` is a first-class token: a
131/// client can ask for it explicitly to switch response compression off for
132/// that request.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum ResponseEncoding {
135    /// No coding at all. Always producible; carries no response header.
136    Identity,
137    Zstd,
138    Gzip,
139}
140
141/// The compressed codings this server advertises, in its own preference
142/// order. Informational only — the *client's* order decides the winner (see
143/// [`pick_response_encoding`]). `identity` is deliberately absent: it is
144/// always available and so carries no information.
145const RESPONSE_ENCODING_PREFERENCE: [ResponseEncoding; 2] =
146    [ResponseEncoding::Zstd, ResponseEncoding::Gzip];
147
148impl ResponseEncoding {
149    /// Wire token, as it appears in `Accept-Encoding` / `Content-Encoding`.
150    fn as_str(self) -> &'static str {
151        match self {
152            ResponseEncoding::Identity => "identity",
153            ResponseEncoding::Zstd => "zstd",
154            ResponseEncoding::Gzip => "gzip",
155        }
156    }
157
158    /// Parse a single already-trimmed, already-lowercased token.
159    fn from_token(token: &str) -> Option<Self> {
160        match token {
161            "identity" => Some(ResponseEncoding::Identity),
162            "zstd" => Some(ResponseEncoding::Zstd),
163            "gzip" => Some(ResponseEncoding::Gzip),
164            _ => None,
165        }
166    }
167
168    /// Can this server actually emit this coding right now? True only when an
169    /// encoder exists in the build *and* configuration enables it.
170    ///
171    /// This is the single source of truth for producibility: both the
172    /// negotiation walk and the `VGI-Supported-Encodings` advertisement read
173    /// it, so the two cannot drift. Registering a new producible codec is one
174    /// arm here plus the matching arm in [`encode_response_body`].
175    fn producible(self, compression_level: Option<i32>) -> bool {
176        match self {
177            // Shipping bytes unchanged needs no encoder and no configuration.
178            ResponseEncoding::Identity => true,
179            // Both encoders are hard dependencies of the HTTP feature;
180            // `response_compression_level` is their shared configuration gate,
181            // on by default at [`DEFAULT_RESPONSE_COMPRESSION_LEVEL`] and
182            // cleared by `HttpStateBuilder::disable_response_compression`.
183            ResponseEncoding::Zstd | ResponseEncoding::Gzip => compression_level.is_some(),
184        }
185    }
186
187    /// Can this server decompress a *request* body in this coding? See
188    /// [`decode_content_encoding`], which implements the request side.
189    fn decodable(self) -> bool {
190        match self {
191            ResponseEncoding::Identity => true,
192            ResponseEncoding::Zstd | ResponseEncoding::Gzip => true,
193        }
194    }
195}
196
197/// Value for the `VGI-Supported-Encodings` capability header: the codings this
198/// server can handle in **both** directions — decode on requests *and* produce
199/// on responses — in server-preference order, excluding `identity`.
200///
201/// The intersection is deliberate. The HTTP server implements zstd and gzip
202/// in both directions, so an enabled server advertises `zstd, gzip`.
203///
204/// Empty when the intersection is empty — response compression disabled by
205/// config (the default) or no encoder available. The header is still emitted
206/// in that case: present-but-empty means "I speak no compression", which is a
207/// different statement from an older server that omits the header entirely
208/// (which clients read as "assume zstd").
209///
210/// Derived from [`ResponseEncoding::producible`] / [`ResponseEncoding::decodable`]
211/// so it cannot drift from what the negotiation walk actually does.
212fn supported_encodings_header_value(compression_level: Option<i32>) -> String {
213    RESPONSE_ENCODING_PREFERENCE
214        .iter()
215        .filter(|e| {
216            **e != ResponseEncoding::Identity && e.producible(compression_level) && e.decodable()
217        })
218        .map(|e| e.as_str())
219        .collect::<Vec<_>>()
220        .join(", ")
221}
222
223/// Parse a comma-separated `Accept-Encoding`-style header value into an
224/// ordered, de-duplicated list of the codings we know.
225///
226/// Mirrors the canonical Python `parse_encoding_list`: split on `,`, trim,
227/// lowercase, drop everything after `;` (q-values are parsed off and
228/// **ignored**, never honoured), skip unknown tokens, keep the first
229/// occurrence of each codec, and preserve the client's stated order. A
230/// missing or empty header parses to an empty list.
231fn parse_encoding_list(header_value: Option<&str>) -> Vec<ResponseEncoding> {
232    let mut out: Vec<ResponseEncoding> = Vec::new();
233    let Some(value) = header_value else {
234        return out;
235    };
236    for raw in value.split(',') {
237        let token = raw
238            .split(';')
239            .next()
240            .unwrap_or("")
241            .trim()
242            .to_ascii_lowercase();
243        if token.is_empty() {
244            continue;
245        }
246        if let Some(enc) = ResponseEncoding::from_token(&token) {
247            if !out.contains(&enc) {
248                out.push(enc);
249            }
250        }
251    }
252    out
253}
254
255/// Negotiate the response content coding, honouring the **client's** stated
256/// preference order rather than a server-side hardcoded one.
257///
258/// The merged sequence is `custom ++ [e for e in standard if e not in custom]`
259/// where `custom` comes from `X-VGI-Accept-Encoding` and `standard` from
260/// `Accept-Encoding`; the first entry this server can produce wins. VGI's own
261/// preference header takes precedence because generic HTTP clients inject
262/// their own list: the DuckDB engine uses cpp-httplib, which sends
263/// `Accept-Encoding: deflate, gzip, br, zstd` — gzip *before* zstd — while VGI
264/// states `X-VGI-Accept-Encoding: zstd, gzip`. Walking the generic header
265/// first would pick gzip, which dominates large Arrow bodies.
266///
267/// `identity` participates in the walk like any other coding and is always
268/// producible, so a client that lists it ahead of a compressed codec gets an
269/// uncompressed body — an explicit, uniform way to switch compression off per
270/// request. Winning as `identity` stamps no encoding header at all.
271///
272/// Returns `None` when the client offered nothing this server can produce (the
273/// body then ships uncompressed — `Accept-Encoding` is a client capability,
274/// not a demand). Otherwise returns the chosen codec plus `used_custom`: true
275/// when the winner appeared only in the custom header, which selects
276/// [`VGI_CONTENT_ENCODING_HEADER`] over the standard `Content-Encoding`.
277fn pick_response_encoding(
278    headers: &HeaderMap,
279    compression_level: Option<i32>,
280) -> Option<(ResponseEncoding, bool)> {
281    let header_str = |name: &str| {
282        headers
283            .get(name)
284            .and_then(|v: &HeaderValue| v.to_str().ok())
285    };
286    let custom = parse_encoding_list(header_str(VGI_ACCEPT_ENCODING_HEADER));
287    let standard = parse_encoding_list(
288        headers
289            .get(header::ACCEPT_ENCODING)
290            .and_then(|v| v.to_str().ok()),
291    );
292    custom
293        .iter()
294        .copied()
295        .chain(standard.iter().copied().filter(|e| !custom.contains(e)))
296        .find(|enc| enc.producible(compression_level))
297        .map(|enc| {
298            let used_custom = custom.contains(&enc) && !standard.contains(&enc);
299            (enc, used_custom)
300        })
301}
302
303/// Compress `body` with `encoding` at `level`. `None` means "no compressed
304/// form to ship", so the caller sends the body as-is and stamps no encoding
305/// header. That covers the client explicitly choosing `identity` or an encoder
306/// failure.
307fn encode_response_body(encoding: ResponseEncoding, body: &[u8], level: i32) -> Option<Vec<u8>> {
308    match encoding {
309        ResponseEncoding::Identity => None,
310        ResponseEncoding::Zstd => zstd::encode_all(std::io::Cursor::new(body), level).ok(),
311        ResponseEncoding::Gzip => {
312            let level = u32::try_from(level.clamp(0, 9)).ok()?;
313            let mut encoder =
314                flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::new(level));
315            std::io::Write::write_all(&mut encoder, body).ok()?;
316            encoder.finish().ok()
317        }
318    }
319}
320
321/// HTTP server state shared across all handlers.
322///
323/// Build via [`HttpState::builder`] (preferred) or [`HttpState::new`] for a
324/// default configuration.
325///
326/// Streaming is stateless: the full `StreamStateKind` travels in every
327/// HTTP continuation request inside an AEAD-sealed state token, so any
328/// worker behind a load balancer can resume any stream. No session map
329/// is held on the server.
330pub struct HttpState {
331    server: Arc<RpcServer>,
332    token_key: [u8; 32],
333    /// Operator-declared proxy-injected headers a custom authenticator
334    /// depends on — the escape hatch for authenticators the framework
335    /// cannot introspect.
336    extra_proxy_auth_headers: Vec<String>,
337    /// Whether the proxy-proof gate runs in require mode. Only then does it
338    /// contribute its header to the §5 note.
339    proxy_proof_required: bool,
340    /// Size of the call-state cache; `0` disables it.
341    call_state_cache_entries: usize,
342    /// Accelerates the fixed half of a stream's state, keyed on the
343    /// authenticated `call_id` paired with the caller's identity. Purely an
344    /// accelerator: a miss reopens the call token the client echoed, so
345    /// correctness never depends on a hit. See [`HttpState::resolve_call`].
346    call_states: std::sync::Mutex<HashMap<String, (u64, Arc<ResolvedCall>)>>,
347    token_ttl: std::time::Duration,
348    max_body_size: usize,
349    /// Wall-clock ceiling for a single HTTP request, enforced by a
350    /// `tower_http::timeout::TimeoutLayer`. A stalled handler or a
351    /// slow-loris client cannot pin a runtime worker indefinitely.
352    request_timeout: std::time::Duration,
353    authenticate: Option<crate::auth::Authenticate>,
354    #[allow(dead_code)]
355    oauth_metadata: Option<Arc<crate::auth::oauth::OAuthResourceMetadata>>,
356    oauth_metadata_json: Option<Vec<u8>>,
357    www_authenticate: Option<String>,
358    cors_max_age: u32,
359    prefix: String,
360    response_compression_level: Option<i32>,
361    landing_page_enabled: bool,
362    describe_page_enabled: bool,
363    health_enabled: bool,
364    max_request_bytes: Option<usize>,
365    /// Hard cap on the HTTP body size for unary and stream-exchange
366    /// responses (advertised via `VGI-Max-Response-Bytes`).  `None` =
367    /// unbounded.  Externalised payloads do not count toward this — they
368    /// leave only tiny pointer batches on the wire.
369    max_response_bytes: Option<usize>,
370    /// Cap on bytes uploaded to external storage while producing one HTTP
371    /// response (advertised via `VGI-Max-Externalized-Response-Bytes`).
372    /// `None` = unbounded.
373    ///
374    /// **Hard on every method type**, unlike `max_response_bytes` which is
375    /// soft for producers: a wire overshoot can be carried to the next turn
376    /// by a continuation token, but bytes already uploaded cannot be
377    /// un-uploaded, so there is nothing for a continuation to rescue.
378    /// Enforced by pre-flighting the payload size before each upload (see
379    /// [`crate::external::prepare_externalize_batch`]) with a post-flush
380    /// backstop on the unary path.
381    max_externalized_response_bytes: Option<usize>,
382    upload_url_provider: Option<Arc<dyn crate::external::UploadUrlProvider>>,
383    /// Sticky-session context, `Some` when the server is sticky-enabled.
384    sticky: Option<Arc<crate::sticky::StickyContext>>,
385    /// Token-introspection endpoint, `Some` only when an operator supplied a
386    /// resolver. `None` leaves `{prefix}/__introspect_token__` answering a
387    /// fixed `404 not_enabled` that looks nothing up.
388    introspect: Option<Arc<crate::auth::introspect::TokenIntrospector>>,
389    /// Producer for the standardized landing contract (`describe.json` +
390    /// lazy columns). `Some` mounts the describe routes; `None` leaves the
391    /// server serving only the shared `landing.html` at `GET {prefix}/`.
392    landing_info: Option<LandingInfo>,
393    /// Capability response headers precomputed from immutable config at
394    /// build time, cloned into each response instead of re-formatting the
395    /// numeric caps (`n.to_string()` + `HeaderValue::from_str`) per request.
396    capability_headers: HeaderMap,
397    /// Whether any capability header beyond the always-present flags is
398    /// set — gates the preflight `Cache-Control` header.
399    capability_has_any: bool,
400    /// `Access-Control-Expose-Headers` value, precomputed alongside
401    /// `capability_headers` so it lists exactly the `VGI-*` headers this
402    /// server actually emits. Without it a browser can read none of them:
403    /// `fetch()` hides every non-safelisted response header.
404    cors_expose_headers: HeaderValue,
405    /// `Access-Control-Allow-Origin` value precomputed from `cors_origins`
406    /// so the per-response CORS path doesn't re-parse the origin string.
407    cors_allow_origin: Option<HeaderValue>,
408}
409
410/// Fluent builder for [`HttpState`].
411#[derive(Default)]
412pub struct HttpStateBuilder {
413    server: Option<Arc<RpcServer>>,
414    token_key: Option<[u8; 32]>,
415    token_ttl: Option<std::time::Duration>,
416    max_body_size: Option<usize>,
417    request_timeout: Option<std::time::Duration>,
418    authenticate: Option<crate::auth::Authenticate>,
419    oauth_metadata: Option<Arc<crate::auth::oauth::OAuthResourceMetadata>>,
420    cors_origins: Option<String>,
421    cors_max_age: Option<u32>,
422    prefix: Option<String>,
423    /// Two-level option so the builder can tell "never configured" (outer
424    /// `None` — take [`DEFAULT_RESPONSE_COMPRESSION_LEVEL`]) apart from
425    /// "explicitly switched off" (`Some(None)`), which the single-level form
426    /// could not express once the default became on.
427    response_compression_level: Option<Option<i32>>,
428    landing_page_enabled: Option<bool>,
429    describe_page_enabled: Option<bool>,
430    health_enabled: Option<bool>,
431    max_request_bytes: Option<usize>,
432    max_upload_bytes: Option<usize>,
433    max_response_bytes: Option<usize>,
434    max_externalized_response_bytes: Option<usize>,
435    upload_url_provider: Option<Arc<dyn crate::external::UploadUrlProvider>>,
436    enable_sticky: Option<bool>,
437    sticky_default_ttl: Option<std::time::Duration>,
438    sticky_echo_headers: Vec<(String, String)>,
439    landing_info: Option<LandingInfo>,
440    proxy_proof_required: Option<bool>,
441    extra_proxy_auth_headers: Vec<String>,
442    call_state_cache_entries: Option<usize>,
443    introspect_resolver: Option<crate::auth::introspect::TokenResolver>,
444    introspect_principals: Vec<String>,
445    introspect_default_ttl_seconds: Option<u64>,
446    introspect_rate_limit: Option<u32>,
447}
448
449impl HttpStateBuilder {
450    pub fn server(mut self, server: Arc<RpcServer>) -> Self {
451        self.server = Some(server);
452        self
453    }
454
455    /// AEAD master key used to seal state tokens. **Must be ≥32 bytes**
456    /// — the XChaCha20-Poly1305 key size; the first 32 bytes are used. A
457    /// shorter slice is a configuration error and panics rather than
458    /// being silently zero-padded into a weak key. When not set, a
459    /// random 32-byte key is generated at `build()` time.
460    pub fn token_key(mut self, key: &[u8]) -> Self {
461        assert!(
462            key.len() >= 32,
463            "token_key: signing key must be at least 32 bytes (got {}). \
464             Generate one with `openssl rand -hex 32`.",
465            key.len()
466        );
467        let mut k = [0u8; 32];
468        k.copy_from_slice(&key[..32]);
469        self.token_key = Some(k);
470        self
471    }
472
473    /// Set the token key from a lowercase-hex string (64 hex chars →
474    /// 32 bytes). Panics on invalid input — intended for startup config,
475    /// not runtime callers.
476    pub fn token_key_hex(self, hex: &str) -> Self {
477        let bytes = decode_hex_key(hex).expect("token_key_hex: invalid hex or wrong length");
478        self.token_key(&bytes)
479    }
480
481    /// Set the token key from a base64-encoded string (standard alphabet,
482    /// padding optional). Panics on invalid input.
483    pub fn token_key_base64(self, b64: &str) -> Self {
484        let bytes =
485            decode_base64_key(b64).expect("token_key_base64: invalid base64 or wrong length");
486        self.token_key(&bytes)
487    }
488
489    /// Read the token key from environment variable `var`. Accepts either
490    /// base64 or lowercase-hex (auto-detected). Panics if the variable is
491    /// unset, empty, or decodes to fewer than 32 bytes. Use this for
492    /// production deployments where the key is supplied by a secret manager.
493    pub fn token_key_from_env(self, var: &str) -> Self {
494        let raw = std::env::var(var)
495            .unwrap_or_else(|_| panic!("token_key_from_env: env var {var} is unset or not UTF-8"));
496        let trimmed = raw.trim();
497        let bytes = decode_base64_key(trimmed)
498            .or_else(|_| decode_hex_key(trimmed))
499            .unwrap_or_else(|e| {
500                panic!("token_key_from_env: {var} is not valid base64 or hex ({e})")
501            });
502        self.token_key(&bytes)
503    }
504
505    /// Assert the protocol's fixed producer limit of one data batch per HTTP
506    /// response. Retained for source compatibility with callers that
507    /// explicitly configured the former default; values other than `1` are
508    /// rejected because HTTP requests are lock-step producer turns.
509    pub fn producer_batch_limit(self, n: usize) -> Self {
510        assert_eq!(
511            n, 1,
512            "producer_batch_limit is fixed at 1 by the lock-step protocol"
513        );
514        self
515    }
516
517    /// Maximum age of a state token. Continuation requests with a token
518    /// older than this are rejected. Default `5 minutes`. Set to
519    /// `Duration::ZERO` to disable TTL enforcement.
520    /// Size the per-process call-state cache; `0` disables it.
521    ///
522    /// The cache is a pure accelerator — a miss reopens the call token the
523    /// client echoed, so correctness never depends on a hit. Disabling it is
524    /// the supported way to prove that: every continuation then takes the
525    /// miss path, so a client that fails to echo the call token fails
526    /// immediately instead of only once the cache goes cold in production.
527    pub fn call_state_cache_entries(mut self, n: usize) -> Self {
528        self.call_state_cache_entries = Some(n);
529        self
530    }
531
532    pub fn token_ttl(mut self, ttl: std::time::Duration) -> Self {
533        self.token_ttl = Some(ttl);
534        self
535    }
536
537    /// Maximum request body size (post-decompression) in bytes. Default
538    /// `64 * 1024 * 1024` (64 MiB). Enforced as a hard ceiling on the
539    /// raw request body by a `RequestBodyLimitLayer` — independent of the
540    /// `Content-Length` header, so a chunked upload cannot bypass it.
541    pub fn max_body_size(mut self, n: usize) -> Self {
542        self.max_body_size = Some(n);
543        self
544    }
545
546    /// Wall-clock timeout for a single HTTP request. Default 30 s.
547    pub fn request_timeout(mut self, d: std::time::Duration) -> Self {
548        self.request_timeout = Some(d);
549        self
550    }
551
552    /// Register an authenticate callback run on every request. Not set →
553    /// anonymous for all callers (mirrors the Python `make_wsgi_app` default).
554    pub fn authenticate(mut self, cb: crate::auth::Authenticate) -> Self {
555        self.authenticate = Some(cb);
556        self
557    }
558
559    /// Advertise `VGI-Proxy-Proof-Required: true` so a proxy can tell it is
560    /// minting proofs for a worker that actually checks them. Set it when
561    /// the [`proof gate`](crate::auth::proof::proof_authenticate) is
562    /// installed in [`ProofMode::Require`](crate::auth::proof::ProofMode).
563    ///
564    /// Advertisement only — it enables and enforces nothing. The gate rides
565    /// in through [`Self::authenticate`] as an opaque callback the builder
566    /// cannot introspect, so the operator states the posture here.
567    /// Declare proxy-injected headers this service's authentication depends
568    /// on, for a custom authenticator the framework cannot introspect. The
569    /// built-in proxy-proof gate registers its own header in require mode.
570    pub fn proxy_auth_headers<I, S>(mut self, headers: I) -> Self
571    where
572        I: IntoIterator<Item = S>,
573        S: Into<String>,
574    {
575        self.extra_proxy_auth_headers = headers.into_iter().map(Into::into).collect();
576        self
577    }
578
579    pub fn proxy_proof_required(mut self, required: bool) -> Self {
580        self.proxy_proof_required = Some(required);
581        self
582    }
583
584    /// Attach RFC 9728 Protected Resource Metadata. When set, the server
585    /// exposes `/.well-known/oauth-protected-resource` and includes a
586    /// `WWW-Authenticate` header on 401 responses.
587    pub fn oauth_resource_metadata(
588        mut self,
589        metadata: crate::auth::oauth::OAuthResourceMetadata,
590    ) -> Self {
591        self.oauth_metadata = Some(Arc::new(metadata));
592        self
593    }
594
595    /// Enable CORS with the given `Access-Control-Allow-Origin` value.
596    /// Pass `"*"` for a permissive server or a specific origin URL.
597    pub fn cors_origins(mut self, origins: impl Into<String>) -> Self {
598        self.cors_origins = Some(origins.into());
599        self
600    }
601
602    /// Override the preflight cache lifetime (seconds). Default `7200`.
603    pub fn cors_max_age(mut self, seconds: u32) -> Self {
604        self.cors_max_age = Some(seconds);
605        self
606    }
607
608    /// Mount the router under a URL prefix (e.g. `/v1`). Default empty.
609    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
610        self.prefix = Some(prefix.into());
611        self
612    }
613
614    /// Override the zstd level (1..=22) used for response compression.
615    ///
616    /// Response compression is **on by default** at
617    /// [`DEFAULT_RESPONSE_COMPRESSION_LEVEL`]; this only changes the level.
618    /// It applies when the client offers zstd in `X-VGI-Accept-Encoding` or
619    /// `Accept-Encoding` and the body is at least 1024 bytes (bodies below
620    /// that threshold are sent uncompressed).
621    /// Use [`HttpStateBuilder::disable_response_compression`] to turn it off.
622    pub fn response_compression_level(mut self, level: i32) -> Self {
623        self.response_compression_level = Some(Some(level));
624        self
625    }
626
627    /// Turn response compression off entirely.
628    ///
629    /// The server then advertises an empty `VGI-Supported-Encodings` — a
630    /// positive statement that it speaks no compression, distinct from a
631    /// legacy server that omits the header — and ships every body
632    /// uncompressed however the client asks. Use this when a proxy or CDN
633    /// already compresses, or when measuring the uncompressed wire.
634    pub fn disable_response_compression(mut self) -> Self {
635        self.response_compression_level = Some(None);
636        self
637    }
638
639    /// Serve a friendly HTML landing page at `GET /`. Default on.
640    pub fn enable_landing_page(mut self, enabled: bool) -> Self {
641        self.landing_page_enabled = Some(enabled);
642        self
643    }
644
645    /// Serve an API reference HTML page at `GET /describe`. Default on.
646    pub fn enable_describe_page(mut self, enabled: bool) -> Self {
647        self.describe_page_enabled = Some(enabled);
648        self
649    }
650
651    /// Serve a liveness probe at `GET /health`. Default on.
652    pub fn enable_health(mut self, enabled: bool) -> Self {
653        self.health_enabled = Some(enabled);
654        self
655    }
656
657    /// Maximum inline-request body size advertised via the
658    /// `VGI-Max-Request-Bytes` capability header and enforced server-side
659    /// (413 Payload Too Large for non-exempt routes). When set together
660    /// with [`Self::upload_url_provider`], clients can externalize
661    /// oversize requests via `__upload_url__/init` + a pointer batch.
662    pub fn max_request_bytes(mut self, n: usize) -> Self {
663        self.max_request_bytes = Some(n);
664        self
665    }
666
667    /// Advertised upper bound on the size of any single client-vended
668    /// upload (header `VGI-Max-Upload-Bytes`). Advertisement only — no
669    /// server-side enforcement.
670    pub fn max_upload_bytes(mut self, n: usize) -> Self {
671        self.max_upload_bytes = Some(n);
672        self
673    }
674
675    /// HTTP body cap (header `VGI-Max-Response-Bytes`). Hard for unary
676    /// and stream-exchange — overshoot replaces the response with a
677    /// fresh EXCEPTION-only IPC stream surfaced via 200 +
678    /// `X-VGI-RPC-Error: true`. Externalised payloads do not count
679    /// toward this cap.
680    pub fn max_response_bytes(mut self, n: usize) -> Self {
681        self.max_response_bytes = Some(n);
682        self
683    }
684
685    /// Cap on bytes uploaded to external storage during one HTTP
686    /// response (header `VGI-Max-Externalized-Response-Bytes`).  Always
687    /// hard — externalised uploads have no escape valve.
688    pub fn max_externalized_response_bytes(mut self, n: usize) -> Self {
689        self.max_externalized_response_bytes = Some(n);
690        self
691    }
692
693    /// Install an [`UploadUrlProvider`](crate::external::UploadUrlProvider).
694    /// When set, the server exposes `POST /__upload_url__/init` and
695    /// advertises `VGI-Upload-URL-Support: true`.
696    pub fn upload_url_provider(
697        mut self,
698        provider: Arc<dyn crate::external::UploadUrlProvider>,
699    ) -> Self {
700        self.upload_url_provider = Some(provider);
701        self
702    }
703
704    /// Opt in to sticky sessions (HTTP-only). When enabled the server
705    /// advertises `VGI-Sticky-Enabled: true`, honours the `VGI-Session` /
706    /// `VGI-Session-Accept` headers, and exposes `DELETE {prefix}/__session__`.
707    /// Off by default — the non-sticky wire path is unchanged.
708    pub fn enable_sticky(mut self, enabled: bool) -> Self {
709        self.enable_sticky = Some(enabled);
710        self
711    }
712
713    /// Default session TTL when a method calls `ctx.open_session` without
714    /// an explicit TTL. Default 300 s. Advertised via `VGI-Sticky-Default-TTL`.
715    pub fn sticky_default_ttl(mut self, ttl: std::time::Duration) -> Self {
716        self.sticky_default_ttl = Some(ttl);
717        self
718    }
719
720    /// Headers the server tells the client to echo back on every
721    /// subsequent request in a session (emitted as `VGI-Echo-<name>` on
722    /// the session-opening response; advertised by name via
723    /// `VGI-Sticky-Echo-Headers`). Used for client-driven routing
724    /// (e.g. `fly-force-instance-id` on Fly.io).
725    pub fn sticky_echo_headers(
726        mut self,
727        headers: impl IntoIterator<Item = (String, String)>,
728    ) -> Self {
729        self.sticky_echo_headers = headers.into_iter().collect();
730        self
731    }
732
733    /// Supply the worker's identity for the standardized landing surface.
734    /// When set, `GET {prefix}/` serves the shared `landing.html` plus a JSON
735    /// status document carrying this identity, and `GET {prefix}/vgi-client.js`
736    /// serves the browser client build the page reads the catalog with.
737    pub fn landing_info(mut self, info: LandingInfo) -> Self {
738        self.landing_info = Some(info);
739        self
740    }
741
742    /// Enable `POST {prefix}/__introspect_token__`, which resolves an opaque
743    /// bearer credential to a principal for a reverse proxy that must know the
744    /// caller's identity before it can authorize.
745    ///
746    /// Off by default: the route answers a fixed `404 not_enabled` and holds no
747    /// resolver, so no worker grows a credential-to-identity oracle by
748    /// upgrading a dependency. It stays definitive rather than unrouted because
749    /// a caller that classifies `401/403/404` as final and everything else as
750    /// transient would retry a generic `415` forever.
751    ///
752    /// Requires [`introspect_principals`](Self::introspect_principals). See
753    /// [`crate::auth::introspect`] for the guards and why it is deliberately
754    /// *not* a replay through the server's own authenticate chain.
755    pub fn introspect_resolver(mut self, resolver: crate::auth::introspect::TokenResolver) -> Self {
756        self.introspect_resolver = Some(resolver);
757        self
758    }
759
760    /// Principals permitted to introspect.
761    ///
762    /// Required alongside [`introspect_resolver`](Self::introspect_resolver),
763    /// with **no permissive default**: authentication and introspection are
764    /// different capabilities, and a deployment where any valid credential may
765    /// introspect lets any user resolve any other user's credential to its
766    /// owner.
767    pub fn introspect_principals<I, S>(mut self, principals: I) -> Self
768    where
769        I: IntoIterator<Item = S>,
770        S: Into<String>,
771    {
772        self.introspect_principals = principals.into_iter().map(Into::into).collect();
773        self
774    }
775
776    /// Cache window advertised as `ttl_seconds` for a resolution that does not
777    /// name its own. Default 300 s. Treat it as an authorization window: for
778    /// any path the asker serves without re-presenting the credential, it is
779    /// exactly that.
780    pub fn introspect_default_ttl(mut self, ttl: std::time::Duration) -> Self {
781        self.introspect_default_ttl_seconds = Some(ttl.as_secs());
782        self
783    }
784
785    /// Introspection requests allowed per caller per second (default 20).
786    /// Bounds, rather than closes, the oracle an allowlisted-but-compromised
787    /// caller still has.
788    pub fn introspect_rate_limit(mut self, per_second: u32) -> Self {
789        self.introspect_rate_limit = Some(per_second);
790        self
791    }
792
793    pub fn build(self) -> Arc<HttpState> {
794        let server = self.server.expect("HttpStateBuilder::server is required");
795        // A wildcard CORS origin combined with a credentialed auth
796        // callback is unsafe and a browser would refuse it anyway: an
797        // `Access-Control-Allow-Origin: *` response cannot carry
798        // `Allow-Credentials: true`. Fail fast at config time instead of
799        // shipping a server whose authenticated cross-origin requests
800        // silently break.
801        assert!(
802            !(self.cors_origins.as_deref() == Some("*") && self.authenticate.is_some()),
803            "HttpStateBuilder: cors_origins(\"*\") cannot be combined with an \
804             authenticate callback — browsers reject credentialed requests \
805             against a wildcard origin. Configure a specific origin."
806        );
807        let token_key = self.token_key.unwrap_or_else(|| {
808            tracing::warn!(
809                target: "vgi_rpc.http",
810                "no token_key configured; using ephemeral per-process AEAD key — \
811                 state tokens will not survive restart or load-balance across workers"
812            );
813            let mut k = [0u8; 32];
814            rand::thread_rng().fill_bytes(&mut k);
815            k
816        });
817        let oauth_metadata_json = self
818            .oauth_metadata
819            .as_ref()
820            .map(|m| m.to_json().into_bytes());
821        let www_authenticate = self.oauth_metadata.as_ref().map(|m| m.www_authenticate());
822        let sticky = if self.enable_sticky.unwrap_or(false) {
823            let ttl = self
824                .sticky_default_ttl
825                .unwrap_or_else(|| std::time::Duration::from_secs(300));
826            Some(crate::sticky::StickyContext::new(
827                token_key,
828                ttl,
829                self.sticky_echo_headers,
830                server.server_id.clone(),
831            ))
832        } else {
833            None
834        };
835        // Introspection is resolved here, before any route exists, so a
836        // misconfiguration fails at construction rather than at the first proxy
837        // preflight.
838        let introspect = match self.introspect_resolver {
839            Some(resolver) => Some(Arc::new(crate::auth::introspect::TokenIntrospector::new(
840                resolver,
841                self.introspect_principals,
842                self.introspect_default_ttl_seconds
843                    .unwrap_or(crate::auth::introspect::DEFAULT_INTROSPECT_TTL_SECONDS),
844                self.introspect_rate_limit
845                    .unwrap_or(crate::auth::introspect::DEFAULT_INTROSPECT_RATE_LIMIT),
846            ))),
847            None => {
848                assert!(
849                    self.introspect_principals.is_empty(),
850                    "introspect_principals was given without introspect_resolver; the \
851                     route stays disabled, so the allowlist would have no effect. Pass \
852                     both or neither."
853                );
854                None
855            }
856        };
857        let cors_allow_origin = self
858            .cors_origins
859            .as_deref()
860            .and_then(|o| HeaderValue::from_str(o).ok());
861        // Unconfigured ⇒ compression on at the default level. `Some(None)` is
862        // the explicit opt-out from `disable_response_compression`.
863        let response_compression_level = self
864            .response_compression_level
865            .unwrap_or(Some(DEFAULT_RESPONSE_COMPRESSION_LEVEL));
866        let (capability_headers, capability_has_any, cors_expose_headers) =
867            build_capability_headers(CapabilityInputs {
868                max_request_bytes: self.max_request_bytes,
869                max_response_bytes: self.max_response_bytes,
870                max_externalized_response_bytes: self.max_externalized_response_bytes,
871                externalization_enabled: server.external_config().is_some(),
872                upload_url_support: self.upload_url_provider.is_some(),
873                max_upload_bytes: self.max_upload_bytes,
874                sticky: sticky.as_deref(),
875                response_compression_level,
876                proxy_proof_required: self.proxy_proof_required.unwrap_or(false),
877                introspect_enabled: introspect.is_some(),
878            });
879        Arc::new(HttpState {
880            extra_proxy_auth_headers: self.extra_proxy_auth_headers.clone(),
881            proxy_proof_required: self.proxy_proof_required.unwrap_or(false),
882            call_state_cache_entries: self
883                .call_state_cache_entries
884                .unwrap_or(CALL_STATE_CACHE_ENTRIES),
885            call_states: std::sync::Mutex::new(HashMap::new()),
886            server,
887            token_key,
888            token_ttl: self
889                .token_ttl
890                .unwrap_or_else(|| std::time::Duration::from_secs(300)),
891            max_body_size: self.max_body_size.unwrap_or(64 * 1024 * 1024),
892            request_timeout: self
893                .request_timeout
894                .unwrap_or_else(|| std::time::Duration::from_secs(30)),
895            authenticate: self.authenticate,
896            oauth_metadata: self.oauth_metadata,
897            oauth_metadata_json,
898            www_authenticate,
899            cors_max_age: self.cors_max_age.unwrap_or(7200),
900            prefix: self.prefix.unwrap_or_default(),
901            response_compression_level,
902            landing_page_enabled: self.landing_page_enabled.unwrap_or(true),
903            describe_page_enabled: self.describe_page_enabled.unwrap_or(true),
904            health_enabled: self.health_enabled.unwrap_or(true),
905            max_request_bytes: self.max_request_bytes,
906            max_response_bytes: self.max_response_bytes,
907            max_externalized_response_bytes: self.max_externalized_response_bytes,
908            upload_url_provider: self.upload_url_provider,
909            sticky,
910            introspect,
911            landing_info: self.landing_info,
912            capability_headers,
913            capability_has_any,
914            cors_expose_headers,
915            cors_allow_origin,
916        })
917    }
918}
919
920/// Everything `build_capability_headers` advertises, grouped so the signature
921/// stays readable as capabilities accumulate.
922struct CapabilityInputs<'a> {
923    max_request_bytes: Option<usize>,
924    max_response_bytes: Option<usize>,
925    max_externalized_response_bytes: Option<usize>,
926    externalization_enabled: bool,
927    upload_url_support: bool,
928    max_upload_bytes: Option<usize>,
929    sticky: Option<&'a crate::sticky::StickyContext>,
930    response_compression_level: Option<i32>,
931    proxy_proof_required: bool,
932    introspect_enabled: bool,
933}
934
935/// Response headers a browser client must be able to read that are *not*
936/// capability headers, and so cannot be derived from the capability map:
937/// the two content-coding stampings (`X-VGI-Content-Encoding` is how a
938/// `fetch()` client learns it must decompress the body itself), the RPC
939/// error marker, the auth challenge, and the two 401 discriminators. The
940/// latter ride only rejection responses, so nothing on `/health` implies
941/// them — yet a browser that cannot read `VGI-Auth-Reason` is back to
942/// matching on message text, which is exactly what the reason code exists to
943/// replace. `Content-Encoding` and `WWW-Authenticate` are technically
944/// CORS-safelisted already; listing them costs nothing and keeps the set
945/// explicit.
946const CORS_EXPOSE_FIXED: [&str; 7] = [
947    "Content-Encoding",
948    VGI_CONTENT_ENCODING_HEADER,
949    RPC_ERROR_HEADER,
950    "WWW-Authenticate",
951    AUTH_REASON_HEADER,
952    AUTH_PROXY_REQUIRED_HEADER,
953    // Rides every response including the failures, and is never advertised
954    // on /health, so a check derived from advertisements cannot reach it.
955    // It is what lets a browser client quote an id this server's own log can
956    // be searched for.
957    REQUEST_ID_RESPONSE_HEADER,
958];
959
960/// Per-request correlation id, echoed from the caller when supplied and
961/// generated otherwise.
962pub const REQUEST_ID_RESPONSE_HEADER: &str = "x-request-id";
963
964/// Precompute the immutable capability response headers once, mirroring the
965/// per-response logic that `attach_capability_headers` used to run. Returns
966/// the header set, whether any "beyond the always-present flags" header is
967/// present (which gates the preflight `Cache-Control`), and the matching
968/// `Access-Control-Expose-Headers` value.
969fn build_capability_headers(inputs: CapabilityInputs<'_>) -> (HeaderMap, bool, HeaderValue) {
970    let CapabilityInputs {
971        max_request_bytes,
972        max_response_bytes,
973        max_externalized_response_bytes,
974        externalization_enabled,
975        upload_url_support,
976        max_upload_bytes,
977        sticky,
978        response_compression_level,
979        proxy_proof_required,
980        introspect_enabled,
981    } = inputs;
982    let mut out = HeaderMap::new();
983    let mut any = false;
984    // Compression codecs usable in both directions. Always emitted, even
985    // empty: present-but-empty says "I speak no compression", while an
986    // absent header means "legacy server, assume zstd". Like
987    // `vgi-externalization-enabled`, an always-present flag does not flip
988    // `any` (which gates the preflight `Cache-Control`).
989    if let Ok(v) = HeaderValue::from_str(&supported_encodings_header_value(
990        response_compression_level,
991    )) {
992        out.insert("vgi-supported-encodings", v);
993    }
994    if let Some(n) = max_request_bytes {
995        if let Ok(v) = HeaderValue::from_str(&n.to_string()) {
996            out.insert("vgi-max-request-bytes", v);
997            any = true;
998        }
999    }
1000    if let Some(n) = max_response_bytes {
1001        if let Ok(v) = HeaderValue::from_str(&n.to_string()) {
1002            out.insert("vgi-max-response-bytes", v);
1003            any = true;
1004        }
1005    }
1006    if let Some(n) = max_externalized_response_bytes {
1007        if let Ok(v) = HeaderValue::from_str(&n.to_string()) {
1008            out.insert("vgi-max-externalized-response-bytes", v);
1009            any = true;
1010        }
1011    }
1012    // Always present so capability-aware clients can decide whether to
1013    // expect externalised payloads.
1014    out.insert(
1015        "vgi-externalization-enabled",
1016        HeaderValue::from_static(if externalization_enabled {
1017            "true"
1018        } else {
1019            "false"
1020        }),
1021    );
1022    if upload_url_support {
1023        out.insert("vgi-upload-url-support", HeaderValue::from_static("true"));
1024        any = true;
1025        if let Some(n) = max_upload_bytes {
1026            if let Ok(v) = HeaderValue::from_str(&n.to_string()) {
1027                out.insert("vgi-max-upload-bytes", v);
1028            }
1029        }
1030    }
1031    // Proxy-proof posture. Emitted only in `require` mode — `allow` never
1032    // denies, so advertising there would tell a proxy the hop is enforced
1033    // when it is not. Absence therefore means "not enforcing", which is why
1034    // there is no negative form.
1035    if proxy_proof_required {
1036        out.insert(
1037            crate::auth::proof::PROOF_REQUIRED_HEADER,
1038            HeaderValue::from_static("true"),
1039        );
1040        any = true;
1041    }
1042    // Token introspection. Positive form only — absence is the answer when the
1043    // route is disabled, and the point of the advert is that a fronting proxy
1044    // preflights at boot rather than discovering at first login that the worker
1045    // it depends on cannot resolve credentials. Riding the capability map means
1046    // it lands in `Access-Control-Expose-Headers` for free below.
1047    if introspect_enabled {
1048        out.insert(
1049            crate::auth::introspect::INTROSPECT_ENABLED_HEADER,
1050            HeaderValue::from_static("true"),
1051        );
1052        any = true;
1053    }
1054    // Sticky-session capabilities. Always emit the enabled flag (negative
1055    // form when off) so capability discovery is unambiguous.
1056    if let Some(sticky) = sticky {
1057        out.insert(STICKY_ENABLED_HEADER, HeaderValue::from_static("true"));
1058        any = true;
1059        if let Ok(v) = HeaderValue::from_str(&sticky.default_ttl.as_secs().to_string()) {
1060            out.insert(STICKY_DEFAULT_TTL_HEADER, v);
1061        }
1062        if !sticky.echo_headers.is_empty() {
1063            let names = sticky
1064                .echo_headers
1065                .iter()
1066                .map(|(n, _)| n.as_str())
1067                .collect::<Vec<_>>()
1068                .join(",");
1069            if let Ok(v) = HeaderValue::from_str(&names) {
1070                out.insert(STICKY_ECHO_HEADERS_HEADER, v);
1071            }
1072        }
1073    } else {
1074        out.insert(STICKY_ENABLED_HEADER, HeaderValue::from_static("false"));
1075    }
1076
1077    // Expose every capability header we actually emit, derived from `out`
1078    // itself so a capability added above is readable from a browser without
1079    // a second edit here. Previously nothing `vgi-*` was exposed at all, so
1080    // a `fetch()` client could see none of them — including
1081    // `VGI-Supported-Encodings`, which is the whole point of the
1082    // advertisement. Names are sorted only for a stable header value;
1083    // `Access-Control-Expose-Headers` is an unordered, case-insensitive set.
1084    let mut expose: Vec<String> = CORS_EXPOSE_FIXED.iter().map(|s| s.to_string()).collect();
1085    let mut cap_names: Vec<String> = out.keys().map(|k| k.as_str().to_string()).collect();
1086    // Sticky session headers are stamped per-response by
1087    // `stamp_session_headers`, not held in the capability map, so they have
1088    // to be named explicitly — a browser client cannot resume a session it
1089    // is unable to read the token for.
1090    if let Some(sticky) = sticky {
1091        cap_names.push(SESSION_HEADER.to_string());
1092        cap_names.push(SESSION_CLOSE_HEADER.to_string());
1093        cap_names.extend(
1094            sticky
1095                .echo_headers
1096                .iter()
1097                .map(|(n, _)| format!("{ECHO_HEADER_PREFIX}{n}")),
1098        );
1099    }
1100    cap_names.sort();
1101    expose.extend(cap_names);
1102    let expose = HeaderValue::from_str(&expose.join(", "))
1103        .unwrap_or_else(|_| HeaderValue::from_static("Content-Encoding, WWW-Authenticate"));
1104    (out, any, expose)
1105}
1106
1107impl HttpState {
1108    /// Create an `HttpState` with default configuration. See [`HttpState::builder`]
1109    /// for the full set of knobs.
1110    pub fn new(server: Arc<RpcServer>) -> Arc<Self> {
1111        Self::builder().server(server).build()
1112    }
1113
1114    pub fn builder() -> HttpStateBuilder {
1115        HttpStateBuilder::default()
1116    }
1117
1118    /// Operator handle for graceful sticky-session drain, or `None` when
1119    /// the server is not sticky-enabled. Wire it into a SIGTERM handler.
1120    pub fn sticky_drain_handle(&self) -> Option<crate::sticky::DrainHandle> {
1121        self.sticky.as_ref().map(|c| c.drain_handle())
1122    }
1123
1124    pub fn token_ttl(&self) -> std::time::Duration {
1125        self.token_ttl
1126    }
1127
1128    pub fn max_body_size(&self) -> usize {
1129        self.max_body_size
1130    }
1131
1132    /// Seal a v4 state token bound to the supplied auth identity.
1133    ///
1134    /// `(domain, principal)` are carried as AEAD associated data, so a
1135    /// token issued under one identity fails decryption when presented
1136    /// by another — same anti-replay guarantee as the prior HMAC subkey
1137    /// derivation, expressed via AAD instead of key derivation.
1138    pub(crate) fn pack_cursor_token(
1139        &self,
1140        auth: &crate::auth::AuthContext,
1141        state_bytes: &[u8],
1142        call_id: &[u8; CALL_ID_LEN],
1143    ) -> String {
1144        let aad = compute_aad(auth);
1145        pack_cursor_token(
1146            &self.token_key,
1147            &aad,
1148            state_bytes,
1149            call_id,
1150            current_unix_secs(),
1151        )
1152    }
1153
1154    /// Mint a stream's call token and warm the cache with what we already
1155    /// hold, so the stream's first continuation need not open the token it
1156    /// was just handed.
1157    pub(crate) fn pack_call_token(
1158        &self,
1159        auth: &crate::auth::AuthContext,
1160        call_id: &[u8; CALL_ID_LEN],
1161        output_schema_bytes: &[u8],
1162        input_schema_bytes: &[u8],
1163        stream_id: &str,
1164    ) -> String {
1165        let aad = compute_call_aad(auth);
1166        let token = pack_call_token(
1167            &self.token_key,
1168            &aad,
1169            call_id,
1170            output_schema_bytes,
1171            input_schema_bytes,
1172            stream_id,
1173            current_unix_secs(),
1174        );
1175        self.cache_call(
1176            auth,
1177            call_id,
1178            Arc::new(ResolvedCall {
1179                output_schema_bytes: output_schema_bytes.to_vec(),
1180                input_schema_bytes: input_schema_bytes.to_vec(),
1181                stream_id: stream_id.to_string(),
1182            }),
1183        );
1184        token
1185    }
1186
1187    fn token_ttl_opt(&self) -> Option<std::time::Duration> {
1188        if self.token_ttl.is_zero() {
1189            None
1190        } else {
1191            Some(self.token_ttl)
1192        }
1193    }
1194
1195    /// Open a cursor token, decrypting under the current caller's
1196    /// identity-derived AAD and enforcing TTL after authenticity.
1197    pub(crate) fn unpack_cursor_token(
1198        &self,
1199        auth: &crate::auth::AuthContext,
1200        token: &str,
1201    ) -> Result<UnpackedCursor> {
1202        let aad = compute_aad(auth);
1203        unpack_cursor_token(&self.token_key, &aad, token, self.token_ttl_opt())
1204    }
1205
1206    /// The proxy-injected headers this server's authentication depends on,
1207    /// or empty when it depends on none.
1208    ///
1209    /// Derived from configuration rather than from what failed on this
1210    /// request, so every 401 says the same thing (spec §5) and the note
1211    /// discloses nothing about which stage rejected a given attempt. A
1212    /// proxy-proof gate contributes only in require mode: in allow mode an
1213    /// absent proof never denies, so the note would misdirect.
1214    fn proxy_auth_headers(&self) -> Vec<String> {
1215        let mut out = Vec::new();
1216        if self.proxy_proof_required {
1217            out.push(crate::auth::proof::PROOF_HEADER.to_string());
1218        }
1219        out.extend(self.extra_proxy_auth_headers.iter().cloned());
1220        out
1221    }
1222
1223    /// Render the standardized 401 of spec §4: the reason header, a
1224    /// no-store cache directive, the proxy note when this service's auth
1225    /// depends on a proxy, and the JSON envelope.
1226    ///
1227    /// §4.2 lets a service skip the HTML page and always answer with JSON;
1228    /// what it must never do is answer a non-HTML request with HTML. This
1229    /// port takes the JSON-only option.
1230    pub(crate) fn unauthorized_response(
1231        &self,
1232        mut headers: HeaderMap,
1233        reason: AuthReason,
1234        detail: &str,
1235    ) -> Response {
1236        let proxy_headers = self.proxy_auth_headers();
1237        let hint = if proxy_headers.is_empty() {
1238            None
1239        } else {
1240            Some(crate::unauthorized::proxy_hint(&proxy_headers))
1241        };
1242        if hint.is_some() {
1243            headers.insert(
1244                HeaderName::from_static(AUTH_PROXY_REQUIRED_HEADER),
1245                HeaderValue::from_static("true"),
1246            );
1247        }
1248        if let Ok(hv) = HeaderValue::from_str(reason.as_str()) {
1249            headers.insert(HeaderName::from_static(AUTH_REASON_HEADER), hv);
1250        }
1251        // A 401 is per-request and flips to 200 on the next attempt with a
1252        // credential, so it must never be held by a shared cache.
1253        headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
1254        headers.insert(
1255            header::CONTENT_TYPE,
1256            HeaderValue::from_static("application/json"),
1257        );
1258        let body = crate::unauthorized::envelope(reason, detail, hint.as_deref());
1259        (StatusCode::UNAUTHORIZED, headers, body).into_response()
1260    }
1261
1262    /// Render the caller-identity half of a cache key.
1263    fn cache_identity(auth: &crate::auth::AuthContext) -> String {
1264        if !auth.authenticated {
1265            "\u{0}anonymous".to_string()
1266        } else {
1267            format!("{}\u{0}{}", auth.domain, auth.principal)
1268        }
1269    }
1270
1271    fn cache_key(auth: &crate::auth::AuthContext, call_id: &[u8; CALL_ID_LEN]) -> String {
1272        format!("{:02x?}\u{0}{}", call_id, Self::cache_identity(auth))
1273    }
1274
1275    fn cache_call(
1276        &self,
1277        auth: &crate::auth::AuthContext,
1278        call_id: &[u8; CALL_ID_LEN],
1279        call: Arc<ResolvedCall>,
1280    ) {
1281        if self.call_state_cache_entries == 0 {
1282            return;
1283        }
1284        let mut cache = self.call_states.lock().unwrap();
1285        let key = Self::cache_key(auth, call_id);
1286        if cache.len() >= self.call_state_cache_entries && !cache.contains_key(&key) {
1287            // Cheap bound: this is an accelerator, and a miss simply reopens
1288            // the client's token, so evicting wholesale costs correctness
1289            // nothing and keeps the map from growing without limit.
1290            cache.clear();
1291        }
1292        cache.insert(key, (current_unix_secs(), call));
1293    }
1294
1295    /// Resolve a stream's fixed half for an already-authenticated cursor.
1296    ///
1297    /// Order matters here, and it is the whole security argument for the
1298    /// cache. The cursor is opened first by the caller; its AEAD tag covers
1299    /// the `call_id` and its AAD covers the caller's identity. Only then is
1300    /// that authenticated `call_id` used as a cache key. A client cannot
1301    /// name a call id the server did not mint for it, so a cache hit can
1302    /// never hand back another principal's call state — and on a hit the
1303    /// presented call token is not consulted at all, which is exactly the
1304    /// work we are trying to avoid.
1305    ///
1306    /// On a miss (cold process, evicted entry, or a request load-balanced to
1307    /// a node that never saw this stream's `/init`) the client-supplied call
1308    /// token is opened and verified, and its embedded `call_id` must match
1309    /// the one the cursor named.
1310    pub(crate) fn resolve_call(
1311        &self,
1312        auth: &crate::auth::AuthContext,
1313        cursor: &UnpackedCursor,
1314        call_token: Option<&str>,
1315    ) -> Result<Arc<ResolvedCall>> {
1316        let key = Self::cache_key(auth, &cursor.call_id);
1317        let ttl = self.token_ttl_opt();
1318        {
1319            let mut cache = self.call_states.lock().unwrap();
1320            if let Some((stored_at, call)) = cache.get(&key).cloned() {
1321                let fresh = match ttl {
1322                    Some(t) => current_unix_secs().saturating_sub(stored_at) <= t.as_secs(),
1323                    None => true,
1324                };
1325                if fresh {
1326                    return Ok(call);
1327                }
1328                cache.remove(&key);
1329            }
1330        }
1331
1332        let Some(call_token) = call_token else {
1333            return Err(RpcError::runtime_error(
1334                "Missing call token in exchange request",
1335            ));
1336        };
1337        let aad = compute_call_aad(auth);
1338        let (call_id, resolved) = unpack_call_token(&self.token_key, &aad, call_token, ttl)?;
1339        if call_id != cursor.call_id {
1340            // The cursor named a different call. Uniform message: reachable
1341            // only by pairing two tokens the same principal legitimately
1342            // holds, so it carries nothing worth distinguishing.
1343            return Err(RpcError::runtime_error("Malformed state token"));
1344        }
1345        let resolved = Arc::new(resolved);
1346        self.cache_call(auth, &cursor.call_id, resolved.clone());
1347        Ok(resolved)
1348    }
1349}
1350
1351/// Build the AEAD associated data that binds a state token to the
1352/// authenticated identity of its issuer. Anonymous and authenticated
1353/// callers produce distinct AAD strings so a token minted in one
1354/// context cannot be opened in another. Mirrors Python's `_compute_aad`.
1355fn compute_aad(auth: &crate::auth::AuthContext) -> Vec<u8> {
1356    compute_aad_with(b"vgi_rpc.state.v4\x00", auth)
1357}
1358
1359/// [`compute_aad`]'s counterpart for call tokens. The prefix differs
1360/// deliberately, so a call token and a cursor token are not interchangeable
1361/// even for the same principal: presenting one where the other is expected
1362/// fails the AEAD tag check rather than decoding into a payload the reader
1363/// would misinterpret.
1364fn compute_call_aad(auth: &crate::auth::AuthContext) -> Vec<u8> {
1365    compute_aad_with(b"vgi_rpc.call.v1\x00", auth)
1366}
1367
1368fn compute_aad_with(prefix: &[u8], auth: &crate::auth::AuthContext) -> Vec<u8> {
1369    if !auth.authenticated {
1370        let mut out = Vec::with_capacity(prefix.len() + b"\x00anonymous".len());
1371        out.extend_from_slice(prefix);
1372        out.extend_from_slice(b"\x00anonymous");
1373        return out;
1374    }
1375    let mut out =
1376        Vec::with_capacity(prefix.len() + 1 + auth.domain.len() + 1 + auth.principal.len());
1377    out.extend_from_slice(prefix);
1378    out.push(0x01);
1379    out.extend_from_slice(auth.domain.as_bytes());
1380    out.push(0);
1381    out.extend_from_slice(auth.principal.as_bytes());
1382    out
1383}
1384
1385/// On-wire version bytes. The cursor and call tokens carry independent
1386/// version lines because they change for independent reasons.
1387///
1388/// Cursor history: v4 = AEAD over the framed plaintext; v5 = AEAD over a
1389/// codec-tagged, compressed payload; v6 = cursor-only, with the schemas and
1390/// stream id moved into the call token.
1391///
1392/// Each bump matters for rolling deploys: an older plaintext frames
1393/// differently, so a newer reader would mis-parse it. Rejecting the old
1394/// version outright turns that into the same clean failure as any other
1395/// stale token.
1396pub(crate) const CURSOR_TOKEN_VERSION: u8 = 0x06;
1397pub(crate) const CALL_TOKEN_VERSION: u8 = 0x01;
1398
1399/// Length of the random per-stream id minted at `/init` that binds a call
1400/// token to its cursors.
1401pub(crate) const CALL_ID_LEN: usize = 16;
1402
1403/// Bounds the per-process call-state cache.
1404const CALL_STATE_CACHE_ENTRIES: usize = 4096;
1405
1406/// Codec tags for the token payload, written as the first plaintext byte
1407/// inside the seal. See [`pack_token_payload`].
1408const TOKEN_CODEC_RAW: u8 = 0x00;
1409const TOKEN_CODEC_ZSTD: u8 = 0x01;
1410
1411/// Matches the Python reference's choice. At token payload sizes this
1412/// measures the same speed as level 1 and slightly smaller, while the levels
1413/// that compress materially better cost many times the CPU for a few hundred
1414/// bytes.
1415const TOKEN_ZSTD_LEVEL: i32 = 3;
1416
1417/// Bounds decompression. The payload is authenticated before it is ever
1418/// decompressed, so this guards against a framework bug rather than an
1419/// attacker — but an unbounded decompress on a request path is not worth
1420/// having.
1421const MAX_TOKEN_PLAINTEXT_BYTES: usize = 64 << 20;
1422
1423/// Compress a token payload and tag which codec was used.
1424///
1425/// Compression happens *inside* the seal, and the order is the whole point:
1426/// once sealed, a token is ciphertext, so the HTTP body codec can no longer
1427/// find any redundancy in it — it recovers only the slack base64 adds, never
1428/// the state's own structure. Compressing first reaches the real redundancy.
1429///
1430/// Compression is skipped when it does not pay, so a small token never grows
1431/// beyond its plaintext plus the one tag byte; the tag means the reader never
1432/// has to guess.
1433fn pack_token_payload(plaintext: &[u8]) -> Vec<u8> {
1434    if let Ok(packed) = zstd::bulk::compress(plaintext, TOKEN_ZSTD_LEVEL) {
1435        if packed.len() < plaintext.len() {
1436            let mut out = Vec::with_capacity(1 + packed.len());
1437            out.push(TOKEN_CODEC_ZSTD);
1438            out.extend_from_slice(&packed);
1439            return out;
1440        }
1441    }
1442    let mut out = Vec::with_capacity(1 + plaintext.len());
1443    out.push(TOKEN_CODEC_RAW);
1444    out.extend_from_slice(plaintext);
1445    out
1446}
1447
1448/// Reverse [`pack_token_payload`].
1449///
1450/// An unknown tag or a body that will not decompress means a token this
1451/// server did not mint, so both surface as the same uniform error every other
1452/// token failure uses.
1453fn unpack_token_payload(data: &[u8]) -> Result<Vec<u8>> {
1454    let (tag, body) = data
1455        .split_first()
1456        .ok_or_else(|| RpcError::runtime_error("Malformed state token"))?;
1457    match *tag {
1458        TOKEN_CODEC_RAW => Ok(body.to_vec()),
1459        TOKEN_CODEC_ZSTD => zstd::bulk::decompress(body, MAX_TOKEN_PLAINTEXT_BYTES)
1460            .map_err(|_| RpcError::runtime_error("Malformed state token")),
1461        _ => Err(RpcError::runtime_error("Malformed state token")),
1462    }
1463}
1464
1465/// Decomposed contents of a v4 state token after AEAD authentication.
1466#[derive(Debug, Clone)]
1467pub(crate) struct UnpackedCursor {
1468    pub state_bytes: Vec<u8>,
1469    /// The call token this cursor belongs to. Recovered from inside the
1470    /// cursor's ciphertext, so it is authenticated before it is trusted.
1471    pub call_id: [u8; CALL_ID_LEN],
1472    #[allow(dead_code)]
1473    pub created_at: u64,
1474}
1475
1476/// The half of a stream's state that is fixed for the life of the call —
1477/// what a cursor's `call_id` resolves to, from cache or from the client's
1478/// echoed call token.
1479#[derive(Debug, Clone)]
1480pub(crate) struct ResolvedCall {
1481    pub output_schema_bytes: Vec<u8>,
1482    pub input_schema_bytes: Vec<u8>,
1483    /// Chain-correlation id, stable across a stream's init and its
1484    /// continuations. Carried so the token round-trips it faithfully; this
1485    /// port does not yet surface it on the continuation dispatch path the
1486    /// way the Go port does.
1487    #[allow(dead_code)]
1488    pub stream_id: String,
1489}
1490
1491/// Current time as seconds since the UNIX epoch.
1492fn current_unix_secs() -> u64 {
1493    std::time::SystemTime::now()
1494        .duration_since(std::time::UNIX_EPOCH)
1495        .map(|d| d.as_secs())
1496        .unwrap_or(0)
1497}
1498
1499/// Seal a state token (v4 wire format).
1500///
1501/// On-wire layout (base64-encoded):
1502///
1503/// ```text
1504/// [1]    version = 0x04
1505/// [24]   XChaCha20-Poly1305 nonce (random)
1506/// [..]   ciphertext = XChaCha20-Poly1305-Seal(plaintext, aad, nonce, key)
1507///        plaintext (little-endian):
1508///          [8]  created_at (u64 seconds since epoch)
1509///          [4]  len(state_bytes)           [N] state_bytes
1510///          [4]  len(output_schema_bytes)   [M] output_schema_bytes
1511///          [4]  len(input_schema_bytes)    [K] input_schema_bytes
1512///          [4]  len(stream_id_bytes)       [L] stream_id_bytes (UTF-8)
1513///        [16]   Poly1305 tag (appended by AEAD construction)
1514/// ```
1515///
1516/// `created_at` lives inside the ciphertext so TTL enforcement runs
1517/// after authenticity is established. The version byte is not part of
1518/// the AAD — it acts as a format selector; a tampered version byte still
1519/// fails decryption because [`crypto::open_bytes`] rejects it before
1520/// touching the cipher.
1521///
1522/// The AEAD envelope (version byte + nonce + ciphertext+tag) is owned by
1523/// [`crypto`]; only the *plaintext* framing inside the ciphertext is this
1524/// function's concern.
1525pub(crate) fn pack_cursor_token(
1526    token_key: &[u8; 32],
1527    aad: &[u8],
1528    state_bytes: &[u8],
1529    call_id: &[u8; CALL_ID_LEN],
1530    created_at: u64,
1531) -> String {
1532    let mut plaintext = Vec::with_capacity(8 + CALL_ID_LEN + 4 + state_bytes.len());
1533    plaintext.extend_from_slice(&created_at.to_le_bytes());
1534    plaintext.extend_from_slice(call_id);
1535    plaintext.extend_from_slice(&(state_bytes.len() as u32).to_le_bytes());
1536    plaintext.extend_from_slice(state_bytes);
1537
1538    crate::crypto::seal_base64(
1539        &pack_token_payload(&plaintext),
1540        token_key,
1541        aad,
1542        CURSOR_TOKEN_VERSION,
1543    )
1544}
1545
1546/// Seal the half of a stream's state that is fixed for the life of the call.
1547/// Minted once, by `/init`; never re-issued.
1548pub(crate) fn pack_call_token(
1549    token_key: &[u8; 32],
1550    aad: &[u8],
1551    call_id: &[u8; CALL_ID_LEN],
1552    output_schema_bytes: &[u8],
1553    input_schema_bytes: &[u8],
1554    stream_id: &str,
1555    created_at: u64,
1556) -> String {
1557    let mut plaintext = Vec::with_capacity(
1558        8 + CALL_ID_LEN
1559            + 4
1560            + output_schema_bytes.len()
1561            + 4
1562            + input_schema_bytes.len()
1563            + 4
1564            + stream_id.len(),
1565    );
1566    plaintext.extend_from_slice(&created_at.to_le_bytes());
1567    plaintext.extend_from_slice(call_id);
1568    plaintext.extend_from_slice(&(output_schema_bytes.len() as u32).to_le_bytes());
1569    plaintext.extend_from_slice(output_schema_bytes);
1570    plaintext.extend_from_slice(&(input_schema_bytes.len() as u32).to_le_bytes());
1571    plaintext.extend_from_slice(input_schema_bytes);
1572    plaintext.extend_from_slice(&(stream_id.len() as u32).to_le_bytes());
1573    plaintext.extend_from_slice(stream_id.as_bytes());
1574
1575    crate::crypto::seal_base64(
1576        &pack_token_payload(&plaintext),
1577        token_key,
1578        aad,
1579        CALL_TOKEN_VERSION,
1580    )
1581}
1582
1583/// Open the AEAD envelope, decompress, and check the TTL. Shared by both
1584/// token kinds; returns the framed plaintext and its `created_at`.
1585fn open_token_plaintext(
1586    token_key: &[u8; 32],
1587    aad: &[u8],
1588    token: &str,
1589    version: u8,
1590    token_ttl: Option<std::time::Duration>,
1591) -> Result<(Vec<u8>, u64)> {
1592    let raw = base64::engine::general_purpose::STANDARD
1593        .decode(token.as_bytes())
1594        .map_err(|_| RpcError::runtime_error("Malformed state token"))?;
1595
1596    let sealed = crate::crypto::open_bytes(&raw, token_key, aad, version)
1597        .map_err(|_| RpcError::runtime_error("State token signature verification failed"))?;
1598    // Decompress only after authentication: nothing an attacker supplies
1599    // reaches the decoder without the token key.
1600    let plaintext = unpack_token_payload(&sealed)?;
1601
1602    if plaintext.len() < 8 + CALL_ID_LEN {
1603        return Err(RpcError::runtime_error("Malformed state token"));
1604    }
1605    let created_at = u64::from_le_bytes(plaintext[0..8].try_into().unwrap());
1606
1607    if let Some(ttl) = token_ttl {
1608        let now = current_unix_secs();
1609        if now > created_at && now - created_at > ttl.as_secs() {
1610            return Err(RpcError::runtime_error("State token expired"));
1611        }
1612        // A `created_at` in the future is clock skew between workers (or
1613        // a tampered host clock). Without this guard the expiry check
1614        // above is simply skipped — a token minted on a fast-clocked
1615        // worker would dodge the TTL on every normal-clocked peer.
1616        const MAX_CLOCK_SKEW_SECS: u64 = 60;
1617        if created_at > now && created_at - now > MAX_CLOCK_SKEW_SECS {
1618            return Err(RpcError::runtime_error(
1619                "State token timestamp is implausibly in the future",
1620            ));
1621        }
1622    }
1623
1624    Ok((plaintext, created_at))
1625}
1626
1627/// Open and verify a cursor token. [`crypto::open_bytes`] authenticates the
1628/// payload; every malformed, wrong-version, tampered, wrong-key, or
1629/// AAD-mismatched (e.g. cross-principal replay) token surfaces as the same
1630/// uniform signature-verification error so callers cannot distinguish
1631/// failure modes via timing or message content. Only a base64 decode
1632/// failure — observable before any crypto work — stays a distinct
1633/// "Malformed state token".
1634pub(crate) fn unpack_cursor_token(
1635    token_key: &[u8; 32],
1636    aad: &[u8],
1637    token: &str,
1638    token_ttl: Option<std::time::Duration>,
1639) -> Result<UnpackedCursor> {
1640    let (plaintext, created_at) =
1641        open_token_plaintext(token_key, aad, token, CURSOR_TOKEN_VERSION, token_ttl)?;
1642
1643    let mut call_id = [0u8; CALL_ID_LEN];
1644    call_id.copy_from_slice(&plaintext[8..8 + CALL_ID_LEN]);
1645
1646    let mut pos = 8 + CALL_ID_LEN;
1647    let state_bytes = read_segment(&plaintext, &mut pos)?;
1648    if pos != plaintext.len() {
1649        return Err(RpcError::runtime_error("Malformed state token"));
1650    }
1651
1652    Ok(UnpackedCursor {
1653        state_bytes,
1654        call_id,
1655        created_at,
1656    })
1657}
1658
1659/// Open and verify a call token, returning it paired with its embedded
1660/// `call_id` so the caller can check it against the cursor that named it.
1661pub(crate) fn unpack_call_token(
1662    token_key: &[u8; 32],
1663    aad: &[u8],
1664    token: &str,
1665    token_ttl: Option<std::time::Duration>,
1666) -> Result<([u8; CALL_ID_LEN], ResolvedCall)> {
1667    let (plaintext, _created_at) =
1668        open_token_plaintext(token_key, aad, token, CALL_TOKEN_VERSION, token_ttl)?;
1669
1670    let mut call_id = [0u8; CALL_ID_LEN];
1671    call_id.copy_from_slice(&plaintext[8..8 + CALL_ID_LEN]);
1672
1673    let mut pos = 8 + CALL_ID_LEN;
1674    let output_schema_bytes = read_segment(&plaintext, &mut pos)?;
1675    let input_schema_bytes = read_segment(&plaintext, &mut pos)?;
1676    let stream_id_bytes = read_segment(&plaintext, &mut pos)?;
1677    if pos != plaintext.len() {
1678        return Err(RpcError::runtime_error("Malformed state token"));
1679    }
1680    let stream_id = String::from_utf8(stream_id_bytes)
1681        .map_err(|_| RpcError::runtime_error("Malformed state token"))?;
1682
1683    Ok((
1684        call_id,
1685        ResolvedCall {
1686            output_schema_bytes,
1687            input_schema_bytes,
1688            stream_id,
1689        },
1690    ))
1691}
1692
1693fn read_segment(buf: &[u8], pos: &mut usize) -> Result<Vec<u8>> {
1694    if *pos + 4 > buf.len() {
1695        return Err(RpcError::runtime_error("Malformed state token"));
1696    }
1697    let len = u32::from_le_bytes(buf[*pos..*pos + 4].try_into().unwrap()) as usize;
1698    *pos += 4;
1699    if *pos + len > buf.len() {
1700        return Err(RpcError::runtime_error("Malformed state token"));
1701    }
1702    let out = buf[*pos..*pos + len].to_vec();
1703    *pos += len;
1704    Ok(out)
1705}
1706
1707/// Serialize an Arrow schema into transportable bytes — wraps it in a
1708/// zero-row IPC stream since the stock writer doesn't expose a raw
1709/// `Schema.serialize()` path. Round-trip via [`read_schema_bytes`].
1710fn write_schema_bytes(schema: &Schema) -> Result<Vec<u8>> {
1711    let empty = empty_batch(schema)?;
1712    crate::wire::write_one_batch(&empty, None)
1713}
1714
1715/// Inverse of [`write_schema_bytes`].
1716fn read_schema_bytes(bytes: &[u8]) -> Result<SchemaRef> {
1717    let r = StreamReader::new(bytes)?;
1718    Ok(r.schema())
1719}
1720
1721/// A future that resolves when the process receives SIGTERM or SIGINT
1722/// (or a Ctrl-C event on non-Unix). Pass to [`axum::serve`](fn@axum::serve)'s
1723/// `with_graceful_shutdown` to stop accepting new connections, drain
1724/// in-flight requests, and exit cleanly.
1725///
1726/// ```no_run
1727/// # async fn run(state: std::sync::Arc<vgi_rpc::http::HttpState>) {
1728/// let app = vgi_rpc::http::build_router(state);
1729/// let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
1730/// axum::serve(listener, app)
1731///     .with_graceful_shutdown(vgi_rpc::http::shutdown_signal())
1732///     .await
1733///     .unwrap();
1734/// # }
1735/// ```
1736pub async fn shutdown_signal() {
1737    #[cfg(unix)]
1738    {
1739        use tokio::signal::unix::{signal, SignalKind};
1740        let mut term = match signal(SignalKind::terminate()) {
1741            Ok(s) => s,
1742            Err(_) => {
1743                let _ = tokio::signal::ctrl_c().await;
1744                return;
1745            }
1746        };
1747        let mut intr = match signal(SignalKind::interrupt()) {
1748            Ok(s) => s,
1749            Err(_) => {
1750                let _ = tokio::signal::ctrl_c().await;
1751                return;
1752            }
1753        };
1754        tokio::select! {
1755            _ = term.recv() => {},
1756            _ = intr.recv() => {},
1757        }
1758    }
1759    #[cfg(not(unix))]
1760    {
1761        let _ = tokio::signal::ctrl_c().await;
1762    }
1763}
1764
1765/// Serve `state` on `listener`, terminating cleanly on SIGTERM/SIGINT.
1766/// Convenience wrapper around [`build_router`] +
1767/// [`axum::serve`](fn@axum::serve) + [`shutdown_signal`].
1768pub async fn serve_with_shutdown(
1769    state: Arc<HttpState>,
1770    listener: tokio::net::TcpListener,
1771) -> std::io::Result<()> {
1772    let app = build_router(state);
1773    axum::serve(listener, app)
1774        .with_graceful_shutdown(shutdown_signal())
1775        .await
1776}
1777
1778/// Absolute ceiling on a buffered HTTP response body in the
1779/// post-processing middleware — large enough for any reasonable Arrow
1780/// batch, small enough that the middleware can never be driven to
1781/// exhaust the heap. Deliberately *not* tied to
1782/// `wire::MAX_IPC_MESSAGE_BYTES`, which is a sanity bound on a claimed
1783/// frame size rather than a policy limit on a response.
1784///
1785/// This is **distinct** from the operator's `max_response_bytes`, which
1786/// is a *soft* producer-side cap: a producer is allowed to overshoot it
1787/// by one batch and then mint a continuation token, so the response
1788/// body on the wire can legitimately exceed `max_response_bytes`. The
1789/// middleware therefore caps at `max(this, 2 × max_response_bytes)` —
1790/// see [`response_buffer_ceiling`].
1791const MAX_RESPONSE_BYTES_HARD_CAP: usize = 256 * 1024 * 1024;
1792
1793/// Hard ceiling the post-processing middleware buffers a response under.
1794/// Always at least [`MAX_RESPONSE_BYTES_HARD_CAP`]; when a (soft)
1795/// `max_response_bytes` is configured, it leaves headroom for the
1796/// one-batch producer overshoot that the continuation-token design
1797/// permits.
1798fn response_buffer_ceiling(state: &HttpState) -> usize {
1799    match state.max_response_bytes {
1800        Some(soft) => MAX_RESPONSE_BYTES_HARD_CAP.max(soft.saturating_mul(2)),
1801        None => MAX_RESPONSE_BYTES_HARD_CAP,
1802    }
1803}
1804
1805pub fn build_router(state: Arc<HttpState>) -> Router {
1806    let body_limit = state.max_body_size;
1807    let request_timeout = state.request_timeout;
1808    build_router_inner(state.clone())
1809        .layer(axum::middleware::from_fn_with_state(
1810            state,
1811            postprocess_middleware,
1812        ))
1813        // Hard ceiling on the raw request body, enforced regardless of
1814        // the `Content-Length` header (chunked uploads included).
1815        //
1816        // `DefaultBodyLimit::disable()` is required for this to mean
1817        // anything. axum installs its own 2 MiB `DefaultBodyLimit` on every
1818        // route, and it is checked *before* this layer — so without the
1819        // disable, `max_body_size` was silently inert above 2 MiB and every
1820        // Arrow batch larger than that got a 413, whatever the builder said.
1821        // Measured before the fix: 2 MiB accepted, 4 MiB rejected, against a
1822        // configured ceiling of 64 MiB.
1823        .layer(axum::extract::DefaultBodyLimit::disable())
1824        .layer(tower_http::limit::RequestBodyLimitLayer::new(body_limit))
1825        // Wall-clock ceiling per request so a stalled handler or a
1826        // slow-loris client can't pin a runtime worker forever.
1827        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1828            StatusCode::REQUEST_TIMEOUT,
1829            request_timeout,
1830        ))
1831        // Convert a panic in handler code into a 500 instead of a
1832        // dropped connection. The `unwrap`s on the HTTP hot path are on
1833        // infallible `Vec` writers and server-controlled schemas, but
1834        // this is the defence-in-depth net for any that slip through.
1835        .layer(tower_http::catch_panic::CatchPanicLayer::new())
1836}
1837
1838async fn postprocess_middleware(
1839    axum::extract::State(state): axum::extract::State<Arc<HttpState>>,
1840    mut req: axum::http::Request<axum::body::Body>,
1841    next: axum::middleware::Next,
1842) -> Response {
1843    use axum::body::to_bytes;
1844    // Bind the server to the HTTP transport on first request. Idempotent
1845    // for the (kind, caps) pair so calling it per-request is cheap;
1846    // fork-safe for pre-fork deployments because each child fires once.
1847    state.server.notify_transport(
1848        crate::transport::TransportKind::Http,
1849        crate::transport::TransportCapabilities::none(),
1850    );
1851    // Extract only the header values the post-processing needs, rather than
1852    // deep-cloning the entire request `HeaderMap` on every request. The
1853    // request is consumed by `next.run(req)` below, so these must be taken
1854    // first. CORS echoes the client's `Access-Control-Request-Headers`;
1855    // compression negotiates over `X-VGI-Accept-Encoding` + `Accept-Encoding`.
1856    let req_acrh = req
1857        .headers()
1858        .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1859        .cloned();
1860    // Echo the caller's correlation id, or mint one. Resolved *before* the
1861    // handler runs and written back onto the request headers, so a handler
1862    // (and the access-log record it produces) sees exactly the value the
1863    // response will carry — including the minted case. An id that is on the
1864    // response but names a different request in the log is worse than none:
1865    // it looks like a working trail right up to the moment someone follows
1866    // it. Set on the way out so it rides every exit path, including the
1867    // errors anybody actually looks up.
1868    let request_id = req
1869        .headers()
1870        .get(REQUEST_ID_RESPONSE_HEADER)
1871        .and_then(|v| v.to_str().ok())
1872        .filter(|v| !v.is_empty() && v.len() <= 128)
1873        .map(str::to_owned)
1874        .unwrap_or_else(new_session_id);
1875    if let Ok(v) = HeaderValue::from_str(&request_id) {
1876        req.headers_mut()
1877            .insert(HeaderName::from_static(REQUEST_ID_RESPONSE_HEADER), v);
1878    }
1879    let req_encoding = pick_response_encoding(req.headers(), state.response_compression_level);
1880    let req_method = req.method().clone();
1881    let req_path = req.uri().path().to_owned();
1882
1883    // Enforce server-advertised max_request_bytes before invoking the
1884    // handler. The upload-URL control body is client-controlled and must be
1885    // capped before it can allocate storage; only payload-free health routes
1886    // are exempt.
1887    if let Some(limit) = state.max_request_bytes {
1888        let exempt = req_path == "/health" || req_path.ends_with("/health");
1889        if !exempt {
1890            if let Some(cl) = req
1891                .headers()
1892                .get(header::CONTENT_LENGTH)
1893                .and_then(|v| v.to_str().ok())
1894                .and_then(|s| s.parse::<usize>().ok())
1895            {
1896                if cl > limit {
1897                    let mut h = HeaderMap::new();
1898                    attach_capability_headers(&state, &mut h, &req_method);
1899                    attach_cors_headers(&state, &mut h, req_acrh.as_ref(), false);
1900                    return (
1901                        StatusCode::PAYLOAD_TOO_LARGE,
1902                        h,
1903                        format!(
1904                            "Request body of {cl} bytes exceeds advertised \
1905                             max_request_bytes={limit}. Use the upload-URL \
1906                             flow (__upload_url__/init) to externalize."
1907                        ),
1908                    )
1909                        .into_response();
1910                }
1911            }
1912
1913            // Content-Length is only a fast rejection. Chunked bodies have
1914            // no declared length, and a peer can otherwise stream an
1915            // arbitrarily large upload-control request through the advertised
1916            // cap. Buffer under limit+1 and restore the body for the handler;
1917            // handlers already consume complete Arrow bodies, so this adds no
1918            // new whole-body buffering behavior.
1919            let (parts, body) = req.into_parts();
1920            let bounded = to_bytes(body, limit.saturating_add(1)).await;
1921            let body = match bounded {
1922                Ok(body) if body.len() <= limit => body,
1923                Ok(body) => {
1924                    let mut h = HeaderMap::new();
1925                    attach_capability_headers(&state, &mut h, &req_method);
1926                    attach_cors_headers(&state, &mut h, req_acrh.as_ref(), false);
1927                    return (
1928                        StatusCode::PAYLOAD_TOO_LARGE,
1929                        h,
1930                        format!(
1931                            "Request body of {} bytes exceeds advertised max_request_bytes={limit}",
1932                            body.len()
1933                        ),
1934                    )
1935                        .into_response();
1936                }
1937                Err(_) => {
1938                    let mut h = HeaderMap::new();
1939                    attach_capability_headers(&state, &mut h, &req_method);
1940                    attach_cors_headers(&state, &mut h, req_acrh.as_ref(), false);
1941                    return (
1942                        StatusCode::PAYLOAD_TOO_LARGE,
1943                        h,
1944                        format!("Request body exceeds advertised max_request_bytes={limit}"),
1945                    )
1946                        .into_response();
1947                }
1948            };
1949            req = axum::http::Request::from_parts(parts, axum::body::Body::from(body));
1950        }
1951    }
1952
1953    let resp = next.run(req).await;
1954    let (mut parts, body) = resp.into_parts();
1955    // Buffer the response under a hard ceiling. `usize::MAX` here let a
1956    // large handler response exhaust the heap; on overflow fail loud
1957    // with a 500 rather than `unwrap_or_default()` silently shipping an
1958    // empty 200. The ceiling is *not* `max_response_bytes` (a soft
1959    // producer-side cap the wire may legitimately overshoot) — see
1960    // `response_buffer_ceiling`. Externalised payloads leave only tiny
1961    // pointer batches on the wire, so they never approach this bound.
1962    let response_limit = response_buffer_ceiling(&state);
1963    let bytes = match to_bytes(body, response_limit).await {
1964        Ok(b) => b,
1965        Err(_) => {
1966            let mut h = HeaderMap::new();
1967            attach_cors_headers(&state, &mut h, req_acrh.as_ref(), false);
1968            return (
1969                StatusCode::INTERNAL_SERVER_ERROR,
1970                h,
1971                "response body exceeded the configured size limit",
1972            )
1973                .into_response();
1974        }
1975    };
1976    let is_arrow = parts
1977        .headers
1978        .get(header::CONTENT_TYPE)
1979        .and_then(|v| v.to_str().ok())
1980        == Some(ARROW_CONTENT_TYPE);
1981
1982    // Attach CORS + capability headers *before* the compression branch, so a
1983    // compressed response can no longer return past them. It previously did,
1984    // leaving every compressed Arrow body without a single `VGI-*` header —
1985    // capability discovery silently degraded to defaults for exactly the
1986    // large responses that matter most.
1987    attach_cors_headers(&state, &mut parts.headers, req_acrh.as_ref(), false);
1988    attach_capability_headers(&state, &mut parts.headers, &req_method);
1989    if let Ok(v) = HeaderValue::from_str(&request_id) {
1990        parts
1991            .headers
1992            .insert(HeaderName::from_static(REQUEST_ID_RESPONSE_HEADER), v);
1993    }
1994
1995    if is_arrow {
1996        if let (Some(level), Some((encoding, used_custom))) =
1997            (state.response_compression_level, req_encoding)
1998        {
1999            // Only compress when the client offered a codec we can produce AND
2000            // the body is large enough to benefit. Below the threshold, frame
2001            // overhead dominates and can enlarge the payload — tiny bodies
2002            // (empty-schema error / continuation-token responses, small scalar
2003            // unary results) waste CPU and allocation for no gain.
2004            // `Accept-Encoding` is a client capability, not a demand, so an
2005            // uncompressed reply is always valid.
2006            if bytes.len() >= MIN_ZSTD_COMPRESS_BYTES {
2007                if let Some(compressed) = encode_response_body(encoding, &bytes, level) {
2008                    // Ship the compressed form only if it actually shrank the
2009                    // body; otherwise fall through and send it uncompressed.
2010                    if compressed.len() < bytes.len() {
2011                        // A client that had to use the custom request header
2012                        // is one whose fetch/proxy layer would auto-decode or
2013                        // mangle a standard `Content-Encoding`, so answer in
2014                        // kind on the custom response header.
2015                        let name = if used_custom {
2016                            axum::http::HeaderName::from_static(VGI_CONTENT_ENCODING_HEADER)
2017                        } else {
2018                            header::CONTENT_ENCODING
2019                        };
2020                        parts
2021                            .headers
2022                            .insert(name, HeaderValue::from_static(encoding.as_str()));
2023                        let compressed_len = compressed.len() as u64;
2024                        let body_new = axum::body::Body::from(compressed);
2025                        emit_deferred_access_records(&mut parts, compressed_len);
2026                        return Response::from_parts(parts, body_new);
2027                    }
2028                }
2029            }
2030        }
2031    }
2032    let body_len = bytes.len() as u64;
2033    emit_deferred_access_records(&mut parts, body_len);
2034    Response::from_parts(parts, axum::body::Body::from(bytes))
2035}
2036
2037/// Emit the request's access-log records now that the body is final.
2038///
2039/// This runs after compression, which is the whole point: a record written
2040/// where the handler finished could only ever report the uncompressed size,
2041/// and the two differ by orders of magnitude on a compressible Arrow body.
2042/// The cost is that a crash between handler and response loses the record;
2043/// the alternative is a permanently wrong number.
2044fn emit_deferred_access_records(parts: &mut axum::http::response::Parts, response_bytes: u64) {
2045    if let Some(sink) = parts.extensions.remove::<crate::hooks::AccessSink>() {
2046        sink.emit(Some(response_bytes));
2047    }
2048}
2049
2050/// Attach `VGI-Max-Request-Bytes`, `VGI-Upload-URL-Support`,
2051/// `VGI-Max-Upload-Bytes` capability headers when configured. On
2052/// `OPTIONS` responses also stamp `Cache-Control: public, max-age=300`
2053/// so clients cache discovery results, mirroring the Python
2054/// `_CapabilitiesMiddleware`.
2055fn attach_capability_headers(
2056    state: &Arc<HttpState>,
2057    out: &mut HeaderMap,
2058    method: &axum::http::Method,
2059) {
2060    // The capability set is immutable config, precomputed once in `build()`.
2061    for (k, v) in state.capability_headers.iter() {
2062        out.insert(k.clone(), v.clone());
2063    }
2064    if state.capability_has_any && method == axum::http::Method::OPTIONS {
2065        out.insert(
2066            header::CACHE_CONTROL,
2067            HeaderValue::from_static("public, max-age=300"),
2068        );
2069    }
2070}
2071
2072fn build_router_inner(state: Arc<HttpState>) -> Router {
2073    let prefix = state.prefix.clone();
2074    let api = Router::new()
2075        .route("/:method", post(handle_unary).options(handle_preflight))
2076        .route(
2077            "/:method/init",
2078            post(handle_stream_init).options(handle_preflight),
2079        )
2080        .route(
2081            "/:method/exchange",
2082            post(handle_stream_exchange).options(handle_preflight),
2083        );
2084
2085    let api = if state.upload_url_provider.is_some() {
2086        api.route(
2087            "/__upload_url__/init",
2088            post(handle_upload_url).options(handle_preflight),
2089        )
2090    } else {
2091        api
2092    };
2093
2094    let api = if state.sticky.is_some() {
2095        api.route(
2096            "/__session__",
2097            axum::routing::delete(handle_delete_session).options(handle_preflight),
2098        )
2099    } else {
2100        api
2101    };
2102
2103    // Always routed, but only ever an oracle when a resolver exists. With
2104    // introspection off the handler holds nothing and looks nothing up; it is
2105    // there so a caller gets a definitive 404 instead of the 415 the generic
2106    // `/:method` route answers a JSON body with — which a caller classifying
2107    // 401/403/404 as final reads as "retry later" and spins on forever.
2108    let api = api.route(
2109        crate::auth::introspect::INTROSPECT_ENDPOINT,
2110        post(handle_introspect_token).options(handle_preflight),
2111    );
2112
2113    let mut app = if prefix.is_empty() {
2114        api
2115    } else {
2116        Router::new().nest(&prefix, api)
2117    };
2118
2119    app = app.route(
2120        &format!(
2121            "{}{}",
2122            prefix,
2123            crate::auth::oauth::OAuthResourceMetadata::well_known_path()
2124        ),
2125        axum::routing::get(handle_oauth_metadata),
2126    );
2127
2128    if state.health_enabled {
2129        // Always mount `/health` at the absolute root, regardless of
2130        // the API prefix. Liveness probes / load-balancer health
2131        // checks should never have to know which URL prefix the API
2132        // is under, and the conformance suite verifies it bypasses
2133        // auth even when every RPC endpoint requires it.
2134        app = app.route(
2135            "/health",
2136            axum::routing::get(handle_health).options(handle_preflight),
2137        );
2138    }
2139    if state.landing_page_enabled {
2140        let landing_path = if prefix.is_empty() {
2141            "/".to_string()
2142        } else {
2143            prefix.clone()
2144        };
2145        app = app.route(&landing_path, axum::routing::get(handle_landing));
2146    }
2147    if state.describe_page_enabled {
2148        app = app.route(
2149            &format!("{prefix}/describe"),
2150            axum::routing::get(handle_describe_page),
2151        );
2152    }
2153    // The browser client build the shared `landing.html` imports. Mounted
2154    // alongside the page whenever the application supplied its identity.
2155    if state.landing_info.is_some() {
2156        app = app.route(
2157            &format!("{prefix}/vgi-client.js"),
2158            axum::routing::get(handle_client_bundle),
2159        );
2160    }
2161
2162    app.with_state(state)
2163}
2164
2165/// `DELETE {prefix}/__session__` — idempotent best-effort session teardown.
2166/// Token absent / stale / forged / wrong-principal ⇒ 200 (no info leak);
2167/// a live session ⇒ close it, emit `VGI-Session-Close: true`, 204.
2168async fn handle_delete_session(
2169    State(state): State<Arc<HttpState>>,
2170    headers: HeaderMap,
2171) -> Response {
2172    let auth = match authenticate_request(&state, SESSION_ENDPOINT, &headers) {
2173        Ok(a) => a,
2174        Err(resp) => return resp,
2175    };
2176    let Some(ctx) = state.sticky.as_ref() else {
2177        return StatusCode::OK.into_response();
2178    };
2179    let session_header = headers.get(SESSION_HEADER).and_then(|v| v.to_str().ok());
2180    match crate::sticky::handle_delete(ctx, &auth, session_header) {
2181        crate::sticky::DeleteOutcome::Idempotent => StatusCode::OK.into_response(),
2182        crate::sticky::DeleteOutcome::Closed => {
2183            let mut h = HeaderMap::new();
2184            h.insert(SESSION_CLOSE_HEADER, HeaderValue::from_static("true"));
2185            (StatusCode::NO_CONTENT, h).into_response()
2186        }
2187    }
2188}
2189
2190/// `POST {prefix}/__introspect_token__` — opaque credential to principal.
2191///
2192/// Two rejection axes, deliberately distinguishable from each other and
2193/// deliberately uniform within themselves: `403` says the *caller* may not
2194/// introspect, `404` says the *subject* credential did not resolve. Both are
2195/// definitive and may be negative-cached; anything transient reaches the caller
2196/// as `503` so it is retried instead.
2197async fn handle_introspect_token(
2198    State(state): State<Arc<HttpState>>,
2199    headers: HeaderMap,
2200    body: axum::body::Body,
2201) -> Response {
2202    use crate::auth::introspect::{IntrospectOutcome, MAX_INTROSPECT_BODY_BYTES};
2203
2204    let Some(introspector) = state.introspect.as_ref() else {
2205        // Deliberately no authentication of its own: "this worker does not do
2206        // introspection" is not a secret, and a caller needs to learn it at
2207        // preflight rather than after arranging credentials.
2208        return introspect_refusal(StatusCode::NOT_FOUND, "not_enabled", None);
2209    };
2210    let auth = match authenticate_request(
2211        &state,
2212        crate::auth::introspect::INTROSPECT_ENDPOINT,
2213        &headers,
2214    ) {
2215        Ok(a) => a,
2216        Err(resp) => return resp,
2217    };
2218    // Bounded here rather than by the global body limit: the only legitimate
2219    // content is one credential, and an over-length body collapses onto the
2220    // same answer an unknown one gets.
2221    let bytes = axum::body::to_bytes(body, MAX_INTROSPECT_BODY_BYTES)
2222        .await
2223        .unwrap_or_default();
2224
2225    match introspector.introspect(&auth, &bytes) {
2226        IntrospectOutcome::Resolved {
2227            principal,
2228            token_name,
2229            ttl_seconds,
2230        } => {
2231            // A closed set of three keys. A `claims` field would let this
2232            // worker choose its caller's tenant routing, row scope and policy
2233            // branch; the asker derives what it needs from the principal alone.
2234            let body = serde_json::json!({
2235                "principal": principal,
2236                "token_name": token_name,
2237                "ttl_seconds": ttl_seconds,
2238            })
2239            .to_string();
2240            introspect_json(StatusCode::OK, body, None)
2241        }
2242        IntrospectOutcome::NotAnIntrospector => {
2243            introspect_refusal(StatusCode::FORBIDDEN, "not_an_introspector", None)
2244        }
2245        IntrospectOutcome::Unresolved => {
2246            introspect_refusal(StatusCode::NOT_FOUND, "unresolved", None)
2247        }
2248        IntrospectOutcome::RateLimited => {
2249            introspect_refusal(StatusCode::TOO_MANY_REQUESTS, "rate_limited", Some(1))
2250        }
2251        IntrospectOutcome::Unavailable {
2252            retry_after_seconds,
2253        } => introspect_refusal(
2254            StatusCode::SERVICE_UNAVAILABLE,
2255            "unavailable",
2256            Some(retry_after_seconds),
2257        ),
2258    }
2259}
2260
2261/// A rejection carrying no detail about why beyond the coarse code.
2262fn introspect_refusal(status: StatusCode, error: &str, retry_after: Option<u32>) -> Response {
2263    introspect_json(
2264        status,
2265        serde_json::json!({ "error": error }).to_string(),
2266        retry_after,
2267    )
2268}
2269
2270fn introspect_json(status: StatusCode, body: String, retry_after: Option<u32>) -> Response {
2271    let mut h = HeaderMap::new();
2272    h.insert(
2273        header::CONTENT_TYPE,
2274        HeaderValue::from_static("application/json"),
2275    );
2276    // A credential's resolution can change; nothing here may sit in a shared
2277    // cache.
2278    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
2279    if let Some(secs) = retry_after {
2280        if let Ok(v) = HeaderValue::from_str(&secs.to_string()) {
2281            h.insert(header::RETRY_AFTER, v);
2282        }
2283    }
2284    (status, h, body).into_response()
2285}
2286
2287async fn handle_preflight(State(state): State<Arc<HttpState>>, headers: HeaderMap) -> Response {
2288    let mut h = HeaderMap::new();
2289    attach_cors_headers(
2290        &state,
2291        &mut h,
2292        headers.get(header::ACCESS_CONTROL_REQUEST_HEADERS),
2293        true,
2294    );
2295    (StatusCode::NO_CONTENT, h).into_response()
2296}
2297
2298async fn handle_health(State(state): State<Arc<HttpState>>) -> Response {
2299    let body = serde_json::json!({
2300        "status": "ok",
2301        "server_id": state.server.server_id,
2302        "protocol": state.server.protocol_name(),
2303    })
2304    .to_string();
2305    let mut h = HeaderMap::new();
2306    h.insert(
2307        header::CONTENT_TYPE,
2308        HeaderValue::from_static("application/json"),
2309    );
2310    (StatusCode::OK, h, body).into_response()
2311}
2312
2313/// `GET {prefix}/` — the shared, self-contained `landing.html` for browsers
2314/// (Accept: text/html); a small JSON status for health checks / `?format=json`
2315/// / `Accept: application/json`. Mirrors the Python `LandingPageResource`.
2316async fn handle_landing(
2317    State(state): State<Arc<HttpState>>,
2318    headers: HeaderMap,
2319    axum::extract::RawQuery(query): axum::extract::RawQuery,
2320) -> Response {
2321    let accept = headers
2322        .get(header::ACCEPT)
2323        .and_then(|v| v.to_str().ok())
2324        .unwrap_or("");
2325    let format_json = query.as_deref().map(query_has_format_json).unwrap_or(false);
2326    let want_json =
2327        format_json || (accept.contains("application/json") && !accept.contains("text/html"));
2328
2329    let mut h = HeaderMap::new();
2330    if want_json {
2331        h.insert(
2332            header::CONTENT_TYPE,
2333            HeaderValue::from_static("application/json"),
2334        );
2335        // Worker identity is not catalog data and has no protocol method, so
2336        // the page reads it from here.
2337        let info = state.landing_info.clone().unwrap_or_default();
2338        let body = serde_json::json!({
2339            "status": "ok",
2340            "server_id": state.server.server_id,
2341            "protocol": state.server.protocol_name(),
2342            "worker": info.name,
2343            "doc": info.doc,
2344            "version": info.version,
2345            "lang": "rust",
2346            "oauth": state.oauth_metadata.is_some(),
2347            "cupola_base": "https://cupola.query-farm.services",
2348        })
2349        .to_string();
2350        return (StatusCode::OK, h, body).into_response();
2351    }
2352    h.insert(
2353        header::CONTENT_TYPE,
2354        HeaderValue::from_static("text/html; charset=utf-8"),
2355    );
2356    (StatusCode::OK, h, LANDING_HTML).into_response()
2357}
2358
2359/// Whether a raw query string carries `format=json`.
2360fn query_has_format_json(raw: &str) -> bool {
2361    raw.split('&')
2362        .any(|pair| pair == "format=json" || pair.strip_prefix("format=") == Some("json"))
2363}
2364
2365/// `GET {prefix}/vgi-client.js` — the browser client build the page imports.
2366async fn handle_client_bundle() -> Response {
2367    let mut h = HeaderMap::new();
2368    h.insert(
2369        header::CONTENT_TYPE,
2370        HeaderValue::from_static("text/javascript; charset=utf-8"),
2371    );
2372    // Immutable for a given worker build: the page and the bundle are vendored
2373    // and released together.
2374    h.insert(
2375        header::CACHE_CONTROL,
2376        HeaderValue::from_static("public, max-age=3600"),
2377    );
2378    (StatusCode::OK, h, CLIENT_BUNDLE).into_response()
2379}
2380
2381async fn handle_describe_page(State(state): State<Arc<HttpState>>) -> Response {
2382    let body = render_describe_page(&state);
2383    let mut h = HeaderMap::new();
2384    h.insert(
2385        header::CONTENT_TYPE,
2386        HeaderValue::from_static("text/html; charset=utf-8"),
2387    );
2388    (StatusCode::OK, h, body).into_response()
2389}
2390
2391fn render_describe_page(state: &Arc<HttpState>) -> String {
2392    let mut body = String::from(
2393        "<!doctype html><html><head><meta charset=\"utf-8\"><title>API reference</title></head><body>",
2394    );
2395    body.push_str(&format!(
2396        "<h1>{}</h1><table><tr><th>method</th><th>type</th><th>doc</th></tr>",
2397        state.server.protocol_name()
2398    ));
2399    for name in state.server.sorted_method_names() {
2400        let m = &state.server.methods()[name];
2401        let kind = match m.method_type {
2402            crate::server::MethodType::Unary => "unary",
2403            _ => "stream",
2404        };
2405        let doc = m.doc.as_deref().unwrap_or("");
2406        body.push_str(&format!(
2407            "<tr><td><code>{name}</code></td><td>{kind}</td><td>{}</td></tr>",
2408            html_escape(doc)
2409        ));
2410    }
2411    body.push_str("</table></body></html>");
2412    body
2413}
2414
2415fn html_escape(s: &str) -> String {
2416    s.replace('&', "&amp;")
2417        .replace('<', "&lt;")
2418        .replace('>', "&gt;")
2419}
2420
2421fn attach_cors_headers(
2422    state: &Arc<HttpState>,
2423    out: &mut HeaderMap,
2424    requested_headers: Option<&HeaderValue>,
2425    is_preflight: bool,
2426) {
2427    let Some(origin) = state.cors_allow_origin.as_ref() else {
2428        return;
2429    };
2430    out.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone());
2431    // When an authenticate callback is configured, requests carry
2432    // credentials (cookie / bearer). A browser only honours those
2433    // cross-origin when `Allow-Credentials: true` is present *and* the
2434    // origin is specific — `HttpStateBuilder::build` rejects the
2435    // `"*"` + auth combination, so the configured origin here is
2436    // always concrete.
2437    if state.authenticate.is_some() {
2438        out.insert(
2439            header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
2440            HeaderValue::from_static("true"),
2441        );
2442    }
2443    out.insert(
2444        header::ACCESS_CONTROL_ALLOW_METHODS,
2445        HeaderValue::from_static("POST, GET, OPTIONS"),
2446    );
2447    // Echo the client's requested headers when present, else a sensible
2448    // default. When the client already sent a valid `HeaderValue` we reuse
2449    // it directly rather than round-tripping through `&str`.
2450    match requested_headers {
2451        Some(v) => {
2452            out.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, v.clone());
2453        }
2454        None => {
2455            out.insert(
2456                header::ACCESS_CONTROL_ALLOW_HEADERS,
2457                // `X-VGI-Accept-Encoding` is how a browser states a codec
2458                // preference at all: `fetch()` cannot set `Accept-Encoding`
2459                // (forbidden header name), so it must be allowed here. The
2460                // three `VGI-*` request headers fail quietly when omitted:
2461                // the browser simply drops them, taking out sticky sessions
2462                // and every call behind a proof gate while plain calls keep
2463                // working.
2464                HeaderValue::from_static(
2465                    "Content-Type, Authorization, Cookie, Accept-Encoding, \
2466                     X-VGI-Accept-Encoding, VGI-Session, VGI-Session-Accept, \
2467                     VGI-Proxy-Proof",
2468                ),
2469            );
2470        }
2471    }
2472    // Precomputed in `build_capability_headers` from the capability headers
2473    // this server actually emits, plus `CORS_EXPOSE_FIXED`. Everything
2474    // `VGI-*` is listed: a browser sees only safelisted response headers
2475    // otherwise, so an unexposed capability header may as well not exist.
2476    out.insert(
2477        header::ACCESS_CONTROL_EXPOSE_HEADERS,
2478        state.cors_expose_headers.clone(),
2479    );
2480    // A server that has opted into serving cross-origin callers has, by
2481    // construction, opted into being embeddable by them: without this a
2482    // caller under COEP require-corp has the response blocked outright.
2483    out.insert(
2484        HeaderName::from_static("cross-origin-resource-policy"),
2485        HeaderValue::from_static("cross-origin"),
2486    );
2487    if is_preflight {
2488        if let Ok(v) = HeaderValue::from_str(&state.cors_max_age.to_string()) {
2489            out.insert(header::ACCESS_CONTROL_MAX_AGE, v);
2490        }
2491    }
2492}
2493
2494async fn handle_oauth_metadata(State(state): State<Arc<HttpState>>) -> Response {
2495    match state.oauth_metadata_json.as_ref() {
2496        Some(body) => {
2497            let mut h = HeaderMap::new();
2498            h.insert(
2499                header::CONTENT_TYPE,
2500                HeaderValue::from_static("application/json"),
2501            );
2502            h.insert(
2503                header::CACHE_CONTROL,
2504                HeaderValue::from_static("public, max-age=60"),
2505            );
2506            (StatusCode::OK, h, body.clone()).into_response()
2507        }
2508        None => (StatusCode::NOT_FOUND, "").into_response(),
2509    }
2510}
2511
2512/// Parse a `Cookie:` header into a name→value map. Surrounding
2513/// double-quotes on a value (RFC 6265 quoted form) are stripped.
2514fn parse_cookies(raw: Option<&str>) -> std::collections::BTreeMap<String, String> {
2515    let mut out = std::collections::BTreeMap::new();
2516    let Some(raw) = raw else { return out };
2517    for part in raw.split(';') {
2518        let part = part.trim();
2519        if let Some((k, v)) = part.split_once('=') {
2520            let v = v.trim();
2521            let v = v
2522                .strip_prefix('"')
2523                .and_then(|s| s.strip_suffix('"'))
2524                .unwrap_or(v);
2525            out.insert(k.trim().to_string(), v.to_string());
2526        }
2527    }
2528    out
2529}
2530
2531/// Copy the request headers into a `Vec<(String, String)>` for AuthRequest.
2532fn headers_to_pairs(headers: &HeaderMap) -> Vec<(String, String)> {
2533    headers
2534        .iter()
2535        .filter_map(|(k, v)| {
2536            v.to_str()
2537                .ok()
2538                .map(|s| (k.as_str().to_string(), s.to_string()))
2539        })
2540        .collect()
2541}
2542
2543/// Run the authenticate callback (if any); on error, build a 401 response
2544/// with WWW-Authenticate attached.
2545// Err is an axum `Response` (large), shared throughout the HTTP layer; boxing
2546// it would churn every call site for no real benefit.
2547#[allow(clippy::result_large_err)]
2548fn authenticate_request(
2549    state: &Arc<HttpState>,
2550    method: &str,
2551    headers: &HeaderMap,
2552) -> std::result::Result<crate::auth::AuthContext, Response> {
2553    let Some(cb) = state.authenticate.as_ref() else {
2554        return Ok(crate::auth::AuthContext::anonymous());
2555    };
2556    let pairs = headers_to_pairs(headers);
2557    let req = crate::auth::AuthRequest {
2558        method,
2559        headers: &pairs,
2560        peer_addr: None,
2561    };
2562    match (cb)(&req) {
2563        Ok(ctx) => Ok(ctx),
2564        Err(err) if err.is_auth_unavailable() => {
2565            // Not a rejection: the authority could not be reached. A 401 here
2566            // tells every caller to re-authenticate against a service that is
2567            // simply down, and invites them to negative-cache an outage.
2568            tracing::warn!(
2569                target: "vgi_rpc.http",
2570                error = %err.message,
2571                "authentication unavailable"
2572            );
2573            let mut h = HeaderMap::new();
2574            let retry_after = err
2575                .retry_after_seconds
2576                .unwrap_or(crate::errors::DEFAULT_AUTH_RETRY_AFTER_SECONDS);
2577            if let Ok(v) = HeaderValue::from_str(&retry_after.to_string()) {
2578                h.insert(header::RETRY_AFTER, v);
2579            }
2580            Err((
2581                StatusCode::SERVICE_UNAVAILABLE,
2582                h,
2583                "authentication service unavailable",
2584            )
2585                .into_response())
2586        }
2587        Err(err) => {
2588            let status = match err.error_type.as_str() {
2589                "PermissionError" | "ValueError" => StatusCode::UNAUTHORIZED,
2590                _ => StatusCode::INTERNAL_SERVER_ERROR,
2591            };
2592            let mut h = HeaderMap::new();
2593            // The response body must not echo internal detail. A 401 is
2594            // part of the auth contract, but the verifier's message can
2595            // carry an attacker-supplied `kid` or a raw library error —
2596            // keep that in the logs, return a generic body. A 500 from
2597            // the auth callback (e.g. a JWKS fetch failure) is purely
2598            // internal and gets the same treatment.
2599            if status == StatusCode::UNAUTHORIZED {
2600                if let Some(wa) = state.www_authenticate.as_deref() {
2601                    if let Ok(hv) = HeaderValue::from_str(wa) {
2602                        h.insert(header::WWW_AUTHENTICATE, hv);
2603                    }
2604                }
2605                tracing::info!(
2606                    target: "vgi_rpc.http",
2607                    error = %err.message,
2608                    "request authentication rejected"
2609                );
2610                // A classified failure names its own reason; otherwise guess
2611                // from the error's *type*, mirroring the Python reference —
2612                // PermissionError means the caller got as far as being
2613                // identified. Guessing more finely would mean matching on
2614                // message text, which misclassifies on any rewording.
2615                let reason = err
2616                    .auth_reason
2617                    .unwrap_or(if err.error_type == "PermissionError" {
2618                        AuthReason::InsufficientScope
2619                    } else {
2620                        AuthReason::Unauthorized
2621                    });
2622                // Echo the detail only when the authenticator classified the
2623                // failure, i.e. chose that text deliberately. An unclassified
2624                // message is an arbitrary verifier/library string that may
2625                // carry attacker-supplied input (a `kid`, say), and spec §2
2626                // keeps that out of the body — it stays in the log above.
2627                let detail = if err.auth_reason.is_some() {
2628                    err.message.as_str()
2629                } else {
2630                    ""
2631                };
2632                return Err(state.unauthorized_response(h, reason, detail));
2633            }
2634            let body = {
2635                tracing::error!(
2636                    target: "vgi_rpc.http",
2637                    error = %err.message,
2638                    "authentication callback errored"
2639                );
2640                "internal error during authentication"
2641            };
2642            Err((status, h, body).into_response())
2643        }
2644    }
2645}
2646
2647// ---------------------------------------------------------------------------
2648// Helpers
2649// ---------------------------------------------------------------------------
2650
2651fn arrow_response(status: StatusCode, body: Vec<u8>) -> Response {
2652    let mut headers = HeaderMap::new();
2653    headers.insert(
2654        header::CONTENT_TYPE,
2655        HeaderValue::from_static(ARROW_CONTENT_TYPE),
2656    );
2657    (status, headers, body).into_response()
2658}
2659
2660/// Hard wire-cap enforcement helper for stream-exchange responses.
2661/// Returns the original 200 response when within budget; otherwise
2662/// rebuilds the response as an EXCEPTION-only IPC stream surfaced via
2663/// 200 + `X-VGI-RPC-Error: true`.
2664fn enforce_response_body_cap(
2665    state: &Arc<HttpState>,
2666    schema: &arrow_schema::Schema,
2667    body: Vec<u8>,
2668    method: &str,
2669    server_id: &str,
2670    request_id: &str,
2671) -> Response {
2672    if let Some(limit) = state.max_response_bytes {
2673        if body.len() > limit {
2674            let err = RpcError::runtime_error(format!(
2675                "HTTP body exceeds max_response_bytes ({} > {}) for method {:?}",
2676                body.len(),
2677                limit,
2678                method
2679            ));
2680            return cap_error_response(schema, &err, server_id, request_id);
2681        }
2682    }
2683    arrow_response(StatusCode::OK, body)
2684}
2685
2686/// The one message shape for a `max_externalized_response_bytes` overshoot.
2687///
2688/// Every enforcement site funnels through here so the wording cannot drift
2689/// between the unary pre-flight, the producer turn, the exchange turn and
2690/// the post-flush backstops. The literal `max_externalized_response_bytes`
2691/// token is load-bearing: the cross-language conformance suite matches the
2692/// client-visible `RpcError` against exactly that string, and the reference
2693/// implementations emit the same `(actual > limit) for method ...` shape.
2694fn external_cap_error(bytes: usize, limit: usize, method: &str) -> RpcError {
2695    RpcError::runtime_error(format!(
2696        "Externalised payload exceeds max_externalized_response_bytes ({bytes} > {limit}) for method {method:?}"
2697    ))
2698}
2699
2700/// Build a fresh IPC stream containing only an EXCEPTION batch and emit
2701/// it as 200 + `X-VGI-RPC-Error: true` so RPC clients see the message
2702/// as `RpcError`, not a transport failure. Used by the response-cap
2703/// strict-fail path.
2704fn cap_error_response(
2705    schema: &arrow_schema::Schema,
2706    err: &RpcError,
2707    server_id: &str,
2708    request_id: &str,
2709) -> Response {
2710    let mut buf = Vec::new();
2711    {
2712        let mut sw = StreamWriter::new(&mut buf, schema).unwrap();
2713        let md = build_error_metadata(err, server_id, request_id);
2714        let _ = sw.write(&empty_batch(schema).unwrap(), Some(&md));
2715        let _ = sw.finish();
2716    }
2717    let mut headers = HeaderMap::new();
2718    headers.insert(
2719        header::CONTENT_TYPE,
2720        HeaderValue::from_static(ARROW_CONTENT_TYPE),
2721    );
2722    headers.insert(RPC_ERROR_HEADER, HeaderValue::from_static("true"));
2723    (StatusCode::OK, headers, buf).into_response()
2724}
2725
2726/// Stamp `X-VGI-RPC-Error: true` on a 200 whose Arrow body carries an
2727/// EXCEPTION batch rather than a result.
2728///
2729/// A failed RPC answers **200**: the call reached the method and the method
2730/// raised, so the failure is application-level and rides the body. The status
2731/// line therefore says nothing, and this header is the only signal a client
2732/// has short of parsing the stream. It must discriminate — a flag on every
2733/// response carries no information, which is the same outage as never
2734/// setting it — so call this only on the error paths.
2735///
2736/// Transport-level rejections (400/404/415/401) keep their status and do
2737/// *not* carry the flag; it marks a 200 that is really a failure.
2738fn stamp_rpc_error(resp: &mut Response) {
2739    resp.headers_mut()
2740        .insert(RPC_ERROR_HEADER, HeaderValue::from_static("true"));
2741}
2742
2743fn plain_error(status: StatusCode, msg: String) -> Response {
2744    (status, msg).into_response()
2745}
2746
2747fn has_arrow_ct(headers: &HeaderMap) -> bool {
2748    headers
2749        .get(header::CONTENT_TYPE)
2750        .and_then(|v| v.to_str().ok())
2751        .map(|s| s == ARROW_CONTENT_TYPE)
2752        .unwrap_or(false)
2753}
2754
2755fn maybe_decompress(headers: &HeaderMap, body: &Bytes, max_size: usize) -> Result<Vec<u8>> {
2756    let enc = headers
2757        .get(header::CONTENT_ENCODING)
2758        .and_then(|v| v.to_str().ok());
2759    if body.len() > max_size {
2760        return Err(RpcError::runtime_error(format!(
2761            "Request body exceeds max size ({} bytes > {})",
2762            body.len(),
2763            max_size
2764        )));
2765    }
2766    decode_content_encoding(body.as_ref(), enc, Some(max_size))
2767}
2768
2769fn zstd_window_log_for_limit(max_size: usize) -> u32 {
2770    // Streaming encoders do not know the eventual content size and zstd's
2771    // level-1 profile therefore advertises a 512 KiB history window even for
2772    // tiny frames. Keep that bounded interoperability floor while still
2773    // refusing larger attacker-selected windows for small output budgets.
2774    const INTEROPERABLE_WINDOW_LOG_FLOOR: u32 = 19;
2775    let bounded = max_size.max(1 << INTEROPERABLE_WINDOW_LOG_FLOOR);
2776    let ceil_log = usize::BITS - bounded.saturating_sub(1).leading_zeros();
2777    ceil_log.clamp(INTEROPERABLE_WINDOW_LOG_FLOOR, 31)
2778}
2779
2780fn request_decode_limit(state: &HttpState) -> usize {
2781    state
2782        .max_request_bytes
2783        .unwrap_or(state.max_body_size)
2784        .min(state.max_body_size)
2785}
2786
2787fn request_decode_error(state: &Arc<HttpState>, error: &RpcError) -> Response {
2788    let status = if error.message.contains("exceeds max size") {
2789        StatusCode::PAYLOAD_TOO_LARGE
2790    } else {
2791        StatusCode::BAD_REQUEST
2792    };
2793    arrow_error(state, status, error, "")
2794}
2795
2796/// Most codings [`decode_content_encoding`] will apply from one
2797/// `Content-Encoding` header. Real bodies carry one; the cap bounds the decode
2798/// work an attacker can buy with a single bounded request body.
2799pub const MAX_CONTENT_CODINGS: usize = 4;
2800
2801/// Decode an HTTP body per its `Content-Encoding`, or return it unchanged.
2802///
2803/// Handles the codings vgi-rpc speaks (`zstd`, `gzip`); the header may list
2804/// several applied in order, which are decoded in reverse. `identity` and
2805/// unknown codings are left as-is. Intended for an intermediary (proxy/gateway)
2806/// that must read a compressed request/response body to inspect or rewrite it —
2807/// see [`crate::intermediary`] for the framing helpers that go with it.
2808///
2809/// `max_output_size` caps the decompressed length of **each** coding, defending
2810/// against zip-bomb-style payloads without first allocating the full result.
2811/// At most [`MAX_CONTENT_CODINGS`] codings are applied, so total decode work
2812/// stays bounded by that multiple of the cap rather than by attacker-chosen
2813/// header length.
2814///
2815/// # Errors
2816///
2817/// Fails on a corrupt payload, a coding that decodes past `max_output_size`, or
2818/// a header listing more than [`MAX_CONTENT_CODINGS`] codings.
2819pub fn decode_content_encoding(
2820    data: &[u8],
2821    content_encoding: Option<&str>,
2822    max_output_size: Option<usize>,
2823) -> Result<Vec<u8>> {
2824    let Some(header) = content_encoding.filter(|h| !h.trim().is_empty()) else {
2825        return Ok(data.to_vec());
2826    };
2827    let max_size = max_output_size.unwrap_or(usize::MAX);
2828    let codings = header
2829        .split(',')
2830        .map(|c| c.trim().to_ascii_lowercase())
2831        .filter(|c| !c.is_empty());
2832    // Each coding gets the full `max_size` budget, so an unbounded list would let
2833    // one bounded request body cost K x max_size of decode work and peak memory.
2834    // Real bodies carry one coding; refuse an absurd chain rather than serve it.
2835    if codings.clone().count() > MAX_CONTENT_CODINGS {
2836        return Err(RpcError::runtime_error(format!(
2837            "Content-Encoding lists more than {MAX_CONTENT_CODINGS} codings"
2838        )));
2839    }
2840    let mut result = data.to_vec();
2841    for name in codings.rev() {
2842        result = match name.as_str() {
2843            "zstd" => {
2844                let mut decoder = zstd::Decoder::new(result.as_slice())
2845                    .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
2846                decoder
2847                    .window_log_max(zstd_window_log_for_limit(max_size))
2848                    .map_err(|e| RpcError::runtime_error(format!("zstd window limit: {e}")))?;
2849                decode_bounded(decoder, result.len(), max_size, "zstd")?
2850            }
2851            "gzip" => decode_bounded(
2852                flate2::read::GzDecoder::new(result.as_slice()),
2853                result.len(),
2854                max_size,
2855                "gzip",
2856            )?,
2857            _ => result, // identity / unknown coding — leave as-is
2858        };
2859    }
2860    Ok(result)
2861}
2862
2863/// Stream a decoder to completion, aborting once the decoded length exceeds
2864/// `max_size` rather than allocating the full result first.
2865fn decode_bounded(
2866    mut decoder: impl std::io::Read,
2867    input_len: usize,
2868    max_size: usize,
2869    coding: &str,
2870) -> Result<Vec<u8>> {
2871    let mut out = Vec::with_capacity(input_len.min(max_size).min(64 * 1024));
2872    let mut buf = [0u8; 16 * 1024];
2873    loop {
2874        let n = decoder
2875            .read(&mut buf)
2876            .map_err(|e| RpcError::runtime_error(format!("{coding} decode: {e}")))?;
2877        if n == 0 {
2878            break;
2879        }
2880        if out.len() + n > max_size {
2881            return Err(RpcError::runtime_error(format!(
2882                "Decompressed body exceeds max size ({}+ bytes > {})",
2883                out.len() + n,
2884                max_size
2885            )));
2886        }
2887        out.extend_from_slice(&buf[..n]);
2888    }
2889    Ok(out)
2890}
2891
2892fn parse_request_from_body(body: &[u8]) -> Result<Request> {
2893    let mut r = StreamReader::new(body)?;
2894    let (batch, metadata) = r
2895        .read_next()?
2896        .ok_or_else(|| RpcError::protocol_error("empty IPC stream"))?;
2897    r.drain()?;
2898    Request::from_read_batch(batch, metadata, true)
2899}
2900
2901fn error_stream_bytes(
2902    schema: &Schema,
2903    err: &RpcError,
2904    server_id: &str,
2905    request_id: &str,
2906) -> Vec<u8> {
2907    let mut buf = Vec::new();
2908    let mut w = StreamWriter::new(&mut buf, schema).unwrap();
2909    let md = build_error_metadata(err, server_id, request_id);
2910    let _ = w.write(&empty_batch(schema).unwrap(), Some(&md));
2911    let _ = w.finish();
2912    drop(w);
2913    buf
2914}
2915
2916/// Build a complete arrow-typed error response. Centralizes the
2917/// `arrow_response(status, error_stream_bytes(Schema::empty(), ...))`
2918/// pattern used by every error-returning branch of the HTTP handlers.
2919fn arrow_error(
2920    state: &Arc<HttpState>,
2921    status: StatusCode,
2922    err: &RpcError,
2923    request_id: &str,
2924) -> Response {
2925    arrow_response(
2926        status,
2927        error_stream_bytes(&Schema::empty(), err, &state.server.server_id, request_id),
2928    )
2929}
2930
2931/// Resolve sticky headers on an incoming request. `Ok(Some(sink))` to
2932/// install on the [`CallContext`]; `Ok(None)` when sticky is disabled;
2933/// `Err(resp)` to short-circuit with a `SessionLostError` response when a
2934/// presented token failed to resolve.
2935#[allow(clippy::result_large_err)]
2936fn sticky_for_request(
2937    state: &Arc<HttpState>,
2938    auth: &crate::auth::AuthContext,
2939    headers: &HeaderMap,
2940) -> std::result::Result<Option<Arc<crate::sticky::StickySinkImpl>>, Response> {
2941    let Some(ctx) = state.sticky.as_ref() else {
2942        return Ok(None);
2943    };
2944    let accept = headers
2945        .get(SESSION_ACCEPT_HEADER)
2946        .and_then(|v| v.to_str().ok())
2947        .map(|s| s.trim().eq_ignore_ascii_case("true"))
2948        .unwrap_or(false);
2949    let session_header = headers.get(SESSION_HEADER).and_then(|v| v.to_str().ok());
2950    match crate::sticky::resolve(ctx, auth, accept, session_header) {
2951        crate::sticky::StickyResolution::Sink(s) => Ok(Some(s)),
2952        crate::sticky::StickyResolution::Lost(err) => Err(cap_error_response(
2953            &Schema::empty(),
2954            &err,
2955            &state.server.server_id,
2956            "",
2957        )),
2958    }
2959}
2960
2961/// Stamp `VGI-Session` (+ echo headers) and `VGI-Session-Close` onto a
2962/// response according to the per-request sink's mint/close signals.
2963fn stamp_session_headers(
2964    resp: &mut Response,
2965    state: &Arc<HttpState>,
2966    sink: &Arc<crate::sticky::StickySinkImpl>,
2967) {
2968    let headers = resp.headers_mut();
2969    if let Some(token) = sink.mint_token() {
2970        if let Ok(v) = HeaderValue::from_str(&token) {
2971            headers.insert(SESSION_HEADER, v);
2972        }
2973        if let Some(ctx) = state.sticky.as_ref() {
2974            for (name, value) in &ctx.echo_headers {
2975                let full = format!("{ECHO_HEADER_PREFIX}{name}");
2976                if let (Ok(n), Ok(v)) = (
2977                    axum::http::HeaderName::from_bytes(full.as_bytes()),
2978                    HeaderValue::from_str(value),
2979                ) {
2980                    headers.insert(n, v);
2981                }
2982            }
2983        }
2984    }
2985    if sink.was_closed() {
2986        headers.insert(SESSION_CLOSE_HEADER, HeaderValue::from_static("true"));
2987    }
2988}
2989
2990fn decode_hex_key(s: &str) -> std::result::Result<Vec<u8>, String> {
2991    let s = s.trim();
2992    if !s.len().is_multiple_of(2) {
2993        return Err("hex length must be even".into());
2994    }
2995    let mut out = Vec::with_capacity(s.len() / 2);
2996    let bytes = s.as_bytes();
2997    for pair in bytes.chunks_exact(2) {
2998        let hi = hex_nibble(pair[0])?;
2999        let lo = hex_nibble(pair[1])?;
3000        out.push((hi << 4) | lo);
3001    }
3002    if out.len() < 32 {
3003        return Err(format!(
3004            "signing key must be ≥ 32 bytes (got {} bytes)",
3005            out.len()
3006        ));
3007    }
3008    Ok(out)
3009}
3010
3011fn hex_nibble(c: u8) -> std::result::Result<u8, String> {
3012    match c {
3013        b'0'..=b'9' => Ok(c - b'0'),
3014        b'a'..=b'f' => Ok(c - b'a' + 10),
3015        b'A'..=b'F' => Ok(c - b'A' + 10),
3016        _ => Err(format!("invalid hex character: {:?}", c as char)),
3017    }
3018}
3019
3020fn decode_base64_key(s: &str) -> std::result::Result<Vec<u8>, String> {
3021    // Accept both padded and unpadded standard base64.
3022    let s = s.trim().trim_end_matches('=');
3023    let mut padded = s.to_string();
3024    while !padded.len().is_multiple_of(4) {
3025        padded.push('=');
3026    }
3027    let bytes = base64::engine::general_purpose::STANDARD
3028        .decode(padded.as_bytes())
3029        .map_err(|e| format!("base64 decode: {e}"))?;
3030    if bytes.len() < 32 {
3031        return Err(format!(
3032            "signing key must be ≥ 32 bytes (got {} bytes)",
3033            bytes.len()
3034        ));
3035    }
3036    Ok(bytes)
3037}
3038
3039fn new_session_id() -> String {
3040    let mut b = [0u8; 16];
3041    rand::thread_rng().fill_bytes(&mut b);
3042    bytes_to_hex(&b)
3043}
3044
3045// ---------------------------------------------------------------------------
3046// Upload-URL endpoint
3047// ---------------------------------------------------------------------------
3048
3049// The method name, count cap, and response schema are the public wire contract;
3050// they live in `crate::external` so intermediaries share one definition.
3051use crate::external::{
3052    upload_url_params_schema, upload_url_response_schema, MAX_UPLOAD_URL_COUNT, UPLOAD_URL_METHOD,
3053};
3054
3055async fn handle_upload_url(
3056    State(state): State<Arc<HttpState>>,
3057    headers: HeaderMap,
3058    body: Bytes,
3059) -> Response {
3060    let auth = match authenticate_request(&state, UPLOAD_URL_METHOD, &headers) {
3061        Ok(a) => a,
3062        Err(resp) => return resp,
3063    };
3064    let _ = auth;
3065    if !has_arrow_ct(&headers) {
3066        return plain_error(
3067            StatusCode::UNSUPPORTED_MEDIA_TYPE,
3068            "need arrow content type".into(),
3069        );
3070    }
3071    let provider = match state.upload_url_provider.as_ref() {
3072        Some(p) => p.clone(),
3073        None => return plain_error(StatusCode::NOT_FOUND, "upload-url not enabled".into()),
3074    };
3075
3076    let body = match maybe_decompress(&headers, &body, request_decode_limit(&state)) {
3077        Ok(b) => b,
3078        Err(e) => return request_decode_error(&state, &e),
3079    };
3080    let req = match parse_request_from_body(&body) {
3081        Ok(r) => r,
3082        Err(e) => return arrow_error(&state, StatusCode::BAD_REQUEST, &e, ""),
3083    };
3084    if req.method != UPLOAD_URL_METHOD {
3085        let err = RpcError::protocol_error(format!(
3086            "Method mismatch: expected '{UPLOAD_URL_METHOD}', got '{}'",
3087            req.method
3088        ));
3089        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3090    }
3091    if let Err(err) = validate_protocol_version(state.server.protocol_version(), &req.metadata) {
3092        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3093    }
3094    if let Err(err) = validate_parameter_batch(&req.batch, &upload_url_params_schema()) {
3095        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3096    }
3097    // Pull `count` from the int64 column (default 1, clamped to [1, MAX]).
3098    let mut count: i64 = 1;
3099    if let Some(arr) = req.column("count") {
3100        use arrow_array::Array;
3101        if let Some(c) = arr.as_any().downcast_ref::<arrow_array::Int64Array>() {
3102            if !c.is_empty() && !Array::is_null(c, 0) {
3103                count = c.value(0);
3104            }
3105        }
3106    }
3107    count = count.clamp(1, MAX_UPLOAD_URL_COUNT);
3108
3109    // Generate URLs (provider may block on HTTP — same caveat as
3110    // ExternalStorage::upload, hence block_in_place).
3111    let urls_res = tokio::task::block_in_place(|| {
3112        let mut out = Vec::with_capacity(count as usize);
3113        for _ in 0..count {
3114            out.push(provider.generate_upload_url()?);
3115        }
3116        Ok::<_, RpcError>(out)
3117    });
3118
3119    let schema = upload_url_response_schema();
3120    let schema_ref = schema.as_ref();
3121    let mut body_buf = Vec::new();
3122    {
3123        let mut sw = match StreamWriter::new(&mut body_buf, schema_ref) {
3124            Ok(w) => w,
3125            Err(e) => {
3126                return arrow_error(
3127                    &state,
3128                    StatusCode::INTERNAL_SERVER_ERROR,
3129                    &e,
3130                    &req.request_id,
3131                )
3132            }
3133        };
3134        match urls_res {
3135            Ok(urls) => {
3136                use arrow_array::{StringArray, TimestampMicrosecondArray};
3137                let upload_arr = StringArray::from(
3138                    urls.iter()
3139                        .map(|u| u.upload_url.clone())
3140                        .collect::<Vec<_>>(),
3141                );
3142                let download_arr = StringArray::from(
3143                    urls.iter()
3144                        .map(|u| u.download_url.clone())
3145                        .collect::<Vec<_>>(),
3146                );
3147                let expires_arr = TimestampMicrosecondArray::from(
3148                    urls.iter().map(|u| u.expires_at_micros).collect::<Vec<_>>(),
3149                )
3150                .with_timezone("UTC");
3151                let batch = match RecordBatch::try_new(
3152                    schema.clone(),
3153                    vec![
3154                        Arc::new(upload_arr),
3155                        Arc::new(download_arr),
3156                        Arc::new(expires_arr),
3157                    ],
3158                ) {
3159                    Ok(b) => b,
3160                    Err(e) => {
3161                        let err = RpcError::runtime_error(format!("upload-url batch: {e}"));
3162                        let md =
3163                            build_error_metadata(&err, &state.server.server_id, &req.request_id);
3164                        let _ = sw.write(&empty_batch(schema_ref).unwrap(), Some(&md));
3165                        let _ = sw.finish();
3166                        drop(sw);
3167                        return arrow_response(StatusCode::OK, body_buf);
3168                    }
3169                };
3170                let _ = sw.write(&batch, None);
3171            }
3172            Err(err) => {
3173                let md = build_error_metadata(&err, &state.server.server_id, &req.request_id);
3174                let _ = sw.write(&empty_batch(schema_ref).unwrap(), Some(&md));
3175            }
3176        }
3177        let _ = sw.finish();
3178    }
3179    arrow_response(StatusCode::OK, body_buf)
3180}
3181
3182// ---------------------------------------------------------------------------
3183// Unary
3184// ---------------------------------------------------------------------------
3185
3186async fn handle_unary(
3187    State(state): State<Arc<HttpState>>,
3188    Path(method): Path<String>,
3189    headers: HeaderMap,
3190    body: Bytes,
3191) -> Response {
3192    // Authenticate before any other rejection: an unauthenticated
3193    // caller should always see 401, regardless of whether they sent
3194    // the right content type or anything else.
3195    let auth = match authenticate_request(&state, &method, &headers) {
3196        Ok(a) => a,
3197        Err(resp) => return resp,
3198    };
3199    if !has_arrow_ct(&headers) {
3200        return plain_error(
3201            StatusCode::UNSUPPORTED_MEDIA_TYPE,
3202            "need arrow content type".into(),
3203        );
3204    }
3205    // Resolve sticky-session headers before dispatch. A presented token
3206    // that fails to resolve short-circuits with a SessionLostError stream.
3207    let sticky_sink = match sticky_for_request(&state, &auth, &headers) {
3208        Ok(s) => s,
3209        Err(resp) => return resp,
3210    };
3211    let cookies = parse_cookies(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()));
3212    let server = state.server.clone();
3213
3214    // Measured before decompression: this is what the peer actually sent,
3215    // which is the number an egress bill is computed from. `input_bytes`
3216    // below counts logical Arrow buffers and is a different question.
3217    let request_wire_bytes = body.len() as u64;
3218    let body = match maybe_decompress(&headers, &body, request_decode_limit(&state)) {
3219        Ok(b) => b,
3220        Err(e) => return request_decode_error(&state, &e),
3221    };
3222    let mut req = match parse_request_from_body(&body) {
3223        Ok(r) => r,
3224        Err(e) => return arrow_error(&state, StatusCode::BAD_REQUEST, &e, ""),
3225    };
3226    if req.method != method {
3227        let err = RpcError::protocol_error(format!(
3228            "Method mismatch: route names '{method}', request metadata names '{}'",
3229            req.method
3230        ));
3231        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3232    }
3233    if let Err(err) = validate_protocol_version(server.protocol_version(), &req.metadata) {
3234        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3235    }
3236
3237    // If the request batch is an external-location pointer (zero rows +
3238    // `vgi_rpc.location` metadata), fetch the referenced bytes and use
3239    // the inner batch's columns for parameter extraction. Dispatch
3240    // metadata (method, request_id) is taken from the outer batch.
3241    if md_get(&req.metadata, crate::metadata::LOCATION_KEY).is_some() {
3242        if let Some(cfg) = server.external_config().as_ref() {
3243            let outer_md = req.metadata.clone();
3244            let outer_batch = req.batch.clone();
3245            let resolved = tokio::task::block_in_place(|| {
3246                crate::external::resolve_external_location(&outer_batch, &outer_md, cfg)
3247            });
3248            match resolved {
3249                Ok((inner_batch, _user_md)) => {
3250                    req.batch = inner_batch;
3251                }
3252                Err(err) => {
3253                    return cap_error_response(
3254                        &Schema::empty(),
3255                        &err,
3256                        &server.server_id,
3257                        &req.request_id,
3258                    );
3259                }
3260            }
3261        }
3262    }
3263
3264    // __describe__ introspection — served as a unary call.
3265    if server.describe_enabled() && method == crate::introspect::DESCRIBE_METHOD_NAME {
3266        let (batch, md) = match crate::introspect::build_describe(
3267            server.protocol_name(),
3268            server.methods(),
3269            &server.server_id,
3270            server.protocol_version(),
3271        ) {
3272            Ok(x) => x,
3273            Err(err) => {
3274                return arrow_error(
3275                    &state,
3276                    StatusCode::INTERNAL_SERVER_ERROR,
3277                    &err,
3278                    &req.request_id,
3279                );
3280            }
3281        };
3282        let mut buf = Vec::new();
3283        let _ = crate::introspect::write_describe_response(&mut buf, &batch, &md);
3284        return arrow_response(StatusCode::OK, buf);
3285    }
3286
3287    let Some(info) = server
3288        .method(&method)
3289        .filter(|m| m.method_type == MethodType::Unary)
3290    else {
3291        let err = RpcError::attribute_error(format!("Unknown method: '{}'", method));
3292        return arrow_error(&state, StatusCode::NOT_FOUND, &err, &req.request_id);
3293    };
3294    if let Err(err) = validate_parameter_batch(&req.batch, &info.params_schema) {
3295        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3296    }
3297
3298    let mut ctx = CallContext::with_auth_cookies(&server, &req, auth.clone(), cookies);
3299    if let Some(s) = sticky_sink.clone() {
3300        ctx.set_sticky(s);
3301    }
3302    let mut dispatch_info = crate::hooks::DispatchInfo::from_request(&server, &req, "unary", &auth);
3303    // Log the HTTP correlation id, not the Arrow-level one. `X-Request-ID`
3304    // on the response and `request_id` in the record have to name the same
3305    // request or the trail cannot be joined; `postprocess_middleware`
3306    // normalizes the inbound header to the value it will stamp on the way
3307    // out, so reading it here is what makes the two agree. Falls back to the
3308    // wire request id for a handler reached without that middleware.
3309    if let Some(id) = headers
3310        .get(REQUEST_ID_RESPONSE_HEADER)
3311        .and_then(|v| v.to_str().ok())
3312        .filter(|v| !v.is_empty())
3313    {
3314        dispatch_info.request_id = id.to_string();
3315    }
3316    let hook = server.dispatch_hook.clone();
3317    // Records cannot be written here: response compression runs after this
3318    // handler returns, so the on-wire size is not known yet. Park them in a
3319    // sink the post-processing middleware drains once the body is final.
3320    let access_sink = hook.as_ref().map(|_| crate::hooks::AccessSink::new());
3321    if hook.is_some() {
3322        dispatch_info.request_bytes = Some(request_wire_bytes);
3323        dispatch_info.access_sink = access_sink.clone();
3324        // Best-effort self-contained IPC bytes of the request batch for
3325        // `request_data`; a failure here must not abort dispatch.
3326        if let Ok(bytes) = crate::server::serialize_request_batch(&req.batch) {
3327            dispatch_info.request_data = bytes;
3328        }
3329    }
3330    let hook_token = hook.as_ref().map(|h| h.on_dispatch_start(&dispatch_info));
3331    // Externalised uploads never reach the HTTP body, so they are counted
3332    // where they happen. No `.await` runs between here and the read below,
3333    // which is what keeps a thread-local honest on an async handler.
3334    let externalized = crate::external::ExternalizedScope::new();
3335
3336    let mut stats = crate::hooks::CallStatistics {
3337        input_batches: 1,
3338        input_rows: req.batch.num_rows() as u64,
3339        ..Default::default()
3340    };
3341
3342    // Isolate handler panics: convert them to an `RpcError` that flows into the
3343    // structured Arrow error envelope below, matching the stdio/unix serve loop.
3344    // Without this a panic would bottom out at the `CatchPanicLayer` as a bare
3345    // 500 the DuckDB client can't parse as a VGI error.
3346    let result =
3347        crate::server::call_guard(|| (info.unary.as_ref().unwrap())(&req, &ctx)).and_then(|r| r);
3348    let logs = ctx.drain_logs();
3349    let mut app_err: Option<RpcError> = None;
3350    // Bytes this response pushed to external storage, in the units
3351    // `max_externalized_response_bytes` is expressed in (raw, pre-compression
3352    // IPC). Kept separate from `dispatch_info.externalized_bytes`, which is
3353    // the compressed egress figure the access log wants.
3354    let mut external_bytes_written = 0usize;
3355
3356    let mut buf = Vec::new();
3357    {
3358        let mut sw = StreamWriter::new(&mut buf, &info.result_schema).unwrap();
3359        for log in &logs {
3360            let md = build_log_metadata(log, &server.server_id, &req.request_id);
3361            let _ = sw.write(&empty_batch(&info.result_schema).unwrap(), Some(&md));
3362        }
3363        match result {
3364            Ok(batch_opt) => {
3365                let out_batch =
3366                    batch_opt.unwrap_or_else(|| empty_batch(&info.result_schema).unwrap());
3367                stats.output_batches = 1;
3368                stats.output_rows = out_batch.num_rows() as u64;
3369                if let Some(cfg) = server.external_config().as_ref() {
3370                    // Prepare (serialize + compress) but do not upload yet:
3371                    // `max_externalized_response_bytes` is pre-flighted below
3372                    // so a violating response costs no storage round trip.
3373                    // The prepare/upload pair may invoke a blocking client
3374                    // (e.g. reqwest::blocking), so both run under
3375                    // `block_in_place` — otherwise the inner client's tokio
3376                    // runtime panics on drop inside this async handler.
3377                    let externalized = tokio::task::block_in_place(|| {
3378                        // The result schema, not `out_batch.schema()`: the
3379                        // response stream declares the former, so that is
3380                        // what the client will validate the fetched payload
3381                        // against.
3382                        let prepared = crate::external::prepare_externalize_batch(
3383                            &out_batch,
3384                            &info.result_schema,
3385                            None,
3386                            cfg,
3387                        )?;
3388                        match prepared {
3389                            None => Ok(None),
3390                            Some(p) => {
3391                                if let Some(limit) = state.max_externalized_response_bytes {
3392                                    if p.cap_bytes() > limit {
3393                                        return Err(external_cap_error(
3394                                            p.cap_bytes(),
3395                                            limit,
3396                                            &method,
3397                                        ));
3398                                    }
3399                                }
3400                                external_bytes_written += p.cap_bytes();
3401                                crate::external::upload_prepared(p, cfg).map(Some)
3402                            }
3403                        }
3404                    });
3405                    match externalized {
3406                        Ok(Some((ptr, md))) => {
3407                            let _ = sw.write(&ptr, Some(&md));
3408                        }
3409                        Ok(None) => {
3410                            let _ = sw.write(&out_batch, None);
3411                        }
3412                        Err(err) => {
3413                            let md = build_error_metadata(&err, &server.server_id, &req.request_id);
3414                            let _ = sw.write(&empty_batch(&info.result_schema).unwrap(), Some(&md));
3415                            app_err = Some(err);
3416                        }
3417                    }
3418                } else {
3419                    let _ = sw.write(&out_batch, None);
3420                }
3421            }
3422            Err(err) => {
3423                let md = build_error_metadata(&err, &server.server_id, &req.request_id);
3424                let _ = sw.write(&empty_batch(&info.result_schema).unwrap(), Some(&md));
3425                app_err = Some(err);
3426            }
3427        }
3428        let _ = sw.finish();
3429    }
3430
3431    dispatch_info.externalized_bytes = externalized.finish();
3432    if let Some(hook) = hook {
3433        hook.on_dispatch_end(
3434            hook_token.unwrap_or(0),
3435            &dispatch_info,
3436            app_err.as_ref(),
3437            &stats,
3438        );
3439    }
3440    // Operator-facing caps.  Both are hard for unary — overshoot replaces
3441    // the response with an EXCEPTION-only IPC stream surfaced via 200 +
3442    // `X-VGI-RPC-Error: true`.  Mirrors Python's strict-fail contract; the
3443    // literal `max_response_bytes` / `max_externalized_response_bytes`
3444    // tokens in the messages are what the cross-language conformance suite
3445    // asserts on.
3446    //
3447    // The external cap has already been pre-flighted above, before the
3448    // upload; this is the backstop that catches an upload path added later
3449    // that forgot to pre-flight.
3450    let body_cap_error = state
3451        .max_response_bytes
3452        .filter(|limit| buf.len() > *limit)
3453        .map(|limit| {
3454            RpcError::runtime_error(format!(
3455                "HTTP body exceeds max_response_bytes ({} > {}) for method {:?}",
3456                buf.len(),
3457                limit,
3458                method
3459            ))
3460        })
3461        .or_else(|| {
3462            state
3463                .max_externalized_response_bytes
3464                .filter(|limit| external_bytes_written > *limit)
3465                .map(|limit| external_cap_error(external_bytes_written, limit, &method))
3466        });
3467    if let Some(err) = body_cap_error {
3468        let mut resp = cap_error_response(
3469            &info.result_schema,
3470            &err,
3471            &server.server_id,
3472            &req.request_id,
3473        );
3474        if let Some(s) = sticky_sink.as_ref() {
3475            stamp_session_headers(&mut resp, &state, s);
3476        }
3477        attach_access_sink(&mut resp, access_sink);
3478        return resp;
3479    }
3480    let mut resp = arrow_response(StatusCode::OK, buf);
3481    if app_err.is_some() {
3482        // The handler raised (or externalisation failed): the body is an
3483        // EXCEPTION batch behind a 200, so flag it.
3484        stamp_rpc_error(&mut resp);
3485    }
3486    if let Some(s) = sticky_sink.as_ref() {
3487        stamp_session_headers(&mut resp, &state, s);
3488    }
3489    attach_access_sink(&mut resp, access_sink);
3490    resp
3491}
3492
3493/// Hand the request's deferred access-log records to the response, so
3494/// `postprocess_middleware` can emit them once the final body exists.
3495///
3496/// Response extensions rather than a task-local: the records ride the same
3497/// value the middleware is already holding, and a response that never
3498/// reaches the middleware still emits when the sink is dropped.
3499fn attach_access_sink(resp: &mut Response, sink: Option<crate::hooks::AccessSink>) {
3500    if let Some(sink) = sink {
3501        if !sink.is_empty() {
3502            resp.extensions_mut().insert(sink);
3503        }
3504    }
3505}
3506
3507// ---------------------------------------------------------------------------
3508// Stream init
3509// ---------------------------------------------------------------------------
3510
3511async fn handle_stream_init(
3512    State(state): State<Arc<HttpState>>,
3513    Path(method): Path<String>,
3514    headers: HeaderMap,
3515    body: Bytes,
3516) -> Response {
3517    let auth = match authenticate_request(&state, &method, &headers) {
3518        Ok(a) => a,
3519        Err(resp) => return resp,
3520    };
3521    if !has_arrow_ct(&headers) {
3522        return plain_error(
3523            StatusCode::UNSUPPORTED_MEDIA_TYPE,
3524            "need arrow content type".into(),
3525        );
3526    }
3527    let sticky_sink = match sticky_for_request(&state, &auth, &headers) {
3528        Ok(s) => s,
3529        Err(resp) => return resp,
3530    };
3531    let auth_for_token = auth.clone();
3532    let cookies = parse_cookies(headers.get(header::COOKIE).and_then(|v| v.to_str().ok()));
3533    let server = state.server.clone();
3534    let body = match maybe_decompress(&headers, &body, request_decode_limit(&state)) {
3535        Ok(b) => b,
3536        Err(e) => return request_decode_error(&state, &e),
3537    };
3538    let mut req = match parse_request_from_body(&body) {
3539        Ok(r) => r,
3540        Err(e) => return arrow_error(&state, StatusCode::BAD_REQUEST, &e, ""),
3541    };
3542    if req.method != method {
3543        let err = RpcError::protocol_error(format!(
3544            "Method mismatch: route names '{method}', request metadata names '{}'",
3545            req.method
3546        ));
3547        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3548    }
3549    if let Err(err) = validate_protocol_version(server.protocol_version(), &req.metadata) {
3550        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3551    }
3552
3553    // Stream initialization accepts the same externalized parameter shape as
3554    // unary calls. Keep routing metadata from the pointer request and replace
3555    // only the parameter batch with the fetched batch.
3556    if md_get(&req.metadata, crate::metadata::LOCATION_KEY).is_some() {
3557        if let Some(cfg) = server.external_config().as_ref() {
3558            let resolved = tokio::task::block_in_place(|| {
3559                crate::external::resolve_external_location(&req.batch, &req.metadata, cfg)
3560            });
3561            match resolved {
3562                Ok((inner_batch, _user_md)) => req.batch = inner_batch,
3563                Err(err) => {
3564                    return cap_error_response(
3565                        &Schema::empty(),
3566                        &err,
3567                        &server.server_id,
3568                        &req.request_id,
3569                    );
3570                }
3571            }
3572        }
3573    }
3574
3575    let Some(info) = server
3576        .method(&method)
3577        .filter(|m| m.method_type != MethodType::Unary)
3578    else {
3579        let err = RpcError::attribute_error(format!("Unknown stream method: '{}'", method));
3580        return arrow_error(&state, StatusCode::NOT_FOUND, &err, &req.request_id);
3581    };
3582    if let Err(err) = validate_parameter_batch(&req.batch, &info.params_schema) {
3583        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, &req.request_id);
3584    }
3585
3586    let mut ctx = CallContext::with_auth_cookies(&server, &req, auth, cookies);
3587    if let Some(s) = sticky_sink.clone() {
3588        ctx.set_sticky(s);
3589    }
3590    // Isolate handler panics into the structured stream error envelope below
3591    // (see the unary path for rationale).
3592    let init_result =
3593        crate::server::call_guard(|| (info.stream.as_ref().unwrap())(&req, &ctx)).and_then(|r| r);
3594    let init_logs = ctx.drain_logs();
3595
3596    let sr = match init_result {
3597        Ok(s) => s,
3598        Err(err) => {
3599            let mut resp = arrow_response(
3600                StatusCode::OK,
3601                error_stream_bytes(&empty_schema(), &err, &server.server_id, &req.request_id),
3602            );
3603            stamp_rpc_error(&mut resp);
3604            return resp;
3605        }
3606    };
3607
3608    let StreamResult {
3609        output_schema,
3610        input_schema,
3611        state: mut ss,
3612        header,
3613        header_metadata,
3614    } = sr;
3615
3616    let mut body_buf = Vec::new();
3617
3618    // Write header stream (if any) into body_buf.
3619    if let Some(header_batch) = header.as_ref() {
3620        let hdr_schema = header_batch.schema();
3621        let mut hw = StreamWriter::new(&mut body_buf, hdr_schema.as_ref()).unwrap();
3622        for log in &init_logs {
3623            let md = build_log_metadata(log, &server.server_id, &req.request_id);
3624            let _ = hw.write(&empty_batch(hdr_schema.as_ref()).unwrap(), Some(&md));
3625        }
3626        let _ = hw.write(header_batch, header_metadata.as_ref());
3627        let _ = hw.finish();
3628    }
3629
3630    let is_producer = matches!(ss, StreamStateKind::Producer(_));
3631    let stream_id = new_session_id();
3632
3633    let mut finished = false;
3634    // Set when the body ends up carrying an EXCEPTION envelope — either the
3635    // producer's first turn raised, or the state token could not be minted.
3636    let mut wrote_error = false;
3637    {
3638        let mut sw = StreamWriter::new(&mut body_buf, output_schema.as_ref()).unwrap();
3639        if header.is_none() {
3640            for log in &init_logs {
3641                let md = build_log_metadata(log, &server.server_id, &req.request_id);
3642                let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
3643            }
3644        }
3645        let _ = header_metadata;
3646        if is_producer {
3647            // The producer's first turn folds into this /init request, so the
3648            // init request's custom metadata is what the pipe transports
3649            // would have delivered on the first tick batch.
3650            let turn = run_producer(
3651                &mut sw,
3652                &mut ss,
3653                &output_schema,
3654                &state,
3655                &method,
3656                &req,
3657                sticky_sink.as_ref(),
3658                Some(req.metadata.as_ref()),
3659            );
3660            finished = turn.finished;
3661            wrote_error |= turn.errored;
3662        }
3663        if !finished {
3664            match build_init_tokens(
3665                &state,
3666                &auth_for_token,
3667                &ss,
3668                &output_schema,
3669                input_schema.as_ref(),
3670                &stream_id,
3671            ) {
3672                Ok((token, call_token)) => {
3673                    // /init is the one response that hands over the call
3674                    // token; continuations re-mint the cursor alone.
3675                    let md = Metadata::from([
3676                        (STATE_KEY.to_string(), token),
3677                        (CALL_STATE_KEY.to_string(), call_token),
3678                    ]);
3679                    let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
3680                }
3681                Err(err) => {
3682                    // Handler doesn't implement encode_state — emit as an
3683                    // error envelope so the client sees a useful message
3684                    // instead of a hung stream.
3685                    let md = build_error_metadata(&err, &server.server_id, &req.request_id);
3686                    let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
3687                    wrote_error = true;
3688                }
3689            }
3690        }
3691        let _ = sw.finish();
3692    }
3693
3694    let mut resp = arrow_response(StatusCode::OK, body_buf);
3695    if wrote_error {
3696        // The body is an EXCEPTION envelope behind a 200; without the flag a
3697        // client reads the failure as a result.
3698        stamp_rpc_error(&mut resp);
3699    }
3700    if let Some(s) = sticky_sink.as_ref() {
3701        stamp_session_headers(&mut resp, &state, s);
3702    }
3703    resp
3704}
3705
3706/// Encode a `StreamStateKind` into a signed state token. The token is
3707/// bound to `auth` so a different identity replaying it will fail HMAC
3708/// verification on the next continuation request.
3709/// Mint a stream's pair of tokens at `/init`: the call token, which carries
3710/// everything fixed for the life of the call, and the first cursor.
3711fn build_init_tokens(
3712    state: &Arc<HttpState>,
3713    auth: &crate::auth::AuthContext,
3714    ss: &StreamStateKind,
3715    output_schema: &SchemaRef,
3716    input_schema: Option<&SchemaRef>,
3717    stream_id: &str,
3718) -> Result<(String, String)> {
3719    let out_schema_bytes = write_schema_bytes(output_schema.as_ref())?;
3720    let in_schema_bytes = match input_schema {
3721        Some(s) => write_schema_bytes(s.as_ref())?,
3722        None => Vec::new(),
3723    };
3724    let mut call_id = [0u8; CALL_ID_LEN];
3725    rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut call_id);
3726
3727    let call_token = state.pack_call_token(
3728        auth,
3729        &call_id,
3730        &out_schema_bytes,
3731        &in_schema_bytes,
3732        stream_id,
3733    );
3734    let cursor = build_cursor_token(state, auth, ss, &call_id)?;
3735    Ok((cursor, call_token))
3736}
3737
3738/// Like [`build_continuation_token`] but takes the already-serialized schema
3739/// IPC bytes. Continuation requests carry these verbatim in the incoming
3740/// token, so re-serializing the `SchemaRef`s (a decode→re-encode round trip,
3741/// plus two `empty_batch` builds) on every turn is pure waste — thread the
3742/// bytes straight through instead.
3743fn build_cursor_token(
3744    state: &Arc<HttpState>,
3745    auth: &crate::auth::AuthContext,
3746    ss: &StreamStateKind,
3747    call_id: &[u8; CALL_ID_LEN],
3748) -> Result<String> {
3749    let state_bytes = crate::server::call_guard(|| match ss {
3750        StreamStateKind::Producer(p) => p.encode_state(),
3751        StreamStateKind::Exchange(e) => e.encode_state(),
3752    })
3753    .and_then(|result| result)?;
3754    Ok(state.pack_cursor_token(auth, &state_bytes, call_id))
3755}
3756
3757/// What the HTTP stream paths should do with one output batch after the
3758/// external-location machinery has had a look at it.
3759enum StreamEmit {
3760    /// Externalised: write this zero-row pointer batch with this metadata.
3761    Pointer(RecordBatch, Metadata),
3762    /// Not externalised (no storage configured, or below the threshold) —
3763    /// write the batch inline exactly as the worker emitted it.
3764    Inline,
3765    /// Refused or failed. The turn must stop and emit this error envelope.
3766    Failed(RpcError),
3767}
3768
3769/// Externalize one stream output batch, enforcing the **hard** external cap.
3770///
3771/// Both HTTP stream shapes route their emitted batches through here.  Before
3772/// this existed the HTTP transport was the only one that never externalised
3773/// stream output: `maybe_externalize_batch` was wired into the unary handler
3774/// and into the pipe/unix serve loop, but the HTTP producer and exchange
3775/// loops wrote every batch inline.  Python (`_flush_collector` under
3776/// `_run_http_producer_turn` / `_run_http_exchange_turn`) and TypeScript
3777/// (`produceStreamResponse`) both externalise here, so a client talking to a
3778/// Rust worker over HTTP got whole batches on the wire where the other ports
3779/// handed back a pointer.
3780///
3781/// `cumulative_external` is the running total for *this HTTP turn*, in the
3782/// units the cap is expressed in, and is advanced only when an upload is
3783/// actually performed.
3784///
3785/// The cap is checked **before** the upload and is hard: a producer's *wire*
3786/// overshoot (`max_response_bytes`) is absorbed by minting a continuation
3787/// token, but there is no equivalent rescue for the external channel —
3788/// by the time a continuation could be minted the bytes are already in
3789/// object storage. So an overshoot ends the turn with an error instead.
3790fn externalize_stream_batch(
3791    state: &Arc<HttpState>,
3792    batch: &RecordBatch,
3793    output_schema: &SchemaRef,
3794    metadata: Option<&Metadata>,
3795    method: &str,
3796    cumulative_external: &mut usize,
3797) -> StreamEmit {
3798    let Some(cfg) = state.server.external_config() else {
3799        return StreamEmit::Inline;
3800    };
3801    // The upload may drive a blocking client (e.g. reqwest::blocking) whose
3802    // inner runtime panics if dropped on an async worker thread; see the
3803    // unary path for the same treatment.
3804    tokio::task::block_in_place(|| {
3805        // `output_schema`, not `batch.schema()` — the response stream was
3806        // opened with the former, and a batch that differs from it only
3807        // cosmetically must externalise to the same bytes it would have
3808        // been delivered as inline.
3809        let prepared =
3810            match crate::external::prepare_externalize_batch(batch, output_schema, metadata, cfg) {
3811                Ok(Some(p)) => p,
3812                Ok(None) => return StreamEmit::Inline,
3813                Err(err) => return StreamEmit::Failed(err),
3814            };
3815        let cap_bytes = prepared.cap_bytes();
3816        if let Some(limit) = state.max_externalized_response_bytes {
3817            let projected = *cumulative_external + cap_bytes;
3818            if projected > limit {
3819                return StreamEmit::Failed(external_cap_error(projected, limit, method));
3820            }
3821        }
3822        match crate::external::upload_prepared(prepared, cfg) {
3823            Ok((ptr, md)) => {
3824                // Charged after the upload succeeded — a failed upload put
3825                // nothing in storage, so charging it would shrink the budget
3826                // for bytes that never left.
3827                *cumulative_external += cap_bytes;
3828                StreamEmit::Pointer(ptr, md)
3829            }
3830            Err(err) => StreamEmit::Failed(err),
3831        }
3832    })
3833}
3834
3835/// Outcome of one HTTP producer turn.
3836///
3837/// `errored` is separate from `finished` because a raising `produce` ends the
3838/// turn exactly like a clean completion does: both stop the loop, but only one
3839/// leaves an EXCEPTION envelope in the body, and that is the case the enclosing
3840/// 200 must be flagged for.
3841struct ProducerTurn {
3842    /// The stream is over — no continuation token should be minted.
3843    finished: bool,
3844    /// The turn wrote an EXCEPTION envelope rather than data.
3845    errored: bool,
3846}
3847
3848/// Metadata keys the HTTP transport itself puts on a stream continuation
3849/// request. They are transport plumbing, not application metadata: the pipe
3850/// transports carry the equivalent state in the connection rather than on the
3851/// batch, so a worker must never see them on a tick. [`STATE_KEY`] in
3852/// particular is a sealed cursor token.
3853const FRAMEWORK_TICK_METADATA_KEYS: [&str; 3] = [STATE_KEY, CALL_STATE_KEY, CANCEL_KEY];
3854
3855/// Returns `md` with the framework's transport keys removed, so a continuation
3856/// request's metadata can be surfaced to user code as that turn's tick
3857/// metadata.
3858fn strip_framework_tick_metadata(md: &Metadata) -> Metadata {
3859    md.iter()
3860        .filter(|(k, _)| !FRAMEWORK_TICK_METADATA_KEYS.contains(&k.as_str()))
3861        .map(|(k, v)| (k.clone(), v.clone()))
3862        .collect()
3863}
3864
3865/// `tick_md` is surfaced as [`CallContext::tick_metadata`] on the single
3866/// `produce` call of this HTTP turn. On the pipe transports every
3867/// producer turn is a distinct tick batch whose custom metadata reaches the
3868/// worker; over HTTP the first turn folds into the /init request and later
3869/// turns are continuation POSTs, so callers pass the corresponding request's
3870/// metadata (framework transport keys stripped by
3871/// [`strip_framework_tick_metadata`]). That carries both the /init-time
3872/// revalidators (`vgi.cache.if_none_match`) and DuckDB's between-tick
3873/// dynamic-filter updates (`vgi_pushdown_filters`).
3874#[allow(clippy::too_many_arguments)]
3875fn run_producer<W: std::io::Write>(
3876    sw: &mut StreamWriter<W>,
3877    ss: &mut StreamStateKind,
3878    output_schema: &SchemaRef,
3879    state: &Arc<HttpState>,
3880    method: &str,
3881    req: &Request,
3882    sticky: Option<&Arc<crate::sticky::StickySinkImpl>>,
3883    tick_md: Option<&Metadata>,
3884) -> ProducerTurn {
3885    let server = &state.server;
3886    // Continuation producers run without auth context (session-bound).
3887    let mut ctx = CallContext::for_request(server, req);
3888    if let Some(s) = sticky {
3889        ctx.set_sticky(s.clone());
3890    }
3891    if let Some(md) = tick_md {
3892        ctx.set_tick_metadata(md.clone());
3893    }
3894    let producer = match ss {
3895        StreamStateKind::Producer(p) => p,
3896        StreamStateKind::Exchange(_) => unreachable!(),
3897    };
3898    // Bytes this turn has pushed to external storage. Resets per HTTP turn,
3899    // matching the reference: each turn is one response and the cap is a
3900    // per-response ceiling.
3901    let mut cumulative_external = 0usize;
3902    let mut out = OutputCollector::new(output_schema.clone(), true);
3903    let result =
3904        crate::server::call_guard(|| producer.produce(&mut out, &ctx)).and_then(|result| result);
3905    for log in ctx.drain_logs() {
3906        let md = build_log_metadata(&log, &server.server_id, &req.request_id);
3907        let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
3908    }
3909    if let Err(err) = result {
3910        let md = build_error_metadata(&err, &server.server_id, &req.request_id);
3911        let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
3912        return ProducerTurn {
3913            finished: true,
3914            errored: true,
3915        };
3916    }
3917    let finished = out.finished();
3918    let mut emitted_data = false;
3919    for item in out.items.drain(..) {
3920        match item {
3921            Emitted::Log(log) => {
3922                let md = build_log_metadata(&log, &server.server_id, &req.request_id);
3923                let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
3924            }
3925            Emitted::Batch { batch, metadata } => {
3926                match externalize_stream_batch(
3927                    state,
3928                    &batch,
3929                    output_schema,
3930                    metadata.as_ref(),
3931                    method,
3932                    &mut cumulative_external,
3933                ) {
3934                    StreamEmit::Pointer(ptr, md) => {
3935                        let _ = sw.write(&ptr, Some(&md));
3936                    }
3937                    StreamEmit::Inline => {
3938                        let _ = sw.write(&batch, metadata.as_ref());
3939                    }
3940                    StreamEmit::Failed(err) => {
3941                        // Hard stop: no continuation token is minted, so
3942                        // the client sees an `RpcError` on iteration
3943                        // rather than a stream that quietly resumes past
3944                        // a cap it just blew.
3945                        let md = build_error_metadata(&err, &server.server_id, &req.request_id);
3946                        let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
3947                        return ProducerTurn {
3948                            finished: true,
3949                            errored: true,
3950                        };
3951                    }
3952                }
3953                emitted_data = true;
3954            }
3955        }
3956    }
3957    ProducerTurn {
3958        // Guard against degenerate producers that neither emit nor finish.
3959        finished: finished || !emitted_data,
3960        errored: false,
3961    }
3962}
3963
3964// ---------------------------------------------------------------------------
3965// Stream exchange / producer continuation / cancel
3966// ---------------------------------------------------------------------------
3967
3968async fn handle_stream_exchange(
3969    State(state): State<Arc<HttpState>>,
3970    Path(method): Path<String>,
3971    headers: HeaderMap,
3972    body: Bytes,
3973) -> Response {
3974    let auth = match authenticate_request(&state, &method, &headers) {
3975        Ok(a) => a,
3976        Err(resp) => return resp,
3977    };
3978    if !has_arrow_ct(&headers) {
3979        return plain_error(
3980            StatusCode::UNSUPPORTED_MEDIA_TYPE,
3981            "need arrow content type".into(),
3982        );
3983    }
3984    let sticky_sink = match sticky_for_request(&state, &auth, &headers) {
3985        Ok(s) => s,
3986        Err(resp) => return resp,
3987    };
3988
3989    let server = state.server.clone();
3990    let body = match maybe_decompress(&headers, &body, request_decode_limit(&state)) {
3991        Ok(b) => b,
3992        Err(e) => return request_decode_error(&state, &e),
3993    };
3994    // Parse input batch (may be empty-schema for cancel / producer continuation).
3995    let (mut batch, mut metadata) = match read_input_batch(&body) {
3996        Ok(x) => x,
3997        Err(e) => return arrow_error(&state, StatusCode::BAD_REQUEST, &e, ""),
3998    };
3999
4000    // Resolve externally uploaded exchange input before reading state tokens
4001    // or dispatching. The resolver merges outer and inner metadata, preserving
4002    // cursor/call tokens regardless of which side of the pointer carried them.
4003    if md_get(&metadata, crate::metadata::LOCATION_KEY).is_some() {
4004        if let Some(cfg) = server.external_config().as_ref() {
4005            let resolved = tokio::task::block_in_place(|| {
4006                crate::external::resolve_external_location(&batch, &metadata, cfg)
4007            });
4008            match resolved {
4009                Ok((inner_batch, inner_metadata)) => {
4010                    batch = inner_batch;
4011                    metadata = inner_metadata;
4012                }
4013                Err(err) => {
4014                    return cap_error_response(&Schema::empty(), &err, &server.server_id, "");
4015                }
4016            }
4017        }
4018    }
4019
4020    let Some(token) = md_get(&metadata, STATE_KEY).map(str::to_owned) else {
4021        let err = RpcError::runtime_error("Missing state token in exchange request");
4022        return arrow_error(&state, StatusCode::BAD_REQUEST, &err, "");
4023    };
4024    let cancelled = md_get(&metadata, CANCEL_KEY).is_some();
4025    let call_token = md_get(&metadata, CALL_STATE_KEY).map(str::to_owned);
4026
4027    // Open the cursor FIRST: its AEAD tag covers the call id and its AAD
4028    // covers the caller, so the id is authenticated before it is used to
4029    // resolve anything. See HttpState::resolve_call for why that ordering is
4030    // the whole security argument for the cache.
4031    let unpacked = match state.unpack_cursor_token(&auth, &token) {
4032        Ok(u) => u,
4033        Err(err) => return arrow_error(&state, StatusCode::BAD_REQUEST, &err, ""),
4034    };
4035    let call = match state.resolve_call(&auth, &unpacked, call_token.as_deref()) {
4036        Ok(c) => c,
4037        Err(err) => return arrow_error(&state, StatusCode::BAD_REQUEST, &err, ""),
4038    };
4039
4040    // Reconstruct schemas from the call token's IPC bytes.
4041    let output_schema = match read_schema_bytes(&call.output_schema_bytes) {
4042        Ok(s) => s,
4043        Err(err) => return arrow_error(&state, StatusCode::BAD_REQUEST, &err, ""),
4044    };
4045    let input_schema: Option<SchemaRef> = if call.input_schema_bytes.is_empty() {
4046        None
4047    } else {
4048        match read_schema_bytes(&call.input_schema_bytes) {
4049            Ok(s) => Some(s),
4050            Err(err) => return arrow_error(&state, StatusCode::BAD_REQUEST, &err, ""),
4051        }
4052    };
4053
4054    // Resolve the method's state decoder from URL path.
4055    let Some(info) = server
4056        .method(&method)
4057        .filter(|m| m.method_type != MethodType::Unary)
4058    else {
4059        let err = RpcError::attribute_error(format!("Unknown stream method: '{}'", method));
4060        return arrow_error(&state, StatusCode::NOT_FOUND, &err, "");
4061    };
4062    let Some(decoder) = info.state_decoder.as_ref() else {
4063        let err = RpcError::runtime_error(format!(
4064            "Stream method '{method}' is registered without a state decoder; \
4065             it cannot serve HTTP continuation requests"
4066        ));
4067        return arrow_error(&state, StatusCode::INTERNAL_SERVER_ERROR, &err, "");
4068    };
4069    let mut ss = match crate::server::call_guard(|| decoder(&unpacked.state_bytes))
4070        .and_then(|result| result)
4071    {
4072        Ok(s) => s,
4073        Err(err) => return arrow_error(&state, StatusCode::BAD_REQUEST, &err, ""),
4074    };
4075
4076    let req = Request {
4077        method: method.clone(),
4078        request_id: md_get(&metadata, REQUEST_ID_KEY).unwrap_or("").to_string(),
4079        batch: empty_batch(&Schema::empty()).unwrap(),
4080        metadata: Arc::new(metadata.clone()),
4081    };
4082    let mut ctx = CallContext::for_request(&server, &req);
4083    if let Some(s) = sticky_sink.clone() {
4084        ctx.set_sticky(s);
4085    }
4086
4087    let mut body_buf = Vec::new();
4088
4089    if cancelled {
4090        let cancel_result = crate::server::call_guard(|| match &mut ss {
4091            StreamStateKind::Producer(p) => p.on_cancel(&ctx),
4092            StreamStateKind::Exchange(e) => e.on_cancel(&ctx),
4093        });
4094        {
4095            let mut sw = StreamWriter::new(&mut body_buf, output_schema.as_ref()).unwrap();
4096            for log in ctx.drain_logs() {
4097                let md = build_log_metadata(&log, &server.server_id, &req.request_id);
4098                let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4099            }
4100            if let Err(err) = &cancel_result {
4101                let md = build_error_metadata(err, &server.server_id, &req.request_id);
4102                let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4103            }
4104            let _ = sw.finish();
4105        }
4106        let mut resp = arrow_response(StatusCode::OK, body_buf);
4107        if cancel_result.is_err() {
4108            stamp_rpc_error(&mut resp);
4109        }
4110        if let Some(s) = sticky_sink.as_ref() {
4111            stamp_session_headers(&mut resp, &state, s);
4112        }
4113        return resp;
4114    }
4115
4116    if matches!(ss, StreamStateKind::Producer(_)) {
4117        // Producer continuation.
4118        let mut wrote_error = false;
4119        {
4120            let mut sw = StreamWriter::new(&mut body_buf, output_schema.as_ref()).unwrap();
4121            // The continuation request's custom metadata is this turn's tick
4122            // metadata. On the pipe transports every producer turn is a tick
4123            // batch whose metadata reaches the worker, and DuckDB uses that to
4124            // push *updated* dynamic filters (`vgi_pushdown_filters` — Top-N
4125            // boundary tightening, join-key IN sets) between ticks. Over HTTP
4126            // a turn is a continuation POST, so its metadata has to be
4127            // forwarded here or those updates are silently dropped. The
4128            // framework's own transport keys are stripped first: the pipe
4129            // transports never put them on a tick, and the stream-state value
4130            // is a sealed cursor token that must not surface to user code.
4131            let continuation_md = strip_framework_tick_metadata(&metadata);
4132            let turn = run_producer(
4133                &mut sw,
4134                &mut ss,
4135                &output_schema,
4136                &state,
4137                &method,
4138                &req,
4139                sticky_sink.as_ref(),
4140                Some(&continuation_md),
4141            );
4142            wrote_error |= turn.errored;
4143            if !turn.finished {
4144                match build_cursor_token(&state, &auth, &ss, &unpacked.call_id) {
4145                    Ok(new_token) => {
4146                        let md = Metadata::from([(STATE_KEY.to_string(), new_token)]);
4147                        let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4148                    }
4149                    Err(err) => {
4150                        let md = build_error_metadata(&err, &server.server_id, &req.request_id);
4151                        let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4152                        wrote_error = true;
4153                    }
4154                }
4155            }
4156            let _ = sw.finish();
4157        }
4158        let mut resp = arrow_response(StatusCode::OK, body_buf);
4159        if wrote_error {
4160            stamp_rpc_error(&mut resp);
4161        }
4162        if let Some(s) = sticky_sink.as_ref() {
4163            stamp_session_headers(&mut resp, &state, s);
4164        }
4165        return resp;
4166    }
4167
4168    // Exchange continuation.
4169    let casted = match &input_schema {
4170        Some(exp) if batch.schema() != *exp => match cast_batch(&batch, exp) {
4171            Ok(b) => b,
4172            Err(e) => {
4173                let mut sw = StreamWriter::new(&mut body_buf, output_schema.as_ref()).unwrap();
4174                let md = build_error_metadata(&e, &server.server_id, &req.request_id);
4175                let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4176                let _ = sw.finish();
4177                drop(sw);
4178                let mut resp = arrow_response(StatusCode::OK, body_buf);
4179                stamp_rpc_error(&mut resp);
4180                return resp;
4181            }
4182        },
4183        _ => batch,
4184    };
4185
4186    // This turn's input-batch metadata is what the pipe transports surface as
4187    // `CallContext::tick_metadata`: server.rs's lockstep loop moves each input
4188    // batch's own metadata into the context before dispatching the exchange.
4189    // Over HTTP an exchange turn is a continuation POST, so that request's
4190    // metadata plays the same role and has to be forwarded here — without it
4191    // an identical worker sees its `vgi.cache.*` revalidators (and any other
4192    // per-batch metadata) over subprocess and nothing at all over HTTP.
4193    //
4194    // The framework's own transport keys are stripped first, exactly as on the
4195    // producer continuation turn: the pipe transports keep the stream cursor
4196    // and call state in the CONNECTION rather than on a batch, and the cursor
4197    // is an AEAD-sealed token that must not surface to application code.
4198    ctx.set_tick_metadata(strip_framework_tick_metadata(&metadata));
4199
4200    let mut out = OutputCollector::new(output_schema.clone(), false);
4201    let res = crate::server::call_guard(|| match &mut ss {
4202        StreamStateKind::Exchange(e) => e.exchange(&casted, &mut out, &ctx),
4203        _ => unreachable!(),
4204    })
4205    .and_then(|result| result);
4206
4207    let mut wrote_error = false;
4208    {
4209        let mut sw = StreamWriter::new(&mut body_buf, output_schema.as_ref()).unwrap();
4210        for log in ctx.drain_logs() {
4211            let md = build_log_metadata(&log, &server.server_id, &req.request_id);
4212            let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4213        }
4214        if let Err(err) = res {
4215            let md = build_error_metadata(&err, &server.server_id, &req.request_id);
4216            let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4217            wrote_error = true;
4218        } else {
4219            let new_token = match build_cursor_token(&state, &auth, &ss, &unpacked.call_id) {
4220                Ok(t) => t,
4221                Err(err) => {
4222                    let md = build_error_metadata(&err, &server.server_id, &req.request_id);
4223                    let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4224                    let _ = sw.finish();
4225                    drop(sw);
4226                    let mut resp = arrow_response(StatusCode::OK, body_buf);
4227                    stamp_rpc_error(&mut resp);
4228                    return resp;
4229                }
4230            };
4231            let mut wrote_data = false;
4232            // Per-response external budget, same units and same hard
4233            // semantics as the producer path.
4234            let mut cumulative_external = 0usize;
4235            for item in out.items.drain(..) {
4236                match item {
4237                    Emitted::Log(log) => {
4238                        let md = build_log_metadata(&log, &server.server_id, &req.request_id);
4239                        let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4240                    }
4241                    Emitted::Batch { batch, metadata } => {
4242                        // The refreshed cursor rides the data batch's
4243                        // metadata, so it has to survive externalisation —
4244                        // pass it in as the pointer batch's inline metadata
4245                        // rather than stamping the batch we may not write.
4246                        let mut md = metadata.unwrap_or_default();
4247                        md.insert(STATE_KEY.to_string(), new_token.clone());
4248                        match externalize_stream_batch(
4249                            &state,
4250                            &batch,
4251                            &output_schema,
4252                            Some(&md),
4253                            &method,
4254                            &mut cumulative_external,
4255                        ) {
4256                            StreamEmit::Pointer(ptr, ptr_md) => {
4257                                let _ = sw.write(&ptr, Some(&ptr_md));
4258                            }
4259                            StreamEmit::Inline => {
4260                                let _ = sw.write(&batch, Some(&md));
4261                            }
4262                            StreamEmit::Failed(err) => {
4263                                let emd =
4264                                    build_error_metadata(&err, &server.server_id, &req.request_id);
4265                                let _ = sw.write(
4266                                    &empty_batch(output_schema.as_ref()).unwrap(),
4267                                    Some(&emd),
4268                                );
4269                                wrote_error = true;
4270                                break;
4271                            }
4272                        }
4273                        wrote_data = true;
4274                    }
4275                }
4276            }
4277            // A cap overshoot already wrote the EXCEPTION envelope and left
4278            // `wrote_data` false; handing back a fresh cursor after it would
4279            // invite the client to keep going on a turn that failed.
4280            if !wrote_data && !wrote_error {
4281                let md = Metadata::from([(STATE_KEY.to_string(), new_token)]);
4282                let _ = sw.write(&empty_batch(output_schema.as_ref()).unwrap(), Some(&md));
4283            }
4284        }
4285        let _ = sw.finish();
4286    }
4287
4288    let mut resp = enforce_response_body_cap(
4289        &state,
4290        output_schema.as_ref(),
4291        body_buf,
4292        &method,
4293        &server.server_id,
4294        "",
4295    );
4296    if wrote_error {
4297        // The handler raised; the envelope is in the body behind a 200.
4298        // (A cap overshoot is already flagged by `cap_error_response`.)
4299        stamp_rpc_error(&mut resp);
4300    }
4301    if let Some(s) = sticky_sink.as_ref() {
4302        stamp_session_headers(&mut resp, &state, s);
4303    }
4304    resp
4305}
4306
4307fn read_input_batch(body: &[u8]) -> Result<(RecordBatch, Metadata)> {
4308    let mut r = StreamReader::new(body)?;
4309    let (batch, metadata) = r
4310        .read_next()?
4311        .ok_or_else(|| RpcError::runtime_error("no batch in exchange request"))?;
4312    r.drain()?;
4313    Ok((batch, metadata))
4314}
4315
4316#[cfg(test)]
4317mod tests {
4318    use super::*;
4319    use std::time::Duration;
4320
4321    fn state_with_key() -> Arc<HttpState> {
4322        use crate::server::RpcServer;
4323        let server = Arc::new(RpcServer::builder().server_id("test").build());
4324        HttpState::builder()
4325            .server(server)
4326            .token_key(&[7u8; 32])
4327            .token_ttl(Duration::from_millis(50))
4328            .max_body_size(1024)
4329            .build()
4330    }
4331
4332    const TEST_CALL_ID: [u8; CALL_ID_LEN] = [9u8; CALL_ID_LEN];
4333
4334    fn sample_schema_bytes() -> Vec<u8> {
4335        use arrow_schema::{DataType, Field, Schema};
4336        write_schema_bytes(&Schema::new(vec![Field::new("x", DataType::Int64, false)])).unwrap()
4337    }
4338
4339    // Token payloads are compressed *inside* the seal. The ordering is the
4340    // whole point: once a token is sealed it is ciphertext, so the HTTP body
4341    // codec can find no redundancy in it — it recovers only the slack base64
4342    // adds, never the state's own structure. Compressing before sealing
4343    // reaches the real redundancy.
4344    //
4345    // None of this is visible on the wire, so the cross-language conformance
4346    // suite cannot reach it; docs/WIRE_PROTOCOL.md in the reference repo
4347    // makes it normative and asks each port to pin it with a language-local
4348    // test like these.
4349
4350    #[test]
4351    fn token_payload_compresses_when_redundant() {
4352        let plaintext = b"vgi-rpc-state-".repeat(1000);
4353        let packed = pack_token_payload(&plaintext);
4354        assert_eq!(packed[0], TOKEN_CODEC_ZSTD, "expected the zstd codec tag");
4355        assert!(
4356            packed.len() < plaintext.len() / 4,
4357            "expected real compression on a redundant payload, got {} from {}",
4358            packed.len(),
4359            plaintext.len()
4360        );
4361    }
4362
4363    #[test]
4364    fn token_payload_stays_raw_when_incompressible() {
4365        // A counter pattern with no repeats gives the codec nothing to find.
4366        // Skipping is what keeps the guarantee one-directional: a token may
4367        // get smaller, never larger than its plaintext plus the one tag byte.
4368        let plaintext: Vec<u8> = (0u8..=255).collect();
4369        let packed = pack_token_payload(&plaintext);
4370        assert_eq!(packed[0], TOKEN_CODEC_RAW, "expected the raw codec tag");
4371        assert_eq!(
4372            packed.len(),
4373            plaintext.len() + 1,
4374            "raw payload must not grow beyond the tag byte"
4375        );
4376    }
4377
4378    #[test]
4379    fn token_payload_round_trips_under_either_codec() {
4380        let incompressible: Vec<u8> = (0u8..=255).collect();
4381        let cases: Vec<Vec<u8>> = vec![
4382            Vec::new(),
4383            b"x".to_vec(),
4384            incompressible,
4385            b"vgi-rpc-state-".repeat(500),
4386        ];
4387        for plaintext in cases {
4388            let packed = pack_token_payload(&plaintext);
4389            let got = unpack_token_payload(&packed).expect("round trip");
4390            assert_eq!(got, plaintext, "round trip changed the payload");
4391        }
4392    }
4393
4394    #[test]
4395    fn token_payload_rejects_malformed_input() {
4396        // An unknown tag, an empty payload, or a body that will not
4397        // decompress all mean a token this server did not mint, so all three
4398        // surface as the same uniform error the caller maps to 400.
4399        assert!(unpack_token_payload(&[]).is_err(), "empty payload");
4400        assert!(
4401            unpack_token_payload(&[0x7f, b'p', b'a', b'y']).is_err(),
4402            "unknown codec tag"
4403        );
4404        let mut corrupt = vec![TOKEN_CODEC_ZSTD];
4405        corrupt.extend_from_slice(b"not-a-zstd-frame");
4406        assert!(unpack_token_payload(&corrupt).is_err(), "corrupt zstd body");
4407    }
4408
4409    #[tokio::test]
4410    async fn sealed_token_shrinks_with_a_compressible_state() {
4411        // End to end: compression inside the seal shrinks the token itself.
4412        // Guards the ordering rather than the codec — a token sealed around
4413        // an uncompressed payload comes out *larger* than its input once
4414        // base64 inflation is counted, which is the regression this catches.
4415        let s = state_with_key();
4416        let auth = crate::auth::AuthContext::anonymous();
4417        let state_bytes = b"vgi-rpc-call-state-".repeat(400);
4418        let token = s.pack_cursor_token(&auth, &state_bytes, &TEST_CALL_ID);
4419        assert!(
4420            token.len() < state_bytes.len() / 4,
4421            "sealed token ({}B) should be far smaller than its state ({}B)",
4422            token.len(),
4423            state_bytes.len()
4424        );
4425
4426        let unpacked = s.unpack_cursor_token(&auth, &token).unwrap();
4427        assert_eq!(unpacked.state_bytes, state_bytes, "state survived the seal");
4428    }
4429
4430    #[tokio::test]
4431    async fn pack_unpack_roundtrip() {
4432        let s = state_with_key();
4433        let auth = crate::auth::AuthContext::anonymous();
4434        let state_bytes = b"state-payload";
4435        let out_sch = sample_schema_bytes();
4436        let in_sch = sample_schema_bytes();
4437        let token = s.pack_cursor_token(&auth, state_bytes, &TEST_CALL_ID);
4438        let unpacked = s.unpack_cursor_token(&auth, &token).unwrap();
4439        assert_eq!(unpacked.state_bytes, state_bytes);
4440        assert_eq!(unpacked.call_id, TEST_CALL_ID);
4441
4442        // The schemas and stream id ride the call token now, not the cursor.
4443        let call_token = s.pack_call_token(&auth, &TEST_CALL_ID, &out_sch, &in_sch, "sid-123");
4444        let call = s.resolve_call(&auth, &unpacked, Some(&call_token)).unwrap();
4445        assert_eq!(call.output_schema_bytes, out_sch);
4446        assert_eq!(call.input_schema_bytes, in_sch);
4447        assert_eq!(call.stream_id, "sid-123");
4448    }
4449
4450    #[tokio::test]
4451    async fn unpack_rejects_tampered_ciphertext() {
4452        let s = state_with_key();
4453        let auth = crate::auth::AuthContext::anonymous();
4454        let token = s.pack_cursor_token(&auth, b"s", &TEST_CALL_ID);
4455        let mut bytes = base64::engine::general_purpose::STANDARD
4456            .decode(token.as_bytes())
4457            .unwrap();
4458        // Flip a bit inside the ciphertext (past the 1-byte version + 24-byte
4459        // nonce header owned by `crypto`).
4460        let cipher_idx = 1 + 24;
4461        bytes[cipher_idx] ^= 0x01;
4462        let tampered = base64::engine::general_purpose::STANDARD.encode(bytes);
4463        assert!(s.unpack_cursor_token(&auth, &tampered).is_err());
4464    }
4465
4466    #[tokio::test]
4467    async fn unpack_rejects_tampered_nonce() {
4468        let s = state_with_key();
4469        let auth = crate::auth::AuthContext::anonymous();
4470        let token = s.pack_cursor_token(&auth, b"s", &TEST_CALL_ID);
4471        let mut bytes = base64::engine::general_purpose::STANDARD
4472            .decode(token.as_bytes())
4473            .unwrap();
4474        // Flip the first nonce byte; AEAD decryption must reject.
4475        bytes[1] ^= 0x01;
4476        let tampered = base64::engine::general_purpose::STANDARD.encode(bytes);
4477        assert!(s.unpack_cursor_token(&auth, &tampered).is_err());
4478    }
4479
4480    #[tokio::test]
4481    async fn unpack_rejects_unknown_version() {
4482        let s = state_with_key();
4483        let auth = crate::auth::AuthContext::anonymous();
4484        let token = s.pack_cursor_token(&auth, b"s", &TEST_CALL_ID);
4485        let mut bytes = base64::engine::general_purpose::STANDARD
4486            .decode(token.as_bytes())
4487            .unwrap();
4488        bytes[0] = 0x99;
4489        let tampered = base64::engine::general_purpose::STANDARD.encode(bytes);
4490        let err = s.unpack_cursor_token(&auth, &tampered).unwrap_err();
4491        // Wrong-version tokens map to the same uniform error as every other
4492        // bad-token mode — callers cannot distinguish failure modes.
4493        assert!(err.message.contains("signature verification failed"));
4494    }
4495
4496    #[tokio::test]
4497    async fn unpack_rejects_malformed_base64() {
4498        let s = state_with_key();
4499        let auth = crate::auth::AuthContext::anonymous();
4500        let err = s.unpack_cursor_token(&auth, "not!base64!").unwrap_err();
4501        assert!(err.message.contains("Malformed"));
4502    }
4503
4504    #[tokio::test]
4505    async fn unpack_rejects_different_key() {
4506        use crate::server::RpcServer;
4507        let server = Arc::new(RpcServer::builder().server_id("t").build());
4508        let a = HttpState::builder()
4509            .server(server.clone())
4510            .token_key(&[1u8; 32])
4511            .build();
4512        let b = HttpState::builder()
4513            .server(server)
4514            .token_key(&[2u8; 32])
4515            .build();
4516        let auth = crate::auth::AuthContext::anonymous();
4517        let tok = a.pack_cursor_token(&auth, b"s", &TEST_CALL_ID);
4518        assert!(b.unpack_cursor_token(&auth, &tok).is_err());
4519    }
4520
4521    #[tokio::test]
4522    async fn unpack_rejects_expired_token() {
4523        let s = state_with_key(); // ttl = 50ms
4524        let auth = crate::auth::AuthContext::anonymous();
4525        // Pack a token whose created_at is far in the past, using the
4526        // same AAD the server will reconstruct for an anonymous caller.
4527        let aad = compute_aad(&auth);
4528        let stale = pack_cursor_token(&[7u8; 32], &aad, b"s", &TEST_CALL_ID, 0);
4529        let err = s.unpack_cursor_token(&auth, &stale).unwrap_err();
4530        assert!(err.message.contains("expired"), "got: {}", err.message);
4531    }
4532
4533    #[tokio::test]
4534    async fn unpack_rejects_different_principal() {
4535        let s = state_with_key();
4536        let alice = crate::auth::AuthContext::for_principal("bearer", "alice");
4537        let bob = crate::auth::AuthContext::for_principal("bearer", "bob");
4538        let tok = s.pack_cursor_token(&alice, b"s", &TEST_CALL_ID);
4539        assert!(s.unpack_cursor_token(&alice, &tok).is_ok());
4540        assert!(s.unpack_cursor_token(&bob, &tok).is_err());
4541        let anon = crate::auth::AuthContext::anonymous();
4542        assert!(s.unpack_cursor_token(&anon, &tok).is_err());
4543    }
4544
4545    #[tokio::test]
4546    async fn unpack_rejects_authenticated_replay_of_anonymous_token() {
4547        let s = state_with_key();
4548        let anon = crate::auth::AuthContext::anonymous();
4549        let alice = crate::auth::AuthContext::for_principal("bearer", "alice");
4550        let tok = s.pack_cursor_token(&anon, b"s", &TEST_CALL_ID);
4551        assert!(s.unpack_cursor_token(&alice, &tok).is_err());
4552    }
4553
4554    #[tokio::test]
4555    async fn unpack_rejects_cross_domain_replay() {
4556        let s = state_with_key();
4557        let bearer_alice = crate::auth::AuthContext::for_principal("bearer", "alice");
4558        let mtls_alice = crate::auth::AuthContext::for_principal("mtls", "alice");
4559        let tok = s.pack_cursor_token(&bearer_alice, b"s", &TEST_CALL_ID);
4560        assert!(s.unpack_cursor_token(&mtls_alice, &tok).is_err());
4561    }
4562
4563    #[tokio::test]
4564    async fn decompress_rejects_oversize() {
4565        let hdr = HeaderMap::new();
4566        let body = Bytes::from(vec![0u8; 1025]);
4567        let err = super::maybe_decompress(&hdr, &body, 1024).unwrap_err();
4568        assert!(err.message.contains("exceeds max size"));
4569    }
4570
4571    #[test]
4572    fn zstd_bounded_rejects_zip_bomb_without_full_alloc() {
4573        // 8 MiB of zeroes compresses to a tiny payload — small enough to
4574        // pass the encoded-size check but it would blow past the limit
4575        // when fully decompressed.
4576        let huge = vec![0u8; 8 * 1024 * 1024];
4577        let compressed = zstd::encode_all(huge.as_slice(), 1).unwrap();
4578        assert!(compressed.len() < 100_000, "compressed should be tiny");
4579        let err =
4580            super::decode_content_encoding(&compressed, Some("zstd"), Some(64 * 1024)).unwrap_err();
4581        assert!(
4582            err.message.contains("exceeds max size"),
4583            "expected oversize error, got: {}",
4584            err.message
4585        );
4586    }
4587
4588    #[test]
4589    fn zstd_bounded_passes_small_payload() {
4590        let small = b"hello-world".repeat(10);
4591        let compressed = zstd::encode_all(small.as_slice(), 1).unwrap();
4592        let out = super::decode_content_encoding(&compressed, Some("zstd"), Some(1024)).unwrap();
4593        assert_eq!(out, small);
4594    }
4595
4596    #[test]
4597    fn zstd_bounded_rejects_large_window_for_small_output() {
4598        use std::io::Write;
4599
4600        let mut encoder = zstd::stream::Encoder::new(Vec::new(), 1).unwrap();
4601        encoder.window_log(20).unwrap();
4602        encoder.include_contentsize(false).unwrap();
4603        encoder.write_all(b"tiny").unwrap();
4604        let frame = encoder.finish().unwrap();
4605
4606        let err = super::decode_content_encoding(&frame, Some("zstd"), Some(1024))
4607            .expect_err("a 1 MiB history window must exceed the 512 KiB decoder floor");
4608        assert!(
4609            err.message.contains("window") || err.message.contains("memory"),
4610            "expected window-limit error, got: {}",
4611            err.message
4612        );
4613    }
4614
4615    fn gzip(data: &[u8]) -> Vec<u8> {
4616        use std::io::Write;
4617        let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
4618        enc.write_all(data).unwrap();
4619        enc.finish().unwrap()
4620    }
4621
4622    #[test]
4623    fn decode_content_encoding_handles_gzip() {
4624        let payload = b"hello-gzip".repeat(10);
4625        let out = super::decode_content_encoding(&gzip(&payload), Some("gzip"), None).unwrap();
4626        assert_eq!(out, payload);
4627    }
4628
4629    #[test]
4630    fn decode_content_encoding_bounds_gzip_zip_bombs() {
4631        let huge = vec![0u8; 8 * 1024 * 1024];
4632        let compressed = gzip(&huge);
4633        assert!(compressed.len() < 100_000, "compressed should be tiny");
4634        let err =
4635            super::decode_content_encoding(&compressed, Some("gzip"), Some(64 * 1024)).unwrap_err();
4636        assert!(err.message.contains("exceeds max size"));
4637    }
4638
4639    #[test]
4640    fn decode_content_encoding_applies_a_coding_list_in_reverse() {
4641        // `Content-Encoding: gzip, zstd` means gzip was applied first, then zstd.
4642        let payload = b"layered".repeat(20);
4643        let body = zstd::encode_all(gzip(&payload).as_slice(), 1).unwrap();
4644        let out = super::decode_content_encoding(&body, Some("gzip, zstd"), None).unwrap();
4645        assert_eq!(out, payload);
4646    }
4647
4648    #[test]
4649    fn decode_content_encoding_refuses_an_absurd_coding_chain() {
4650        let chain = std::iter::repeat_n("zstd", super::MAX_CONTENT_CODINGS + 1)
4651            .collect::<Vec<_>>()
4652            .join(", ");
4653        let err = super::decode_content_encoding(b"x", Some(&chain), Some(1024)).unwrap_err();
4654        assert!(err.message.contains("more than"), "got: {}", err.message);
4655    }
4656
4657    #[test]
4658    fn decode_content_encoding_allows_a_chain_up_to_the_cap() {
4659        // gzip applied twice, then zstd: 3 codings, under the cap.
4660        let payload = b"bounded".repeat(8);
4661        let body = zstd::encode_all(gzip(&gzip(&payload)).as_slice(), 1).unwrap();
4662        let out = super::decode_content_encoding(&body, Some("gzip, gzip, zstd"), Some(64 * 1024))
4663            .unwrap();
4664        assert_eq!(out, payload);
4665    }
4666
4667    #[test]
4668    fn decode_content_encoding_passes_through_identity_and_unknown() {
4669        let raw = b"plain bytes";
4670        for header in [None, Some(""), Some("identity"), Some("br")] {
4671            let out = super::decode_content_encoding(raw, header, None).unwrap();
4672            assert_eq!(out, raw, "header {header:?} should pass through");
4673        }
4674    }
4675
4676    // ---- response-codec negotiation ------------------------------------
4677    //
4678    // `pick_response_encoding` walks the *client's* stated order over the
4679    // merged `X-VGI-Accept-Encoding ++ Accept-Encoding` list. The tests below
4680    // pin the ordering, parsing, and which response header carries the answer.
4681
4682    /// Build a `HeaderMap` from `(name, value)` pairs.
4683    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
4684        let mut h = HeaderMap::new();
4685        for (k, v) in pairs {
4686            h.insert(
4687                axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
4688                HeaderValue::from_str(v).unwrap(),
4689            );
4690        }
4691        h
4692    }
4693
4694    #[test]
4695    fn parse_encoding_list_is_ordered_deduped_and_ignores_q_values() {
4696        use super::ResponseEncoding::*;
4697        assert_eq!(super::parse_encoding_list(None), vec![]);
4698        assert_eq!(super::parse_encoding_list(Some("")), vec![]);
4699        assert_eq!(super::parse_encoding_list(Some("  ,  ,")), vec![]);
4700        // Unknown tokens are skipped, known ones keep the client's order.
4701        assert_eq!(
4702            super::parse_encoding_list(Some("deflate, gzip, br, zstd")),
4703            vec![Gzip, Zstd]
4704        );
4705        // q-values are parsed off and ignored — not honoured as weights.
4706        assert_eq!(
4707            super::parse_encoding_list(Some("zstd;q=0.5, gzip")),
4708            vec![Zstd, Gzip]
4709        );
4710        // Case-insensitive, whitespace-tolerant, first occurrence wins.
4711        assert_eq!(
4712            super::parse_encoding_list(Some(" ZSTD , gzip ,zstd")),
4713            vec![Zstd, Gzip]
4714        );
4715        // `identity` is a first-class token, ordered like any other.
4716        assert_eq!(
4717            super::parse_encoding_list(Some("identity, zstd")),
4718            vec![Identity, Zstd]
4719        );
4720        // `identity;q=0` is not special-cased — q is parsed off and ignored,
4721        // so order alone decides and identity still leads.
4722        assert_eq!(
4723            super::parse_encoding_list(Some("identity;q=0, zstd")),
4724            vec![Identity, Zstd]
4725        );
4726    }
4727
4728    #[test]
4729    fn custom_header_alone_negotiates_and_stamps_the_custom_response_header() {
4730        // The browser/WASM case: `fetch()` cannot set `Accept-Encoding`, so
4731        // the only preference the server sees is the custom header. Before
4732        // this negotiation existed such a client always got identity.
4733        let h = headers(&[("x-vgi-accept-encoding", "zstd, gzip")]);
4734        let (enc, used_custom) = super::pick_response_encoding(&h, Some(3)).unwrap();
4735        assert_eq!(enc, super::ResponseEncoding::Zstd);
4736        assert!(
4737            used_custom,
4738            "zstd came only from the custom header, so the custom response \
4739             header must carry it"
4740        );
4741    }
4742
4743    #[test]
4744    fn custom_header_wins_over_a_gzip_first_standard_header() {
4745        // The cpp-httplib regression case: the DuckDB engine's HTTP client
4746        // injects `Accept-Encoding: deflate, gzip, br, zstd` (gzip first)
4747        // while VGI states zstd first. zstd must win, and because it is in
4748        // both lists the answer rides the standard `Content-Encoding`.
4749        let h = headers(&[
4750            ("accept-encoding", "deflate, gzip, br, zstd"),
4751            ("x-vgi-accept-encoding", "zstd, gzip"),
4752        ]);
4753        let (enc, used_custom) = super::pick_response_encoding(&h, Some(3)).unwrap();
4754        assert_eq!(enc, super::ResponseEncoding::Zstd);
4755        assert!(
4756            !used_custom,
4757            "zstd is in the standard header too, so stamp Content-Encoding"
4758        );
4759    }
4760
4761    #[test]
4762    fn no_encoding_headers_means_uncompressed() {
4763        assert!(super::pick_response_encoding(&HeaderMap::new(), Some(3)).is_none());
4764    }
4765
4766    #[test]
4767    fn an_unknown_codec_means_uncompressed() {
4768        for pairs in [
4769            &[("accept-encoding", "br, deflate")][..],
4770            &[("x-vgi-accept-encoding", "br")][..],
4771        ] {
4772            let h = headers(pairs);
4773            assert!(
4774                super::pick_response_encoding(&h, Some(3)).is_none(),
4775                "unexpected codec chosen for {pairs:?}"
4776            );
4777        }
4778    }
4779
4780    #[test]
4781    fn compression_disabled_means_uncompressed_whatever_the_client_offers() {
4782        // The configuration gate is part of "producible": default-off stays
4783        // off no matter how the client asks.
4784        let h = headers(&[
4785            ("accept-encoding", "zstd"),
4786            ("x-vgi-accept-encoding", "zstd"),
4787        ]);
4788        assert!(!matches!(
4789            super::pick_response_encoding(&h, None),
4790            Some((super::ResponseEncoding::Zstd, _))
4791        ));
4792    }
4793
4794    #[test]
4795    fn standard_header_alone_still_negotiates_on_the_standard_response_header() {
4796        let h = headers(&[("accept-encoding", "zstd, identity")]);
4797        let (enc, used_custom) = super::pick_response_encoding(&h, Some(3)).unwrap();
4798        assert_eq!(enc, super::ResponseEncoding::Zstd);
4799        assert!(!used_custom);
4800    }
4801
4802    #[test]
4803    fn gzip_only_offer_negotiates_and_round_trips() {
4804        use std::io::Read;
4805
4806        let h = headers(&[("accept-encoding", "gzip")]);
4807        let (enc, used_custom) = super::pick_response_encoding(&h, Some(3)).unwrap();
4808        assert_eq!(enc, super::ResponseEncoding::Gzip);
4809        assert!(!used_custom);
4810
4811        let body = b"gzip-response".repeat(1024);
4812        let encoded = super::encode_response_body(enc, &body, 3).unwrap();
4813        assert!(encoded.len() < body.len());
4814        let mut decoded = Vec::new();
4815        flate2::read::GzDecoder::new(encoded.as_slice())
4816            .read_to_end(&mut decoded)
4817            .unwrap();
4818        assert_eq!(decoded, body);
4819    }
4820
4821    #[test]
4822    fn q_values_do_not_reorder_the_walk() {
4823        // `zstd;q=0.5, gzip` — a q-value-honouring parser would prefer gzip.
4824        // We parse q off and keep the stated order, so zstd stays first.
4825        let h = headers(&[("x-vgi-accept-encoding", "zstd;q=0.5, gzip")]);
4826        let (enc, _) = super::pick_response_encoding(&h, Some(3)).unwrap();
4827        assert_eq!(enc, super::ResponseEncoding::Zstd);
4828    }
4829
4830    #[test]
4831    fn identity_first_wins_and_suppresses_compression() {
4832        // `identity` is always producible, so reaching it first ends the walk
4833        // — an explicit, uniform way for a client to turn compression off for
4834        // one request. Encoding it produces no compressed form, so the caller
4835        // ships the body as-is and stamps no header.
4836        for pairs in [
4837            &[("x-vgi-accept-encoding", "identity")][..],
4838            &[
4839                ("x-vgi-accept-encoding", "identity"),
4840                ("accept-encoding", "gzip, zstd"),
4841            ][..],
4842            &[("x-vgi-accept-encoding", "identity, zstd")][..],
4843            &[("accept-encoding", "identity, gzip")][..],
4844        ] {
4845            let h = headers(pairs);
4846            let (enc, _) = super::pick_response_encoding(&h, Some(3)).unwrap();
4847            assert_eq!(
4848                enc,
4849                super::ResponseEncoding::Identity,
4850                "identity should win for {pairs:?}"
4851            );
4852            assert!(
4853                super::encode_response_body(enc, &vec![0u8; 4096], 3).is_none(),
4854                "identity must never produce a compressed body"
4855            );
4856        }
4857    }
4858
4859    #[test]
4860    fn identity_after_a_producible_codec_does_not_win() {
4861        let h = headers(&[("x-vgi-accept-encoding", "zstd, identity")]);
4862        let (enc, used_custom) = super::pick_response_encoding(&h, Some(3)).unwrap();
4863        assert_eq!(enc, super::ResponseEncoding::Zstd);
4864        assert!(used_custom);
4865        // A producible gzip offer ahead of identity wins.
4866        let h = headers(&[("x-vgi-accept-encoding", "gzip, identity, zstd")]);
4867        let (enc, _) = super::pick_response_encoding(&h, Some(3)).unwrap();
4868        assert_eq!(enc, super::ResponseEncoding::Gzip);
4869    }
4870
4871    #[test]
4872    fn supported_encodings_is_the_both_directions_intersection() {
4873        assert_eq!(
4874            super::supported_encodings_header_value(Some(3)),
4875            "zstd, gzip"
4876        );
4877        // Compression off — the default — advertises an empty list, not an
4878        // absent header. Present-but-empty means "I speak no compression".
4879        assert_eq!(super::supported_encodings_header_value(None), "");
4880        // `identity` is never advertised: always available, no information.
4881        assert!(!super::supported_encodings_header_value(Some(3)).contains("identity"));
4882    }
4883
4884    #[test]
4885    fn decode_hex_key_roundtrip() {
4886        let key =
4887            decode_hex_key("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
4888                .unwrap();
4889        assert_eq!(key.len(), 32);
4890        assert_eq!(key[0], 0x00);
4891        assert_eq!(key[31], 0x1f);
4892    }
4893
4894    #[test]
4895    fn decode_hex_key_rejects_short() {
4896        assert!(decode_hex_key("deadbeef").is_err());
4897    }
4898
4899    #[test]
4900    fn decode_hex_key_rejects_bad_char() {
4901        assert!(decode_hex_key(&"zz".repeat(32)).is_err());
4902    }
4903
4904    #[test]
4905    fn decode_base64_key_accepts_padded() {
4906        let s = base64::engine::general_purpose::STANDARD.encode([7u8; 32]);
4907        let out = decode_base64_key(&s).unwrap();
4908        assert_eq!(out, vec![7u8; 32]);
4909    }
4910
4911    #[test]
4912    fn decode_base64_key_accepts_unpadded() {
4913        let s = base64::engine::general_purpose::STANDARD
4914            .encode([7u8; 32])
4915            .trim_end_matches('=')
4916            .to_string();
4917        let out = decode_base64_key(&s).unwrap();
4918        assert_eq!(out, vec![7u8; 32]);
4919    }
4920
4921    #[test]
4922    fn decode_base64_key_rejects_short() {
4923        let s = base64::engine::general_purpose::STANDARD.encode(b"short");
4924        assert!(decode_base64_key(&s).is_err());
4925    }
4926
4927    #[tokio::test]
4928    async fn token_key_hex_round_trips_through_token() {
4929        use crate::server::RpcServer;
4930        let server = Arc::new(RpcServer::builder().server_id("t").build());
4931        let a = HttpState::builder()
4932            .server(server.clone())
4933            .token_key_hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
4934            .build();
4935        let b = HttpState::builder()
4936            .server(server)
4937            .token_key_hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
4938            .build();
4939        let auth = crate::auth::AuthContext::anonymous();
4940        let tok = a.pack_cursor_token(&auth, b"s", &TEST_CALL_ID);
4941        assert_eq!(
4942            b.unpack_cursor_token(&auth, &tok).unwrap().call_id,
4943            TEST_CALL_ID
4944        );
4945    }
4946
4947    #[test]
4948    fn response_buffer_ceiling_never_below_hard_cap() {
4949        use crate::server::RpcServer;
4950        let mk = |soft: Option<usize>| {
4951            let server = Arc::new(RpcServer::builder().server_id("t").build());
4952            let mut b = HttpState::builder().server(server).token_key(&[9u8; 32]);
4953            if let Some(n) = soft {
4954                b = b.max_response_bytes(n);
4955            }
4956            b.build()
4957        };
4958        // No soft cap → exactly the hard cap.
4959        assert_eq!(
4960            response_buffer_ceiling(&mk(None)),
4961            MAX_RESPONSE_BYTES_HARD_CAP
4962        );
4963        // A small soft cap must NOT shrink the middleware ceiling — the
4964        // soft cap is a producer knob the wire may legitimately
4965        // overshoot.
4966        assert_eq!(
4967            response_buffer_ceiling(&mk(Some(8))),
4968            MAX_RESPONSE_BYTES_HARD_CAP
4969        );
4970        // A soft cap larger than the hard cap leaves overshoot headroom.
4971        let big = MAX_RESPONSE_BYTES_HARD_CAP;
4972        assert_eq!(response_buffer_ceiling(&mk(Some(big))), big * 2);
4973    }
4974}