Skip to main content

netget/server/dict/
wire.rs

1//! DICT (RFC 2229) wire format: command parsing and response rendering.
2//!
3//! Everything here is a pure function of its arguments, so the session loop, the action
4//! executor and the tests all share one definition of what a DICT line looks like. **The model
5//! never writes a byte of framing**: it supplies words, database names and free text, and this
6//! module decides the status lines, the quoting, the dot-stuffing and the terminator. A model
7//! that writes a definition whose line happens to be `.` produces a line reading `..` on the
8//! wire, and the client's un-stuffing turns it back into `.` — not a premature end of the text.
9
10use crate::utils::sanitize;
11
12/// The longest command line a peer may send, **including** the trailing CRLF.
13///
14/// RFC 2229 §2.2: "Command lines MUST NOT exceed 1024 characters in length, counting all
15/// characters including spaces, separators, punctuation, and the trailing CRLF." A line one
16/// byte longer is refused with `500` before it is parsed, and before any model is consulted.
17pub const MAX_LINE_BYTES: usize = 1024;
18
19/// The longest text line this server writes, before dot-stuffing and CRLF.
20///
21/// RFC 2229 §2.4.1 holds *text* lines to the same 1024 characters "including … the extra
22/// initial period (if needed), and the trailing CRLF", so the hard ceiling is 1021 bytes of
23/// content. Wrapping at 1000 leaves room for both with a margin, and a model that writes a
24/// 5 KB paragraph as one line gets it split across several lines rather than a line a strict
25/// client is entitled to reject.
26pub const MAX_TEXT_LINE_CONTENT: usize = 1000;
27
28/// Status codes whose response is followed by a dot-terminated text block.
29///
30/// `150` is deliberately absent: it announces the count and is followed by `151` responses,
31/// not by text of its own.
32pub const TEXT_FOLLOWS_CODES: &[u16] = &[110, 111, 112, 113, 114, 151, 152];
33
34/// The 5xx codes `send_dict_error` may send, each with RFC 2229's own text as its default.
35pub const ERROR_CODES: &[(u16, &str)] = &[
36    (500, "Syntax error, command not recognized"),
37    (501, "Syntax error, illegal parameters"),
38    (502, "Command not implemented"),
39    (503, "Command parameter not implemented"),
40    (530, "Access denied"),
41    (
42        531,
43        "Access denied, use \"SHOW INFO\" for server information",
44    ),
45    (532, "Access denied, unknown mechanism"),
46    (
47        550,
48        "Invalid database, use \"SHOW DB\" for list of databases",
49    ),
50    (
51        551,
52        "Invalid strategy, use \"SHOW STRAT\" for a list of strategies",
53    ),
54    (552, "No match"),
55    (554, "No databases present"),
56    (555, "No strategies available"),
57];
58
59/// The three informational text responses `send_dict_text` renders, with their status text.
60pub const TEXT_CODES: &[(u16, &str)] = &[
61    (112, "database information follows"),
62    (113, "help text follows"),
63    (114, "server information follows"),
64];
65
66/// The header a MIME-mode text block is prefaced with (RFC 2229 §3.10.1, OPTION MIME).
67///
68/// RFC 2045's default is `text/plain; charset=us-ascii`, which is false for a definition with
69/// an accent in it, so the charset is stated rather than left to the default.
70pub const MIME_HEADER: &str =
71    "Content-type: text/plain; charset=utf-8\r\nContent-transfer-encoding: 8bit\r\n\r\n";
72
73/// Why a command line could not be split into parameters.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ArgError {
76    /// A `"` or `'` was opened and never closed.
77    UnterminatedQuote,
78    /// The line ended in a lone backslash.
79    DanglingEscape,
80}
81
82/// Split a command line into parameters per RFC 2229 §2.2.
83///
84/// Parameters are separated by spaces or tabs. A parameter may be an atom, a double-quoted
85/// string, a single-quoted string, or any concatenation of those; a backslash quotes the next
86/// character wherever it appears. The real `dict(1)` client quotes every word it sends
87/// (`define * "hello"`), so quoting is the common case rather than an edge.
88pub fn split_args(line: &str) -> Result<Vec<String>, ArgError> {
89    let mut args = Vec::new();
90    let mut current = String::new();
91    let mut in_token = false;
92    let mut quote: Option<char> = None;
93    let mut chars = line.chars();
94
95    while let Some(c) = chars.next() {
96        match (quote, c) {
97            (_, '\\') => {
98                let next = chars.next().ok_or(ArgError::DanglingEscape)?;
99                current.push(next);
100                in_token = true;
101            }
102            (Some(q), c) if c == q => quote = None,
103            (Some(_), c) => current.push(c),
104            (None, '"') | (None, '\'') => {
105                quote = Some(c);
106                in_token = true;
107            }
108            (None, ' ') | (None, '\t') => {
109                if in_token {
110                    args.push(std::mem::take(&mut current));
111                    in_token = false;
112                }
113            }
114            (None, c) => {
115                current.push(c);
116                in_token = true;
117            }
118        }
119    }
120    if quote.is_some() {
121        return Err(ArgError::UnterminatedQuote);
122    }
123    if in_token {
124        args.push(current);
125    }
126    Ok(args)
127}
128
129/// Render a string as a DICT double-quoted string: control characters become spaces (a CR or
130/// LF would end the status line and forge the next one), and `\` and `"` are escaped.
131pub fn quoted(s: &str) -> String {
132    let clean = sanitize::line_field(s);
133    let mut out = String::with_capacity(clean.len() + 2);
134    out.push('"');
135    for c in clean.chars() {
136        if c == '"' || c == '\\' {
137            out.push('\\');
138        }
139        out.push(c);
140    }
141    out.push('"');
142    out
143}
144
145/// Render a database or strategy name: bare when it is a plain atom (which every real name
146/// is — `wn`, `gcide`, `prefix`), quoted otherwise so a name with a space in it cannot split
147/// one listing line into two columns.
148pub fn atom(s: &str) -> String {
149    let plain = !s.is_empty()
150        && sanitize::strip_controls(s) == s
151        && s.chars()
152            .all(|c| !c.is_whitespace() && !matches!(c, '"' | '\'' | '\\'));
153    if plain {
154        s.to_string()
155    } else {
156        quoted(s)
157    }
158}
159
160/// Split one line into pieces of at most [`MAX_TEXT_LINE_CONTENT`] bytes, on char boundaries.
161fn wrap_line(line: &str) -> Vec<&str> {
162    let mut pieces = Vec::new();
163    let mut rest = line;
164    while rest.len() > MAX_TEXT_LINE_CONTENT {
165        let mut cut = MAX_TEXT_LINE_CONTENT;
166        while !rest.is_char_boundary(cut) {
167            cut -= 1;
168        }
169        pieces.push(&rest[..cut]);
170        rest = &rest[cut..];
171    }
172    pieces.push(rest);
173    pieces
174}
175
176/// The lines of a model-supplied text body, ready for a text block: line endings normalised,
177/// control characters other than tab removed, long lines wrapped, trailing blank lines
178/// dropped. Not yet dot-stuffed — [`text_block`] does that, once, for every line it writes.
179pub fn text_lines(text: &str) -> Vec<String> {
180    let normalised = text.replace("\r\n", "\n").replace('\r', "\n");
181    let clean = sanitize::multiline(&normalised);
182    let trimmed = clean.trim_end_matches('\n');
183    if trimmed.is_empty() {
184        return Vec::new();
185    }
186    trimmed
187        .split('\n')
188        .flat_map(|line| wrap_line(line).into_iter().map(str::to_string))
189        .collect()
190}
191
192/// Dot-stuff one text line (RFC 2229 §2.4.1): a line that begins with `.` gets a second one,
193/// so the only line that reads as `.` alone is the terminator this module writes.
194pub fn stuff(line: &str) -> String {
195    if line.starts_with('.') {
196        format!(".{line}")
197    } else {
198        line.to_string()
199    }
200}
201
202/// A status line followed by a dot-terminated text block.
203pub fn text_block(status_line: &str, lines: &[String]) -> String {
204    let mut out = String::new();
205    out.push_str(status_line);
206    out.push_str("\r\n");
207    for line in lines {
208        out.push_str(&stuff(line));
209        out.push_str("\r\n");
210    }
211    out.push_str(".\r\n");
212    out
213}
214
215/// One definition, as the model supplies it.
216#[derive(Debug, Clone)]
217pub struct Definition {
218    pub word: String,
219    pub database: String,
220    pub database_description: String,
221    pub text: String,
222}
223
224/// `150` + one `151` block per definition + `250`, or `552 no match` when there are none.
225pub fn render_definitions(definitions: &[Definition]) -> String {
226    if definitions.is_empty() {
227        return "552 no match\r\n".to_string();
228    }
229    let mut out = format!(
230        "150 {} definitions retrieved - definitions follow\r\n",
231        definitions.len()
232    );
233    for d in definitions {
234        let status = format!(
235            "151 {} {} {} - text follows",
236            quoted(&d.word),
237            atom(&d.database),
238            quoted(&d.database_description)
239        );
240        out.push_str(&text_block(&status, &text_lines(&d.text)));
241    }
242    out.push_str("250 ok\r\n");
243    out
244}
245
246/// `152` + one `database "word"` line per match + `250`, or `552 no match`.
247pub fn render_matches(matches: &[(String, String)]) -> String {
248    if matches.is_empty() {
249        return "552 no match\r\n".to_string();
250    }
251    let lines: Vec<String> = matches
252        .iter()
253        .map(|(db, word)| format!("{} {}", atom(db), quoted(word)))
254        .collect();
255    let mut out = text_block(
256        &format!("152 {} matches found - text follows", matches.len()),
257        &lines,
258    );
259    out.push_str("250 ok\r\n");
260    out
261}
262
263/// A `name "description"` listing (`110` databases or `111` strategies) + `250`, or the
264/// listing's own empty-set code (`554` / `555`).
265pub fn render_listing(
266    entries: &[(String, String)],
267    code: u16,
268    noun: &str,
269    empty_line: &str,
270) -> String {
271    if entries.is_empty() {
272        return format!("{empty_line}\r\n");
273    }
274    let lines: Vec<String> = entries
275        .iter()
276        .map(|(name, desc)| format!("{} {}", atom(name), quoted(desc)))
277        .collect();
278    let mut out = text_block(
279        &format!("{code} {} {noun} - text follows", entries.len()),
280        &lines,
281    );
282    out.push_str("250 ok\r\n");
283    out
284}
285
286pub fn render_databases(entries: &[(String, String)]) -> String {
287    render_listing(
288        entries,
289        110,
290        "databases present",
291        "554 no databases present",
292    )
293}
294
295pub fn render_strategies(entries: &[(String, String)]) -> String {
296    render_listing(
297        entries,
298        111,
299        "strategies available",
300        "555 no strategies available",
301    )
302}
303
304/// `112` / `113` / `114` + a text block + `250`. `None` for any other code.
305pub fn render_text(code: u16, text: &str) -> Option<String> {
306    let (_, label) = TEXT_CODES.iter().find(|(c, _)| *c == code)?;
307    let mut out = text_block(&format!("{code} {label}"), &text_lines(text));
308    out.push_str("250 ok\r\n");
309    Some(out)
310}
311
312/// A single 5xx status line. `None` for a code outside [`ERROR_CODES`].
313///
314/// An empty message takes RFC 2229's own text for the code. The message is one line, so
315/// control characters become spaces, and it is cut to 900 characters so the line stays well
316/// inside [`MAX_LINE_BYTES`].
317pub fn render_error(code: u16, message: &str) -> Option<String> {
318    let (_, default) = ERROR_CODES.iter().find(|(c, _)| *c == code)?;
319    let text = sanitize::token(message, 900);
320    let text = if text.is_empty() {
321        default.to_string()
322    } else {
323        text
324    };
325    Some(format!("{code} {text}\r\n"))
326}
327
328/// The numeric code a rendered response begins with.
329pub fn leading_code(response: &[u8]) -> Option<u16> {
330    let head = response.get(..3)?;
331    if !head.iter().all(u8::is_ascii_digit) {
332        return None;
333    }
334    std::str::from_utf8(head).ok()?.parse().ok()
335}
336
337/// Insert [`MIME_HEADER`] after every status line that opens a text block.
338///
339/// Applied by the session loop to a connection that has sent `OPTION MIME`. It works on the
340/// rendered bytes rather than inside the renderers because the renderers are also the action
341/// executor, which has no idea what a given connection negotiated. That is safe only because
342/// every response it sees was produced by this module: inside a block the loop consumes lines
343/// until the lone `.` terminator, and dot-stuffing guarantees no content line reads as one, so
344/// a definition that happens to begin with `151 ` is never mistaken for a status line.
345pub fn apply_mime(response: &[u8]) -> Vec<u8> {
346    let mut out = Vec::with_capacity(response.len() + 128);
347    let mut in_block = false;
348    for line in response.split_inclusive(|b| *b == b'\n') {
349        out.extend_from_slice(line);
350        if in_block {
351            if line == b".\r\n" || line == b".\n" {
352                in_block = false;
353            }
354            continue;
355        }
356        if let Some(code) = leading_code(line) {
357            if TEXT_FOLLOWS_CODES.contains(&code) {
358                out.extend_from_slice(MIME_HEADER.as_bytes());
359                in_block = true;
360            }
361        }
362    }
363    out
364}