diff --git a/crates/npro-fuzz/src/lib.rs b/crates/npro-fuzz/src/lib.rs
index 61c6204..0ee3415 100644
--- a/crates/npro-fuzz/src/lib.rs
+++ b/crates/npro-fuzz/src/lib.rs
@@ -26,7 +26,7 @@ mod ws;
pub use h1::{chunked, h1_request, h1_response};
pub use targets::{base64, sha1, transcript, utf8};
-pub use ws::ws_server;
+pub use ws::{ws_client, ws_server};
/// A fuzz target: its name is the libFuzzer target's, the seed directory's
/// under `fuzz/seeds/`, and the corpus's under `corpus-<name>`.
@@ -57,11 +57,13 @@ pub enum Target {
Chunked,
/// [`ws_server`]: the ws frame parser, as a server.
WsServer,
+ /// [`ws_client`]: the ws frame parser, as a client.
+ WsClient,
}
impl Target {
/// Every target.
- pub const ALL: [Self; 8] = [
+ pub const ALL: [Self; 9] = [
Self::Utf8,
Self::Sha1,
Self::Base64,
@@ -70,6 +72,7 @@ impl Target {
Self::H1Response,
Self::Chunked,
Self::WsServer,
+ Self::WsClient,
];
/// The target's name.
@@ -84,6 +87,7 @@ impl Target {
Self::H1Response => "h1-response",
Self::Chunked => "chunked",
Self::WsServer => "ws-server",
+ Self::WsClient => "ws-client",
}
}
@@ -103,6 +107,7 @@ impl Target {
Self::H1Response => h1_response(data),
Self::Chunked => chunked(data),
Self::WsServer => ws_server(data),
+ Self::WsClient => ws_client(data),
}
}
}
diff --git a/crates/npro-fuzz/src/ws.rs b/crates/npro-fuzz/src/ws.rs
index 9abe2db..824ef36 100644
--- a/crates/npro-fuzz/src/ws.rs
+++ b/crates/npro-fuzz/src/ws.rs
@@ -1,16 +1,31 @@
-//! The ws target: a server's frame parser, as C's `fuzz-ws` has it, after
-//! an upgrade.
+//! The ws targets: a server's frame parser, as C's `fuzz-ws` has it, after
+//! an upgrade, and a client's.
-use npro_ws::conn::{Event, Kind, Ws};
+use npro_core::random::{Random, Unavailable};
+use npro_ws::conn::{Close, Event, Kind, Role, Side, Ws};
use crate::targets::{Pieces, control, finding};
-const TARGET: &str = "ws-server";
-
/// The most a piece is unmasked into: the fuzzer's input is copied here,
/// since the parser unmasks where the bytes lie.
const MAX_INPUT: usize = 64 * 1024;
+/// The mask a client's frames are written with: not zero, so masking is
+/// done, and fixed, so a run is its input's alone.
+const MASK: [u8; 4] = [0x5a, 0xa5, 0x0f, 0xf0];
+
+/// A client's masks.
+struct FixedMask;
+
+impl Random for FixedMask {
+ fn fill(&mut self, buf: &mut [u8]) -> Result<(), Unavailable> {
+ for (b, m) in buf.iter_mut().zip(MASK.iter().cycle()) {
+ *b = *m;
+ }
+ Ok(())
+ }
+}
+
/// What the application was handed, a message being whole once it is.
#[derive(Debug, PartialEq, Eq)]
enum Given {
@@ -20,13 +35,13 @@ enum Given {
}
/// Everything a run came to: what the application was handed, the part of
-/// a message still open, what the server wrote, and how it closed.
+/// a message still open, what was written, and how it closed.
#[derive(Debug, PartialEq, Eq)]
struct Run {
given: Vec<Given>,
open: Option<(Kind, Vec<u8>)>,
wrote: Vec<u8>,
- close: Option<npro_ws::conn::Close>,
+ close: Option<Close>,
}
struct Nothing;
@@ -37,11 +52,15 @@ impl npro_h1::server::TxSource for Nothing {
}
}
-/// Feeds `pieces` to a fresh server, checking what each call says as it
-/// goes; writes nothing until the end, so the run does not depend on when
-/// the pong slot is drained.
-fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run {
- let mut ws = Ws::server(b"");
+/// Feeds `pieces` to `ws`, checking what each call says as it goes;
+/// writes nothing until the end, so the run does not depend on when the
+/// pong slot is drained.
+fn run<'a, P: Role>(
+ target: &str,
+ how: &str,
+ mut ws: Ws<P>,
+ pieces: impl Iterator<Item = &'a [u8]>,
+) -> Run {
let mut given = Vec::new();
let mut open: Option<(Kind, Vec<u8>)> = None;
let mut buf = Vec::new();
@@ -53,14 +72,14 @@ fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run {
let rx = ws.rx(input);
if rx.consumed == 0 {
finding(
- TARGET,
+ target,
format_args!("{how}: took none of {} bytes", input.len()),
);
}
at = at.saturating_add(rx.consumed);
let Some(ev) = rx.event else { continue };
if matches!(given.last(), Some(Given::PeerClose(_))) {
- finding(TARGET, format_args!("{how}: {ev:?} after the peer's close"));
+ finding(target, format_args!("{how}: {ev:?} after the peer's close"));
}
match ev {
Event::Message {
@@ -73,7 +92,7 @@ fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run {
(true, None) => (kind, Vec::new()),
(false, Some(m)) if m.0 == kind => m,
(_, was) => finding(
- TARGET,
+ target,
format_args!("{how}: first {first} with {was:?} open"),
),
};
@@ -106,52 +125,83 @@ fn run<'a>(how: &str, pieces: impl Iterator<Item = &'a [u8]>) -> Run {
}
}
-/// The frames a server wrote, checked as a client would read them: each
-/// whole, final and unmasked, a control frame at most 125 bytes, a close's
-/// payload a code and its reason, and nothing after a close.
+/// The frames `side` wrote, checked as its peer would read them, and
+/// returned as their opcodes and payloads: each whole and final, masked
+/// with [`MASK`] by a client and not by a server, a control frame at most
+/// 125 bytes, a close's payload a code and its reason, and nothing after a
+/// close.
///
/// `peer_close` is the payload of the peer's close, if it sent one. C
/// answers with it whatever it is, a lone byte included, which RFC 6455
/// 5.5.1 does not allow, and npro does as C does: so a close of one byte
/// is taken from npro only as that echo.
-fn check_written(wrote: &[u8], peer_close: Option<&[u8]>) {
+fn written(
+ target: &str,
+ side: Side,
+ wrote: &[u8],
+ peer_close: Option<&[u8]>,
+) -> Vec<(u8, Vec<u8>)> {
+ let masked = match side {
+ Side::Server => 0,
+ Side::Client => 0x80,
+ };
+ let mut frames = Vec::new();
let mut rest = wrote;
while let [b0, b1, tail @ ..] = rest {
let (op, len) = (b0 & 0x0f, usize::from(b1 & 0x7f));
- if b0 & 0xf0 != 0x80 || b1 & 0x80 != 0 || len > 125 {
- finding(TARGET, format_args!("wrote a frame {b0:#04x} {b1:#04x}"));
+ if b0 & 0xf0 != 0x80 || b1 & 0x80 != masked || len > 125 {
+ finding(target, format_args!("wrote a frame {b0:#04x} {b1:#04x}"));
}
+ let tail = if masked == 0 {
+ tail
+ } else {
+ match tail.split_first_chunk::<4>() {
+ Some((m, t)) if *m == MASK => t,
+ _ => finding(target, format_args!("wrote a frame without the mask")),
+ }
+ };
let Some((payload, after)) = tail.split_at_checked(len) else {
finding(
- TARGET,
+ target,
format_args!("wrote {} of a {len} byte frame", tail.len()),
);
};
+ let payload: Vec<u8> = if masked == 0 {
+ payload.to_vec()
+ } else {
+ payload
+ .iter()
+ .zip(MASK.iter().cycle())
+ .map(|(b, m)| b ^ m)
+ .collect()
+ };
match op {
// a pong, of a ping's payload
0xa => {}
0x8 => {
- let echoed = peer_close == Some(payload);
+ let echoed = peer_close == Some(payload.as_slice());
if (payload.len() == 1 && !echoed) || !after.is_empty() {
finding(
- TARGET,
+ target,
format_args!("wrote a close {payload:?} then {after:?}"),
);
}
}
_ => finding(
- TARGET,
+ target,
format_args!("wrote opcode {op:#x} with nothing sent"),
),
}
+ frames.push((op, payload));
rest = after;
}
if !rest.is_empty() {
finding(
- TARGET,
+ target,
format_args!("wrote a frame of {} bytes", rest.len()),
);
}
+ frames
}
/// The text message a run leaves open, empty if none; `None` for binary.
@@ -165,43 +215,44 @@ fn open_text(r: &Run) -> Option<&[u8]> {
/// Whether two runs leave the same message open. Text is handed over a
/// piece at a time once each piece checks out, so where it turns out not to
-/// be UTF-8, how much of its good start went first depends on the split:
-/// then, and only then, one run's open message need only start the other's.
-fn opens_agree(a: &Run, b: &Run) -> bool {
+/// be UTF-8, `bad_utf8`, how much of its good start went first depends on
+/// the split: then, and only then, one run's open message need only start
+/// the other's.
+fn opens_agree(a: &Run, b: &Run, bad_utf8: bool) -> bool {
if a.open == b.open {
return true;
}
- let bad_utf8 = a.wrote.get(..4) == Some(b"\x88\x0a\x03\xef".as_slice())
- || a.wrote.get(..4) == Some(b"\x88\x0e\x03\xef".as_slice());
match (open_text(a), open_text(b)) {
(Some(x), Some(y)) => bad_utf8 && (x.starts_with(y) || y.starts_with(x)),
(None, _) | (_, None) => false,
}
}
-/// A client's frames as a server reads them after the upgrade: C's
-/// `fuzz-ws`.
-///
-/// The first byte chooses how the rest is split. In one piece and in
-/// pieces, the server must hand the application the same messages, pongs
-/// and close, write the same, and close the same, of a message it refuses
-/// as not UTF-8 having handed over a start of it that may differ (see
-/// `opens_agree`); every call must take
-/// something; a message's pieces must say where it starts; nothing may
-/// follow the peer's close; a whole text message must be UTF-8 by
-/// `core::str`; and what the server writes must be frames a client reads.
-pub fn ws_server(data: &[u8]) {
+/// Runs `frames` through `make()`'s connection whole and in pieces, and
+/// checks both: see [`ws_server`].
+fn frames<P: Role>(target: &str, data: &[u8], make: impl Fn() -> Ws<P>) {
let (ctl, frames) = control(data);
let frames = frames.get(..MAX_INPUT).unwrap_or(frames);
- let whole = run("whole", core::iter::once(frames));
- let pieces = run("in pieces", Pieces::new(ctl, frames));
+ let side = make().side();
+ let whole = run(target, "whole", make(), core::iter::once(frames));
+ let pieces = run(target, "in pieces", make(), Pieces::new(ctl, frames));
+
+ let peer_close = whole.given.iter().find_map(|g| match g {
+ Given::PeerClose(p) => Some(p.as_slice()),
+ Given::Message(..) | Given::Pong(_) => None,
+ });
+ let sent = written(target, side, &whole.wrote, peer_close);
+ let bad_utf8 = matches!(
+ sent.first(),
+ Some((0x8, p)) if p.get(..2) == Some(&1007u16.to_be_bytes()[..])
+ );
if whole.given != pieces.given
|| whole.wrote != pieces.wrote
|| whole.close != pieces.close
- || !opens_agree(&whole, &pieces)
+ || !opens_agree(&whole, &pieces, bad_utf8)
{
finding(
- TARGET,
+ target,
format_args!("whole {whole:?}, in pieces {pieces:?}"),
);
}
@@ -211,12 +262,28 @@ pub fn ws_server(data: &[u8]) {
});
for t in texts {
if core::str::from_utf8(t).is_err() {
- finding(TARGET, format_args!("text {t:?} is not UTF-8"));
+ finding(target, format_args!("text {t:?} is not UTF-8"));
}
}
- let peer_close = whole.given.iter().find_map(|g| match g {
- Given::PeerClose(p) => Some(p.as_slice()),
- Given::Message(..) | Given::Pong(_) => None,
- });
- check_written(&whole.wrote, peer_close);
+}
+
+/// A client's frames as a server reads them after the upgrade: C's
+/// `fuzz-ws`.
+///
+/// The first byte chooses how the rest is split. In one piece and in
+/// pieces, the server must hand the application the same messages, pongs
+/// and close, write the same, and close the same, of a message it refuses
+/// as not UTF-8 having handed over a start of it that may differ (see
+/// `opens_agree`); every call must take something; a message's pieces must
+/// say where it starts; nothing may follow the peer's close; a whole text
+/// message must be UTF-8 by `core::str`; and what the server writes must be
+/// frames a client reads.
+pub fn ws_server(data: &[u8]) {
+ frames("ws-server", data, || Ws::server(b""));
+}
+
+/// A server's frames as a client reads them after the upgrade: as
+/// [`ws_server`], from the client's side, which writes its frames masked.
+pub fn ws_client(data: &[u8]) {
+ frames("ws-client", data, || Ws::client(FixedMask));
}
diff --git a/crates/npro-fuzz/tests/smoke.rs b/crates/npro-fuzz/tests/smoke.rs
index 3dfd76f..c741a39 100644
--- a/crates/npro-fuzz/tests/smoke.rs
+++ b/crates/npro-fuzz/tests/smoke.rs
@@ -232,6 +232,11 @@ fn ws_server() {
}
#[test]
+fn ws_client() {
+ smoke(Target::WsClient, seeded).unwrap();
+}
+
+#[test]
fn every_target_has_a_smoke_test() {
// the tests above, by name: a new target needs its own
let tested = [
@@ -243,6 +248,7 @@ fn every_target_has_a_smoke_test() {
"h1-response",
"chunked",
"ws-server",
+ "ws-client",
];
assert_eq!(Target::ALL.map(Target::name), tested);
}
diff --git a/docs/fuzzing.md b/docs/fuzzing.md
index 2931d03..6846d0a 100644
--- a/docs/fuzzing.md
+++ b/docs/fuzzing.md
@@ -26,6 +26,7 @@ Fuzzing runs in three places:
| `h1-response` | `npro_h1::head` as a client | as `h1-request` |
| `chunked` | `npro_h1::chunked::Dechunk` | a second reading of RFC 9112 7.1 with C's bounds, written apart from the decoder: the same data, ending at the same byte, or refused, in one piece and in pieces |
| `ws-server` | `npro_ws::conn::Ws` as a server, after the upgrade, as C's `fuzz-ws` | in one piece and in pieces, the same messages, pongs and peer's close handed to the application, the same frames written and the same close; where text turns out not to be UTF-8, how much of its good start was handed over may differ with the split, the one only starting the other. Every call takes something; a message's pieces say where it starts; nothing follows the peer's close; a whole text message is UTF-8 by `core::str`; what is written is whole, final, unmasked frames, a close last |
+| `ws-client` | `npro_ws::conn::Ws` as a client, after the upgrade | as `ws-server`, from the client's side: what it writes is masked, with a fixed mask from the harness |
A target that splits its input to feed it in pieces takes the split from
the input's first byte, so libFuzzer explores the split like the rest of
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
index f87317c..e65a282 100644
--- a/fuzz/Cargo.toml
+++ b/fuzz/Cargo.toml
@@ -75,6 +75,13 @@ test = false
doc = false
bench = false
+[[bin]]
+name = "ws-client"
+path = "fuzz_targets/ws_client.rs"
+test = false
+doc = false
+bench = false
+
[lints.rust]
unsafe_code = "forbid"
unexpected_cfgs = "deny"
diff --git a/fuzz/fuzz_targets/ws_client.rs b/fuzz/fuzz_targets/ws_client.rs
new file mode 100644
index 0000000..0d5646a
--- /dev/null
+++ b/fuzz/fuzz_targets/ws_client.rs
@@ -0,0 +1,5 @@
+//! libFuzzer target for [`npro_fuzz::Target::WsClient`].
+
+#![no_main]
+
+libfuzzer_sys::fuzz_target!(|data: &[u8]| npro_fuzz::Target::WsClient.run(data));
diff --git a/fuzz/seeds/README.md b/fuzz/seeds/README.md
index 4779106..d4da004 100644
--- a/fuzz/seeds/README.md
+++ b/fuzz/seeds/README.md
@@ -24,7 +24,10 @@ The protocol crates' targets start from the C library's corpora
- `h1-response` and `chunked`: some of `crates/npro-test/h1/responses/`
and `chunked/`;
- `ws-server`: C's `fuzz/fuzz-ws/seeds`, each behind a control byte of 0,
- named as there.
+ named as there;
+- `ws-client`: C has no client corpus, so what the server sent after the
+ 101 in each of the `ws-client-*` transcripts but the pmd ones, behind a
+ control byte of 0, named for the transcript.
A `tight-*` seed's control byte has its top bit set, choosing the small
table with token limits. A seed named `regress-*` is an input the fuzzer
diff --git a/fuzz/seeds/ws-client/ws-client-huge-frame.ws b/fuzz/seeds/ws-client/ws-client-huge-frame.ws
new file mode 100644
index 0000000..f8aba4e
Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client-huge-frame.ws differ
diff --git a/fuzz/seeds/ws-client/ws-client-ping-close.ws b/fuzz/seeds/ws-client/ws-client-ping-close.ws
new file mode 100644
index 0000000..fe36e9d
Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client-ping-close.ws differ
diff --git a/fuzz/seeds/ws-client/ws-client-rsv1-no-ext.ws b/fuzz/seeds/ws-client/ws-client-rsv1-no-ext.ws
new file mode 100644
index 0000000..7307c7b
Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client-rsv1-no-ext.ws differ
diff --git a/fuzz/seeds/ws-client/ws-client-rsv2.ws b/fuzz/seeds/ws-client/ws-client-rsv2.ws
new file mode 100644
index 0000000..e887c97
Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client-rsv2.ws differ
diff --git a/fuzz/seeds/ws-client/ws-client.ws b/fuzz/seeds/ws-client/ws-client.ws
new file mode 100644
index 0000000..83d248c
Binary files /dev/null and b/fuzz/seeds/ws-client/ws-client.ws differ