Files
Kigi-CLI/crates/codegen/kigi-shell/src/auth/storage.rs
T
ZacharyZhang-NY 27d009cb6e fix(fs): Windows-safe atomic replace everywhere — model switch now sticks
Root cause of 'model+effort switch works on Mac, not on Windows': the
switch APPLIES in-session (the dispatch/apply chain is platform-identical,
verified adversarially) but its persistence never sticks on Windows.
Every tmp+rename atomic write except auth/storage.rs committed with a
bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a
sharing violation whenever AV/search-indexer/cloud-sync transiently holds
the destination open. Consequences: [models].default never persisted
(next launch = original model), the session summary's current model never
persisted (resume = original model), and the models cache went silently
stale (all its write errors were swallowed).

- New kigi_shell_base::util::fs::replace_file — THE commit step for
  tmp+rename: plain rename on Unix; on Windows delete-first + two short
  backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on
  failure, error always returned. Windows branch type-checked against
  x86_64-pc-windows-msvc.
- Adopted at every replace site: config.toml (save_config /
  atomic_write_string / mcp saves), models cache (plus unique tmp
  suffixes and tracing::warn on failure — writes were fully silent),
  session storage (summary/current-model, jsonl, plan/signals/
  announcement/goal/graph state), auth.json, active-sessions registry,
  prompt history, claude/kimi import, campaigns state, goal artifacts.
  Directory-move renames (worktree pool, corrupt-file backups) keep
  plain rename — their destinations don't pre-exist.

Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean,
msvc-target check of the new cfg(windows) code clean.
2026-07-22 19:42:13 -04:00

893 lines
34 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(),
))
}
}
}
/// 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::<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
}
/// 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<AuthStore> {
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<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(()) => {
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
// (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<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<()> {
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();
}
}