Skip to main content

termivar_scanner/payload_strategies/
encoding.rs

1//! Pure, bounded byte-encoding helpers.
2//!
3//! These functions do not select an input, issue a request, fingerprint a
4//! defense product, or imply bypass behavior. Encoding into a payload artifact
5//! preserves the standard byte limit and redacted receipt boundary.
6
7use serde::{Deserialize, Serialize};
8
9use crate::payload_strategy::{
10    PayloadArtifact, PayloadStrategyError, PayloadStrategyLimits, PayloadStrategyRef,
11    PayloadVariantRole,
12};
13
14/// Percent-encodes every byte outside the ASCII URL-unreserved set.
15#[must_use]
16pub fn percent_encode(input: &[u8]) -> String {
17    let mut output = String::with_capacity(input.len());
18    for byte in input {
19        if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'_' | b'.' | b'~') {
20            output.push(char::from(*byte));
21        } else {
22            output.push_str(&format!("%{:02X}", *byte));
23        }
24    }
25    output
26}
27
28/// Encodes every byte as lowercase hexadecimal without interpreting it.
29#[must_use]
30pub fn hex_encode(input: &[u8]) -> String {
31    input.iter().map(|byte| format!("{:02x}", *byte)).collect()
32}
33
34/// Neutral byte encoding selected by an integrating host.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum PayloadEncoding {
38    Percent,
39    Hex,
40}
41
42impl PayloadEncoding {
43    #[must_use]
44    pub fn apply(self, input: &[u8]) -> Vec<u8> {
45        match self {
46            Self::Percent => percent_encode(input).into_bytes(),
47            Self::Hex => hex_encode(input).into_bytes(),
48        }
49    }
50}
51
52/// Encodes one bounded payload artifact under the existing strategy identity.
53pub fn encode_into_artifact(
54    strategy: &PayloadStrategyRef,
55    role: PayloadVariantRole,
56    input: &[u8],
57    encoding: PayloadEncoding,
58    limits: PayloadStrategyLimits,
59) -> Result<PayloadArtifact, PayloadStrategyError> {
60    PayloadArtifact::new(strategy.clone(), role, encoding.apply(input), limits)
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn reference() -> PayloadStrategyRef {
68        PayloadStrategyRef::new("encoding.fixture", 1).expect("valid fixture identity")
69    }
70
71    #[test]
72    fn encoders_are_byte_based_and_deterministic() {
73        assert_eq!(percent_encode(b"a b/c"), "a%20b%2Fc");
74        assert_eq!(hex_encode(b"AB"), "4142");
75        assert_eq!(percent_encode(&[0xff]), "%FF");
76        assert_eq!(percent_encode(b"a b"), percent_encode(b"a b"));
77    }
78
79    #[test]
80    fn artifact_encoding_enforces_the_byte_bound_and_redacts_values() {
81        let tight = PayloadStrategyLimits::new(64, 4).expect("valid limits");
82        assert!(matches!(
83            encode_into_artifact(
84                &reference(),
85                PayloadVariantRole::Candidate,
86                b"<a>",
87                PayloadEncoding::Percent,
88                tight,
89            ),
90            Err(PayloadStrategyError::ArtifactTooLarge { .. })
91        ));
92
93        let artifact = encode_into_artifact(
94            &reference(),
95            PayloadVariantRole::Control,
96            b"ab",
97            PayloadEncoding::Hex,
98            PayloadStrategyLimits::default(),
99        )
100        .expect("bounded artifact");
101        assert_eq!(artifact.as_bytes(), b"6162");
102        assert!(!format!("{artifact:?}").contains("6162"));
103        assert!(!serde_json::to_string(&artifact.receipt())
104            .expect("receipt serializes")
105            .contains("6162"));
106    }
107}