1use std::collections::HashMap;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{AppError, Result};
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16pub struct Auth(pub HashMap<String, ProviderAuth>);
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum ProviderAuth {
21 ApiKey { key: String },
22}
23
24impl Auth {
25 pub fn load(agent_dir: &Path) -> Result<Self> {
26 load(agent_dir)
27 }
28
29 pub fn api_key(&self, provider: &str) -> Option<&str> {
32 match self.0.get(provider)? {
33 ProviderAuth::ApiKey { key } => Some(key.as_str()),
34 }
35 }
36}
37
38pub fn load(agent_dir: &Path) -> Result<Auth> {
40 let path = agent_dir.join("auth.json");
41 load_from(&path)
42}
43
44pub fn load_from(path: &Path) -> Result<Auth> {
46 if !path.exists() {
47 return Ok(Auth::default());
48 }
49 let raw = std::fs::read_to_string(path)
50 .map_err(|err| AppError::Config(format!("failed to read {}: {err}", path.display())))?;
51 serde_json::from_str(&raw)
52 .map_err(|err| AppError::Config(format!("failed to parse {}: {err}", path.display())))
53}
54
55pub fn save_with_mode(path: &Path, auth: &Auth) -> Result<()> {
58 if let Some(parent) = path.parent() {
59 std::fs::create_dir_all(parent).map_err(|err| {
60 AppError::Config(format!("failed to mkdir {}: {err}", parent.display()))
61 })?;
62 }
63 let json = serde_json::to_string_pretty(auth)
64 .map_err(|err| AppError::Config(format!("failed to serialize auth: {err}")))?;
65 write_secret_file(path, &json)?;
66
67 #[cfg(unix)]
68 {
69 use std::os::unix::fs::PermissionsExt;
70 let perms = std::fs::Permissions::from_mode(0o600);
71 std::fs::set_permissions(path, perms).map_err(|err| {
72 AppError::Config(format!("failed to chmod {}: {err}", path.display()))
73 })?;
74 }
75 Ok(())
76}
77
78#[cfg(unix)]
79fn write_secret_file(path: &Path, json: &str) -> Result<()> {
80 use std::io::Write;
81 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
82
83 if path.exists() {
84 let perms = std::fs::Permissions::from_mode(0o600);
85 std::fs::set_permissions(path, perms).map_err(|err| {
86 AppError::Config(format!("failed to chmod {}: {err}", path.display()))
87 })?;
88 }
89 let mut file = std::fs::OpenOptions::new()
90 .write(true)
91 .create(true)
92 .truncate(true)
93 .mode(0o600)
94 .open(path)
95 .map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))?;
96 file.write_all(json.as_bytes())
97 .map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))
98}
99
100#[cfg(not(unix))]
101fn write_secret_file(path: &Path, json: &str) -> Result<()> {
102 std::fs::write(path, json)
103 .map_err(|err| AppError::Config(format!("failed to write {}: {err}", path.display())))
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use tempfile::TempDir;
110
111 fn temp_dir() -> TempDir {
112 match tempfile::tempdir() {
113 Ok(dir) => dir,
114 Err(err) => panic!("tempdir failed: {err}"),
115 }
116 }
117
118 #[test]
119 fn missing_auth_file_returns_default() {
120 let dir = temp_dir();
121 let a = match Auth::load(dir.path()) {
122 Ok(auth) => auth,
123 Err(err) => panic!("load failed: {err}"),
124 };
125 assert!(a.0.is_empty());
126 assert!(a.api_key("anthropic").is_none());
127 }
128
129 #[test]
130 fn round_trip_api_key() {
131 let dir = temp_dir();
132 let path = dir.path().join("auth.json");
133 let mut a = Auth::default();
134 a.0.insert(
135 "anthropic".into(),
136 ProviderAuth::ApiKey {
137 key: "sk-ant-test".into(),
138 },
139 );
140 if let Err(err) = save_with_mode(&path, &a) {
141 panic!("save failed: {err}");
142 }
143
144 let loaded = match load_from(&path) {
145 Ok(auth) => auth,
146 Err(err) => panic!("load_from failed: {err}"),
147 };
148 assert_eq!(loaded.api_key("anthropic"), Some("sk-ant-test"));
149 }
150
151 #[test]
152 #[cfg(unix)]
153 fn save_with_mode_tightens_existing_file_before_write_on_unix() {
154 use std::os::unix::fs::PermissionsExt;
155 let dir = temp_dir();
156 let path = dir.path().join("auth.json");
157 if let Err(err) = std::fs::write(&path, "old") {
158 panic!("seed failed: {err}");
159 }
160 if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) {
161 panic!("chmod seed failed: {err}");
162 }
163
164 if let Err(err) = save_with_mode(&path, &Auth::default()) {
165 panic!("save failed: {err}");
166 }
167
168 let mode = match std::fs::metadata(&path) {
169 Ok(metadata) => metadata.permissions().mode(),
170 Err(err) => panic!("metadata failed: {err}"),
171 };
172 assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
173 }
174
175 #[test]
176 #[cfg(unix)]
177 fn save_with_mode_sets_0600_on_unix() {
178 use std::os::unix::fs::PermissionsExt;
179 let dir = temp_dir();
180 let path = dir.path().join("auth.json");
181 let a = Auth::default();
182 if let Err(err) = save_with_mode(&path, &a) {
183 panic!("save failed: {err}");
184 }
185
186 let mode = match std::fs::metadata(&path) {
187 Ok(metadata) => metadata.permissions().mode(),
188 Err(err) => panic!("metadata failed: {err}"),
189 };
190 assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
192 }
193}