1use std::path::{Path, PathBuf};
10
11use crate::error::{Error, Result};
12
13const 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 unsafe { libc::getuid() }
30}
31
32fn overridden_dir(explicit_key: &str) -> Option<PathBuf> {
34 resolve_overridden_dir(env_var(explicit_key).as_deref())
35}
36
37fn resolve_overridden_dir(explicit: Option<&str>) -> Option<PathBuf> {
43 explicit
44 .filter(|value| !value.is_empty())
45 .map(PathBuf::from)
46}
47
48pub 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
57pub fn config_file() -> Result<PathBuf> {
59 Ok(config_dir()?.join("config.toml"))
60}
61
62pub 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
74pub fn audio_cache_dir() -> Result<PathBuf> {
76 Ok(cache_dir()?.join("audio").join("episodes"))
77}
78
79pub fn catalog_cache_file() -> Result<PathBuf> {
81 Ok(cache_dir()?.join("catalog.json"))
82}
83
84pub fn update_check_file() -> Result<PathBuf> {
88 Ok(cache_dir()?.join("update-check.json"))
89}
90
91pub 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
100pub fn state_file() -> Result<PathBuf> {
102 Ok(state_dir()?.join("state.json"))
103}
104
105pub fn log_file() -> Result<PathBuf> {
108 Ok(state_dir()?.join("daemon.log"))
109}
110
111pub 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
141pub 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 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}