use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const CONFIG_SUBDIR: &str = "fluidattacks";
const TOKEN_FILE: &str = "oauth.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredToken {
pub access_token: String,
pub refresh_token: String,
pub expires_at: u64,
pub email: String,
}
impl StoredToken {
#[must_use]
pub const fn is_access_expired(&self, now: u64, skew: u64) -> bool {
now.saturating_add(skew) >= self.expires_at
}
}
pub fn load() -> io::Result<Option<StoredToken>> {
token_path().map_or(Ok(None), |path| load_from(&path))
}
pub fn save(token: &StoredToken) -> io::Result<()> {
let path =
token_path().ok_or_else(|| io::Error::other("no per-user config directory found"))?;
save_at(&path, token)
}
pub fn delete() -> io::Result<()> {
token_path().map_or(Ok(()), |path| delete_at(&path))
}
fn token_path() -> Option<PathBuf> {
config_dir().map(|dir| dir.join(CONFIG_SUBDIR).join(TOKEN_FILE))
}
fn config_dir() -> Option<PathBuf> {
if let Some(base) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(base));
}
let home = PathBuf::from(env::var_os("HOME").filter(|value| !value.is_empty())?);
Some(if cfg!(target_os = "macos") {
home.join("Library").join("Application Support")
} else {
home.join(".config")
})
}
fn load_from(path: &Path) -> io::Result<Option<StoredToken>> {
let raw = match fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
match serde_json::from_str(&raw) {
Ok(token) => Ok(Some(token)),
Err(err) => {
tracing::warn!(error = %err, "ignoring unreadable OAuth token store");
Ok(None)
}
}
}
fn save_at(path: &Path, token: &StoredToken) -> io::Result<()> {
if let Some(parent) = path.parent() {
create_private_dir(parent)?;
}
let payload = serde_json::to_string(token).map_err(io::Error::other)?;
write_private(path, &payload)
}
fn delete_at(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn create_private_dir(dir: &Path) -> io::Result<()> {
fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn write_private(path: &Path, contents: &str) -> io::Result<()> {
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
fs::write(path, contents)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample() -> StoredToken {
StoredToken {
access_token: "acc".to_owned(),
refresh_token: "ref".to_owned(),
expires_at: 1000,
email: "u@fluidattacks.com".to_owned(),
}
}
#[test]
fn missing_file_loads_as_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("oauth.json");
assert_eq!(load_from(&path).unwrap(), None);
}
#[test]
fn save_then_load_round_trips() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("oauth.json");
save_at(&path, &sample()).unwrap();
assert_eq!(load_from(&path).unwrap(), Some(sample()));
}
#[test]
fn corrupt_file_loads_as_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("oauth.json");
fs::write(&path, "{ not json").unwrap();
assert_eq!(load_from(&path).unwrap(), None);
}
#[test]
fn delete_is_idempotent() {
let dir = tempdir().unwrap();
let path = dir.path().join("oauth.json");
save_at(&path, &sample()).unwrap();
delete_at(&path).unwrap();
delete_at(&path).unwrap();
assert_eq!(load_from(&path).unwrap(), None);
}
#[cfg(unix)]
#[test]
fn saved_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let path = dir.path().join("oauth.json");
save_at(&path, &sample()).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn expiry_accounts_for_skew() {
let token = sample();
assert!(!token.is_access_expired(900, 30));
assert!(token.is_access_expired(980, 30));
assert!(token.is_access_expired(1000, 0));
}
}