Skip to main content

synaps_cli/core/auth/
storage.rs

1use std::path::PathBuf;
2
3use super::{AuthFile, OAuthCredentials};
4
5/// Get the path to auth.json (~/.synaps-cli/auth.json).
6pub fn auth_file_path() -> PathBuf {
7    crate::config::resolve_read_path("auth.json")
8}
9
10/// Load credentials from auth.json.
11pub fn load_auth() -> std::result::Result<Option<AuthFile>, String> {
12    let path = auth_file_path();
13    if !path.exists() {
14        return Ok(None);
15    }
16    let content = std::fs::read_to_string(&path)
17        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
18    let auth: AuthFile = serde_json::from_str(&content)
19        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
20    Ok(Some(auth))
21}
22
23/// Load one provider's OAuth credential from auth.json.
24pub fn load_provider_auth(provider: &str) -> std::result::Result<Option<OAuthCredentials>, String> {
25    let path = auth_file_path();
26    if !path.exists() {
27        return Ok(None);
28    }
29    let content = std::fs::read_to_string(&path)
30        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
31    let value: serde_json::Value = serde_json::from_str(&content)
32        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
33    let Some(raw) = value.get(provider) else {
34        return Ok(None);
35    };
36    let creds: OAuthCredentials = serde_json::from_value(raw.clone())
37        .map_err(|e| format!("Failed to parse {} credential: {}", provider, e))?;
38    Ok(Some(creds))
39}
40
41/// Save credentials to auth.json.
42pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
43    save_provider_auth("anthropic", creds)
44}
45
46/// Save one provider credential while preserving other auth.json entries.
47pub fn save_provider_auth(provider: &str, creds: &OAuthCredentials) -> std::result::Result<(), String> {
48    let path = crate::config::resolve_write_path("auth.json");
49    save_provider_auth_at(&path, provider, creds)
50}
51
52/// Path-explicit variant of `save_provider_auth`. Splits out the I/O so
53/// the corrupt-file fallback path can be unit-tested without touching the
54/// user's real `~/.synaps-cli/auth.json`.
55fn save_provider_auth_at(
56    path: &std::path::Path,
57    provider: &str,
58    creds: &OAuthCredentials,
59) -> std::result::Result<(), String> {
60    use fs4::fs_std::FileExt;
61
62    // Ensure parent directory exists
63    if let Some(parent) = path.parent() {
64        std::fs::create_dir_all(parent)
65            .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
66    }
67
68    // Hold an exclusive lock for the entire read-modify-write cycle.
69    // Without this, two concurrent `synaps login` processes can race:
70    // both read the same file, each adds their provider, second write
71    // silently drops the first's credential.
72    let lock_path = path.with_extension("json.lock");
73    let lock_file = std::fs::OpenOptions::new()
74        .create(true)
75        .truncate(true)
76        .write(true)
77        .open(&lock_path)
78        .map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
79    FileExt::lock_exclusive(&lock_file)
80        .map_err(|e| format!("Failed to lock {}: {}", lock_path.display(), e))?;
81
82    let mut root = if path.exists() {
83        let content = std::fs::read_to_string(path)
84            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
85        // Corrupt-file recovery: if the existing auth.json is not a JSON
86        // object (truncated write, manual edit error, swap-file detritus),
87        // log a warning and start fresh rather than refusing to save the
88        // new credential. The alternative is permanently locking the user
89        // out of `synaps login`.
90        match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content) {
91            Ok(map) => map,
92            Err(e) => {
93                tracing::warn!(
94                    path = %path.display(),
95                    error = %e,
96                    "auth.json could not be parsed as a JSON object; replacing with a fresh structure"
97                );
98                // Back up the corrupt file so credentials are potentially recoverable.
99                let ts = std::time::SystemTime::now()
100                    .duration_since(std::time::UNIX_EPOCH)
101                    .map(|d| d.as_secs())
102                    .unwrap_or(0);
103                let backup = path.with_extension(format!("json.corrupt.{}", ts));
104                match std::fs::copy(path, &backup) {
105                    Ok(_) => {
106                        eprintln!(
107                            "[warn] auth.json was corrupt and has been reset. Backup saved to: {}",
108                            backup.display()
109                        );
110                    }
111                    Err(copy_err) => {
112                        eprintln!(
113                            "[warn] auth.json was corrupt and has been reset, but backup failed: {}",
114                            copy_err
115                        );
116                    }
117                }
118                serde_json::Map::new()
119            }
120        }
121    } else {
122        serde_json::Map::new()
123    };
124
125    root.insert(
126        provider.to_string(),
127        serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?,
128    );
129
130    let json = serde_json::to_string_pretty(&root)
131        .map_err(|e| format!("Failed to serialize auth: {}", e))?;
132
133    // Atomic write: write to .tmp then rename. rename(2) is atomic on POSIX.
134    // This prevents a crash/kill between truncate and write from zeroing auth.json.
135    // Create with restrictive permissions from the start so credentials are never
136    // world-readable, even briefly.
137    let tmp_path = path.with_extension("json.tmp");
138    {
139        use std::io::Write;
140        let mut file = std::fs::OpenOptions::new()
141            .write(true)
142            .create(true)
143            .truncate(true)
144            .open(&tmp_path)
145            .map_err(|e| format!("Failed to create {}: {}", tmp_path.display(), e))?;
146
147        #[cfg(unix)]
148        {
149            use std::os::unix::fs::PermissionsExt;
150            let perms = std::fs::Permissions::from_mode(0o600);
151            file.set_permissions(perms)
152                .map_err(|e| format!("Failed to set permissions on {}: {}", tmp_path.display(), e))?;
153        }
154
155        file.write_all(json.as_bytes())
156            .map_err(|e| format!("Failed to write {}: {}", tmp_path.display(), e))?;
157        file.sync_all()
158            .map_err(|e| format!("Failed to fsync {}: {}", tmp_path.display(), e))?;
159    }
160
161    std::fs::rename(&tmp_path, path)
162        .map_err(|e| format!("Failed to atomically replace {}: {}", path.display(), e))?;
163
164    Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn fresh_creds() -> OAuthCredentials {
172        OAuthCredentials {
173            auth_type: "oauth".to_string(),
174            refresh: "r".to_string(),
175            access: "a".to_string(),
176            expires: 1,
177            account_id: None,
178        }
179    }
180
181    #[test]
182    fn save_provider_auth_at_creates_file_when_absent() {
183        let dir = tempfile::tempdir().expect("tempdir");
184        let path = dir.path().join("auth.json");
185        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
186        assert!(path.exists());
187        let content = std::fs::read_to_string(&path).unwrap();
188        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
189        assert!(parsed.get("openai-codex").is_some());
190    }
191
192    #[test]
193    fn save_provider_auth_at_preserves_other_providers() {
194        let dir = tempfile::tempdir().expect("tempdir");
195        let path = dir.path().join("auth.json");
196        std::fs::write(
197            &path,
198            r#"{"anthropic":{"type":"oauth","refresh":"r2","access":"a2","expires":2}}"#,
199        )
200        .unwrap();
201        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
202        let content = std::fs::read_to_string(&path).unwrap();
203        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
204        assert!(parsed.get("anthropic").is_some(), "must keep anthropic entry");
205        assert!(parsed.get("openai-codex").is_some());
206    }
207
208    #[test]
209    fn save_provider_auth_at_recovers_from_corrupt_file() {
210        // Pre-fix: a corrupt auth.json would lock the user out of
211        // `synaps login` entirely because save_provider_auth would fail
212        // to parse and bail. After fix: corrupt content is replaced with
213        // a fresh structure containing the new credential.
214        let dir = tempfile::tempdir().expect("tempdir");
215        let path = dir.path().join("auth.json");
216        std::fs::write(&path, "this is not json {{{").unwrap();
217        save_provider_auth_at(&path, "openai-codex", &fresh_creds())
218            .expect("save must succeed even on corrupt input");
219        let content = std::fs::read_to_string(&path).unwrap();
220        let parsed: serde_json::Value = serde_json::from_str(&content)
221            .expect("file must now contain valid JSON");
222        assert!(parsed.get("openai-codex").is_some());
223        assert!(
224            parsed.get("anthropic").is_none(),
225            "corrupt fallback discards old (unrecoverable) entries"
226        );
227    }
228
229    #[test]
230    fn save_provider_auth_at_recovers_from_array_root() {
231        // auth.json was a JSON array (perhaps from a botched migration).
232        // Treat it as corrupt — same recovery as garbage input.
233        let dir = tempfile::tempdir().expect("tempdir");
234        let path = dir.path().join("auth.json");
235        std::fs::write(&path, "[1, 2, 3]").unwrap();
236        save_provider_auth_at(&path, "openai-codex", &fresh_creds())
237            .expect("save must succeed against non-object root");
238        let parsed: serde_json::Value =
239            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
240        assert!(parsed.is_object());
241        assert!(parsed.get("openai-codex").is_some());
242    }
243}