use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::time::Duration;
use crate::error::{Error, Result};
use crate::url::Url;
const IO_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Eq)]
struct DictRequest {
verb: Verb,
word: String,
database: String,
strategy: String,
}
#[derive(Debug, PartialEq, Eq)]
enum Verb {
Define,
Match,
ShowDatabases,
}
impl DictRequest {
fn to_command(&self) -> String {
match self.verb {
Verb::Define => format!("DEFINE {} {}", self.database, self.word),
Verb::Match => format!("MATCH {} {} {}", self.database, self.strategy, self.word),
Verb::ShowDatabases => "SHOW DATABASES".to_string(),
}
}
}
fn parse_path(path: &str) -> Result<DictRequest> {
let raw = path.strip_prefix('/').unwrap_or(path);
if raw.is_empty() {
return Ok(DictRequest {
verb: Verb::ShowDatabases,
word: String::new(),
database: "*".into(),
strategy: ".".into(),
});
}
let (verb, rest) = if let Some(r) = raw.strip_prefix("d:") {
(Verb::Define, r)
} else if let Some(r) = raw.strip_prefix("m:") {
(Verb::Match, r)
} else {
(Verb::Define, raw)
};
let mut parts = rest.split(':');
let word = parts
.next()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::InvalidUrl(format!("dict: empty word in path '{path}'")))?;
let database = parts
.next()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "*".into());
let strategy = parts
.next()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| ".".into());
reject_control_bytes(&word, "word", path)?;
reject_control_bytes(&database, "database", path)?;
reject_control_bytes(&strategy, "strategy", path)?;
Ok(DictRequest {
verb,
word,
database,
strategy,
})
}
fn reject_control_bytes(value: &str, field: &str, path: &str) -> Result<()> {
if value.bytes().any(|b| b.is_ascii_control()) {
return Err(Error::InvalidUrl(format!(
"dict: control byte in {field} of path '{path}'"
)));
}
Ok(())
}
fn parse_status(line: &str) -> Result<(u16, &str)> {
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.len() < 3 {
return Err(Error::BadResponse(format!(
"dict: short status line '{trimmed}'"
)));
}
let (code_str, rest) = trimmed.split_at(3);
let code: u16 = code_str
.parse()
.map_err(|_| Error::BadResponse(format!("dict: non-numeric status '{trimmed}'")))?;
let msg = rest.strip_prefix(' ').unwrap_or(rest);
Ok((code, msg))
}
fn is_error_code(code: u16) -> bool {
(400..600).contains(&code)
}
fn read_line<R: BufRead>(reader: &mut R) -> Result<String> {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Err(Error::UnexpectedEof);
}
Ok(line)
}
fn read_text_block<R: BufRead>(reader: &mut R, out: &mut Vec<u8>) -> Result<()> {
loop {
let line = read_line(reader)?;
let body = line.trim_end_matches(['\r', '\n']);
if body == "." {
return Ok(());
}
let unescaped = body.strip_prefix('.').unwrap_or(body);
let to_write = if body.starts_with('.') {
unescaped
} else {
body
};
out.extend_from_slice(to_write.as_bytes());
out.extend_from_slice(b"\n");
}
}
pub fn fetch(url: &Url) -> Result<Vec<u8>> {
let request = parse_path(&url.path)?;
let stream = TcpStream::connect((url.host.as_str(), url.port))?;
stream.set_read_timeout(Some(IO_TIMEOUT))?;
stream.set_write_timeout(Some(IO_TIMEOUT))?;
let mut writer = stream.try_clone()?;
let mut reader = BufReader::new(stream);
let banner = read_line(&mut reader)?;
let (code, msg) = parse_status(&banner)?;
if code != 220 {
return Err(Error::BadResponse(format!("dict: {code} {msg}")));
}
let hello = format!("CLIENT rsurl/{}\r\n", env!("CARGO_PKG_VERSION"));
writer.write_all(hello.as_bytes())?;
let _ = read_line(&mut reader)?;
let command = request.to_command();
writer.write_all(command.as_bytes())?;
writer.write_all(b"\r\n")?;
let mut output: Vec<u8> = Vec::new();
let result = read_response(&mut reader, &request.verb, &mut output);
let _ = writer.write_all(b"QUIT\r\n");
let _ = read_line(&mut reader);
let _ = writer.shutdown(std::net::Shutdown::Both);
result?;
Ok(output)
}
fn read_response<R: BufRead>(reader: &mut R, verb: &Verb, out: &mut Vec<u8>) -> Result<()> {
let mut status = read_line(reader)?;
loop {
let (code, msg) = parse_status(&status)?;
if is_error_code(code) {
return Err(Error::BadResponse(format!("dict: {code} {msg}")));
}
match (verb, code) {
(Verb::ShowDatabases, 110) => {
read_text_block(reader, out)?;
}
(Verb::Match, 152) => {
read_text_block(reader, out)?;
}
(Verb::Define, 150) => {
}
(Verb::Define, 151) => {
out.extend_from_slice(msg.as_bytes());
out.extend_from_slice(b"\n");
read_text_block(reader, out)?;
}
(_, 250) => return Ok(()),
(_, 130) | (_, 230) => {}
(_, c) if (200..300).contains(&c) => return Ok(()),
_ => {
out.extend_from_slice(msg.as_bytes());
out.extend_from_slice(b"\n");
return Ok(());
}
}
status = read_line(reader)?;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_empty_means_show_databases() {
let r = parse_path("/").unwrap();
assert_eq!(r.verb, Verb::ShowDatabases);
assert_eq!(r.to_command(), "SHOW DATABASES");
}
#[test]
fn path_bare_word_defines_against_any_db() {
let r = parse_path("/rust").unwrap();
assert_eq!(r.verb, Verb::Define);
assert_eq!(r.word, "rust");
assert_eq!(r.database, "*");
assert_eq!(r.to_command(), "DEFINE * rust");
}
#[test]
fn path_define_with_db() {
let r = parse_path("/d:rust:foldoc").unwrap();
assert_eq!(r.verb, Verb::Define);
assert_eq!(r.word, "rust");
assert_eq!(r.database, "foldoc");
assert_eq!(r.to_command(), "DEFINE foldoc rust");
}
#[test]
fn path_define_with_explicit_verb_no_db() {
let r = parse_path("/d:rust").unwrap();
assert_eq!(r.verb, Verb::Define);
assert_eq!(r.word, "rust");
assert_eq!(r.database, "*");
assert_eq!(r.to_command(), "DEFINE * rust");
}
#[test]
fn path_match_default_db_and_strategy() {
let r = parse_path("/m:rust").unwrap();
assert_eq!(r.verb, Verb::Match);
assert_eq!(r.word, "rust");
assert_eq!(r.database, "*");
assert_eq!(r.strategy, ".");
assert_eq!(r.to_command(), "MATCH * . rust");
}
#[test]
fn path_match_with_db_and_strategy() {
let r = parse_path("/m:rust:foldoc:prefix").unwrap();
assert_eq!(r.verb, Verb::Match);
assert_eq!(r.word, "rust");
assert_eq!(r.database, "foldoc");
assert_eq!(r.strategy, "prefix");
assert_eq!(r.to_command(), "MATCH foldoc prefix rust");
}
#[test]
fn path_match_with_only_strategy_uses_default_db() {
let r = parse_path("/m:rust::prefix").unwrap();
assert_eq!(r.database, "*");
assert_eq!(r.strategy, "prefix");
}
#[test]
fn path_with_only_verb_prefix_is_rejected() {
assert!(parse_path("/d:").is_err());
assert!(parse_path("/m:").is_err());
}
#[test]
fn path_rejects_crlf_injection_in_word() {
let err = parse_path("/d:rust\r\nQUIT").unwrap_err();
assert!(matches!(err, Error::InvalidUrl(_)));
assert!(parse_path("/rust\nDEFINE * evil").is_err());
assert!(parse_path("/d:rust\rQUIT").is_err());
}
#[test]
fn path_rejects_control_bytes_in_database_and_strategy() {
assert!(parse_path("/d:rust:fol\r\ndoc").is_err());
assert!(parse_path("/m:rust:foldoc:pre\nfix").is_err());
assert!(parse_path("/d:rust:fol\0doc").is_err());
}
#[test]
fn path_accepts_clean_input() {
assert!(parse_path("/d:rust:foldoc").is_ok());
assert!(parse_path("/m:rust:foldoc:prefix").is_ok());
}
#[test]
fn parse_status_extracts_code_and_message() {
let (code, msg) = parse_status("220 dict.org dictd 1.12.1\r\n").unwrap();
assert_eq!(code, 220);
assert!(msg.starts_with("dict.org"));
}
#[test]
fn parse_status_handles_code_only() {
let (code, msg) = parse_status("250\r\n").unwrap();
assert_eq!(code, 250);
assert_eq!(msg, "");
}
#[test]
fn parse_status_rejects_garbage() {
assert!(parse_status("OK\r\n").is_err());
assert!(parse_status("ab").is_err());
}
#[test]
fn is_error_code_classifies_correctly() {
assert!(!is_error_code(220));
assert!(!is_error_code(250));
assert!(!is_error_code(150));
assert!(is_error_code(420));
assert!(is_error_code(500));
assert!(is_error_code(550));
assert!(!is_error_code(600));
}
#[test]
fn read_text_block_stops_at_dot() {
let input = b"line one\r\nline two\r\n.\r\n250 ok\r\n";
let mut reader = std::io::BufReader::new(&input[..]);
let mut out = Vec::new();
read_text_block(&mut reader, &mut out).unwrap();
assert_eq!(out, b"line one\nline two\n");
let mut tail = String::new();
reader.read_line(&mut tail).unwrap();
assert_eq!(tail, "250 ok\r\n");
}
#[test]
fn read_text_block_unescapes_dot_stuffing() {
let input = b"normal\r\n..hidden\r\n.\r\n";
let mut reader = std::io::BufReader::new(&input[..]);
let mut out = Vec::new();
read_text_block(&mut reader, &mut out).unwrap();
assert_eq!(out, b"normal\n.hidden\n");
}
#[test]
fn read_text_block_errors_on_premature_eof() {
let input = b"line one\r\n";
let mut reader = std::io::BufReader::new(&input[..]);
let mut out = Vec::new();
let err = read_text_block(&mut reader, &mut out).unwrap_err();
assert!(matches!(err, Error::UnexpectedEof));
}
}