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.
893 lines
34 KiB
Rust
893 lines
34 KiB
Rust
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();
|
||
}
|
||
}
|