use crate::utils::sanitize;
pub const MAX_LINE_BYTES: usize = 1024;
pub const MAX_TEXT_LINE_CONTENT: usize = 1000;
pub const TEXT_FOLLOWS_CODES: &[u16] = &[110, 111, 112, 113, 114, 151, 152];
pub const ERROR_CODES: &[(u16, &str)] = &[
(500, "Syntax error, command not recognized"),
(501, "Syntax error, illegal parameters"),
(502, "Command not implemented"),
(503, "Command parameter not implemented"),
(530, "Access denied"),
(
531,
"Access denied, use \"SHOW INFO\" for server information",
),
(532, "Access denied, unknown mechanism"),
(
550,
"Invalid database, use \"SHOW DB\" for list of databases",
),
(
551,
"Invalid strategy, use \"SHOW STRAT\" for a list of strategies",
),
(552, "No match"),
(554, "No databases present"),
(555, "No strategies available"),
];
pub const TEXT_CODES: &[(u16, &str)] = &[
(112, "database information follows"),
(113, "help text follows"),
(114, "server information follows"),
];
pub const MIME_HEADER: &str =
"Content-type: text/plain; charset=utf-8\r\nContent-transfer-encoding: 8bit\r\n\r\n";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArgError {
UnterminatedQuote,
DanglingEscape,
}
pub fn split_args(line: &str) -> Result<Vec<String>, ArgError> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_token = false;
let mut quote: Option<char> = None;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match (quote, c) {
(_, '\\') => {
let next = chars.next().ok_or(ArgError::DanglingEscape)?;
current.push(next);
in_token = true;
}
(Some(q), c) if c == q => quote = None,
(Some(_), c) => current.push(c),
(None, '"') | (None, '\'') => {
quote = Some(c);
in_token = true;
}
(None, ' ') | (None, '\t') => {
if in_token {
args.push(std::mem::take(&mut current));
in_token = false;
}
}
(None, c) => {
current.push(c);
in_token = true;
}
}
}
if quote.is_some() {
return Err(ArgError::UnterminatedQuote);
}
if in_token {
args.push(current);
}
Ok(args)
}
pub fn quoted(s: &str) -> String {
let clean = sanitize::line_field(s);
let mut out = String::with_capacity(clean.len() + 2);
out.push('"');
for c in clean.chars() {
if c == '"' || c == '\\' {
out.push('\\');
}
out.push(c);
}
out.push('"');
out
}
pub fn atom(s: &str) -> String {
let plain = !s.is_empty()
&& sanitize::strip_controls(s) == s
&& s.chars()
.all(|c| !c.is_whitespace() && !matches!(c, '"' | '\'' | '\\'));
if plain {
s.to_string()
} else {
quoted(s)
}
}
fn wrap_line(line: &str) -> Vec<&str> {
let mut pieces = Vec::new();
let mut rest = line;
while rest.len() > MAX_TEXT_LINE_CONTENT {
let mut cut = MAX_TEXT_LINE_CONTENT;
while !rest.is_char_boundary(cut) {
cut -= 1;
}
pieces.push(&rest[..cut]);
rest = &rest[cut..];
}
pieces.push(rest);
pieces
}
pub fn text_lines(text: &str) -> Vec<String> {
let normalised = text.replace("\r\n", "\n").replace('\r', "\n");
let clean = sanitize::multiline(&normalised);
let trimmed = clean.trim_end_matches('\n');
if trimmed.is_empty() {
return Vec::new();
}
trimmed
.split('\n')
.flat_map(|line| wrap_line(line).into_iter().map(str::to_string))
.collect()
}
pub fn stuff(line: &str) -> String {
if line.starts_with('.') {
format!(".{line}")
} else {
line.to_string()
}
}
pub fn text_block(status_line: &str, lines: &[String]) -> String {
let mut out = String::new();
out.push_str(status_line);
out.push_str("\r\n");
for line in lines {
out.push_str(&stuff(line));
out.push_str("\r\n");
}
out.push_str(".\r\n");
out
}
#[derive(Debug, Clone)]
pub struct Definition {
pub word: String,
pub database: String,
pub database_description: String,
pub text: String,
}
pub fn render_definitions(definitions: &[Definition]) -> String {
if definitions.is_empty() {
return "552 no match\r\n".to_string();
}
let mut out = format!(
"150 {} definitions retrieved - definitions follow\r\n",
definitions.len()
);
for d in definitions {
let status = format!(
"151 {} {} {} - text follows",
quoted(&d.word),
atom(&d.database),
quoted(&d.database_description)
);
out.push_str(&text_block(&status, &text_lines(&d.text)));
}
out.push_str("250 ok\r\n");
out
}
pub fn render_matches(matches: &[(String, String)]) -> String {
if matches.is_empty() {
return "552 no match\r\n".to_string();
}
let lines: Vec<String> = matches
.iter()
.map(|(db, word)| format!("{} {}", atom(db), quoted(word)))
.collect();
let mut out = text_block(
&format!("152 {} matches found - text follows", matches.len()),
&lines,
);
out.push_str("250 ok\r\n");
out
}
pub fn render_listing(
entries: &[(String, String)],
code: u16,
noun: &str,
empty_line: &str,
) -> String {
if entries.is_empty() {
return format!("{empty_line}\r\n");
}
let lines: Vec<String> = entries
.iter()
.map(|(name, desc)| format!("{} {}", atom(name), quoted(desc)))
.collect();
let mut out = text_block(
&format!("{code} {} {noun} - text follows", entries.len()),
&lines,
);
out.push_str("250 ok\r\n");
out
}
pub fn render_databases(entries: &[(String, String)]) -> String {
render_listing(
entries,
110,
"databases present",
"554 no databases present",
)
}
pub fn render_strategies(entries: &[(String, String)]) -> String {
render_listing(
entries,
111,
"strategies available",
"555 no strategies available",
)
}
pub fn render_text(code: u16, text: &str) -> Option<String> {
let (_, label) = TEXT_CODES.iter().find(|(c, _)| *c == code)?;
let mut out = text_block(&format!("{code} {label}"), &text_lines(text));
out.push_str("250 ok\r\n");
Some(out)
}
pub fn render_error(code: u16, message: &str) -> Option<String> {
let (_, default) = ERROR_CODES.iter().find(|(c, _)| *c == code)?;
let text = sanitize::token(message, 900);
let text = if text.is_empty() {
default.to_string()
} else {
text
};
Some(format!("{code} {text}\r\n"))
}
pub fn leading_code(response: &[u8]) -> Option<u16> {
let head = response.get(..3)?;
if !head.iter().all(u8::is_ascii_digit) {
return None;
}
std::str::from_utf8(head).ok()?.parse().ok()
}
pub fn apply_mime(response: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(response.len() + 128);
let mut in_block = false;
for line in response.split_inclusive(|b| *b == b'\n') {
out.extend_from_slice(line);
if in_block {
if line == b".\r\n" || line == b".\n" {
in_block = false;
}
continue;
}
if let Some(code) = leading_code(line) {
if TEXT_FOLLOWS_CODES.contains(&code) {
out.extend_from_slice(MIME_HEADER.as_bytes());
in_block = true;
}
}
}
out
}