| 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 | |
| 5 | use std::fmt; |
| 6 | |
| 7 | use super::error::{Ack, MpdError}; |
| 8 | |
| 9 | /// A single key/value line of an MPD response. |
| 10 | pub 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)] |
| 17 | pub struct Command { |
| 18 | line: String, |
| 19 | } |
| 20 | |
| 21 | impl 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 | |
| 47 | impl fmt::Display for Command { |
| 48 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 49 | f.write_str(&self.line) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | fn 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. |
| 60 | pub 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")`. |
| 72 | pub fn filter_eq(tag: &str, value: &str) -> String { |
| 73 | format!("({tag} == \"{}\")", escape(value)) |
| 74 | } |
| 75 | |
| 76 | /// Combines filter expressions with `AND`. |
| 77 | pub fn filter_and<I, S>(filters: I) -> String |
| 78 | where |
| 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)] |
| 91 | pub 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). |
| 101 | pub 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`. |
| 114 | fn 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. |
| 129 | pub 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. |
| 138 | pub 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)] |
| 150 | mod 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 | } |