test: hostile-input tests for the remote-facing parsers

Seeded pseudo-random input, biased towards each parser's control bytes,
is fed to the IRC line parser and state machine (plus mIRC formatting),
Telnet IAC negotiation, VNC framebuffer updates (including ZRLE behind a
real zlib stream, with a check that the decoders are actually reached),
~/.ssh/config parsing and the update manifest. None panic, and Telnet's
pending buffer stays bounded.

These run on stable in every CI run; they are not coverage-guided, so a
cargo-fuzz setup (nightly) remains a possible follow-up.
This commit is contained in:
Andy Dixon
2026-10-12 09:40:16 +01:00
parent 0d0b3a7c75
commit 2e11d40034
6 changed files with 125 additions and 0 deletions
+17
View File
@@ -1052,6 +1052,23 @@ mod tests {
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
#[test]
fn hostile_lines_never_panic() {
let client = Client::new(Config::default());
client.st().nick = "me".into();
client.handle_message(&parse_line(":me!u@h JOIN #a").unwrap());
let mut n = crate::Noise::new(0x1bc);
for _ in 0..5000 {
let raw = n.bytes(120, b":!@# ,;=\x01\x02\x03\x0f\x16\x1d\x1fme#aJOINPARTKICKPRIVMSGNOTICE3534330011234567890");
let line = String::from_utf8_lossy(&raw);
if let Some(m) = parse_line(&line) {
client.handle_message(&m);
let e = Event { kind: "message".into(), target: m.param(0).into(), from: m.source.clone(), text: m.last().into(), ..Default::default() };
let _ = crate::ircformat::format_event(&e, "12:00");
}
}
}
#[test]
fn remember_room_membership_tracks_confirmed_channels() {
let updates = Arc::new(Mutex::new(Vec::new()));
+13
View File
@@ -322,6 +322,19 @@ mod tests {
use std::sync::mpsc;
use std::time::Duration;
#[test]
fn hostile_negotiation_never_panics() {
let c = TelnetClient::new(&Profile::new(crate::models::Protocol::Telnet), Callbacks::noop(), None);
let mut n = crate::Noise::new(0x7e1);
let mut pending = Vec::new();
for _ in 0..5000 {
// IAC, SE, SB, WILL, WONT, DO, DONT and the options the client handles.
let chunk = n.bytes(64, &[255, 240, 250, 251, 252, 253, 254, 1, 3, 24, 31, 32, 0, b'a']);
c.inner.process(&mut pending, &chunk);
assert!(pending.len() <= 16 << 10, "pending must stay bounded");
}
}
#[test]
fn line_endings() {
let cases = [
+38
View File
@@ -866,6 +866,44 @@ mod tests {
use super::*;
use std::net::TcpListener;
#[test]
fn hostile_updates_never_panic() {
use flate2::{Compress, Compression, FlushCompress};
let mut n = crate::Noise::new(0x5eed);
let (mut fb, mut dec) = (Framebuffer::new(32, 32), Decoders::default());
let mut z = Compress::new(Compression::fast(), true);
let mut decoded = [0usize; 2]; // successful ZRLE / other updates
for _ in 0..3000 {
let enc: i32 = [0, 1, 5, 7, 16, -223, -224, -308, 99][n.below(9)];
let dim = |n: &mut crate::Noise| n.below(40) as u16;
let (x, y, w, h) = (dim(&mut n), dim(&mut n), dim(&mut n), dim(&mut n));
let mut msg = vec![0, 0, 1];
for v in [x, y, w, h] {
msg.extend_from_slice(&v.to_be_bytes());
}
msg.extend_from_slice(&enc.to_be_bytes());
let body = n.bytes(600, &[0, 1, 2, 0xff, 0x80, 0x10, 0x1f]);
if enc == 16 {
// ZRLE: random tiles behind a real zlib stream, so the decoder itself is reached.
let mut out = Vec::with_capacity(body.len() * 2 + 64);
z.compress_vec(&body, &mut out, FlushCompress::Sync).unwrap();
msg.extend_from_slice(&(out.len() as u32).to_be_bytes());
msg.extend_from_slice(&out);
} else {
msg.extend_from_slice(&body);
}
match read_update(&mut &msg[..], &mut fb, &mut dec) {
Ok(_) => decoded[(enc != 16) as usize] += 1,
// The persistent zlib streams may have desynchronised; start them afresh.
Err(_) => (dec, z) = (Decoders::default(), Compress::new(Compression::fast(), true)),
}
if fb.width as usize * fb.height as usize > 1 << 20 {
fb = Framebuffer::new(32, 32);
}
}
assert!(decoded.iter().all(|&d| d > 0), "the harness must reach the decoders: {decoded:?}");
}
#[test]
fn des_response_matches_known_vector() {
// openssl enc -des-ecb -K 0e86ceceeef64e26 (bit-reversed "password") over "0123456789abcdef".
+31
View File
@@ -19,3 +19,34 @@ pub mod validation;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>;
/// Seeded pseudo-random input for the parsers' "hostile bytes never panic" tests. Not coverage
/// guided like cargo-fuzz, but runs on stable in every CI run.
#[cfg(test)]
pub(crate) struct Noise(u64);
#[cfg(test)]
impl Noise {
pub fn new(seed: u64) -> Noise {
Noise(seed | 1)
}
pub fn next(&mut self) -> u64 {
// xorshift64
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
pub fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
/// Up to `max` bytes, mostly drawn from `alphabet` (the bytes a parser cares about) and
/// otherwise uniformly random.
pub fn bytes(&mut self, max: usize, alphabet: &[u8]) -> Vec<u8> {
let n = self.below(max + 1);
(0..n).map(|_| if self.below(3) > 0 { alphabet[self.below(alphabet.len())] } else { self.next() as u8 }).collect()
}
}
+17
View File
@@ -175,6 +175,23 @@ fn is_trueish(v: &str) -> bool {
mod tests {
use super::*;
#[test]
fn hostile_configs_never_panic() {
let mut n = crate::Noise::new(0x55c);
let words: &[&[u8]] =
&[b"Host ", b"HostName ", b"Port ", b"User ", b"ProxyJump ", b"IdentityFile ", b"Match ", b"*", b"=", b"\n", b"\"", b" ", b"a@b:22,", b"99999"];
for _ in 0..3000 {
let mut text = Vec::new();
for _ in 0..n.below(40) {
text.extend_from_slice(words[n.below(words.len())]);
}
text.extend(n.bytes(20, b" \t\n="));
if let Ok(blocks) = parse(&String::from_utf8_lossy(&text)) {
let _ = to_profiles(&blocks, "f");
}
}
}
const TEST_CONFIG: &str = "# Test SSH config
Host *
+9
View File
@@ -145,6 +145,15 @@ fn leading_int(s: Option<&&str>) -> i64 {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_manifests_never_panic() {
let mut n = crate::Noise::new(0x9d);
for _ in 0..5000 {
let body = n.bytes(200, br#"{}[]":,0123456789.vlatestVersionreleaseURL"#);
let _ = parse_manifest(&body, "1.2.3");
}
}
use std::io::Write;
/// Serves one HTTP response on a random local port; returns its URL.