use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use anyhow::{anyhow, Result};
use fs2::FileExt;
use serde::{Deserialize, Deserializer, Serialize};
use sha2::{Digest, Sha256};
use zeroize::Zeroize;
use crate::secret::Secret;
const STATUS_FILE: &str = ".swapdex-refresh-status.json";
const STATUS_VERSION: u8 = 1;
const STATUS_TYPE: &str = "refresh_rejection";
const PROVIDER: &str = "codex";
const MAX_STATUS_BYTES: u64 = 4 * 1024;
const MAX_AUTH_BYTES: u64 = 2 * 1024 * 1024;
struct SecretText(String);
impl<'de> Deserialize<'de> for SecretText {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer).map(Self)
}
}
impl Drop for SecretText {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[derive(Deserialize)]
struct CodexAuth {
tokens: CredentialFields,
}
#[derive(Deserialize)]
struct CredentialFields {
account_id: SecretText,
refresh_token: SecretText,
}
#[derive(Serialize)]
struct StatusToWrite<'a> {
version: u8,
#[serde(rename = "type")]
kind: &'static str,
provider: &'static str,
rejected_at_ms: i64,
credential_fingerprint: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'a str>,
}
#[derive(Deserialize)]
struct StoredStatus {
version: u8,
#[serde(rename = "type")]
kind: String,
provider: String,
rejected_at_ms: i64,
credential_fingerprint: String,
#[serde(default)]
reason: Option<String>,
}
pub fn codex_credential_fingerprint(dir: &Path) -> Option<String> {
let blob = Secret::new(read_bounded_regular(
&dir.join("auth.json"),
MAX_AUTH_BYTES,
)?);
codex_credential_fingerprint_from_blob(blob.expose())
}
pub fn codex_credential_fingerprint_from_blob(blob: &[u8]) -> Option<String> {
let auth: CodexAuth = serde_json::from_slice(blob).ok()?;
if auth.tokens.account_id.0.is_empty() || auth.tokens.refresh_token.0.is_empty() {
return None;
}
let mut hasher = Sha256::new();
for part in [
b"swapdex-refresh-health-v1".as_slice(),
PROVIDER.as_bytes(),
auth.tokens.account_id.0.as_bytes(),
auth.tokens.refresh_token.0.as_bytes(),
] {
hasher.update((part.len() as u64).to_be_bytes());
hasher.update(part);
}
let digest = hasher.finalize();
let mut fingerprint = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
write!(fingerprint, "{byte:02x}").ok()?;
}
Some(fingerprint)
}
pub fn record_codex_rejection(dir: &Path, expected_fingerprint: &str, now_ms: i64) -> Result<()> {
record_codex_rejection_because(dir, expected_fingerprint, now_ms, None)
}
pub fn record_codex_rejection_because(
dir: &Path,
expected_fingerprint: &str,
now_ms: i64,
reason: Option<&str>,
) -> Result<()> {
let Some(_lock) =
lock_directory(dir).map_err(|_| anyhow!("could not persist Codex refresh rejection"))?
else {
return Ok(());
};
if codex_credential_fingerprint(dir).as_deref() != Some(expected_fingerprint) {
return Ok(());
}
let rejected_at_ms = match read_status(dir)
.filter(|status| status.credential_fingerprint == expected_fingerprint)
{
Some(status) if status.rejected_at_ms > now_ms => return Ok(()),
Some(status) if status.rejected_at_ms == now_ms => status.rejected_at_ms.saturating_add(1),
_ => now_ms,
};
let status = StatusToWrite {
version: STATUS_VERSION,
kind: STATUS_TYPE,
provider: PROVIDER,
rejected_at_ms,
credential_fingerprint: expected_fingerprint,
reason,
};
let bytes = serde_json::to_vec_pretty(&status)
.map_err(|_| anyhow!("could not persist Codex refresh rejection"))?;
crate::atomic::write_secret(&dir.join(STATUS_FILE), &bytes)
.map_err(|_| anyhow!("could not persist Codex refresh rejection"))
}
pub fn codex_rejection(dir: &Path) -> Option<i64> {
let current = codex_credential_fingerprint(dir)?;
codex_rejection_for_fingerprint(dir, ¤t)
}
pub fn codex_rejection_reason(dir: &Path) -> Option<String> {
let current = codex_credential_fingerprint(dir)?;
read_status(dir)
.filter(|status| status.credential_fingerprint == current)
.and_then(|status| status.reason)
}
pub(crate) fn codex_rejection_for_fingerprint(dir: &Path, fingerprint: &str) -> Option<i64> {
let status = read_status(dir)?;
(status.credential_fingerprint == fingerprint).then_some(status.rejected_at_ms)
}
pub fn clear_codex_rejection(dir: &Path, expected_fingerprint: &str) -> Result<()> {
clear_codex_rejection_inner(dir, expected_fingerprint, None)
}
pub fn clear_codex_rejection_before(
dir: &Path,
expected_fingerprint: &str,
request_started_at_ms: i64,
) -> Result<()> {
clear_codex_rejection_inner(dir, expected_fingerprint, Some(request_started_at_ms))
}
fn clear_codex_rejection_inner(
dir: &Path,
expected_fingerprint: &str,
not_newer_than_ms: Option<i64>,
) -> Result<()> {
let Some(_lock) =
lock_directory(dir).map_err(|_| anyhow!("could not clear Codex refresh rejection"))?
else {
return Ok(());
};
let Some(status) = read_status(dir) else {
return Ok(());
};
if status.credential_fingerprint != expected_fingerprint
|| not_newer_than_ms.is_some_and(|limit| status.rejected_at_ms > limit)
{
return Ok(());
}
match std::fs::remove_file(dir.join(STATUS_FILE)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(anyhow!("could not clear Codex refresh rejection")),
}
}
const CLAUDE_STATUS_FILE: &str = ".swapdex-claude-refresh-status.json";
const CLAUDE_PROVIDER: &str = "claude-code";
pub fn claude_refresh_fingerprint(blob: &[u8]) -> Option<String> {
let value: serde_json::Value = serde_json::from_slice(blob).ok()?;
let refresh = value["claudeAiOauth"]["refreshToken"].as_str()?;
if refresh.is_empty() {
return None;
}
let mut hasher = Sha256::new();
for part in [
b"swapdex-refresh-health-v1".as_slice(),
CLAUDE_PROVIDER.as_bytes(),
refresh.as_bytes(),
] {
hasher.update((part.len() as u64).to_be_bytes());
hasher.update(part);
}
let digest = hasher.finalize();
let mut fingerprint = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
write!(fingerprint, "{byte:02x}").ok()?;
}
Some(fingerprint)
}
pub fn record_claude_rejection(dir: &Path, fingerprint: &str, now_ms: i64) -> Result<()> {
record_claude_rejection_because(dir, fingerprint, now_ms, None)
}
pub fn record_claude_rejection_because(
dir: &Path,
fingerprint: &str,
now_ms: i64,
reason: Option<&str>,
) -> Result<()> {
let Some(_lock) =
lock_directory(dir).map_err(|_| anyhow!("could not persist Claude refresh rejection"))?
else {
return Ok(());
};
let status = StatusToWrite {
version: STATUS_VERSION,
kind: STATUS_TYPE,
provider: CLAUDE_PROVIDER,
rejected_at_ms: now_ms,
credential_fingerprint: fingerprint,
reason,
};
let bytes = serde_json::to_vec_pretty(&status)
.map_err(|_| anyhow!("could not persist Claude refresh rejection"))?;
crate::atomic::write_secret(&dir.join(CLAUDE_STATUS_FILE), &bytes)
.map_err(|_| anyhow!("could not persist Claude refresh rejection"))
}
pub fn claude_rejection(dir: &Path, fingerprint: &str) -> Option<i64> {
let bytes = read_bounded_regular(&dir.join(CLAUDE_STATUS_FILE), MAX_STATUS_BYTES)?;
let status: StoredStatus = serde_json::from_slice(&bytes).ok()?;
(status.version == STATUS_VERSION
&& status.kind == STATUS_TYPE
&& status.provider == CLAUDE_PROVIDER
&& status.credential_fingerprint == fingerprint)
.then_some(status.rejected_at_ms)
}
pub fn claude_rejection_reason(dir: &Path, fingerprint: &str) -> Option<String> {
let bytes = read_bounded_regular(&dir.join(CLAUDE_STATUS_FILE), MAX_STATUS_BYTES)?;
let status: StoredStatus = serde_json::from_slice(&bytes).ok()?;
(status.provider == CLAUDE_PROVIDER && status.credential_fingerprint == fingerprint)
.then_some(status.reason)
.flatten()
}
fn read_status(dir: &Path) -> Option<StoredStatus> {
let bytes = read_bounded_regular(&dir.join(STATUS_FILE), MAX_STATUS_BYTES)?;
let status: StoredStatus = serde_json::from_slice(&bytes).ok()?;
if status.version != STATUS_VERSION
|| status.kind != STATUS_TYPE
|| status.provider != PROVIDER
|| !valid_fingerprint(&status.credential_fingerprint)
{
return None;
}
Some(status)
}
fn valid_fingerprint(fingerprint: &str) -> bool {
fingerprint.len() == 64
&& fingerprint
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn read_bounded_regular(path: &Path, max_bytes: u64) -> Option<Vec<u8>> {
let metadata = std::fs::symlink_metadata(path).ok()?;
if !metadata.file_type().is_file() || metadata.len() > max_bytes {
return None;
}
let mut file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.ok()?;
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.by_ref()
.take(max_bytes + 1)
.read_to_end(&mut bytes)
.ok()?;
(bytes.len() as u64 <= max_bytes).then_some(bytes)
}
fn lock_directory(dir: &Path) -> std::io::Result<Option<std::fs::File>> {
let directory = match std::fs::File::open(dir) {
Ok(directory) => directory,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
FileExt::lock_exclusive(&directory)?;
Ok(Some(directory))
}