Skip to main content

grabcli_core/
paths.rs

1//! Where grabcli keeps its files, and private/atomic file writes.
2//!
3//! - config dir: `$GRABCLI_CONFIG_DIR`, else `$XDG_CONFIG_HOME/grabcli`, else
4//!   `~/.config/grabcli`. Holds `config.toml`, `session.json` (0600) and the
5//!   optional `hydra-key.pem` (0600).
6//! - state dir: `$GRABCLI_STATE_DIR`, else `$XDG_STATE_HOME/grabcli`, else
7//!   `~/.local/state/grabcli`. Each service gets a subdirectory (`food/`).
8//! - cache dir: `$GRABCLI_CACHE_DIR`, else `$XDG_CACHE_HOME/grabcli`, else
9//!   `~/.cache/grabcli`. Disposable response cache (see [`crate::cache`]).
10
11use std::fs;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14
15use crate::error::{Error, Result};
16
17fn home() -> Result<PathBuf> {
18    std::env::var_os("HOME")
19        .map(PathBuf::from)
20        .ok_or_else(|| Error::invalid("HOME is not set"))
21}
22
23fn env_path(name: &str) -> Option<PathBuf> {
24    std::env::var_os(name)
25        .filter(|v| !v.is_empty())
26        .map(PathBuf::from)
27}
28
29pub fn config_dir() -> Result<PathBuf> {
30    if let Some(p) = env_path("GRABCLI_CONFIG_DIR") {
31        return Ok(p);
32    }
33    if let Some(p) = env_path("XDG_CONFIG_HOME") {
34        return Ok(p.join("grabcli"));
35    }
36    Ok(home()?.join(".config").join("grabcli"))
37}
38
39pub fn state_dir() -> Result<PathBuf> {
40    if let Some(p) = env_path("GRABCLI_STATE_DIR") {
41        return Ok(p);
42    }
43    if let Some(p) = env_path("XDG_STATE_HOME") {
44        return Ok(p.join("grabcli"));
45    }
46    Ok(home()?.join(".local").join("state").join("grabcli"))
47}
48
49pub fn cache_dir() -> Result<PathBuf> {
50    if let Some(p) = env_path("GRABCLI_CACHE_DIR") {
51        return Ok(p);
52    }
53    if let Some(p) = env_path("XDG_CACHE_HOME") {
54        return Ok(p.join("grabcli"));
55    }
56    Ok(home()?.join(".cache").join("grabcli"))
57}
58
59/// `$GRABCLI_SESSION` or `<config dir>/session.json`.
60pub fn session_path() -> Result<PathBuf> {
61    if let Some(p) = env_path("GRABCLI_SESSION") {
62        return Ok(p);
63    }
64    Ok(config_dir()?.join("session.json"))
65}
66
67/// `$GRABCLI_CONFIG` or `<config dir>/config.toml`.
68pub fn config_path() -> Result<PathBuf> {
69    if let Some(p) = env_path("GRABCLI_CONFIG") {
70        return Ok(p);
71    }
72    Ok(config_dir()?.join("config.toml"))
73}
74
75/// Create `dir` (and parents) with mode 0700 on the leaf.
76pub fn ensure_private_dir(dir: &Path) -> Result<()> {
77    if !dir.exists() {
78        fs::create_dir_all(dir)?;
79        #[cfg(unix)]
80        {
81            use std::os::unix::fs::PermissionsExt;
82            fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
83        }
84    }
85    Ok(())
86}
87
88fn tmp_path(path: &Path) -> PathBuf {
89    let name = path
90        .file_name()
91        .map(|n| n.to_string_lossy().into_owned())
92        .unwrap_or_default();
93    path.with_file_name(format!(".{name}.tmp.{}", std::process::id()))
94}
95
96/// Atomically write a secret file: parent dir 0700, file 0600 from the moment
97/// it is created, then renamed over `path`.
98pub fn write_private(path: &Path, data: &[u8]) -> Result<()> {
99    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
100        ensure_private_dir(parent)?;
101    }
102    let tmp = tmp_path(path);
103    {
104        let mut opts = fs::OpenOptions::new();
105        opts.write(true).create(true).truncate(true);
106        #[cfg(unix)]
107        {
108            use std::os::unix::fs::OpenOptionsExt;
109            opts.mode(0o600);
110        }
111        let mut f = opts.open(&tmp)?;
112        f.write_all(data)?;
113        f.sync_all()?;
114    }
115    fs::rename(&tmp, path)?;
116    Ok(())
117}
118
119/// Atomically write a non-secret file (tmp + rename, default permissions).
120pub fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
121    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
122        fs::create_dir_all(parent)?;
123    }
124    let tmp = tmp_path(path);
125    {
126        let mut f = fs::File::create(&tmp)?;
127        f.write_all(data)?;
128        f.sync_all()?;
129    }
130    fs::rename(&tmp, path)?;
131    Ok(())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    #[cfg(unix)]
140    fn private_write_is_0600() -> Result<()> {
141        use std::os::unix::fs::PermissionsExt;
142        let dir = tempfile::tempdir()?;
143        let p = dir.path().join("sub").join("s.json");
144        write_private(&p, b"{}")?;
145        assert_eq!(fs::metadata(&p)?.permissions().mode() & 0o777, 0o600);
146        assert_eq!(
147            fs::metadata(p.parent().ok_or_else(|| Error::invalid("no parent"))?)?
148                .permissions()
149                .mode()
150                & 0o777,
151            0o700
152        );
153        assert_eq!(fs::read(&p)?, b"{}");
154        Ok(())
155    }
156}