termivar_scanner/payload_strategies/
http_header_control_pair.rs1use crate::payload_strategy::{
17 PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
18 PayloadStrategyRef, PayloadVariantRole,
19};
20
21pub const HTTP_HEADER_CONTROL_PAIR_ID: &str = "http.header.control-pair";
23
24pub const HTTP_HEADER_CONTROL_PAIR_REVISION: u32 = 1;
26
27pub const HTTP_HEADER_CONTROL_PAIR_HEADER_NAME: &str = "accept";
30
31const BASELINE_VALUE: &[u8] = b"*/*";
33
34const CANDIDATE_PREFIX: &[u8] = b"*/*, ";
36
37#[derive(Debug, Clone)]
39pub struct HttpHeaderControlPairStrategy {
40 reference: PayloadStrategyRef,
41}
42
43impl HttpHeaderControlPairStrategy {
44 pub fn new() -> Self {
50 let reference = PayloadStrategyRef::new(
51 HTTP_HEADER_CONTROL_PAIR_ID,
52 HTTP_HEADER_CONTROL_PAIR_REVISION,
53 )
54 .expect("http.header.control-pair@1 is a valid strategy reference");
55 Self { reference }
56 }
57
58 fn is_safe_header_value(bytes: &[u8]) -> bool {
66 if bytes.is_empty() {
67 return false;
68 }
69 if bytes.first() == Some(&b' ') || bytes.last() == Some(&b' ') {
70 return false;
71 }
72 bytes
73 .iter()
74 .all(|&byte| byte == b' ' || (0x21..=0x7e).contains(&byte))
75 }
76}
77
78impl Default for HttpHeaderControlPairStrategy {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl PayloadStrategy for HttpHeaderControlPairStrategy {
85 fn strategy_ref(&self) -> &PayloadStrategyRef {
86 &self.reference
87 }
88
89 fn derive_one(
90 &self,
91 role: PayloadVariantRole,
92 seed: &PayloadSeed,
93 limits: PayloadStrategyLimits,
94 ) -> Result<PayloadArtifact, PayloadStrategyError> {
95 if !Self::is_safe_header_value(seed.as_bytes()) {
98 return Err(PayloadStrategyError::DerivationFailed);
99 }
100
101 let bytes = match role {
102 PayloadVariantRole::Control => BASELINE_VALUE.to_vec(),
103 PayloadVariantRole::Candidate => {
104 let mut bytes = Vec::with_capacity(CANDIDATE_PREFIX.len() + seed.len());
105 bytes.extend_from_slice(CANDIDATE_PREFIX);
106 bytes.extend_from_slice(seed.as_bytes());
107 bytes
108 },
109 };
110
111 PayloadArtifact::new(self.reference.clone(), role, bytes, limits)
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use std::{sync::Arc, thread};
118
119 use super::*;
120 use crate::payload_strategy::PayloadStrategyRegistry;
121
122 fn limits() -> PayloadStrategyLimits {
123 PayloadStrategyLimits::default()
124 }
125
126 fn seed(value: &[u8]) -> PayloadSeed {
127 PayloadSeed::new(value.to_vec(), limits()).unwrap()
128 }
129
130 #[test]
131 fn reference_is_stable_and_versioned() {
132 let strategy = HttpHeaderControlPairStrategy::new();
133 assert_eq!(strategy.strategy_ref().id(), HTTP_HEADER_CONTROL_PAIR_ID);
134 assert_eq!(
135 strategy.strategy_ref().revision(),
136 HTTP_HEADER_CONTROL_PAIR_REVISION
137 );
138 assert_eq!(
139 strategy.strategy_ref().to_string(),
140 "http.header.control-pair@1"
141 );
142 }
143
144 #[test]
145 fn control_is_seed_independent_and_candidate_embeds_seed() {
146 let strategy = HttpHeaderControlPairStrategy::new();
147
148 let control_a = strategy
149 .derive_one(PayloadVariantRole::Control, &seed(b"one"), limits())
150 .unwrap();
151 let control_b = strategy
152 .derive_one(PayloadVariantRole::Control, &seed(b"different"), limits())
153 .unwrap();
154 assert_eq!(control_a.as_bytes(), b"*/*");
155 assert_eq!(control_a.as_bytes(), control_b.as_bytes());
156
157 let candidate = strategy
158 .derive_one(
159 PayloadVariantRole::Candidate,
160 &seed(b"application/json"),
161 limits(),
162 )
163 .unwrap();
164 assert_eq!(candidate.as_bytes(), b"*/*, application/json");
165 assert_ne!(candidate.as_bytes(), control_a.as_bytes());
166 }
167
168 #[test]
169 fn same_input_is_deterministic_across_repeats() {
170 let strategy = HttpHeaderControlPairStrategy::new();
171 let first = strategy
172 .derive_one(PayloadVariantRole::Candidate, &seed(b"text/html"), limits())
173 .unwrap();
174 let second = strategy
175 .derive_one(PayloadVariantRole::Candidate, &seed(b"text/html"), limits())
176 .unwrap();
177 assert_eq!(first, second);
178 assert_eq!(first.receipt(), second.receipt());
179 }
180
181 #[test]
182 fn derivation_is_deterministic_under_concurrency() {
183 let strategy = Arc::new(HttpHeaderControlPairStrategy::new());
184 let expected = strategy
185 .derive_one(
186 PayloadVariantRole::Candidate,
187 &seed(b"probe-token"),
188 limits(),
189 )
190 .unwrap();
191
192 let handles: Vec<_> = (0..8)
193 .map(|_| {
194 let strategy = Arc::clone(&strategy);
195 thread::spawn(move || {
196 strategy
197 .derive_one(
198 PayloadVariantRole::Candidate,
199 &seed(b"probe-token"),
200 limits(),
201 )
202 .unwrap()
203 .receipt()
204 })
205 })
206 .collect();
207
208 for handle in handles {
209 assert_eq!(handle.join().unwrap(), expected.receipt());
210 }
211 }
212
213 #[test]
214 fn unsafe_seeds_fail_closed_on_both_legs() {
215 let strategy = HttpHeaderControlPairStrategy::new();
216 for unsafe_seed in [
217 b"".as_slice(),
218 b" leading",
219 b"trailing ",
220 b"line\nbreak",
221 b"carriage\rreturn",
222 b"tab\ttoken",
223 b"null\0byte",
224 ] {
225 let seed = PayloadSeed::new(unsafe_seed.to_vec(), limits()).unwrap();
226 for role in [PayloadVariantRole::Control, PayloadVariantRole::Candidate] {
227 assert!(
228 matches!(
229 strategy.derive_one(role, &seed, limits()),
230 Err(PayloadStrategyError::DerivationFailed)
231 ),
232 "expected fail-closed derivation for seed {unsafe_seed:?} role {role:?}"
233 );
234 }
235 }
236 }
237
238 #[test]
239 fn oversized_candidate_value_fails_closed() {
240 let strategy = HttpHeaderControlPairStrategy::new();
241 let tight = PayloadStrategyLimits::new(64, 4).unwrap();
243 let seed = PayloadSeed::new(b"json".to_vec(), tight).unwrap();
244 assert!(matches!(
245 strategy.derive_one(PayloadVariantRole::Candidate, &seed, tight),
246 Err(PayloadStrategyError::ArtifactTooLarge { .. })
247 ));
248 }
249
250 #[test]
251 fn resolves_and_revalidates_through_registry() {
252 let strategy = HttpHeaderControlPairStrategy::new();
253 let reference = strategy.strategy_ref().clone();
254 let mut registry = PayloadStrategyRegistry::new();
255 registry.register(Arc::new(strategy)).unwrap();
256
257 let artifact = registry
258 .derive_one(
259 &reference,
260 PayloadVariantRole::Candidate,
261 &seed(b"application/json"),
262 limits(),
263 )
264 .unwrap();
265 assert_eq!(artifact.strategy(), &reference);
266 assert_eq!(artifact.role(), PayloadVariantRole::Candidate);
267 assert_eq!(artifact.as_bytes(), b"*/*, application/json");
268 }
269
270 #[test]
271 fn raw_seed_never_enters_receipt_json() {
272 let strategy = HttpHeaderControlPairStrategy::new();
273 let artifact = strategy
274 .derive_one(
275 PayloadVariantRole::Candidate,
276 &seed(b"secret-token-value"),
277 limits(),
278 )
279 .unwrap();
280 let receipt_json = serde_json::to_string(&artifact.receipt()).unwrap();
281 let debug = format!("{artifact:?}");
282 assert!(!receipt_json.contains("secret-token-value"));
283 assert!(!debug.contains("secret-token-value"));
284 assert_eq!(artifact.receipt().sha256().len(), 64);
285 }
286}