use crate::Result;
#[cfg(unix)]
use crate::config::ensure_secret_file_permissions;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct CachedToken {
pub access_token: SecretString,
pub refresh_token: SecretString,
pub token_type: String,
pub device_token: String,
pub expires_at: Option<i64>,
}
#[derive(Serialize, Deserialize)]
struct CachedTokenOnDisk {
access_token: String,
refresh_token: String,
token_type: String,
device_token: String,
expires_at: Option<i64>,
}
impl From<&CachedToken> for CachedTokenOnDisk {
fn from(token: &CachedToken) -> Self {
Self {
access_token: token.access_token.expose_secret().to_owned(),
refresh_token: token.refresh_token.expose_secret().to_owned(),
token_type: token.token_type.clone(),
device_token: token.device_token.clone(),
expires_at: token.expires_at,
}
}
}
impl From<CachedTokenOnDisk> for CachedToken {
fn from(token: CachedTokenOnDisk) -> Self {
Self {
access_token: SecretString::from(token.access_token),
refresh_token: SecretString::from(token.refresh_token),
token_type: token.token_type,
device_token: token.device_token,
expires_at: token.expires_at,
}
}
}
impl std::fmt::Debug for CachedToken {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CachedToken")
.field("access_token", &"[REDACTED]")
.field("refresh_token", &"[REDACTED]")
.field("token_type", &self.token_type)
.field("device_token", &self.device_token)
.field("expires_at", &self.expires_at)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct TokenCache {
path: PathBuf,
}
impl TokenCache {
pub fn with_path(path: PathBuf) -> Self {
Self { path }
}
pub fn load(&self) -> Result<Option<CachedToken>> {
if !self.path.try_exists()? {
return Ok(None);
}
#[cfg(unix)]
ensure_secret_file_permissions(&self.path)?;
let data = std::fs::read_to_string(&self.path)?;
let on_disk: CachedTokenOnDisk = match serde_json::from_str(&data) {
Ok(v) => v,
Err(err) => {
tracing::warn!(
path = %self.path.display(),
%err,
"Token cache is corrupt or unrecognized so treating as absent; \
run `rhood login` to create a fresh session"
);
return Ok(None);
}
};
let token = CachedToken::from(on_disk);
if let Some(exp) = token.expires_at
&& chrono::Utc::now().timestamp() >= exp
{
self.clear()?;
return Ok(None);
}
Ok(Some(token))
}
pub fn save(&self, token: &CachedToken) -> Result<()> {
if let Some(parent) = self.path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let on_disk = CachedTokenOnDisk::from(token);
let data = serde_json::to_string_pretty(&on_disk)?;
Self::write_restricted(&self.path, data.as_bytes())?;
Ok(())
}
fn write_restricted(path: &Path, data: &[u8]) -> Result<()> {
use std::io::Write;
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut temporary = tempfile::Builder::new()
.prefix(".rhood-token-")
.tempfile_in(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(error) = temporary
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))
{
return Self::fail_and_cleanup(temporary, error);
}
}
if let Err(error) = temporary.write_all(data) {
return Self::fail_and_cleanup(temporary, error);
}
if let Err(error) = temporary.as_file().sync_all() {
return Self::fail_and_cleanup(temporary, error);
}
match temporary.persist(path) {
Ok(file) => drop(file),
Err(error) => return Self::fail_and_cleanup(error.file, error.error),
}
#[cfg(unix)]
std::fs::File::open(parent)?.sync_all()?;
Ok(())
}
fn fail_and_cleanup(
temporary: tempfile::NamedTempFile,
operation_error: std::io::Error,
) -> Result<()> {
if let Err(cleanup_error) = temporary.close() {
tracing::warn!(%cleanup_error, "Failed to clean up token-cache temporary file");
}
Err(operation_error.into())
}
pub fn clear(&self) -> Result<()> {
if self.path.exists() {
std::fs::remove_file(&self.path)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_token(access: &str, expires_at: Option<i64>) -> CachedToken {
CachedToken {
access_token: SecretString::from(access),
refresh_token: SecretString::from("ref"),
token_type: "Bearer".into(),
device_token: "dev".into(),
expires_at,
}
}
#[test]
fn round_trip_token_cache() {
let dir = tempfile::tempdir().unwrap();
let cache = TokenCache::with_path(dir.path().join("token.json"));
assert!(cache.load().unwrap().is_none());
let token = make_token("acc", Some(chrono::Utc::now().timestamp() + 3600));
cache.save(&token).unwrap();
let loaded = cache.load().unwrap().unwrap();
assert_eq!(loaded.access_token.expose_secret(), "acc");
cache.clear().unwrap();
assert!(cache.load().unwrap().is_none());
}
#[test]
fn save_atomically_replaces_existing_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
let cache = TokenCache::with_path(path);
cache.save(&make_token("old", None)).unwrap();
cache.save(&make_token("new", None)).unwrap();
let loaded = cache.load().unwrap().unwrap();
assert_eq!(loaded.access_token.expose_secret(), "new");
}
#[test]
fn failed_replacement_cleans_up_temporary_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
std::fs::create_dir(&path).unwrap();
std::fs::write(path.join("keep"), b"force replacement failure").unwrap();
let cache = TokenCache::with_path(path.clone());
assert!(cache.save(&make_token("new", None)).is_err());
let entries = std::fs::read_dir(dir.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
assert_eq!(entries, vec![path.file_name().unwrap()]);
}
#[test]
fn expired_token_returns_none() {
let dir = tempfile::tempdir().unwrap();
let cache = TokenCache::with_path(dir.path().join("token.json"));
let token = make_token("old", Some(0));
cache.save(&token).unwrap();
assert!(cache.load().unwrap().is_none());
}
#[test]
fn corrupt_cache_returns_none_instead_of_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
std::fs::write(&path, b"{\"not_a_token\": true}").unwrap();
#[cfg(unix)]
set_mode(&path, 0o600);
let cache = TokenCache::with_path(path);
let result = cache.load().unwrap();
assert!(
result.is_none(),
"expected None for corrupt cache, got Some"
);
}
#[test]
fn completely_invalid_json_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
std::fs::write(&path, b"this is not json at all!!!").unwrap();
#[cfg(unix)]
set_mode(&path, 0o600);
let cache = TokenCache::with_path(path);
assert!(cache.load().unwrap().is_none());
}
#[cfg(unix)]
fn set_mode(path: &std::path::Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
}
#[cfg(unix)]
#[test]
fn owner_only_token_file_loads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
let cache = TokenCache::with_path(path.clone());
let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
cache.save(&token).unwrap();
set_mode(&path, 0o600);
assert!(cache.load().unwrap().is_some());
}
#[cfg(unix)]
#[test]
fn group_readable_token_file_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
let cache = TokenCache::with_path(path.clone());
let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
cache.save(&token).unwrap();
set_mode(&path, 0o644);
let error = cache.load().unwrap_err();
assert!(error.to_string().contains(&path.display().to_string()));
}
#[cfg(unix)]
#[test]
fn save_tightens_existing_token_file_permissions() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
let cache = TokenCache::with_path(path.clone());
let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
cache.save(&token).unwrap();
set_mode(&path, 0o644);
cache.save(&token).unwrap();
let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "Token file should be owner-only (0600)");
}
#[cfg(unix)]
#[test]
fn failed_save_preserves_existing_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
let cache = TokenCache::with_path(path);
cache.save(&make_token("old", None)).unwrap();
set_mode(dir.path(), 0o500);
let result = cache.save(&make_token("new", None));
set_mode(dir.path(), 0o700);
assert!(result.is_err());
let loaded = cache.load().unwrap().unwrap();
assert_eq!(loaded.access_token.expose_secret(), "old");
}
#[cfg(unix)]
#[test]
fn token_file_has_restricted_permissions() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token.json");
let cache = TokenCache::with_path(path.clone());
let token = make_token("secret", Some(chrono::Utc::now().timestamp() + 3600));
cache.save(&token).unwrap();
let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "Token file should be owner-only (0600)");
}
}