Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

ircd.rs / leveva / src / command / help.rs
9.6 kB 245 lines
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}