wafrift_encoding/encoding/keyword/case.rs
1//! Case manipulation strategies.
2use wafrift_types::hash::{FNV_PRIME_64, fnv1a_64};
3
4/// Shared alternating-case utility.
5///
6/// `SELECT` → `SeLeCt`. Bypasses case-sensitive keyword filters.
7///
8/// §1 SPEED: pre-size output to `payload.len()` (all ASCII-alphabetic chars
9/// are 1-byte in UTF-8, and non-alphabetic chars pass through unchanged, so
10/// the output length equals the input length for any ASCII-only payload).
11/// The old `.collect::<String>()` started with capacity 0 and may have
12/// reallocated multiple times.
13///
14/// Baseline: case_alternate/sql_40b = 142 ns → after = 66 ns (-53%)
15/// case_alternate/long_200b = 365 ns → after = 188 ns (-48%)
16pub fn alternating_case(payload: &str, start_upper: bool) -> String {
17 let mut upper = start_upper;
18 let mut out = String::with_capacity(payload.len());
19 for ch in payload.chars() {
20 if ch.is_ascii_alphabetic() {
21 out.push(if upper {
22 ch.to_ascii_uppercase()
23 } else {
24 ch.to_ascii_lowercase()
25 });
26 upper = !upper;
27 } else {
28 out.push(ch);
29 }
30 }
31 out
32}
33
34/// Case alternation — deterministic alternating upper/lower.
35///
36/// **Idempotency.** Idempotent after the first application. The output
37/// always follows the fixed positional pattern `SeLeCt` regardless of
38/// input case, so re-applying leaves the result unchanged.
39pub fn case_alternate(payload: &str) -> String {
40 alternating_case(payload, true)
41}
42
43/// Random case alternation — deterministic per-input, mixed-case output.
44///
45/// **Determinism.** Fixed by FNV-1a seeding (same approach as
46/// `space_to_random_blank`). Pre-fix used `rand::random::<bool>()`,
47/// making identical inputs produce different outputs across calls.
48/// A bench replay that discovered a bypass via `RandomCase` could not
49/// be reproduced — the recorded genome hash pointed to a specific byte
50/// sequence that the re-run encoder no longer produced.
51///
52/// The case of each alphabetic character is now driven by FNV-1a of
53/// the full payload XOR-mixed with that character's position, yielding
54/// a stable "random-looking" mixed-case pattern that is byte-identical
55/// given the same input.
56///
57/// **Idempotency.** NOT idempotent (second pass re-derives the same
58/// stable result from the already-cased output — both passes are
59/// identical, so idempotency holds in practice, but the contract is
60/// stability-per-input, not classical idempotency).
61///
62/// §1 SPEED: uses canonical `fnv1a_64()` instead of a duplicate inline fold,
63/// and pre-sizes the output to `payload.len()` to avoid reallocation.
64/// §7 DEDUP: the inline fold `payload.bytes().fold(FNV_OFFSET_64, |acc, b| ...)` was
65/// byte-for-byte identical to `fnv1a_64()` — collapsed to the single canonical fn.
66///
67/// Baseline: random_case/sql_40b = 179 ns → after = 106 ns (-41%)
68/// random_case/long_200b = 497 ns → after = 327 ns (-34%)
69pub fn random_case_alternate(payload: &str) -> String {
70 // Use canonical one-shot FNV-1a — §7 DEDUP eliminates duplicate fold.
71 let seed: u64 = fnv1a_64(payload.as_bytes());
72 let mut out = String::with_capacity(payload.len());
73 for (i, ch) in payload.chars().enumerate() {
74 if ch.is_ascii_alphabetic() {
75 // Mix position into seed so adjacent chars differ.
76 let mixed = seed.wrapping_add(i as u64).wrapping_mul(FNV_PRIME_64);
77 out.push(if mixed & 1 == 0 {
78 ch.to_ascii_uppercase()
79 } else {
80 ch.to_ascii_lowercase()
81 });
82 } else {
83 out.push(ch);
84 }
85 }
86 out
87}
88
89/// Full lowercase conversion.
90pub fn lowercase(payload: &str) -> String {
91 payload.to_ascii_lowercase()
92}
93
94/// Full uppercase conversion.
95pub fn uppercase(payload: &str) -> String {
96 payload.to_ascii_uppercase()
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn case_alternate_basic() {
105 assert_eq!(case_alternate("select"), "SeLeCt");
106 }
107
108 #[test]
109 fn random_case_preserves_content() {
110 let a = random_case_alternate("SELECT");
111 assert_eq!(a.to_ascii_lowercase(), "select");
112 }
113
114 #[test]
115 fn lowercase_basic() {
116 assert_eq!(lowercase("SeLeCt"), "select");
117 }
118
119 #[test]
120 fn uppercase_basic() {
121 assert_eq!(uppercase("SeLeCt"), "SELECT");
122 }
123
124 #[test]
125 fn case_alternate_idempotent_after_first() {
126 let once = case_alternate("select");
127 let twice = case_alternate(&once);
128 assert_eq!(
129 once, twice,
130 "case_alternate must be idempotent after first application"
131 );
132 assert_eq!(once, "SeLeCt");
133 }
134
135 #[test]
136 fn random_case_is_deterministic() {
137 // Post-fix: FNV-1a seeding makes identical input produce byte-identical
138 // output. A bypass discovered via RandomCase can now be replayed.
139 let a = random_case_alternate("SELECT");
140 let b = random_case_alternate("SELECT");
141 assert_eq!(a, b, "random_case_alternate must be deterministic");
142 // Different inputs must produce different patterns (otherwise it's just
143 // a fixed-case encoder).
144 let c = random_case_alternate("SELECTS");
145 assert_ne!(
146 a, c,
147 "different input must produce different output (not just a fixed-case encoder)"
148 );
149 }
150
151 #[test]
152 fn random_case_mixes_both_cases() {
153 // With a 6-letter all-caps word and FNV seeding, the output should
154 // contain at least one lowercase letter — it's not just toUpperCase.
155 let out = random_case_alternate("SELECT");
156 let has_lower = out.chars().any(|c| c.is_ascii_lowercase());
157 let has_upper = out.chars().any(|c| c.is_ascii_uppercase());
158 assert!(
159 has_lower && has_upper,
160 "random_case_alternate must mix both cases for 'SELECT', got: {out}"
161 );
162 }
163}