use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process;
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(key: Option<&str>) -> io::Result<Option<StoredToken>> {
token_path(key).map_or(Ok(None), |path| load_from(&path))
}
pub fn save(key: Option<&str>, token: &StoredToken) -> io::Result<()> {
let path =
token_path(key).ok_or_else(|| io::Error::other("no per-user config directory found"))?;
save_at(&path, token)
}
pub fn delete(key: Option<&str>) -> io::Result<()> {
token_path(key).map_or(Ok(()), |path| delete_at(&path))
}
fn token_file(key: Option<&str>) -> String {
key.map_or_else(
|| TOKEN_FILE.to_owned(),
|key| format!("oauth-{}.json", slug(key)),
)
}
fn slug(key: &str) -> String {
let mut out = String::with_capacity(key.len());
for c in key.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
out.trim_matches('-').to_owned()
}
fn token_path(key: Option<&str>) -> Option<PathBuf> {
config_dir().map(|dir| dir.join(CONFIG_SUBDIR).join(token_file(key)))
}
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<()> {
let temporary = temporary_beside(path);
write_new_private(&temporary, contents)?;
match fs::rename(&temporary, path) {
Ok(()) => Ok(()),
Err(err) => {
let _ = fs::remove_file(&temporary);
Err(err)
}
}
}
fn temporary_beside(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".{}.tmp", process::id()));
path.with_file_name(name)
}
fn write_new_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 default_platform_keeps_the_original_file_name() {
assert_eq!(token_file(None), "oauth.json");
}
#[test]
fn each_platform_gets_its_own_file() {
assert_eq!(
token_file(Some("localhost:8001")),
"oauth-localhost-8001.json"
);
assert_ne!(token_file(Some("localhost:8001")), token_file(None));
}
#[test]
fn slug_reduces_anything_unsafe_for_a_file_name() {
assert_eq!(slug("App.Fluidattacks.COM"), "app-fluidattacks-com");
assert_eq!(slug("https://x/../y"), "https-x-y");
}
#[test]
fn saving_replaces_the_file_rather_than_truncating_it() {
let dir = tempdir().unwrap();
let path = dir.path().join("oauth.json");
save_at(&path, &sample()).unwrap();
let mut replacement = sample();
replacement.access_token = "second".to_owned();
save_at(&path, &replacement).unwrap();
assert_eq!(load_from(&path).unwrap(), Some(replacement));
let leftovers: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(leftovers.is_empty());
}
#[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));
}
}