Skip to main content

plecto_host/
contract.rs

1//! `plecto:filter` contract version detection and 0.1/0.2 → 0.3 adapters
2//! (ADR 000071 / 000073).
3
4mod bindings_v01 {
5    // Vendored copy of `plecto/wit/v0.1.0/` — see `crate::bindings`'s comment for why.
6    wasmtime::component::bindgen!({
7        path: "wit/v0.1.0",
8        world: "filter",
9        exports: { default: async },
10    });
11}
12
13mod bindings_v02 {
14    // Vendored copy of `plecto/wit/v0.2.0/` — see `crate::bindings`'s comment for why.
15    wasmtime::component::bindgen!({
16        path: "wit/v0.2.0",
17        world: "filter",
18        exports: { default: async },
19    });
20}
21
22/// The canonical `plecto:filter@0.3.0` contract text, byte-identical to the vendored
23/// `wit/world.wit` this module's `crate::bindings` resolves — so a consumer that needs the raw
24/// WIT source (e.g. `plecto new-filter`'s scaffold, ADR 000072) can never drift from what this
25/// binary's own host actually runs. Re-exported via `plecto-control` for `plecto-server`, which
26/// takes no direct `plecto-host` production dependency.
27pub const FILTER_WIT: &str = include_str!("../wit/world.wit");
28
29pub(crate) use crate::bindings::{
30    Filter as FilterV03, FilterPre as FilterPreV03, plecto::filter::types as types_v03,
31};
32pub(crate) use bindings_v01::{
33    Filter as FilterV01, FilterPre as FilterPreV01, plecto::filter::types as types_v01,
34};
35pub(crate) use bindings_v02::{
36    Filter as FilterV02, FilterPre as FilterPreV02, plecto::filter::types as types_v02,
37};
38
39use crate::{
40    Header, HttpRequest, HttpResponse, RequestBodyDecision, RequestDecision, RequestEdit,
41    ResponseDecision, ResponseEdit,
42};
43
44/// Which `plecto:filter` package version a loaded component targets.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ContractVersion {
47    V01,
48    V02,
49    V03,
50}
51
52/// Detect the contract version from the component's decoded import names (wasmtime's own
53/// validated type information, not a byte scan — a scan can false-positive on a string the
54/// guest embeds in a data segment). Keyed on ANY `plecto:filter/…@0.N.` import, not one
55/// specific interface: componentization prunes unused imports, so a guest that never
56/// logs has no `host-log` import at all.
57///
58/// Returns `None` (fail-closed at load) when the component imports no `plecto:filter/…`
59/// interface, or imports one at an unknown version (e.g. a future `@0.4.`). Only an explicit
60/// `@0.1.` / `@0.2.` / `@0.3.` match is accepted — never a silent default to the latest.
61pub(crate) fn detect_contract_version(
62    component: &wasmtime::component::Component,
63    engine: &wasmtime::Engine,
64) -> Option<ContractVersion> {
65    for (name, _) in component.component_type().imports(engine) {
66        if !name.starts_with("plecto:filter/") {
67            continue;
68        }
69        if name.contains("@0.1.") {
70            return Some(ContractVersion::V01);
71        }
72        if name.contains("@0.2.") {
73            return Some(ContractVersion::V02);
74        }
75        if name.contains("@0.3.") {
76            return Some(ContractVersion::V03);
77        }
78        // A `plecto:filter/…` import at an unrecognised version — do not guess.
79        return None;
80    }
81    None
82}
83
84pub(crate) fn request_to_v01(req: &HttpRequest) -> types_v01::HttpRequest {
85    types_v01::HttpRequest {
86        method: req.method.clone(),
87        path: req.path.clone(),
88        authority: req.authority.clone(),
89        scheme: req.scheme.clone(),
90        headers: req
91            .headers
92            .iter()
93            .map(|h| types_v01::Header {
94                name: h.name.clone(),
95                value: String::from_utf8_lossy(&h.value).into_owned(),
96            })
97            .collect(),
98    }
99}
100
101pub(crate) fn response_to_v01(resp: &HttpResponse) -> types_v01::HttpResponse {
102    types_v01::HttpResponse {
103        status: resp.status,
104        headers: resp
105            .headers
106            .iter()
107            .map(|h| types_v01::Header {
108                name: h.name.clone(),
109                value: String::from_utf8_lossy(&h.value).into_owned(),
110            })
111            .collect(),
112        body: resp.body.clone(),
113    }
114}
115
116/// Project the canonical (0.3-shaped) request into the frozen 0.2 record: the same
117/// byte-valued shape, so this is a mechanical per-field clone, not a lossy projection
118/// (unlike [`request_to_v01`]).
119pub(crate) fn request_to_v02(req: &HttpRequest) -> types_v02::HttpRequest {
120    types_v02::HttpRequest {
121        method: req.method.clone(),
122        path: req.path.clone(),
123        authority: req.authority.clone(),
124        scheme: req.scheme.clone(),
125        headers: req
126            .headers
127            .iter()
128            .map(|h| types_v02::Header {
129                name: h.name.clone(),
130                value: h.value.clone(),
131            })
132            .collect(),
133    }
134}
135
136pub(crate) fn response_to_v02(resp: &HttpResponse) -> types_v02::HttpResponse {
137    types_v02::HttpResponse {
138        status: resp.status,
139        headers: resp
140            .headers
141            .iter()
142            .map(|h| types_v02::Header {
143                name: h.name.clone(),
144                value: h.value.clone(),
145            })
146            .collect(),
147        body: resp.body.clone(),
148    }
149}
150
151fn header_from_v01(h: types_v01::Header) -> Option<Header> {
152    validate_and_header(&h.name, h.value.as_bytes())
153}
154
155fn header_from_v02(h: types_v02::Header) -> Option<Header> {
156    validate_and_header(&h.name, &h.value)
157}
158
159fn header_from_v03(h: types_v03::Header) -> Option<Header> {
160    validate_and_header(&h.name, &h.value)
161}
162
163const MAX_GUEST_HEADER_NAME_LEN: usize = 256;
164const MAX_GUEST_HEADER_VALUE_LEN: usize = 8192;
165/// Ceiling on a guest-synthesised response body (`short-circuit` / `replace`). Symmetric to the
166/// request-body buffer cap on the fast path: an unbounded guest body is a trivial host OOM.
167pub(crate) const MAX_GUEST_RESPONSE_BODY_LEN: usize = 1 << 20; // 1 MiB
168
169/// RFC 9110 §5.6.2 `tchar` — the exact set hyper's `HeaderName::from_bytes` accepts, so a name
170/// that passes here can never be silently dropped at the egress `copy_headers` conversion.
171fn is_tchar(b: u8) -> bool {
172    b.is_ascii_alphanumeric()
173        || matches!(
174            b,
175            b'!' | b'#'..=b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
176        )
177}
178
179/// RFC 9110 §5.5 field content: HTAB / VCHAR / obs-text (0x80–0xFF permitted — that byte range
180/// is the whole point of the `list<u8>` contract). Mirrors hyper's `HeaderValue::from_bytes`,
181/// same reason as [`is_tchar`]: no silent drops past this gate.
182fn is_field_value_byte(b: u8) -> bool {
183    b == b'\t' || (b >= 0x20 && b != 0x7f)
184}
185
186/// Hop-by-hop names (RFC 9110 §7.6.1) a guest cannot meaningfully set — the fast path strips
187/// them at egress. Kept in sync with the strip list in `plecto-server::headers`. The mappers
188/// DROP these instead of failing the decision: the observable behavior (the header never
189/// reaches the peer) is what deployments already had, whereas failing closed would turn a
190/// filter that harmlessly sets `Connection: close` into an every-request `InvalidOutput`.
191const HOP_BY_HOP_GUEST_HEADERS: &[&str] = &[
192    "connection",
193    "keep-alive",
194    "proxy-connection",
195    "transfer-encoding",
196    "te",
197    "trailer",
198    "upgrade",
199    "proxy-authorization",
200    "proxy-authenticate",
201];
202
203fn is_hop_by_hop_guest_header(name: &str) -> bool {
204    HOP_BY_HOP_GUEST_HEADERS
205        .iter()
206        .any(|h| name.eq_ignore_ascii_case(h))
207}
208
209/// Validate a guest-supplied header (guest output is untrusted): reject CRLF / CTLs / non-tchar
210/// names / oversize, fail-closed instead of trapping. Alignment with hyper's accepted sets means
211/// everything admitted here survives egress byte-for-byte. Hop-by-hop names never reach this
212/// gate — the mappers below drop them first (see [`HOP_BY_HOP_GUEST_HEADERS`]).
213fn validate_and_header(name: &str, value: &[u8]) -> Option<Header> {
214    if name.is_empty() || name.len() > MAX_GUEST_HEADER_NAME_LEN {
215        return None;
216    }
217    if !name.bytes().all(is_tchar) {
218        return None;
219    }
220    if value.len() > MAX_GUEST_HEADER_VALUE_LEN {
221        return None;
222    }
223    if !value.iter().all(|b| is_field_value_byte(*b)) {
224        return None;
225    }
226    Some(Header {
227        name: name.to_string(),
228        value: value.to_vec(),
229    })
230}
231
232fn request_edit_from_v01(edit: types_v01::RequestEdit) -> Option<RequestEdit> {
233    let set_headers = edit
234        .set_headers
235        .into_iter()
236        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
237        .map(header_from_v01)
238        .collect::<Option<Vec<_>>>()?;
239    Some(RequestEdit {
240        set_headers,
241        remove_headers: edit.remove_headers,
242    })
243}
244
245fn request_edit_from_v02(edit: types_v02::RequestEdit) -> Option<RequestEdit> {
246    let set_headers = edit
247        .set_headers
248        .into_iter()
249        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
250        .map(header_from_v02)
251        .collect::<Option<Vec<_>>>()?;
252    Some(RequestEdit {
253        set_headers,
254        remove_headers: edit.remove_headers,
255    })
256}
257
258fn response_edit_from_v01(edit: types_v01::ResponseEdit) -> Option<ResponseEdit> {
259    let set_headers = edit
260        .set_headers
261        .into_iter()
262        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
263        .map(header_from_v01)
264        .collect::<Option<Vec<_>>>()?;
265    Some(ResponseEdit {
266        set_status: match edit.set_status {
267            Some(status) => Some(validated_guest_status(status)?),
268            None => None,
269        },
270        set_headers,
271        remove_headers: edit.remove_headers,
272    })
273}
274
275fn response_edit_from_v02(edit: types_v02::ResponseEdit) -> Option<ResponseEdit> {
276    let set_headers = edit
277        .set_headers
278        .into_iter()
279        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
280        .map(header_from_v02)
281        .collect::<Option<Vec<_>>>()?;
282    Some(ResponseEdit {
283        set_status: match edit.set_status {
284            Some(status) => Some(validated_guest_status(status)?),
285            None => None,
286        },
287        set_headers,
288        remove_headers: edit.remove_headers,
289    })
290}
291
292fn request_edit_from_v03(edit: types_v03::RequestEdit) -> Option<RequestEdit> {
293    let set_headers = edit
294        .set_headers
295        .into_iter()
296        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
297        .map(header_from_v03)
298        .collect::<Option<Vec<_>>>()?;
299    Some(RequestEdit {
300        set_headers,
301        remove_headers: edit.remove_headers,
302    })
303}
304
305fn response_edit_from_v03(edit: types_v03::ResponseEdit) -> Option<ResponseEdit> {
306    let set_headers = edit
307        .set_headers
308        .into_iter()
309        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
310        .map(header_from_v03)
311        .collect::<Option<Vec<_>>>()?;
312    Some(ResponseEdit {
313        set_status: match edit.set_status {
314            Some(status) => Some(validated_guest_status(status)?),
315            None => None,
316        },
317        set_headers,
318        remove_headers: edit.remove_headers,
319    })
320}
321
322fn validated_guest_body(body: Vec<u8>) -> Option<Vec<u8>> {
323    if body.len() > MAX_GUEST_RESPONSE_BODY_LEN {
324        return None;
325    }
326    Some(body)
327}
328
329/// Guest-supplied status codes join the header rules at this gate (the module invariant:
330/// everything admitted here survives egress): only 100..=599 — the range hyper's
331/// `StatusCode::from_u16` accepts — passes; anything else is rejected as `InvalidOutput`
332/// instead of being silently rewritten to 502 downstream (the server's clamp stays as
333/// defence in depth).
334fn validated_guest_status(status: u16) -> Option<u16> {
335    (100..=599).contains(&status).then_some(status)
336}
337
338fn response_from_v01(resp: types_v01::HttpResponse) -> Option<HttpResponse> {
339    let headers = resp
340        .headers
341        .into_iter()
342        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
343        .map(header_from_v01)
344        .collect::<Option<Vec<_>>>()?;
345    Some(HttpResponse {
346        status: validated_guest_status(resp.status)?,
347        headers,
348        body: validated_guest_body(resp.body)?,
349    })
350}
351
352fn response_from_v02(resp: types_v02::HttpResponse) -> Option<HttpResponse> {
353    let headers = resp
354        .headers
355        .into_iter()
356        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
357        .map(header_from_v02)
358        .collect::<Option<Vec<_>>>()?;
359    Some(HttpResponse {
360        status: validated_guest_status(resp.status)?,
361        headers,
362        body: validated_guest_body(resp.body)?,
363    })
364}
365
366fn response_from_v03(resp: types_v03::HttpResponse) -> Option<HttpResponse> {
367    let headers = resp
368        .headers
369        .into_iter()
370        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
371        .map(header_from_v03)
372        .collect::<Option<Vec<_>>>()?;
373    Some(HttpResponse {
374        status: validated_guest_status(resp.status)?,
375        headers,
376        body: validated_guest_body(resp.body)?,
377    })
378}
379
380/// Map a 0.1 guest decision to native byte-valued types. `Continue` does not rewrite headers.
381pub(crate) fn request_decision_from_v01(
382    decision: types_v01::RequestDecision,
383) -> Option<RequestDecision> {
384    match decision {
385        types_v01::RequestDecision::Continue => Some(RequestDecision::Continue),
386        types_v01::RequestDecision::Modified(edit) => {
387            Some(RequestDecision::Modified(request_edit_from_v01(edit)?))
388        }
389        types_v01::RequestDecision::ShortCircuit(resp) => {
390            Some(RequestDecision::ShortCircuit(response_from_v01(resp)?))
391        }
392    }
393}
394
395pub(crate) fn response_decision_from_v01(
396    decision: types_v01::ResponseDecision,
397) -> Option<ResponseDecision> {
398    match decision {
399        types_v01::ResponseDecision::Continue => Some(ResponseDecision::Continue),
400        types_v01::ResponseDecision::Modified(edit) => {
401            Some(ResponseDecision::Modified(response_edit_from_v01(edit)?))
402        }
403    }
404}
405
406pub(crate) fn request_body_decision_from_v01(
407    decision: types_v01::RequestBodyDecision,
408) -> Option<RequestBodyDecision> {
409    match decision {
410        types_v01::RequestBodyDecision::Continue(body) => Some(RequestBodyDecision::Continue(body)),
411        types_v01::RequestBodyDecision::ShortCircuit(resp) => {
412            Some(RequestBodyDecision::ShortCircuit(response_from_v01(resp)?))
413        }
414    }
415}
416
417pub(crate) fn request_decision_from_v02(
418    decision: types_v02::RequestDecision,
419) -> Option<RequestDecision> {
420    match decision {
421        types_v02::RequestDecision::Continue => Some(RequestDecision::Continue),
422        types_v02::RequestDecision::Modified(edit) => {
423            Some(RequestDecision::Modified(request_edit_from_v02(edit)?))
424        }
425        types_v02::RequestDecision::ShortCircuit(resp) => {
426            Some(RequestDecision::ShortCircuit(response_from_v02(resp)?))
427        }
428    }
429}
430
431pub(crate) fn response_decision_from_v02(
432    decision: types_v02::ResponseDecision,
433) -> Option<ResponseDecision> {
434    match decision {
435        types_v02::ResponseDecision::Continue => Some(ResponseDecision::Continue),
436        types_v02::ResponseDecision::Modified(edit) => {
437            Some(ResponseDecision::Modified(response_edit_from_v02(edit)?))
438        }
439    }
440}
441
442pub(crate) fn request_body_decision_from_v02(
443    decision: types_v02::RequestBodyDecision,
444) -> Option<RequestBodyDecision> {
445    match decision {
446        types_v02::RequestBodyDecision::Continue(body) => Some(RequestBodyDecision::Continue(body)),
447        types_v02::RequestBodyDecision::ShortCircuit(resp) => {
448            Some(RequestBodyDecision::ShortCircuit(response_from_v02(resp)?))
449        }
450    }
451}
452
453pub(crate) fn request_decision_from_v03(
454    decision: types_v03::RequestDecision,
455) -> Option<RequestDecision> {
456    match decision {
457        types_v03::RequestDecision::Continue => Some(RequestDecision::Continue),
458        types_v03::RequestDecision::Modified(edit) => {
459            Some(RequestDecision::Modified(request_edit_from_v03(edit)?))
460        }
461        types_v03::RequestDecision::ShortCircuit(resp) => {
462            Some(RequestDecision::ShortCircuit(response_from_v03(resp)?))
463        }
464    }
465}
466
467/// Map a 0.3 guest response decision to the validated native one. `replace` output is
468/// untrusted guest data on its way to the client, so it passes the SAME fail-closed header
469/// validation as a request-side `short-circuit` (ADR 000073 decision 4).
470pub(crate) fn response_decision_from_v03(
471    decision: types_v03::ResponseDecision,
472) -> Option<ResponseDecision> {
473    match decision {
474        types_v03::ResponseDecision::Continue => Some(ResponseDecision::Continue),
475        types_v03::ResponseDecision::Modified(edit) => {
476            Some(ResponseDecision::Modified(response_edit_from_v03(edit)?))
477        }
478        types_v03::ResponseDecision::Replace(resp) => {
479            Some(ResponseDecision::Replace(response_from_v03(resp)?))
480        }
481    }
482}
483
484pub(crate) fn request_body_decision_from_v03(
485    decision: types_v03::RequestBodyDecision,
486) -> Option<RequestBodyDecision> {
487    match decision {
488        types_v03::RequestBodyDecision::Continue(body) => Some(RequestBodyDecision::Continue(body)),
489        types_v03::RequestBodyDecision::ShortCircuit(resp) => {
490            Some(RequestBodyDecision::ShortCircuit(response_from_v03(resp)?))
491        }
492    }
493}
494
495/// Build a canonical header (byte-valued) for callers and tests.
496pub fn header(name: impl Into<String>, value: impl AsRef<[u8]>) -> Header {
497    Header {
498        name: name.into(),
499        value: value.as_ref().to_vec(),
500    }
501}
502
503mod v01_host {
504    use super::bindings_v01::plecto::filter::{
505        host_clock, host_config, host_counter, host_kv, host_log, host_ratelimit,
506    };
507    use crate::LogLevel;
508    use crate::bindings::plecto::filter::{
509        host_clock as host_clock_v03, host_config as host_config_v03,
510        host_counter as host_counter_v03, host_kv as host_kv_v03, host_log as host_log_v03,
511        host_ratelimit as host_ratelimit_v03,
512    };
513    use crate::state::HostState;
514
515    impl super::bindings_v01::plecto::filter::types::Host for HostState {}
516
517    fn log_level(level: host_log::Level) -> LogLevel {
518        match level {
519            host_log::Level::Trace => LogLevel::Trace,
520            host_log::Level::Debug => LogLevel::Debug,
521            host_log::Level::Info => LogLevel::Info,
522            host_log::Level::Warn => LogLevel::Warn,
523            host_log::Level::Error => LogLevel::Error,
524        }
525    }
526
527    impl host_log::Host for HostState {
528        fn log(&mut self, level: host_log::Level, message: String) {
529            host_log_v03::Host::log(self, log_level(level), message);
530        }
531    }
532
533    impl host_clock::Host for HostState {
534        fn now_ms(&mut self) -> u64 {
535            host_clock_v03::Host::now_ms(self)
536        }
537    }
538
539    impl host_kv::Host for HostState {
540        fn get(&mut self, key: String) -> Option<Vec<u8>> {
541            host_kv_v03::Host::get(self, key)
542        }
543        fn set(&mut self, key: String, value: Vec<u8>) {
544            host_kv_v03::Host::set(self, key, value);
545        }
546        fn delete(&mut self, key: String) {
547            host_kv_v03::Host::delete(self, key);
548        }
549    }
550
551    impl host_counter::Host for HostState {
552        fn increment(&mut self, key: String, delta: i64) -> i64 {
553            host_counter_v03::Host::increment(self, key, delta)
554        }
555        fn get(&mut self, key: String) -> i64 {
556            host_counter_v03::Host::get(self, key)
557        }
558    }
559
560    impl host_ratelimit::Host for HostState {
561        fn try_acquire(&mut self, key: String, cost: u64) -> host_ratelimit::Acquire {
562            let out = host_ratelimit_v03::Host::try_acquire(self, key, cost);
563            host_ratelimit::Acquire {
564                allowed: out.allowed,
565                remaining: out.remaining,
566                retry_after_ms: out.retry_after_ms,
567            }
568        }
569    }
570
571    impl host_config::Host for HostState {
572        fn get(&mut self, key: String) -> Option<String> {
573            host_config_v03::Host::get(self, key)
574        }
575    }
576}
577
578mod v02_host {
579    use super::bindings_v02::plecto::filter::{
580        host_clock, host_config, host_counter, host_kv, host_log, host_ratelimit,
581    };
582    use crate::LogLevel;
583    use crate::bindings::plecto::filter::{
584        host_clock as host_clock_v03, host_config as host_config_v03,
585        host_counter as host_counter_v03, host_kv as host_kv_v03, host_log as host_log_v03,
586        host_ratelimit as host_ratelimit_v03,
587    };
588    use crate::state::HostState;
589
590    impl super::bindings_v02::plecto::filter::types::Host for HostState {}
591
592    fn log_level(level: host_log::Level) -> LogLevel {
593        match level {
594            host_log::Level::Trace => LogLevel::Trace,
595            host_log::Level::Debug => LogLevel::Debug,
596            host_log::Level::Info => LogLevel::Info,
597            host_log::Level::Warn => LogLevel::Warn,
598            host_log::Level::Error => LogLevel::Error,
599        }
600    }
601
602    impl host_log::Host for HostState {
603        fn log(&mut self, level: host_log::Level, message: String) {
604            host_log_v03::Host::log(self, log_level(level), message);
605        }
606    }
607
608    impl host_clock::Host for HostState {
609        fn now_ms(&mut self) -> u64 {
610            host_clock_v03::Host::now_ms(self)
611        }
612    }
613
614    impl host_kv::Host for HostState {
615        fn get(&mut self, key: String) -> Option<Vec<u8>> {
616            host_kv_v03::Host::get(self, key)
617        }
618        fn set(&mut self, key: String, value: Vec<u8>) {
619            host_kv_v03::Host::set(self, key, value);
620        }
621        fn delete(&mut self, key: String) {
622            host_kv_v03::Host::delete(self, key);
623        }
624    }
625
626    impl host_counter::Host for HostState {
627        fn increment(&mut self, key: String, delta: i64) -> i64 {
628            host_counter_v03::Host::increment(self, key, delta)
629        }
630        fn get(&mut self, key: String) -> i64 {
631            host_counter_v03::Host::get(self, key)
632        }
633    }
634
635    impl host_ratelimit::Host for HostState {
636        fn try_acquire(&mut self, key: String, cost: u64) -> host_ratelimit::Acquire {
637            let out = host_ratelimit_v03::Host::try_acquire(self, key, cost);
638            host_ratelimit::Acquire {
639                allowed: out.allowed,
640                remaining: out.remaining,
641                retry_after_ms: out.retry_after_ms,
642            }
643        }
644    }
645
646    impl host_config::Host for HostState {
647        fn get(&mut self, key: String) -> Option<String> {
648            host_config_v03::Host::get(self, key)
649        }
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn rejects_crlf_in_guest_header_value() {
659        assert!(validate_and_header("x", b"a\r\nb").is_none());
660        assert!(validate_and_header("x", b"a\rb").is_none());
661        assert!(validate_and_header("x", b"a\nb").is_none());
662        assert!(validate_and_header("x", b"ok").is_some());
663    }
664
665    #[test]
666    fn hop_by_hop_guest_headers_are_dropped_not_fatal() {
667        // RFC 9110 §7.6.1 names never survive the proxy (the fast path strips them at egress),
668        // so the mappers drop them instead of failing the whole decision — a deployed filter
669        // that harmlessly sets `Connection: close` must not start failing every request.
670        let edit = types_v03::RequestDecision::Modified(types_v03::RequestEdit {
671            set_headers: vec![
672                types_v03::Header {
673                    name: "Connection".to_string(),
674                    value: b"close".to_vec(),
675                },
676                types_v03::Header {
677                    name: "x-user".to_string(),
678                    value: b"alice".to_vec(),
679                },
680            ],
681            remove_headers: vec![],
682        });
683        match request_decision_from_v03(edit) {
684            Some(RequestDecision::Modified(edit)) => {
685                assert_eq!(
686                    edit.set_headers.len(),
687                    1,
688                    "hop-by-hop dropped, the rest kept"
689                );
690                assert_eq!(edit.set_headers[0].name, "x-user");
691            }
692            other => panic!("expected Modified, got {other:?}"),
693        }
694        for name in [
695            "Keep-Alive",
696            "transfer-encoding",
697            "proxy-authorization",
698            "TE",
699            "upgrade",
700        ] {
701            let sc = types_v03::RequestDecision::ShortCircuit(types_v03::HttpResponse {
702                status: 200,
703                headers: vec![types_v03::Header {
704                    name: name.to_string(),
705                    value: b"x".to_vec(),
706                }],
707                body: Vec::new(),
708            });
709            match request_decision_from_v03(sc) {
710                Some(RequestDecision::ShortCircuit(resp)) => {
711                    assert!(resp.headers.is_empty(), "{name} must be dropped, not fatal");
712                }
713                other => panic!("expected ShortCircuit, got {other:?}"),
714            }
715        }
716    }
717
718    #[test]
719    fn validated_guest_status_accepts_only_http_range() {
720        assert_eq!(validated_guest_status(100), Some(100));
721        assert_eq!(validated_guest_status(200), Some(200));
722        assert_eq!(validated_guest_status(599), Some(599));
723        assert_eq!(validated_guest_status(99), None);
724        assert_eq!(validated_guest_status(600), None);
725        assert_eq!(validated_guest_status(0), None);
726        assert_eq!(validated_guest_status(1000), None);
727    }
728
729    #[test]
730    fn rejects_ctl_bytes_and_non_tchar_names_that_hyper_would_silently_drop() {
731        // Anything admitted here must survive the egress `HeaderName`/`HeaderValue::from_bytes`
732        // conversion — otherwise a header the guest set vanishes silently instead of failing
733        // closed at the contract boundary.
734        assert!(
735            validate_and_header("x:y", b"v").is_none(),
736            "':' is not tchar"
737        );
738        assert!(
739            validate_and_header("x y", b"v").is_none(),
740            "space is not tchar"
741        );
742        assert!(validate_and_header("x", b"a\0b").is_none(), "NUL is a CTL");
743        assert!(
744            validate_and_header("x", b"a\x7fb").is_none(),
745            "DEL is rejected"
746        );
747        assert!(validate_and_header("", b"v").is_none(), "empty name");
748    }
749
750    #[test]
751    fn accepts_obs_text_bytes_the_contract_exists_to_carry() {
752        // Non-UTF-8 / high bytes are legal field content (RFC 9110 obs-text) — rejecting them
753        // would defeat the `list<u8>` lift (ADR 000071).
754        let raw: &[u8] = &[0xC3, 0x28, 0xFF];
755        let h = validate_and_header("x-blob", raw).expect("obs-text is valid field content");
756        assert_eq!(h.value, raw);
757        assert!(
758            validate_and_header("x", b"tab\tok").is_some(),
759            "HTAB is legal"
760        );
761    }
762
763    #[test]
764    fn enforces_size_caps() {
765        assert!(validate_and_header(&"n".repeat(MAX_GUEST_HEADER_NAME_LEN), b"v").is_some());
766        assert!(validate_and_header(&"n".repeat(MAX_GUEST_HEADER_NAME_LEN + 1), b"v").is_none());
767        assert!(validate_and_header("x", &vec![b'a'; MAX_GUEST_HEADER_VALUE_LEN]).is_some());
768        assert!(validate_and_header("x", &vec![b'a'; MAX_GUEST_HEADER_VALUE_LEN + 1]).is_none());
769    }
770
771    #[test]
772    fn v01_projection_is_lossy_but_v01_continue_keeps_native_bytes() {
773        // The 0.1 guest sees the lossy string; `continue` never rebuilds headers from it —
774        // the host's canonical byte header is what flows on (ADR 000071 decision 2).
775        let req = HttpRequest {
776            method: "GET".to_string(),
777            path: "/".to_string(),
778            authority: "a".to_string(),
779            scheme: "https".to_string(),
780            headers: vec![Header {
781                name: "x-blob".to_string(),
782                value: vec![0xC3, 0x28],
783            }],
784        };
785        let projected = request_to_v01(&req);
786        assert_eq!(projected.headers[0].value, "\u{FFFD}(");
787        assert!(matches!(
788            request_decision_from_v01(types_v01::RequestDecision::Continue),
789            Some(RequestDecision::Continue)
790        ));
791    }
792
793    #[test]
794    fn v01_modified_applies_utf8_bytes_and_fails_closed_on_crlf() {
795        let ok = types_v01::RequestDecision::Modified(types_v01::RequestEdit {
796            set_headers: vec![types_v01::Header {
797                name: "x-user".to_string(),
798                value: "alice".to_string(),
799            }],
800            remove_headers: vec![],
801        });
802        match request_decision_from_v01(ok) {
803            Some(RequestDecision::Modified(edit)) => {
804                assert_eq!(edit.set_headers[0].value, b"alice");
805            }
806            other => panic!("expected Modified, got {other:?}"),
807        }
808
809        let bad = types_v01::RequestDecision::Modified(types_v01::RequestEdit {
810            set_headers: vec![types_v01::Header {
811                name: "x-evil".to_string(),
812                value: "a\r\nx-smuggled: 1".to_string(),
813            }],
814            remove_headers: vec![],
815        });
816        assert!(
817            request_decision_from_v01(bad).is_none(),
818            "CRLF fails closed"
819        );
820    }
821
822    #[test]
823    fn v03_replace_output_passes_the_same_fail_closed_validation_as_short_circuit() {
824        // ADR 000073 decision 4: `replace` output is untrusted guest data headed for the client,
825        // so it passes the SAME header validation as a request-side short-circuit. CRLF fails
826        // closed (the mapper returns None → RunError::InvalidOutput); clean bytes pass intact.
827        let bad = types_v03::ResponseDecision::Replace(types_v03::HttpResponse {
828            status: 200,
829            headers: vec![types_v03::Header {
830                name: "x-evil".to_string(),
831                value: b"a\r\nx-smuggled: 1".to_vec(),
832            }],
833            body: b"payload".to_vec(),
834        });
835        assert!(
836            response_decision_from_v03(bad).is_none(),
837            "CRLF in a replace header fails closed"
838        );
839
840        let ok = types_v03::ResponseDecision::Replace(types_v03::HttpResponse {
841            status: 418,
842            headers: vec![types_v03::Header {
843                name: "x-blob".to_string(),
844                value: vec![0xC3, 0x28, 0xFF],
845            }],
846            body: b"payload".to_vec(),
847        });
848        match response_decision_from_v03(ok) {
849            Some(ResponseDecision::Replace(resp)) => {
850                assert_eq!(resp.status, 418);
851                assert_eq!(resp.headers[0].value, vec![0xC3, 0x28, 0xFF]);
852                assert_eq!(resp.body, b"payload");
853            }
854            other => panic!("expected Replace, got {other:?}"),
855        }
856    }
857
858    #[test]
859    fn v02_projection_is_byte_faithful() {
860        // The 0.2 projection is a mechanical clone (same byte-valued shape), NOT the 0.1
861        // lossy-UTF-8 form: non-UTF-8 header bytes survive verbatim.
862        let req = HttpRequest {
863            method: "GET".to_string(),
864            path: "/".to_string(),
865            authority: "a".to_string(),
866            scheme: "https".to_string(),
867            headers: vec![Header {
868                name: "x-blob".to_string(),
869                value: vec![0xC3, 0x28],
870            }],
871        };
872        let projected = request_to_v02(&req);
873        assert_eq!(projected.headers[0].value, vec![0xC3, 0x28]);
874        let resp = HttpResponse {
875            status: 200,
876            headers: vec![Header {
877                name: "x-blob".to_string(),
878                value: vec![0xFF],
879            }],
880            body: b"b".to_vec(),
881        };
882        assert_eq!(response_to_v02(&resp).headers[0].value, vec![0xFF]);
883    }
884
885    #[test]
886    fn detects_version_from_decoded_imports_not_bytes() {
887        // Real components prune unused imports (the MoonBit fixture has no host-kv/clock/config),
888        // so detection must key on ANY plecto:filter interface — and must read the decoded type
889        // structure, not raw bytes. Unknown / absent versions fail closed (`None`) at load.
890        let engine = wasmtime::Engine::default();
891        let cases: &[(&str, Option<ContractVersion>)] = &[
892            (
893                r#"(component (import "plecto:filter/host-log@0.1.0" (instance)))"#,
894                Some(ContractVersion::V01),
895            ),
896            // a 0.1 guest that never logs: host-log is pruned, another interface remains.
897            (
898                r#"(component (import "plecto:filter/host-clock@0.1.0" (instance)))"#,
899                Some(ContractVersion::V01),
900            ),
901            (
902                r#"(component (import "plecto:filter/host-log@0.2.0" (instance)))"#,
903                Some(ContractVersion::V02),
904            ),
905            (
906                r#"(component (import "plecto:filter/host-log@0.3.0" (instance)))"#,
907                Some(ContractVersion::V03),
908            ),
909            (
910                r#"(component (import "plecto:filter/host-clock@0.2.0" (instance)))"#,
911                Some(ContractVersion::V02),
912            ),
913            // no plecto import at all: fail closed at load (do not guess V03).
914            (r"(component)", None),
915            // future / unknown track: fail closed (do not silently bind as V03).
916            (
917                r#"(component (import "plecto:filter/host-log@0.4.0" (instance)))"#,
918                None,
919            ),
920        ];
921        for (wat, want) in cases {
922            let component =
923                wasmtime::component::Component::new(&engine, wat).expect("valid component wat");
924            assert_eq!(
925                detect_contract_version(&component, &engine),
926                *want,
927                "wat: {wat}"
928            );
929        }
930    }
931
932    #[test]
933    fn oversize_guest_response_body_fails_closed() {
934        let ok = types_v03::ResponseDecision::Replace(types_v03::HttpResponse {
935            status: 200,
936            headers: vec![],
937            body: vec![0u8; MAX_GUEST_RESPONSE_BODY_LEN],
938        });
939        assert!(response_decision_from_v03(ok).is_some());
940
941        let over = types_v03::ResponseDecision::Replace(types_v03::HttpResponse {
942            status: 200,
943            headers: vec![],
944            body: vec![0u8; MAX_GUEST_RESPONSE_BODY_LEN + 1],
945        });
946        assert!(
947            response_decision_from_v03(over).is_none(),
948            "a synthesised body over the cap must fail closed"
949        );
950    }
951}