camel_component_wasm/source_host.rs
1//! Source host state, linker functions, and host imports.
2//!
3//! This module provides:
4//! - [`SourceHostState`]: host state for the source world (separate from `WasmHostState`)
5//! - [`SourceChannels`]: bounded tokio channels bridging async guest imports to host tasks
6//! - `HostWithStore` impl for async `accept-http`, `submit-exchange`; sync `Host` impl for
7//! `is-cancelled`
8//! - `run_http_listener`: async axum task feeding HTTP requests into the channel
9//! - [`run_pipeline_bridge`]: async task forwarding exchanges to the pipeline
10
11use std::sync::Arc;
12
13use bytes::Bytes;
14use tokio::sync::{Notify, mpsc, oneshot};
15use tokio_util::sync::CancellationToken;
16use wasmtime::component::{Accessor, AccessorTask, HasSelf, Linker, Resource, ResourceTable};
17
18use camel_api::security_policy::{AccessMode, RouteSecurityPlan};
19use camel_api::{Body, CamelError, Exchange, ExchangePattern, Message, StreamBody, Value};
20use camel_auth::AuthenticatedPrincipal;
21use camel_auth::ProviderRegistry;
22use camel_component_api::SecurityContext;
23use camel_component_api::consumer::ConsumerContext;
24
25use crate::return_stream::{
26 DEFAULT_DRAIN_CHANNEL_BOUND, DrainCoord, DrainEvent, StreamReturnable, TerminalSlot,
27 drain_guest_stream, receiver_to_body_stream, write_terminal,
28};
29use crate::source_auth_edge::{EdgeAuthOutcome, authenticate_edge};
30use crate::source_bindings::camel::plugin::source_host::{HttpRequest, SubmitOutcome};
31use crate::source_bindings::camel::plugin::types::{
32 WasmBody, WasmExchange, WasmMessage, WasmPattern,
33};
34
35/// Kernel authentication state captured by [`WasmSourceConsumer`] when the
36/// route controller delivers the route's [`SecurityContext`] before
37/// `start()` (`wasm-source-auth-kernel`, Task 1.3).
38///
39/// Construction-order lifecycle: the compiled plan and the provider registry
40/// arrive from the route's security context through
41/// `Consumer::set_security_context` — before the listener binds and before
42/// any request reaches the guest. A context lacking either piece leaves the
43/// handshake unwired: the classification plan is still retained separately
44/// by the consumer, so a non-Public classification without kernel state
45/// fails closed per request (Task 2.2) rather than degrading to Public.
46// Fields are read by the bind-gate snapshot (Task 1.4) and the host-edge
47// handshake (Task 2.2 — plan via credential extraction + kernel mint,
48// providers via `kernel_authenticate`).
49pub(crate) struct WasmSourceKernelAuth {
50 pub(crate) plan: RouteSecurityPlan,
51 pub(crate) providers: Arc<ProviderRegistry>,
52}
53
54impl WasmSourceKernelAuth {
55 /// Capture the kernel state from a route's security context.
56 ///
57 /// `None` unless both the compiled plan and the provider registry are
58 /// present: a plan without providers can never mint a principal, and a
59 /// registry without a plan has nothing to enforce.
60 pub(crate) fn from_security_context(ctx: &SecurityContext) -> Option<Self> {
61 Some(Self {
62 plan: ctx.plan.clone()?,
63 providers: ctx.providers.clone()?,
64 })
65 }
66}
67
68/// Concrete type for the http-listener resource in the ResourceTable.
69/// Stateless — the handle is a marker that the guest holds while running.
70/// Mapped via `with:` in source_bindings.rs to replace the empty enum
71/// generated by bindgen for the `resource http-listener` declaration.
72pub struct HttpListenerHandle;
73
74/// Materialized HTTP request metadata (everything except the body).
75///
76/// Sent through the request channel alongside a per-request body receiver.
77/// The body itself is streamed incrementally through the receiver — never
78/// materialized as a single `Vec<u8>` — so large payloads are not capped.
79pub struct HttpMeta {
80 pub method: String,
81 pub path: String,
82 pub headers: Vec<(String, String)>,
83 /// Principal minted at the host edge before the request entered the
84 /// channel (Task 2.1; the edge handshake that mints it lands in Task
85 /// 2.2). Private on purpose: the guest never observes it — the
86 /// `accept_http` conversion maps only the public fields into the WIT
87 /// `HttpRequest` — and `accept_http` stashes it into
88 /// [`SourceHostState::pending_principal`] for the Exchange that
89 /// `submit-exchange` assembles next.
90 principal: Option<AuthenticatedPrincipal>,
91}
92
93/// Per-request body chunk receiver. The host's axum handler streams body
94/// frames into the matching `mpsc::Sender`; the guest reads via the
95/// `stream-body-handle` minted by `accept_http`.
96type BodyChunkRx = mpsc::Receiver<Result<Bytes, CamelError>>;
97
98/// Request channel payload: metadata + body streaming receiver.
99type RequestChannelItem = (HttpMeta, BodyChunkRx);
100
101// ─── Channel capacity constants ───────────────────────────────────────────
102
103/// Capacity for the HTTP request channel (host listener → guest).
104///
105/// 1 propagates backpressure all the way to the HTTP client: the axum handler
106/// `.send().await`s onto this channel, so once the guest is busy draining a
107/// prior exchange into the (also capacity-1) pipeline, further inbound
108/// requests park on the send until the guest calls `accept-http` again. A
109/// larger buffer would let a burst of clients receive 202 responses for work
110/// the pipeline has not yet accepted, silently decoupling client-visible
111/// success from actual delivery — undesirable for a source that must not
112/// acknowledge messages it may drop on shutdown.
113pub const REQUEST_CHANNEL_CAPACITY: usize = 1;
114
115/// Capacity for the exchange channel (guest → pipeline bridge).
116/// 1 enforces strict backpressure — guest blocks until pipeline accepts.
117pub const EXCHANGE_CHANNEL_CAPACITY: usize = 1;
118
119// ─── SourceChannels ───────────────────────────────────────────────────────
120
121/// Paired channel endpoints for bridging the guest to async host tasks.
122///
123/// Created once per source consumer instance. The `*_tx` halves go to the
124/// HTTP listener and pipeline bridge tasks; `request_rx` lives in
125/// [`SourceHostState`] inside the wasmtime `Store`, wrapped in an
126/// `Arc<tokio::sync::Mutex<…>>` so the async host import (which receives only
127/// `&Accessor`, not `&mut self`) can `lock().await` it to call `recv().await`.
128///
129/// The request channel carries `(HttpMeta, body_rx)` — metadata plus a
130/// per-request bounded channel streaming body chunks. The body is never
131/// materialized as a single `Vec<u8>`, removing the old 10 MiB cap.
132pub struct SourceChannels {
133 pub request_tx: mpsc::Sender<RequestChannelItem>,
134 pub request_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<RequestChannelItem>>>,
135 pub exchange_tx: mpsc::Sender<(Exchange, oneshot::Sender<SubmitOutcome>)>,
136 pub exchange_rx: mpsc::Receiver<(Exchange, oneshot::Sender<SubmitOutcome>)>,
137}
138
139impl SourceChannels {
140 /// Create a new set of channels with the documented capacities.
141 pub fn new() -> Self {
142 let (request_tx, request_rx) = mpsc::channel(REQUEST_CHANNEL_CAPACITY);
143 let (exchange_tx, exchange_rx) = mpsc::channel(EXCHANGE_CHANNEL_CAPACITY);
144 Self {
145 request_tx,
146 request_rx: Arc::new(tokio::sync::Mutex::new(request_rx)),
147 exchange_tx,
148 exchange_rx,
149 }
150 }
151}
152
153impl Default for SourceChannels {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159// ─── SourceHostState ──────────────────────────────────────────────────────
160
161/// Default maximum request body size in bytes (10 MiB).
162///
163/// Matches [`camel_api::body::DEFAULT_MATERIALIZE_LIMIT`] — the same cap the
164/// old `to_bytes` path enforced. Restores the DoS backstop that was
165/// inadvertently removed when the body switched from materialized to streamed.
166pub const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 10 * 1024 * 1024;
167
168/// Host state for a source consumer. Lives inside the `wasmtime::Store`.
169///
170/// Separate from [`crate::runtime::WasmHostState`] because the source world
171/// has a fundamentally different lifecycle: the guest IS the source, owns a
172/// run loop, and communicates via channels rather than direct call-process.
173///
174/// `request_rx` is wrapped in `Arc<tokio::sync::Mutex<…>>` because the async
175/// host imports receive `&Accessor` (shared access), not `&mut self`; the
176/// Mutex provides the interior mutability needed to `recv().await` the
177/// receiver across import calls.
178pub struct SourceHostState {
179 pub table: ResourceTable,
180 pub wasi: wasmtime_wasi::WasiCtx,
181 pub request_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<RequestChannelItem>>>,
182 pub exchange_tx: mpsc::Sender<(Exchange, oneshot::Sender<SubmitOutcome>)>,
183 pub cancel_token: CancellationToken,
184 /// Maximum bytes the guest may read from a single request body before
185 /// the stream producer terminates with an overflow error. Restores the
186 /// DoS backstop that the old `to_bytes(MAX_BODY_BYTES)` path provided.
187 pub max_request_body_bytes: u64,
188 /// Principal minted at the host edge for the most recently accepted,
189 /// not-yet-submitted request (Task 2.1). Stashed by `accept_http`,
190 /// consumed by `submit_exchange` when the typed carrier is installed on
191 /// the native Exchange. `None` while no request is in flight or when the
192 /// edge handshake minted no principal.
193 pub pending_principal: Option<AuthenticatedPrincipal>,
194 /// One-outstanding-request invariant marker: an `accept-http` returned
195 /// without an intervening `submit-exchange`. While set, a further
196 /// `accept-http` fails closed instead of overwriting
197 /// `pending_principal` — the WIT contract does not force accept/submit
198 /// alternation, so the host enforces it for identity integrity.
199 pub accept_outstanding: bool,
200}
201
202impl wasmtime_wasi::WasiView for SourceHostState {
203 fn ctx(&mut self) -> wasmtime_wasi::WasiCtxView<'_> {
204 wasmtime_wasi::WasiCtxView {
205 ctx: &mut self.wasi,
206 table: &mut self.table,
207 }
208 }
209}
210
211// ─── Pending-principal slot (Task 2.1) ─────────────────────────────────────
212
213impl SourceHostState {
214 /// Stash the principal of a just-accepted request and mark the
215 /// accept/submit slot outstanding.
216 ///
217 /// One-outstanding-request invariant: the WIT contract does not force
218 /// accept/submit alternation, and a buggy guest may accept twice — the
219 /// host must not let a second accept overwrite the first request's
220 /// identity. Returns `Err` (fail closed) when an accept is already
221 /// outstanding; the caller surfaces it as the WIT error result for the
222 /// second accept. `Ok` replaces the slot unconditionally (the previous
223 /// request, if any, was already submitted and consumed it).
224 fn stash_pending_principal(
225 &mut self,
226 principal: Option<AuthenticatedPrincipal>,
227 ) -> Result<(), &'static str> {
228 if self.accept_outstanding {
229 return Err(
230 "accept-http rejected: a previous request is outstanding; submit-exchange it before accepting again",
231 );
232 }
233 self.accept_outstanding = true;
234 self.pending_principal = principal;
235 Ok(())
236 }
237
238 /// Take the stashed principal, install it as the submitted Exchange's
239 /// typed carrier, and clear the outstanding marker — the slot is free
240 /// for the next accept.
241 ///
242 /// Called by `submit-exchange` after the native Exchange is assembled
243 /// and before it is sent down the pipeline channel.
244 fn install_pending_carrier(&mut self, exchange: &mut Exchange) {
245 if let Some(principal) = self.pending_principal.take() {
246 camel_auth::install_carrier(exchange, &principal);
247 }
248 self.accept_outstanding = false;
249 }
250
251 /// Clear the pending-principal slot and the outstanding marker after a
252 /// failed accept (body-assembly failure): the request never reaches the
253 /// guest, so the slot must not poison a follow-up accept under the
254 /// one-outstanding-request invariant.
255 fn abort_pending_accept(&mut self) {
256 self.pending_principal = None;
257 self.accept_outstanding = false;
258 }
259}
260
261// ─── Host trait implementation ────────────────────────────────────────────
262
263type SourceWasmError = crate::source_bindings::camel::plugin::types::WasmError;
264
265// The http-listener resource has no methods in WIT, but wasmtime bindgen
266// generates a HostHttpListener trait that must be implemented.
267impl crate::source_bindings::camel::plugin::source_host::HostHttpListener for SourceHostState {
268 fn drop(
269 &mut self,
270 _resource: wasmtime::component::Resource<HttpListenerHandle>,
271 ) -> wasmtime::Result<()> {
272 // HttpListenerHandle is stateless; nothing to clean up.
273 Ok(())
274 }
275}
276
277impl crate::source_bindings::camel::plugin::source_host::Host for SourceHostState {
278 fn is_cancelled(&mut self) -> bool {
279 // Stays sync (a `with`-style peek that must not yield). Returns
280 // immediately so tight guest loops can poll cancellation cheaply.
281 self.cancel_token.is_cancelled()
282 }
283}
284
285/// Async host imports. The guest's `run` (async) awaits these; each receives
286/// `&Accessor<SourceHostState, HasSelf<SourceHostState>>` (shared access into
287/// the store, NOT `&mut self`). We snapshot owned values (clones) out of a
288/// `with` call, then `.await` OUTSIDE it — `Accessor::with` is sync and must
289/// not be held across an await. Cancellation races every await against the
290/// shared `CancellationToken` so `stop()` unblocks a parked import promptly.
291impl crate::source_bindings::camel::plugin::source_host::HostWithStore<SourceHostState>
292 for wasmtime::component::HasSelf<SourceHostState>
293{
294 async fn accept_http(
295 accessor: &Accessor<SourceHostState, wasmtime::component::HasSelf<SourceHostState>>,
296 _listener: Resource<HttpListenerHandle>,
297 ) -> Result<Option<HttpRequest>, SourceWasmError> {
298 // Snapshot clones via `with`, then await outside. request_rx is an
299 // Arc<Mutex<Receiver>>; cloning the Arc is cheap and lets us lock it
300 // across the await without borrowing the accessor.
301 // Single `with` snapshot — one TLS lookup for all three fields.
302 let (request_rx, cancel_token, max_body) = accessor.with(|mut view| {
303 let state = view.get();
304 (
305 state.request_rx.clone(),
306 state.cancel_token.clone(),
307 state.max_request_body_bytes,
308 )
309 });
310
311 // Uncontended lock: the guest runs a single sequential run loop, so
312 // only one accept_http is in flight at a time.
313 let mut guard = request_rx.lock().await;
314 let meta_and_body = tokio::select! {
315 r = guard.recv() => r,
316 _ = cancel_token.cancelled() => {
317 return Ok(None);
318 }
319 };
320 // Drop the lock before minting the StreamReader (needs accessor.with).
321 drop(guard);
322
323 // None => channel closed (listener exited); treat as cancellation.
324 let Some((mut meta, mut body_rx)) = meta_and_body else {
325 return Ok(None);
326 };
327
328 // One-outstanding-request invariant (Task 2.1): stash the
329 // edge-minted principal for the Exchange `submit-exchange` assembles
330 // next. A second accept while one is outstanding fails closed — the
331 // earlier request's identity is never overwritten.
332 if let Err(msg) =
333 accessor.with(|mut view| view.get().stash_pending_principal(meta.principal.take()))
334 {
335 // log-policy: handler-owned — the violation belongs to the guest.
336 tracing::warn!("source: accept-http invariant: {msg}");
337 return Err(SourceWasmError::ProcessorError(msg.to_string()));
338 }
339
340 // Build a BoxStream from the per-request body channel via poll_fn.
341 // NOT receiver_to_body_stream — that is DrainEvent-specific (the
342 // submit-exchange direction). Here the channel carries
343 // Result<Bytes, CamelError> directly.
344 let box_stream: futures::stream::BoxStream<'static, Result<Bytes, CamelError>> =
345 Box::pin(futures::stream::poll_fn(move |cx| body_rx.poll_recv(cx)));
346
347 // Mint the StreamReader + terminal FutureReader inside accessor.with.
348 // assemble_stream_body_source takes &Accessor<S, U> and internally
349 // calls accessor.with for each reader creation.
350 let cancel = cancel_token.clone();
351 let handle = crate::stream_bridge::assemble_stream_body_source(
352 accessor, box_stream, cancel, max_body,
353 );
354 let request = match handle {
355 Ok(body) => Some(HttpRequest {
356 method: meta.method,
357 path: meta.path,
358 headers: meta.headers,
359 body,
360 }),
361 Err(e) => {
362 // log-policy: system-broken
363 tracing::error!("failed to assemble stream body for accept-http: {e}");
364 // The request is dropped on this failure return; clear the
365 // stashed principal slot and the outstanding marker so a
366 // follow-up accept is not poisoned by the one-outstanding
367 // invariant.
368 accessor.with(|mut view| view.get().abort_pending_accept());
369 None
370 }
371 };
372 Ok(request)
373 }
374
375 async fn submit_exchange(
376 accessor: &Accessor<SourceHostState, wasmtime::component::HasSelf<SourceHostState>>,
377 mut exchange: WasmExchange,
378 ) -> Result<SubmitOutcome, SourceWasmError> {
379 // Snapshot owned values (clones) + extract the stream (if any) inside a
380 // single `with`. The closure must return before `spawn` is called:
381 // `Accessor::spawn` internally calls `with`, which panics if nested
382 // (concurrent.rs:454). `take_stream` replaces a `WasmBody::Stream` with
383 // `Empty`, moving the live `StreamReader`/`FutureReader` out so the
384 // metadata conversion below does not trip over the non-cloneable handle.
385 let (exchange_tx, cancel_token, stream_parts) = accessor.with(|mut view| {
386 let state = view.get();
387 let exchange_tx = state.exchange_tx.clone();
388 let cancel_token = state.cancel_token.clone();
389 let stream_parts = exchange.take_stream();
390 (exchange_tx, cancel_token, stream_parts)
391 });
392 // `with` guard dropped here — safe to call `accessor.spawn` below.
393
394 // Build the native Exchange from the (now stream-stripped) metadata.
395 // The body is `Empty` for the stream case; it is overwritten with
396 // `Body::Stream` after the drain is spawned.
397 let mut native = source_exchange_to_native(exchange);
398
399 // Install the typed carrier from the principal stashed by
400 // accept-http (Task 2.1) BEFORE the exchange reaches the pipeline.
401 accessor.with(|mut view| view.get().install_pending_carrier(&mut native));
402
403 let (reply_tx, reply_rx) = oneshot::channel();
404
405 if let Some((stream_reader, terminal, stream_metadata)) = stream_parts {
406 // Streaming submit-exchange: fire-and-return.
407 //
408 // The guest emits a `stream<u8>` body. Reading a wasmtime stream
409 // requires the concurrent execution context (an `&Accessor`), which
410 // is only live while the event loop driving `run` polls. We CANNOT
411 // `tokio::spawn` a moved store + `run_concurrent` (the source
412 // world's `run` is already inside `run_concurrent`;
413 // `check_recursive_run` panics). Instead, `Accessor::spawn` queues
414 // the drain onto the SAME event loop, where it progresses
415 // concurrently with the guest fiber.
416 let (chunk_tx, chunk_rx) = mpsc::channel::<DrainEvent>(DEFAULT_DRAIN_CHANNEL_BOUND);
417 // Non-blocking terminal slot: written BEFORE sender drop so the
418 // consumer observes the terminal result after buffered chunks drain.
419 // Created ONCE and shared between the drain task and the body stream.
420 let source_terminal: TerminalSlot = Arc::new(std::sync::Mutex::new(None));
421 // progress/receiver_gone unused under source (no watchdog);
422 // required by shared drain_guest_stream signature.
423 let coord = DrainCoord {
424 cancel: cancel_token.clone(),
425 progress: Arc::new(Notify::new()),
426 receiver_gone: Arc::new(Notify::new()),
427 terminal_slot: source_terminal.clone(),
428 };
429
430 // Register the drain BEFORE sending the exchange. Ordering is
431 // load-bearing: the chunk-channel receiver only gets a consumer
432 // (the downstream `Body::Stream` reader) once the exchange is
433 // delivered. If we awaited drain completion before sending, the
434 // bounded chunk channel would fill, backpressure-stall the drain,
435 // and self-deadlock.
436 // wasmtime 48: Accessor::spawn is fallible (returns Err when the
437 // concurrent event loop is already gone). On failure the drain
438 // never runs, so the downstream body stream would hang forever —
439 // fail fast by writing the terminal error the drain would have
440 // written; chunk_tx drops inside spawn, closing the channel.
441 // write_terminal is poison-safe: the error always surfaces.
442 if let Err(spawn_err) = accessor.spawn(SubmitExchangeDrain {
443 stream_reader,
444 terminal,
445 chunk_tx,
446 coord,
447 }) {
448 write_terminal(
449 &source_terminal,
450 Err(CamelError::ProcessorError(format!(
451 "stream drain spawn failed: {spawn_err}"
452 ))),
453 );
454 }
455
456 // Attach the lazy body. Downstream reads from this receiver; the
457 // spawned drain feeds it from the guest's stream.
458 native.input.body = Body::Stream(StreamBody {
459 stream: Arc::new(tokio::sync::Mutex::new(Some(Box::pin(
460 receiver_to_body_stream(crate::return_stream::DrainReceiver {
461 rx: chunk_rx,
462 terminal: source_terminal,
463 }),
464 )))),
465 metadata: stream_metadata,
466 });
467 }
468
469 // Send the (native) exchange and await the pipeline's acceptance reply.
470 // submit-exchange returns once the pipeline ACCEPTS the envelope —
471 // before full body drain (fire-and-return). Race against cancellation
472 // so `stop()` unblocks a parked submit promptly.
473 let outcome = tokio::select! {
474 res = async {
475 if exchange_tx.send((native, reply_tx)).await.is_err() {
476 // Bridge gone (clean shutdown) — report Stopped.
477 return SubmitOutcome::Stopped;
478 }
479 // Pipeline bridge replies with the delivery outcome; a dropped
480 // reply (bridge exited after accept) is also a clean stop.
481 reply_rx.await.unwrap_or(SubmitOutcome::Stopped)
482 } => res,
483 _ = cancel_token.cancelled() => SubmitOutcome::Stopped,
484 };
485 Ok(outcome)
486 }
487}
488
489// ─── submit-exchange background drain (AccessorTask) ──────────────────────
490
491/// `AccessorTask` driving the guest→host body drain for a streaming
492/// `submit-exchange`.
493///
494/// Unlike the plugin/bean return path (`spawn_return_drain`), the source world
495/// CANNOT `tokio::spawn` a moved store and open a fresh `run_concurrent` — the
496/// source `run` is already inside `run_concurrent` and `check_recursive_run`
497/// panics on a nested call. `Accessor::spawn` instead registers this task on
498/// the SAME event loop already driving `run`, so the drain progresses
499/// concurrently with the guest fiber (and the guest's `spawn_local` writer).
500struct SubmitExchangeDrain {
501 stream_reader: wasmtime::component::StreamReader<u8>,
502 terminal: wasmtime::component::FutureReader<Result<(), SourceWasmError>>,
503 chunk_tx: mpsc::Sender<DrainEvent>,
504 coord: DrainCoord,
505}
506
507impl AccessorTask<SourceHostState, HasSelf<SourceHostState>> for SubmitExchangeDrain {
508 async fn run(
509 self,
510 accessor: &Accessor<SourceHostState, HasSelf<SourceHostState>>,
511 ) -> wasmtime::Result<()> {
512 drain_guest_stream::<SourceWasmError, SourceHostState>(
513 accessor,
514 self.stream_reader,
515 self.terminal,
516 self.chunk_tx,
517 self.coord,
518 )
519 .await;
520 // Terminal errors are surfaced through the chunk channel
521 // (`DrainEvent::Error`), not the task return — the downstream
522 // `Body::Stream` reader observes them. Returning `Ok` keeps the
523 // event loop's task accounting clean.
524 Ok(())
525 }
526}
527
528// ─── source WasmExchange → native Exchange conversion ──────────────────────
529
530/// Convert a (stream-stripped) source-world `WasmExchange` into a native
531/// [`Exchange`]. Inlines the `serde_bridge` field-mapping because the source
532/// bindings generate distinct types from the plugin bindings (TODO WIT-001).
533///
534/// A `WasmBody::Stream` never reaches here: `submit_exchange` calls
535/// `take_stream` (replacing it with `Empty`) before conversion and attaches a
536/// `Body::Stream` separately.
537fn source_exchange_to_native(wasm: WasmExchange) -> Exchange {
538 let input = source_message_to_native(wasm.input);
539 let mut exchange = Exchange::new(input);
540 // Preserve the guest's correlation_id (Exchange::new generates a random one).
541 if !wasm.correlation_id.is_empty() {
542 exchange.correlation_id = wasm.correlation_id;
543 }
544 if let Some(out) = wasm.output {
545 exchange.output = Some(source_message_to_native(out));
546 }
547 for (key, raw) in wasm.properties {
548 let value = serde_json::from_str::<Value>(&raw).unwrap_or(Value::String(raw));
549 exchange.properties.insert(key, value);
550 }
551 exchange.pattern = match wasm.pattern {
552 WasmPattern::InOnly => ExchangePattern::InOnly,
553 WasmPattern::InOut => ExchangePattern::InOut,
554 };
555 exchange
556}
557
558fn source_message_to_native(msg: WasmMessage) -> Message {
559 let headers = msg
560 .headers
561 .into_iter()
562 .map(|(k, raw)| {
563 let value = serde_json::from_str::<Value>(&raw).unwrap_or(Value::String(raw));
564 (k, value)
565 })
566 .collect();
567 Message {
568 headers,
569 body: source_body_to_native(msg.body),
570 }
571}
572
573fn source_body_to_native(body: WasmBody) -> Body {
574 match body {
575 WasmBody::Empty => Body::Empty,
576 WasmBody::Text(s) => Body::Text(s),
577 WasmBody::Bytes(v) => Body::Bytes(Bytes::from(v)),
578 WasmBody::Json(s) => serde_json::from_str::<Value>(&s)
579 .map(Body::Json)
580 .unwrap_or(Body::Text(s)),
581 WasmBody::Xml(s) => Body::Xml(s),
582 // Unreachable: `submit_exchange` extracts the stream via `take_stream`
583 // before conversion. Defensive guard (drop + warn) mirrors
584 // `serde_bridge::wasm_to_body`.
585 WasmBody::Stream(_) => {
586 tracing::warn!("source: undrained stream body reached conversion — dropped");
587 Body::Empty
588 }
589 }
590}
591
592// ─── Linker setup ─────────────────────────────────────────────────────────
593
594/// Register the source-host interface and WASI p2 into a linker for [`SourceHostState`].
595///
596/// Uses the ASYNCHRONOUS WASI linker: the guest's `run` export is async and is
597/// driven by `Store::run_concurrent` on a tokio task (see
598/// [`crate::source_consumer`]). Its async host imports (`accept-http`,
599/// `submit-exchange`) receive an `&Accessor` and use async channel ops.
600pub fn add_to_linker(linker: &mut Linker<SourceHostState>) -> Result<(), wasmtime::Error> {
601 // WASI p2 preview — register the command-adapter surface that the
602 // `wasm32-wasip2` source fixture imports (hardened ctx backs it with no
603 // resources). Filesystem and sockets stay unregistered (ADR-0050).
604 crate::wasi_surface::register_command_adapter_wasi(linker)?;
605
606 // Source-host interface (accept-http, submit-exchange, is-cancelled).
607 crate::source_bindings::camel::plugin::source_host::add_to_linker::<_, HasSelf<_>>(
608 linker,
609 |state| state,
610 )?;
611
612 Ok(())
613}
614
615// ─── HTTP listener task (async) ───────────────────────────────────────────
616
617/// Run an axum HTTP server that feeds incoming requests into `request_tx`.
618///
619/// The caller must have already bound the TCP listener (see
620/// [`crate::source_consumer::WasmSourceConsumer::start`]) so that a bind
621/// failure surfaces synchronously as a `start()` error rather than a
622/// background warning that leaves the route appearing healthy with no
623/// listener accepting requests.
624///
625/// Each request's body is streamed incrementally into a per-request bounded
626/// channel — the old 10 MiB `to_bytes` materialization cap is removed. The
627/// guest reads the body via the `stream-body-handle` returned by
628/// `accept-http`.
629///
630/// # Response timing (fire-and-forget semantic)
631///
632/// Returns **202 Accepted** once the request metadata is handed to the guest
633/// via the request channel. The body is streamed asynchronously *after* the
634/// response is sent — the handler does NOT wait for the full body before
635/// responding. This is inherent to the streaming shape: you cannot stream
636/// and wait for full receipt simultaneously. Mid-body connection drops
637/// surface as stream errors to the guest (the body channel receives an
638/// `Err` frame), not as HTTP-level failures to the client. The source
639/// accepts the request for processing; it does not guarantee full body
640/// receipt.
641///
642/// # Host-edge authentication (Task 2.2)
643///
644/// For non-Public classifications the handler runs the kernel handshake
645/// (see [`crate::source_auth_edge::authenticate_edge`]) BEFORE the request
646/// channel is touched: a denial renders 401 (`unauthenticated`) and returns
647/// without `tx.send` and without reading the body — the guest never wakes.
648///
649/// Shuts down gracefully when `cancel` is triggered.
650pub(crate) async fn run_http_listener(
651 listener: tokio::net::TcpListener,
652 path_filter: Option<String>,
653 request_tx: mpsc::Sender<RequestChannelItem>,
654 cancel: CancellationToken,
655 kernel: Option<Arc<WasmSourceKernelAuth>>,
656 plan_access: Option<AccessMode>,
657) -> Result<(), CamelError> {
658 use axum::Router;
659 use axum::extract::State;
660 use axum::http::Request;
661 use axum::response::Response;
662 use axum::routing::any;
663 use http_body_util::BodyExt;
664
665 struct ListenerState {
666 tx: mpsc::Sender<RequestChannelItem>,
667 cancel: CancellationToken,
668 kernel: Option<Arc<WasmSourceKernelAuth>>,
669 plan_access: Option<AccessMode>,
670 }
671
672 async fn handler(
673 State(state): State<Arc<ListenerState>>,
674 req: Request<axum::body::Body>,
675 ) -> Response {
676 let (parts, body) = req.into_parts();
677
678 // Host-edge kernel handshake (Task 2.2) BEFORE the request channel
679 // is touched. Driven by the classification (`plan_access`), never by
680 // kernel presence alone: absent/Public classifications pass through,
681 // non-Public ones must mint or deny (fail-closed without wiring).
682 // A denial returns without `tx.send` and without reading the body.
683 let principal = match authenticate_edge(
684 state.plan_access.as_ref(),
685 state.kernel.as_deref(),
686 &parts.headers,
687 &parts.uri,
688 )
689 .await
690 {
691 Ok(EdgeAuthOutcome::PassThrough) => None,
692 Ok(EdgeAuthOutcome::Authenticated(principal)) => Some(*principal),
693 Err(status) => {
694 return Response::builder()
695 .status(status)
696 .body(axum::body::Body::from("unauthenticated"))
697 .unwrap(); // allow-unwrap
698 }
699 };
700
701 // Per-request bounded channel for streaming the body to the guest.
702 // Capacity matches the drain-channel bound used elsewhere for
703 // guest→host streaming (return_stream.rs).
704 let (body_tx, body_rx) = mpsc::channel::<Result<Bytes, CamelError>>(
705 crate::return_stream::DEFAULT_DRAIN_CHANNEL_BOUND,
706 );
707
708 let http_meta = HttpMeta {
709 method: parts.method.to_string(),
710 path: parts.uri.path().to_string(),
711 headers: parts
712 .headers
713 .iter()
714 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
715 .collect(),
716 // Principal minted at the host edge above (Task 2.2); `None`
717 // for pass-through classifications and unauthenticated-free
718 // flows.
719 principal,
720 };
721
722 if state.tx.send((http_meta, body_rx)).await.is_err() {
723 return Response::builder()
724 .status(503)
725 .body(axum::body::Body::from("service unavailable"))
726 .unwrap(); // allow-unwrap
727 }
728
729 // Stream the axum body into body_tx incrementally. The spawned task
730 // owns the body and the sender; when the body is fully consumed (or
731 // errors), body_tx drops = EOF on the guest's StreamReader.
732 // Racing against cancel ensures the drain exits promptly on source
733 // shutdown rather than relying solely on connection-close propagation.
734 let drain_cancel = state.cancel.clone();
735 tokio::spawn(async move {
736 let mut body = body;
737 loop {
738 tokio::select! {
739 frame = body.frame() => {
740 match frame {
741 Some(Ok(frame)) => {
742 if let Ok(data) = frame.into_data()
743 && body_tx.send(Ok(data)).await.is_err()
744 {
745 // Guest stopped reading — abandon the stream.
746 break;
747 }
748 // Trailers frame: silently ignored (HTTP metadata, not body bytes).
749 }
750 Some(Err(e)) => {
751 let _ = body_tx
752 .send(Err(CamelError::Io(format!("body read: {e}"))))
753 .await;
754 break;
755 }
756 // None = clean EOF from hyper — body fully consumed.
757 None => break,
758 }
759 }
760 _ = drain_cancel.cancelled() => break,
761 }
762 }
763 // body_tx drops here → guest observes clean EOF.
764 });
765
766 Response::builder()
767 .status(202)
768 .body(axum::body::Body::from("accepted"))
769 .unwrap() // allow-unwrap
770 }
771
772 let state = Arc::new(ListenerState {
773 tx: request_tx,
774 cancel: cancel.clone(),
775 kernel,
776 plan_access,
777 });
778
779 let route_path = path_filter
780 .filter(|path| !path.is_empty())
781 .map(|path| {
782 if path.starts_with('/') {
783 path
784 } else {
785 format!("/{path}")
786 }
787 })
788 .unwrap_or_else(|| "/{*path}".to_string());
789
790 let app = Router::new()
791 .route(&route_path, any(handler))
792 .with_state(state);
793
794 // Listener is already bound by the caller; capture the address for logging.
795 let local = listener.local_addr().ok();
796
797 if let Some(addr) = &local {
798 tracing::info!(%addr, "source HTTP listener started");
799 }
800
801 axum::serve(listener, app)
802 .with_graceful_shutdown(async move { cancel.cancelled().await })
803 .await
804 .map_err(|e| CamelError::Io(format!("HTTP listener error: {e}")))?;
805
806 if let Some(addr) = &local {
807 tracing::info!(%addr, "source HTTP listener stopped");
808 }
809 Ok(())
810}
811
812// ─── Pipeline bridge task (async) ─────────────────────────────────────────
813
814/// Receive native exchanges from the guest's `submit-exchange` import and
815/// forward them to the pipeline via [`ConsumerContext`].
816///
817/// The WasmExchange→native conversion (and streaming-body drain setup) happens
818/// inside the import now (see `source_exchange_to_native`), so the bridge is
819/// reduced to a pure forwarder. Sends [`SubmitOutcome::Accepted`] /
820/// [`SubmitOutcome::Stopped`] back to the guest via the oneshot reply channel.
821pub async fn run_pipeline_bridge(
822 mut exchange_rx: mpsc::Receiver<(Exchange, oneshot::Sender<SubmitOutcome>)>,
823 ctx: ConsumerContext,
824) -> Result<(), CamelError> {
825 while let Some((exchange, reply_tx)) = exchange_rx.recv().await {
826 let outcome = match ctx.send(exchange).await {
827 Ok(()) => SubmitOutcome::Accepted,
828 Err(_) => SubmitOutcome::Stopped,
829 };
830 // If the guest has already abandoned the exchange (unlikely), this
831 // send fails silently.
832 let _ = reply_tx.send(outcome);
833 }
834 Ok(())
835}
836
837// ─── Tests ────────────────────────────────────────────────────────────────
838
839#[cfg(test)]
840mod tests;