Skip to main content

agent_core/core/auth/
storage.rs

1use std::path::{Path, PathBuf};
2
3use super::account::{Account, AccountSummary, CredentialRef};
4use super::provider::OAuthProviderId;
5use super::{AuthFile, OAuthCredentials};
6
7/// Get the path to auth.json (~/.synaps-cli/auth.json).
8pub fn auth_file_path() -> PathBuf {
9    crate::config::resolve_read_path("auth.json")
10}
11
12/// Load credentials from auth.json.
13pub fn load_auth() -> std::result::Result<Option<AuthFile>, String> {
14    let path = auth_file_path();
15    if !path.exists() {
16        return Ok(None);
17    }
18    let content = std::fs::read_to_string(&path)
19        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
20    let auth: AuthFile = serde_json::from_str(&content)
21        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
22    Ok(Some(auth))
23}
24
25/// Load one provider's OAuth credential from auth.json.
26///
27/// `provider` is a storage key: the bare provider id for the default account
28/// or `<provider>@<label>` for a named account (see [`CredentialRef`]).
29pub fn load_provider_auth(provider: &str) -> std::result::Result<Option<OAuthCredentials>, String> {
30    load_provider_auth_at(&auth_file_path(), provider)
31}
32
33pub(crate) fn load_provider_auth_at(
34    path: &Path,
35    provider: &str,
36) -> std::result::Result<Option<OAuthCredentials>, String> {
37    load_provider_auth_typed_at(path, provider).map_err(String::from)
38}
39
40/// [`load_provider_auth_at`] that reports an unparseable store as
41/// [`StoreError::Corrupt`].
42pub(crate) fn load_provider_auth_typed_at(
43    path: &Path,
44    provider: &str,
45) -> std::result::Result<Option<OAuthCredentials>, StoreError> {
46    if !path.exists() {
47        return Ok(None);
48    }
49    let content = std::fs::read_to_string(path)
50        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
51    let value: serde_json::Value =
52        serde_json::from_str(&content).map_err(|e| corrupt_store(path, &e))?;
53    let Some(raw) = value.get(provider) else {
54        return Ok(None);
55    };
56    let creds: OAuthCredentials = serde_json::from_value(raw.clone())
57        .map_err(|e| format!("Failed to parse {} credential: {}", provider, e))?;
58    Ok(Some(creds))
59}
60
61// ── Account-addressed API (additive; bare keys are the `default` account) ────
62
63/// Load the credential stored in one account slot.
64pub fn load_credential(
65    cred: &CredentialRef,
66) -> std::result::Result<Option<OAuthCredentials>, String> {
67    load_provider_auth(&cred.storage_key())
68}
69
70/// Persist a credential into exactly one account slot. Other slots (including
71/// the provider's default slot) and any non-secret metadata already stored in
72/// this slot are preserved. A corrupt store is an error — the account path
73/// never resets the file (that would wipe every other slot); the legacy
74/// single-credential [`save_provider_auth`] resets only an empty file.
75pub fn save_credential(
76    cred: &CredentialRef,
77    creds: &OAuthCredentials,
78) -> std::result::Result<(), String> {
79    let path = crate::config::resolve_write_path("auth.json");
80    save_credential_at(&path, cred, creds)
81}
82
83pub(crate) fn save_credential_at(
84    path: &Path,
85    cred: &CredentialRef,
86    creds: &OAuthCredentials,
87) -> std::result::Result<(), String> {
88    let encoded =
89        serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
90    let fields = encoded
91        .as_object()
92        .cloned()
93        .ok_or_else(|| "credential did not serialize to an object".to_string())?;
94    let key = cred.storage_key();
95    with_locked_root(path, false, |root| {
96        let mut slot = root
97            .remove(&key)
98            .filter(serde_json::Value::is_object)
99            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
100        if let Some(target) = slot.as_object_mut() {
101            for (k, v) in &fields {
102                target.insert(k.clone(), v.clone());
103            }
104        }
105        root.insert(key.clone(), slot);
106        Ok((true, ()))
107    })
108}
109
110/// Remove one account slot. Returns `true` if a key was removed. Never leaves
111/// a partial file and never touches any other key. A corrupt file is an error
112/// (nothing is reset on a removal path).
113///
114/// Profiles: listings and loads read through [`auth_file_path`], which falls
115/// back to the base `auth.json` when the active profile has none. Removal
116/// refuses to act on such an *inherited* file rather than silently
117/// "succeeding" against an empty profile file or deleting from the base
118/// store on the profile's behalf.
119pub fn remove_credential(cred: &CredentialRef) -> std::result::Result<bool, String> {
120    let read_path = auth_file_path();
121    let write_path = crate::config::resolve_write_path("auth.json");
122    if read_path != write_path {
123        return Err(format!(
124            "{} is stored in {} which the active profile inherits; re-run without --profile \
125             (or with the profile that owns that file) to remove it",
126            cred,
127            read_path.display()
128        ));
129    }
130    remove_key_at(&write_path, &cred.storage_key())
131}
132
133pub(crate) fn remove_key_at(path: &Path, key: &str) -> std::result::Result<bool, String> {
134    // NOTE: the per-credential refresh lock file (`<auth>.<key>.refresh.lock`)
135    // is deliberately NOT unlinked here: unlinking a lock file another
136    // process may hold defeats flock (a re-created file is a new inode).
137    // The parked-refresh sidecar IS removed: it is a regular file holding a
138    // live refresh token for the account being removed.
139    let removed = if path.exists() {
140        with_locked_root(path, false, |root| {
141            let removed = root.remove(key).is_some();
142            Ok((removed, removed))
143        })?
144    } else {
145        false
146    };
147    let _ = std::fs::remove_file(refresh_pending_path(path, key));
148    Ok(removed)
149}
150
151/// `<auth>.<key>.refresh.pending`: a rotation whose save failed, parked
152/// beside the account's refresh lock (see `token.rs`).
153pub(crate) fn refresh_pending_path(auth_path: &Path, key: &str) -> PathBuf {
154    let file_name = auth_path
155        .file_name()
156        .and_then(|n| n.to_str())
157        .unwrap_or("auth.json");
158    auth_path.with_file_name(format!("{file_name}.{key}.refresh.pending"))
159}
160
161/// Non-secret metadata stored beside a credential (`label`, `identity`,
162/// `addedAt`). Kept separate from [`OAuthCredentials`] so refresh writes,
163/// which only carry token fields, never clobber it.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct AccountMetadata {
166    /// Display-only provider identity (e.g. an email claim). Never a secret.
167    pub identity: Option<String>,
168    /// Epoch seconds when the slot was created.
169    pub added_at: Option<u64>,
170}
171
172/// Merge non-secret metadata into an account slot (which must already exist).
173pub fn save_account_metadata(
174    cred: &CredentialRef,
175    metadata: &AccountMetadata,
176) -> std::result::Result<(), String> {
177    let path = crate::config::resolve_write_path("auth.json");
178    save_account_metadata_at(&path, cred, metadata)
179}
180
181pub(crate) fn save_account_metadata_at(
182    path: &Path,
183    cred: &CredentialRef,
184    metadata: &AccountMetadata,
185) -> std::result::Result<(), String> {
186    let key = cred.storage_key();
187    with_locked_root(path, false, |root| {
188        let Some(entry) = root.get_mut(&key).and_then(serde_json::Value::as_object_mut) else {
189            return Err(format!("no credential stored for {key}"));
190        };
191        match &cred.account {
192            Account::Default => {
193                entry.remove("label");
194            }
195            Account::Named(label) => {
196                entry.insert("label".into(), serde_json::Value::String(label.as_str().into()));
197            }
198        }
199        match &metadata.identity {
200            Some(identity) if !identity.trim().is_empty() => {
201                entry.insert(
202                    "identity".into(),
203                    serde_json::Value::String(identity.trim().to_string()),
204                );
205            }
206            _ => {}
207        }
208        if let Some(added_at) = metadata.added_at {
209            entry.insert("addedAt".into(), serde_json::Value::from(added_at));
210        }
211        Ok((true, ()))
212    })
213}
214
215/// Record the provider seat identity on an EXISTING OAuth slot: sets
216/// `accountId` (the full provider account id) and, when given, the display
217/// `identity`. Used by the `auth identify` backfill for slots whose login
218/// predates identity acquisition (Anthropic tokens are opaque; the seat
219/// comes from the profile endpoint after the fact).
220///
221/// Touches no other field — a `refresh`/`access` rotated by another process
222/// between the caller's read and this write is never clobbered, because the
223/// write happens under the store lock and only inserts the two identity
224/// keys into the entry read under that same lock. A missing slot, a
225/// non-OAuth entry, or an empty `account_id` is an error and writes nothing.
226///
227/// Profiles: like [`remove_credential`], this refuses to act on an auth.json
228/// the active profile merely inherits.
229pub fn set_slot_identity(
230    cred: &CredentialRef,
231    account_id: &str,
232    identity: Option<&str>,
233) -> std::result::Result<(), String> {
234    let read_path = auth_file_path();
235    let write_path = crate::config::resolve_write_path("auth.json");
236    if read_path != write_path {
237        return Err(format!(
238            "{} is stored in {} which the active profile inherits; re-run without --profile \
239             (or with the profile that owns that file) to record its identity",
240            cred,
241            read_path.display()
242        ));
243    }
244    set_slot_identity_at(&write_path, cred, account_id, identity)
245}
246
247pub(crate) fn set_slot_identity_at(
248    path: &Path,
249    cred: &CredentialRef,
250    account_id: &str,
251    identity: Option<&str>,
252) -> std::result::Result<(), String> {
253    let account_id = account_id.trim();
254    if account_id.is_empty() {
255        return Err("account id is empty; nothing recorded".into());
256    }
257    let key = cred.storage_key();
258    if !path.exists() {
259        return Err(format!("no credential stored for {key}"));
260    }
261    let identity = identity.map(str::trim).filter(|s| !s.is_empty());
262    with_locked_root(path, false, |root| {
263        let Some(entry) = root
264            .get_mut(&key)
265            .and_then(serde_json::Value::as_object_mut)
266        else {
267            return Err(format!("no credential stored for {key}"));
268        };
269        if entry.get("type").and_then(|t| t.as_str()) != Some("oauth") {
270            return Err(format!(
271                "{key} is not an OAuth credential; nothing recorded"
272            ));
273        }
274        entry.insert(
275            "accountId".into(),
276            serde_json::Value::String(account_id.to_string()),
277        );
278        if let Some(identity) = identity {
279            entry.insert(
280                "identity".into(),
281                serde_json::Value::String(identity.to_string()),
282            );
283        }
284        Ok((true, ()))
285    })
286}
287
288/// Outcome of a compare-and-swap credential write (refresh rotation).
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum CasOutcome {
291    /// The stored refresh token matched; the rotated credential was written.
292    Saved,
293    /// The slot no longer exists (removed while the refresh was in flight).
294    Removed,
295    /// The slot now holds a different credential (re-login while the refresh
296    /// was in flight). Nothing was written.
297    Replaced,
298    /// The credential file itself is gone (e.g. moved aside by the user).
299    /// Nothing was written.
300    StoreMissing,
301}
302
303/// Write a rotated credential only if the slot still holds the credential
304/// the rotation started from (identified by its refresh token). Preserves
305/// metadata and, when the rotated credential omits `accountId`, the stored
306/// one. Used by the refresh path so a concurrent removal or re-login is never
307/// overwritten by a stale rotation.
308#[cfg(test)]
309pub(crate) fn save_provider_auth_if_refresh_matches_at(
310    path: &Path,
311    key: &str,
312    expected_refresh: &str,
313    creds: &OAuthCredentials,
314) -> std::result::Result<CasOutcome, String> {
315    save_provider_auth_if_refresh_matches_typed_at(path, key, expected_refresh, creds, STORE_LOCK_WAIT)
316        .map_err(String::from)
317}
318
319/// [`save_provider_auth_if_refresh_matches_at`] that tells a busy store
320/// lock apart from other failures. `wait` bounds the `auth.json.lock` wait.
321pub(crate) fn save_provider_auth_if_refresh_matches_typed_at(
322    path: &Path,
323    key: &str,
324    expected_refresh: &str,
325    creds: &OAuthCredentials,
326    wait: std::time::Duration,
327) -> std::result::Result<CasOutcome, StoreError> {
328    let encoded =
329        serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
330    let fields = encoded
331        .as_object()
332        .cloned()
333        .ok_or_else(|| "credential did not serialize to an object".to_string())?;
334    if !path.exists() {
335        return Ok(CasOutcome::StoreMissing);
336    }
337    with_locked_root_typed(path, false, wait, |root| {
338        let Some(entry) = root.get_mut(key).and_then(serde_json::Value::as_object_mut) else {
339            return Ok((false, CasOutcome::Removed));
340        };
341        let stored_refresh = entry.get("refresh").and_then(|v| v.as_str()).unwrap_or("");
342        if stored_refresh != expected_refresh {
343            return Ok((false, CasOutcome::Replaced));
344        }
345        for (k, v) in &fields {
346            entry.insert(k.clone(), v.clone());
347        }
348        Ok((true, CasOutcome::Saved))
349    })
350}
351
352/// Listing of one provider's stored accounts plus any sibling keys that look
353/// like accounts of this provider but fail validation (never loaded).
354#[derive(Debug, Clone, Default, PartialEq, Eq)]
355pub struct AccountInventory {
356    pub accounts: Vec<AccountSummary>,
357    pub malformed_keys: Vec<String>,
358    /// Storage keys of slots that share a provider `accountId` with another
359    /// slot of the same provider (two refresh owners for one seat).
360    pub duplicate_identity_keys: Vec<String>,
361}
362
363/// Enumerate stored OAuth accounts for `provider` (non-secret rows;
364/// `selected` is left `false` for the caller to annotate).
365pub fn list_accounts(provider: OAuthProviderId) -> std::result::Result<Vec<AccountSummary>, String> {
366    Ok(list_accounts_detailed_at(&auth_file_path(), Some(provider))?.accounts)
367}
368
369/// Enumerate stored OAuth accounts for every provider.
370pub fn list_all_accounts() -> std::result::Result<Vec<AccountSummary>, String> {
371    Ok(list_accounts_detailed_at(&auth_file_path(), None)?.accounts)
372}
373
374/// Enumerate with malformed-key diagnostics (for `auth list`).
375pub fn list_accounts_detailed(
376    provider: Option<OAuthProviderId>,
377) -> std::result::Result<AccountInventory, String> {
378    list_accounts_detailed_at(&auth_file_path(), provider)
379}
380
381pub(crate) fn list_accounts_detailed_at(
382    path: &Path,
383    provider: Option<OAuthProviderId>,
384) -> std::result::Result<AccountInventory, String> {
385    let mut inventory = AccountInventory::default();
386    if !path.exists() {
387        return Ok(inventory);
388    }
389    let content = std::fs::read_to_string(path)
390        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
391    let root: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&content)
392        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
393    for (key, entry) in &root {
394        let belongs = match provider {
395            Some(p) => CredentialRef::key_belongs_to(key, p),
396            None => super::provider::DESCRIPTORS
397                .iter()
398                .any(|d| CredentialRef::key_belongs_to(key, d.id)),
399        };
400        if !belongs {
401            continue;
402        }
403        let Some(cred) = CredentialRef::parse_storage_key(key) else {
404            tracing::warn!(key_len = key.len(), "ignoring malformed account key in auth.json");
405            inventory.malformed_keys.push(display_key(key));
406            continue;
407        };
408        let Some(obj) = entry.as_object() else {
409            inventory.malformed_keys.push(display_key(key));
410            continue;
411        };
412        if obj.get("type").and_then(|t| t.as_str()) != Some("oauth") {
413            // Bare provider keys may legitimately hold non-OAuth state; only
414            // OAuth entries are accounts.
415            continue;
416        }
417        // An OAuth entry is only "configured" when it actually carries token
418        // material (same rule as `oauth_provider_logged_in`). Entries with
419        // missing/non-string tokens are reported by KEY ONLY — never values.
420        let token_present = |field: &str| {
421            obj.get(field)
422                .and_then(|v| v.as_str())
423                .is_some_and(|v| !v.is_empty())
424        };
425        let tokens_typed = ["access", "refresh"]
426            .iter()
427            .all(|f| matches!(obj.get(*f), None | Some(serde_json::Value::String(_))));
428        if !tokens_typed || !(token_present("access") || token_present("refresh")) {
429            tracing::warn!(key_len = key.len(), "oauth entry without usable token fields");
430            inventory.malformed_keys.push(display_key(key));
431            continue;
432        }
433        inventory.accounts.push(AccountSummary {
434            identity: obj
435                .get("identity")
436                .and_then(|v| v.as_str())
437                .filter(|s| !s.trim().is_empty())
438                .map(|s| s.trim().to_string()),
439            expires: obj.get("expires").and_then(|v| v.as_u64()).unwrap_or(0),
440            added_at: obj.get("addedAt").and_then(|v| v.as_u64()),
441            ..AccountSummary::new(cred.provider, &cred.account)
442                .with_account_id(obj.get("accountId").and_then(|v| v.as_str()))
443        });
444    }
445    inventory
446        .accounts
447        .sort_by(|a, b| (&a.provider, a.label != "default", &a.label).cmp(&(&b.provider, b.label != "default", &b.label)));
448    // Duplicate seats: same provider + same full accountId in two slots.
449    let mut seen: std::collections::BTreeMap<(String, String), Vec<String>> =
450        std::collections::BTreeMap::new();
451    for (key, entry) in &root {
452        let Some(cred) = CredentialRef::parse_storage_key(key) else {
453            continue;
454        };
455        if let Some(id) = entry
456            .get("accountId")
457            .and_then(|v| v.as_str())
458            .map(str::trim)
459            .filter(|s| !s.is_empty())
460        {
461            if provider.is_none_or(|p| p == cred.provider) {
462                seen.entry((cred.provider.as_str().to_string(), id.to_string()))
463                    .or_default()
464                    .push(key.clone());
465            }
466        }
467    }
468    for keys in seen.into_values() {
469        if keys.len() > 1 {
470            inventory.duplicate_identity_keys.extend(keys);
471        }
472    }
473    inventory.duplicate_identity_keys.sort();
474    Ok(inventory)
475}
476
477/// Bounded key text for diagnostics (keys are labels, never token material,
478/// but a hand-edited file could put anything there).
479fn display_key(key: &str) -> String {
480    crate::truncate_str(key, 64).to_string()
481}
482
483/// True if auth.json holds at least one OAuth credential of ANY provider or
484/// account. Used for broker startup/health so non-Anthropic-only
485/// installations work.
486pub fn any_oauth_credential_present() -> std::result::Result<bool, String> {
487    any_oauth_credential_present_at(&auth_file_path())
488}
489
490pub(crate) fn any_oauth_credential_present_at(path: &Path) -> std::result::Result<bool, String> {
491    Ok(!list_accounts_detailed_at(path, None)?.accounts.is_empty())
492}
493
494/// What the duplicate-identity guard could establish for a new login.
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum IdentityEvidence {
497    /// The provider exposes no trustworthy identity for this credential, so
498    /// a duplicate seat under another label CANNOT be detected.
499    NoEvidence,
500    /// Identity known; no other slot of this provider holds the same seat.
501    NoDuplicate,
502    /// Another slot of this provider already holds this seat.
503    Duplicate(Account),
504}
505
506/// Outcome of [`save_credential_unless_duplicate`].
507#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct LoginPersistOutcome {
509    pub evidence: IdentityEvidence,
510    /// False iff a duplicate was found (nothing was written).
511    pub saved: bool,
512}
513
514/// Login persistence: duplicate scan and write happen inside ONE locked
515/// read-modify-write of auth.json, so two concurrent logins (or a login
516/// racing a remove/re-login) can never create two slots owning the same
517/// seat. A duplicate is another slot of the same provider with the same
518/// non-empty `accountId`, or with an identical refresh token.
519///
520/// Metadata is rewritten atomically with the credential: `label` follows the
521/// slot, `identity` is set or CLEARED (a re-login with a different seat never
522/// keeps the old identity), `addedAt` is set to `metadata.added_at`.
523///
524/// A corrupt store is an error (never reset: that would wipe every other
525/// slot). With an active profile that has no own auth.json while the base
526/// store exists, the write is REFUSED: creating a profile file would hide
527/// every inherited account from that profile, and tokens are never copied.
528pub fn save_credential_unless_duplicate(
529    cred: &CredentialRef,
530    creds: &OAuthCredentials,
531    metadata: &AccountMetadata,
532) -> std::result::Result<LoginPersistOutcome, String> {
533    let read_path = auth_file_path();
534    let write_path = crate::config::resolve_write_path("auth.json");
535    if read_path != write_path && read_path.exists() {
536        return Err(format!(
537            "the active profile has no auth.json of its own and inherits {}; logging in here would \
538             create a profile store that hides every inherited account (tokens are never copied). \
539             Re-run without --profile to add the account to the shared store.",
540            read_path.display()
541        ));
542    }
543    save_credential_unless_duplicate_at(&write_path, cred, creds, metadata)
544}
545
546pub(crate) fn save_credential_unless_duplicate_at(
547    path: &Path,
548    cred: &CredentialRef,
549    creds: &OAuthCredentials,
550    metadata: &AccountMetadata,
551) -> std::result::Result<LoginPersistOutcome, String> {
552    let encoded =
553        serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
554    let fields = encoded
555        .as_object()
556        .cloned()
557        .ok_or_else(|| "credential did not serialize to an object".to_string())?;
558    let key = cred.storage_key();
559    let account_id = creds
560        .account_id
561        .as_deref()
562        .map(str::trim)
563        .filter(|s| !s.is_empty());
564    with_locked_root(path, false, |root| {
565        // Scan sibling slots of the same provider under the lock.
566        let mut duplicate: Option<Account> = None;
567        for (other_key, entry) in root.iter() {
568            if other_key == &key {
569                continue;
570            }
571            let Some(other) = CredentialRef::parse_storage_key(other_key) else {
572                continue;
573            };
574            if other.provider != cred.provider {
575                continue;
576            }
577            let stored_id = entry
578                .get("accountId")
579                .and_then(|v| v.as_str())
580                .map(str::trim)
581                .unwrap_or("");
582            let stored_refresh = entry.get("refresh").and_then(|v| v.as_str()).unwrap_or("");
583            let same_seat = account_id.is_some_and(|id| id == stored_id)
584                || (!creds.refresh.is_empty() && creds.refresh == stored_refresh);
585            if same_seat {
586                duplicate = Some(other.account);
587                break;
588            }
589        }
590        if let Some(account) = duplicate {
591            return Ok((
592                false,
593                LoginPersistOutcome {
594                    evidence: IdentityEvidence::Duplicate(account),
595                    saved: false,
596                },
597            ));
598        }
599        let mut slot = root
600            .remove(&key)
601            .filter(serde_json::Value::is_object)
602            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
603        if let Some(target) = slot.as_object_mut() {
604            for (k, v) in &fields {
605                target.insert(k.clone(), v.clone());
606            }
607            if account_id.is_none() {
608                // The new credential carries no identity: never keep a stale
609                // one from a previous login into this slot.
610                target.remove("accountId");
611            }
612            match &cred.account {
613                Account::Default => {
614                    target.remove("label");
615                }
616                Account::Named(label) => {
617                    target.insert("label".into(), serde_json::Value::String(label.as_str().into()));
618                }
619            }
620            match metadata.identity.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
621                Some(identity) => {
622                    target.insert("identity".into(), serde_json::Value::String(identity.into()));
623                }
624                None => {
625                    target.remove("identity");
626                }
627            }
628            match metadata.added_at {
629                Some(added_at) => {
630                    target.insert("addedAt".into(), serde_json::Value::from(added_at));
631                }
632                None => {
633                    target.remove("addedAt");
634                }
635            }
636        }
637        root.insert(key.clone(), slot);
638        Ok((
639            true,
640            LoginPersistOutcome {
641                evidence: if account_id.is_some() {
642                    IdentityEvidence::NoDuplicate
643                } else {
644                    IdentityEvidence::NoEvidence
645                },
646                saved: true,
647            },
648        ))
649    })
650}
651
652/// Duplicate-identity guard for login: returns the account (of the same
653/// provider, other than `except`) that already stores `account_id`.
654pub fn find_duplicate_identity(
655    provider: OAuthProviderId,
656    account_id: &str,
657    except: &Account,
658) -> std::result::Result<Option<Account>, String> {
659    find_duplicate_identity_at(&auth_file_path(), provider, account_id, except)
660}
661
662pub(crate) fn find_duplicate_identity_at(
663    path: &Path,
664    provider: OAuthProviderId,
665    account_id: &str,
666    except: &Account,
667) -> std::result::Result<Option<Account>, String> {
668    let account_id = account_id.trim();
669    if account_id.is_empty() || !path.exists() {
670        return Ok(None);
671    }
672    let content = std::fs::read_to_string(path)
673        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
674    let root: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&content)
675        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
676    for (key, entry) in &root {
677        let Some(cred) = CredentialRef::parse_storage_key(key) else {
678            continue;
679        };
680        if cred.provider != provider || &cred.account == except {
681            continue;
682        }
683        let stored = entry
684            .get("accountId")
685            .and_then(|v| v.as_str())
686            .map(str::trim)
687            .unwrap_or("");
688        if !stored.is_empty() && stored == account_id {
689            return Ok(Some(cred.account));
690        }
691    }
692    Ok(None)
693}
694
695/// Save credentials to auth.json.
696pub fn save_auth(creds: &OAuthCredentials) -> std::result::Result<(), String> {
697    save_provider_auth("anthropic", creds)
698}
699
700/// Save one provider credential while preserving other auth.json entries.
701pub fn save_provider_auth(
702    provider: &str,
703    creds: &OAuthCredentials,
704) -> std::result::Result<(), String> {
705    let path = crate::config::resolve_write_path("auth.json");
706    save_provider_auth_at(&path, provider, creds)
707}
708
709/// Load an opaque broker-owned cloud provider state object.
710pub fn load_cloud_state(provider: &str) -> std::result::Result<Option<serde_json::Value>, String> {
711    let path = auth_file_path();
712    if !path.exists() {
713        return Ok(None);
714    }
715    let root: serde_json::Value = serde_json::from_str(
716        &std::fs::read_to_string(&path)
717            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?,
718    )
719    .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
720    Ok(root
721        .get(provider)
722        .and_then(|v| v.get("cloud_state"))
723        .cloned())
724}
725
726/// Atomically persist opaque cloud state while preserving every unrelated field.
727pub fn save_cloud_state(
728    provider: &str,
729    state: &serde_json::Value,
730) -> std::result::Result<(), String> {
731    let path = crate::config::resolve_write_path("auth.json");
732    let mut fields = serde_json::Map::new();
733    fields.insert("type".into(), serde_json::Value::String("cloud".into()));
734    fields.insert("cloud_state".into(), state.clone());
735    save_provider_fields_at(&path, provider, &fields)
736}
737
738/// Load one provider's broker-owned static API key from auth.json.
739///
740/// Static keys are persisted as `{"type":"api_key","key":"…"}` entries in the
741/// same open JSON object as OAuth credentials, so unknown providers and
742/// provider metadata round-trip through the exact same merge writer.
743pub fn load_static_key(provider: &str) -> std::result::Result<Option<String>, String> {
744    load_static_key_at(&auth_file_path(), provider)
745}
746
747pub(crate) fn load_static_key_at(
748    path: &std::path::Path,
749    provider: &str,
750) -> std::result::Result<Option<String>, String> {
751    if !path.exists() {
752        return Ok(None);
753    }
754    let content = std::fs::read_to_string(path)
755        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
756    let value: serde_json::Value = serde_json::from_str(&content)
757        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
758    let Some(entry) = value.get(provider) else {
759        return Ok(None);
760    };
761    if entry.get("type").and_then(|t| t.as_str()) != Some("api_key") {
762        return Ok(None);
763    }
764    Ok(entry
765        .get("key")
766        .and_then(|k| k.as_str())
767        .filter(|k| !k.is_empty())
768        .map(str::to_string))
769}
770
771/// Persist a broker-owned static API key while preserving every other
772/// auth.json entry and any provider metadata.
773pub fn save_static_key(provider: &str, key: &str) -> std::result::Result<(), String> {
774    let path = crate::config::resolve_write_path("auth.json");
775    save_static_key_at(&path, provider, key)
776}
777
778pub(crate) fn save_static_key_at(
779    path: &std::path::Path,
780    provider: &str,
781    key: &str,
782) -> std::result::Result<(), String> {
783    let mut fields = serde_json::Map::new();
784    fields.insert(
785        "type".to_string(),
786        serde_json::Value::String("api_key".to_string()),
787    );
788    fields.insert(
789        "key".to_string(),
790        serde_json::Value::String(key.to_string()),
791    );
792    save_provider_fields_at(path, provider, &fields)
793}
794
795/// Path-explicit variant of `save_provider_auth`. Splits out the I/O so
796/// the corrupt-file fallback path can be unit-tested without touching the
797/// user's real `~/.synaps-cli/auth.json`.
798/// [`save_provider_auth`] that reports a corrupt store as
799/// [`StoreError::Corrupt`] (provider logins with typed errors).
800pub(crate) fn save_provider_auth_typed(
801    provider: &str,
802    creds: &OAuthCredentials,
803) -> std::result::Result<(), StoreError> {
804    let path = crate::config::resolve_write_path("auth.json");
805    save_provider_auth_typed_at(&path, provider, creds)
806}
807
808pub(crate) fn save_provider_auth_typed_at(
809    path: &std::path::Path,
810    provider: &str,
811    creds: &OAuthCredentials,
812) -> std::result::Result<(), StoreError> {
813    let encoded =
814        serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
815    let fields = encoded
816        .as_object()
817        .ok_or_else(|| "credential did not serialize to an object".to_string())?;
818    with_locked_root_typed(path, true, STORE_LOCK_WAIT, |root| {
819        merge_provider_fields(root, provider, fields);
820        Ok((true, ()))
821    })
822}
823
824pub(crate) fn save_provider_auth_at(
825    path: &std::path::Path,
826    provider: &str,
827    creds: &OAuthCredentials,
828) -> std::result::Result<(), String> {
829    let encoded =
830        serde_json::to_value(creds).map_err(|e| format!("Failed to serialize auth: {}", e))?;
831    let fields = encoded
832        .as_object()
833        .ok_or_else(|| "credential did not serialize to an object".to_string())?;
834    save_provider_fields_at(path, provider, fields)
835}
836
837/// Shared merge writer: lock, read-merge every provider entry, merge `fields`
838/// into the one provider object (preserving unknown metadata), atomic rename.
839fn save_provider_fields_at(
840    path: &std::path::Path,
841    provider: &str,
842    fields: &serde_json::Map<String, serde_json::Value>,
843) -> std::result::Result<(), String> {
844    with_locked_root(path, true, |root| {
845        merge_provider_fields(root, provider, fields);
846        Ok((true, ()))
847    })
848}
849
850/// Merge known credential fields into an existing provider object rather
851/// than replacing it. Providers may add metadata (including nested objects)
852/// that must survive refresh/login writes.
853fn merge_provider_fields(
854    root: &mut serde_json::Map<String, serde_json::Value>,
855    provider: &str,
856    fields: &serde_json::Map<String, serde_json::Value>,
857) {
858    let mut provider_value = root
859        .remove(provider)
860        .filter(serde_json::Value::is_object)
861        .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
862    if let Some(target) = provider_value.as_object_mut() {
863        for (key, value) in fields {
864            target.insert(key.clone(), value.clone());
865        }
866    }
867    root.insert(provider.to_string(), provider_value);
868}
869
870/// Locked read-modify-write over the auth.json root object.
871///
872/// Holds the exclusive `auth.json.lock` for the whole cycle, hands the parsed
873/// root to `edit`, and — when `edit` returns `(true, _)` — writes the result
874/// atomically (0600 tmp + rename). `recover_corrupt` lets the legacy
875/// single-credential writers reset an EMPTY file (with a backup); any other
876/// unparseable content is always an error.
877fn with_locked_root<T>(
878    path: &std::path::Path,
879    recover_corrupt: bool,
880    edit: impl FnOnce(
881        &mut serde_json::Map<String, serde_json::Value>,
882    ) -> std::result::Result<(bool, T), String>,
883) -> std::result::Result<T, String> {
884    with_locked_root_typed(path, recover_corrupt, STORE_LOCK_WAIT, edit).map_err(String::from)
885}
886
887/// Longest wait for `auth.json.lock`. A healthy holder keeps it for one
888/// read-merge-fsync-rename (milliseconds); a stopped or wedged process must
889/// not block every saver (and the refresh runtime's workers) forever.
890pub(crate) const STORE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(15);
891
892/// Why a locked store edit failed: the lock stayed held past the deadline,
893/// or anything else (I/O, parse, the edit itself).
894#[derive(Debug)]
895pub(crate) enum StoreError {
896    Busy(String),
897    /// auth.json exists but does not parse; the message names the file and
898    /// the exact command to move it aside (see [`corrupt_store`]).
899    Corrupt(String),
900    Failed(String),
901}
902
903impl From<StoreError> for String {
904    fn from(e: StoreError) -> Self {
905        match e {
906            StoreError::Busy(m) | StoreError::Corrupt(m) | StoreError::Failed(m) => m,
907        }
908    }
909}
910
911/// The corrupt-store error: names the file, says why it is not reset, and
912/// gives the exact command to move it aside. Never suggests `synaps login`
913/// on its own: login fails on the same file until it is moved.
914pub(crate) fn corrupt_store(path: &Path, err: &serde_json::Error) -> StoreError {
915    let ts = std::time::SystemTime::now()
916        .duration_since(std::time::UNIX_EPOCH)
917        .map(|d| d.as_secs())
918        .unwrap_or(0);
919    let p = path.display().to_string();
920    StoreError::Corrupt(format!(
921        "{p} is not valid JSON ({err}); synaps will not overwrite it because it may hold other \
922         accounts. Move it aside with `mv {} {}`, then log in again (or repair it by hand).",
923        shell_quote(&p),
924        shell_quote(&format!("{p}.broken-{ts}"))
925    ))
926}
927
928/// POSIX single-quote `s` for a copy-pasteable command.
929fn shell_quote(s: &str) -> String {
930    format!("'{}'", s.replace('\'', r"'\''"))
931}
932
933impl From<String> for StoreError {
934    fn from(m: String) -> Self {
935        StoreError::Failed(m)
936    }
937}
938
939/// Poll `try_lock_exclusive` until `wait` runs out.
940fn lock_store_exclusive(
941    lock_file: &std::fs::File,
942    lock_path: &std::path::Path,
943    wait: std::time::Duration,
944) -> std::result::Result<(), StoreError> {
945    use fs4::fs_std::FileExt;
946    let deadline = std::time::Instant::now() + wait;
947    loop {
948        match FileExt::try_lock_exclusive(lock_file) {
949            Ok(true) => return Ok(()),
950            Ok(false) => {}
951            Err(e) => {
952                return Err(StoreError::Failed(format!(
953                    "Failed to lock {}: {}",
954                    lock_path.display(),
955                    e
956                )))
957            }
958        }
959        if std::time::Instant::now() >= deadline {
960            return Err(StoreError::Busy(format!(
961                "timed out after {}s waiting for {} (another synaps process holds it)",
962                wait.as_secs(),
963                lock_path.display()
964            )));
965        }
966        std::thread::sleep(std::time::Duration::from_millis(20));
967    }
968}
969
970fn with_locked_root_typed<T>(
971    path: &std::path::Path,
972    recover_corrupt: bool,
973    wait: std::time::Duration,
974    edit: impl FnOnce(
975        &mut serde_json::Map<String, serde_json::Value>,
976    ) -> std::result::Result<(bool, T), String>,
977) -> std::result::Result<T, StoreError> {
978    // Ensure parent directory exists
979    if let Some(parent) = path.parent() {
980        std::fs::create_dir_all(parent)
981            .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
982    }
983
984    // Hold an exclusive lock for the entire read-modify-write cycle.
985    // Without this, two concurrent `synaps login` processes can race:
986    // both read the same file, each adds their provider, second write
987    // silently drops the first's credential.
988    let lock_path = path.with_extension("json.lock");
989    let lock_file = std::fs::OpenOptions::new()
990        .create(true)
991        .truncate(true)
992        .write(true)
993        .open(&lock_path)
994        .map_err(|e| format!("Failed to open lock file {}: {}", lock_path.display(), e))?;
995    lock_store_exclusive(&lock_file, &lock_path, wait)?;
996
997    let mut root = if path.exists() {
998        let content = std::fs::read_to_string(path)
999            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1000        // Corrupt-file recovery is limited to a file with nothing in it
1001        // (zero-length or whitespace, e.g. a crash before the first write):
1002        // that holds no account to lose. Anything else that does not parse
1003        // (a truncated write, a manual edit error) may still hold every
1004        // named slot's refresh token; resetting it would log the user out
1005        // of all of them, so it is an error for the user to repair.
1006        match serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&content) {
1007            Ok(map) => map,
1008            Err(e) if !recover_corrupt || !content.trim().is_empty() => {
1009                return Err(corrupt_store(path, &e));
1010            }
1011            Err(e) => {
1012                tracing::warn!(
1013                    path = %path.display(),
1014                    error = %e,
1015                    "auth.json could not be parsed as a JSON object; replacing with a fresh structure"
1016                );
1017                // Back up the corrupt file so credentials are potentially recoverable.
1018                let ts = std::time::SystemTime::now()
1019                    .duration_since(std::time::UNIX_EPOCH)
1020                    .map(|d| d.as_secs())
1021                    .unwrap_or(0);
1022                let backup = path.with_extension(format!("json.corrupt.{}", ts));
1023                match std::fs::copy(path, &backup) {
1024                    Ok(_) => {
1025                        eprintln!(
1026                            "[warn] auth.json was corrupt and has been reset. Backup saved to: {}",
1027                            backup.display()
1028                        );
1029                    }
1030                    Err(copy_err) => {
1031                        eprintln!(
1032                            "[warn] auth.json was corrupt and has been reset, but backup failed: {}",
1033                            copy_err
1034                        );
1035                    }
1036                }
1037                serde_json::Map::new()
1038            }
1039        }
1040    } else {
1041        serde_json::Map::new()
1042    };
1043
1044    let (write, result) = edit(&mut root)?;
1045    if !write {
1046        return Ok(result);
1047    }
1048
1049    let json = serde_json::to_string_pretty(&root)
1050        .map_err(|e| format!("Failed to serialize auth: {}", e))?;
1051
1052    // Atomic write: write to .tmp then rename. rename(2) is atomic on POSIX.
1053    // This prevents a crash/kill between truncate and write from zeroing auth.json.
1054    // Create with restrictive permissions from the start so credentials are never
1055    // world-readable, even briefly.
1056    let tmp_path = path.with_extension("json.tmp");
1057    {
1058        use std::io::Write;
1059        let mut file = std::fs::OpenOptions::new()
1060            .write(true)
1061            .create(true)
1062            .truncate(true)
1063            .open(&tmp_path)
1064            .map_err(|e| format!("Failed to create {}: {}", tmp_path.display(), e))?;
1065
1066        #[cfg(unix)]
1067        {
1068            use std::os::unix::fs::PermissionsExt;
1069            let perms = std::fs::Permissions::from_mode(0o600);
1070            file.set_permissions(perms).map_err(|e| {
1071                format!("Failed to set permissions on {}: {}", tmp_path.display(), e)
1072            })?;
1073        }
1074
1075        file.write_all(json.as_bytes())
1076            .map_err(|e| format!("Failed to write {}: {}", tmp_path.display(), e))?;
1077        file.sync_all()
1078            .map_err(|e| format!("Failed to fsync {}: {}", tmp_path.display(), e))?;
1079    }
1080
1081    std::fs::rename(&tmp_path, path)
1082        .map_err(|e| format!("Failed to atomically replace {}: {}", path.display(), e))?;
1083
1084    Ok(result)
1085}
1086
1087/// Test-only re-export of `save_provider_auth_at` so that `token::tests`
1088/// can exercise the same function `ensure_fresh_token` now delegates to,
1089/// without making the path-explicit variant part of the public API.
1090#[cfg(test)]
1091pub(super) fn save_provider_auth_at_test_hook(
1092    path: &std::path::Path,
1093    provider: &str,
1094    creds: &OAuthCredentials,
1095) -> std::result::Result<(), String> {
1096    save_provider_auth_at(path, provider, creds)
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102
1103    fn fresh_creds() -> OAuthCredentials {
1104        OAuthCredentials {
1105            auth_type: "oauth".to_string(),
1106            refresh: "r".to_string(),
1107            access: "a".to_string(),
1108            expires: 1,
1109            account_id: None,
1110        }
1111    }
1112
1113    #[test]
1114    fn save_preserves_unknown_nested_provider_metadata() {
1115        let dir = tempfile::tempdir().expect("tempdir");
1116        let path = dir.path().join("auth.json");
1117        std::fs::write(&path, r#"{"openai-codex":{"type":"oauth","refresh":"old","access":"old","expires":1,"metadata":{"tenant":"t1","flags":{"beta":true}}}}"#).unwrap();
1118        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).unwrap();
1119        let value: serde_json::Value =
1120            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1121        assert_eq!(value["openai-codex"]["metadata"]["tenant"], "t1");
1122        assert_eq!(value["openai-codex"]["metadata"]["flags"]["beta"], true);
1123        assert_eq!(value["openai-codex"]["access"], "a");
1124    }
1125
1126    #[test]
1127    fn save_provider_auth_at_creates_file_when_absent() {
1128        let dir = tempfile::tempdir().expect("tempdir");
1129        let path = dir.path().join("auth.json");
1130        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
1131        assert!(path.exists());
1132        let content = std::fs::read_to_string(&path).unwrap();
1133        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1134        assert!(parsed.get("openai-codex").is_some());
1135    }
1136
1137    #[test]
1138    fn save_provider_auth_at_preserves_other_providers() {
1139        let dir = tempfile::tempdir().expect("tempdir");
1140        let path = dir.path().join("auth.json");
1141        std::fs::write(
1142            &path,
1143            r#"{"anthropic":{"type":"oauth","refresh":"r2","access":"a2","expires":2}}"#,
1144        )
1145        .unwrap();
1146        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save");
1147        let content = std::fs::read_to_string(&path).unwrap();
1148        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1149        assert!(
1150            parsed.get("anthropic").is_some(),
1151            "must keep anthropic entry"
1152        );
1153        assert!(parsed.get("openai-codex").is_some());
1154    }
1155
1156    /// 147 I4: a corrupt multi-account store is never reset by a legacy
1157    /// save (it would drop every named slot). The save fails and the file is
1158    /// left exactly as it was, for the user to repair.
1159    #[test]
1160    fn legacy_save_refuses_to_reset_a_corrupt_store() {
1161        let dir = tempfile::tempdir().expect("tempdir");
1162        let path = dir.path().join("auth.json");
1163        // A truncated multi-account store.
1164        let corrupt = r#"{"anthropic":{"type":"oauth","refresh":"r1"},"anthropic@work":{"type":"oau"#;
1165        std::fs::write(&path, corrupt).unwrap();
1166        for save in [
1167            save_provider_auth_at(&path, "openai-codex", &fresh_creds()),
1168            save_static_key_at(&path, "groq", "gsk-1"),
1169        ] {
1170            let err = save.expect_err("must not reset a corrupt store");
1171            assert!(err.contains("not valid JSON"), "{err}");
1172            assert!(err.contains("other accounts"), "{err}");
1173        }
1174        assert_eq!(std::fs::read_to_string(&path).unwrap(), corrupt, "file untouched");
1175        let backups = std::fs::read_dir(dir.path())
1176            .unwrap()
1177            .filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().contains(".corrupt."))
1178            .count();
1179        assert_eq!(backups, 0, "nothing reset, nothing to back up");
1180    }
1181
1182    #[test]
1183    fn legacy_save_refuses_a_non_object_root() {
1184        let dir = tempfile::tempdir().expect("tempdir");
1185        let path = dir.path().join("auth.json");
1186        std::fs::write(&path, "[1, 2, 3]").unwrap();
1187        save_provider_auth_at(&path, "openai-codex", &fresh_creds())
1188            .expect_err("a non-object root may still hold data");
1189        assert_eq!(std::fs::read_to_string(&path).unwrap(), "[1, 2, 3]");
1190    }
1191
1192    /// An empty / whitespace-only file holds nothing to lose: it is reset
1193    /// (with the usual `.corrupt.<ts>` backup) so `synaps login` still works.
1194    #[test]
1195    fn legacy_save_resets_an_empty_store_with_a_backup() {
1196        for content in ["", "  \n\t "] {
1197            let dir = tempfile::tempdir().expect("tempdir");
1198            let path = dir.path().join("auth.json");
1199            std::fs::write(&path, content).unwrap();
1200            save_provider_auth_at(&path, "openai-codex", &fresh_creds())
1201                .expect("empty store is reset");
1202            let parsed: serde_json::Value =
1203                serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1204            assert!(parsed.get("openai-codex").is_some());
1205            let backups = std::fs::read_dir(dir.path())
1206                .unwrap()
1207                .filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().contains(".corrupt."))
1208                .count();
1209            assert_eq!(backups, 1, "{content:?}");
1210        }
1211    }
1212
1213    // ── Broker-owned static-key storage ──────────────────────────────────────
1214
1215    /// Saving a static key must preserve existing OAuth entries (cross-provider
1216    /// isolation at the storage layer), and vice versa.
1217    #[test]
1218    fn static_key_save_preserves_oauth_entries_and_roundtrips() {
1219        let dir = tempfile::tempdir().expect("tempdir");
1220        let path = dir.path().join("auth.json");
1221        save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
1222        save_static_key_at(&path, "groq", "gsk-secret-1").expect("save static key");
1223
1224        assert_eq!(
1225            load_static_key_at(&path, "groq").unwrap().as_deref(),
1226            Some("gsk-secret-1")
1227        );
1228        let parsed: serde_json::Value =
1229            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1230        assert_eq!(
1231            parsed["anthropic"]["refresh"], "r",
1232            "OAuth entry must survive"
1233        );
1234        assert_eq!(parsed["groq"]["type"], "api_key");
1235    }
1236
1237    /// An OAuth save must not disturb a broker-owned static key.
1238    #[test]
1239    fn oauth_save_preserves_static_key_entries() {
1240        let dir = tempfile::tempdir().expect("tempdir");
1241        let path = dir.path().join("auth.json");
1242        save_static_key_at(&path, "openrouter", "sk-or-1").expect("save static key");
1243        save_provider_auth_at(&path, "openai-codex", &fresh_creds()).expect("save oauth");
1244        assert_eq!(
1245            load_static_key_at(&path, "openrouter").unwrap().as_deref(),
1246            Some("sk-or-1")
1247        );
1248    }
1249
1250    /// Static-key updates merge in place and keep unknown metadata on the entry.
1251    #[test]
1252    fn static_key_upsert_preserves_entry_metadata() {
1253        let dir = tempfile::tempdir().expect("tempdir");
1254        let path = dir.path().join("auth.json");
1255        std::fs::write(
1256            &path,
1257            r#"{"groq":{"type":"api_key","key":"old","note":{"source":"migrated"}}}"#,
1258        )
1259        .unwrap();
1260        save_static_key_at(&path, "groq", "new-key").expect("upsert");
1261        let parsed: serde_json::Value =
1262            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1263        assert_eq!(parsed["groq"]["key"], "new-key");
1264        assert_eq!(parsed["groq"]["note"]["source"], "migrated");
1265    }
1266
1267    /// Loading a static key from an OAuth entry must return None — the two
1268    /// credential kinds never blur (cross-kind isolation).
1269    #[test]
1270    fn load_static_key_ignores_oauth_entries() {
1271        let dir = tempfile::tempdir().expect("tempdir");
1272        let path = dir.path().join("auth.json");
1273        save_provider_auth_at(&path, "anthropic", &fresh_creds()).expect("save oauth");
1274        assert_eq!(load_static_key_at(&path, "anthropic").unwrap(), None);
1275        assert_eq!(load_static_key_at(&path, "missing").unwrap(), None);
1276    }
1277
1278    // ── Bug #159 regression suite ────────────────────────────────────────────
1279    //
1280    // Repro: `synaps login --provider openai-codex` (saves "openai-codex"),
1281    // then `synaps login` (Anthropic OAuth, saves "anthropic") → the first
1282    // provider's credential was wiped because save_auth() rebuilt AuthFile
1283    // with only the new entry and did a whole-file overwrite.
1284    //
1285    // Root cause (pre-fix): src/core/auth/storage.rs @ 90f8f71 — save_auth()
1286    // constructed `AuthFile { anthropic: creds }` (no read of existing file)
1287    // then called `fs::write(path, json)`, discarding every other provider.
1288    //
1289    // All three multi-provider tests below would FAIL against that code.
1290
1291    /// Primary repro (bug #159): save openai-codex, then save anthropic via
1292    /// the same write path `synaps login` (Anthropic OAuth) takes.
1293    /// openai-codex MUST survive the second login.
1294    #[test]
1295    fn bug159_anthropic_login_preserves_existing_openai_codex() {
1296        let dir = tempfile::tempdir().expect("tempdir");
1297        let path = dir.path().join("auth.json");
1298
1299        // Step 1: user runs `synaps login --provider openai-codex`
1300        let codex_creds = OAuthCredentials {
1301            auth_type: "oauth".to_string(),
1302            refresh: "codex-refresh".to_string(),
1303            access: "codex-access".to_string(),
1304            expires: 9_999_999_999_000,
1305            account_id: Some("acct_codex_123".to_string()),
1306        };
1307        save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
1308
1309        // Step 2: user then runs `synaps login` (Anthropic Claude OAuth)
1310        let anthropic_creds = OAuthCredentials {
1311            auth_type: "oauth".to_string(),
1312            refresh: "anth-refresh".to_string(),
1313            access: "anth-access".to_string(),
1314            expires: 9_999_999_999_000,
1315            account_id: None,
1316        };
1317        save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
1318
1319        // Both providers must be present — bug #159 would wipe openai-codex here
1320        let content = std::fs::read_to_string(&path).unwrap();
1321        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1322
1323        assert!(
1324            parsed.get("anthropic").is_some(),
1325            "anthropic credential must be present after login"
1326        );
1327        assert!(
1328            parsed.get("openai-codex").is_some(),
1329            "bug #159: openai-codex credential must NOT be wiped when logging in as anthropic"
1330        );
1331        assert_eq!(
1332            parsed["openai-codex"]["refresh"].as_str(),
1333            Some("codex-refresh"),
1334            "openai-codex refresh token must be unchanged"
1335        );
1336        assert_eq!(
1337            parsed["anthropic"]["refresh"].as_str(),
1338            Some("anth-refresh"),
1339            "anthropic refresh token must be correctly saved"
1340        );
1341    }
1342
1343    /// Reverse of primary repro: anthropic first, then openai-codex login.
1344    /// anthropic MUST survive.
1345    #[test]
1346    fn bug159_openai_codex_login_preserves_existing_anthropic() {
1347        let dir = tempfile::tempdir().expect("tempdir");
1348        let path = dir.path().join("auth.json");
1349
1350        // Step 1: anthropic already logged in
1351        let anthropic_creds = OAuthCredentials {
1352            auth_type: "oauth".to_string(),
1353            refresh: "anth-refresh-first".to_string(),
1354            access: "anth-access-first".to_string(),
1355            expires: 9_999_999_999_000,
1356            account_id: None,
1357        };
1358        save_provider_auth_at(&path, "anthropic", &anthropic_creds).expect("save anthropic");
1359
1360        // Step 2: user runs `synaps login --provider openai-codex`
1361        let codex_creds = OAuthCredentials {
1362            auth_type: "oauth".to_string(),
1363            refresh: "codex-refresh-second".to_string(),
1364            access: "codex-access-second".to_string(),
1365            expires: 9_999_999_999_000,
1366            account_id: Some("acct_456".to_string()),
1367        };
1368        save_provider_auth_at(&path, "openai-codex", &codex_creds).expect("save openai-codex");
1369
1370        let content = std::fs::read_to_string(&path).unwrap();
1371        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1372
1373        assert!(
1374            parsed.get("anthropic").is_some(),
1375            "anthropic credential must survive openai-codex login"
1376        );
1377        assert!(
1378            parsed.get("openai-codex").is_some(),
1379            "openai-codex credential must be present"
1380        );
1381        assert_eq!(
1382            parsed["anthropic"]["refresh"].as_str(),
1383            Some("anth-refresh-first"),
1384            "anthropic refresh token must be unchanged"
1385        );
1386    }
1387
1388    /// Three-provider scenario: saving a third provider preserves the first two.
1389    /// Guards against regressions if/when more OAuth providers are added.
1390    #[test]
1391    fn bug159_third_provider_login_preserves_both_existing_providers() {
1392        let dir = tempfile::tempdir().expect("tempdir");
1393        let path = dir.path().join("auth.json");
1394
1395        save_provider_auth_at(
1396            &path,
1397            "anthropic",
1398            &OAuthCredentials {
1399                auth_type: "oauth".to_string(),
1400                refresh: "anth-r".to_string(),
1401                access: "anth-a".to_string(),
1402                expires: 1,
1403                account_id: None,
1404            },
1405        )
1406        .expect("save anthropic");
1407
1408        save_provider_auth_at(
1409            &path,
1410            "openai-codex",
1411            &OAuthCredentials {
1412                auth_type: "oauth".to_string(),
1413                refresh: "codex-r".to_string(),
1414                access: "codex-a".to_string(),
1415                expires: 1,
1416                account_id: Some("acct_codex".to_string()),
1417            },
1418        )
1419        .expect("save openai-codex");
1420
1421        save_provider_auth_at(
1422            &path,
1423            "future-provider",
1424            &OAuthCredentials {
1425                auth_type: "oauth".to_string(),
1426                refresh: "future-r".to_string(),
1427                access: "future-a".to_string(),
1428                expires: 1,
1429                account_id: None,
1430            },
1431        )
1432        .expect("save future-provider");
1433
1434        let content = std::fs::read_to_string(&path).unwrap();
1435        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1436
1437        assert!(parsed.get("anthropic").is_some(), "anthropic must survive");
1438        assert!(
1439            parsed.get("openai-codex").is_some(),
1440            "openai-codex must survive"
1441        );
1442        assert!(
1443            parsed.get("future-provider").is_some(),
1444            "future-provider must be present"
1445        );
1446    }
1447
1448    /// Upsert: saving the same provider twice updates the credential in place
1449    /// without duplicating or corrupting the entry.
1450    #[test]
1451    fn bug159_same_provider_login_upserts_in_place() {
1452        let dir = tempfile::tempdir().expect("tempdir");
1453        let path = dir.path().join("auth.json");
1454
1455        save_provider_auth_at(
1456            &path,
1457            "anthropic",
1458            &OAuthCredentials {
1459                auth_type: "oauth".to_string(),
1460                refresh: "old-refresh".to_string(),
1461                access: "old-access".to_string(),
1462                expires: 1,
1463                account_id: None,
1464            },
1465        )
1466        .expect("initial save");
1467
1468        save_provider_auth_at(
1469            &path,
1470            "anthropic",
1471            &OAuthCredentials {
1472                auth_type: "oauth".to_string(),
1473                refresh: "new-refresh".to_string(),
1474                access: "new-access".to_string(),
1475                expires: 2,
1476                account_id: None,
1477            },
1478        )
1479        .expect("upsert save");
1480
1481        let content = std::fs::read_to_string(&path).unwrap();
1482        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1483        let obj = parsed.as_object().unwrap();
1484
1485        assert_eq!(obj.len(), 1, "only one anthropic entry — no duplicates");
1486        assert_eq!(
1487            parsed["anthropic"]["refresh"].as_str(),
1488            Some("new-refresh"),
1489            "credential must be updated to latest value"
1490        );
1491    }
1492
1493    // ── Multi-account slots (G1) ─────────────────────────────────────────────
1494
1495    fn creds(refresh: &str, account_id: Option<&str>) -> OAuthCredentials {
1496        OAuthCredentials {
1497            auth_type: "oauth".to_string(),
1498            refresh: refresh.to_string(),
1499            access: format!("{refresh}-access"),
1500            expires: 5,
1501            account_id: account_id.map(str::to_string),
1502        }
1503    }
1504
1505    fn named(provider: OAuthProviderId, label: &str) -> CredentialRef {
1506        CredentialRef::new(provider, Account::named(label).unwrap())
1507    }
1508
1509    fn read_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
1510        serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1511    }
1512
1513    #[test]
1514    fn named_slot_save_never_touches_default_slot() {
1515        let dir = tempfile::tempdir().unwrap();
1516        let path = dir.path().join("auth.json");
1517        // Legacy single-account file, written exactly as older versions do.
1518        std::fs::write(
1519            &path,
1520            r#"{"openai-codex":{"type":"oauth","refresh":"legacy-r","access":"legacy-a","expires":1,"accountId":"acct-legacy"}}"#,
1521        )
1522        .unwrap();
1523        let astra2 = named(OAuthProviderId::OpenAiCodex, "astra2");
1524        save_provider_auth_at(&path, &astra2.storage_key(), &creds("r2", Some("acct-2"))).unwrap();
1525        let root = read_root(&path);
1526        assert_eq!(root["openai-codex"]["refresh"], "legacy-r", "default slot untouched");
1527        assert_eq!(root["openai-codex"]["accountId"], "acct-legacy");
1528        assert_eq!(root["openai-codex@astra2"]["refresh"], "r2");
1529        assert_eq!(root.len(), 2);
1530
1531        // Legacy readers keep resolving the bare key.
1532        let legacy = load_provider_auth_at(&path, "openai-codex").unwrap().unwrap();
1533        assert_eq!(legacy.refresh, "legacy-r");
1534        let slot = load_provider_auth_at(&path, &astra2.storage_key()).unwrap().unwrap();
1535        assert_eq!(slot.refresh, "r2");
1536        // The named slot is invisible under the bare key and vice versa.
1537        assert!(load_provider_auth_at(&path, "openai-codex@missing").unwrap().is_none());
1538    }
1539
1540    #[test]
1541    fn list_accounts_reports_default_named_and_malformed_without_secrets() {
1542        let dir = tempfile::tempdir().unwrap();
1543        let path = dir.path().join("auth.json");
1544        std::fs::write(
1545            &path,
1546            r#"{
1547              "openai-codex": {"type":"oauth","refresh":"R0","access":"A0","expires":10,"accountId":"2b2f0000-1111"},
1548              "openai-codex@astra2": {"type":"oauth","refresh":"R1","access":"A1","expires":20,"accountId":"7f530000-2222","label":"astra2","identity":"a@example.com","addedAt":99},
1549              "openai-codex@Bad Label": {"type":"oauth","refresh":"R2","access":"A2","expires":0},
1550              "openai-codex@default": {"type":"oauth","refresh":"R3","access":"A3","expires":0},
1551              "openai-codex@notoken": {"type":"oauth","expires":5},
1552              "openai-codex@badtype": {"type":"oauth","refresh":{"nested":"SECRET-VALUE"},"access":"A5","expires":5},
1553              "openai-codex@empty": {"type":"oauth","refresh":"","access":"","expires":5},
1554              "anthropic": {"type":"oauth","refresh":"R4","access":"A4","expires":30},
1555              "groq": {"type":"api_key","key":"gsk-secret"},
1556              "aws-bedrock": {"type":"cloud","cloud_state":{}}
1557            }"#,
1558        )
1559        .unwrap();
1560        let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::OpenAiCodex)).unwrap();
1561        assert_eq!(inv.accounts.len(), 2, "{:?}", inv.accounts);
1562        assert_eq!(inv.accounts[0].label, "default");
1563        assert_eq!(inv.accounts[0].account_id_prefix.as_deref(), Some("2b2f0000"));
1564        assert_eq!(
1565            inv.accounts[0].seat_fingerprint,
1566            super::super::account::seat_fingerprint(OAuthProviderId::OpenAiCodex, "2b2f0000-1111")
1567        );
1568        assert_ne!(inv.accounts[0].seat_fingerprint, inv.accounts[1].seat_fingerprint);
1569        assert_eq!(inv.accounts[0].expires, 10);
1570        assert_eq!(inv.accounts[1].label, "astra2");
1571        assert_eq!(inv.accounts[1].identity.as_deref(), Some("a@example.com"));
1572        assert_eq!(inv.accounts[1].added_at, Some(99));
1573        assert_eq!(inv.malformed_keys.len(), 5, "{:?}", inv.malformed_keys);
1574        for key in [
1575            "openai-codex@Bad Label",
1576            "openai-codex@default",
1577            "openai-codex@notoken",
1578            "openai-codex@badtype",
1579            "openai-codex@empty",
1580        ] {
1581            assert!(inv.malformed_keys.contains(&key.to_string()), "{key}");
1582        }
1583        let diag = format!("{:?}", inv);
1584        assert!(!diag.contains("SECRET-VALUE"), "diagnostics must not dump values");
1585
1586        let all = list_accounts_detailed_at(&path, None).unwrap();
1587        assert_eq!(all.accounts.len(), 3);
1588        assert!(all.accounts.iter().all(|a| a.provider != "groq" && a.provider != "aws-bedrock"));
1589        let json = serde_json::to_string(&all.accounts).unwrap();
1590        for secret in ["R0", "R1", "R4", "A0", "A1", "A4", "A5", "gsk-secret", "SECRET-VALUE"] {
1591            assert!(!json.contains(secret), "listing leaked {secret}: {json}");
1592        }
1593        assert!(!json.contains("2b2f0000-1111"), "full account id must not be exposed");
1594        assert!(any_oauth_credential_present_at(&path).unwrap());
1595    }
1596
1597    #[test]
1598    fn any_oauth_credential_present_accepts_non_anthropic_only_and_rejects_empty() {
1599        let dir = tempfile::tempdir().unwrap();
1600        let path = dir.path().join("auth.json");
1601        assert!(!any_oauth_credential_present_at(&path).unwrap(), "missing file");
1602        std::fs::write(&path, r#"{"groq":{"type":"api_key","key":"k"}}"#).unwrap();
1603        assert!(!any_oauth_credential_present_at(&path).unwrap(), "static keys are not OAuth");
1604        std::fs::write(
1605            &path,
1606            r#"{"kimi-code@m27":{"type":"oauth","refresh":"r","access":"a","expires":1}}"#,
1607        )
1608        .unwrap();
1609        assert!(any_oauth_credential_present_at(&path).unwrap(), "named Kimi-only install");
1610        std::fs::write(&path, "not json").unwrap();
1611        assert!(any_oauth_credential_present_at(&path).is_err());
1612    }
1613
1614    #[test]
1615    fn remove_credential_removes_exactly_one_key() {
1616        let dir = tempfile::tempdir().unwrap();
1617        let path = dir.path().join("auth.json");
1618        save_provider_auth_at(&path, "openai-codex", &creds("r0", None)).unwrap();
1619        save_provider_auth_at(&path, "openai-codex@a", &creds("ra", None)).unwrap();
1620        save_provider_auth_at(&path, "openai-codex@b", &creds("rb", None)).unwrap();
1621        assert!(remove_key_at(&path, "openai-codex@a").unwrap());
1622        let root = read_root(&path);
1623        assert_eq!(root.len(), 2);
1624        assert_eq!(root["openai-codex"]["refresh"], "r0");
1625        assert_eq!(root["openai-codex@b"]["refresh"], "rb");
1626        assert!(!remove_key_at(&path, "openai-codex@a").unwrap(), "idempotent");
1627        assert!(!path.with_extension("json.tmp").exists());
1628        // Removal never resets a corrupt file.
1629        std::fs::write(&path, "garbage{").unwrap();
1630        assert!(remove_key_at(&path, "openai-codex").is_err());
1631        assert_eq!(std::fs::read_to_string(&path).unwrap(), "garbage{");
1632    }
1633
1634    #[test]
1635    fn metadata_round_trip_and_preserved_across_refresh_write() {
1636        let dir = tempfile::tempdir().unwrap();
1637        let path = dir.path().join("auth.json");
1638        let cred = named(OAuthProviderId::Anthropic, "work");
1639        save_provider_auth_at(&path, &cred.storage_key(), &creds("r1", None)).unwrap();
1640        save_account_metadata_at(
1641            &path,
1642            &cred,
1643            &AccountMetadata {
1644                identity: Some("me@example.com".into()),
1645                added_at: Some(1234),
1646            },
1647        )
1648        .unwrap();
1649        // A refresh-style write carries only token fields.
1650        save_provider_auth_at(&path, &cred.storage_key(), &creds("r2", None)).unwrap();
1651        let root = read_root(&path);
1652        assert_eq!(root["anthropic@work"]["label"], "work");
1653        assert_eq!(root["anthropic@work"]["identity"], "me@example.com");
1654        assert_eq!(root["anthropic@work"]["addedAt"], 1234);
1655        assert_eq!(root["anthropic@work"]["refresh"], "r2");
1656        // Metadata on a missing slot is an error, not a phantom entry.
1657        assert!(save_account_metadata_at(
1658            &path,
1659            &named(OAuthProviderId::Anthropic, "nope"),
1660            &AccountMetadata::default()
1661        )
1662        .is_err());
1663        assert!(read_root(&path).get("anthropic@nope").is_none());
1664    }
1665
1666    #[test]
1667    fn set_slot_identity_sets_only_identity_fields_on_existing_slot() {
1668        let dir = tempfile::tempdir().unwrap();
1669        let path = dir.path().join("auth.json");
1670        // Hand-written entry with every field a real slot carries, plus
1671        // provider metadata that must survive untouched.
1672        std::fs::write(
1673            &path,
1674            r#"{
1675              "anthropic@claude1": {"type":"oauth","refresh":"R-claude1","access":"A-claude1","expires":1234567,"label":"claude1","addedAt":99,"identity":"old@example.com","metadata":{"tenant":"t1"}},
1676              "anthropic": {"type":"oauth","refresh":"R-default","access":"A-default","expires":7},
1677              "groq": {"type":"api_key","key":"gsk-secret"}
1678            }"#,
1679        )
1680        .unwrap();
1681        let before = read_root(&path);
1682        let cred = named(OAuthProviderId::Anthropic, "claude1");
1683
1684        set_slot_identity_at(
1685            &path,
1686            &cred,
1687            "  b8a1448d-0000-4000-8000-00000000000a  ",
1688            Some("  jr@example.com "),
1689        )
1690        .unwrap();
1691        let after = read_root(&path);
1692        let slot = after["anthropic@claude1"].as_object().unwrap();
1693        assert_eq!(
1694            slot["accountId"], "b8a1448d-0000-4000-8000-00000000000a",
1695            "trimmed"
1696        );
1697        assert_eq!(slot["identity"], "jr@example.com", "trimmed");
1698        // Every other field is byte-identical to what was stored.
1699        for field in [
1700            "type", "refresh", "access", "expires", "label", "addedAt", "metadata",
1701        ] {
1702            assert_eq!(
1703                slot.get(field),
1704                before["anthropic@claude1"].get(field),
1705                "{field} must be untouched"
1706            );
1707        }
1708        assert_eq!(
1709            slot.len(),
1710            before["anthropic@claude1"].as_object().unwrap().len() + 1,
1711            "exactly one new key (accountId); identity was overwritten in place"
1712        );
1713        // Sibling slots and non-OAuth entries are untouched.
1714        assert_eq!(after["anthropic"], before["anthropic"]);
1715        assert_eq!(after["groq"], before["groq"]);
1716        assert_eq!(after.len(), before.len());
1717        // The listing now sees the seat.
1718        let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::Anthropic)).unwrap();
1719        let row = inv.accounts.iter().find(|a| a.label == "claude1").unwrap();
1720        assert_eq!(row.account_id_prefix.as_deref(), Some("b8a1448d"));
1721        assert_eq!(row.identity.as_deref(), Some("jr@example.com"));
1722        assert!(row.seat_fingerprint.is_some());
1723
1724        // identity None keeps the stored identity; accountId is updated.
1725        set_slot_identity_at(&path, &cred, "other-seat", None).unwrap();
1726        let slot = &read_root(&path)["anthropic@claude1"];
1727        assert_eq!(slot["accountId"], "other-seat");
1728        assert_eq!(slot["identity"], "jr@example.com");
1729        // Empty/blank identity is ignored, never written as "".
1730        set_slot_identity_at(&path, &cred, "other-seat", Some("   ")).unwrap();
1731        assert_eq!(
1732            read_root(&path)["anthropic@claude1"]["identity"],
1733            "jr@example.com"
1734        );
1735
1736        // Backfilling the default slot makes the duplicate visible.
1737        set_slot_identity_at(
1738            &path,
1739            &CredentialRef::default_for(OAuthProviderId::Anthropic),
1740            "other-seat",
1741            None,
1742        )
1743        .unwrap();
1744        let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::Anthropic)).unwrap();
1745        assert_eq!(
1746            inv.duplicate_identity_keys,
1747            vec!["anthropic".to_string(), "anthropic@claude1".to_string()]
1748        );
1749        assert_eq!(read_root(&path)["anthropic"]["refresh"], "R-default");
1750    }
1751
1752    #[test]
1753    fn set_slot_identity_fails_closed_and_writes_nothing() {
1754        let dir = tempfile::tempdir().unwrap();
1755        let path = dir.path().join("auth.json");
1756        let cred = named(OAuthProviderId::Anthropic, "claude1");
1757        // Missing store: error, and no file/lock/dir side effects.
1758        assert!(set_slot_identity_at(&path, &cred, "seat", None).is_err());
1759        assert!(!path.exists());
1760        assert!(!path.with_extension("json.lock").exists());
1761
1762        std::fs::write(
1763            &path,
1764            r#"{"anthropic":{"type":"oauth","refresh":"R","access":"A","expires":1},"groq":{"type":"api_key","key":"k"}}"#,
1765        )
1766        .unwrap();
1767        let before = std::fs::read_to_string(&path).unwrap();
1768        // Missing slot.
1769        assert!(set_slot_identity_at(&path, &cred, "seat", Some("x@example.com")).is_err());
1770        assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
1771        assert!(
1772            read_root(&path).get("anthropic@claude1").is_none(),
1773            "no phantom slot"
1774        );
1775        // Empty / blank account id.
1776        let default = CredentialRef::default_for(OAuthProviderId::Anthropic);
1777        assert!(set_slot_identity_at(&path, &default, "", None).is_err());
1778        assert!(set_slot_identity_at(&path, &default, "   ", Some("x@example.com")).is_err());
1779        assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
1780        assert!(read_root(&path)["anthropic"].get("accountId").is_none());
1781        // Corrupt store: refused, never reset.
1782        std::fs::write(&path, "{{not json").unwrap();
1783        assert!(set_slot_identity_at(&path, &default, "seat", None).is_err());
1784        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
1785    }
1786
1787    #[test]
1788    fn cas_save_saved_removed_replaced() {
1789        let dir = tempfile::tempdir().unwrap();
1790        let path = dir.path().join("auth.json");
1791        let key = "openai-codex@astra2";
1792        save_provider_auth_at(&path, key, &creds("old", Some("acct-1"))).unwrap();
1793
1794        // Rotated credential without accountId must keep the stored one.
1795        let rotated = creds("old-rotated", None);
1796        assert_eq!(
1797            save_provider_auth_if_refresh_matches_at(&path, key, "old", &rotated).unwrap(),
1798            CasOutcome::Saved
1799        );
1800        let root = read_root(&path);
1801        assert_eq!(root[key]["refresh"], "old-rotated");
1802        assert_eq!(root[key]["accountId"], "acct-1", "accountId preserved");
1803
1804        // Re-login replaced the slot meanwhile: stale rotation must not win.
1805        save_provider_auth_at(&path, key, &creds("fresh-login", Some("acct-9"))).unwrap();
1806        assert_eq!(
1807            save_provider_auth_if_refresh_matches_at(&path, key, "old-rotated", &creds("stale", None))
1808                .unwrap(),
1809            CasOutcome::Replaced
1810        );
1811        assert_eq!(read_root(&path)[key]["refresh"], "fresh-login");
1812
1813        // Slot removed meanwhile: nothing is resurrected.
1814        remove_key_at(&path, key).unwrap();
1815        assert_eq!(
1816            save_provider_auth_if_refresh_matches_at(&path, key, "fresh-login", &creds("x", None))
1817                .unwrap(),
1818            CasOutcome::Removed
1819        );
1820        assert!(read_root(&path).get(key).is_none());
1821        // Missing file: nothing is created either (and the caller can tell
1822        // a missing store from a removed slot).
1823        std::fs::remove_file(&path).unwrap();
1824        assert_eq!(
1825            save_provider_auth_if_refresh_matches_at(&path, key, "x", &creds("x", None)).unwrap(),
1826            CasOutcome::StoreMissing
1827        );
1828        assert!(!path.exists());
1829    }
1830
1831    #[test]
1832    fn locked_login_persist_refuses_duplicate_seat_and_writes_metadata_atomically() {
1833        let dir = tempfile::tempdir().unwrap();
1834        let path = dir.path().join("auth.json");
1835        save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
1836        let astra2 = named(OAuthProviderId::OpenAiCodex, "astra2");
1837        let meta = AccountMetadata {
1838            identity: Some("a@example.com".into()),
1839            added_at: Some(100),
1840        };
1841        // Same seat under a new label: refused, nothing written.
1842        let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("rx", Some("acct-A")), &meta)
1843            .unwrap();
1844        assert_eq!(out.evidence, IdentityEvidence::Duplicate(Account::Default));
1845        assert!(!out.saved);
1846        assert!(read_root(&path).get("openai-codex@astra2").is_none());
1847        // Same refresh token (identical login) under a new label: refused too.
1848        let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("r0", None), &meta).unwrap();
1849        assert_eq!(out.evidence, IdentityEvidence::Duplicate(Account::Default));
1850        assert!(!out.saved);
1851        // Distinct seat: saved with label/identity/addedAt.
1852        let out = save_credential_unless_duplicate_at(&path, &astra2, &creds("r2", Some("acct-B")), &meta)
1853            .unwrap();
1854        assert_eq!(out.evidence, IdentityEvidence::NoDuplicate);
1855        assert!(out.saved);
1856        let root = read_root(&path);
1857        assert_eq!(root["openai-codex@astra2"]["label"], "astra2");
1858        assert_eq!(root["openai-codex@astra2"]["identity"], "a@example.com");
1859        assert_eq!(root["openai-codex@astra2"]["addedAt"], 100);
1860        assert_eq!(root["openai-codex"]["refresh"], "r0", "default slot untouched");
1861        // Re-login into the same slot with a different seat and no identity:
1862        // stale identity/accountId are cleared, addedAt updated.
1863        let out = save_credential_unless_duplicate_at(
1864            &path,
1865            &astra2,
1866            &creds("r3", None),
1867            &AccountMetadata {
1868                identity: None,
1869                added_at: Some(200),
1870            },
1871        )
1872        .unwrap();
1873        assert_eq!(out.evidence, IdentityEvidence::NoEvidence);
1874        assert!(out.saved);
1875        let root = read_root(&path);
1876        assert!(root["openai-codex@astra2"].get("identity").is_none());
1877        assert!(root["openai-codex@astra2"].get("accountId").is_none());
1878        assert_eq!(root["openai-codex@astra2"]["addedAt"], 200);
1879        assert_eq!(root["openai-codex@astra2"]["refresh"], "r3");
1880        // Corrupt store: refused, never reset.
1881        std::fs::write(&path, "{{not json").unwrap();
1882        assert!(save_credential_unless_duplicate_at(&path, &astra2, &creds("r4", None), &meta).is_err());
1883        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
1884        assert!(save_credential_at(&path, &astra2, &creds("r4", None)).is_err());
1885        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{{not json");
1886    }
1887
1888    #[test]
1889    fn inventory_flags_duplicate_seats() {
1890        let dir = tempfile::tempdir().unwrap();
1891        let path = dir.path().join("auth.json");
1892        save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
1893        save_provider_auth_at(&path, "openai-codex@b", &creds("rb", Some("acct-A"))).unwrap();
1894        save_provider_auth_at(&path, "openai-codex@c", &creds("rc", Some("acct-C"))).unwrap();
1895        let inv = list_accounts_detailed_at(&path, Some(OAuthProviderId::OpenAiCodex)).unwrap();
1896        assert_eq!(
1897            inv.duplicate_identity_keys,
1898            vec!["openai-codex".to_string(), "openai-codex@b".to_string()]
1899        );
1900    }
1901
1902    #[test]
1903    fn duplicate_identity_detects_same_seat_under_other_label() {
1904        let dir = tempfile::tempdir().unwrap();
1905        let path = dir.path().join("auth.json");
1906        save_provider_auth_at(&path, "openai-codex", &creds("r0", Some("acct-A"))).unwrap();
1907        save_provider_auth_at(&path, "openai-codex@b", &creds("rb", Some("acct-B"))).unwrap();
1908        save_provider_auth_at(&path, "anthropic@x", &creds("rx", Some("acct-A"))).unwrap();
1909        let dup = find_duplicate_identity_at(
1910            &path,
1911            OAuthProviderId::OpenAiCodex,
1912            "acct-A",
1913            &Account::named("new").unwrap(),
1914        )
1915        .unwrap();
1916        assert_eq!(dup, Some(Account::Default));
1917        // Re-login into the same slot is not a duplicate of itself.
1918        assert_eq!(
1919            find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "acct-A", &Account::Default)
1920                .unwrap(),
1921            None
1922        );
1923        // Other providers never collide; unknown ids never match; empty never matches.
1924        assert_eq!(
1925            find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "acct-Z", &Account::Default)
1926                .unwrap(),
1927            None
1928        );
1929        assert_eq!(
1930            find_duplicate_identity_at(&path, OAuthProviderId::OpenAiCodex, "", &Account::Default)
1931                .unwrap(),
1932            None
1933        );
1934    }
1935}
1936
1937/// m2: a wedged process holding `auth.json.lock` must not block a saver
1938/// (and with it a refresh-runtime worker) forever.
1939#[cfg(test)]
1940mod store_lock_wait_tests {
1941    use super::*;
1942    use std::time::{Duration, Instant};
1943
1944    fn hold_store_lock(path: &Path) -> std::fs::File {
1945        use fs4::fs_std::FileExt;
1946        let lock = std::fs::OpenOptions::new()
1947            .create(true)
1948            .truncate(false)
1949            .write(true)
1950            .open(path.with_extension("json.lock"))
1951            .unwrap();
1952        assert!(FileExt::try_lock_exclusive(&lock).unwrap());
1953        lock
1954    }
1955
1956    #[test]
1957    fn store_lock_wait_has_a_deadline() {
1958        let dir = tempfile::tempdir().unwrap();
1959        let path = dir.path().join("auth.json");
1960        std::fs::write(&path, "{}").unwrap();
1961        let _held = hold_store_lock(&path);
1962        let started = Instant::now();
1963        let err = with_locked_root_typed(&path, false, Duration::from_millis(200), |_| {
1964            Ok((false, ()))
1965        })
1966        .unwrap_err();
1967        assert!(matches!(err, StoreError::Busy(_)), "{err:?}");
1968        assert!(started.elapsed() < Duration::from_secs(5));
1969        assert!(String::from(err).contains("auth.json.lock"));
1970    }
1971
1972    #[test]
1973    fn store_lock_is_taken_once_free() {
1974        let dir = tempfile::tempdir().unwrap();
1975        let path = dir.path().join("auth.json");
1976        std::fs::write(&path, "{}").unwrap();
1977        let held = hold_store_lock(&path);
1978        let release = std::thread::spawn(move || {
1979            std::thread::sleep(Duration::from_millis(100));
1980            drop(held);
1981        });
1982        with_locked_root_typed(&path, false, Duration::from_secs(10), |_| Ok((false, ())))
1983            .expect("lock is acquired after the holder lets go");
1984        release.join().unwrap();
1985    }
1986
1987    #[test]
1988    fn production_store_lock_wait_is_bounded() {
1989        assert!(STORE_LOCK_WAIT >= Duration::from_secs(5));
1990        assert!(STORE_LOCK_WAIT <= Duration::from_secs(30));
1991    }
1992}
1993
1994/// Round-2 I2: a corrupt store is a typed error that names the file and the
1995/// exact command to move it aside, on the legacy, account and load paths.
1996#[cfg(test)]
1997mod corrupt_store_error_tests {
1998    use super::*;
1999
2000    fn assert_actionable(msg: &str, path: &Path) {
2001        let p = path.display().to_string();
2002        assert!(msg.contains(&format!("mv '{p}' '{p}.broken-")), "{msg}");
2003        assert!(msg.contains("not valid JSON"), "{msg}");
2004        assert!(!msg.contains("synaps login"), "{msg}");
2005    }
2006
2007    #[test]
2008    fn corrupt_store_error_names_the_file_and_the_mv_command() {
2009        let dir = tempfile::tempdir().unwrap();
2010        let path = dir.path().join("auth.json");
2011        std::fs::write(&path, "{\"anthropic\":{").unwrap();
2012        let cred = CredentialRef::parse_storage_key("anthropic@work").unwrap();
2013        let creds = OAuthCredentials {
2014            auth_type: "oauth".into(),
2015            refresh: "r".into(),
2016            access: "a".into(),
2017            expires: 1,
2018            account_id: None,
2019        };
2020        // Legacy (login) save and account-scoped save: same text.
2021        assert_actionable(&save_provider_auth_at(&path, "kimi-code", &creds).unwrap_err(), &path);
2022        assert_actionable(&save_credential_at(&path, &cred, &creds).unwrap_err(), &path);
2023        // Typed: both the login save and the load say Corrupt.
2024        for err in [
2025            save_provider_auth_typed_at(&path, "muse", &creds).unwrap_err(),
2026            load_provider_auth_typed_at(&path, "anthropic").unwrap_err(),
2027        ] {
2028            let StoreError::Corrupt(msg) = err else {
2029                panic!("expected Corrupt, got {err:?}");
2030            };
2031            assert_actionable(&msg, &path);
2032        }
2033        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"anthropic\":{");
2034    }
2035
2036    #[test]
2037    fn shell_quoting_survives_a_quote_in_the_path() {
2038        assert_eq!(shell_quote("/a/it's/auth.json"), r#"'/a/it'\''s/auth.json'"#);
2039    }
2040}
2041
2042/// R2-m1: removing an account also removes its parked refresh sidecar (a
2043/// live refresh token for an account the user removed).
2044#[cfg(test)]
2045mod remove_sidecar_tests {
2046    use super::*;
2047
2048    #[test]
2049    fn removing_a_slot_deletes_its_refresh_sidecar() {
2050        let dir = tempfile::tempdir().unwrap();
2051        let path = dir.path().join("auth.json");
2052        std::fs::write(&path, r#"{"anthropic@work":{"type":"oauth"},"anthropic":{"type":"oauth"}}"#)
2053            .unwrap();
2054        let work = refresh_pending_path(&path, "anthropic@work");
2055        let other = refresh_pending_path(&path, "anthropic");
2056        assert_eq!(
2057            work,
2058            dir.path().join("auth.json.anthropic@work.refresh.pending")
2059        );
2060        std::fs::write(&work, "{}").unwrap();
2061        std::fs::write(&other, "{}").unwrap();
2062        assert!(remove_key_at(&path, "anthropic@work").unwrap());
2063        assert!(!work.exists(), "removed slot's sidecar is gone");
2064        assert!(other.exists(), "other slots keep theirs");
2065        // Even when the slot was already gone from the store.
2066        std::fs::write(&work, "{}").unwrap();
2067        assert!(!remove_key_at(&path, "anthropic@work").unwrap());
2068        assert!(!work.exists());
2069    }
2070}