Files
Kigi-CLI/crates/codegen/kigi-shell/src/auth/storage.rs
T
ZacharyZhang-NYandClaude Fable 5 50669bf302 chore(gates): zero clippy warnings and a buildable sandbox example on Windows
The full-workspace gates had never run on this platform: collapsible
ifs in the Windows shell probe, a same-type cast in the crash handler,
an unused AsRawHandle import, and the sandbox smoke-test example
building its unix-only body (libc, kernel enforcement) unconditionally.
The example now stubs main off-unix; formatting reflow from cargo fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:55:19 -04:00

1033 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<KimiAuth>),
/// 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<bool> = 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<Result<keyring::Entry, keyring::Error>> =
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(),
))
}
}
}
#[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::<KimiAuth>(&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
}
#[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<AuthStore> {
let mut file = File::open(auth_file)?;
// Tighten world-readable copies (hand-restored, umask edge cases, etc.)
// right after open, so even an unreadable/invalid file gets fixed.
// Best-effort: a chmod failure must not block login/read paths.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
path = %auth_file.display(),
error = %e,
"auth: failed to enforce owner-only permissions on auth.json"
);
}
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<AuthStore> {
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.<millis>` (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<PathBuf> {
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(()) => {
// Corrupt backups still hold token material — keep them owner-only.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(&backup) {
tracing::warn!(
path = %backup.display(),
error = %e,
"auth: failed to tighten permissions on corrupt-auth backup"
);
}
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.<millis>`,
/// 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<AuthStore> {
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
// 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()?;
// `open_secure_file` mode bits apply only on create; tighten existing
// paths. Best-effort after durable content: a chmod-only failure must not
// look like a failed write — the in-place fallback restores the prior
// snapshot on any `write_store_to` Err, which would discard freshly
// written tokens. The load path re-tightens on the next read.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(path) {
tracing::warn!(
error = %e,
path = %path.display(),
"auth: failed to ensure owner-only permissions after write"
);
}
Ok(())
}
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`).
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
write_auth_json_atomic_with(auth_file, auth_store, write_store_to)
}
/// Inner of [`write_auth_json_atomic`] with the tmp-file writer injectable so
/// the torn-tmp-write path is unit-testable without real I/O faults.
fn write_auth_json_atomic_with(
auth_file: &Path,
auth_store: &AuthStore,
write: fn(&Path, &AuthStore) -> std::io::Result<()>,
) -> std::io::Result<()> {
// Unique per write (pid + monotonic seq): two concurrent in-process
// writers (e.g. background mint + proactive refresher) must not share
// one tmp path, or each can tear the other's half-written bytes.
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let tmp = auth_file.with_extension(format!(
"json.{}.{}.tmp",
std::process::id(),
TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
if let Err(e) = write(&tmp, auth_store) {
// Reclaim the partial tmp; the unique name otherwise accumulates
// one orphan per failed write. `replace_file` cleans its own leg.
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
crate::util::fs::replace_file(&tmp, auth_file)?;
// Re-assert on the final path (covers rename edge cases / FS quirks).
// Best-effort: the replace already published the new tokens.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
error = %e,
path = %auth_file.display(),
"auth: failed to ensure owner-only permissions after replace"
);
}
Ok(())
}
/// 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 in place of a parseable (if stale)
/// credential. 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()?;
crate::util::secure_file::ensure_owner_only_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<String> {
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<String> {
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<String> {
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<String> {
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<()> {
// `open_secure_file` truncates to 0 bytes.
crate::util::secure_file::open_secure_file(path)?;
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 the still-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"
);
}
/// An in-place rewrite over a pre-existing world-readable auth.json must
/// tighten it to 0o600 — `open_secure_file` mode bits apply only on
/// create, so truncating an existing loose file keeps its old mode. The
/// in-place path is the one that reuses the inode (the atomic path
/// replaces it with a fresh 0o600 temp file).
#[cfg(unix)]
#[test]
fn in_place_write_tightens_preexisting_world_readable_auth_json() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, b"{}").unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
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 rewrite must tighten preexisting open perms"
);
}
/// Simulates a torn tmp write: partial bytes land at the target path,
/// then the write fails — the temp file must not be left behind.
fn fake_partial_write_then_fail(path: &Path, _: &AuthStore) -> std::io::Result<()> {
std::fs::write(path, b"{ torn")?;
Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
}
/// A tmp write that fails partway must reclaim the temp file; the unique
/// tmp name otherwise accumulates one orphan per failed write.
#[test]
fn atomic_write_reclaims_tmp_when_store_write_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let err = write_auth_json_atomic_with(&path, &sample_store(), fake_partial_write_then_fail)
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::StorageFull);
let orphans: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
orphans.is_empty(),
"failed tmp write must not leave temp files behind: {orphans:?}"
);
}
/// The replace step failing (target path occupied by a directory) must
/// also leave no temp file — `replace_file` reclaims its tmp on failure.
#[test]
fn atomic_write_reclaims_tmp_when_replace_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::create_dir(&path).unwrap();
assert!(
write_auth_json_atomic(&path, &sample_store()).is_err(),
"replacing a directory must fail"
);
let orphans: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
orphans.is_empty(),
"failed replace must not leave temp files behind: {orphans:?}"
);
}
/// Rollback after a failed write must leave the file owner-only (0o600)
/// even when the file had loose permissions going in — the restore path
/// truncates the existing inode, which keeps its old mode unless
/// explicitly tightened.
#[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 mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).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 be 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();
}
}