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 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
98pub 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
110pub 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 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
129pub 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
145pub fn flatpak_id() -> Option<String> {
148 std::env::var("FLATPAK_ID").ok().filter(|id| !id.is_empty())
149}
150
151fn 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
158fn 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
184pub 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 fs::File::open(dir)?.sync_all()?;
221 Ok(())
222}
223
224pub 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 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 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 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}