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
186#[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
212fn 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
332fn 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
383pub(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
470pub(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
498pub 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 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 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 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 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 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 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 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 (
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 (r"(component)", None),
918 (
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}