use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Auth(pub HashMap<String, ProviderAuth>);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProviderAuth {
ApiKey { key: String },
}
impl Auth {
pub fn load(agent_dir: &Path) -> Result<Self> {
load(agent_dir)
}
pub fn api_key(&self, provider: &str) -> Option<&str> {
match self.0.get(provider)? {
ProviderAuth::ApiKey { key } => Some(key.as_str()),
}
}
}
pub fn load(agent_dir: &Path) -> Result<Auth> {
let path = agent_dir.join("auth.json");
load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Auth> {
if !path.exists() {
return Ok(Auth::default());
}
let raw = std::fs::read_to_string(path)
.map_err(|err| AppError::Config(format!("failed to read {}: {err}", path.display())))?;
serde_json::from_str(&raw)
.map_err(|err| AppError::Config(format!("failed to parse {}: {err}", path.display())))
}
pub fn save_with_mode(path: &Path, auth: &Auth) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
AppError::Config(format!("failed to mkdir {}: {err}", parent.display()))
})?;
}
let json = serde_json::to_string_pretty(auth)
.map_err(|err| AppError::Config(format!("failed to serialize auth: {err}")))?;
write_secret_file(path, &json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms).map_err(|err| {
AppError::Config(format!("failed to chmod {}: {err}", path.display()))
})?;
}
Ok(())
}
#[cfg(unix)]
fn write_secret_file(path: &Path, json: &str) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
if path.exists() {
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms).map_err(|err| {
AppError::Config(format!("failed to chmod {}: {err}", path.display()))
})?;
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))?;
file.write_all(json.as_bytes())
.map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))
}
#[cfg(not(unix))]
fn write_secret_file(path: &Path, json: &str) -> Result<()> {
std::fs::write(path, json)
.map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_dir() -> TempDir {
match tempfile::tempdir() {
Ok(dir) => dir,
Err(err) => panic!("tempdir failed: {err}"),
}
}
#[test]
fn missing_auth_file_returns_default() {
let dir = temp_dir();
let a = match Auth::load(dir.path()) {
Ok(auth) => auth,
Err(err) => panic!("load failed: {err}"),
};
assert!(a.0.is_empty());
assert!(a.api_key("anthropic").is_none());
}
#[test]
fn round_trip_api_key() {
let dir = temp_dir();
let path = dir.path().join("auth.json");
let mut a = Auth::default();
a.0.insert(
"anthropic".into(),
ProviderAuth::ApiKey {
key: "sk-ant-test".into(),
},
);
if let Err(err) = save_with_mode(&path, &a) {
panic!("save failed: {err}");
}
let loaded = match load_from(&path) {
Ok(auth) => auth,
Err(err) => panic!("load_from failed: {err}"),
};
assert_eq!(loaded.api_key("anthropic"), Some("sk-ant-test"));
}
#[test]
#[cfg(unix)]
fn save_with_mode_tightens_existing_file_before_write_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir();
let path = dir.path().join("auth.json");
if let Err(err) = std::fs::write(&path, "old") {
panic!("seed failed: {err}");
}
if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) {
panic!("chmod seed failed: {err}");
}
if let Err(err) = save_with_mode(&path, &Auth::default()) {
panic!("save failed: {err}");
}
let mode = match std::fs::metadata(&path) {
Ok(metadata) => metadata.permissions().mode(),
Err(err) => panic!("metadata failed: {err}"),
};
assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
}
#[test]
#[cfg(unix)]
fn save_with_mode_sets_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir();
let path = dir.path().join("auth.json");
let a = Auth::default();
if let Err(err) = save_with_mode(&path, &a) {
panic!("save failed: {err}");
}
let mode = match std::fs::metadata(&path) {
Ok(metadata) => metadata.permissions().mode(),
Err(err) => panic!("metadata failed: {err}"),
};
assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
}
}