Skip to main content

uselesskey_core_negative_pem/
lib.rs

1#![forbid(unsafe_code)]
2#![cfg_attr(not(feature = "std"), no_std)]
3
4//! PEM-focused negative fixture corruption helpers for test fixtures.
5//!
6//! Use [`corrupt_pem`] to apply a specific corruption strategy, or
7//! [`corrupt_pem_deterministic`] to let the variant string choose the
8//! strategy deterministically.
9//!
10//! # Examples
11//!
12//! ```
13//! use uselesskey_core_negative_pem::{corrupt_pem, CorruptPem};
14//!
15//! let pem = "-----BEGIN RSA PRIVATE KEY-----\nABC=\n-----END RSA PRIVATE KEY-----\n";
16//!
17//! // Replace the header with an invalid one
18//! let bad = corrupt_pem(pem, CorruptPem::BadHeader);
19//! assert!(bad.starts_with("-----BEGIN CORRUPTED KEY-----"));
20//!
21//! // Inject invalid base64 so decoders reject it
22//! let bad = corrupt_pem(pem, CorruptPem::BadBase64);
23//! assert!(bad.contains("THIS_IS_NOT_BASE64!!!"));
24//! ```
25//!
26//! Deterministic corruption picks a strategy from the variant string,
27//! producing the same output every time:
28//!
29//! ```
30//! use uselesskey_core_negative_pem::corrupt_pem_deterministic;
31//!
32//! let pem = "-----BEGIN PUBLIC KEY-----\nABC=\n-----END PUBLIC KEY-----\n";
33//! let a = corrupt_pem_deterministic(pem, "corrupt:test-v1");
34//! let b = corrupt_pem_deterministic(pem, "corrupt:test-v1");
35//! assert_eq!(a, b); // same variant ⇒ same corruption
36//! ```
37
38extern crate alloc;
39
40use alloc::string::{String, ToString};
41use alloc::vec::Vec;
42
43use uselesskey_core_hash::hash32;
44
45/// Strategies for corrupting PEM-encoded data.
46#[derive(Clone, Copy, Debug)]
47pub enum CorruptPem {
48    /// Replace the `-----BEGIN …-----` line with an invalid header.
49    BadHeader,
50    /// Replace the `-----END …-----` line with an invalid footer.
51    BadFooter,
52    /// Inject a non-base64 line into the body so decoders reject the payload.
53    BadBase64,
54    /// Keep only the first `bytes` characters of the PEM string.
55    Truncate {
56        /// Maximum number of characters to keep.
57        bytes: usize,
58    },
59    /// Insert a blank line after the header, breaking strict PEM parsers.
60    ExtraBlankLine,
61}
62
63/// Apply a specific [`CorruptPem`] corruption strategy to the given PEM string.
64pub fn corrupt_pem(pem: &str, how: CorruptPem) -> String {
65    match how {
66        CorruptPem::BadHeader => replace_first_line(pem, "-----BEGIN CORRUPTED KEY-----"),
67        CorruptPem::BadFooter => replace_last_line(pem, "-----END CORRUPTED KEY-----"),
68        CorruptPem::BadBase64 => inject_bad_base64_line(pem),
69        CorruptPem::Truncate { bytes } => pem.chars().take(bytes).collect(),
70        CorruptPem::ExtraBlankLine => inject_blank_line(pem),
71    }
72}
73
74/// Choose a corruption strategy deterministically from `variant` and apply it to `pem`.
75///
76/// The same `(pem, variant)` pair always produces the same corrupted output.
77pub fn corrupt_pem_deterministic(pem: &str, variant: &str) -> String {
78    let digest = hash32(variant.as_bytes());
79    let bytes = digest.as_bytes();
80
81    match bytes[0] % 5 {
82        0 => corrupt_pem(pem, CorruptPem::BadHeader),
83        1 => corrupt_pem(pem, CorruptPem::BadFooter),
84        2 => corrupt_pem(pem, CorruptPem::BadBase64),
85        3 => corrupt_pem(pem, CorruptPem::ExtraBlankLine),
86        _ => {
87            let bytes = derived_truncate_len(pem, bytes);
88            corrupt_pem(pem, CorruptPem::Truncate { bytes })
89        }
90    }
91}
92
93fn derived_truncate_len(pem: &str, digest: &[u8; 32]) -> usize {
94    let chars = pem.chars().count();
95    if chars <= 1 {
96        return 0;
97    }
98
99    let span = chars - 1;
100    1 + (u16::from_be_bytes([digest[1], digest[2]]) as usize % span)
101}
102
103fn replace_first_line(pem: &str, replacement: &str) -> String {
104    let mut lines = pem.lines();
105    let _first = lines.next();
106
107    let mut out = String::new();
108    out.push_str(replacement);
109    out.push('\n');
110
111    for l in lines {
112        out.push_str(l);
113        out.push('\n');
114    }
115
116    out
117}
118
119fn replace_last_line(pem: &str, replacement: &str) -> String {
120    let mut all: Vec<&str> = pem.lines().collect();
121    if all.is_empty() {
122        return replacement.to_string();
123    }
124    let last_idx = all.len() - 1;
125    all[last_idx] = replacement;
126
127    let mut out = String::new();
128    for l in all {
129        out.push_str(l);
130        out.push('\n');
131    }
132    out
133}
134
135fn inject_bad_base64_line(pem: &str) -> String {
136    let mut lines: Vec<&str> = pem.lines().collect();
137    if lines.len() < 3 {
138        return alloc::format!("{pem}\nTHIS_IS_NOT_BASE64!!!\n");
139    }
140
141    lines.insert(1, "THIS_IS_NOT_BASE64!!!");
142
143    let mut out = String::new();
144    for l in lines {
145        out.push_str(l);
146        out.push('\n');
147    }
148    out
149}
150
151fn inject_blank_line(pem: &str) -> String {
152    let mut lines: Vec<&str> = pem.lines().collect();
153    if lines.len() < 3 {
154        return alloc::format!("{pem}\n\n");
155    }
156    lines.insert(1, "");
157
158    let mut out = String::new();
159    for l in lines {
160        out.push_str(l);
161        out.push('\n');
162    }
163    out
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use std::collections::HashSet;
170
171    #[test]
172    fn bad_header_replaces_first_line() {
173        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
174        let out = corrupt_pem(pem, CorruptPem::BadHeader);
175        assert!(out.starts_with("-----BEGIN CORRUPTED KEY-----\n"));
176    }
177
178    #[test]
179    fn bad_footer_replaces_last_line() {
180        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
181        let out = corrupt_pem(pem, CorruptPem::BadFooter);
182        assert!(out.contains("-----END CORRUPTED KEY-----\n"));
183    }
184
185    #[test]
186    fn bad_footer_on_empty_input_returns_replacement() {
187        let out = corrupt_pem("", CorruptPem::BadFooter);
188        assert_eq!(out, "-----END CORRUPTED KEY-----");
189    }
190
191    #[test]
192    fn bad_base64_short_input_inserts_line() {
193        let out = corrupt_pem("x", CorruptPem::BadBase64);
194        assert_eq!(out, "x\nTHIS_IS_NOT_BASE64!!!\n");
195    }
196
197    #[test]
198    fn extra_blank_line_short_input_appends_newlines() {
199        let out = corrupt_pem("x", CorruptPem::ExtraBlankLine);
200        assert_eq!(out, "x\n\n");
201    }
202
203    #[test]
204    fn truncate_variant_limits_length() {
205        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
206        let out = corrupt_pem(pem, CorruptPem::Truncate { bytes: 10 });
207        assert_eq!(out.len(), 10);
208    }
209
210    #[test]
211    fn deterministic_corruption_is_stable_for_same_variant() {
212        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
213        let first = corrupt_pem_deterministic(pem, "corrupt:variant-a");
214        let second = corrupt_pem_deterministic(pem, "corrupt:variant-a");
215        assert_eq!(first, second);
216    }
217
218    #[test]
219    fn deterministic_corruption_produces_multiple_shapes_across_variants() {
220        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
221        let variants = ["a", "b", "c", "d", "e", "f", "g", "h"];
222        let mut outputs = HashSet::new();
223        for v in variants {
224            outputs.insert(corrupt_pem_deterministic(pem, v));
225        }
226        assert!(outputs.len() >= 2);
227    }
228
229    fn find_variant(target: u8) -> String {
230        for i in 0u64.. {
231            let v = format!("v{i}");
232            if hash32(v.as_bytes()).as_bytes()[0] % 5 == target {
233                return v;
234            }
235        }
236        unreachable!()
237    }
238
239    #[test]
240    fn deterministic_pem_arm0_bad_header() {
241        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
242        let out = corrupt_pem_deterministic(pem, &find_variant(0));
243        assert!(out.contains("BEGIN CORRUPTED KEY"));
244    }
245
246    #[test]
247    fn deterministic_pem_arm1_bad_footer() {
248        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
249        let out = corrupt_pem_deterministic(pem, &find_variant(1));
250        assert!(out.contains("END CORRUPTED KEY"));
251    }
252
253    #[test]
254    fn deterministic_pem_arm2_bad_base64() {
255        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
256        let out = corrupt_pem_deterministic(pem, &find_variant(2));
257        assert!(out.contains("THIS_IS_NOT_BASE64!!!"));
258    }
259
260    #[test]
261    fn deterministic_pem_arm3_blank_line() {
262        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
263        let out = corrupt_pem_deterministic(pem, &find_variant(3));
264        assert!(out.contains("BEGIN TEST-----\n\n"));
265    }
266
267    #[test]
268    fn deterministic_pem_arm4_truncate() {
269        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
270        let out = corrupt_pem_deterministic(pem, &find_variant(4));
271        assert!(out.len() < pem.len());
272    }
273
274    #[test]
275    fn bad_base64_inserts_after_header_in_normal_pem() {
276        // Catches `< 3` → `== 3` and `<= 3`: those would take the early-return
277        // path for a 3-line PEM, appending at end instead of inserting after line 1.
278        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
279        let out = corrupt_pem(pem, CorruptPem::BadBase64);
280        let lines: Vec<&str> = out.lines().collect();
281        assert_eq!(lines.len(), 4);
282        assert_eq!(lines[0], "-----BEGIN TEST-----");
283        assert_eq!(lines[1], "THIS_IS_NOT_BASE64!!!");
284        assert_eq!(lines[2], "AAA=");
285    }
286
287    #[test]
288    fn bad_base64_two_line_pem_appends() {
289        // Catches `< 3` → `> 3`: with `> 3`, a 2-line input would insert
290        // instead of taking the early-return append path.
291        let pem = "line1\nline2";
292        let out = corrupt_pem(pem, CorruptPem::BadBase64);
293        assert_eq!(out, "line1\nline2\nTHIS_IS_NOT_BASE64!!!\n");
294    }
295
296    #[test]
297    fn blank_line_inserts_after_header_in_normal_pem() {
298        // Same boundary check as inject_bad_base64_line.
299        let pem = "-----BEGIN TEST-----\nAAA=\n-----END TEST-----\n";
300        let out = corrupt_pem(pem, CorruptPem::ExtraBlankLine);
301        let lines: Vec<&str> = out.lines().collect();
302        assert_eq!(lines.len(), 4);
303        assert_eq!(lines[0], "-----BEGIN TEST-----");
304        assert_eq!(lines[1], "");
305        assert_eq!(lines[2], "AAA=");
306    }
307
308    #[test]
309    fn blank_line_two_line_pem_appends() {
310        let pem = "line1\nline2";
311        let out = corrupt_pem(pem, CorruptPem::ExtraBlankLine);
312        assert_eq!(out, "line1\nline2\n\n");
313    }
314
315    #[test]
316    fn derived_truncate_len_exact_arithmetic() {
317        // Catches `return 0`, `return 1`, `+ → *`, and arithmetic mutations
318        // on the span / modulo computation.
319        let mut digest = [0u8; 32];
320        digest[1] = 0x0A;
321        digest[2] = 0x0B;
322        // chars=10, span=9, u16=0x0A0B=2571, 2571%9=6, result=1+6=7
323        assert_eq!(derived_truncate_len("0123456789", &digest), 7);
324    }
325
326    #[test]
327    fn derived_truncate_len_empty_returns_zero() {
328        let digest = [0u8; 32];
329        assert_eq!(derived_truncate_len("", &digest), 0);
330    }
331
332    #[test]
333    fn derived_truncate_len_single_char_returns_zero() {
334        let digest = [0xFF; 32];
335        assert_eq!(derived_truncate_len("x", &digest), 0);
336    }
337}