use std::fs::File; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use super::model::{API_KEY_SCOPE, AuthMode, AuthStore, KimiAuth, lookup_auth}; // ── System-keyring storage for the Kimi Code OAuth session ───────────── // // PRD F1: the OAuth token set lives in the system keyring (service `kigi`, // entry `oauth/kimi-code`); when the keyring is unavailable we fall back to // the file mechanism below (`auth.json`, owner-only, atomic writes). The // official Kimi client's keyring entries (service `kimi-code`) and `~/.kimi` // files are never touched. /// Keyring service name — deliberately distinct from the official client's /// `kimi-code` service. #[cfg(any(target_os = "macos", windows))] pub(crate) const KEYRING_SERVICE: &str = "kigi"; /// Outcome of a keyring read for the session scope. #[derive(Debug)] pub(crate) enum KeyringRead { /// Backend reachable and the entry exists. Found(Box), /// Backend reachable, no entry stored. Missing, /// Keyring disabled, unsupported on this platform, or the backend /// errored — callers fall back to the file store. Unavailable, } /// Whether keyring storage participates for the session credential. /// /// Disabled when: /// - the platform has no supported backend (non-macOS/Windows builds), /// - `KIGI_DISABLE_KEYRING` is set to a truthy value, /// - a non-default credential location is in use (`KIGI_SHARE_DIR` / /// `KIGI_AUTH_PATH`): the keyring entry belongs to the default user /// install; alternate profiles (and tests) stay file-scoped, and /// - in unit-test builds, unless a test explicitly opted into the mock /// keyring via [`enable_mock_keyring_for_test`]. pub(crate) fn keyring_enabled() -> bool { #[cfg(test)] { // Thread-local so keyring-specific tests (which opt in via // `enable_mock_keyring_for_test`) can't leak the toggle into // concurrently running persistence tests on other threads. TEST_KEYRING_ENABLED.with(|flag| flag.get()) } #[cfg(not(test))] { #[cfg(not(any(target_os = "macos", windows)))] { false } #[cfg(any(target_os = "macos", windows))] { let disabled = std::env::var("KIGI_DISABLE_KEYRING") .is_ok_and(|v| !matches!(v.trim(), "" | "0" | "false" | "off" | "no")); !disabled && std::env::var_os("KIGI_SHARE_DIR").is_none() && std::env::var_os("KIGI_AUTH_PATH").is_none() } } } #[cfg(test)] thread_local! { static TEST_KEYRING_ENABLED: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Route keyring calls through an in-memory mock store for this process, /// enable [`keyring_enabled`] on this thread, and clear any entry left by an /// earlier test. Tests using this must serialize on the `kigi_keyring` key: /// the mock entry is shared process-wide. #[cfg(all(test, any(target_os = "macos", windows)))] pub(crate) fn enable_mock_keyring_for_test() { keyring::set_default_credential_builder(keyring::mock::default_credential_builder()); TEST_KEYRING_ENABLED.with(|flag| flag.set(true)); if let Err(e) = keyring_delete_session() { panic!("mock keyring cleanup failed: {e}"); } } /// Disable the test keyring again (paired with /// [`enable_mock_keyring_for_test`] in an RAII guard or test teardown). #[cfg(test)] pub(crate) fn disable_mock_keyring_for_test() { TEST_KEYRING_ENABLED.with(|flag| flag.set(false)); } /// The process-wide keyring entry handle. Cached so every reader/writer talks /// to the same credential object: real backends read live state from the OS /// store on each call, and the test mock keeps its state on the entry itself. #[cfg(any(target_os = "macos", windows))] fn keyring_entry() -> Result<&'static keyring::Entry, keyring::Error> { static ENTRY: std::sync::OnceLock> = std::sync::OnceLock::new(); match ENTRY .get_or_init(|| keyring::Entry::new(KEYRING_SERVICE, crate::auth::KIMI_CODE_OAUTH_SCOPE)) { Ok(entry) => Ok(entry), // `keyring::Error` is not `Clone`; surface a stable equivalent. Err(e) => { tracing::warn!(error = %e, "auth: keyring entry construction failed"); Err(keyring::Error::Invalid( "keyring entry".into(), e.to_string(), )) } } } /// Read the session credential from the system keyring. #[cfg(any(target_os = "macos", windows))] pub(crate) fn keyring_read_session() -> KeyringRead { if !keyring_enabled() { return KeyringRead::Unavailable; } let entry = match keyring_entry() { Ok(entry) => entry, Err(e) => { tracing::warn!(error = %e, "auth: keyring entry unavailable, falling back to file"); return KeyringRead::Unavailable; } }; match entry.get_password() { Ok(raw) => match serde_json::from_str::(&raw) { Ok(auth) => KeyringRead::Found(Box::new(auth)), Err(e) => { tracing::warn!(error = %e, "auth: keyring entry is not valid JSON, ignoring"); KeyringRead::Missing } }, Err(keyring::Error::NoEntry) => KeyringRead::Missing, Err(e) => { tracing::warn!(error = %e, "auth: keyring read failed, falling back to file"); KeyringRead::Unavailable } } } #[cfg(not(any(target_os = "macos", windows)))] pub(crate) fn keyring_read_session() -> KeyringRead { KeyringRead::Unavailable } /// Write the session credential to the system keyring. #[cfg(any(target_os = "macos", windows))] pub(crate) fn keyring_write_session(auth: &KimiAuth) -> anyhow::Result<()> { anyhow::ensure!(keyring_enabled(), "keyring storage disabled"); let payload = serde_json::to_string(auth)?; keyring_entry()?.set_password(&payload)?; tracing::info!("auth: session credential written to system keyring"); Ok(()) } #[cfg(not(any(target_os = "macos", windows)))] pub(crate) fn keyring_write_session(_auth: &KimiAuth) -> anyhow::Result<()> { anyhow::bail!("keyring storage is not supported on this platform") } /// Delete the session credential from the system keyring (Ok when absent). #[cfg(any(target_os = "macos", windows))] pub(crate) fn keyring_delete_session() -> anyhow::Result<()> { if !keyring_enabled() { return Ok(()); } match keyring_entry()?.delete_credential() { Ok(()) => { tracing::info!("auth: session credential removed from system keyring"); Ok(()) } Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(e.into()), } } #[cfg(not(any(target_os = "macos", windows)))] pub(crate) fn keyring_delete_session() -> anyhow::Result<()> { Ok(()) } /// RAII guard for an exclusive advisory lock on `auth.json.lock`. /// The lock is released when the inner `File` is dropped (closing the FD). pub(crate) struct AuthFileLock { pub(super) _file: File, } impl AuthFileLock { /// Returns `true` while this guard still refers to the **live** /// `auth.json.lock` inode. /// /// A waiter that finds a holder stuck past the stale-lock timeout breaks /// the lock by `unlink`ing the file and recreating it on a fresh inode /// (see [`crate::auth::manager::lock`]). The usual cause of a "stuck" /// holder is a process **suspended across system sleep** while holding the /// lock: it stays alive (so the kernel never releases its flock) yet makes /// no progress, so siblings break it. When such a holder resumes, its /// flock lives on the now-deleted inode — it no longer holds the live lock /// even though this `AuthFileLock` still exists. /// /// Callers about to perform an irreversible, lock-protected action /// (sending a refresh token to the IdP, writing `auth.json`) MUST /// re-validate first; otherwise two processes can spend the same refresh /// token and trip token-family revocation. /// /// Non-Unix has no inode concept, so this conservatively returns `true`. #[cfg(unix)] pub(crate) fn still_live(&self, auth_json_path: &Path) -> bool { use std::os::unix::fs::MetadataExt; let lock_path = auth_json_path.with_file_name("auth.json.lock"); let (Ok(fd_meta), Ok(path_meta)) = (self._file.metadata(), std::fs::metadata(&lock_path)) else { // Lock file gone or unreadable → we no longer hold the live lock. return false; }; fd_meta.ino() == path_meta.ino() && fd_meta.dev() == path_meta.dev() } #[cfg(not(unix))] pub(crate) fn still_live(&self, _auth_json_path: &Path) -> bool { true } } pub fn read_auth_json(auth_file: &Path) -> std::io::Result { let mut file = File::open(auth_file)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; // Empty files are valid (recover from prior crash/partial write). let trimmed = contents.trim(); if trimmed.is_empty() { return Ok(AuthStore::new()); } let map = serde_json::from_str(trimmed) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; Ok(map) } /// Read auth.json, returning an empty map if the file does not exist. /// /// Non-empty corrupt JSON, permission errors, etc. are returned as errors /// so the caller can decide whether to skip the write (to avoid clobbering /// sibling scopes). /// /// Kept for the test-only `persist_and_swap` and as a strict reader. #[cfg_attr( not(test), expect( dead_code, reason = "used from tests only; remove expect when wired in production" ) )] pub(crate) fn read_auth_json_or_empty(auth_file: &Path) -> std::io::Result { match read_auth_json(auth_file) { Ok(map) => Ok(map), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()), Err(e) => Err(e), } } /// Best-effort backup of a corrupt (unparseable) auth.json. /// /// If the file exists and `read_auth_json` fails with `InvalidData`, /// it is renamed to `auth.json.corrupt.` (sibling in the same /// directory) and the backup path is returned. Used before recovery /// writes so the original bytes are never silently lost. pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option { if !path.exists() { return None; } if read_auth_json(path).is_ok() { return None; } let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(); let file_name = path .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "auth.json".to_string()); let backup_name = format!("{}.corrupt.{}", file_name, ts); let backup = path.with_file_name(backup_name); match std::fs::rename(path, &backup) { Ok(()) => { tracing::warn!( original = %path.display(), backup = %backup.display(), "auth: backed up corrupt auth.json before recovery write" ); // Must reach unified.jsonl: the tracing line above is invisible // in production captures, and this is the only record of both // the corruption and where the original bytes went. kigi_log::unified_log::error( "auth: corrupt auth.json backed up", None, Some(serde_json::json!({ "original": path.display().to_string(), "backup": backup.display().to_string(), })), ); Some(backup) } Err(e) => { tracing::warn!(error = %e, "auth: failed to rename corrupt auth.json for backup"); kigi_log::unified_log::error( "auth: corrupt auth.json backup failed", None, Some(serde_json::json!({ "original": path.display().to_string(), "error": e.to_string(), })), ); None } } } /// Read auth.json for an upcoming write, with recovery for corrupt files. /// /// - Missing/empty → empty map (safe to write fresh) /// - Valid JSON → parsed map /// - Non-empty corrupt JSON → backs up to `auth.json.corrupt.`, /// then returns empty map so the caller can write the new credential. /// /// Other I/O errors (PermissionDenied, etc.) are still returned as errors. pub(crate) fn read_auth_json_or_empty_recovering_corrupt( auth_file: &Path, ) -> std::io::Result { match read_auth_json(auth_file) { Ok(map) => Ok(map), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()), Err(e) if e.kind() == std::io::ErrorKind::InvalidData => { let _ = backup_corrupt_auth_file(auth_file); Ok(AuthStore::new()) } Err(e) => Err(e), } } /// Persist `auth.json`, preferring a crash-safe atomic write but falling /// back to a non-atomic in-place write when the disk is full. /// /// The atomic path (temp + rename) needs free space >= the file size, /// because the old file and a full temp copy coexist until the rename. On a /// nearly-full disk that temp copy can fail with `StorageFull` (ENOSPC) /// even though the credentials themselves are tiny. When that happens we /// retry with an in-place truncate+write, which only needs the freed blocks /// of the old file — far less than the temp-copy approach. /// /// The in-place path is non-atomic, with two accepted trade-offs: /// - If the in-place write itself fails (e.g. a concurrent process grabs the /// just-freed blocks, or a crash mid-write), the prior bytes are restored /// best-effort so a torn/empty file never *replaces* the previous on-disk /// credential — on-disk state ends up no worse than before the attempt. /// - Unlocked concurrent readers can still observe a torn (partial) file /// during the brief write window; a partial file is healed on the next /// read via [`read_auth_json_or_empty_recovering_corrupt`] (backup + /// relogin). This window is inherent to any sub-1×-free single-file /// replace and is preferable to persisting nothing at all, which would /// leave every concurrent process with a stale, already-revoked token. pub(super) fn write_auth_json(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> { write_auth_json_with(auth_file, auth_store, write_auth_json_atomic) } /// Dispatch helper: run `atomic`, and on `StorageFull` fall back to an /// in-place write. Split out (with `atomic` injectable) so the disk-full /// fallback is unit-testable without an actually-full filesystem. fn write_auth_json_with( auth_file: &Path, auth_store: &AuthStore, atomic: fn(&Path, &AuthStore) -> std::io::Result<()>, ) -> std::io::Result<()> { match atomic(auth_file, auth_store) { Err(e) if e.kind() == std::io::ErrorKind::StorageFull => { tracing::warn!( path = %auth_file.display(), "auth: disk full during atomic write, falling back to in-place write" ); // Must reach unified.jsonl: a silent in-memory-only credential // (the prior behavior) leaves sibling processes with a stale // refresh token and no record of why. Surface it loudly. kigi_log::unified_log::warn( "auth: disk full, falling back to non-atomic in-place write", None, Some(serde_json::json!({ "path": auth_file.display().to_string(), })), ); write_auth_json_in_place(auth_file, auth_store) } other => other, } } /// Serialize `auth_store` to `path` (truncate + rewrite), owner-only (0o600) /// and `fsync`'d. Shared core of the atomic path (which targets the temp /// file) and the in-place fallback (which targets `auth.json` directly). /// /// Uses streaming `to_writer_pretty` through a `BufWriter` to avoid /// allocating the entire JSON string in memory — eliminates OOM risk under /// severe memory pressure. fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> { use crate::util::secure_file::open_secure_file; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let file = open_secure_file(path)?; let mut writer = std::io::BufWriter::new(file); serde_json::to_writer_pretty(&mut writer, auth_store) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; writer.flush()?; writer .into_inner() .map_err(|e| e.into_error())? .sync_all()?; #[cfg(windows)] { crate::util::secure_file::set_windows_secure_permissions(path)?; } Ok(()) } /// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`, /// which this site's inline delete-first pattern graduated into). fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> { let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id())); write_store_to(&tmp, auth_store)?; crate::util::fs::replace_file(&tmp, auth_file) } /// Non-atomic fallback: truncate and rewrite `auth.json` in place. /// /// Used only when [`write_auth_json_atomic`] fails with `StorageFull`. /// Opening with truncation first frees the old content's blocks before the /// new bytes are written, so this needs only the file size in free space /// rather than the temp-copy approach's file-size-of-headroom. /// /// Truncation is destructive, so the prior bytes are snapshotted first and /// restored best-effort if the rewrite fails partway — a failed fallback /// must not leave an empty/torn file where a parseable (if stale) credential /// used to be. A partial file that survives (because even the restore failed) /// is healed on the next read via [`read_auth_json_or_empty_recovering_corrupt`]. fn write_auth_json_in_place(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> { write_auth_json_in_place_with(auth_file, auth_store, write_store_to) } /// Inner of [`write_auth_json_in_place`] with `write` injectable so the /// rollback-on-failure path is unit-testable without an actually-full disk. fn write_auth_json_in_place_with( auth_file: &Path, auth_store: &AuthStore, write: fn(&Path, &AuthStore) -> std::io::Result<()>, ) -> std::io::Result<()> { // Snapshot the prior bytes so a torn/empty write can be rolled back to // the previous on-disk credential. `None` when the file is absent. let prior = std::fs::read(auth_file).ok(); match write(auth_file, auth_store) { Ok(()) => Ok(()), Err(e) => { if let Some(prior) = prior && let Err(restore_err) = restore_prior_bytes(auth_file, &prior) { tracing::warn!( error = %restore_err, "auth: failed to restore prior auth.json after in-place write failure" ); } Err(e) } } } /// Best-effort rollback: rewrite `bytes` (owner-only, `fsync`'d) after a /// failed in-place write so a torn/empty file does not replace the prior /// credential. fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> { use crate::util::secure_file::open_secure_file; let mut file = open_secure_file(auth_file)?; file.write_all(bytes)?; file.sync_all()?; #[cfg(windows)] { crate::util::secure_file::set_windows_secure_permissions(auth_file)?; } Ok(()) } /// Read a single auth token from `auth.json` by scope key. pub fn read_token_by_scope(kigi_home: &Path, scope: &str) -> anyhow::Result { let path = kigi_home.join("auth.json"); let store = read_auth_json(&path).map_err(|_| anyhow::anyhow!("Not logged in. Run `kigi login`."))?; lookup_auth(&store, scope).map(|a| a.key).ok_or_else(|| { anyhow::anyhow!("Your auth token is invalid. Run `kigi login` to re-authenticate.") }) } /// Read the API key from the `kigi::api_key` scope in auth.json. pub fn read_api_key(kigi_home: &Path) -> Option { let path = kigi_home.join("auth.json"); let map = read_auth_json(&path).ok()?; map.get(API_KEY_SCOPE).map(|a| a.key.clone()) } /// Store a plain API key in auth.json under the `kigi::api_key` scope. /// /// Uses the corrupt-recovery reader so a malformed auth.json (e.g. from a /// previous crash) can be healed when the user sets an API key. pub fn store_api_key(kigi_home: &Path, api_key: &str) -> std::io::Result<()> { let path = kigi_home.join("auth.json"); let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?; map.insert( API_KEY_SCOPE.to_owned(), KimiAuth { key: api_key.to_owned(), auth_mode: AuthMode::ApiKey, ..Default::default() }, ); write_auth_json(&path, &map) } /// Read an API-key platform's key from auth.json. The scope is the platform /// id itself (`anthropic`, `moonshot-cn`, …) — the stable per-provider /// auth.json key contract. `None` when absent or unreadable. pub fn read_platform_api_key( kigi_home: &Path, platform: kigi_models::PlatformId, ) -> Option { let path = kigi_home.join("auth.json"); let map = read_auth_json(&path).ok()?; map.get(platform.as_str()).map(|a| a.key.clone()) } /// Store an API-key platform's key in auth.json under its platform-id scope. /// Same corrupt-recovery + atomic-write path as [`store_api_key`]; all other /// scopes (OAuth session, other platforms) are preserved. /// /// SECURITY: the key must never be logged; errors carry only IO context. pub fn store_platform_api_key( kigi_home: &Path, platform: kigi_models::PlatformId, api_key: &str, ) -> std::io::Result<()> { if platform.uses_oauth() { // Real error, not debug_assert: an OAuth scope written here would // shadow the session entry in release builds too. return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "{} authenticates via OAuth and takes no API key", platform.as_str() ), )); } let path = kigi_home.join("auth.json"); // Serialize with the manager's cross-process auth.json writers (token // refresh holds the same flock): an unlocked read-modify-write here // could write back a pre-refresh map and revert a rotated refresh // token (token-family revocation → forced re-login). Bounded retry; // a sustained holder fails loudly rather than racing. let mut lock = None; for _ in 0..20 { lock = super::manager::try_lock_auth_file_nonblocking(&path); if lock.is_some() { break; } std::thread::sleep(std::time::Duration::from_millis(25)); } let Some(_lock) = lock else { return Err(std::io::Error::new( std::io::ErrorKind::WouldBlock, "auth.json is locked by another kigi process; try again", )); }; let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?; map.insert( platform.as_str().to_owned(), KimiAuth { key: api_key.to_owned(), auth_mode: AuthMode::ApiKey, ..Default::default() }, ); write_auth_json(&path, &map) } /// Remove the `kigi::api_key` scope from auth.json. pub fn clear_api_key(kigi_home: &Path) -> std::io::Result<()> { let path = kigi_home.join("auth.json"); if let Ok(mut map) = read_auth_json(&path) { map.remove(API_KEY_SCOPE); if map.is_empty() { let _ = std::fs::remove_file(&path); } else { write_auth_json(&path, &map)?; } } Ok(()) } #[cfg(test)] mod platform_key_tests { use super::*; /// Store/read round-trip under the platform-id scope; the OAuth session /// scope and other platform scopes in the same file are preserved. #[test] fn platform_key_round_trip_preserves_other_scopes() { let dir = tempfile::tempdir().unwrap(); let home = dir.path(); // Pre-existing OAuth session entry must survive platform-key writes. let path = home.join("auth.json"); let mut map = AuthStore::new(); map.insert( crate::auth::KIMI_CODE_OAUTH_SCOPE.to_owned(), KimiAuth { key: "oauth-token".to_owned(), auth_mode: AuthMode::OAuth, ..Default::default() }, ); write_auth_json(&path, &map).unwrap(); store_platform_api_key(home, kigi_models::PlatformId::MoonshotCn, "sk-cn").unwrap(); store_platform_api_key(home, kigi_models::PlatformId::MoonshotAi, "sk-ai").unwrap(); assert_eq!( read_platform_api_key(home, kigi_models::PlatformId::MoonshotCn).as_deref(), Some("sk-cn") ); assert_eq!( read_platform_api_key(home, kigi_models::PlatformId::MoonshotAi).as_deref(), Some("sk-ai") ); let stored = read_auth_json(&path).unwrap(); assert_eq!( stored .get(crate::auth::KIMI_CODE_OAUTH_SCOPE) .map(|a| a.key.as_str()), Some("oauth-token"), "platform-key writes must not clobber the OAuth session scope" ); assert_eq!( stored.get("moonshot-cn").map(|a| a.auth_mode.clone()), Some(AuthMode::ApiKey), "platform keys are stored as api_key mode under the platform id" ); } /// The OAuth platform takes no API key — a real error in release builds. #[test] fn storing_key_for_oauth_platform_is_invalid_input() { let dir = tempfile::tempdir().unwrap(); let err = store_platform_api_key(dir.path(), kigi_models::PlatformId::KimiCode, "sk-x") .expect_err("oauth platform must reject api keys"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert!( !dir.path().join("auth.json").exists(), "rejected write must not create auth.json" ); } /// Missing file reads as None (not an error) — resolution treats absent /// auth.json as "no stored key". #[test] fn reading_platform_key_without_auth_json_is_none() { let dir = tempfile::tempdir().unwrap(); assert_eq!( read_platform_api_key(dir.path(), kigi_models::PlatformId::MoonshotCn), None ); } } #[cfg(test)] mod write_fallback_tests { use super::*; fn sample_store() -> AuthStore { let mut map = AuthStore::new(); map.insert( API_KEY_SCOPE.to_owned(), KimiAuth { key: "secret-key".to_owned(), auth_mode: AuthMode::ApiKey, ..Default::default() }, ); map } fn read_key(path: &Path) -> Option { read_auth_json(path) .ok() .and_then(|m| m.get(API_KEY_SCOPE).map(|a| a.key.clone())) } fn fake_storage_full(_: &Path, _: &AuthStore) -> std::io::Result<()> { Err(std::io::Error::from(std::io::ErrorKind::StorageFull)) } fn fake_permission_denied(_: &Path, _: &AuthStore) -> std::io::Result<()> { Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) } /// Simulates an in-place write that truncates the file (destroying the /// old content, as `open_secure_file` does) and then fails partway — the /// torn-write case the rollback must recover from. fn fake_truncate_then_fail(path: &Path, _: &AuthStore) -> std::io::Result<()> { crate::util::secure_file::open_secure_file(path)?; // truncates to 0 bytes Err(std::io::Error::from(std::io::ErrorKind::StorageFull)) } #[test] fn in_place_write_roundtrips() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("auth.json"); write_auth_json_in_place(&path, &sample_store()).unwrap(); assert_eq!(read_key(&path).as_deref(), Some("secret-key")); } #[cfg(unix)] #[test] fn in_place_write_is_owner_only() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("auth.json"); write_auth_json_in_place(&path, &sample_store()).unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600, "in-place write must stay 0o600"); } /// A `StorageFull` (ENOSPC) failure on the atomic path must fall back to /// the in-place write so the credential still lands on disk. #[test] fn falls_back_to_in_place_on_storage_full() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("auth.json"); write_auth_json_with(&path, &sample_store(), fake_storage_full).unwrap(); assert_eq!( read_key(&path).as_deref(), Some("secret-key"), "disk-full atomic write must fall back to a successful in-place write" ); } /// Non-ENOSPC errors must propagate unchanged and must NOT trigger the /// in-place fallback (e.g. a permission error should not write the file). #[test] fn propagates_non_storage_full_errors() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("auth.json"); let err = write_auth_json_with(&path, &sample_store(), fake_permission_denied).unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); assert!(!path.exists(), "non-ENOSPC failure must not write the file"); } /// The normal (real atomic) path still works end to end. #[test] fn atomic_write_roundtrips() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("auth.json"); write_auth_json(&path, &sample_store()).unwrap(); assert_eq!(read_key(&path).as_deref(), Some("secret-key")); } /// A fallback write that truncates then fails must roll back to the prior /// bytes instead of leaving an empty/torn file — otherwise a second /// disk-full failure would destroy a previously-valid credential. #[test] fn in_place_restores_prior_bytes_on_failure() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("auth.json"); // Seed a valid prior credential. write_auth_json_in_place(&path, &sample_store()).unwrap(); assert_eq!(read_key(&path).as_deref(), Some("secret-key")); let mut replacement = AuthStore::new(); replacement.insert( API_KEY_SCOPE.to_owned(), KimiAuth { key: "replacement-key".to_owned(), auth_mode: AuthMode::ApiKey, ..Default::default() }, ); let err = write_auth_json_in_place_with(&path, &replacement, fake_truncate_then_fail) .unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::StorageFull); assert_eq!( read_key(&path).as_deref(), Some("secret-key"), "a failed in-place write must restore the prior credential, not leave an empty file" ); } /// Rollback after a failed write must keep the file owner-only (0o600). #[cfg(unix)] #[test] fn in_place_restore_is_owner_only() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("auth.json"); write_auth_json_in_place(&path, &sample_store()).unwrap(); let _ = write_auth_json_in_place_with(&path, &sample_store(), fake_truncate_then_fail); let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600, "restored file must stay 0o600"); } } #[cfg(all(test, any(target_os = "macos", windows)))] mod keyring_tests { use super::*; use chrono::Utc; /// RAII teardown so a panicking test doesn't leave the process-global /// test-keyring toggle enabled for later tests. struct MockKeyringGuard; impl MockKeyringGuard { fn enable() -> Self { enable_mock_keyring_for_test(); Self } } impl Drop for MockKeyringGuard { fn drop(&mut self) { disable_mock_keyring_for_test(); } } fn session_auth(key: &str, rt: &str) -> KimiAuth { KimiAuth { key: key.into(), refresh_token: Some(rt.into()), expires_at: Some(Utc::now() + chrono::Duration::seconds(3600)), expires_in: Some(3600), scope: Some("kimi-code".into()), token_type: Some("bearer".into()), ..KimiAuth::test_default() } } #[test] #[serial_test::serial(kigi_keyring)] fn keyring_session_roundtrip() { let _guard = MockKeyringGuard::enable(); // Fresh mock store: nothing there yet. assert!(matches!(keyring_read_session(), KeyringRead::Missing)); keyring_write_session(&session_auth("at-1", "rt-1")).unwrap(); let KeyringRead::Found(read) = keyring_read_session() else { panic!("expected Found after write"); }; assert_eq!(read.key, "at-1"); assert_eq!(read.refresh_token.as_deref(), Some("rt-1")); assert_eq!(read.expires_in, Some(3600)); // Overwrite rotates in place. keyring_write_session(&session_auth("at-2", "rt-2")).unwrap(); let KeyringRead::Found(read) = keyring_read_session() else { panic!("expected Found after rotate"); }; assert_eq!(read.key, "at-2"); // Delete is idempotent. keyring_delete_session().unwrap(); assert!(matches!(keyring_read_session(), KeyringRead::Missing)); keyring_delete_session().unwrap(); } #[test] #[serial_test::serial(kigi_keyring)] fn keyring_disabled_reads_unavailable() { disable_mock_keyring_for_test(); assert!(matches!(keyring_read_session(), KeyringRead::Unavailable)); assert!(keyring_write_session(&session_auth("a", "r")).is_err()); // Delete when disabled is a no-op success (logout stays best-effort). keyring_delete_session().unwrap(); } }