jellytau_lib/player/mpv_command.rs
1//! The two things every libmpv handle in this process must get right before it
2//! is handed a URL.
3//!
4//! **Commands are an argument vector.** The pinned `libmpv` crate's
5//! `Mpv::command` joins its arguments with spaces and hands the result to
6//! `mpv_command_string`, which parses it as input.conf syntax: whitespace splits
7//! arguments, `;` separates commands, `#` starts a comment. Every URL this app
8//! loads carries server-controlled text — item and media-source ids, the
9//! server's own `TranscodingUrl`, and for a download the file name, which is the
10//! track title — so a title like `x;run sh -c …;#` ran a shell command the
11//! moment it played. [`command`] goes through `mpv_command` instead, where each
12//! argument reaches mpv as one opaque string and nothing is parsed.
13//!
14//! **TLS is verified.** mpv's `tls-verify` defaults to *no*, and the stream URLs
15//! it loads carry the account's `ApiKey`. Every reqwest client in the app
16//! verifies certificates; without [`harden`] mpv was the one path where anyone
17//! able to present a certificate for the server's host could read the token.
18//! `ytdl` goes with it: libmpv loads its youtube-dl hook by default and hands a
19//! URL that failed to open — token included — to an external `yt-dlp`.
20//!
21//! TRACES: UR-003, UR-004, UR-012 | DR-298, DR-299
22
23use std::ffi::{CStr, CString};
24use std::os::raw::c_char;
25
26use libmpv::Mpv;
27
28/// Run an mpv command with each argument passed through verbatim.
29///
30/// TRACES: UR-003, UR-004 | DR-298 | UT-268
31pub fn command(mpv: &Mpv, args: &[&str]) -> Result<(), String> {
32 if args.is_empty() {
33 return Err("empty mpv command".to_string());
34 }
35 // A NUL cannot be represented in a C string; refusing is the only honest
36 // answer, since truncating would load a different URL than the one asked.
37 let owned = args
38 .iter()
39 .map(|a| CString::new(*a).map_err(|_| format!("mpv argument contains NUL: {a:?}")))
40 .collect::<Result<Vec<_>, _>>()?;
41 let mut argv: Vec<*const c_char> = owned.iter().map(|a| a.as_ptr()).collect();
42 argv.push(std::ptr::null());
43
44 // SAFETY: `argv` is a NULL-terminated array of pointers into `owned`, which
45 // outlives the call; mpv copies what it keeps. `ctx` is the live handle.
46 let rc = unsafe { libmpv_sys::mpv_command(mpv.ctx.as_ptr(), argv.as_mut_ptr()) };
47 if rc < 0 {
48 // SAFETY: mpv_error_string returns a static string for any code.
49 let msg = unsafe { CStr::from_ptr(libmpv_sys::mpv_error_string(rc)) };
50 return Err(format!("{} ({rc})", msg.to_string_lossy()));
51 }
52 Ok(())
53}
54
55/// Configure a freshly created handle so it will not trust an unverified server.
56///
57/// Must run before the first `loadfile`. Failure is an error, not a warning: a
58/// handle that could not be told to verify TLS is exactly the one that leaks.
59///
60/// TRACES: UR-012 | DR-299 | UT-270
61pub fn harden(mpv: &Mpv) -> Result<(), String> {
62 for (name, value) in [("tls-verify", "yes"), ("ytdl", "no")] {
63 mpv.set_property(name, value)
64 .map_err(|e| format!("could not set {name}={value}: {e:?}"))?;
65 }
66 Ok(())
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 /// A handle that decodes nothing and opens no device, so the tests need no
74 /// audio system and no display.
75 fn null_mpv() -> Mpv {
76 let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
77 mpv.set_property("ao", "null").unwrap();
78 mpv.set_property("vo", "null").unwrap();
79 mpv
80 }
81
82 /// The injection itself, against a real libmpv: a URL carrying `;` and a
83 /// second command must be loaded as one (unreachable) URL, not split and
84 /// executed. `set volume 13` stands in for `run …` — the same parse, but
85 /// observable without spawning a process.
86 ///
87 /// TRACES: UR-003, UR-004 | DR-298 | UT-268
88 #[test]
89 fn a_url_cannot_smuggle_a_second_mpv_command() {
90 let mpv = null_mpv();
91 mpv.set_property("volume", 100i64).unwrap();
92
93 // Port 9 (discard) on loopback: nothing is fetched either way.
94 let url = "http://127.0.0.1:9/Audio/x;set volume 13;#/stream?ApiKey=k";
95 let _ = command(&mpv, &["loadfile", url, "replace"]);
96
97 let volume: i64 = mpv.get_property("volume").unwrap();
98 assert_eq!(
99 volume, 100,
100 "text inside a URL was executed as an mpv command"
101 );
102 }
103
104 /// An argument with a space in it — every downloaded title with one —
105 /// arrives as one argument rather than being split into the next slot.
106 ///
107 /// TRACES: UR-003, UR-004 | DR-298 | UT-268
108 #[test]
109 fn an_argument_with_spaces_stays_one_argument() {
110 let mpv = null_mpv();
111 // Split on the space this would be `loadfile file:///tmp/My Song.mp3`
112 // — `Song.mp3` taken as the flags argument, which mpv rejects.
113 assert!(command(&mpv, &["loadfile", "file:///nonexistent/My Song.mp3"]).is_ok());
114 }
115
116 /// No playback path may call the string-joining `Mpv::command` directly;
117 /// they all go through [`command`]. Asserted against the source because the
118 /// dangerous call and the safe one have the same shape at the call site.
119 ///
120 /// TRACES: UR-003, UR-004 | DR-298 | UT-269
121 #[test]
122 fn players_never_use_the_string_command_api() {
123 for (file, src) in [
124 ("mpv_backend.rs", include_str!("mpv_backend.rs")),
125 ("mpv_player.rs", include_str!("mpv_player.rs")),
126 ] {
127 assert!(
128 !src.contains(".command(\""),
129 "{file} calls Mpv::command, which parses its arguments as a command string"
130 );
131 }
132 }
133
134 /// TRACES: UR-012 | DR-299 | UT-270
135 #[test]
136 fn a_hardened_handle_verifies_tls_and_never_hands_urls_to_ytdl() {
137 let mpv = null_mpv();
138 harden(&mpv).unwrap();
139
140 let tls: String = mpv.get_property("tls-verify").unwrap();
141 assert_eq!(tls, "yes");
142 let ytdl: String = mpv.get_property("ytdl").unwrap();
143 assert_eq!(ytdl, "no");
144 }
145
146 /// Both constructors apply [`harden`]; a handle built without it is the bug.
147 ///
148 /// TRACES: UR-012 | DR-299 | UT-270
149 #[test]
150 fn every_player_hardens_its_handle() {
151 for (file, src) in [
152 ("mpv_backend.rs", include_str!("mpv_backend.rs")),
153 ("mpv_player.rs", include_str!("mpv_player.rs")),
154 ] {
155 assert!(
156 src.contains("mpv_command::harden(&mpv)"),
157 "{file} creates an mpv handle without hardening it"
158 );
159 }
160 }
161}