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.

feat(leveva): IRCv3 message-tags foundation (P11 slice 56)

Add tag support to the core Message type and advertise the `message-tags`
capability — the prerequisite that unblocks the deferred slice-55 bot tag plus
server-time/message-ids/message-redaction/oper-tag.

message.rs:
- Message/MessageBuilder carry ordered `tags: Vec<(String,String)>`; `parse`
peels a leading `@key=value;...` segment (length-checked against
MAX_TAG_DATA_LEN=8191 → ParseError::TagsTooLong, unescaped per the spec table),
`to_line`/`to_wire` render it (server tags before client `+` tags, by insertion
order); `tags()`/`tag(key)` accessors; builder `.tag(k,v)`.
- to_wire caps only the body at 512; the tag prefix is separate budget per spec.
- Escaping table both ways: `;`<->`\:`, ` `<->`\s`, `\`<->`\\`, CR<->`\r`,
LF<->`\n`; on unescape `\<other>` drops the backslash, lone trailing `\` drops.

cap.rs: advertise `message-tags` in SUPPORTED; `ClientCaps.message_tags`.

This is behavior-bearing on its own: the moment the cap is advertised, tag-capable
clients send `@tag PRIVMSG ...`, and the server now parses the `@tags` segment
correctly (it would otherwise be mistaken for the command). A client-only tag is
parsed and currently dropped; TAGMSG + client-only tag relay (per-recipient cap
gating + S2S) and the 417 reply are slice 57.

Tests (TDD, inverse invariants): message.rs units (tag/body split; untagged line
byte-identical; escaping round-trip; valueless/empty-value normalization;
render->parse->render stable; TagsTooLong; body-cap-with-tag-prefix; lone/unknown
escapes). cap.rs units updated for the new LS token. Fuzzing:
message_tags_proptest.rs (render/parse round-trip, escaping round-trip,
never-panic on arbitrary @input, untagged-line no-regression). Regenerated the 2
golden_cap snapshots.

cargo test -p leveva 814 lib + proptests green; clippy clean; build warning-free.

+482 -18
+81
docs/superpowers/plans/2026-06-10-p11-slice56-message-tags.md
··· 1 + # P11 slice 56 — message-tags foundation (IRCv3) 2 + 3 + Implements the foundation of the IRCv3 **message-tags** spec 4 + (`docs/rfcs/ircv3/message-tags.md`): tag rendering/parsing on the core `Message` 5 + type, the escaping table, the tag-data length limit, and advertising the 6 + `message-tags` capability. 7 + 8 + ## Why this slice / why split it 9 + 10 + The P11 survey identified `message-tags` as the highest-leverage next slice: the 11 + `Message` type has **no** tag support, which is the hard prerequisite blocking ≥5 12 + already-spec'd caps (the slice-55 bot tag, `server-time`, `message-ids`, 13 + `message-redaction`, `draft/oper-tag`). Per the [[leveva-ircv3-track]] framework-vs- 14 + behavior rule, this lands as two slices: 15 + 16 + - **56 (this):** the *machinery* — `Message` carries tags, `parse` accepts the 17 + `@tags` segment (with unescaping + the length limit), render emits tags, and the 18 + `message-tags` cap is advertised. This is **behavior-bearing on its own**: the 19 + moment the cap is advertised, tag-capable clients send `@tag PRIVMSG …`, and the 20 + server must parse the `@tags` segment correctly (otherwise `@tags` is mistaken for 21 + the command). After this slice a tagged inbound line dispatches correctly (the 22 + client-only tag is parsed and currently dropped, not relayed). 23 + - **57 (next):** the *delivery behavior* — the `TAGMSG` command, client-only (`+`) 24 + tag relay to `message-tags` subscribers (per-recipient cap gating in the delivery 25 + plane + across S2S), and `417 ERR_INPUTTOOLONG` on oversized inbound tag data. 26 + 27 + ## Locked decisions 28 + 29 + - **leveva-native, IRCv3 track** — no oracle has message-tags → no differential; the 30 + gate is unit + proptest + cap golden (the [[leveva-ircv3-track]] discipline). 31 + - **Insertion order preserved** on render (a `Vec<(String,String)>`, not a map): this 32 + round-trips cleanly and lets a future behavior add server tags before client tags by 33 + insertion order. Valueless (`key`) and empty-value (`key=`) both parse to 34 + `(key, "")` and render as `key` — a documented normalization. 35 + - **Escaping table** (spec § Escaping values), both directions: `;`↔`\:`, ` `↔`\s`, 36 + `\`↔`\\`, CR↔`\r`, LF↔`\n`; on unescape, `\<other>` drops the backslash and a lone 37 + trailing `\` is dropped. 38 + - **Length limit** `MAX_TAG_DATA_LEN = 8191` on the `<tags>` segment (between `@` and 39 + the space). `parse` returns `ParseError::TagsTooLong` past it; **wiring that to a 40 + `417` reply is deferred to slice 57** (today the session drops an unparseable line, 41 + which is safe). 42 + - **512 cap excludes tags.** `to_wire` caps only the message body at 43 + `MAX_CONTENT_LEN`; the tag prefix (bounded by 8191) is prepended uncapped, per spec. 44 + 45 + ## Design 46 + 47 + | File | Change | 48 + | ---- | ------ | 49 + | `message.rs` | `Message`/`MessageBuilder` gain `tags: Vec<(String,String)>`; `parse` peels a leading `@…` segment (length-checked, unescaped via `unescape_tag_value`); `write_into` split into tag + body writers so `to_line` includes tags and `to_wire` caps only the body; `tags()` accessor; builder `tag()`/`tags()`; `escape_tag_value`/`unescape_tag_value`/`parse_tags`/`MAX_TAG_DATA_LEN`; `ParseError::TagsTooLong`. | 50 + | `cap.rs` | `MESSAGE_TAGS = "message-tags"` const; add to `SUPPORTED` (after `multi-prefix`); `ClientCaps.message_tags` + `client_caps()`; update the LS/`supported_names` unit assertions. | 51 + | `tests/golden_cap.rs` snaps | regenerate (LS now lists `message-tags`). | 52 + | `help/` | no new command yet (TAGMSG is slice 57). | 53 + 54 + ## Tests (TDD — failing first, inverse invariants) 55 + 56 + - `message.rs` units: parse a tagged line (`@a=1;b;c=hi\sthere :nick CMD x :y`) → 57 + tags + prefix + command intact; **inverse**: a line with **no** `@` parses exactly 58 + as before (byte-identical `to_line`); render→parse round-trip preserves tags; 59 + escaping round-trip for every special char; `key=` normalizes to `key`; 60 + `TagsTooLong` past 8191; `to_wire` body still capped at 512 with a tag prefix 61 + present; valueless + valued mix renders `@k1;k2=v `. 62 + - `cap.rs` units: `message-tags` advertised in LS, negotiable, `client_caps()` flips; 63 + inverse `-message-tags` clears it; `supported_names` includes it. 64 + - `tests/message_tags_proptest.rs` (**fuzzing**): random tag maps (random keys from a 65 + tag-name alphabet, random arbitrary-byte values) → (1) render→parse→render is 66 + stable; (2) parse(render(tags)) == tags after normalization; (3) escaping 67 + round-trips any value; (4) **never panics** on arbitrary `@…` input; (5) a random 68 + non-`@` line parses identically with and without the tag code path (no regression). 69 + 70 + ## Divergences 71 + 72 + - TAGMSG, client-only tag relay, S2S tag forwarding, and the `417` reply are **slice 73 + 57** (documented above). After slice 56 a client-only tag on PRIVMSG is parsed and 74 + dropped (not relayed) — a safe degradation, not a protocol violation. 75 + - `key=` vs `key` normalization (both → `key`), as above. 76 + 77 + ## Gate 78 + 79 + `cargo test -p leveva` green + `cargo clippy -p leveva --tests` clean + 80 + `cargo build --workspace` 0 warnings. 81 + </content>
+18 -4
leveva/src/cap.rs
··· 28 28 /// rather than only the highest — the first behavior-bearing cap on the framework. 29 29 pub const MULTI_PREFIX: &str = "multi-prefix"; 30 30 31 + /// The IRCv3 [`message-tags`](https://ircv3.net/specs/extensions/message-tags) capability 32 + /// name. The `Message` type parses/renders the `@tags` segment unconditionally (slice 56); 33 + /// negotiating this cap marks a client as a tag *consumer* — the gate the delivery plane 34 + /// (TAGMSG + client-only tag relay, slice 57) will read before forwarding tags to it. 35 + pub const MESSAGE_TAGS: &str = "message-tags"; 36 + 31 37 /// The CAP LS version at which capability **values**, **multiline** `LS`/`LIST`, and 32 38 /// implicit `cap-notify` unlock. 33 39 const CAP_302: u32 = 302; ··· 51 57 name: MULTI_PREFIX, 52 58 value: None, 53 59 }, 60 + Capability { 61 + name: MESSAGE_TAGS, 62 + value: None, 63 + }, 54 64 ]; 55 65 56 66 /// A per-connection snapshot of the **behavior-bearing** capabilities a registered client ··· 63 73 /// IRCv3 [`multi-prefix`](MULTI_PREFIX): render all of a member's status sigils (`@+`) 64 74 /// rather than only the highest, in NAMES/WHO/WHOIS. 65 75 pub multi_prefix: bool, 76 + /// IRCv3 [`message-tags`](MESSAGE_TAGS): this client consumes message tags — the gate 77 + /// the delivery plane reads before forwarding client-only (`+`) tags / TAGMSG to it. 78 + pub message_tags: bool, 66 79 } 67 80 68 81 /// The names leveva advertises (for callers — e.g. property tests — that need to model ··· 145 158 pub fn client_caps(&self) -> ClientCaps { 146 159 ClientCaps { 147 160 multi_prefix: self.enabled.contains(MULTI_PREFIX), 161 + message_tags: self.enabled.contains(MESSAGE_TAGS), 148 162 } 149 163 } 150 164 ··· 393 407 fn ls_without_version_lists_caps_bare() { 394 408 let mut c = CapState::new(); 395 409 let out = wire(&unreg(&mut c, "CAP LS")); 396 - assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix\r\n"); 410 + assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags\r\n"); 397 411 // No version stored, no implicit cap-notify in the enabled set. 398 412 assert_eq!(c.version(), None); 399 413 assert!(!c.is_enabled(CAP_NOTIFY)); ··· 404 418 fn ls_302_stores_version_and_implicitly_enables_cap_notify() { 405 419 let mut c = CapState::new(); 406 420 let out = wire(&unreg(&mut c, "CAP LS 302")); 407 - assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix\r\n"); 421 + assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags\r\n"); 408 422 assert_eq!(c.version(), Some(302)); 409 423 assert!(c.is_enabled(CAP_NOTIFY), "302 implies cap-notify"); 410 424 assert!(c.eligible_for_notify()); ··· 443 457 let mut c = CapState::new(); 444 458 // Advertised in LS (after cap-notify, declaration order). 445 459 let out = wire(&unreg(&mut c, "CAP LS")); 446 - assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix\r\n"); 460 + assert_eq!(out, ":leveva.test CAP * LS :cap-notify multi-prefix message-tags\r\n"); 447 461 // REQ acks it and the snapshot flips on; the inverse (-) flips it back off. 448 462 assert!(!c.client_caps().multi_prefix, "off until negotiated"); 449 463 let ack = wire(&unreg(&mut c, "CAP REQ :multi-prefix")); ··· 600 614 601 615 #[test] 602 616 fn supported_names_lists_advertised_caps() { 603 - assert_eq!(supported_names(), vec![CAP_NOTIFY, MULTI_PREFIX]); 617 + assert_eq!(supported_names(), vec![CAP_NOTIFY, MULTI_PREFIX, MESSAGE_TAGS]); 604 618 } 605 619 }
+266 -12
leveva/src/message.rs
··· 24 24 use core::fmt::Display; 25 25 use core::str::FromStr; 26 26 27 - /// The maximum length of an IRC message, CRLF included (RFC 1459 §2.3). 27 + /// The maximum length of an IRC message, CRLF included (RFC 1459 §2.3). This bound 28 + /// applies to the message **body** — the IRCv3 [tag](Message::tags) prefix is extra 29 + /// budget (up to [`MAX_TAG_DATA_LEN`]) and is not counted against it. 28 30 pub const MAX_MESSAGE_LEN: usize = 512; 29 31 /// The maximum length of message content, before the trailing CRLF. 30 32 pub const MAX_CONTENT_LEN: usize = MAX_MESSAGE_LEN - 2; 31 33 32 - /// A finalized IRC message: optional prefix, a command, middle params, and an 33 - /// optional trailing param. 34 + /// The maximum length of the IRCv3 message-tags segment (the bytes between the leading 35 + /// `@` and the space that ends the tag list), per the 36 + /// [message-tags spec](../../docs/rfcs/ircv3/message-tags.md) § Size limit. 37 + pub const MAX_TAG_DATA_LEN: usize = 8191; 38 + 39 + /// A finalized IRC message: optional IRCv3 tags, an optional prefix, a command, middle 40 + /// params, and an optional trailing param. 34 41 #[derive(Debug, Clone, PartialEq, Eq)] 35 42 pub struct Message { 43 + /// IRCv3 message tags in insertion order: `(key, value)`, where `value` is `""` 44 + /// for a valueless tag. Keys may carry the client-only `+` prefix and/or a vendor 45 + /// prefix; only values are escaped on the wire. 46 + tags: Vec<(String, String)>, 36 47 prefix: Option<String>, 37 48 command: String, 38 49 params: Vec<String>, ··· 66 77 pub fn parse(input: &str) -> Result<Message, ParseError> { 67 78 let mut rest = input.trim_end_matches(['\r', '\n']); 68 79 80 + // IRCv3 message tags: an optional leading `@key=value;...` segment ending at the 81 + // first space. Only the body after it is subject to the 512-byte limit; the tag 82 + // data has its own (larger) bound. 83 + let mut tags = Vec::new(); 84 + if let Some(after_at) = rest.strip_prefix('@') { 85 + let (tag_seg, tail) = split_first_space(after_at); 86 + if tag_seg.len() > MAX_TAG_DATA_LEN { 87 + return Err(ParseError::TagsTooLong); 88 + } 89 + tags = parse_tags(tag_seg); 90 + rest = tail.trim_start_matches(' '); 91 + if rest.is_empty() { 92 + return Err(ParseError::MissingCommand); 93 + } 94 + } 95 + 69 96 let prefix = if let Some(after) = rest.strip_prefix(':') { 70 97 let (pfx, tail) = split_first_space(after); 71 98 rest = tail; ··· 103 130 } 104 131 105 132 Ok(Message { 133 + tags, 106 134 prefix, 107 135 command: command.to_string(), 108 136 params, ··· 110 138 }) 111 139 } 112 140 141 + /// The IRCv3 message tags in insertion order: `(key, value)` with `value == ""` 142 + /// for a valueless tag. 143 + #[inline] 144 + pub fn tags(&self) -> &[(String, String)] { 145 + &self.tags 146 + } 147 + 148 + /// The value of tag `key`, if present (`Some("")` for a valueless tag). 149 + pub fn tag(&self, key: &str) -> Option<&str> { 150 + self.tags 151 + .iter() 152 + .find(|(k, _)| k == key) 153 + .map(|(_, v)| v.as_str()) 154 + } 155 + 113 156 /// The message prefix (source), if any, without the leading `:`. 114 157 #[inline] 115 158 pub fn prefix(&self) -> Option<&str> { ··· 134 177 self.trailing.as_deref() 135 178 } 136 179 137 - fn write_into(&self, out: &mut String) { 180 + /// Write the IRCv3 tag prefix (`@k1=v1;k2 `, trailing space included) if any tags 181 + /// are present. Values are escaped; keys are written verbatim. 182 + fn write_tags_into(&self, out: &mut String) { 183 + if self.tags.is_empty() { 184 + return; 185 + } 186 + out.push('@'); 187 + for (i, (k, v)) in self.tags.iter().enumerate() { 188 + if i > 0 { 189 + out.push(';'); 190 + } 191 + out.push_str(k); 192 + if !v.is_empty() { 193 + out.push('='); 194 + escape_tag_value_into(v, out); 195 + } 196 + } 197 + out.push(' '); 198 + } 199 + 200 + /// Write the message body (prefix/command/params/trailing) — everything except the 201 + /// tag prefix. 202 + fn write_body_into(&self, out: &mut String) { 138 203 if let Some(p) = &self.prefix { 139 204 out.push(':'); 140 205 out.push_str(p); ··· 151 216 } 152 217 } 153 218 219 + fn write_into(&self, out: &mut String) { 220 + self.write_tags_into(out); 221 + self.write_body_into(out); 222 + } 223 + 154 224 /// The message as a single line, **without** the terminating CRLF and without 155 225 /// length capping. Useful for inspection and tests. 156 226 pub fn to_line(&self) -> String { ··· 159 229 s 160 230 } 161 231 162 - /// The message as wire bytes: the line truncated to fit `MAX_MESSAGE_LEN` 163 - /// (on a UTF-8 char boundary) and terminated with CRLF. 232 + /// The message as wire bytes: the **body** truncated to fit `MAX_MESSAGE_LEN` 233 + /// (on a UTF-8 char boundary), prefixed with the (uncapped) IRCv3 tag segment, and 234 + /// terminated with CRLF. The 512-byte limit applies only to the body — tags are 235 + /// separate budget (≤ [`MAX_TAG_DATA_LEN`]), per the message-tags spec. 164 236 pub fn to_wire(&self) -> Vec<u8> { 165 - let line = self.to_line(); 166 - let mut end = line.len().min(MAX_CONTENT_LEN); 167 - while end > 0 && !line.is_char_boundary(end) { 237 + let mut tag_prefix = String::new(); 238 + self.write_tags_into(&mut tag_prefix); 239 + 240 + let mut body = String::new(); 241 + self.write_body_into(&mut body); 242 + let mut end = body.len().min(MAX_CONTENT_LEN); 243 + while end > 0 && !body.is_char_boundary(end) { 168 244 end -= 1; 169 245 } 170 - let mut bytes = Vec::with_capacity(end + 2); 171 - bytes.extend_from_slice(&line.as_bytes()[..end]); 246 + 247 + let mut bytes = Vec::with_capacity(tag_prefix.len() + end + 2); 248 + bytes.extend_from_slice(tag_prefix.as_bytes()); 249 + bytes.extend_from_slice(&body.as_bytes()[..end]); 172 250 bytes.extend_from_slice(b"\r\n"); 173 251 bytes 174 252 } 175 253 } 176 254 255 + /// Parse the `<tags>` segment (the bytes between `@` and the space) into ordered 256 + /// `(key, value)` pairs. Empty tokens (a stray `;`) are skipped; a `key` with no `=` 257 + /// (or a `key=` with an empty value) yields an empty value. 258 + fn parse_tags(seg: &str) -> Vec<(String, String)> { 259 + seg.split(';') 260 + .filter(|t| !t.is_empty()) 261 + .map(|t| match t.split_once('=') { 262 + Some((k, v)) => (k.to_string(), unescape_tag_value(v)), 263 + None => (t.to_string(), String::new()), 264 + }) 265 + .collect() 266 + } 267 + 268 + /// IRCv3 tag-value unescaping (spec § Escaping values): `\:`→`;`, `\s`→space, `\\`→`\`, 269 + /// `\r`→CR, `\n`→LF; `\<other>` drops the backslash; a lone trailing `\` is dropped. 270 + fn unescape_tag_value(v: &str) -> String { 271 + let mut out = String::with_capacity(v.len()); 272 + let mut chars = v.chars(); 273 + while let Some(c) = chars.next() { 274 + if c != '\\' { 275 + out.push(c); 276 + continue; 277 + } 278 + match chars.next() { 279 + Some(':') => out.push(';'), 280 + Some('s') => out.push(' '), 281 + Some('\\') => out.push('\\'), 282 + Some('r') => out.push('\r'), 283 + Some('n') => out.push('\n'), 284 + Some(other) => out.push(other), 285 + None => {} // lone trailing backslash: dropped 286 + } 287 + } 288 + out 289 + } 290 + 291 + /// IRCv3 tag-value escaping (the inverse of [`unescape_tag_value`]). 292 + fn escape_tag_value_into(v: &str, out: &mut String) { 293 + for c in v.chars() { 294 + match c { 295 + ';' => out.push_str("\\:"), 296 + ' ' => out.push_str("\\s"), 297 + '\\' => out.push_str("\\\\"), 298 + '\r' => out.push_str("\\r"), 299 + '\n' => out.push_str("\\n"), 300 + _ => out.push(c), 301 + } 302 + } 303 + } 304 + 177 305 /// Split `s` at its first space into `(before, after)`; the space is consumed and 178 306 /// `after` is `""` when there is none. 179 307 fn split_first_space(s: &str) -> (&str, &str) { ··· 188 316 pub enum ParseError { 189 317 /// The line was empty (or only whitespace / a bare CRLF). 190 318 Empty, 191 - /// A prefix was present but no command followed it. 319 + /// A prefix (or a tag segment) was present but no command followed it. 192 320 MissingCommand, 321 + /// The IRCv3 tag segment exceeded [`MAX_TAG_DATA_LEN`]. 322 + TagsTooLong, 193 323 } 194 324 195 325 impl core::fmt::Display for ParseError { ··· 197 327 match self { 198 328 ParseError::Empty => write!(f, "empty message"), 199 329 ParseError::MissingCommand => write!(f, "prefix with no command"), 330 + ParseError::TagsTooLong => write!(f, "message tags exceed the size limit"), 200 331 } 201 332 } 202 333 } ··· 214 345 /// Builder for a [`Message`]. See the [module docs](self). 215 346 #[derive(Debug, Clone)] 216 347 pub struct MessageBuilder { 348 + tags: Vec<(String, String)>, 217 349 prefix: Option<String>, 218 350 command: String, 219 351 params: Vec<String>, ··· 225 357 #[inline] 226 358 pub fn new(command: impl Display) -> Self { 227 359 MessageBuilder { 360 + tags: Vec::new(), 228 361 prefix: None, 229 362 command: command.to_string(), 230 363 params: Vec::new(), ··· 232 365 } 233 366 } 234 367 368 + /// Attach one IRCv3 message tag, in declaration order. Pass `""` for `value` to 369 + /// emit a valueless tag (`@key`). The value is escaped on render; the key is 370 + /// written verbatim (include any `+` / vendor prefix in `key`). 371 + #[inline] 372 + pub fn tag(mut self, key: impl Display, value: impl Display) -> Self { 373 + self.tags.push((key.to_string(), value.to_string())); 374 + self 375 + } 376 + 235 377 /// Set the message prefix (source) — written with a leading `:`. Accepts any 236 378 /// `Display` value, so typed identifiers (`Nick`, `ServerName`, …) pass straight 237 379 /// through. ··· 273 415 #[inline] 274 416 pub fn build(self) -> Message { 275 417 Message { 418 + tags: self.tags, 276 419 prefix: self.prefix, 277 420 command: self.command, 278 421 params: self.params, ··· 447 590 let m = Message::parse(line).unwrap(); 448 591 assert_eq!(m.to_line(), line, "round-trip mismatch for {line:?}"); 449 592 } 593 + } 594 + 595 + // ---- IRCv3 message tags (slice 56) ---- 596 + 597 + #[test] 598 + fn parse_tagged_line_splits_tags_from_body() { 599 + let m = Message::parse("@id=42;bot;time=now :nick!u@h PRIVMSG #c :hi").unwrap(); 600 + assert_eq!( 601 + m.tags(), 602 + &[ 603 + ("id".to_string(), "42".to_string()), 604 + ("bot".to_string(), String::new()), 605 + ("time".to_string(), "now".to_string()), 606 + ] 607 + ); 608 + // The body parses exactly as if the tags were absent. 609 + assert_eq!(m.prefix(), Some("nick!u@h")); 610 + assert_eq!(m.command(), "PRIVMSG"); 611 + assert_eq!(m.params(), ["#c"]); 612 + assert_eq!(m.trailing(), Some("hi")); 613 + } 614 + 615 + #[test] 616 + fn untagged_line_parses_byte_identically() { 617 + // Inverse: no `@` → no tags, and the body parse is unchanged from before tags. 618 + let line = ":nick!user@host PRIVMSG #chan :hello world"; 619 + let m = Message::parse(line).unwrap(); 620 + assert!(m.tags().is_empty()); 621 + assert_eq!(m.to_line(), line); 622 + // A literal `@` only counts as a tag prefix at the very start. 623 + let m2 = Message::parse("PRIVMSG #c :@notatag here").unwrap(); 624 + assert!(m2.tags().is_empty()); 625 + assert_eq!(m2.trailing(), Some("@notatag here")); 626 + } 627 + 628 + #[test] 629 + fn tag_value_escaping_round_trips() { 630 + // Each special char escapes/unescapes per the spec table. 631 + let value = "a;b c\\d\re\nf"; // ; space backslash CR LF 632 + let line = Message::builder("TAGMSG") 633 + .tag("+draft/x", value) 634 + .param("#c") 635 + .to_line(); 636 + assert_eq!(line, "@+draft/x=a\\:b\\sc\\\\d\\re\\nf TAGMSG #c"); 637 + // Parsing it back recovers the exact original value. 638 + let m = Message::parse(&line).unwrap(); 639 + assert_eq!(m.tag("+draft/x"), Some(value)); 640 + } 641 + 642 + #[test] 643 + fn valueless_and_empty_value_both_render_bare() { 644 + // `key` and `key=` both mean "no value" → parse to "" → render as bare `key`. 645 + let m = Message::parse("@a;b= CMD").unwrap(); 646 + assert_eq!(m.tag("a"), Some("")); 647 + assert_eq!(m.tag("b"), Some("")); 648 + let m2 = Message::builder("CMD").tag("a", "").tag("b", "v").build(); 649 + assert_eq!(m2.to_line(), "@a;b=v CMD"); 650 + } 651 + 652 + #[test] 653 + fn render_parse_render_is_stable() { 654 + let m = Message::builder("PRIVMSG") 655 + .tag("time", "2026-06-10T00:00:00.000Z") 656 + .tag("+typing", "active") 657 + .prefix("nick!u@h") 658 + .param("#c") 659 + .trailing("hello there") 660 + .build(); 661 + let line = m.to_line(); 662 + let reparsed = Message::parse(&line).unwrap(); 663 + assert_eq!(reparsed.to_line(), line, "render→parse→render must be stable"); 664 + assert_eq!(reparsed.tags(), m.tags()); 665 + } 666 + 667 + #[test] 668 + fn tags_too_long_is_rejected() { 669 + let huge = format!("@x={} CMD", "v".repeat(MAX_TAG_DATA_LEN)); 670 + assert_eq!(Message::parse(&huge), Err(ParseError::TagsTooLong)); 671 + // Exactly at the limit parses. 672 + let at_limit = format!("@{} CMD", "x".repeat(MAX_TAG_DATA_LEN)); 673 + assert!(Message::parse(&at_limit).is_ok()); 674 + } 675 + 676 + #[test] 677 + fn tags_with_no_command_is_missing_command() { 678 + assert_eq!(Message::parse("@only=tags"), Err(ParseError::MissingCommand)); 679 + assert_eq!(Message::parse("@only=tags "), Err(ParseError::MissingCommand)); 680 + } 681 + 682 + #[test] 683 + fn wire_caps_body_at_512_but_keeps_full_tag_prefix() { 684 + // The 512 limit is on the body only; a present tag prefix is extra budget. 685 + let huge = "x".repeat(1000); 686 + let wire = Message::builder("PRIVMSG") 687 + .tag("time", "2026-06-10T00:00:00.000Z") 688 + .param("#c") 689 + .trailing(huge) 690 + .to_wire(); 691 + let s = String::from_utf8(wire).unwrap(); 692 + let (tagseg, body) = s.split_once(' ').unwrap(); 693 + assert_eq!(tagseg, "@time=2026-06-10T00:00:00.000Z"); 694 + // Body (with CRLF) is capped at 512; the tag prefix is *not* counted in it. 695 + assert_eq!(body.len(), MAX_MESSAGE_LEN); 696 + assert!(body.ends_with("\r\n")); 697 + } 698 + 699 + #[test] 700 + fn lone_trailing_backslash_and_unknown_escape_are_dropped() { 701 + // `\<other>` keeps the char without the backslash; a lone trailing `\` vanishes. 702 + let m = Message::parse("@k=a\\qb\\ CMD").unwrap(); 703 + assert_eq!(m.tag("k"), Some("aqb")); 450 704 } 451 705 }
+115
leveva/tests/message_tags_proptest.rs
··· 1 + //! Property-based fuzzing of the IRCv3 message-tags machinery (slice 56) on the pure 2 + //! [`Message`] type. Invariants: 3 + //! 4 + //! - **render→parse→render is stable** for any built tag set (the wire form is a fixed 5 + //! point); 6 + //! - **parse(render(tags)) == tags** after the documented normalization (valueless and 7 + //! empty-value both become a bare key); 8 + //! - **escaping round-trips** any value byte-for-byte through `tag(k,v)` → wire → 9 + //! `tag(k)`; 10 + //! - **never panics** on arbitrary `@…` input, and always emits a single CRLF-terminated 11 + //! line whose body (sans the tag prefix) respects the 512 cap; 12 + //! - **no regression**: a random line that does *not* start with `@` parses identically 13 + //! to how it always did (the tag code path is inert without a leading `@`). 14 + //! 15 + //! Runs on stable Rust inside `cargo test`; shrinking minimizes any counterexample. 16 + 17 + use leveva::message::{Message, MAX_MESSAGE_LEN}; 18 + use proptest::prelude::*; 19 + 20 + /// A tag-key alphabet: letters/digits plus the vendor (`/`,`.`,`-`) and client-only (`+`) 21 + /// prefix characters that keys legitimately carry. Kept non-empty and `;`/`=`/space-free 22 + /// so it is always a syntactically valid key. 23 + fn tag_key() -> impl Strategy<Value = String> { 24 + "[+]?[a-zA-Z][a-zA-Z0-9./-]{0,15}".prop_filter("non-empty", |s| !s.is_empty()) 25 + } 26 + 27 + /// A tag value: arbitrary printable-ish text including the five characters that must be 28 + /// escaped (`;`, space, `\`, CR, LF), so the escaping table is actually exercised. 29 + fn tag_value() -> impl Strategy<Value = String> { 30 + proptest::collection::vec( 31 + prop_oneof![ 32 + Just(';'), 33 + Just(' '), 34 + Just('\\'), 35 + Just('\r'), 36 + Just('\n'), 37 + any::<char>().prop_filter("no NUL", |c| *c != '\0'), 38 + ], 39 + 0..24, 40 + ) 41 + .prop_map(|cs| cs.into_iter().collect()) 42 + } 43 + 44 + proptest! { 45 + /// Build a message with random tags, render it, and re-parse: the wire form is stable 46 + /// and the parsed tags match the originals after normalization (empty value → bare key). 47 + #[test] 48 + fn render_parse_round_trips( 49 + tags in proptest::collection::vec((tag_key(), tag_value()), 0..8), 50 + cmd in "[A-Z]{1,8}", 51 + ) { 52 + // Build, deduping keys (a map semantics — last write wins is not what we assert; 53 + // we just need unique keys so parse's by-key lookup is unambiguous). 54 + let mut seen = std::collections::HashSet::new(); 55 + let mut b = Message::builder(&cmd); 56 + let mut expected: Vec<(String, String)> = Vec::new(); 57 + for (k, v) in tags { 58 + if seen.insert(k.clone()) { 59 + b = b.tag(&k, &v); 60 + expected.push((k, v)); 61 + } 62 + } 63 + let msg = b.param("#c").build(); 64 + let line = msg.to_line(); 65 + 66 + let reparsed = Message::parse(&line).expect("rendered line must parse"); 67 + // The wire form is a fixed point. 68 + prop_assert_eq!(reparsed.to_line(), line); 69 + 70 + // Each original tag survives round-trip, with the empty-value normalization. 71 + for (k, v) in &expected { 72 + prop_assert_eq!(reparsed.tag(k), Some(v.as_str()), "tag {} lost/altered", k); 73 + } 74 + prop_assert_eq!(reparsed.tags().len(), expected.len()); 75 + } 76 + 77 + /// Escaping round-trips any value byte-for-byte. 78 + #[test] 79 + fn escaping_round_trips_any_value(v in tag_value()) { 80 + let line = Message::builder("X").tag("k", &v).build().to_line(); 81 + let m = Message::parse(&line).unwrap(); 82 + prop_assert_eq!(m.tag("k"), Some(v.as_str())); 83 + } 84 + 85 + /// Arbitrary `@…` input never panics and yields a well-formed single line. 86 + #[test] 87 + fn arbitrary_tagged_input_never_panics(rest in ".{0,300}") { 88 + let line = format!("@{rest}"); 89 + if let Ok(m) = Message::parse(&line) { 90 + let wire = m.to_wire(); 91 + // Exactly one terminating CRLF, and the body (after the tag prefix) is capped. 92 + prop_assert!(wire.ends_with(b"\r\n")); 93 + let s = String::from_utf8_lossy(&wire); 94 + let body = match s.split_once(' ') { 95 + Some((tagseg, body)) if tagseg.starts_with('@') => body, 96 + _ => &s, 97 + }; 98 + prop_assert!(body.len() <= MAX_MESSAGE_LEN, "body over the 512 cap: {}", body.len()); 99 + } 100 + } 101 + 102 + /// No regression: a line that does not start with `@` carries no tags and round-trips 103 + /// its body exactly (the tag code path is inert). 104 + #[test] 105 + fn untagged_lines_are_unaffected( 106 + cmd in "[A-Z]{1,8}", 107 + param in "[a-zA-Z0-9#]{1,16}", 108 + text in "[a-zA-Z0-9 ]{0,40}", 109 + ) { 110 + let line = format!("{cmd} {param} :{text}"); 111 + let m = Message::parse(&line).unwrap(); 112 + prop_assert!(m.tags().is_empty()); 113 + prop_assert_eq!(m.to_line(), line); 114 + } 115 + }
+1 -1
leveva/tests/snapshots/golden_cap__cap_302_negotiation_suspends_then_resumes.snap
··· 2 2 source: leveva/tests/golden_cap.rs 3 3 expression: canonicalize(&transcript) 4 4 --- 5 - :leveva.test CAP * LS :cap-notify multi-prefix 5 + :leveva.test CAP * LS :cap-notify multi-prefix message-tags 6 6 :leveva.test CAP * ACK :cap-notify 7 7 :leveva.test 001 dan :Welcome to the Internet Relay Network dan!d@127.0.0.1 8 8 :leveva.test 002 dan :Your host is leveva.test, running version leveva-0.0.0
+1 -1
leveva/tests/snapshots/golden_cap__cap_ls_without_version_then_end.snap
··· 2 2 source: leveva/tests/golden_cap.rs 3 3 expression: "canonicalize(&format!(\"{ls}{welcome}\"))" 4 4 --- 5 - :leveva.test CAP * LS :cap-notify multi-prefix 5 + :leveva.test CAP * LS :cap-notify multi-prefix message-tags 6 6 :leveva.test 001 jo :Welcome to the Internet Relay Network jo!j@127.0.0.1 7 7 :leveva.test 002 jo :Your host is leveva.test, running version leveva-0.0.0 8 8 :leveva.test 003 jo :This server was created <MASKED>