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`; that crate's
188/// test suite asserts the two lists are identical (`doc(hidden)` keeps this off the semver
189/// surface — it exists only for that cross-crate assertion). The mappers
190/// DROP these instead of failing the decision: the observable behavior (the header never
191/// reaches the peer) is what deployments already had, whereas failing closed would turn a
192/// filter that harmlessly sets `Connection: close` into an every-request `InvalidOutput`.
193#[doc(hidden)]
194pub const HOP_BY_HOP_GUEST_HEADERS: &[&str] = &[
195    "connection",
196    "keep-alive",
197    "proxy-connection",
198    "transfer-encoding",
199    "te",
200    "trailer",
201    "upgrade",
202    "proxy-authorization",
203    "proxy-authenticate",
204];
205
206fn is_hop_by_hop_guest_header(name: &str) -> bool {
207    HOP_BY_HOP_GUEST_HEADERS
208        .iter()
209        .any(|h| name.eq_ignore_ascii_case(h))
210}
211
212/// Validate a guest-supplied header (guest output is untrusted): reject CRLF / CTLs / non-tchar
213/// names / oversize, fail-closed instead of trapping. Alignment with hyper's accepted sets means
214/// everything admitted here survives egress byte-for-byte. Hop-by-hop names never reach this
215/// gate — the mappers below drop them first (see [`HOP_BY_HOP_GUEST_HEADERS`]).
216fn validate_and_header(name: &str, value: &[u8]) -> Option<Header> {
217    if name.is_empty() || name.len() > MAX_GUEST_HEADER_NAME_LEN {
218        return None;
219    }
220    if !name.bytes().all(is_tchar) {
221        return None;
222    }
223    if value.len() > MAX_GUEST_HEADER_VALUE_LEN {
224        return None;
225    }
226    if !value.iter().all(|b| is_field_value_byte(*b)) {
227        return None;
228    }
229    Some(Header {
230        name: name.to_string(),
231        value: value.to_vec(),
232    })
233}
234
235fn request_edit_from_v01(edit: types_v01::RequestEdit) -> Option<RequestEdit> {
236    let set_headers = edit
237        .set_headers
238        .into_iter()
239        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
240        .map(header_from_v01)
241        .collect::<Option<Vec<_>>>()?;
242    Some(RequestEdit {
243        set_headers,
244        remove_headers: edit.remove_headers,
245    })
246}
247
248fn request_edit_from_v02(edit: types_v02::RequestEdit) -> Option<RequestEdit> {
249    let set_headers = edit
250        .set_headers
251        .into_iter()
252        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
253        .map(header_from_v02)
254        .collect::<Option<Vec<_>>>()?;
255    Some(RequestEdit {
256        set_headers,
257        remove_headers: edit.remove_headers,
258    })
259}
260
261fn response_edit_from_v01(edit: types_v01::ResponseEdit) -> Option<ResponseEdit> {
262    let set_headers = edit
263        .set_headers
264        .into_iter()
265        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
266        .map(header_from_v01)
267        .collect::<Option<Vec<_>>>()?;
268    Some(ResponseEdit {
269        set_status: match edit.set_status {
270            Some(status) => Some(validated_guest_status(status)?),
271            None => None,
272        },
273        set_headers,
274        remove_headers: edit.remove_headers,
275    })
276}
277
278fn response_edit_from_v02(edit: types_v02::ResponseEdit) -> Option<ResponseEdit> {
279    let set_headers = edit
280        .set_headers
281        .into_iter()
282        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
283        .map(header_from_v02)
284        .collect::<Option<Vec<_>>>()?;
285    Some(ResponseEdit {
286        set_status: match edit.set_status {
287            Some(status) => Some(validated_guest_status(status)?),
288            None => None,
289        },
290        set_headers,
291        remove_headers: edit.remove_headers,
292    })
293}
294
295fn request_edit_from_v03(edit: types_v03::RequestEdit) -> Option<RequestEdit> {
296    let set_headers = edit
297        .set_headers
298        .into_iter()
299        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
300        .map(header_from_v03)
301        .collect::<Option<Vec<_>>>()?;
302    Some(RequestEdit {
303        set_headers,
304        remove_headers: edit.remove_headers,
305    })
306}
307
308fn response_edit_from_v03(edit: types_v03::ResponseEdit) -> Option<ResponseEdit> {
309    let set_headers = edit
310        .set_headers
311        .into_iter()
312        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
313        .map(header_from_v03)
314        .collect::<Option<Vec<_>>>()?;
315    Some(ResponseEdit {
316        set_status: match edit.set_status {
317            Some(status) => Some(validated_guest_status(status)?),
318            None => None,
319        },
320        set_headers,
321        remove_headers: edit.remove_headers,
322    })
323}
324
325fn validated_guest_body(body: Vec<u8>) -> Option<Vec<u8>> {
326    if body.len() > MAX_GUEST_RESPONSE_BODY_LEN {
327        return None;
328    }
329    Some(body)
330}
331
332/// Guest-supplied status codes join the header rules at this gate (the module invariant:
333/// everything admitted here survives egress): only 100..=599 — the range hyper's
334/// `StatusCode::from_u16` accepts — passes; anything else is rejected as `InvalidOutput`
335/// instead of being silently rewritten to 502 downstream (the server's clamp stays as
336/// defence in depth).
337fn validated_guest_status(status: u16) -> Option<u16> {
338    (100..=599).contains(&status).then_some(status)
339}
340
341fn response_from_v01(resp: types_v01::HttpResponse) -> Option<HttpResponse> {
342    let headers = resp
343        .headers
344        .into_iter()
345        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
346        .map(header_from_v01)
347        .collect::<Option<Vec<_>>>()?;
348    Some(HttpResponse {
349        status: validated_guest_status(resp.status)?,
350        headers,
351        body: validated_guest_body(resp.body)?,
352    })
353}
354
355fn response_from_v02(resp: types_v02::HttpResponse) -> Option<HttpResponse> {
356    let headers = resp
357        .headers
358        .into_iter()
359        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
360        .map(header_from_v02)
361        .collect::<Option<Vec<_>>>()?;
362    Some(HttpResponse {
363        status: validated_guest_status(resp.status)?,
364        headers,
365        body: validated_guest_body(resp.body)?,
366    })
367}
368
369fn response_from_v03(resp: types_v03::HttpResponse) -> Option<HttpResponse> {
370    let headers = resp
371        .headers
372        .into_iter()
373        .filter(|h| !is_hop_by_hop_guest_header(&h.name))
374        .map(header_from_v03)
375        .collect::<Option<Vec<_>>>()?;
376    Some(HttpResponse {
377        status: validated_guest_status(resp.status)?,
378        headers,
379        body: validated_guest_body(resp.body)?,
380    })
381}
382
383/// Map a 0.1 guest decision to native byte-valued types. `Continue` does not rewrite headers.
384pub(crate) fn request_decision_from_v01(
385    decision: types_v01::RequestDecision,
386) -> Option<RequestDecision> {
387    match decision {
388        types_v01::RequestDecision::Continue => Some(RequestDecision::Continue),
389        types_v01::RequestDecision::Modified(edit) => {
390            Some(RequestDecision::Modified(request_edit_from_v01(edit)?))
391        }
392        types_v01::RequestDecision::ShortCircuit(resp) => {
393            Some(RequestDecision::ShortCircuit(response_from_v01(resp)?))
394        }
395    }
396}
397
398pub(crate) fn response_decision_from_v01(
399    decision: types_v01::ResponseDecision,
400) -> Option<ResponseDecision> {
401    match decision {
402        types_v01::ResponseDecision::Continue => Some(ResponseDecision::Continue),
403        types_v01::ResponseDecision::Modified(edit) => {
404            Some(ResponseDecision::Modified(response_edit_from_v01(edit)?))
405        }
406    }
407}
408
409pub(crate) fn request_body_decision_from_v01(
410    decision: types_v01::RequestBodyDecision,
411) -> Option<RequestBodyDecision> {
412    match decision {
413        types_v01::RequestBodyDecision::Continue(body) => Some(RequestBodyDecision::Continue(body)),
414        types_v01::RequestBodyDecision::ShortCircuit(resp) => {
415            Some(RequestBodyDecision::ShortCircuit(response_from_v01(resp)?))
416        }
417    }
418}
419
420pub(crate) fn request_decision_from_v02(
421    decision: types_v02::RequestDecision,
422) -> Option<RequestDecision> {
423    match decision {
424        types_v02::RequestDecision::Continue => Some(RequestDecision::Continue),
425        types_v02::RequestDecision::Modified(edit) => {
426            Some(RequestDecision::Modified(request_edit_from_v02(edit)?))
427        }
428        types_v02::RequestDecision::ShortCircuit(resp) => {
429            Some(RequestDecision::ShortCircuit(response_from_v02(resp)?))
430        }
431    }
432}
433
434pub(crate) fn response_decision_from_v02(
435    decision: types_v02::ResponseDecision,
436) -> Option<ResponseDecision> {
437    match decision {
438        types_v02::ResponseDecision::Continue => Some(ResponseDecision::Continue),
439        types_v02::ResponseDecision::Modified(edit) => {
440            Some(ResponseDecision::Modified(response_edit_from_v02(edit)?))
441        }
442    }
443}
444
445pub(crate) fn request_body_decision_from_v02(
446    decision: types_v02::RequestBodyDecision,
447) -> Option<RequestBodyDecision> {
448    match decision {
449        types_v02::RequestBodyDecision::Continue(body) => Some(RequestBodyDecision::Continue(body)),
450        types_v02::RequestBodyDecision::ShortCircuit(resp) => {
451            Some(RequestBodyDecision::ShortCircuit(response_from_v02(resp)?))
452        }
453    }
454}
455
456pub(crate) fn request_decision_from_v03(
457    decision: types_v03::RequestDecision,
458) -> Option<RequestDecision> {
459    match decision {
460        types_v03::RequestDecision::Continue => Some(RequestDecision::Continue),
461        types_v03::RequestDecision::Modified(edit) => {
462            Some(RequestDecision::Modified(request_edit_from_v03(edit)?))
463        }
464        types_v03::RequestDecision::ShortCircuit(resp) => {
465            Some(RequestDecision::ShortCircuit(response_from_v03(resp)?))
466        }
467    }
468}
469
470/// Map a 0.3 guest response decision to the validated native one. `replace` output is
471/// untrusted guest data on its way to the client, so it passes the SAME fail-closed header
472/// validation as a request-side `short-circuit` (ADR 000073 decision 4).
473pub(crate) fn response_decision_from_v03(
474    decision: types_v03::ResponseDecision,
475) -> Option<ResponseDecision> {
476    match decision {
477        types_v03::ResponseDecision::Continue => Some(ResponseDecision::Continue),
478        types_v03::ResponseDecision::Modified(edit) => {
479            Some(ResponseDecision::Modified(response_edit_from_v03(edit)?))
480        }
481        types_v03::ResponseDecision::Replace(resp) => {
482            Some(ResponseDecision::Replace(response_from_v03(resp)?))
483        }
484    }
485}
486
487pub(crate) fn request_body_decision_from_v03(
488    decision: types_v03::RequestBodyDecision,
489) -> Option<RequestBodyDecision> {
490    match decision {
491        types_v03::RequestBodyDecision::Continue(body) => Some(RequestBodyDecision::Continue(body)),
492        types_v03::RequestBodyDecision::ShortCircuit(resp) => {
493            Some(RequestBodyDecision::ShortCircuit(response_from_v03(resp)?))
494        }
495    }
496}
497
498/// Build a canonical header (byte-valued) for callers and tests.
499pub fn header(name: impl Into<String>, value: impl AsRef<[u8]>) -> Header {
500    Header {
501        name: name.into(),
502        value: value.as_ref().to_vec(),
503    }
504}
505
506mod v01_host {
507    use super::bindings_v01::plecto::filter::{
508        host_clock, host_config, host_counter, host_kv, host_log, host_ratelimit,
509    };
510    use crate::LogLevel;
511    use crate::bindings::plecto::filter::{
512        host_clock as host_clock_v03, host_config as host_config_v03,
513        host_counter as host_counter_v03, host_kv as host_kv_v03, host_log as host_log_v03,
514        host_ratelimit as host_ratelimit_v03,
515    };
516    use crate::state::HostState;
517
518    impl super::bindings_v01::plecto::filter::types::Host for HostState {}
519
520    fn log_level(level: host_log::Level) -> LogLevel {
521        match level {
522            host_log::Level::Trace => LogLevel::Trace,
523            host_log::Level::Debug => LogLevel::Debug,
524            host_log::Level::Info => LogLevel::Info,
525            host_log::Level::Warn => LogLevel::Warn,
526            host_log::Level::Error => LogLevel::Error,
527        }
528    }
529
530    impl host_log::Host for HostState {
531        fn log(&mut self, level: host_log::Level, message: String) {
532            host_log_v03::Host::log(self, log_level(level), message);
533        }
534    }
535
536    impl host_clock::Host for HostState {
537        fn now_ms(&mut self) -> u64 {
538            host_clock_v03::Host::now_ms(self)
539        }
540    }
541
542    impl host_kv::Host for HostState {
543        fn get(&mut self, key: String) -> Option<Vec<u8>> {
544            host_kv_v03::Host::get(self, key)
545        }
546        fn set(&mut self, key: String, value: Vec<u8>) {
547            host_kv_v03::Host::set(self, key, value);
548        }
549        fn delete(&mut self, key: String) {
550            host_kv_v03::Host::delete(self, key);
551        }
552    }
553
554    impl host_counter::Host for HostState {
555        fn increment(&mut self, key: String, delta: i64) -> i64 {
556            host_counter_v03::Host::increment(self, key, delta)
557        }
558        fn get(&mut self, key: String) -> i64 {
559            host_counter_v03::Host::get(self, key)
560        }
561    }
562
563    impl host_ratelimit::Host for HostState {
564        fn try_acquire(&mut self, key: String, cost: u64) -> host_ratelimit::Acquire {
565            let out = host_ratelimit_v03::Host::try_acquire(self, key, cost);
566            host_ratelimit::Acquire {
567                allowed: out.allowed,
568                remaining: out.remaining,
569                retry_after_ms: out.retry_after_ms,
570            }
571        }
572    }
573
574    impl host_config::Host for HostState {
575        fn get(&mut self, key: String) -> Option<String> {
576            host_config_v03::Host::get(self, key)
577        }
578    }
579}
580
581mod v02_host {
582    use super::bindings_v02::plecto::filter::{
583        host_clock, host_config, host_counter, host_kv, host_log, host_ratelimit,
584    };
585    use crate::LogLevel;
586    use crate::bindings::plecto::filter::{
587        host_clock as host_clock_v03, host_config as host_config_v03,
588        host_counter as host_counter_v03, host_kv as host_kv_v03, host_log as host_log_v03,
589        host_ratelimit as host_ratelimit_v03,
590    };
591    use crate::state::HostState;
592
593    impl super::bindings_v02::plecto::filter::types::Host for HostState {}
594
595    fn log_level(level: host_log::Level) -> LogLevel {
596        match level {
597            host_log::Level::Trace => LogLevel::Trace,
598            host_log::Level::Debug => LogLevel::Debug,
599            host_log::Level::Info => LogLevel::Info,
600            host_log::Level::Warn => LogLevel::Warn,
601            host_log::Level::Error => LogLevel::Error,
602        }
603    }
604
605    impl host_log::Host for HostState {
606        fn log(&mut self, level: host_log::Level, message: String) {
607            host_log_v03::Host::log(self, log_level(level), message);
608        }
609    }
610
611    impl host_clock::Host for HostState {
612        fn now_ms(&mut self) -> u64 {
613            host_clock_v03::Host::now_ms(self)
614        }
615    }
616
617    impl host_kv::Host for HostState {
618        fn get(&mut self, key: String) -> Option<Vec<u8>> {
619            host_kv_v03::Host::get(self, key)
620        }
621        fn set(&mut self, key: String, value: Vec<u8>) {
622            host_kv_v03::Host::set(self, key, value);
623        }
624        fn delete(&mut self, key: String) {
625            host_kv_v03::Host::delete(self, key);
626        }
627    }
628
629    impl host_counter::Host for HostState {
630        fn increment(&mut self, key: String, delta: i64) -> i64 {
631            host_counter_v03::Host::increment(self, key, delta)
632        }
633        fn get(&mut self, key: String) -> i64 {
634            host_counter_v03::Host::get(self, key)
635        }
636    }
637
638    impl host_ratelimit::Host for HostState {
639        fn try_acquire(&mut self, key: String, cost: u64) -> host_ratelimit::Acquire {
640            let out = host_ratelimit_v03::Host::try_acquire(self, key, cost);
641            host_ratelimit::Acquire {
642                allowed: out.allowed,
643                remaining: out.remaining,
644                retry_after_ms: out.retry_after_ms,
645            }
646        }
647    }
648
649    impl host_config::Host for HostState {
650        fn get(&mut self, key: String) -> Option<String> {
651            host_config_v03::Host::get(self, key)
652        }
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn rejects_crlf_in_guest_header_value() {
662        assert!(validate_and_header("x", b"a\r\nb").is_none());
663        assert!(validate_and_header("x", b"a\rb").is_none());
664        assert!(validate_and_header("x", b"a\nb").is_none());
665        assert!(validate_and_header("x", b"ok").is_some());
666    }
667
668    #[test]
669    fn hop_by_hop_guest_headers_are_dropped_not_fatal() {
670        // RFC 9110 §7.6.1 names never survive the proxy (the fast path strips them at egress),
671        // so the mappers drop them instead of failing the whole decision — a deployed filter
672        // that harmlessly sets `Connection: close` must not start failing every request.
673        let edit = types_v03::RequestDecision::Modified(types_v03::RequestEdit {
674            set_headers: vec![
675                types_v03::Header {
676                    name: "Connection".to_string(),
677                    value: b"close".to_vec(),
678                },
679                types_v03::Header {
680                    name: "x-user".to_string(),
681                    value: b"alice".to_vec(),
682                },
683            ],
684            remove_headers: vec![],
685        });
686        match request_decision_from_v03(edit) {
687            Some(RequestDecision::Modified(edit)) => {
688                assert_eq!(
689                    edit.set_headers.len(),
690                    1,
691                    "hop-by-hop dropped, the rest kept"
692                );
693                assert_eq!(edit.set_headers[0].name, "x-user");
694            }
695            other => panic!("expected Modified, got {other:?}"),
696        }
697        for name in [
698            "Keep-Alive",
699            "transfer-encoding",
700            "proxy-authorization",
701            "TE",
702            "upgrade",
703        ] {
704            let sc = types_v03::RequestDecision::ShortCircuit(types_v03::HttpResponse {
705                status: 200,
706                headers: vec![types_v03::Header {
707                    name: name.to_string(),
708                    value: b"x".to_vec(),
709                }],
710                body: Vec::new(),
711            });
712            match request_decision_from_v03(sc) {
713                Some(RequestDecision::ShortCircuit(resp)) => {
714                    assert!(resp.headers.is_empty(), "{name} must be dropped, not fatal");
715                }
716                other => panic!("expected ShortCircuit, got {other:?}"),
717            }
718        }
719    }
720
721    #[test]
722    fn validated_guest_status_accepts_only_http_range() {
723        assert_eq!(validated_guest_status(100), Some(100));
724        assert_eq!(validated_guest_status(200), Some(200));
725        assert_eq!(validated_guest_status(599), Some(599));
726        assert_eq!(validated_guest_status(99), None);
727        assert_eq!(validated_guest_status(600), None);
728        assert_eq!(validated_guest_status(0), None);
729        assert_eq!(validated_guest_status(1000), None);
730    }
731
732    #[test]
733    fn rejects_ctl_bytes_and_non_tchar_names_that_hyper_would_silently_drop() {
734        // Anything admitted here must survive the egress `HeaderName`/`HeaderValue::from_bytes`
735        // conversion — otherwise a header the guest set vanishes silently instead of failing
736        // closed at the contract boundary.
737        assert!(
738            validate_and_header("x:y", b"v").is_none(),
739            "':' is not tchar"
740        );
741        assert!(
742            validate_and_header("x y", b"v").is_none(),
743            "space is not tchar"
744        );
745        assert!(validate_and_header("x", b"a\0b").is_none(), "NUL is a CTL");
746        assert!(
747            validate_and_header("x", b"a\x7fb").is_none(),
748            "DEL is rejected"
749        );
750        assert!(validate_and_header("", b"v").is_none(), "empty name");
751    }
752
753    #[test]
754    fn accepts_obs_text_bytes_the_contract_exists_to_carry() {
755        // Non-UTF-8 / high bytes are legal field content (RFC 9110 obs-text) — rejecting them
756        // would defeat the `list<u8>` lift (ADR 000071).
757        let raw: &[u8] = &[0xC3, 0x28, 0xFF];
758        let h = validate_and_header("x-blob", raw).expect("obs-text is valid field content");
759        assert_eq!(h.value, raw);
760        assert!(
761            validate_and_header("x", b"tab\tok").is_some(),
762            "HTAB is legal"
763        );
764    }
765
766    #[test]
767    fn enforces_size_caps() {
768        assert!(validate_and_header(&"n".repeat(MAX_GUEST_HEADER_NAME_LEN), b"v").is_some());
769        assert!(validate_and_header(&"n".repeat(MAX_GUEST_HEADER_NAME_LEN + 1), b"v").is_none());
770        assert!(validate_and_header("x", &vec![b'a'; MAX_GUEST_HEADER_VALUE_LEN]).is_some());
771        assert!(validate_and_header("x", &vec![b'a'; MAX_GUEST_HEADER_VALUE_LEN + 1]).is_none());
772    }
773
774    #[test]
775    fn v01_projection_is_lossy_but_v01_continue_keeps_native_bytes() {
776        // The 0.1 guest sees the lossy string; `continue` never rebuilds headers from it —
777        // the host's canonical byte header is what flows on (ADR 000071 decision 2).
778        let req = HttpRequest {
779            method: "GET".to_string(),
780            path: "/".to_string(),
781            authority: "a".to_string(),
782            scheme: "https".to_string(),
783            headers: vec![Header {
784                name: "x-blob".to_string(),
785                value: vec![0xC3, 0x28],
786            }],
787        };
788        let projected = request_to_v01(&req);
789        assert_eq!(projected.headers[0].value, "\u{FFFD}(");
790        assert!(matches!(
791            request_decision_from_v01(types_v01::RequestDecision::Continue),
792            Some(RequestDecision::Continue)
793        ));
794    }
795
796    #[test]
797    fn v01_modified_applies_utf8_bytes_and_fails_closed_on_crlf() {
798        let ok = types_v01::RequestDecision::Modified(types_v01::RequestEdit {
799            set_headers: vec![types_v01::Header {
800                name: "x-user".to_string(),
801                value: "alice".to_string(),
802            }],
803            remove_headers: vec![],
804        });
805        match request_decision_from_v01(ok) {
806            Some(RequestDecision::Modified(edit)) => {
807                assert_eq!(edit.set_headers[0].value, b"alice");
808            }
809            other => panic!("expected Modified, got {other:?}"),
810        }
811
812        let bad = types_v01::RequestDecision::Modified(types_v01::RequestEdit {
813            set_headers: vec![types_v01::Header {
814                name: "x-evil".to_string(),
815                value: "a\r\nx-smuggled: 1".to_string(),
816            }],
817            remove_headers: vec![],
818        });
819        assert!(
820            request_decision_from_v01(bad).is_none(),
821            "CRLF fails closed"
822        );
823    }
824
825    #[test]
826    fn v03_replace_output_passes_the_same_fail_closed_validation_as_short_circuit() {
827        // ADR 000073 decision 4: `replace` output is untrusted guest data headed for the client,
828        // so it passes the SAME header validation as a request-side short-circuit. CRLF fails
829        // closed (the mapper returns None → RunError::InvalidOutput); clean bytes pass intact.
830        let bad = types_v03::ResponseDecision::Replace(types_v03::HttpResponse {
831            status: 200,
832            headers: vec![types_v03::Header {
833                name: "x-evil".to_string(),
834                value: b"a\r\nx-smuggled: 1".to_vec(),
835            }],
836            body: b"payload".to_vec(),
837        });
838        assert!(
839            response_decision_from_v03(bad).is_none(),
840            "CRLF in a replace header fails closed"
841        );
842
843        let ok = types_v03::ResponseDecision::Replace(types_v03::HttpResponse {
844            status: 418,
845            headers: vec![types_v03::Header {
846                name: "x-blob".to_string(),
847                value: vec![0xC3, 0x28, 0xFF],
848            }],
849            body: b"payload".to_vec(),
850        });
851        match response_decision_from_v03(ok) {
852            Some(ResponseDecision::Replace(resp)) => {
853                assert_eq!(resp.status, 418);
854                assert_eq!(resp.headers[0].value, vec![0xC3, 0x28, 0xFF]);
855                assert_eq!(resp.body, b"payload");
856            }
857            other => panic!("expected Replace, got {other:?}"),
858        }
859    }
860
861    #[test]
862    fn v02_projection_is_byte_faithful() {
863        // The 0.2 projection is a mechanical clone (same byte-valued shape), NOT the 0.1
864        // lossy-UTF-8 form: non-UTF-8 header bytes survive verbatim.
865        let req = HttpRequest {
866            method: "GET".to_string(),
867            path: "/".to_string(),
868            authority: "a".to_string(),
869            scheme: "https".to_string(),
870            headers: vec![Header {
871                name: "x-blob".to_string(),
872                value: vec![0xC3, 0x28],
873            }],
874        };
875        let projected = request_to_v02(&req);
876        assert_eq!(projected.headers[0].value, vec![0xC3, 0x28]);
877        let resp = HttpResponse {
878            status: 200,
879            headers: vec![Header {
880                name: "x-blob".to_string(),
881                value: vec![0xFF],
882            }],
883            body: b"b".to_vec(),
884        };
885        assert_eq!(response_to_v02(&resp).headers[0].value, vec![0xFF]);
886    }
887
888    #[test]
889    fn detects_version_from_decoded_imports_not_bytes() {
890        // Real components prune unused imports (the MoonBit fixture has no host-kv/clock/config),
891        // so detection must key on ANY plecto:filter interface — and must read the decoded type
892        // structure, not raw bytes. Unknown / absent versions fail closed (`None`) at load.
893        let engine = wasmtime::Engine::default();
894        let cases: &[(&str, Option<ContractVersion>)] = &[
895            (
896                r#"(component (import "plecto:filter/host-log@0.1.0" (instance)))"#,
897                Some(ContractVersion::V01),
898            ),
899            // a 0.1 guest that never logs: host-log is pruned, another interface remains.
900            (
901                r#"(component (import "plecto:filter/host-clock@0.1.0" (instance)))"#,
902                Some(ContractVersion::V01),
903            ),
904            (
905                r#"(component (import "plecto:filter/host-log@0.2.0" (instance)))"#,
906                Some(ContractVersion::V02),
907            ),
908            (
909                r#"(component (import "plecto:filter/host-log@0.3.0" (instance)))"#,
910                Some(ContractVersion::V03),
911            ),
912            (
913                r#"(component (import "plecto:filter/host-clock@0.2.0" (instance)))"#,
914                Some(ContractVersion::V02),
915            ),
916            // no plecto import at all: fail closed at load (do not guess V03).
917            (r"(component)", None),
918            // future / unknown track: fail closed (do not silently bind as V03).
919            (
920                r#"(component (import "plecto:filter/host-log@0.4.0" (instance)))"#,
921                None,
922            ),
923        ];
924        for (wat, want) in cases {
925            let component =
926                wasmtime::component::Component::new(&engine, wat).expect("valid component wat");
927            assert_eq!(
928                detect_contract_version(&component, &engine),
929                *want,
930                "wat: {wat}"
931            );
932        }
933    }
934
935    #[test]
936    fn oversize_guest_response_body_fails_closed() {
937        let ok = types_v03::ResponseDecision::Replace(types_v03::HttpResponse {
938            status: 200,
939            headers: vec![],
940            body: vec![0u8; MAX_GUEST_RESPONSE_BODY_LEN],
941        });
942        assert!(response_decision_from_v03(ok).is_some());
943
944        let over = types_v03::ResponseDecision::Replace(types_v03::HttpResponse {
945            status: 200,
946            headers: vec![],
947            body: vec![0u8; MAX_GUEST_RESPONSE_BODY_LEN + 1],
948        });
949        assert!(
950            response_decision_from_v03(over).is_none(),
951            "a synthesised body over the cap must fail closed"
952        );
953    }
954}