Krolyxon
Branch master

rmptuisrcmpdprotocol.rs

227 lines · 7.0 KB · Rust

1//! Wire format of MPD's text protocol: command encoding and response parsing.
2//!
3//! Everything here is pure so it can be tested without a server.
4
5use std::fmt;
6
7use super::error::{Ack, MpdError};
8
9/// A single key/value line of an MPD response.
10pub type Pair = (String, String);
11
12/// A command ready to be written to the socket.
13///
14/// Arguments are always quoted and escaped, so user-provided names and paths
15/// can never inject additional commands.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct Command {
18 line: String,
19}
20
21impl Command {
22 pub fn new(name: &str) -> Self {
23 Self {
24 line: name.to_owned(),
25 }
26 }
27
28 /// Appends a quoted argument.
29 #[must_use]
30 pub fn arg(mut self, value: impl AsRef<str>) -> Self {
31 self.line.push(' ');
32 push_quoted(&mut self.line, value.as_ref());
33 self
34 }
35
36 /// The encoded command line, without the trailing newline.
37 pub fn as_str(&self) -> &str {
38 &self.line
39 }
40
41 /// The command name, used in error messages.
42 pub fn name(&self) -> &str {
43 self.line.split(' ').next().unwrap_or_default()
44 }
45}
46
47impl fmt::Display for Command {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.write_str(&self.line)
50 }
51}
52
53fn push_quoted(out: &mut String, value: &str) {
54 out.push('"');
55 out.push_str(&escape(value));
56 out.push('"');
57}
58
59/// Escapes `\` and `"` so that `value` can be placed inside double quotes.
60pub fn escape(value: &str) -> String {
61 let mut escaped = String::with_capacity(value.len());
62 for ch in value.chars() {
63 if matches!(ch, '"' | '\\') {
64 escaped.push('\\');
65 }
66 escaped.push(ch);
67 }
68 escaped
69}
70
71/// Builds an MPD filter expression `(TAG == "VALUE")`.
72pub fn filter_eq(tag: &str, value: &str) -> String {
73 format!("({tag} == \"{}\")", escape(value))
74}
75
76/// Combines filter expressions with `AND`.
77pub fn filter_and<I, S>(filters: I) -> String
78where
79 I: IntoIterator<Item = S>,
80 S: AsRef<str>,
81{
82 let parts: Vec<String> = filters.into_iter().map(|f| f.as_ref().to_owned()).collect();
83 match parts.as_slice() {
84 [single] => single.clone(),
85 _ => format!("({})", parts.join(" AND ")),
86 }
87}
88
89/// One parsed response line.
90#[derive(Debug, PartialEq, Eq)]
91pub enum Line {
92 /// `OK`: the response is complete.
93 Ok,
94 /// `list_OK`: one command of a `command_list_ok_begin` list is complete.
95 ListOk,
96 /// A `key: value` pair.
97 Pair(Pair),
98}
99
100/// Parses one response line (without its trailing newline).
101pub fn parse_line(line: &str) -> Result<Line, MpdError> {
102 match line {
103 "OK" => Ok(Line::Ok),
104 "list_OK" => Ok(Line::ListOk),
105 _ if line.starts_with("ACK ") => Err(MpdError::Ack(parse_ack(line)?)),
106 _ => line
107 .split_once(": ")
108 .map(|(key, value)| Line::Pair((key.to_owned(), value.to_owned())))
109 .ok_or_else(|| MpdError::Protocol(format!("unexpected response line `{line}`"))),
110 }
111}
112
113/// Parses `ACK [error@command_listNum] {current_command} message_text`.
114fn parse_ack(line: &str) -> Result<Ack, MpdError> {
115 let malformed = || MpdError::Protocol(format!("malformed ACK line `{line}`"));
116 let rest = line.strip_prefix("ACK [").ok_or_else(malformed)?;
117 let (codes, rest) = rest.split_once("] {").ok_or_else(malformed)?;
118 let (command, message) = rest.split_once('}').ok_or_else(malformed)?;
119 let (code, index) = codes.split_once('@').ok_or_else(malformed)?;
120 Ok(Ack {
121 code: code.parse().map_err(|_| malformed())?,
122 index: index.parse().map_err(|_| malformed())?,
123 command: command.to_owned(),
124 message: message.trim_start().to_owned(),
125 })
126}
127
128/// Parses the `OK MPD <version>` greeting.
129pub fn parse_greeting(line: &str) -> Result<String, MpdError> {
130 line.strip_prefix("OK MPD ")
131 .map(str::to_owned)
132 .ok_or_else(|| MpdError::Protocol(format!("not an MPD server (greeting `{line}`)")))
133}
134
135/// Splits a flat list of pairs into groups, starting a new group whenever a
136/// key from `starts` is encountered. Pairs before the first start key are
137/// dropped.
138pub fn split_groups<'a>(pairs: &'a [Pair], starts: &[&str]) -> Vec<&'a [Pair]> {
139 let mut bounds: Vec<usize> = pairs
140 .iter()
141 .enumerate()
142 .filter(|(_, (key, _))| starts.contains(&key.as_str()))
143 .map(|(index, _)| index)
144 .collect();
145 bounds.push(pairs.len());
146 bounds.windows(2).map(|w| &pairs[w[0]..w[1]]).collect()
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn quotes_and_escapes_arguments() {
155 let cmd = Command::new("add").arg(r#"dir/a "quoted" \ name"#);
156 assert_eq!(cmd.as_str(), r#"add "dir/a \"quoted\" \\ name""#);
157 assert_eq!(cmd.name(), "add");
158 }
159
160 #[test]
161 fn newlines_cannot_start_a_new_command() {
162 // Newlines stay inside the quoted argument; MPD rejects the argument
163 // rather than executing a second command.
164 let cmd = Command::new("load").arg("x\"\nclear");
165 assert_eq!(cmd.as_str().matches('"').count(), 3);
166 assert!(cmd.as_str().contains("\\\""));
167 }
168
169 #[test]
170 fn builds_filters() {
171 assert_eq!(filter_eq("Artist", r#"A "B""#), r#"(Artist == "A \"B\"")"#);
172 assert_eq!(
173 filter_and([filter_eq("Artist", "x"), filter_eq("Album", "y")]),
174 r#"((Artist == "x") AND (Album == "y"))"#
175 );
176 assert_eq!(filter_and([filter_eq("Album", "y")]), r#"(Album == "y")"#);
177 // Filters are quoted once more when used as an argument.
178 let cmd = Command::new("find").arg(filter_eq("Album", r"a\b"));
179 assert_eq!(cmd.as_str(), r#"find "(Album == \"a\\\\b\")""#);
180 }
181
182 #[test]
183 fn parses_lines() {
184 assert_eq!(parse_line("OK").unwrap(), Line::Ok);
185 assert_eq!(parse_line("list_OK").unwrap(), Line::ListOk);
186 assert_eq!(
187 parse_line("Title: a: b").unwrap(),
188 Line::Pair(("Title".into(), "a: b".into()))
189 );
190 assert!(matches!(parse_line("garbage"), Err(MpdError::Protocol(_))));
191 }
192
193 #[test]
194 fn parses_ack() {
195 let Err(MpdError::Ack(ack)) = parse_line("ACK [50@1] {play} No such song") else {
196 panic!("expected ACK");
197 };
198 assert_eq!(ack.code, 50);
199 assert_eq!(ack.index, 1);
200 assert_eq!(ack.command, "play");
201 assert_eq!(ack.message, "No such song");
202 }
203
204 #[test]
205 fn parses_greeting() {
206 assert_eq!(parse_greeting("OK MPD 0.24.0").unwrap(), "0.24.0");
207 assert!(parse_greeting("HTTP/1.1 400").is_err());
208 }
209
210 #[test]
211 fn splits_groups() {
212 let pairs: Vec<Pair> = [
213 ("x", "0"),
214 ("file", "a"),
215 ("Title", "A"),
216 ("directory", "d"),
217 ("file", "b"),
218 ]
219 .iter()
220 .map(|(k, v)| ((*k).into(), (*v).into()))
221 .collect();
222 let groups = split_groups(&pairs, &["file", "directory"]);
223 assert_eq!(groups.len(), 3);
224 assert_eq!(groups[0].len(), 2);
225 assert_eq!(groups[1][0].1, "d");
226 }
227}