Skip to main content

centinel_analytica_fastly/
lib.rs

1//! Centinel Analytica bot-protection SDK for Fastly Compute.
2//!
3//! # Quick start
4//!
5//! ```ignore
6//! use centinel_analytica_fastly::Centinel;
7//! use fastly::{Error, Request, Response};
8//!
9//! #[fastly::main]
10//! fn main(req: Request) -> Result<Response, Error> {
11//!     Centinel::protect(req, |r| r.send("origin"))
12//! }
13//! ```
14//!
15//! # Observability & extensibility
16//!
17//! - [`CentinelObserver`] + [`CentinelEvent`]: subscribe to lifecycle
18//!   events (path excluded, validator call start/end/error, decision,
19//!   script injected). Ship [`JsonLogObserver`] for Fastly log endpoints
20//!   or implement your own.
21//! - [`Hooks`]: mutate the outbound validator request, override the
22//!   decision, or decorate the block/redirect response.
23//! - [`TraceContext`]: incoming W3C `traceparent` / `tracestate` are
24//!   forwarded to the validator so its span chains under the caller's
25//!   trace.
26//!
27//! Every block/redirect response carries `Server-Timing`,
28//! `x-centinel-decision`, and `x-centinel-request-id` headers for
29//! correlation.
30
31#![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
192/// Entry point for Centinel bot protection.
193///
194/// Construct via [`Centinel::new`] (reads the `centinel` Config Store)
195/// or [`Centinel::with_config`] (explicit configuration). Attach an
196/// observer with [`Centinel::with_observer`] and/or hooks with
197/// [`Centinel::with_hooks`] before handling traffic.
198///
199/// The typical shape in a Fastly Compute `main` is:
200///
201/// ```ignore
202/// let centinel = Centinel::new()?;
203/// centinel.handle_request(req, |r| r.send("origin"))
204/// ```
205pub 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    /// Load configuration from the Fastly Config Store named `centinel`
220    /// and construct a [`Centinel`].
221    pub fn new() -> Result<Self, ConfigError> {
222        let config = CentinelConfig::from_config_store()?;
223        Self::with_config(config)
224    }
225
226    /// Construct a [`Centinel`] from an explicit [`CentinelConfig`].
227    ///
228    /// Regex patterns in the config are compiled eagerly; invalid
229    /// patterns return [`ConfigError::InvalidPattern`].
230    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    /// Attach an observer that receives lifecycle [`CentinelEvent`]s.
253    /// Default is [`NoopObserver`].
254    pub fn with_observer<O: CentinelObserver + 'static>(mut self, observer: O) -> Self {
255        self.observer = Box::new(observer);
256        self
257    }
258
259    /// Attach a set of consumer-supplied mutation [`Hooks`].
260    pub fn with_hooks(mut self, hooks: Hooks) -> Self {
261        self.hooks = hooks;
262        self
263    }
264
265    /// One-shot helper: construct a [`Centinel`] from the Config Store
266    /// and run [`Centinel::handle_request`]. Fails open (runs
267    /// `on_allowed` directly) when configuration is missing.
268    ///
269    /// Does not support observers or hooks — build the instance
270    /// manually when you need those.
271    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    /// Inspect a request and return a block/redirect response when the
282    /// validator says so; `Ok(None)` means the caller should forward.
283    ///
284    /// For full integration use [`Centinel::handle_request`], which also
285    /// handles script injection and `Server-Timing` on allow responses.
286    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                // success=false means the validator reported its own error;
335                // treat as fail-open and skip the Decision event (no real
336                // decision was made). Duration is still propagated so
337                // Server-Timing reflects the round-trip.
338                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    /// Inject the collector `<script>` tag into an HTML response when a
426    /// site key is configured. No-op otherwise or for non-HTML bodies.
427    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    /// `true` when a site key is configured (script injection enabled).
441    pub fn has_site_key(&self) -> bool {
442        self.config.site_key.is_some()
443    }
444
445    /// Full integration: validate, forward to origin via `on_allowed`,
446    /// inject the collector script, and stamp `Server-Timing`.
447    ///
448    /// Fails open on any validator error.
449    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    // --- Base64 correctness ---
477
478    #[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        // Base64 with line breaks (common in PEM-style encodings)
514        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        // /w== decodes to [0xFF] which is invalid UTF-8
527        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        // But raw byte decoding should succeed
535        let bytes = base64_decode_bytes("/w==").unwrap();
536        assert_eq!(bytes, vec![0xFF]);
537    }
538
539    // --- Config validation ---
540
541    #[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    // --- decode_decision_html ---
588
589    #[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    // --- apply_cookies ---
628
629    #[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    // --- apply_response_headers ---
667
668    #[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        // Should not panic, no headers added
693    }
694
695    // --- build_decision_response ---
696
697    #[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    // --- observer wiring ---
756
757    #[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        // The RecorderObserver was moved into Centinel; we can't read back
769        // its events without Rc/RefCell plumbing. This test just exercises
770        // the code path and ensures no panic.
771    }
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    // --- has_site_key ---
806
807    #[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(&regex), 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}