Skip to main content

postvak_core/
paths.rs

1use std::ffi::OsString;
2use std::fs;
3use std::io;
4use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7
8#[derive(Debug, Clone)]
9pub struct Paths {
10    /// The root given to `under` (`POSTVAK_HOME`); None for the platform directories.
11    pub home: Option<PathBuf>,
12    pub config_dir: PathBuf,
13    pub state_dir: PathBuf,
14    pub cache_dir: PathBuf,
15}
16
17impl Paths {
18    pub fn discover() -> anyhow::Result<Paths> {
19        let dirs = directories::ProjectDirs::from("", "", "postvak")
20            .ok_or_else(|| anyhow::anyhow!("no home directory found"))?;
21        let state_dir = dirs
22            .state_dir()
23            .map(Path::to_path_buf)
24            .unwrap_or_else(|| dirs.data_local_dir().to_path_buf());
25        Ok(Paths {
26            home: None,
27            config_dir: dirs.config_dir().to_path_buf(),
28            state_dir,
29            cache_dir: dirs.cache_dir().to_path_buf(),
30        })
31    }
32
33    pub fn under(root: &Path) -> Paths {
34        Paths {
35            home: Some(root.to_path_buf()),
36            config_dir: root.join("config"),
37            state_dir: root.join("state"),
38            cache_dir: root.join("cache"),
39        }
40    }
41
42    pub fn config_file(&self) -> PathBuf {
43        self.config_dir.join("config.toml")
44    }
45
46    pub fn rules_file(&self) -> PathBuf {
47        self.config_dir.join("rules.toml")
48    }
49
50    pub fn daemon_socket(&self) -> PathBuf {
51        self.state_dir.join("daemon.sock")
52    }
53
54    pub fn daemon_lock(&self) -> PathBuf {
55        self.state_dir.join("daemon.lock")
56    }
57
58    pub fn daemon_log(&self) -> PathBuf {
59        self.state_dir.join("daemon.log")
60    }
61
62    pub fn crash_dir(&self) -> PathBuf {
63        self.state_dir.join("crashes")
64    }
65
66    pub fn update_check(&self) -> PathBuf {
67        self.cache_dir.join("update-check.json")
68    }
69
70    pub fn account_dir(&self, name: &str) -> PathBuf {
71        self.state_dir.join("accounts").join(name)
72    }
73
74    pub fn mail_db(&self, name: &str) -> PathBuf {
75        self.account_dir(name).join("mail.db")
76    }
77
78    pub fn trash_dir(&self, name: &str) -> PathBuf {
79        self.account_dir(name).join("trash")
80    }
81
82    pub fn ensure_account(&self, name: &str) -> io::Result<()> {
83        create_private_dir(&self.config_dir)?;
84        create_private_dir(&self.cache_dir)?;
85        create_private_dir(&self.account_dir(name))?;
86        create_private_dir(&self.trash_dir(name))
87    }
88}
89
90pub fn create_private_dir(dir: &Path) -> io::Result<()> {
91    fs::DirBuilder::new()
92        .recursive(true)
93        .mode(0o700)
94        .create(dir)?;
95    fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
96}
97
98/// The file's text, or empty when it does not exist.
99pub fn read_or_empty(path: &Path) -> io::Result<String> {
100    match fs::read_to_string(path) {
101        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(String::new()),
102        read => read,
103    }
104}
105
106pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
107    write_atomic_in(path, bytes, true)
108}
109
110/// For a folder another app owns: an existing parent keeps its mode, a missing one is created private.
111pub fn write_atomic_keeping_dir_mode(path: &Path, bytes: &[u8]) -> io::Result<()> {
112    write_atomic_in(path, bytes, false)
113}
114
115fn write_atomic_in(path: &Path, bytes: &[u8], private_dir: bool) -> io::Result<()> {
116    // A symlink (say into a dotfiles repo) is written through, not replaced; a dangling one fails in canonicalize.
117    if fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) {
118        return write_file_atomic(&fs::canonicalize(path)?, bytes);
119    }
120    let dir = path
121        .parent()
122        .ok_or_else(|| io::Error::other("path has no parent"))?;
123    if private_dir || !dir.exists() {
124        create_private_dir(dir)?;
125    }
126    write_file_atomic(path, bytes)
127}
128
129/// This binary's path for anything that outlives the process (service file, MCP host config, an auto-started
130/// daemon). On Linux `current_exe` resolves symlinks, so a Homebrew install reports its versioned keg, which
131/// `brew upgrade` later deletes, and an AppImage reports its temporary mount, which goes away when it exits.
132pub fn stable_exe() -> io::Result<PathBuf> {
133    let exe = std::env::current_exe()?;
134    let appimage = std::env::var_os("APPIMAGE").map(PathBuf::from);
135    let appdir = std::env::var_os("APPDIR").map(PathBuf::from);
136    if let Some(image) = appimage_path(&exe, appimage.as_deref(), appdir.as_deref()) {
137        return Ok(image);
138    }
139    Ok(match homebrew_opt_path(&exe) {
140        Some(opt) if same_file(&opt, &exe) => opt,
141        _ => exe,
142    })
143}
144
145/// The app id when this process runs in a Flatpak sandbox, which sets `FLATPAK_ID`. The binary and config folders
146/// there are the sandbox's own, so something the host runs later must go through `flatpak run`.
147pub fn flatpak_id() -> Option<String> {
148    std::env::var("FLATPAK_ID").ok().filter(|id| !id.is_empty())
149}
150
151/// The AppImage file this binary runs from: the runtime sets `APPIMAGE` and `APPDIR` (its mount), and only a binary
152/// inside that mount is the AppImage; children of an AppImage inherit both variables.
153fn appimage_path(exe: &Path, appimage: Option<&Path>, appdir: Option<&Path>) -> Option<PathBuf> {
154    let (image, mount) = (appimage?, appdir?);
155    exe.starts_with(mount).then(|| image.to_path_buf())
156}
157
158/// `<prefix>/Cellar/<formula>/<version>/<rest>` becomes `<prefix>/opt/<formula>/<rest>`, Homebrew's link to the
159/// current keg.
160fn homebrew_opt_path(exe: &Path) -> Option<PathBuf> {
161    let parts: Vec<_> = exe.components().collect();
162    let cellar = parts.iter().rposition(|c| c.as_os_str() == "Cellar")?;
163    let (prefix, rest) = parts.split_at(cellar);
164    let [_cellar, formula, _version, rest @ ..] = rest else {
165        return None;
166    };
167    if rest.is_empty() {
168        return None;
169    }
170    let mut opt: PathBuf = prefix.iter().collect();
171    opt.push("opt");
172    opt.push(formula);
173    opt.extend(rest);
174    Some(opt)
175}
176
177fn same_file(a: &Path, b: &Path) -> bool {
178    match (fs::canonicalize(a), fs::canonicalize(b)) {
179        (Ok(a), Ok(b)) => a == b,
180        _ => false,
181    }
182}
183
184/// Whether this process is the macOS app opened from Finder, the Dock or `open`: the binary sits in a bundle's
185/// `Contents/MacOS`, got no arguments (very old macOS passes a `-psn_…` process serial number, which counts as none)
186/// and stdin is not a terminal. Then the app opens the mail window instead of printing help; a terminal run of the
187/// same binary (the cask links it as the `postvak` command) still prints help. `args` excludes argv\[0\].
188pub fn launched_as_app(exe: &Path, args: &[OsString], stdin_is_terminal: bool) -> bool {
189    let in_bundle = exe
190        .parent()
191        .is_some_and(|dir| dir.ends_with("Contents/MacOS"))
192        && exe.ancestors().nth(3).is_some_and(|bundle| {
193            bundle
194                .extension()
195                .is_some_and(|ext| ext.eq_ignore_ascii_case("app"))
196        });
197    let no_args = args
198        .iter()
199        .all(|a| a.to_str().is_some_and(|a| a.starts_with("-psn_")));
200    in_bundle && no_args && !stdin_is_terminal
201}
202
203fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
204    use std::io::Write;
205
206    let dir = path
207        .parent()
208        .ok_or_else(|| io::Error::other("path has no parent"))?;
209    let tmp = dir.join(format!(
210        ".{}.{}.tmp",
211        path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
212        std::process::id()
213    ));
214    let mut file = fs::File::create(&tmp)?;
215    file.set_permissions(fs::Permissions::from_mode(0o600))?;
216    file.write_all(bytes)?;
217    file.sync_all()?;
218    fs::rename(&tmp, path)?;
219    // The rename lives in the directory entry; without this a crash can bring back the old file or none.
220    fs::File::open(dir)?.sync_all()?;
221    Ok(())
222}
223
224/// A file's modification time, to notice edits by polling.
225pub struct FileWatch {
226    pub path: PathBuf,
227    modified: Option<SystemTime>,
228}
229
230impl FileWatch {
231    pub fn new(path: PathBuf) -> FileWatch {
232        FileWatch {
233            modified: modified_time(&path),
234            path,
235        }
236    }
237
238    /// Whether the modification time changed since the last call.
239    pub fn changed(&mut self) -> bool {
240        let modified = modified_time(&self.path);
241        if modified == self.modified {
242            return false;
243        }
244        self.modified = modified;
245        true
246    }
247}
248
249fn modified_time(path: &Path) -> Option<SystemTime> {
250    fs::metadata(path).and_then(|meta| meta.modified()).ok()
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn args(list: &[&str]) -> Vec<OsString> {
258        list.iter().map(OsString::from).collect()
259    }
260
261    const APP_EXE: &str = "/Applications/Postvak.app/Contents/MacOS/postvak";
262
263    #[test]
264    fn finder_launch_of_the_bundle_opens_the_app() {
265        assert!(launched_as_app(Path::new(APP_EXE), &[], false));
266        assert!(launched_as_app(
267            Path::new("/Users/me/Downloads/Postvak.APP/Contents/MacOS/postvak"),
268            &[],
269            false
270        ));
271    }
272
273    #[test]
274    fn an_old_macos_process_serial_number_counts_as_no_arguments() {
275        assert!(launched_as_app(
276            Path::new(APP_EXE),
277            &args(&["-psn_0_12345"]),
278            false
279        ));
280    }
281
282    #[test]
283    fn any_real_argument_runs_the_cli() {
284        assert!(!launched_as_app(
285            Path::new(APP_EXE),
286            &args(&["list"]),
287            false
288        ));
289        assert!(!launched_as_app(
290            Path::new(APP_EXE),
291            &args(&["-psn_0_1", "--help"]),
292            false
293        ));
294    }
295
296    #[test]
297    fn a_terminal_run_of_the_bundled_binary_prints_help() {
298        assert!(!launched_as_app(Path::new(APP_EXE), &[], true));
299    }
300
301    #[test]
302    fn a_binary_outside_a_bundle_runs_the_cli() {
303        for exe in [
304            "/opt/homebrew/bin/postvak",
305            "/usr/local/Cellar/postvak/0.1.0/bin/postvak",
306            "/tmp/Contents/MacOS/postvak",
307            "/x/Postvak.app/Contents/Resources/postvak",
308            "postvak",
309        ] {
310            assert!(!launched_as_app(Path::new(exe), &[], false), "{exe}");
311        }
312    }
313
314    #[test]
315    fn under_root_lays_out_three_dirs() {
316        let root = tempfile::tempdir().unwrap();
317        let p = Paths::under(root.path());
318        assert_eq!(p.config_file(), root.path().join("config/config.toml"));
319        assert_eq!(p.rules_file(), root.path().join("config/rules.toml"));
320        assert_eq!(
321            p.mail_db("work"),
322            root.path().join("state/accounts/work/mail.db")
323        );
324        assert_eq!(
325            p.trash_dir("work"),
326            root.path().join("state/accounts/work/trash")
327        );
328        assert_eq!(
329            p.update_check(),
330            root.path().join("cache/update-check.json")
331        );
332    }
333
334    #[test]
335    fn ensure_account_creates_dirs() {
336        let root = tempfile::tempdir().unwrap();
337        let p = Paths::under(root.path());
338        p.ensure_account("work").unwrap();
339        assert!(p.trash_dir("work").is_dir());
340        assert!(p.config_dir.is_dir());
341    }
342
343    #[test]
344    fn write_atomic_replaces_content_and_leaves_no_tmp() {
345        let root = tempfile::tempdir().unwrap();
346        let file = root.path().join("a.txt");
347        write_atomic(&file, b"one").unwrap();
348        write_atomic(&file, b"two").unwrap();
349        assert_eq!(fs::read(&file).unwrap(), b"two");
350        let leftovers: Vec<_> = fs::read_dir(root.path()).unwrap().collect();
351        assert_eq!(leftovers.len(), 1);
352    }
353
354    #[test]
355    fn write_atomic_creates_private_dir_and_file() {
356        let root = tempfile::tempdir().unwrap();
357        let path = root.path().join("config").join("config.toml");
358        write_atomic(&path, b"x").unwrap();
359        let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
360        assert_eq!(mode(path.parent().unwrap()), 0o700);
361        assert_eq!(mode(&path), 0o600);
362    }
363
364    #[test]
365    fn ensure_account_dirs_are_private() {
366        let root = tempfile::tempdir().unwrap();
367        let p = Paths::under(root.path());
368        p.ensure_account("work").unwrap();
369
370        let check_mode = |dir: &Path| {
371            let mode = fs::metadata(dir).unwrap().permissions().mode();
372            assert_eq!(mode & 0o777, 0o700, "directory {:?} is not 0700", dir);
373        };
374
375        check_mode(&p.state_dir);
376        check_mode(&p.state_dir.join("accounts"));
377        check_mode(&p.account_dir("work"));
378        check_mode(&p.trash_dir("work"));
379    }
380
381    #[test]
382    fn ensure_account_is_idempotent_across_accounts() {
383        let root = tempfile::tempdir().unwrap();
384        let p = Paths::under(root.path());
385        p.ensure_account("work").unwrap();
386        p.ensure_account("work").unwrap();
387        p.ensure_account("personal").unwrap();
388        assert!(p.trash_dir("work").is_dir());
389        assert!(p.trash_dir("personal").is_dir());
390    }
391
392    #[test]
393    fn an_appimage_reports_its_own_file_not_its_mount() {
394        let exe = Path::new("/tmp/.mount_PostbXy12/usr/bin/postvak");
395        let image = Path::new("/home/me/Apps/Postvak-x86_64.AppImage");
396        let mount = Path::new("/tmp/.mount_PostbXy12");
397        assert_eq!(
398            appimage_path(exe, Some(image), Some(mount)),
399            Some(image.to_path_buf())
400        );
401        // A postvak started from a shell that inherited another AppImage's environment is not that AppImage.
402        let other = Path::new("/home/me/.cargo/bin/postvak");
403        assert_eq!(appimage_path(other, Some(image), Some(mount)), None);
404        assert_eq!(appimage_path(exe, None, Some(mount)), None);
405        assert_eq!(appimage_path(exe, Some(image), None), None);
406    }
407
408    #[test]
409    fn homebrew_keg_path_maps_to_opt() {
410        let opt = |p: &str| homebrew_opt_path(Path::new(p));
411        assert_eq!(
412            opt("/home/linuxbrew/.linuxbrew/Cellar/postvak/0.3.0/bin/postvak"),
413            Some(PathBuf::from(
414                "/home/linuxbrew/.linuxbrew/opt/postvak/bin/postvak"
415            ))
416        );
417        assert_eq!(
418            opt("/opt/homebrew/Cellar/postvak/0.3.0_1/bin/postvak"),
419            Some(PathBuf::from("/opt/homebrew/opt/postvak/bin/postvak"))
420        );
421        assert_eq!(opt("/home/me/.cargo/bin/postvak"), None);
422        assert_eq!(opt("/opt/homebrew/Cellar/postvak/0.3.0"), None);
423        assert_eq!(opt("/opt/homebrew/Cellar/postvak"), None);
424    }
425
426    #[test]
427    fn opt_link_is_used_only_when_it_is_the_same_binary() {
428        let root = tempfile::tempdir().unwrap();
429        let keg = |v: &str| root.path().join(format!("Cellar/postvak/{v}/bin/postvak"));
430        for v in ["0.3.0", "0.4.0"] {
431            fs::create_dir_all(keg(v).parent().unwrap()).unwrap();
432            fs::write(keg(v), v).unwrap();
433        }
434        fs::create_dir_all(root.path().join("opt")).unwrap();
435        std::os::unix::fs::symlink(
436            root.path().join("Cellar/postvak/0.4.0"),
437            root.path().join("opt/postvak"),
438        )
439        .unwrap();
440        let opt = homebrew_opt_path(&keg("0.4.0")).unwrap();
441        assert_eq!(opt, root.path().join("opt/postvak/bin/postvak"));
442        assert!(same_file(&opt, &keg("0.4.0")));
443        // An older keg still running after an upgrade keeps its own path rather than pointing at another version.
444        assert!(!same_file(
445            &homebrew_opt_path(&keg("0.3.0")).unwrap(),
446            &keg("0.3.0")
447        ));
448    }
449
450    #[test]
451    fn write_atomic_writes_through_a_symlink() {
452        let root = tempfile::tempdir().unwrap();
453        let real = root.path().join("dotfiles/rules.toml");
454        fs::create_dir_all(real.parent().unwrap()).unwrap();
455        fs::write(&real, "old").unwrap();
456        let link = root.path().join("config/rules.toml");
457        fs::create_dir_all(link.parent().unwrap()).unwrap();
458        std::os::unix::fs::symlink(&real, &link).unwrap();
459        write_atomic(&link, b"new").unwrap();
460        assert!(
461            fs::symlink_metadata(&link)
462                .unwrap()
463                .file_type()
464                .is_symlink()
465        );
466        assert_eq!(fs::read_to_string(&real).unwrap(), "new");
467    }
468
469    #[test]
470    fn write_atomic_refuses_a_dangling_symlink() {
471        let root = tempfile::tempdir().unwrap();
472        let link = root.path().join("rules.toml");
473        std::os::unix::fs::symlink(root.path().join("missing/rules.toml"), &link).unwrap();
474        assert!(write_atomic(&link, b"new").is_err());
475        assert!(
476            fs::symlink_metadata(&link)
477                .unwrap()
478                .file_type()
479                .is_symlink()
480        );
481    }
482}