Skip to content

Commit aea75c0

Browse files
johnsideserfclaude
andauthored
Add fuzz testing for external input boundaries (#179)
Set up cargo-fuzz with four harness targets: - fuzz_json_rpc: JSON-RPC parsing (parse_signal_event, parse_rpc_result) - fuzz_input_edit: UTF-8 cursor operations (insert/delete/move at byte boundaries) - fuzz_key_combo: keybinding string parsing from user TOML files - fuzz_command_parse: slash command parsing from user input Adds src/lib.rs to expose modules to fuzz harnesses (binary crate modules are not accessible to external crates). Makes parse_signal_event and parse_rpc_result pub for the JSON-RPC harness. Closes #172. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b4f200 commit aea75c0

File tree

9 files changed

+198
-2
lines changed

9 files changed

+198
-2
lines changed

fuzz/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
target
2+
corpus
3+
artifacts
4+
coverage

fuzz/Cargo.toml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[package]
2+
name = "siggy-fuzz"
3+
version = "0.0.0"
4+
publish = false
5+
edition = "2021"
6+
7+
[package.metadata]
8+
cargo-fuzz = true
9+
10+
[dependencies]
11+
libfuzzer-sys = "0.4"
12+
serde_json = "1"
13+
crossterm = "0.28"
14+
15+
[dependencies.siggy]
16+
path = ".."
17+
18+
[[bin]]
19+
name = "fuzz_json_rpc"
20+
path = "fuzz_targets/fuzz_json_rpc.rs"
21+
test = false
22+
doc = false
23+
bench = false
24+
25+
[[bin]]
26+
name = "fuzz_input_edit"
27+
path = "fuzz_targets/fuzz_input_edit.rs"
28+
test = false
29+
doc = false
30+
bench = false
31+
32+
[[bin]]
33+
name = "fuzz_key_combo"
34+
path = "fuzz_targets/fuzz_key_combo.rs"
35+
test = false
36+
doc = false
37+
bench = false
38+
39+
[[bin]]
40+
name = "fuzz_command_parse"
41+
path = "fuzz_targets/fuzz_command_parse.rs"
42+
test = false
43+
doc = false
44+
bench = false
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#![no_main]
2+
use libfuzzer_sys::fuzz_target;
3+
4+
fuzz_target!(|data: &[u8]| {
5+
if let Ok(s) = std::str::from_utf8(data) {
6+
// Should never panic regardless of input
7+
let _ = siggy::input::parse_input(s);
8+
}
9+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#![no_main]
2+
use libfuzzer_sys::fuzz_target;
3+
4+
/// Replicate the cursor helpers from app.rs to fuzz the same logic.
5+
fn next_char_pos(buf: &str, pos: usize) -> usize {
6+
if pos >= buf.len() {
7+
return buf.len();
8+
}
9+
pos + buf[pos..].chars().next().map_or(1, |c| c.len_utf8())
10+
}
11+
12+
fn prev_char_pos(buf: &str, pos: usize) -> usize {
13+
if pos == 0 {
14+
return 0;
15+
}
16+
pos - buf[..pos].chars().next_back().map_or(1, |c| c.len_utf8())
17+
}
18+
19+
fuzz_target!(|data: &[u8]| {
20+
// Need at least 1 byte for the operation selector
21+
if data.is_empty() {
22+
return;
23+
}
24+
25+
// Interpret the first bytes as initial UTF-8 buffer content
26+
let split = data.len() / 2;
27+
let buf_bytes = &data[..split];
28+
let ops = &data[split..];
29+
30+
let Ok(initial) = std::str::from_utf8(buf_bytes) else {
31+
return;
32+
};
33+
34+
let mut buffer = initial.to_string();
35+
let mut cursor: usize = 0;
36+
37+
// Apply a sequence of editing operations driven by the fuzzer
38+
for &op in ops {
39+
match op % 8 {
40+
// Move right
41+
0 => cursor = next_char_pos(&buffer, cursor),
42+
// Move left
43+
1 => cursor = prev_char_pos(&buffer, cursor),
44+
// Backspace
45+
2 => {
46+
if cursor > 0 {
47+
cursor = prev_char_pos(&buffer, cursor);
48+
if cursor < buffer.len() && buffer.is_char_boundary(cursor) {
49+
buffer.remove(cursor);
50+
}
51+
}
52+
}
53+
// Delete
54+
3 => {
55+
if cursor < buffer.len() && buffer.is_char_boundary(cursor) {
56+
buffer.remove(cursor);
57+
}
58+
}
59+
// Insert ASCII char
60+
4 => {
61+
if buffer.is_char_boundary(cursor) {
62+
buffer.insert(cursor, 'a');
63+
cursor += 1;
64+
}
65+
}
66+
// Insert multi-byte char
67+
5 => {
68+
if buffer.is_char_boundary(cursor) {
69+
buffer.insert(cursor, '\u{1F600}'); // 4-byte emoji
70+
cursor += 4;
71+
}
72+
}
73+
// Home
74+
6 => cursor = 0,
75+
// End
76+
7 => cursor = buffer.len(),
77+
_ => unreachable!(),
78+
}
79+
80+
// Invariant: cursor must always be at a valid char boundary
81+
assert!(
82+
cursor <= buffer.len(),
83+
"cursor {cursor} past end {}",
84+
buffer.len()
85+
);
86+
if cursor < buffer.len() {
87+
assert!(
88+
buffer.is_char_boundary(cursor),
89+
"cursor {cursor} not on char boundary"
90+
);
91+
}
92+
}
93+
});

fuzz/fuzz_targets/fuzz_json_rpc.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#![no_main]
2+
use libfuzzer_sys::fuzz_target;
3+
use siggy::signal::types::JsonRpcResponse;
4+
use std::path::Path;
5+
6+
fuzz_target!(|data: &[u8]| {
7+
let download_dir = Path::new("/tmp");
8+
9+
// Fuzz the full JSON-RPC pipeline: deserialize arbitrary bytes, then parse
10+
if let Ok(s) = std::str::from_utf8(data) {
11+
if let Ok(resp) = serde_json::from_str::<JsonRpcResponse>(s) {
12+
let _ = siggy::signal::client::parse_signal_event(&resp, download_dir);
13+
14+
// Also fuzz parse_rpc_result directly with the result field
15+
if let Some(result) = &resp.result {
16+
for method in &["send", "listContacts", "listGroups", "listIdentities"] {
17+
let _ = siggy::signal::client::parse_rpc_result(
18+
method,
19+
result,
20+
resp.id.as_deref(),
21+
);
22+
}
23+
}
24+
}
25+
}
26+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#![no_main]
2+
use libfuzzer_sys::fuzz_target;
3+
4+
fuzz_target!(|data: &[u8]| {
5+
if let Ok(s) = std::str::from_utf8(data) {
6+
// Should never panic, only return Ok/Err
7+
let _ = siggy::keybindings::parse_key_combo(s);
8+
}
9+
});

src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Library entrypoint for fuzz testing and external consumers.
2+
// The main binary (main.rs) has its own module tree; this re-exports
3+
// only what fuzz harnesses need.
4+
5+
pub mod config;
6+
#[allow(dead_code)]
7+
mod debug_log;
8+
pub mod input;
9+
pub mod keybindings;
10+
pub mod signal;

src/signal/client.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,7 +1108,7 @@ impl SignalClient {
11081108
}
11091109
}
11101110

1111-
fn parse_rpc_result(method: &str, result: &serde_json::Value, rpc_id: Option<&str>) -> Option<SignalEvent> {
1111+
pub fn parse_rpc_result(method: &str, result: &serde_json::Value, rpc_id: Option<&str>) -> Option<SignalEvent> {
11121112
match method {
11131113
"send" => {
11141114
let id = rpc_id?.to_string();
@@ -1216,7 +1216,7 @@ fn parse_rpc_result(method: &str, result: &serde_json::Value, rpc_id: Option<&st
12161216
}
12171217
}
12181218

1219-
fn parse_signal_event(
1219+
pub fn parse_signal_event(
12201220
resp: &JsonRpcResponse,
12211221
download_dir: &std::path::Path,
12221222
) -> Option<SignalEvent> {

src/signal/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ pub enum TrustLevel {
4949
}
5050

5151
impl TrustLevel {
52+
#[allow(clippy::should_implement_trait)]
5253
pub fn from_str(s: &str) -> Self {
5354
match s {
5455
"UNTRUSTED" => TrustLevel::Untrusted,

0 commit comments

Comments
 (0)