1mod bindings_v01 {
5 wasmtime::component::bindgen!({
7 path: "wit/v0.1.0",
8 world: "filter",
9 exports: { default: async },
10 });
11}
12
13mod bindings_v02 {
14 wasmtime::component::bindgen!({
16 path: "wit/v0.2.0",
17 world: "filter",
18 exports: { default: async },
19 });
20}
21
22pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ContractVersion {
47 V01,
48 V02,
49 V03,
50}
51
52pub(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 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
116pub(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;
165pub(crate) const MAX_GUEST_RESPONSE_BODY_LEN: usize = 1 << 20; fn 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
179fn is_field_value_byte(b: u8) -> bool {
183 b == b'\t' || (b >= 0x20 && b != 0x7f)
184}
185
186const 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
209fn 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
329fn 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
380pub(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
467pub(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
495pub 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 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 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 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 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 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 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 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 (
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 (r"(component)", None),
915 (
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}