Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
1use crate::command::*;
2use rust_embed::RustEmbed;
3
4/// The per-command help pages, embedded from `leveva/help/`. In a release build the
5/// `.md` files are baked into the binary; in a debug build `rust-embed` reads them from
6/// disk at run time (relative to `CARGO_MANIFEST_DIR`), so help text can be edited without
7/// recompiling. `HELP` (bare) serves `index.md`; `HELP <CMD>` serves `<CMD>.md`.
8#[derive(RustEmbed)]
9#[folder = "help/"]
10struct HelpFiles;
11
12/// `HELP [<command>]` → the embedded help text, one `:<server> NOTICE <nick> :<line>` per
13/// line, wrapped in a `*** … ***` header/footer.
14///
15/// - bare `HELP` → `help/index.md` (the landing page).
16/// - `HELP <command>` → `help/<COMMAND>.md` (the command name is upper-folded; only
17/// `[A-Z0-9]` names are looked up, so a path-traversal argument simply misses).
18/// - an unknown / unhelped command → a single not-found `NOTICE`.
19///
20/// Faithful to the oracle `m_help` *delivery* (plain `NOTICE`s, no terminating numeric, no
21/// parameter requirement); the *content* is leveva's own embedded pages rather than a dump
22/// of the `msgtab` command names (documented divergence).
23pub(crate) fn help(nick: &str, msg: &Message, ctx: &ServerContext) -> Vec<Message> {
24 let arg = msg
25 .params()
26 .first()
27 .map(String::as_str)
28 .or_else(|| msg.trailing())
29 .map(str::trim)
30 .filter(|s| !s.is_empty());
31
32 match arg {
33 None => render(ctx, nick, "leveva help", "index.md")
34 .expect("the embedded help/index.md page is always present"),
35 Some(topic) => sanitize(topic)
36 .map(|name| format!("{name}.md"))
37 .and_then(|file| render(ctx, nick, &format!("Help on {}", file_stem(&file)), &file))
38 .unwrap_or_else(|| not_found(ctx, nick, topic)),
39 }
40}
41
42/// Fold a requested topic to the on-disk filename stem: upper-case, and only if it is a
43/// non-empty, bounded run of ASCII alphanumerics or `_` (the latter for multi-word
44/// reference topics like `USER_MODES`). Anything else (spaces, dots, slashes, `..`)
45/// returns `None` — which makes path traversal and junk simply miss. `_` cannot form a
46/// path separator or `..`, so allowing it keeps traversal impossible.
47fn sanitize(topic: &str) -> Option<String> {
48 let up = topic.to_ascii_uppercase();
49 let ok =
50 (1..=32).contains(&up.len()) && up.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
51 ok.then_some(up)
52}
53
54fn file_stem(file: &str) -> &str {
55 file.strip_suffix(".md").unwrap_or(file)
56}
57
58/// Render an embedded help file as `NOTICE` lines, or `None` if the file is not embedded.
59fn render(ctx: &ServerContext, nick: &str, label: &str, file: &str) -> Option<Vec<Message>> {
60 let embedded = HelpFiles::get(file)?;
61 let text = String::from_utf8_lossy(&embedded.data);
62 let notice = |line: &str| {
63 Message::builder("NOTICE")
64 .prefix(&ctx.name)
65 .param(nick)
66 .trailing(line)
67 .build()
68 };
69 let mut out = vec![notice(&format!("*** {label} ***"))];
70 out.extend(text.lines().map(notice));
71 out.push(notice("*** End of help ***"));
72 Some(out)
73}
74
75fn not_found(ctx: &ServerContext, nick: &str, topic: &str) -> Vec<Message> {
76 vec![Message::builder("NOTICE")
77 .prefix(&ctx.name)
78 .param(nick)
79 .trailing(format!(
80 "No help available for '{topic}'. Try HELP with no argument for the index."
81 ))
82 .build()]
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use crate::command::testutil::*;
89
90 /// leveva's implemented command set — the canonical list the help pages and the
91 /// dispatch router must agree on. Test-only: it powers the two invariants below.
92 const COMMANDS: &[&str] = &[
93 "NICK", "SANICK", "PRIVMSG", "NOTICE", "TAGMSG", "JOIN", "SAJOIN", "OJOIN", "PART",
94 "SAPART", "MODE", "SAMODE", "TOPIC", "KICK", "INVITE", "KNOCK", "NAMES", "LIST", "WHO",
95 "WHOIS", "WHOWAS", "USERHOST", "USERIP", "ISON", "MONITOR", "ACCEPT", "METADATA",
96 "CHATHISTORY", "AWAY",
97 "PING",
98 "OPER",
99 "WALLOPS", "OPERWALL", "LOCOPS", "KILL", "CHGHOST", "SETNAME", "VERSION", "TIME", "ADMIN",
100 "INFO",
101 "LUSERS",
102 "USERS", "SUMMON", "MOTD", "HELP", "LINKS", "MAP", "STATS", "TRACE", "ETRACE", "TKLINE",
103 "UNTKLINE", "DLINE", "UNDLINE", "RESV", "UNRESV", "REHASH", "RESTART", "DIE", "SQUIT",
104 "CONNECT", "MKPASSWD",
105 ];
106
107 /// Reference topic pages that are *not* commands — multi-word mode catalogues the
108 /// command pages delegate to (`MODE` → `USER_MODES`/`CHANNEL_MODES`). Reachable via
109 /// `HELP <TOPIC>` like any page; allowlisted so the orphan-page check accepts them.
110 const TOPICS: &[&str] = &[
111 "USER_MODES",
112 "CHANNEL_MODES",
113 "CHANNEL_TYPES",
114 "REOP_LIST",
115 "EXTBANS",
116 "SNOMASK",
117 ];
118
119 fn trailings(ms: &[Message]) -> Vec<String> {
120 ms.iter()
121 .map(|m| m.trailing().unwrap_or_default().to_string())
122 .collect()
123 }
124
125 #[test]
126 fn bare_help_serves_the_index() {
127 let ctx = ctx();
128 let r = run(&ctx, "HELP");
129 assert!(r.iter().all(|m| m.command() == "NOTICE"));
130 assert!(r.iter().all(|m| m.params() == ["alice"]));
131 let lines = trailings(&r);
132 assert_eq!(lines.first().unwrap(), "*** leveva help ***");
133 assert_eq!(lines.last().unwrap(), "*** End of help ***");
134 assert!(lines.iter().any(|l| l.contains("HELP <command>")));
135 }
136
137 #[test]
138 fn help_command_serves_its_file() {
139 let ctx = ctx();
140 let lines = trailings(&run(&ctx, "HELP JOIN"));
141 assert_eq!(lines.first().unwrap(), "*** Help on JOIN ***");
142 assert_eq!(lines.last().unwrap(), "*** End of help ***");
143 assert!(lines.iter().any(|l| l.starts_with("JOIN <channel>")));
144 }
145
146 /// The lookup folds case: `HELP join` resolves the same `JOIN.md`.
147 #[test]
148 fn help_is_case_insensitive() {
149 let ctx = ctx();
150 assert_eq!(
151 trailings(&run(&ctx, "HELP join")),
152 trailings(&run(&ctx, "HELP JOIN"))
153 );
154 }
155
156 /// Inverse of "serve a page": an unknown command yields exactly one not-found NOTICE.
157 #[test]
158 fn unknown_command_is_not_found() {
159 let ctx = ctx();
160 let r = run(&ctx, "HELP FLOOBLE");
161 assert_eq!(r.len(), 1);
162 assert_eq!(r[0].command(), "NOTICE");
163 assert!(r[0]
164 .trailing()
165 .unwrap()
166 .contains("No help available for 'FLOOBLE'"));
167 }
168
169 /// A path-traversal / junk argument never escapes the embedded set — `sanitize`
170 /// rejects it (non-alphanumeric) so it simply misses to not-found, no panic, no read.
171 #[test]
172 fn path_traversal_argument_is_rejected() {
173 let ctx = ctx();
174 for evil in ["../JOIN", "../../etc/passwd", "a/b", "index", ".", ".."] {
175 let r = run(&ctx, &format!("HELP {evil}"));
176 assert_eq!(
177 r.len(),
178 1,
179 "{evil} should miss to a single not-found NOTICE"
180 );
181 assert!(r[0].trailing().unwrap().contains("No help available"));
182 }
183 }
184
185 /// Honesty: every command leveva claims to implement dispatches to a real handler
186 /// (never the `421 ERR_UNKNOWNCOMMAND` fallback).
187 #[test]
188 fn commands_are_all_implemented() {
189 let ctx = ctx();
190 for &cmd in COMMANDS {
191 let r = run(&ctx, cmd);
192 assert!(
193 r.first().map(|m| m.command()) != Some("421"),
194 "{cmd} falls through to 421"
195 );
196 }
197 }
198
199 /// No orphan help pages: every shipped `help/<X>.md` (bar `index.md`) names a real
200 /// implemented command or an allowlisted reference topic, so a page can't outlive what
201 /// it documents.
202 #[test]
203 fn shipped_help_files_name_real_commands() {
204 for path in HelpFiles::iter() {
205 if path.as_ref() == "index.md" {
206 continue;
207 }
208 let stem = file_stem(&path).to_string();
209 assert!(
210 COMMANDS.contains(&stem.as_str()) || TOPICS.contains(&stem.as_str()),
211 "help/{path} documents '{stem}', which is neither a command nor a topic"
212 );
213 }
214 }
215
216 /// The reference topic pages are reachable via `HELP <TOPIC>` (the `_` in their names
217 /// resolves), and each ships an embedded page.
218 #[test]
219 fn reference_topics_are_served() {
220 let ctx = ctx();
221 for &topic in TOPICS {
222 assert!(
223 HelpFiles::get(&format!("{topic}.md")).is_some(),
224 "topic '{topic}' has no help/{topic}.md page"
225 );
226 let lines = trailings(&run(&ctx, &format!("HELP {topic}")));
227 assert_eq!(lines.first().unwrap(), &format!("*** Help on {topic} ***"));
228 assert_eq!(lines.last().unwrap(), "*** End of help ***");
229 // It is a real page, not the not-found fallback.
230 assert!(lines.len() > 2, "topic '{topic}' served an empty/odd page");
231 }
232 }
233
234 /// The convention, enforced: every implemented command ships a help page. Adding a
235 /// command means adding its `help/<CMD>.md` — this test fails until you do.
236 #[test]
237 fn every_command_has_a_help_page() {
238 for &cmd in COMMANDS {
239 assert!(
240 HelpFiles::get(&format!("{cmd}.md")).is_some(),
241 "command '{cmd}' has no help/{cmd}.md page — add one"
242 );
243 }
244 }
245}