Skip to main content

mtp_mount/
spool.rs

1//! Where write buffers and read caches are spooled.
2//!
3//! Both the write path and the read path back their temp files with real disk,
4//! never `$TMPDIR`: on most current Linux distros `/tmp` is a tmpfs, so spooling
5//! a multi-gigabyte upload there fills RAM and gets the process stopped by the
6//! OOM killer. The spool lives under the user's cache directory instead.
7//!
8//! The files stay **unlinked** (`tempfile::tempfile_in`), so a crash reclaims
9//! their space with no cleanup pass and no leftovers.
10
11use std::io;
12use std::path::{Path, PathBuf};
13
14use crate::error::MountError;
15
16/// Cache-directory convention to follow when no explicit spool dir is given.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CacheConvention {
19    /// XDG base directories: `$XDG_CACHE_HOME`, else `$HOME/.cache`.
20    Xdg,
21    /// macOS: `$HOME/Library/Caches`.
22    MacOs,
23}
24
25impl CacheConvention {
26    /// The convention for the platform this binary was built for.
27    pub fn current() -> Self {
28        if cfg!(target_os = "macos") {
29            Self::MacOs
30        } else {
31            Self::Xdg
32        }
33    }
34}
35
36/// Resolve the spool directory from an optional override and the environment.
37///
38/// Pure: every input is a parameter, so this is testable without touching the
39/// real environment. Empty env values count as unset, per the XDG spec.
40///
41/// - `override_dir`: the `--spool-dir` flag, wins over everything.
42/// - `xdg_cache_home`: `$XDG_CACHE_HOME` (ignored under [`CacheConvention::MacOs`]).
43/// - `home`: `$HOME`.
44pub fn resolve_spool_dir(
45    override_dir: Option<&Path>,
46    xdg_cache_home: Option<&str>,
47    home: Option<&str>,
48    convention: CacheConvention,
49) -> Result<PathBuf, MountError> {
50    if let Some(dir) = override_dir {
51        return Ok(dir.to_path_buf());
52    }
53
54    // Empty env values count as unset, per the XDG spec.
55    fn non_empty(v: Option<&str>) -> Option<&str> {
56        v.filter(|s| !s.is_empty())
57    }
58    let home = non_empty(home);
59
60    let base = match convention {
61        CacheConvention::MacOs => home
62            .map(|h| Path::new(h).join("Library").join("Caches"))
63            .ok_or_else(|| {
64                MountError::Other(
65                    "can't find a cache directory: $HOME is not set. \
66                     Pass --spool-dir to say where writes should be spooled."
67                        .into(),
68                )
69            })?,
70        CacheConvention::Xdg => match non_empty(xdg_cache_home) {
71            Some(xdg) => PathBuf::from(xdg),
72            None => home.map(|h| Path::new(h).join(".cache")).ok_or_else(|| {
73                MountError::Other(
74                    "can't find a cache directory: neither $XDG_CACHE_HOME nor $HOME is set. \
75                     Pass --spool-dir to say where writes should be spooled."
76                        .into(),
77                )
78            })?,
79        },
80    };
81
82    Ok(base.join("mtp-mount").join("spool"))
83}
84
85/// Resolve the spool directory from the live environment.
86pub fn spool_dir_from_env(override_dir: Option<&Path>) -> Result<PathBuf, MountError> {
87    let xdg = std::env::var("XDG_CACHE_HOME").ok();
88    let home = std::env::var("HOME").ok();
89    resolve_spool_dir(
90        override_dir,
91        xdg.as_deref(),
92        home.as_deref(),
93        CacheConvention::current(),
94    )
95}
96
97/// Create the spool directory if needed and prove it's writable.
98///
99/// Fails loudly, naming the path: silently falling back to `$TMPDIR` would put
100/// the spool back in RAM where nobody would notice.
101pub fn prepare_spool_dir(dir: &Path) -> Result<(), MountError> {
102    std::fs::create_dir_all(dir).map_err(|e| spool_error(dir, e))?;
103    // The write path only ever makes unlinked temp files here, so making one is
104    // both the honest permission check and a no-op on success.
105    tempfile::tempfile_in(dir).map_err(|e| spool_error(dir, e))?;
106    Ok(())
107}
108
109fn spool_error(dir: &Path, source: io::Error) -> MountError {
110    MountError::Other(format!(
111        "can't use the spool directory {}: {source}. \
112         Pass --spool-dir to point it at a writable directory on disk.",
113        dir.display()
114    ))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn xdg_cache_home_wins_when_set() {
123        let dir = resolve_spool_dir(
124            None,
125            Some("/cache"),
126            Some("/home/dave"),
127            CacheConvention::Xdg,
128        )
129        .unwrap();
130        assert_eq!(dir, PathBuf::from("/cache/mtp-mount/spool"));
131    }
132
133    #[test]
134    fn xdg_unset_falls_back_to_home_cache() {
135        let dir = resolve_spool_dir(None, None, Some("/home/dave"), CacheConvention::Xdg).unwrap();
136        assert_eq!(dir, PathBuf::from("/home/dave/.cache/mtp-mount/spool"));
137    }
138
139    #[test]
140    fn empty_xdg_counts_as_unset() {
141        let dir =
142            resolve_spool_dir(None, Some(""), Some("/home/dave"), CacheConvention::Xdg).unwrap();
143        assert_eq!(dir, PathBuf::from("/home/dave/.cache/mtp-mount/spool"));
144    }
145
146    #[test]
147    fn override_wins_over_env() {
148        let dir = resolve_spool_dir(
149            Some(Path::new("/mnt/scratch")),
150            Some("/cache"),
151            Some("/home/dave"),
152            CacheConvention::Xdg,
153        )
154        .unwrap();
155        assert_eq!(dir, PathBuf::from("/mnt/scratch"));
156    }
157
158    #[test]
159    fn macos_uses_library_caches_and_ignores_xdg() {
160        let dir = resolve_spool_dir(
161            None,
162            Some("/cache"),
163            Some("/Users/dave"),
164            CacheConvention::MacOs,
165        )
166        .unwrap();
167        assert_eq!(
168            dir,
169            PathBuf::from("/Users/dave/Library/Caches/mtp-mount/spool")
170        );
171    }
172
173    #[test]
174    fn no_home_and_no_xdg_errors() {
175        let err = resolve_spool_dir(None, None, None, CacheConvention::Xdg).unwrap_err();
176        assert!(err.to_string().contains("--spool-dir"));
177
178        let err =
179            resolve_spool_dir(None, Some("/cache"), None, CacheConvention::MacOs).unwrap_err();
180        assert!(err.to_string().contains("--spool-dir"));
181    }
182
183    #[test]
184    fn prepare_creates_missing_directory() {
185        let parent = tempfile::tempdir().unwrap();
186        let dir = parent.path().join("mtp-mount").join("spool");
187        prepare_spool_dir(&dir).unwrap();
188        assert!(dir.is_dir());
189    }
190
191    #[test]
192    fn prepare_reports_the_path_it_could_not_use() {
193        // A regular file where the directory should be: `create_dir_all` fails.
194        let parent = tempfile::tempdir().unwrap();
195        let blocked = parent.path().join("not-a-dir");
196        std::fs::write(&blocked, b"x").unwrap();
197        let err = prepare_spool_dir(&blocked).unwrap_err().to_string();
198        assert!(err.contains(&blocked.display().to_string()), "{err}");
199    }
200}