Skip to main content

termivar_scanner/payload_strategies/
reflection_marker_query_pair.rs

1//! Bounded scanner-owned reflection marker pair.
2//!
3//! Both values are inert visible ASCII. They contain no HTML, JavaScript, URI,
4//! quote, or delimiter syntax, so the context parser observes server placement
5//! rather than syntax introduced by the probe itself.
6
7use crate::payload_strategy::{
8    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
9    PayloadStrategyRef, PayloadVariantRole,
10};
11
12pub const REFLECTION_MARKER_QUERY_PAIR_ID: &str = "web.review.reflection.marker-query-pair";
13pub const REFLECTION_MARKER_QUERY_PAIR_REVISION: u32 = 1;
14
15#[derive(Debug, Clone)]
16pub struct ReflectionMarkerQueryPairStrategy {
17    reference: PayloadStrategyRef,
18}
19
20impl ReflectionMarkerQueryPairStrategy {
21    pub fn new() -> Self {
22        Self {
23            reference: PayloadStrategyRef::new(
24                REFLECTION_MARKER_QUERY_PAIR_ID,
25                REFLECTION_MARKER_QUERY_PAIR_REVISION,
26            )
27            .expect("the reflection marker strategy identity is static and valid"),
28        }
29    }
30
31    fn marker(seed: &[u8], role: PayloadVariantRole) -> Option<String> {
32        let identity = std::str::from_utf8(seed).ok()?;
33        if identity.len() != 32
34            || !identity
35                .bytes()
36                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
37        {
38            return None;
39        }
40        let leg = match role {
41            PayloadVariantRole::Control => "control",
42            PayloadVariantRole::Candidate => "candidate",
43        };
44        Some(format!("venom-reflection-{leg}-{identity}-end"))
45    }
46}
47
48impl Default for ReflectionMarkerQueryPairStrategy {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl PayloadStrategy for ReflectionMarkerQueryPairStrategy {
55    fn strategy_ref(&self) -> &PayloadStrategyRef {
56        &self.reference
57    }
58
59    fn derive_one(
60        &self,
61        role: PayloadVariantRole,
62        seed: &PayloadSeed,
63        limits: PayloadStrategyLimits,
64    ) -> Result<PayloadArtifact, PayloadStrategyError> {
65        let value =
66            Self::marker(seed.as_bytes(), role).ok_or(PayloadStrategyError::DerivationFailed)?;
67        PayloadArtifact::new(self.reference.clone(), role, value.into_bytes(), limits)
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    fn limits() -> PayloadStrategyLimits {
76        PayloadStrategyLimits::new(128, 128).unwrap()
77    }
78
79    #[test]
80    fn pair_is_distinct_inert_bounded_and_versioned() {
81        let strategy = ReflectionMarkerQueryPairStrategy::new();
82        let seed =
83            PayloadSeed::new(b"0123456789abcdef0123456789abcdef".to_vec(), limits()).unwrap();
84        let control = strategy
85            .derive_one(PayloadVariantRole::Control, &seed, limits())
86            .unwrap();
87        let candidate = strategy
88            .derive_one(PayloadVariantRole::Candidate, &seed, limits())
89            .unwrap();
90        assert_eq!(
91            strategy.strategy_ref().to_string(),
92            "web.review.reflection.marker-query-pair@1"
93        );
94        assert_ne!(control.as_bytes(), candidate.as_bytes());
95        for value in [control.as_bytes(), candidate.as_bytes()] {
96            assert!(value.len() <= 80);
97            assert!(value
98                .iter()
99                .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-'));
100        }
101    }
102
103    #[test]
104    fn unbounded_or_noncanonical_seed_fails_closed() {
105        let strategy = ReflectionMarkerQueryPairStrategy::new();
106        for value in [
107            "short",
108            "0123456789ABCDEF0123456789ABCDEF",
109            "0123456789abcdef0123456789abcdeg",
110            "0123456789abcdef0123456789abcdef0",
111        ] {
112            let seed = PayloadSeed::new(value.as_bytes().to_vec(), limits()).unwrap();
113            assert!(matches!(
114                strategy.derive_one(PayloadVariantRole::Candidate, &seed, limits()),
115                Err(PayloadStrategyError::DerivationFailed)
116            ));
117        }
118    }
119}