use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::error::{Error, ErrorKind, Result};
use crate::oauth::TokenResponse;
pub trait TokenStorage: Send + Sync {
fn save(&self, key: &str, token: &TokenResponse) -> Result<()>;
fn load(&self, key: &str) -> Result<Option<TokenResponse>>;
fn delete(&self, key: &str) -> Result<()>;
fn exists(&self, key: &str) -> Result<bool>;
fn list(&self) -> Result<Vec<String>>;
}
#[derive(Debug, Clone)]
pub struct FileTokenStorage {
base_path: PathBuf,
}
impl FileTokenStorage {
pub fn new() -> Result<Self> {
let base_path = default_token_dir()?;
Ok(Self { base_path })
}
pub fn with_path(path: impl AsRef<Path>) -> Self {
Self {
base_path: path.as_ref().to_path_buf(),
}
}
fn token_path(&self, key: &str) -> PathBuf {
let safe_key = key
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
self.base_path.join(format!("{}.json", safe_key))
}
fn ensure_dir(&self) -> Result<()> {
if !self.base_path.exists() {
std::fs::create_dir_all(&self.base_path)?;
}
Ok(())
}
}
impl Default for FileTokenStorage {
fn default() -> Self {
Self::new().expect("Failed to create default token storage")
}
}
impl TokenStorage for FileTokenStorage {
fn save(&self, key: &str, token: &TokenResponse) -> Result<()> {
self.ensure_dir()?;
let path = self.token_path(key);
let stored = StoredToken {
token: token.clone(),
stored_at: chrono::Utc::now(),
};
let json = serde_json::to_string_pretty(&stored)?;
std::fs::write(&path, json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms)?;
}
Ok(())
}
fn load(&self, key: &str) -> Result<Option<TokenResponse>> {
let path = self.token_path(key);
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path)?;
let stored: StoredToken = serde_json::from_str(&json)?;
Ok(Some(stored.token))
}
fn delete(&self, key: &str) -> Result<()> {
let path = self.token_path(key);
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
fn exists(&self, key: &str) -> Result<bool> {
Ok(self.token_path(key).exists())
}
fn list(&self) -> Result<Vec<String>> {
if !self.base_path.exists() {
return Ok(Vec::new());
}
let mut keys = Vec::new();
for entry in std::fs::read_dir(&self.base_path)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Some(stem) = path.file_stem() {
keys.push(stem.to_string_lossy().to_string());
}
}
}
Ok(keys)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StoredToken {
token: TokenResponse,
stored_at: chrono::DateTime<chrono::Utc>,
}
pub fn default_token_dir() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| {
Error::new(ErrorKind::Config(
"Could not find home directory".to_string(),
))
})?;
Ok(home.join(".sf-api").join("tokens"))
}
#[allow(dead_code)]
pub fn default_token_path(key: &str) -> Result<PathBuf> {
let dir = default_token_dir()?;
Ok(dir.join(format!("{}.json", key)))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_token() -> TokenResponse {
TokenResponse {
access_token: "test_access".to_string(),
refresh_token: Some("test_refresh".to_string()),
instance_url: "https://test.salesforce.com".to_string(),
id: None,
token_type: Some("Bearer".to_string()),
scope: None,
signature: None,
issued_at: None,
}
}
#[test]
fn test_file_storage_save_load() {
let temp_dir = TempDir::new().unwrap();
let storage = FileTokenStorage::with_path(temp_dir.path());
let token = test_token();
storage.save("test_org", &token).unwrap();
let loaded = storage.load("test_org").unwrap().unwrap();
assert_eq!(loaded.access_token, "test_access");
assert_eq!(loaded.refresh_token, Some("test_refresh".to_string()));
}
#[test]
fn test_file_storage_exists() {
let temp_dir = TempDir::new().unwrap();
let storage = FileTokenStorage::with_path(temp_dir.path());
assert!(!storage.exists("missing").unwrap());
storage.save("exists", &test_token()).unwrap();
assert!(storage.exists("exists").unwrap());
}
#[test]
fn test_file_storage_delete() {
let temp_dir = TempDir::new().unwrap();
let storage = FileTokenStorage::with_path(temp_dir.path());
storage.save("to_delete", &test_token()).unwrap();
assert!(storage.exists("to_delete").unwrap());
storage.delete("to_delete").unwrap();
assert!(!storage.exists("to_delete").unwrap());
}
#[test]
fn test_file_storage_list() {
let temp_dir = TempDir::new().unwrap();
let storage = FileTokenStorage::with_path(temp_dir.path());
storage.save("org1", &test_token()).unwrap();
storage.save("org2", &test_token()).unwrap();
let keys = storage.list().unwrap();
assert_eq!(keys.len(), 2);
assert!(keys.contains(&"org1".to_string()));
assert!(keys.contains(&"org2".to_string()));
}
#[test]
fn test_key_sanitization() {
let temp_dir = TempDir::new().unwrap();
let storage = FileTokenStorage::with_path(temp_dir.path());
storage.save("user@example.com", &test_token()).unwrap();
let path = storage.token_path("user@example.com");
assert!(path
.file_name()
.unwrap()
.to_str()
.unwrap()
.contains("user_example_com"));
}
}