Skip to main content

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}