1use crate::utils::sanitize;
11
12pub const MAX_LINE_BYTES: usize = 1024;
18
19pub const MAX_TEXT_LINE_CONTENT: usize = 1000;
27
28pub const TEXT_FOLLOWS_CODES: &[u16] = &[110, 111, 112, 113, 114, 151, 152];
33
34pub 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
59pub const TEXT_CODES: &[(u16, &str)] = &[
61 (112, "database information follows"),
62 (113, "help text follows"),
63 (114, "server information follows"),
64];
65
66pub const MIME_HEADER: &str =
71 "Content-type: text/plain; charset=utf-8\r\nContent-transfer-encoding: 8bit\r\n\r\n";
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ArgError {
76 UnterminatedQuote,
78 DanglingEscape,
80}
81
82pub 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
129pub 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
145pub 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
160fn 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
176pub 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
192pub fn stuff(line: &str) -> String {
195 if line.starts_with('.') {
196 format!(".{line}")
197 } else {
198 line.to_string()
199 }
200}
201
202pub 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#[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
224pub 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
246pub 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
263pub 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
304pub 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
312pub 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
328pub 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
337pub 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}