Skip to main content

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}