Skip to main content

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;