Skip to main content

termivar_scanner/payload_strategies/
template_jinja_uppercase_expression_pair.rs

1//! Bounded Jinja-compatible uppercase-filter expression probes.
2//!
3//! This closed family applies only the `upper` filter to a scanner-owned
4//! lowercase ASCII literal. It contains no calls, object traversal, indexing,
5//! statement separators, paths, or I/O-capable syntax. A match establishes
6//! only compatibility with the listed expression semantics, never engine
7//! identity or code execution.
8
9use crate::payload_strategy::{
10    PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
11    PayloadStrategyRef, PayloadVariantRole,
12};
13
14pub const TEMPLATE_JINJA_UPPERCASE_EXPRESSION_PAIR_ID: &str =
15    "web.review.template-evaluation.jinja-compatible-uppercase-expression-pair";
16pub const TEMPLATE_JINJA_UPPERCASE_EXPRESSION_PAIR_REVISION: u32 = 1;
17pub(crate) const TEMPLATE_JINJA_UPPERCASE_FAMILY_ID: &str =
18    crate::template_evaluation_review::TEMPLATE_EVALUATION_REVIEW_FAMILY_ID;
19
20/// One deterministic scanner-owned uppercase-filter probe and exact outcomes.
21#[derive(Clone, PartialEq, Eq)]
22pub(crate) struct TemplateJinjaUppercaseProbe {
23    nonce: String,
24}
25
26impl std::fmt::Debug for TemplateJinjaUppercaseProbe {
27    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        formatter
29            .debug_struct("TemplateJinjaUppercaseProbe")
30            .field("family", &TEMPLATE_JINJA_UPPERCASE_FAMILY_ID)
31            .field("encoded_bytes", &self.seed().len())
32            .finish()
33    }
34}
35
36impl TemplateJinjaUppercaseProbe {
37    pub(crate) fn new(nonce: String) -> Option<Self> {
38        if !(16..=32).contains(&nonce.len())
39            || !nonce
40                .bytes()
41                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
42        {
43            return None;
44        }
45        Some(Self { nonce })
46    }
47
48    pub(crate) fn seed(&self) -> String {
49        format!("v1-{}", self.nonce)
50    }
51
52    pub(crate) fn control_value(&self) -> String {
53        format!("termivar-template-{}-control-end", self.nonce)
54    }
55
56    pub(crate) fn candidate_value(&self) -> String {
57        format!(
58            "termivar-template-{}-{{{{'termivar_{}'|upper}}}}-end",
59            self.nonce, self.nonce
60        )
61    }
62
63    pub(crate) fn expected_value(&self) -> String {
64        let evaluated_literal = format!("termivar_{}", self.nonce).to_ascii_uppercase();
65        format!("termivar-template-{}-{evaluated_literal}-end", self.nonce)
66    }
67
68    fn parse(seed: &[u8]) -> Option<Self> {
69        let text = std::str::from_utf8(seed).ok()?;
70        let nonce = text.strip_prefix("v1-")?;
71        if nonce.contains('-') {
72            return None;
73        }
74        Self::new(nonce.to_owned())
75    }
76}
77
78#[derive(Debug, Clone)]
79pub struct TemplateJinjaUppercaseExpressionPairStrategy {
80    reference: PayloadStrategyRef,
81}
82
83impl TemplateJinjaUppercaseExpressionPairStrategy {
84    pub fn new() -> Self {
85        Self {
86            reference: PayloadStrategyRef::new(
87                TEMPLATE_JINJA_UPPERCASE_EXPRESSION_PAIR_ID,
88                TEMPLATE_JINJA_UPPERCASE_EXPRESSION_PAIR_REVISION,
89            )
90            .expect("the template uppercase strategy identity is static and valid"),
91        }
92    }
93}
94
95impl Default for TemplateJinjaUppercaseExpressionPairStrategy {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl PayloadStrategy for TemplateJinjaUppercaseExpressionPairStrategy {
102    fn strategy_ref(&self) -> &PayloadStrategyRef {
103        &self.reference
104    }
105
106    fn derive_one(
107        &self,
108        role: PayloadVariantRole,
109        seed: &PayloadSeed,
110        limits: PayloadStrategyLimits,
111    ) -> Result<PayloadArtifact, PayloadStrategyError> {
112        let probe = TemplateJinjaUppercaseProbe::parse(seed.as_bytes())
113            .ok_or(PayloadStrategyError::DerivationFailed)?;
114        let value = match role {
115            PayloadVariantRole::Control => probe.control_value(),
116            PayloadVariantRole::Candidate => probe.candidate_value(),
117        };
118        PayloadArtifact::new(self.reference.clone(), role, value.into_bytes(), limits)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn limits() -> PayloadStrategyLimits {
127        PayloadStrategyLimits::new(256, 256).unwrap()
128    }
129
130    #[test]
131    fn family_is_bounded_harmless_and_exactly_correlated() {
132        let probe = TemplateJinjaUppercaseProbe::new("a1b2c3d4e5f60708".into()).unwrap();
133        let seed = PayloadSeed::new(probe.seed().into_bytes(), limits()).unwrap();
134        let strategy = TemplateJinjaUppercaseExpressionPairStrategy::new();
135        let control = strategy
136            .derive_one(PayloadVariantRole::Control, &seed, limits())
137            .unwrap();
138        let candidate = strategy
139            .derive_one(PayloadVariantRole::Candidate, &seed, limits())
140            .unwrap();
141        assert_eq!(control.as_bytes(), probe.control_value().as_bytes());
142        assert_eq!(candidate.as_bytes(), probe.candidate_value().as_bytes());
143        assert_eq!(
144            probe.expected_value(),
145            "termivar-template-a1b2c3d4e5f60708-TERMIVAR_A1B2C3D4E5F60708-end"
146        );
147        for forbidden in [".", "[", "]", "(", ")", ";", "/", "\\"] {
148            assert!(!probe.candidate_value().contains(forbidden));
149        }
150    }
151
152    #[test]
153    fn invalid_or_unbounded_seeds_fail_closed() {
154        let strategy = TemplateJinjaUppercaseExpressionPairStrategy::new();
155        for seed in [
156            "v2-a1b2c3d4e5f60708",
157            "v1-short",
158            "v1-A1B2C3D4E5F60708",
159            "v1-a1b2c3d4e5f6070x",
160            "v1-a1b2c3d4e5f60708-extra",
161        ] {
162            let seed = PayloadSeed::new(seed.as_bytes().to_vec(), limits()).unwrap();
163            assert!(matches!(
164                strategy.derive_one(PayloadVariantRole::Candidate, &seed, limits()),
165                Err(PayloadStrategyError::DerivationFailed)
166            ));
167        }
168    }
169}