Skip to main content

ballistics_engine/
credentials.rs

1//! CLI credential store for the online API Personal Access Token (PAT).
2//!
3//! Resolution precedence: the `BALLISTICS_API_TOKEN` environment variable takes precedence over
4//! the credentials file at `~/.ballistics/credentials.toml` (mirroring the ToS-acceptance file
5//! location). The file is written `0600` on Unix and never echoed after saving.
6
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11const ENV_VAR: &str = "BALLISTICS_API_TOKEN";
12
13/// Path to the credentials file (`~/.ballistics/credentials.toml`).
14pub fn credentials_path() -> Option<PathBuf> {
15    dirs::home_dir().map(|home| home.join(".ballistics").join("credentials.toml"))
16}
17
18fn save_token_at(path: &Path, token: &str) -> io::Result<()> {
19    if let Some(dir) = path.parent() {
20        fs::create_dir_all(dir)?;
21    }
22    fs::write(path, format!("token = \"{}\"\n", token.trim()))?;
23    #[cfg(unix)]
24    {
25        use std::os::unix::fs::PermissionsExt;
26        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
27    }
28    Ok(())
29}
30
31fn load_token_from(path: &Path) -> Option<String> {
32    let content = fs::read_to_string(path).ok()?;
33    for line in content.lines() {
34        let line = line.trim();
35        if let Some(rest) = line.strip_prefix("token") {
36            let rest = rest.trim_start().strip_prefix('=')?.trim();
37            let val = rest.trim_matches('"');
38            if !val.is_empty() {
39                return Some(val.to_string());
40            }
41        }
42    }
43    None
44}
45
46/// Save the token to the credentials file (`0600` on Unix), creating the directory as needed.
47pub fn save_token(token: &str) -> io::Result<()> {
48    let path = credentials_path()
49        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no home directory"))?;
50    save_token_at(&path, token)
51}
52
53/// Resolve the token: `BALLISTICS_API_TOKEN` env var first, then the credentials file.
54pub fn load_token() -> Option<String> {
55    if let Ok(t) = std::env::var(ENV_VAR) {
56        let t = t.trim().to_string();
57        if !t.is_empty() {
58            return Some(t);
59        }
60    }
61    load_token_from(&credentials_path()?)
62}
63
64/// Remove the credentials file if present.
65pub fn clear_token() -> io::Result<()> {
66    if let Some(path) = credentials_path() {
67        if path.exists() {
68            fs::remove_file(path)?;
69        }
70    }
71    Ok(())
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn temp_path(tag: &str) -> PathBuf {
79        std::env::temp_dir()
80            .join(format!("ballistics-cred-{}-{}", tag, std::process::id()))
81            .join("credentials.toml")
82    }
83
84    #[test]
85    fn save_and_load_roundtrip() {
86        let path = temp_path("rt");
87        let _ = fs::remove_dir_all(path.parent().unwrap());
88        save_token_at(&path, "bpat_abc123").unwrap();
89        assert_eq!(load_token_from(&path).as_deref(), Some("bpat_abc123"));
90        let _ = fs::remove_dir_all(path.parent().unwrap());
91    }
92
93    #[test]
94    fn load_from_missing_file_is_none() {
95        let path = temp_path("missing");
96        let _ = fs::remove_dir_all(path.parent().unwrap());
97        assert!(load_token_from(&path).is_none());
98    }
99
100    #[test]
101    fn env_var_takes_precedence_over_file() {
102        std::env::set_var(ENV_VAR, "bpat_env");
103        assert_eq!(load_token().as_deref(), Some("bpat_env"));
104        std::env::remove_var(ENV_VAR);
105    }
106
107    #[cfg(unix)]
108    #[test]
109    fn saved_file_is_0600() {
110        use std::os::unix::fs::PermissionsExt;
111        let path = temp_path("perm");
112        let _ = fs::remove_dir_all(path.parent().unwrap());
113        save_token_at(&path, "bpat_x").unwrap();
114        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
115        assert_eq!(mode, 0o600);
116        let _ = fs::remove_dir_all(path.parent().unwrap());
117    }
118}