Skip to main content

mfp_core/
paths.rs

1//! Where the player keeps its configuration, caches, durable state, logs, and socket. The
2//! daemon and every client resolve these identically, so they always agree on the endpoint
3//! and on which files they share.
4//!
5//! Every location has an explicit override: `$MFP_SOCKET`, `$MFP_CONFIG_DIR`,
6//! `$MFP_CACHE_DIR`, and `$MFP_STATE_DIR`. Isolating an instance takes all of them -
7//! scoping the socket alone leaves two daemons writing one `state.json` and one cache.
8
9use std::path::{Path, PathBuf};
10
11use crate::error::{Error, Result};
12
13/// Every directory the player owns, named after the binary as the `$MFP_*` overrides are.
14const APP_DIR: &str = "mfp";
15const SOCKET_NAME: &str = "daemon.sock";
16
17fn env_var(key: &str) -> Option<String> {
18    std::env::var(key).ok().filter(|value| !value.is_empty())
19}
20
21fn home_dir() -> Result<PathBuf> {
22    directories::BaseDirs::new()
23        .map(|dirs| dirs.home_dir().to_path_buf())
24        .ok_or_else(|| Error::Internal("cannot determine the home directory".into()))
25}
26
27fn current_uid() -> u32 {
28    // SAFETY: getuid is always safe; it reads the calling process's real user id
29    unsafe { libc::getuid() }
30}
31
32/// The environment override in force for one of the config, cache, or state directories.
33fn overridden_dir(explicit_key: &str) -> Option<PathBuf> {
34    resolve_overridden_dir(env_var(explicit_key).as_deref())
35}
36
37/// An explicit `$MFP_*_DIR` is used as given: it names one instance's own directory, not
38/// a root shared with other applications. `None` leaves the platform default in force.
39///
40/// Deliberately no `$XDG_*_HOME` fallback: honouring one without the others scatters an
41/// instance across several roots - config read from one place, state written to another.
42fn resolve_overridden_dir(explicit: Option<&str>) -> Option<PathBuf> {
43    explicit
44        .filter(|value| !value.is_empty())
45        .map(PathBuf::from)
46}
47
48/// The directory holding `config.toml`: `$MFP_CONFIG_DIR` if set, otherwise
49/// `~/.config/mfp` on every platform.
50pub fn config_dir() -> Result<PathBuf> {
51    if let Some(dir) = overridden_dir("MFP_CONFIG_DIR") {
52        return Ok(dir);
53    }
54    Ok(home_dir()?.join(".config").join(APP_DIR))
55}
56
57/// The optional configuration file. Its absence is not an error; see [`crate::config`].
58pub fn config_file() -> Result<PathBuf> {
59    Ok(config_dir()?.join("config.toml"))
60}
61
62/// The directory holding the catalog cache and downloaded audio: `$MFP_CACHE_DIR` if set,
63/// otherwise `~/.cache/mfp` on every platform.
64///
65/// Deliberately not `~/Library/Caches` on macOS: the downloads here are a library a user
66/// browses and copies from, not a cache the system may reclaim behind their back.
67pub fn cache_dir() -> Result<PathBuf> {
68    if let Some(dir) = overridden_dir("MFP_CACHE_DIR") {
69        return Ok(dir);
70    }
71    Ok(home_dir()?.join(".cache").join(APP_DIR))
72}
73
74/// The directory holding `<identifier>.mp3` and `<identifier>.mp3.part` files.
75pub fn audio_cache_dir() -> Result<PathBuf> {
76    Ok(cache_dir()?.join("audio").join("episodes"))
77}
78
79/// The normalised JSON document holding the cached catalog.
80pub fn catalog_cache_file() -> Result<PathBuf> {
81    Ok(cache_dir()?.join("catalog.json"))
82}
83
84/// The directory holding `state.json` and `daemon.log`: `$MFP_STATE_DIR` if set, otherwise
85/// `~/.local/state/mfp` on every platform.
86pub fn state_dir() -> Result<PathBuf> {
87    if let Some(dir) = overridden_dir("MFP_STATE_DIR") {
88        return Ok(dir);
89    }
90    Ok(home_dir()?.join(".local").join("state").join(APP_DIR))
91}
92
93/// The durable listening state file.
94pub fn state_file() -> Result<PathBuf> {
95    Ok(state_dir()?.join("state.json"))
96}
97
98/// The file an autostarted daemon's output is redirected to, and the path a client names
99/// when it reports the daemon as unreachable.
100pub fn log_file() -> Result<PathBuf> {
101    Ok(state_dir()?.join("daemon.log"))
102}
103
104/// The Unix domain socket the daemon listens on: `$MFP_SOCKET` if set, otherwise
105/// `$XDG_RUNTIME_DIR/mfp/daemon.sock` where that variable is set, and
106/// `$TMPDIR/mfp-$UID/daemon.sock` otherwise, with `$TMPDIR` falling back to `/tmp`.
107pub fn socket_path() -> PathBuf {
108    resolve_socket_path(
109        env_var("MFP_SOCKET").as_deref(),
110        env_var("XDG_RUNTIME_DIR").as_deref(),
111        env_var("TMPDIR").as_deref(),
112        current_uid(),
113    )
114}
115
116fn resolve_socket_path(
117    mfp_socket: Option<&str>,
118    xdg_runtime_dir: Option<&str>,
119    tmpdir: Option<&str>,
120    uid: u32,
121) -> PathBuf {
122    if let Some(explicit) = mfp_socket.filter(|value| !value.is_empty()) {
123        return PathBuf::from(explicit);
124    }
125    if let Some(runtime) = xdg_runtime_dir.filter(|value| !value.is_empty()) {
126        return Path::new(runtime).join(APP_DIR).join(SOCKET_NAME);
127    }
128    let tmp = tmpdir.filter(|value| !value.is_empty()).unwrap_or("/tmp");
129    Path::new(tmp)
130        .join(format!("{APP_DIR}-{uid}"))
131        .join(SOCKET_NAME)
132}
133
134/// Creates the socket's parent directory mode `0700`, tightening it if it already exists
135/// with broader permissions. Fails rather than binding when it belongs to another user, is
136/// a symbolic link, or is something other than a directory.
137pub fn ensure_socket_dir(socket_path: &Path) -> Result<()> {
138    use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
139
140    let dir = socket_path.parent().ok_or_else(|| {
141        Error::Internal(format!(
142            "socket path {} has no parent directory",
143            socket_path.display()
144        ))
145    })?;
146
147    // another user can pre-create the default directory in world-writable `/tmp` as a link
148    // to one of ours, which `metadata` would follow: chmod and bind inside a directory of
149    // their choosing
150    match std::fs::symlink_metadata(dir) {
151        Ok(metadata) => {
152            if metadata.is_symlink() {
153                return Err(Error::Internal(format!(
154                    "{} is a symbolic link",
155                    dir.display()
156                )));
157            }
158            if !metadata.is_dir() {
159                return Err(Error::Internal(format!(
160                    "{} exists and is not a directory",
161                    dir.display()
162                )));
163            }
164            let uid = current_uid();
165            if metadata.uid() != uid {
166                return Err(Error::Internal(format!(
167                    "{} is owned by uid {} rather than uid {}",
168                    dir.display(),
169                    metadata.uid(),
170                    uid
171                )));
172            }
173            if metadata.permissions().mode() & 0o777 != 0o700 {
174                std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
175            }
176        }
177        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
178            std::fs::DirBuilder::new()
179                .recursive(true)
180                .mode(0o700)
181                .create(dir)?;
182        }
183        Err(error) => return Err(error.into()),
184    }
185
186    Ok(())
187}
188
189#[cfg(test)]
190mod tests {
191    use std::os::unix::fs::PermissionsExt;
192
193    use super::*;
194
195    #[test]
196    fn xdg_runtime_dir_branch() {
197        assert_eq!(
198            resolve_socket_path(None, Some("/run/user/1000"), None, 1000),
199            PathBuf::from("/run/user/1000/mfp/daemon.sock")
200        );
201    }
202
203    #[test]
204    fn tmpdir_branch_when_xdg_runtime_dir_is_absent() {
205        assert_eq!(
206            resolve_socket_path(None, None, Some("/var/folders/ab/T/"), 501),
207            PathBuf::from("/var/folders/ab/T/mfp-501/daemon.sock")
208        );
209    }
210
211    #[test]
212    fn tmpdir_branch_falls_back_to_tmp() {
213        assert_eq!(
214            resolve_socket_path(None, None, None, 501),
215            PathBuf::from("/tmp/mfp-501/daemon.sock")
216        );
217    }
218
219    #[test]
220    fn empty_variables_are_treated_as_unset() {
221        assert_eq!(
222            resolve_socket_path(Some(""), Some(""), Some(""), 7),
223            PathBuf::from("/tmp/mfp-7/daemon.sock")
224        );
225    }
226
227    #[test]
228    fn explicit_override_wins_over_xdg_runtime_dir() {
229        assert_eq!(
230            resolve_socket_path(
231                Some("/tmp/custom.sock"),
232                Some("/run/user/1000"),
233                Some("/var/tmp"),
234                1000
235            ),
236            PathBuf::from("/tmp/custom.sock")
237        );
238    }
239
240    #[test]
241    fn an_explicit_directory_override_wins_over_xdg() {
242        assert_eq!(
243            resolve_overridden_dir(Some("/tmp/instance-a/state")),
244            Some(PathBuf::from("/tmp/instance-a/state"))
245        );
246    }
247
248    #[test]
249    fn without_an_explicit_override_the_platform_default_stays_in_force() {
250        assert_eq!(resolve_overridden_dir(None), None);
251    }
252
253    #[test]
254    fn empty_directory_variables_are_treated_as_unset() {
255        assert_eq!(resolve_overridden_dir(Some("")), None);
256    }
257
258    #[test]
259    fn missing_socket_directory_is_created_private() {
260        let root = tempfile::tempdir().unwrap();
261        let socket = root.path().join("mfp").join("daemon.sock");
262
263        ensure_socket_dir(&socket).unwrap();
264
265        let mode = std::fs::metadata(socket.parent().unwrap())
266            .unwrap()
267            .permissions()
268            .mode();
269        assert_eq!(mode & 0o777, 0o700);
270    }
271
272    #[test]
273    fn a_world_readable_socket_directory_is_tightened() {
274        let root = tempfile::tempdir().unwrap();
275        let dir = root.path().join("mfp");
276        std::fs::create_dir(&dir).unwrap();
277        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
278
279        ensure_socket_dir(&dir.join("daemon.sock")).unwrap();
280
281        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
282        assert_eq!(mode & 0o777, 0o700);
283    }
284
285    #[test]
286    fn a_symlinked_socket_directory_is_refused() {
287        let root = tempfile::tempdir().unwrap();
288        let target = root.path().join("elsewhere");
289        let link = root.path().join("mfp");
290        std::fs::create_dir(&target).unwrap();
291        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
292        std::os::unix::fs::symlink(&target, &link).unwrap();
293
294        let error = ensure_socket_dir(&link.join("daemon.sock")).unwrap_err();
295
296        assert_eq!(error.code(), crate::error::ErrorCode::Internal);
297        assert!(error.to_string().contains("symbolic link"));
298        assert_eq!(
299            std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
300            0o755
301        );
302    }
303
304    #[test]
305    fn a_file_occupying_the_directory_path_is_refused() {
306        let root = tempfile::tempdir().unwrap();
307        let occupied = root.path().join("mfp");
308        std::fs::write(&occupied, b"not a directory").unwrap();
309
310        let error = ensure_socket_dir(&occupied.join("daemon.sock")).unwrap_err();
311
312        assert_eq!(error.code(), crate::error::ErrorCode::Internal);
313        assert!(error.to_string().contains("not a directory"));
314    }
315}