1#![warn(missing_docs)]
32
33pub mod client;
34pub mod config;
35pub mod decision;
36pub mod exclusions;
37pub mod hooks;
38pub mod observer;
39pub mod request;
40pub mod script_injection;
41
42use std::time::Instant;
43
44use fastly::{Error, Request, Response};
45use regex::Regex;
46
47pub use config::{CentinelConfig, ConfigError, DEFAULT_EXCLUSION_PATTERN};
48pub use decision::{CookieData, CrawlerInfo, Decision, ValidationResponse, MAX_REDIRECT_HTML_SIZE};
49pub use hooks::Hooks;
50pub use observer::{
51 CentinelEvent, CentinelObserver, JsonLogObserver, NoopObserver, RecorderObserver, TraceContext,
52};
53
54const BASE64_DECODE_TABLE: [u8; 256] = {
55 let mut table = [255u8; 256];
56 let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
57 let mut i = 0;
58 while i < 64 {
59 table[alphabet[i] as usize] = i as u8;
60 i += 1;
61 }
62 table[b'=' as usize] = 64;
63 table
64};
65
66fn base64_decode(encoded: &str) -> Result<String, String> {
67 let bytes = base64_decode_bytes(encoded)?;
68 std::str::from_utf8(&bytes)
69 .map(|s| s.to_string())
70 .map_err(|e| format!("UTF-8 decode error: {}", e))
71}
72
73fn base64_decode_bytes(encoded: &str) -> Result<Vec<u8>, String> {
74 let input: Vec<u8> = encoded
75 .bytes()
76 .filter(|b| !b.is_ascii_whitespace())
77 .collect();
78 let mut result = Vec::with_capacity(input.len() * 3 / 4);
79
80 for chunk in input.chunks(4) {
81 if chunk.is_empty() {
82 break;
83 }
84
85 let mut buf = [0u8; 4];
86 for (i, &b) in chunk.iter().enumerate() {
87 let val = BASE64_DECODE_TABLE[b as usize];
88 if val == 255 {
89 return Err(format!("Invalid base64 character: {}", b as char));
90 }
91 buf[i] = val;
92 }
93
94 result.push((buf[0] << 2) | (buf[1] >> 4));
95 if buf[2] != 64 {
96 result.push((buf[1] << 4) | (buf[2] >> 2));
97 }
98 if buf[3] != 64 {
99 result.push((buf[2] << 6) | buf[3]);
100 }
101 }
102
103 Ok(result)
104}
105
106fn apply_cookies(response: &mut Response, cookies: &[CookieData]) {
107 for cookie in cookies {
108 let mut val = format!("{}={}", cookie.name, cookie.value);
109 if let Some(ref path) = cookie.path {
110 if !path.is_empty() {
111 val.push_str("; Path=");
112 val.push_str(path);
113 }
114 }
115 if let Some(ref domain) = cookie.domain {
116 if !domain.is_empty() {
117 val.push_str("; Domain=");
118 val.push_str(domain);
119 }
120 }
121 val.push_str("; SameSite=Lax; Secure");
122 response.append_header("Set-Cookie", val);
123 }
124}
125
126fn apply_response_headers(
127 response: &mut Response,
128 headers: &Option<std::collections::HashMap<String, String>>,
129) {
130 if let Some(ref headers) = headers {
131 for (name, value) in headers {
132 if !decision::is_blocked_header(name) {
133 response.set_header(name, value);
134 }
135 }
136 }
137}
138
139fn decode_decision_html(encoded: Option<&str>, fallback: &str) -> String {
140 match encoded {
141 Some(b64) if !b64.is_empty() && b64.len() <= MAX_REDIRECT_HTML_SIZE => {
142 base64_decode(b64).unwrap_or_else(|_| fallback.to_string())
143 }
144 _ => fallback.to_string(),
145 }
146}
147
148fn append_server_timing(response: &mut Response, duration_ms: u128) {
149 let entry = format!("validator;dur={}", duration_ms);
150 let existing = response
151 .get_header("Server-Timing")
152 .and_then(|v| std::str::from_utf8(v.as_bytes()).ok())
153 .filter(|s| !s.is_empty());
154 let merged = match existing {
155 Some(existing) => format!("{}, {}", existing, entry),
156 None => entry,
157 };
158 response.set_header("Server-Timing", merged);
159}
160
161fn add_correlation_headers(
162 response: &mut Response,
163 decision: &Decision,
164 request_id: Option<&str>,
165 duration_ms: u128,
166) {
167 append_server_timing(response, duration_ms);
168 response.set_header("x-centinel-decision", decision.as_str());
169 if let Some(rid) = request_id {
170 response.set_header("x-centinel-request-id", rid);
171 }
172}
173
174fn build_decision_response(
175 status: u16,
176 body: impl Into<fastly::Body>,
177 api_response: &ValidationResponse,
178 duration_ms: u128,
179) -> Response {
180 let mut response = Response::from_status(status).with_body(body);
181 apply_response_headers(&mut response, &api_response.headers);
182 apply_cookies(&mut response, &api_response.cookies);
183 add_correlation_headers(
184 &mut response,
185 &api_response.decision,
186 api_response.request_id.as_deref(),
187 duration_ms,
188 );
189 response
190}
191
192pub struct Centinel {
206 config: CentinelConfig,
207 exclusion_regex: Option<Regex>,
208 inclusion_regex: Option<Regex>,
209 observer: Box<dyn CentinelObserver>,
210 hooks: Hooks,
211}
212
213struct CheckOutcome {
214 response: Option<Response>,
215 validator_duration_ms: Option<u128>,
216}
217
218impl Centinel {
219 pub fn new() -> Result<Self, ConfigError> {
222 let config = CentinelConfig::from_config_store()?;
223 Self::with_config(config)
224 }
225
226 pub fn with_config(config: CentinelConfig) -> Result<Self, ConfigError> {
231 let exclusion_regex = config
232 .url_exclusion_pattern
233 .as_ref()
234 .map(|p| Regex::new(p).map_err(|e| ConfigError::InvalidPattern(e.to_string())))
235 .transpose()?;
236
237 let inclusion_regex = config
238 .url_inclusion_pattern
239 .as_ref()
240 .map(|p| Regex::new(p).map_err(|e| ConfigError::InvalidPattern(e.to_string())))
241 .transpose()?;
242
243 Ok(Self {
244 config,
245 exclusion_regex,
246 inclusion_regex,
247 observer: Box::new(NoopObserver),
248 hooks: Hooks::default(),
249 })
250 }
251
252 pub fn with_observer<O: CentinelObserver + 'static>(mut self, observer: O) -> Self {
255 self.observer = Box::new(observer);
256 self
257 }
258
259 pub fn with_hooks(mut self, hooks: Hooks) -> Self {
261 self.hooks = hooks;
262 self
263 }
264
265 pub fn protect<F>(req: Request, on_allowed: F) -> Result<Response, Error>
272 where
273 F: FnOnce(Request) -> Result<Response, Error>,
274 {
275 match Self::new() {
276 Ok(centinel) => centinel.handle_request(req, on_allowed),
277 Err(_) => on_allowed(req),
278 }
279 }
280
281 pub fn check_request(&self, req: &Request) -> Result<Option<Response>, Error> {
287 Ok(self.check_internal(req)?.response)
288 }
289
290 fn check_internal(&self, req: &Request) -> Result<CheckOutcome, Error> {
291 let path = req.get_path();
292
293 if exclusions::should_exclude_path(
294 path,
295 self.exclusion_regex.as_ref(),
296 self.inclusion_regex.as_ref(),
297 ) {
298 self.observer
299 .on_event(&CentinelEvent::PathExcluded { path });
300 return Ok(CheckOutcome {
301 response: None,
302 validator_duration_ms: None,
303 });
304 }
305
306 let mut validation_req = request::ValidationRequest::from_fastly_request(req);
307 if let Some(hook) = &self.hooks.before_validate {
308 hook(&mut validation_req);
309 }
310
311 let trace = TraceContext::from_request(req);
312 let validator_url = format!("{}/validate", self.config.api_endpoint);
313 self.observer.on_event(&CentinelEvent::ValidatorCallStart {
314 url: &validator_url,
315 trace_ctx: trace,
316 });
317
318 let client = client::CentinelClient::new(&self.config);
319 let call_started = Instant::now();
320
321 match client.validate(&validation_req, trace) {
322 Ok((mut api_response, latency)) => {
323 let duration_ms = latency.as_millis();
324 self.observer.on_event(&CentinelEvent::ValidatorCallEnd {
325 status: 200,
326 duration_ms,
327 request_id: api_response.request_id.as_deref(),
328 });
329
330 if let Some(hook) = &self.hooks.after_validate {
331 hook(&mut api_response);
332 }
333
334 if !api_response.success {
339 return Ok(CheckOutcome {
340 response: None,
341 validator_duration_ms: Some(duration_ms),
342 });
343 }
344
345 let status_code = match api_response.decision {
346 Decision::Block => api_response.validated_status_code(403),
347 _ => api_response.validated_status_code(200),
348 };
349
350 self.observer.on_event(&CentinelEvent::Decision {
351 decision: &api_response.decision,
352 status_code,
353 request_id: api_response.request_id.as_deref(),
354 crawler: api_response.crawler.as_ref(),
355 validator_duration_ms: duration_ms,
356 });
357
358 let response = match api_response.decision {
359 Decision::Allow | Decision::NotMatched | Decision::Unknown => None,
360 Decision::Block => {
361 let body = decode_decision_html(
362 api_response.response_html.as_deref(),
363 "Access Denied",
364 );
365 let mut resp =
366 build_decision_response(status_code, body, &api_response, duration_ms);
367 if let Some(hook) = &self.hooks.decorate_response {
368 hook(&mut resp, &Decision::Block);
369 }
370 Some(resp)
371 }
372 Decision::Redirect => {
373 let html_b64 = match api_response.response_html {
374 Some(ref h) => h,
375 None => {
376 return Ok(CheckOutcome {
377 response: None,
378 validator_duration_ms: Some(duration_ms),
379 })
380 }
381 };
382 if html_b64.len() > MAX_REDIRECT_HTML_SIZE {
383 return Ok(CheckOutcome {
384 response: None,
385 validator_duration_ms: Some(duration_ms),
386 });
387 }
388 match base64_decode(html_b64) {
389 Ok(html) => {
390 let mut resp = build_decision_response(
391 status_code,
392 html,
393 &api_response,
394 duration_ms,
395 );
396 if let Some(hook) = &self.hooks.decorate_response {
397 hook(&mut resp, &Decision::Redirect);
398 }
399 Some(resp)
400 }
401 Err(_) => None,
402 }
403 }
404 };
405
406 Ok(CheckOutcome {
407 response,
408 validator_duration_ms: Some(duration_ms),
409 })
410 }
411 Err(e) => {
412 let duration_ms = call_started.elapsed().as_millis();
413 self.observer.on_event(&CentinelEvent::ValidatorError {
414 error: &e,
415 duration_ms,
416 });
417 Ok(CheckOutcome {
418 response: None,
419 validator_duration_ms: Some(duration_ms),
420 })
421 }
422 }
423 }
424
425 pub fn inject_script(&self, response: Response) -> Response {
428 if let Some(ref site_key) = self.config.site_key {
429 let (injected, bytes_added) = script_injection::inject_script(response, site_key);
430 if bytes_added > 0 {
431 self.observer
432 .on_event(&CentinelEvent::ScriptInjected { bytes_added });
433 }
434 injected
435 } else {
436 response
437 }
438 }
439
440 pub fn has_site_key(&self) -> bool {
442 self.config.site_key.is_some()
443 }
444
445 pub fn handle_request<F>(&self, req: Request, on_allowed: F) -> Result<Response, Error>
450 where
451 F: FnOnce(Request) -> Result<Response, Error>,
452 {
453 let outcome = self.check_internal(&req)?;
454 if let Some(resp) = outcome.response {
455 return Ok(resp);
456 }
457
458 let mut resp = on_allowed(req)?;
459
460 if let Some(dur) = outcome.validator_duration_ms {
461 append_server_timing(&mut resp, dur);
462 }
463
464 if self.has_site_key() {
465 resp = self.inject_script(resp);
466 }
467
468 Ok(resp)
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
479 fn test_base64_lookup_table_correctness() {
480 let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
481 for (i, &byte) in alphabet.iter().enumerate() {
482 assert_eq!(
483 BASE64_DECODE_TABLE[byte as usize], i as u8,
484 "Lookup table wrong for '{}' at index {}",
485 byte as char, i
486 );
487 }
488 assert_eq!(BASE64_DECODE_TABLE[b'=' as usize], 64);
489 for b in [b'!', b'@', b'#', b'$', b'%', b'^', b'&', b'*'] {
490 assert_eq!(BASE64_DECODE_TABLE[b as usize], 255);
491 }
492 }
493
494 #[test]
495 fn test_base64_known_values() {
496 assert_eq!(base64_decode("SGVsbG8=").unwrap(), "Hello");
497 assert_eq!(base64_decode("SGVsbG8gV29ybGQ=").unwrap(), "Hello World");
498 assert_eq!(base64_decode("").unwrap(), "");
499 assert_eq!(base64_decode("YQ==").unwrap(), "a");
500 assert_eq!(base64_decode("YWI=").unwrap(), "ab");
501 assert_eq!(base64_decode("YWJj").unwrap(), "abc");
502 }
503
504 #[test]
505 fn test_base64_html_payload() {
506 let html = "<html><body><h1>Challenge</h1></body></html>";
507 let encoded = "PGh0bWw+PGJvZHk+PGgxPkNoYWxsZW5nZTwvaDE+PC9ib2R5PjwvaHRtbD4=";
508 assert_eq!(base64_decode(encoded).unwrap(), html);
509 }
510
511 #[test]
512 fn test_base64_with_whitespace() {
513 let encoded = "SGVs\nbG8g\nV29y\nbGQ=";
515 assert_eq!(base64_decode(encoded).unwrap(), "Hello World");
516 }
517
518 #[test]
519 fn test_base64_invalid_character() {
520 assert!(base64_decode("SGVs!G8=").is_err());
521 assert!(base64_decode("@@@").is_err());
522 }
523
524 #[test]
525 fn test_base64_non_utf8_returns_error() {
526 let result = base64_decode("/w==");
528 assert!(
529 result.is_err(),
530 "Expected UTF-8 error for non-UTF8 bytes, got: {:?}",
531 result
532 );
533
534 let bytes = base64_decode_bytes("/w==").unwrap();
536 assert_eq!(bytes, vec![0xFF]);
537 }
538
539 #[test]
542 fn test_with_config_valid_regex() {
543 let config = CentinelConfig {
544 url_exclusion_pattern: Some(r"\.(js|css)$".to_string()),
545 url_inclusion_pattern: Some(r"^/api/".to_string()),
546 ..CentinelConfig::default()
547 };
548 assert!(Centinel::with_config(config).is_ok());
549 }
550
551 #[test]
552 fn test_with_config_invalid_exclusion_regex() {
553 let config = CentinelConfig {
554 url_exclusion_pattern: Some(r"[invalid".to_string()),
555 ..CentinelConfig::default()
556 };
557 let result = Centinel::with_config(config);
558 assert!(result.is_err());
559 let err = result.err().unwrap();
560 assert!(matches!(err, ConfigError::InvalidPattern(_)));
561 }
562
563 #[test]
564 fn test_with_config_invalid_inclusion_regex() {
565 let config = CentinelConfig {
566 url_inclusion_pattern: Some(r"(unclosed".to_string()),
567 ..CentinelConfig::default()
568 };
569 let result = Centinel::with_config(config);
570 assert!(result.is_err());
571 let err = result.err().unwrap();
572 assert!(matches!(err, ConfigError::InvalidPattern(_)));
573 }
574
575 #[test]
576 fn test_with_config_no_patterns() {
577 let config = CentinelConfig {
578 url_exclusion_pattern: None,
579 url_inclusion_pattern: None,
580 ..CentinelConfig::default()
581 };
582 let centinel = Centinel::with_config(config).unwrap();
583 assert!(centinel.exclusion_regex.is_none());
584 assert!(centinel.inclusion_regex.is_none());
585 }
586
587 #[test]
590 fn test_decode_decision_html_decodes_valid_base64() {
591 assert_eq!(
592 decode_decision_html(Some("PGgxPkJsb2NrZWQ8L2gxPg=="), "fallback"),
593 "<h1>Blocked</h1>"
594 );
595 }
596
597 #[test]
598 fn test_decode_decision_html_none_returns_fallback() {
599 assert_eq!(decode_decision_html(None, "Access Denied"), "Access Denied");
600 }
601
602 #[test]
603 fn test_decode_decision_html_empty_returns_fallback() {
604 assert_eq!(
605 decode_decision_html(Some(""), "Access Denied"),
606 "Access Denied"
607 );
608 }
609
610 #[test]
611 fn test_decode_decision_html_invalid_base64_returns_fallback() {
612 assert_eq!(
613 decode_decision_html(Some("!!!not-base64!!!"), "Access Denied"),
614 "Access Denied"
615 );
616 }
617
618 #[test]
619 fn test_decode_decision_html_oversize_returns_fallback() {
620 let huge = "A".repeat(MAX_REDIRECT_HTML_SIZE + 1);
621 assert_eq!(
622 decode_decision_html(Some(&huge), "Access Denied"),
623 "Access Denied"
624 );
625 }
626
627 #[test]
630 fn test_apply_cookies_full() {
631 let cookies = vec![CookieData {
632 name: "_centinel".to_string(),
633 value: "abc123".to_string(),
634 path: Some("/".to_string()),
635 domain: Some(".example.com".to_string()),
636 }];
637 let mut response = Response::from_status(200);
638 apply_cookies(&mut response, &cookies);
639 let cookie_header = response.get_header_str("Set-Cookie").unwrap().to_string();
640 assert!(cookie_header.contains("_centinel=abc123"));
641 assert!(cookie_header.contains("; Path=/"));
642 assert!(cookie_header.contains("; Domain=.example.com"));
643 }
644
645 #[test]
646 fn test_apply_cookies_no_optional_fields() {
647 let cookies = vec![CookieData {
648 name: "simple".to_string(),
649 value: "val".to_string(),
650 path: None,
651 domain: None,
652 }];
653 let mut response = Response::from_status(200);
654 apply_cookies(&mut response, &cookies);
655 let cookie_header = response.get_header_str("Set-Cookie").unwrap().to_string();
656 assert_eq!(cookie_header, "simple=val; SameSite=Lax; Secure");
657 }
658
659 #[test]
660 fn test_apply_cookies_empty() {
661 let mut response = Response::from_status(200);
662 apply_cookies(&mut response, &[]);
663 assert!(response.get_header_str("Set-Cookie").is_none());
664 }
665
666 #[test]
669 fn test_apply_response_headers_filters_blocked() {
670 let mut headers = std::collections::HashMap::new();
671 headers.insert("Content-Type".to_string(), "text/html".to_string());
672 headers.insert("Transfer-Encoding".to_string(), "chunked".to_string());
673 headers.insert("Connection".to_string(), "keep-alive".to_string());
674 headers.insert("X-Custom".to_string(), "allowed".to_string());
675
676 let mut response = Response::from_status(200);
677 apply_response_headers(&mut response, &Some(headers));
678
679 assert_eq!(
680 response.get_header_str("Content-Type").unwrap(),
681 "text/html"
682 );
683 assert_eq!(response.get_header_str("X-Custom").unwrap(), "allowed");
684 assert!(response.get_header_str("Transfer-Encoding").is_none());
685 assert!(response.get_header_str("Connection").is_none());
686 }
687
688 #[test]
689 fn test_apply_response_headers_none() {
690 let mut response = Response::from_status(200);
691 apply_response_headers(&mut response, &None);
692 }
694
695 #[test]
698 fn test_build_decision_response_block() {
699 let api_response = ValidationResponse {
700 success: true,
701 status_code: Some(403),
702 request_id: Some("req-123".to_string()),
703 decision: Decision::Block,
704 response_html: None,
705 cookies: vec![CookieData {
706 name: "_c".to_string(),
707 value: "v1".to_string(),
708 path: Some("/".to_string()),
709 domain: None,
710 }],
711 headers: Some({
712 let mut h = std::collections::HashMap::new();
713 h.insert("Cache-Control".to_string(), "no-store".to_string());
714 h
715 }),
716 crawler: None,
717 };
718
719 let resp = build_decision_response(403, "Blocked", &api_response, 42);
720 assert_eq!(resp.get_status().as_u16(), 403);
721 assert_eq!(
722 resp.get_header_str("Server-Timing").unwrap(),
723 "validator;dur=42"
724 );
725 assert_eq!(resp.get_header_str("x-centinel-decision").unwrap(), "block");
726 assert_eq!(
727 resp.get_header_str("x-centinel-request-id").unwrap(),
728 "req-123"
729 );
730 assert_eq!(resp.get_header_str("Cache-Control").unwrap(), "no-store");
731 assert!(resp.get_header_str("Set-Cookie").unwrap().contains("_c=v1"));
732 }
733
734 #[test]
735 fn test_append_server_timing_merges_existing() {
736 let mut resp = Response::from_status(200);
737 resp.set_header("Server-Timing", "origin;dur=10");
738 append_server_timing(&mut resp, 42);
739 assert_eq!(
740 resp.get_header_str("Server-Timing").unwrap(),
741 "origin;dur=10, validator;dur=42"
742 );
743 }
744
745 #[test]
746 fn test_append_server_timing_sets_when_missing() {
747 let mut resp = Response::from_status(200);
748 append_server_timing(&mut resp, 7);
749 assert_eq!(
750 resp.get_header_str("Server-Timing").unwrap(),
751 "validator;dur=7"
752 );
753 }
754
755 #[test]
758 fn test_inject_script_emits_event_for_html() {
759 let config = CentinelConfig {
760 site_key: Some("test-key".to_string()),
761 ..CentinelConfig::default()
762 };
763 let recorder = RecorderObserver::new();
764 let centinel = Centinel::with_config(config).unwrap();
765 let centinel = centinel.with_observer(recorder);
766 let html = Response::new().with_body_text_html("<html><body>x</body></html>");
767 let _out = centinel.inject_script(html);
768 }
772
773 #[test]
774 fn test_inject_script_no_event_for_non_html() {
775 use std::cell::RefCell;
776 use std::rc::Rc;
777
778 struct RcRecorder(Rc<RefCell<usize>>);
779 impl CentinelObserver for RcRecorder {
780 fn on_event(&self, _e: &CentinelEvent<'_>) {
781 *self.0.borrow_mut() += 1;
782 }
783 }
784
785 let counter = Rc::new(RefCell::new(0usize));
786 let config = CentinelConfig {
787 site_key: Some("k".to_string()),
788 ..CentinelConfig::default()
789 };
790 let centinel = Centinel::with_config(config)
791 .unwrap()
792 .with_observer(RcRecorder(counter.clone()));
793
794 let json = Response::new()
795 .with_body_text_plain(r#"{"x":1}"#)
796 .with_header("Content-Type", "application/json");
797 let _ = centinel.inject_script(json);
798 assert_eq!(*counter.borrow(), 0, "no event for non-HTML");
799
800 let html = Response::new().with_body_text_html("<p>hi</p>");
801 let _ = centinel.inject_script(html);
802 assert_eq!(*counter.borrow(), 1, "one event for HTML injection");
803 }
804
805 #[test]
808 fn test_has_site_key() {
809 let config_with = CentinelConfig {
810 site_key: Some("key".to_string()),
811 ..CentinelConfig::default()
812 };
813 let centinel = Centinel::with_config(config_with).unwrap();
814 assert!(centinel.has_site_key());
815
816 let config_without = CentinelConfig {
817 site_key: None,
818 ..CentinelConfig::default()
819 };
820 let centinel = Centinel::with_config(config_without).unwrap();
821 assert!(!centinel.has_site_key());
822 }
823}
824
825#[cfg(test)]
826mod perf_tests {
827 use super::*;
828 use std::time::Instant;
829
830 fn make_base64_payload(decoded_size: usize) -> String {
831 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
832 let encoded_len = decoded_size.div_ceil(3) * 4;
833 (0..encoded_len).map(|i| ALPHABET[i % 64] as char).collect()
834 }
835
836 #[test]
837 fn perf_base64_decode_1kb() {
838 let payload = make_base64_payload(1024);
839 let iterations = 1000;
840 let start = Instant::now();
841 for _ in 0..iterations {
842 let _ = base64_decode_bytes(payload.as_str());
843 }
844 let per_op = start.elapsed() / iterations;
845 assert!(
846 per_op.as_micros() < 500,
847 "base64 1KB: {:?}/op (limit: 500µs)",
848 per_op
849 );
850 }
851
852 #[test]
853 fn perf_base64_decode_10kb() {
854 let payload = make_base64_payload(10 * 1024);
855 let iterations = 100;
856 let start = Instant::now();
857 for _ in 0..iterations {
858 let _ = base64_decode_bytes(payload.as_str());
859 }
860 let per_op = start.elapsed() / iterations;
861 assert!(
862 per_op.as_micros() < 2000,
863 "base64 10KB: {:?}/op (limit: 2ms)",
864 per_op
865 );
866 }
867
868 #[test]
869 fn perf_base64_decode_100kb() {
870 let payload = make_base64_payload(100 * 1024);
871 let iterations = 10;
872 let start = Instant::now();
873 for _ in 0..iterations {
874 let _ = base64_decode_bytes(payload.as_str());
875 }
876 let per_op = start.elapsed() / iterations;
877 assert!(
878 per_op.as_millis() < 20,
879 "base64 100KB: {:?}/op (limit: 20ms)",
880 per_op
881 );
882 }
883
884 #[test]
885 fn perf_path_exclusion_default_pattern() {
886 let regex = Regex::new(DEFAULT_EXCLUSION_PATTERN).unwrap();
887 let paths = [
888 "/static/app.js",
889 "/images/logo.png",
890 "/fonts/font.woff2",
891 "/api/users",
892 "/index.html",
893 "/products/123",
894 "/styles/main.css",
895 "/video/stream.mp4",
896 ];
897 let iterations = 10_000;
898 let start = Instant::now();
899 for _ in 0..iterations {
900 for path in &paths {
901 let _ = exclusions::should_exclude_path(path, Some(®ex), None);
902 }
903 }
904 let per_op = start.elapsed() / (iterations * paths.len() as u32);
905 assert!(
906 per_op.as_micros() < 50,
907 "path exclusion: {:?}/op (limit: 50µs)",
908 per_op
909 );
910 }
911
912 #[test]
913 fn perf_path_exclusion_combined_patterns() {
914 let exclusion = Regex::new(DEFAULT_EXCLUSION_PATTERN).unwrap();
915 let inclusion = Regex::new(r"^/(api|admin|protected)/").unwrap();
916 let paths = [
917 "/api/users",
918 "/admin/dashboard",
919 "/protected/data",
920 "/public/page",
921 "/api/data.json",
922 "/static/app.js",
923 ];
924 let iterations = 10_000;
925 let start = Instant::now();
926 for _ in 0..iterations {
927 for path in &paths {
928 let _ = exclusions::should_exclude_path(path, Some(&exclusion), Some(&inclusion));
929 }
930 }
931 let per_op = start.elapsed() / (iterations * paths.len() as u32);
932 assert!(
933 per_op.as_micros() < 50,
934 "combined path exclusion: {:?}/op (limit: 50µs)",
935 per_op
936 );
937 }
938
939 #[test]
940 fn perf_script_injection_small_html() {
941 let html = "<html><head><title>Test</title></head><body><p>Hello</p></body></html>";
942 let iterations = 1000;
943 let start = Instant::now();
944 for _ in 0..iterations {
945 let response = Response::new().with_body_text_html(html);
946 let (_resp, _bytes) = script_injection::inject_script(response, "test-site-key-abc123");
947 }
948 let per_op = start.elapsed() / iterations;
949 assert!(
950 per_op.as_micros() < 500,
951 "script injection small: {:?}/op (limit: 500µs)",
952 per_op
953 );
954 }
955
956 #[test]
957 fn perf_script_injection_large_html() {
958 let body_content: String = "<p>Lorem ipsum dolor sit amet. </p>\n".repeat(1000);
959 let html = format!(
960 "<html><head><title>Large</title></head><body>{}</body></html>",
961 body_content
962 );
963 let iterations = 100;
964 let start = Instant::now();
965 for _ in 0..iterations {
966 let response = Response::new().with_body_text_html(&html);
967 let (_resp, _bytes) = script_injection::inject_script(response, "test-site-key-abc123");
968 }
969 let per_op = start.elapsed() / iterations;
970 assert!(
971 per_op.as_millis() < 10,
972 "script injection large: {:?}/op (limit: 10ms)",
973 per_op
974 );
975 }
976
977 #[test]
978 fn perf_validation_response_deserialize() {
979 let json = r#"{"success":true,"decision":"allow","status_code":200,"request_id":"req-abc",
980 "cookies":[{"name":"_c","value":"v1","path":"/","domain":".example.com"}],
981 "headers":{"Content-Type":"text/html","Cache-Control":"no-store"},
982 "crawler":{"id":"b1","name":"Googlebot","category":"search","access_allowed":true}}"#;
983 let iterations = 5000;
984 let start = Instant::now();
985 for _ in 0..iterations {
986 let _: ValidationResponse = serde_json::from_str(json).unwrap();
987 }
988 let per_op = start.elapsed() / iterations;
989 assert!(
990 per_op.as_micros() < 200,
991 "JSON deser: {:?}/op (limit: 200µs)",
992 per_op
993 );
994 }
995
996 #[test]
997 fn perf_validation_request_serialize() {
998 let mut headers = std::collections::HashMap::with_capacity(5);
999 headers.insert("User-Agent".to_string(), "Mozilla/5.0".to_string());
1000 headers.insert("Accept".to_string(), "text/html".to_string());
1001 let req = request::ValidationRequest {
1002 url: "https://example.com/api/resource".to_string(),
1003 method: "GET".to_string(),
1004 ip: "203.0.113.42".to_string(),
1005 headers,
1006 referrer: "https://example.com/".to_string(),
1007 cookie: "abc123".to_string(),
1008 };
1009 let iterations = 5000;
1010 let start = Instant::now();
1011 for _ in 0..iterations {
1012 let _ = serde_json::to_string(&req).unwrap();
1013 }
1014 let per_op = start.elapsed() / iterations;
1015 assert!(
1016 per_op.as_micros() < 200,
1017 "JSON ser: {:?}/op (limit: 200µs)",
1018 per_op
1019 );
1020 }
1021
1022 #[test]
1023 fn perf_apply_cookies() {
1024 let cookies = vec![
1025 CookieData {
1026 name: "_c".to_string(),
1027 value: "v1".to_string(),
1028 path: Some("/".to_string()),
1029 domain: Some(".example.com".to_string()),
1030 },
1031 CookieData {
1032 name: "_s".to_string(),
1033 value: "v2".to_string(),
1034 path: Some("/".to_string()),
1035 domain: None,
1036 },
1037 CookieData {
1038 name: "_t".to_string(),
1039 value: "v3".to_string(),
1040 path: None,
1041 domain: None,
1042 },
1043 ];
1044 let iterations = 5000;
1045 let start = Instant::now();
1046 for _ in 0..iterations {
1047 let mut response = Response::from_status(200);
1048 apply_cookies(&mut response, &cookies);
1049 }
1050 let per_op = start.elapsed() / iterations;
1051 assert!(
1052 per_op.as_micros() < 100,
1053 "apply_cookies: {:?}/op (limit: 100µs)",
1054 per_op
1055 );
1056 }
1057
1058 #[test]
1059 fn perf_centinel_with_config() {
1060 let config = CentinelConfig {
1061 url_exclusion_pattern: Some(DEFAULT_EXCLUSION_PATTERN.to_string()),
1062 url_inclusion_pattern: Some(r"^/(api|admin)/".to_string()),
1063 ..CentinelConfig::default()
1064 };
1065 let iterations = 100;
1066 let start = Instant::now();
1067 for _ in 0..iterations {
1068 let _ = Centinel::with_config(config.clone()).unwrap();
1069 }
1070 let per_op = start.elapsed() / iterations;
1071 assert!(
1072 per_op.as_millis() < 50,
1073 "config init: {:?}/op (limit: 50ms)",
1074 per_op
1075 );
1076 }
1077}