Skip to main content

wafrift_encoding/encoding/
layered.rs

1//! Multi-strategy encoding chains and aggressiveness scoring.
2
3use super::strategy::{Strategy, all_strategies, encode};
4use crate::error::EncodeError;
5
6/// Maximum accumulated output size for layered encoding.
7pub const MAX_LAYERED_OUTPUT_SIZE: usize = 8 * 1024 * 1024;
8
9/// Apply multiple encoding strategies in sequence (layered encoding).
10///
11/// # Errors
12/// Returns `EncodeError::PayloadTooLarge` if the input exceeds [`super::strategy::MAX_PAYLOAD_SIZE`].
13/// Returns `EncodeError::LayeredOutputTooLarge` if any intermediate output
14/// exceeds [`MAX_LAYERED_OUTPUT_SIZE`].
15pub fn encode_layered(
16    payload: impl AsRef<[u8]>,
17    strategies: &[Strategy],
18) -> Result<String, EncodeError> {
19    let payload = payload.as_ref();
20    let mut result = encode(
21        payload,
22        strategies.first().copied().unwrap_or(Strategy::UrlEncode),
23    )?;
24
25    for strategy in strategies.iter().skip(1) {
26        if result.len() > MAX_LAYERED_OUTPUT_SIZE {
27            return Err(EncodeError::LayeredOutputTooLarge {
28                max: MAX_LAYERED_OUTPUT_SIZE,
29                actual: result.len(),
30            });
31        }
32        result = encode(&result, *strategy)?;
33    }
34
35    if result.len() > MAX_LAYERED_OUTPUT_SIZE {
36        return Err(EncodeError::LayeredOutputTooLarge {
37            max: MAX_LAYERED_OUTPUT_SIZE,
38            actual: result.len(),
39        });
40    }
41
42    Ok(result)
43}
44
45/// Generate programmatic combinations up to a depth limit.
46///
47/// Filters out redundant pairings (same strategy twice, or pairings that
48/// produce semantically equivalent outputs).
49pub fn layered_combinations(depth: usize) -> Vec<Vec<Strategy>> {
50    let base = all_strategies();
51    let mut results: Vec<Vec<Strategy>> = Vec::new();
52
53    fn backtrack(
54        base: &[Strategy],
55        current: &mut Vec<Strategy>,
56        results: &mut Vec<Vec<Strategy>>,
57        depth: usize,
58    ) {
59        if current.len() >= 2 && current.len() <= depth {
60            results.push(current.clone());
61        }
62        if current.len() >= depth {
63            return;
64        }
65        for s in base {
66            // Skip redundant consecutive duplicates
67            if current.last() == Some(s) {
68                continue;
69            }
70            // Skip some known-redundant pairings
71            if let Some(last) = current.last()
72                && redundant_pair(*last, *s)
73            {
74                continue;
75            }
76            current.push(*s);
77            backtrack(base, current, results, depth);
78            current.pop();
79        }
80    }
81
82    let mut current = Vec::new();
83    backtrack(base, &mut current, &mut results, depth);
84    results
85}
86
87fn redundant_pair(a: Strategy, b: Strategy) -> bool {
88    // URL + URL variants are redundant with existing single strategies
89    matches!(
90        (a, b),
91        (Strategy::UrlEncode | Strategy::UrlEncodeLower | Strategy::DoubleUrlEncode |
92Strategy::TripleUrlEncode, Strategy::UrlEncode) |
93(Strategy::UrlEncode | Strategy::UrlEncodeLower, Strategy::UrlEncodeLower) |
94(Strategy::CaseAlternation, Strategy::RandomCase) |
95(Strategy::RandomCase, Strategy::CaseAlternation)
96    )
97}
98
99/// Estimate how aggressive an encoding strategy is (0.0 = mild, 1.0 = extreme).
100///
101/// Used by the strategy engine to decide escalation order.
102#[must_use]
103pub fn aggressiveness(strategy: Strategy) -> f64 {
104    match strategy {
105        Strategy::CaseAlternation => 0.05,
106        Strategy::RandomCase => 0.08,
107        Strategy::UrlEncode => 0.1,
108        Strategy::UrlEncodeLower => 0.1,
109        Strategy::WhitespaceInsertion => 0.12,
110        Strategy::SqlCommentInsertion => 0.12,
111        Strategy::SpaceToPlus => 0.13,
112        Strategy::SpaceToRandomBlank => 0.14,
113        Strategy::SpaceToComment => 0.15,
114        Strategy::SpaceToDash => 0.15,
115        Strategy::SpaceToHash => 0.15,
116        Strategy::HtmlEntityEncode => 0.2,
117        Strategy::HtmlEntityDecimalEncode => 0.2,
118        Strategy::DoubleUrlEncode => 0.25,
119        Strategy::UnicodeEncode => 0.3,
120        Strategy::IisUnicodeEncode => 0.3,
121        Strategy::JsonEncode => 0.3,
122        Strategy::NullByte => 0.35,
123        Strategy::FullwidthEncode => 0.36,
124        Strategy::HomoglyphEncode => 0.37,
125        Strategy::PercentagePrefix => 0.4,
126        Strategy::ParameterPollution => 0.45,
127        Strategy::TripleUrlEncode => 0.5,
128        Strategy::MysqlVersionedComment => 0.55,
129        Strategy::Base64Encode => 0.6,
130        Strategy::Base64UrlEncode => 0.6,
131        Strategy::OverlongUtf8 => 0.7,
132        Strategy::OverlongUtf8More => 0.75,
133        Strategy::HexEncode => 0.8,
134        Strategy::Utf7Encode => 0.85,
135        Strategy::BetweenObfuscation => 0.88,
136        Strategy::UnmagicQuotes => 0.9,
137        Strategy::ChunkedSplit => 0.92,
138        Strategy::GzipEncode => 0.95,
139        Strategy::DeflateEncode => 0.95,
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::encoding::strategy::all_strategies;
147
148    #[test]
149    fn encode_layered_basic() {
150        let result =
151            encode_layered("A", &[Strategy::UrlEncode, Strategy::DoubleUrlEncode]).unwrap();
152        assert!(result.contains('%'));
153    }
154
155    #[test]
156    fn encode_layered_size_limit() {
157        // Use a non-unreserved char so URL encoding multiplies size by ~3x each pass
158        let big = "!".repeat(5 * 1024 * 1024);
159        let result = encode_layered(
160            &big,
161            &[
162                Strategy::UrlEncode,
163                Strategy::UrlEncode,
164                Strategy::UrlEncode,
165            ],
166        );
167        assert!(matches!(
168            result,
169            Err(EncodeError::LayeredOutputTooLarge { .. })
170        ));
171    }
172
173    #[test]
174    fn layered_combinations_depth_2() {
175        let combos = layered_combinations(2);
176        assert!(!combos.is_empty());
177        // All combos should have length 2
178        assert!(combos.iter().all(|c| c.len() == 2));
179    }
180
181    #[test]
182    fn layered_combinations_no_consecutive_duplicates() {
183        let combos = layered_combinations(3);
184        for combo in combos {
185            for window in combo.windows(2) {
186                assert_ne!(window[0], window[1], "no consecutive duplicates: {combo:?}");
187            }
188        }
189    }
190
191    #[test]
192    fn aggressiveness_ordering() {
193        let strategies = all_strategies();
194        for i in 1..strategies.len() {
195            assert!(
196                aggressiveness(strategies[i - 1]) <= aggressiveness(strategies[i]),
197                "aggressiveness should be non-decreasing"
198            );
199        }
200    }
201
202    #[test]
203    fn encode_layered_empty_strategies() {
204        let result = encode_layered("hello", &[]).unwrap();
205        assert_eq!(result, "hello");
206    }
207
208    #[test]
209    fn encode_layered_single_strategy() {
210        let result = encode_layered("A<", &[Strategy::UrlEncode]).unwrap();
211        assert_eq!(result, "A%3C");
212    }
213
214    #[test]
215    fn layered_combinations_depth_1_returns_empty() {
216        let combos = layered_combinations(1);
217        assert!(combos.is_empty());
218    }
219
220    #[test]
221    fn aggressiveness_in_valid_range() {
222        for &s in all_strategies() {
223            let a = aggressiveness(s);
224            assert!(
225                (0.0..=1.0).contains(&a),
226                "aggressiveness for {s:?} out of range: {a}"
227            );
228        }
229    }
230}