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 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
93pub fn state_file() -> Result<PathBuf> {
95 Ok(state_dir()?.join("state.json"))
96}
97
98pub fn log_file() -> Result<PathBuf> {
101 Ok(state_dir()?.join("daemon.log"))
102}
103
104pub 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
134pub 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 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}