Skip to main content

capo_agent/
auth.rs

1//! Credential storage (`~/.capo/agent/auth.json`).
2//!
3//! JSON map of provider name → credential. Separate file (mode `0600`
4//! on Unix) so credentials don't share fate with general settings.
5//!
6//! M3 includes the reader + a writer helper. M4 wires `capo auth login`.
7
8use 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    /// Look up an API key for `provider`. Returns `None` if not configured
30    /// or if the credential is of a different type.
31    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
38/// Load `auth.json` from the per-user agent directory.
39pub fn load(agent_dir: &Path) -> Result<Auth> {
40    let path = agent_dir.join("auth.json");
41    load_from(&path)
42}
43
44/// Load from an explicit path. Returns `Auth::default()` if the file is missing.
45pub 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
55/// Write `auth.json` with restrictive permissions (`0600` on Unix; default ACL on Windows).
56/// Used by M4's `capo auth login`; exposed in M3 so the mode-check test can exercise it.
57pub 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        // Extract the low 9 perm bits (drop the file-type bits).
191        assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
192    }
193}