Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
1//! Property-based fuzzing of `ETRACE` at the [`dispatch`](leveva::command::dispatch)
2//! layer. ETRACE is **fully oper-gated** (the whole command needs `ACL_TRACE`), so the
3//! fuzzing surface is: the privilege gate (no `trace` → exactly `[481]`); the privileged
4//! sweep (one `708` per seeded local person in folded-nick order, then one `759`); the
5//! privileged single-target lookup (a registered nick → `[708, 759]`, otherwise `[759]`
6//! only); and arbitrary args / nick / privilege — none of which may panic the handler or
7//! emit a non-ETRACE numeric.
8//!
9//! - **`gate_then_sweep`** — a no-arg `ETRACE`: a requester *without* `ACL_TRACE` yields
10//! exactly `[481]`; *with* it, exactly one `708` per seeded person (folded order) then
11//! one closing `759`.
12//! - **`target_resolves_or_terminator`** — a privileged `ETRACE <target>`: a registered
13//! nick → `[708, 759]` (the `708` naming the target), any other token → `[759]` only.
14//! - **`arbitrary_never_panics`** — arbitrary printable args / nick / privilege never panic
15//! and emit only `{481, 708, 759}`.
16
17use std::sync::Arc;
18
19use leveva::channel::Channels;
20use leveva::command::{dispatch, Registered};
21use leveva::config::{Admin, OperPrivilege};
22use leveva::registry::Registry;
23use leveva::server::{Counters, ServerContext};
24use leveva::{casemap, Message, Sid, Uid, UserMode};
25use proptest::prelude::*;
26use tokio::sync::mpsc::unbounded_channel;
27
28const SERVER: &str = "leveva.test";
29
30fn ctx() -> Arc<ServerContext> {
31 Arc::new(ServerContext {
32 name: SERVER.to_string(),
33 description: "T".to_string(),
34 network: "TestNet".to_string(),
35 version: "leveva-0.0.0",
36 created: "FIXED".to_string(),
37 motd: None,
38 counters: Counters::default(),
39 registry: Registry::new(),
40 channels: Channels::new(),
41 whowas: leveva::whowas::WhowasHistory::default(),
42 uids: leveva::uid::UidGenerator::new(Sid::try_from("0ABC").unwrap()),
43 default_user_modes: 0,
44 default_channel_modes: 0,
45 operators: Vec::new(),
46 stats_conf: leveva::server::StatsConf::default(),
47 klines: leveva::kline::KlineStore::new(),
48 dlines: leveva::dline::DlineStore::new(),
49 net: leveva::s2s::Network::new(),
50 peers: leveva::s2s::PeerLinks::new(),
51 monitors: leveva::monitor::Monitors::new(),
52 conn_limits: leveva::connlimit::ConnLimits::new(),
53 linking: leveva::link::LinkingSet::new(),
54 knock_throttle: leveva::knock_throttle::KnockThrottle::new(),
55 knock_user_throttle: leveva::knock_throttle::KnockThrottle::new(),
56 resvs: leveva::resv::ResvStore::new(),
57 history: leveva::history::History::disabled(),
58 auth: leveva::server::AuthConfig::default(),
59 admin: Admin::default(),
60 })
61}
62
63/// A distinct UID for seed index `i` (≤ 19, the strategy cap): `0ABCAAAA{A..T}`.
64fn uid_for(i: usize) -> Uid {
65 Uid::try_from(format!("0ABCAAAA{}", (b'A' + i as u8) as char).as_str()).unwrap()
66}
67
68/// Seed `nick` (index `i`) with `is_oper` into the registry; return its UID.
69fn seed(ctx: &ServerContext, i: usize, nick: &str, is_oper: bool) -> Uid {
70 let uid = uid_for(i);
71 let (tx, _rx) = unbounded_channel();
72 ctx.registry
73 .try_claim(&uid, nick, "u", "h", "r", tx)
74 .expect("free nick");
75 if is_oper {
76 ctx.registry.set_modes(&uid, UserMode::Oper.bit());
77 }
78 uid
79}
80
81fn client(nick: &str, trace_priv: bool) -> Registered {
82 Registered {
83 uid: uid_for(0),
84 nick: nick.to_string(),
85 user: "u".into(),
86 host: "127.0.0.1".into(),
87 realname: "R".into(),
88 modes: 0,
89 privileges: if trace_priv {
90 vec![OperPrivilege::Trace]
91 } else {
92 Vec::new()
93 },
94 caps: Default::default(),
95 }
96}
97
98fn codes(ms: &[Message]) -> Vec<&str> {
99 ms.iter().map(|m| m.command()).collect()
100}
101
102/// A parse-safe nick token. The first entry is always the requester.
103fn members() -> impl Strategy<Value = Vec<String>> {
104 prop::collection::vec("[a-zA-Z][a-zA-Z0-9]{0,5}", 1..8)
105}
106
107/// Dedup `raw` by folded nick (first wins), preserving order — registry claims are keyed
108/// by folded nick, so collisions would otherwise fail the seed.
109fn dedup(raw: &[String]) -> Vec<String> {
110 let mut seen = std::collections::HashSet::new();
111 raw.iter()
112 .filter(|n| seen.insert(casemap::fold(n)))
113 .cloned()
114 .collect()
115}
116
117proptest! {
118 #![proptest_config(ProptestConfig::with_cases(512))]
119
120 /// A no-arg `ETRACE`: unprivileged → `[481]`; privileged → one `708` per person
121 /// (folded order) then one `759`.
122 #[test]
123 fn gate_then_sweep(raw in members(), trace_priv in any::<bool>()) {
124 let people = dedup(&raw);
125 let ctx = ctx();
126 for (i, nick) in people.iter().enumerate() {
127 // Oper-status here only flips the 708 Oper/User label, not membership.
128 seed(&ctx, i, nick, i % 2 == 0);
129 }
130 let me = client(&people[0], trace_priv);
131 let r = dispatch(&mut me.clone(), &Message::parse("ETRACE").unwrap(), &ctx);
132 let c = codes(&r);
133
134 if !trace_priv {
135 prop_assert_eq!(&c, &["481"]);
136 } else {
137 // One 708 per seeded person, folded order, then a single trailing 759.
138 let mut folded: Vec<String> = people.iter().map(|n| casemap::fold(n)).collect();
139 folded.sort();
140 let mut want: Vec<&str> = vec!["708"; folded.len()];
141 want.push("759");
142 prop_assert_eq!(&c, &want);
143 // The 708 nick column follows folded order; the 759 names the server.
144 let got_nicks: Vec<String> =
145 r[..r.len() - 1].iter().map(|m| casemap::fold(&m.params()[3])).collect();
146 prop_assert_eq!(got_nicks, folded);
147 prop_assert_eq!(r.last().unwrap().params()[1].as_str(), SERVER);
148 }
149 }
150
151 /// A privileged `ETRACE <target>`: registered nick → `[708, 759]`; else `[759]`.
152 #[test]
153 fn target_resolves_or_terminator(raw in members(), target in "[a-zA-Z0-9]{1,8}") {
154 let people = dedup(&raw);
155 let ctx = ctx();
156 for (i, nick) in people.iter().enumerate() {
157 seed(&ctx, i, nick, false);
158 }
159 let me = client(&people[0], true); // privileged
160 let line = format!("ETRACE {target}");
161 let r = dispatch(&mut me.clone(), &Message::parse(&line).unwrap(), &ctx);
162 let c = codes(&r);
163
164 if let Some(nick) = people.iter().find(|n| casemap::fold(n) == casemap::fold(&target)) {
165 prop_assert_eq!(&c, &["708", "759"]);
166 prop_assert_eq!(casemap::fold(&r[0].params()[3]), casemap::fold(nick));
167 } else {
168 prop_assert_eq!(&c, &["759"]);
169 }
170 }
171
172 /// Arbitrary args / nick / privilege never panic and emit only ETRACE numerics.
173 #[test]
174 fn arbitrary_never_panics(
175 nick in "[a-zA-Z][a-zA-Z0-9]{0,8}",
176 trace_priv in any::<bool>(),
177 args in prop::collection::vec("[a-zA-Z0-9.*?_-]{0,8}", 0..4),
178 ) {
179 let ctx = ctx();
180 seed(&ctx, 0, &nick, false);
181 let line = format!("ETRACE {}", args.join(" "));
182 let me = client(&nick, trace_priv);
183 let r = dispatch(&mut me.clone(), &Message::parse(line.trim()).unwrap(), &ctx);
184 prop_assert!(codes(&r).iter().all(|x| matches!(*x, "481" | "708" | "759")));
185 }
186}