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/// What the last version check found, and when. Beside the catalog cache rather than in the
85/// state directory: it is a remembered answer that can be deleted at any time, not state
86/// the player would miss.
87pub fn update_check_file() -> Result<PathBuf> {
88    Ok(cache_dir()?.join("update-check.json"))
89}
90
91/// The directory holding `state.json` and `daemon.log`: `$MFP_STATE_DIR` if set, otherwise
92/// `~/.local/state/mfp` on every platform.
93pub fn state_dir() -> Result<PathBuf> {
94    if let Some(dir) = overridden_dir("MFP_STATE_DIR") {
95        return Ok(dir);
96    }
97    Ok(home_dir()?.join(".local").join("state").join(APP_DIR))
98}
99
100/// The durable listening state file.
101pub fn state_file() -> Result<PathBuf> {
102    Ok(state_dir()?.join("state.json"))
103}
104
105/// The file an autostarted daemon's output is redirected to, and the path a client names
106/// when it reports the daemon as unreachable.
107pub fn log_file() -> Result<PathBuf> {
108    Ok(state_dir()?.join("daemon.log"))
109}
110
111/// The Unix domain socket the daemon listens on: `$MFP_SOCKET` if set, otherwise
112/// `$XDG_RUNTIME_DIR/mfp/daemon.sock` where that variable is set, and
113/// `$TMPDIR/mfp-$UID/daemon.sock` otherwise, with `$TMPDIR` falling back to `/tmp`.
114pub fn socket_path() -> PathBuf {
115    resolve_socket_path(
116        env_var("MFP_SOCKET").as_deref(),
117        env_var("XDG_RUNTIME_DIR").as_deref(),
118        env_var("TMPDIR").as_deref(),
119        current_uid(),
120    )
121}
122
123fn resolve_socket_path(
124    mfp_socket: Option<&str>,
125    xdg_runtime_dir: Option<&str>,
126    tmpdir: Option<&str>,
127    uid: u32,
128) -> PathBuf {
129    if let Some(explicit) = mfp_socket.filter(|value| !value.is_empty()) {
130        return PathBuf::from(explicit);
131    }
132    if let Some(runtime) = xdg_runtime_dir.filter(|value| !value.is_empty()) {
133        return Path::new(runtime).join(APP_DIR).join(SOCKET_NAME);
134    }
135    let tmp = tmpdir.filter(|value| !value.is_empty()).unwrap_or("/tmp");
136    Path::new(tmp)
137        .join(format!("{APP_DIR}-{uid}"))
138        .join(SOCKET_NAME)
139}
140
141/// Creates the socket's parent directory mode `0700`, tightening it if it already exists
142/// with broader permissions. Fails rather than binding when it belongs to another user, is
143/// a symbolic link, or is something other than a directory.
144pub fn ensure_socket_dir(socket_path: &Path) -> Result<()> {
145    use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
146
147    let dir = socket_path.parent().ok_or_else(|| {
148        Error::Internal(format!(
149            "Socket path {} has no parent directory",
150            socket_path.display()
151        ))
152    })?;
153
154    // another user can pre-create the default directory in world-writable `/tmp` as a link
155    // to one of ours, which `metadata` would follow: chmod and bind inside a directory of
156    // their choosing
157    match std::fs::symlink_metadata(dir) {
158        Ok(metadata) => {
159            if metadata.is_symlink() {
160                return Err(Error::Internal(format!(
161                    "{} is a symbolic link",
162                    dir.display()
163                )));
164            }
165            if !metadata.is_dir() {
166                return Err(Error::Internal(format!(
167                    "{} exists and is not a directory",
168                    dir.display()
169                )));
170            }
171            let uid = current_uid();
172            if metadata.uid() != uid {
173                return Err(Error::Internal(format!(
174                    "{} is owned by uid {} rather than uid {}",
175                    dir.display(),
176                    metadata.uid(),
177                    uid
178                )));
179            }
180            if metadata.permissions().mode() & 0o777 != 0o700 {
181                std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
182            }
183        }
184        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
185            std::fs::DirBuilder::new()
186                .recursive(true)
187                .mode(0o700)
188                .create(dir)?;
189        }
190        Err(error) => return Err(error.into()),
191    }
192
193    Ok(())
194}
195
196#[cfg(test)]
197mod tests {
198    use std::os::unix::fs::PermissionsExt;
199
200    use super::*;
201
202    #[test]
203    fn xdg_runtime_dir_branch() {
204        assert_eq!(
205            resolve_socket_path(None, Some("/run/user/1000"), None, 1000),
206            PathBuf::from("/run/user/1000/mfp/daemon.sock")
207        );
208    }
209
210    #[test]
211    fn tmpdir_branch_when_xdg_runtime_dir_is_absent() {
212        assert_eq!(
213            resolve_socket_path(None, None, Some("/var/folders/ab/T/"), 501),
214            PathBuf::from("/var/folders/ab/T/mfp-501/daemon.sock")
215        );
216    }
217
218    #[test]
219    fn tmpdir_branch_falls_back_to_tmp() {
220        assert_eq!(
221            resolve_socket_path(None, None, None, 501),
222            PathBuf::from("/tmp/mfp-501/daemon.sock")
223        );
224    }
225
226    #[test]
227    fn empty_variables_are_treated_as_unset() {
228        assert_eq!(
229            resolve_socket_path(Some(""), Some(""), Some(""), 7),
230            PathBuf::from("/tmp/mfp-7/daemon.sock")
231        );
232    }
233
234    #[test]
235    fn explicit_override_wins_over_xdg_runtime_dir() {
236        assert_eq!(
237            resolve_socket_path(
238                Some("/tmp/custom.sock"),
239                Some("/run/user/1000"),
240                Some("/var/tmp"),
241                1000
242            ),
243            PathBuf::from("/tmp/custom.sock")
244        );
245    }
246
247    #[test]
248    fn an_explicit_directory_override_wins_over_xdg() {
249        assert_eq!(
250            resolve_overridden_dir(Some("/tmp/instance-a/state")),
251            Some(PathBuf::from("/tmp/instance-a/state"))
252        );
253    }
254
255    #[test]
256    fn without_an_explicit_override_the_platform_default_stays_in_force() {
257        assert_eq!(resolve_overridden_dir(None), None);
258    }
259
260    #[test]
261    fn empty_directory_variables_are_treated_as_unset() {
262        assert_eq!(resolve_overridden_dir(Some("")), None);
263    }
264
265    #[test]
266    fn missing_socket_directory_is_created_private() {
267        let root = tempfile::tempdir().unwrap();
268        let socket = root.path().join("mfp").join("daemon.sock");
269
270        ensure_socket_dir(&socket).unwrap();
271
272        let mode = std::fs::metadata(socket.parent().unwrap())
273            .unwrap()
274            .permissions()
275            .mode();
276        assert_eq!(mode & 0o777, 0o700);
277    }
278
279    #[test]
280    fn a_world_readable_socket_directory_is_tightened() {
281        let root = tempfile::tempdir().unwrap();
282        let dir = root.path().join("mfp");
283        std::fs::create_dir(&dir).unwrap();
284        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
285
286        ensure_socket_dir(&dir.join("daemon.sock")).unwrap();
287
288        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
289        assert_eq!(mode & 0o777, 0o700);
290    }
291
292    #[test]
293    fn a_symlinked_socket_directory_is_refused() {
294        let root = tempfile::tempdir().unwrap();
295        let target = root.path().join("elsewhere");
296        let link = root.path().join("mfp");
297        std::fs::create_dir(&target).unwrap();
298        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
299        std::os::unix::fs::symlink(&target, &link).unwrap();
300
301        let error = ensure_socket_dir(&link.join("daemon.sock")).unwrap_err();
302
303        assert_eq!(error.code(), crate::error::ErrorCode::Internal);
304        assert!(error.to_string().contains("symbolic link"));
305        assert_eq!(
306            std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
307            0o755
308        );
309    }
310
311    #[test]
312    fn a_file_occupying_the_directory_path_is_refused() {
313        let root = tempfile::tempdir().unwrap();
314        let occupied = root.path().join("mfp");
315        std::fs::write(&occupied, b"not a directory").unwrap();
316
317        let error = ensure_socket_dir(&occupied.join("daemon.sock")).unwrap_err();
318
319        assert_eq!(error.code(), crate::error::ErrorCode::Internal);
320        assert!(error.to_string().contains("not a directory"));
321    }
322}