use anyhow::{Context, Result, anyhow};
use axum::{
Json, Router,
extract::{Request, State},
http::{StatusCode, header::AUTHORIZATION},
middleware::{self, Next},
response::{IntoResponse, Response},
};
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
const AUTH_RATE_LIMIT_BURST: u32 = 100;
const AUTH_RATE_LIMIT_REPLENISH_MS: u64 = 600;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthSource {
Cli,
Env,
File(PathBuf),
Generated(PathBuf),
Disabled,
}
impl AuthSource {
pub fn describe(&self) -> String {
match self {
Self::Cli => "cli".to_string(),
Self::Env => "env".to_string(),
Self::File(path) => format!("file:{}", path.display()),
Self::Generated(path) => format!("generated:{}", path.display()),
Self::Disabled => "disabled".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub token: Option<String>,
pub source: AuthSource,
}
impl AuthConfig {
pub fn is_enforced(&self) -> bool {
self.token.is_some()
}
pub fn disabled() -> Self {
Self {
token: None,
source: AuthSource::Disabled,
}
}
}
fn default_token_path() -> Result<PathBuf> {
Ok(crate::store::resolve_aicx_home()?.join("auth-token"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenSourceProbe {
Env,
File(PathBuf),
WouldGenerate(PathBuf),
Unresolved,
}
impl TokenSourceProbe {
pub fn describe(&self) -> String {
match self {
Self::Env => "env (AICX_HTTP_AUTH_TOKEN)".to_string(),
Self::File(path) => format!("file: {}", path.display()),
Self::WouldGenerate(path) => {
format!(
"none yet — would generate at {} on first HTTP serve",
path.display()
)
}
Self::Unresolved => "unresolved (no home directory)".to_string(),
}
}
}
pub fn probe_token_source() -> TokenSourceProbe {
if std::env::var("AICX_HTTP_AUTH_TOKEN")
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
return TokenSourceProbe::Env;
}
match default_token_path() {
Ok(path) if path.exists() => TokenSourceProbe::File(path),
Ok(path) => TokenSourceProbe::WouldGenerate(path),
Err(_) => TokenSourceProbe::Unresolved,
}
}
pub fn load_auth_config(cli_token: Option<&str>, require_auth: bool) -> Result<AuthConfig> {
if !require_auth {
return Ok(AuthConfig::disabled());
}
if let Some(token) = cli_token {
let token = token.trim();
if token.is_empty() {
return Err(anyhow!("--auth-token must not be empty"));
}
return Ok(AuthConfig {
token: Some(token.to_string()),
source: AuthSource::Cli,
});
}
if let Ok(value) = std::env::var("AICX_HTTP_AUTH_TOKEN") {
let value = value.trim().to_string();
if !value.is_empty() {
return Ok(AuthConfig {
token: Some(value),
source: AuthSource::Env,
});
}
}
let path = default_token_path()?;
if path.exists() {
let content = crate::sanitize::read_to_string_validated(&path)
.with_context(|| format!("Read auth token file {}", path.display()))?;
let token = content.trim().to_string();
if !token.is_empty() {
return Ok(AuthConfig {
token: Some(token),
source: AuthSource::File(path),
});
}
}
let token = generate_token().context("Generate HTTP auth token")?;
match persist_token_file(&path, &token).context("Persist HTTP auth token to file")? {
TokenPersistOutcome::Created | TokenPersistOutcome::Overwrote => Ok(AuthConfig {
token: Some(token),
source: AuthSource::Generated(path),
}),
TokenPersistOutcome::AdoptedExisting(existing) => Ok(AuthConfig {
token: Some(existing),
source: AuthSource::File(path),
}),
}
}
#[derive(Debug)]
#[cfg_attr(not(unix), allow(dead_code))]
enum TokenPersistOutcome {
Created,
AdoptedExisting(String),
Overwrote,
}
fn generate_token() -> Result<String> {
let mut buf = [0u8; 32];
getrandom::fill(&mut buf)
.map_err(|err| anyhow!("Generate random bytes for auth token: {err}"))?;
Ok(hex_encode(&buf))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(*byte >> 4) as usize] as char);
out.push(HEX[(*byte & 0x0f) as usize] as char);
}
out
}
fn persist_token_file(path: &Path, token: &str) -> Result<TokenPersistOutcome> {
#[cfg(windows)]
{
use std::io::{ErrorKind, Write};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Create token directory {}", parent.display()))?;
}
match windows_acl::create_new_restricted(path) {
Ok(mut file) => {
file.write_all(format!("{}\n", token).as_bytes())
.with_context(|| format!("Write token file {}", path.display()))?;
file.flush()
.with_context(|| format!("Flush token file {}", path.display()))?;
Ok(TokenPersistOutcome::Created)
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
let existing =
crate::sanitize::read_to_string_validated(path).with_context(|| {
format!(
"Re-read existing token file after AlreadyExists: {}",
path.display()
)
})?;
let trimmed = existing.trim();
if !trimmed.is_empty() {
return Ok(TokenPersistOutcome::AdoptedExisting(trimmed.to_string()));
}
atomic_replace_token_file(path, token)
.with_context(|| format!("Replace empty token file {}", path.display()))?;
Ok(TokenPersistOutcome::Overwrote)
}
Err(err) => Err(err).with_context(|| {
format!(
"Create token file {} atomically with restricted owner-only ACL",
path.display()
)
}),
}
}
#[cfg(unix)]
{
use std::io::{ErrorKind, Write};
use std::os::unix::fs::OpenOptionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Create token directory {}", parent.display()))?;
}
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
{
Ok(mut file) => {
file.write_all(format!("{}\n", token).as_bytes())
.with_context(|| format!("Write token file {}", path.display()))?;
file.flush()
.with_context(|| format!("Flush token file {}", path.display()))?;
Ok(TokenPersistOutcome::Created)
}
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
let existing =
crate::sanitize::read_to_string_validated(path).with_context(|| {
format!(
"Re-read existing token file after AlreadyExists: {}",
path.display()
)
})?;
let trimmed = existing.trim();
if !trimmed.is_empty() {
return Ok(TokenPersistOutcome::AdoptedExisting(trimmed.to_string()));
}
atomic_replace_token_file(path, token)
.with_context(|| format!("Replace empty token file {}", path.display()))?;
Ok(TokenPersistOutcome::Overwrote)
}
Err(err) => Err(err).with_context(|| {
format!(
"Create token file {} atomically with mode 0600",
path.display()
)
}),
}
}
#[cfg(all(not(unix), not(windows)))]
{
let _ = token;
Err(anyhow!(
"Refusing to persist aicx auth token file {} because this platform does not expose Unix mode 0600 or Windows restricted ACL handling. Pass --auth-token <token> explicitly so the token file is never written.",
path.display()
))
}
}
#[cfg(unix)]
fn atomic_replace_token_file(path: &Path, token: &str) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let parent = path
.parent()
.ok_or_else(|| anyhow!("Token file path has no parent: {}", path.display()))?;
let file_name = path
.file_name()
.ok_or_else(|| anyhow!("Token file path has no filename: {}", path.display()))?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let mut rand = [0u8; 8];
getrandom::fill(&mut rand)
.map_err(|err| anyhow!("Generate random tmp suffix for token replace: {err}"))?;
let tmp_path = parent.join(format!(
".{}.tmp.{}.{}.{}",
file_name.to_string_lossy(),
std::process::id(),
nanos,
hex_encode(&rand),
));
let res = (|| -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)
.with_context(|| format!("Create tmp token file {}", tmp_path.display()))?;
file.write_all(format!("{}\n", token).as_bytes())
.with_context(|| format!("Write tmp token file {}", tmp_path.display()))?;
file.flush()
.with_context(|| format!("Flush tmp token file {}", tmp_path.display()))?;
drop(file);
std::fs::rename(&tmp_path, path).with_context(|| {
format!(
"Rename tmp token file {} -> {}",
tmp_path.display(),
path.display()
)
})
})();
if res.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
res
}
#[cfg(windows)]
fn atomic_replace_token_file(path: &Path, token: &str) -> Result<()> {
use std::io::Write;
let parent = path
.parent()
.ok_or_else(|| anyhow!("Token file path has no parent: {}", path.display()))?;
let file_name = path
.file_name()
.ok_or_else(|| anyhow!("Token file path has no filename: {}", path.display()))?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let mut rand = [0u8; 8];
getrandom::fill(&mut rand)
.map_err(|err| anyhow!("Generate random tmp suffix for token replace: {err}"))?;
let tmp_path = parent.join(format!(
".{}.tmp.{}.{}.{}",
file_name.to_string_lossy(),
std::process::id(),
nanos,
hex_encode(&rand),
));
let res = (|| -> Result<()> {
let mut file = windows_acl::create_new_restricted(&tmp_path)
.with_context(|| format!("Create tmp token file {}", tmp_path.display()))?;
file.write_all(format!("{}\n", token).as_bytes())
.with_context(|| format!("Write tmp token file {}", tmp_path.display()))?;
file.flush()
.with_context(|| format!("Flush tmp token file {}", tmp_path.display()))?;
drop(file);
windows_acl::replace_existing(&tmp_path, path).with_context(|| {
format!(
"Rename tmp token file {} -> {}",
tmp_path.display(),
path.display()
)
})
})();
if res.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
res
}
#[cfg(windows)]
mod windows_acl {
use std::ffi::c_void;
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::FromRawHandle;
use std::path::Path;
const TOKEN_SDDL: &str = "D:P(A;;FA;;;OW)(A;;FA;;;SY)";
const GENERIC_WRITE: u32 = 0x4000_0000;
const CREATE_NEW: u32 = 1;
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
const SDDL_REVISION_1: u32 = 1;
const ERROR_FILE_EXISTS: i32 = 80;
const ERROR_ALREADY_EXISTS: i32 = 183;
const INVALID_HANDLE_VALUE: isize = -1;
const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
#[repr(C)]
struct SecurityAttributes {
n_length: u32,
lp_security_descriptor: *mut c_void,
b_inherit_handle: i32,
}
unsafe extern "system" {
fn ConvertStringSecurityDescriptorToSecurityDescriptorW(
string_security_descriptor: *const u16,
string_sd_revision: u32,
security_descriptor: *mut *mut c_void,
security_descriptor_size: *mut u32,
) -> i32;
fn CreateFileW(
lp_file_name: *const u16,
dw_desired_access: u32,
dw_share_mode: u32,
lp_security_attributes: *mut SecurityAttributes,
dw_creation_disposition: u32,
dw_flags_and_attributes: u32,
h_template_file: *mut c_void,
) -> *mut c_void;
fn MoveFileExW(
lp_existing_file_name: *const u16,
lp_new_file_name: *const u16,
dw_flags: u32,
) -> i32;
fn LocalFree(h_mem: *mut c_void) -> *mut c_void;
}
fn wide(path: &Path) -> Vec<u16> {
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
pub fn create_new_restricted(path: &Path) -> io::Result<File> {
let sddl: Vec<u16> = TOKEN_SDDL
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut psd: *mut c_void = std::ptr::null_mut();
let ok = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl.as_ptr(),
SDDL_REVISION_1,
&mut psd,
std::ptr::null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
let mut sa = SecurityAttributes {
n_length: std::mem::size_of::<SecurityAttributes>() as u32,
lp_security_descriptor: psd,
b_inherit_handle: 0,
};
let wpath = wide(path);
let handle = unsafe {
CreateFileW(
wpath.as_ptr(),
GENERIC_WRITE,
0, &mut sa,
CREATE_NEW,
FILE_ATTRIBUTE_NORMAL,
std::ptr::null_mut(),
)
};
let create_err = (handle as isize == INVALID_HANDLE_VALUE).then(io::Error::last_os_error);
unsafe { LocalFree(psd) };
match create_err {
None => {
Ok(unsafe { File::from_raw_handle(handle as _) })
}
Some(err) => {
if matches!(
err.raw_os_error(),
Some(ERROR_FILE_EXISTS) | Some(ERROR_ALREADY_EXISTS)
) {
Err(io::Error::from(io::ErrorKind::AlreadyExists))
} else {
Err(err)
}
}
}
}
pub fn replace_existing(src: &Path, dst: &Path) -> io::Result<()> {
let wsrc = wide(src);
let wdst = wide(dst);
let ok = unsafe {
MoveFileExW(
wsrc.as_ptr(),
wdst.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if ok == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
}
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut result: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
result |= x ^ y;
}
result == 0
}
#[derive(Serialize)]
struct UnauthorizedBody {
error: &'static str,
}
fn unauthorized_response() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(UnauthorizedBody {
error: "unauthorized",
}),
)
.into_response()
}
async fn auth_middleware(
State(config): State<Arc<AuthConfig>>,
request: Request,
next: Next,
) -> Response {
let Some(expected) = config.token.as_deref() else {
return next.run(request).await;
};
let presented = request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer "));
let Some(provided) = presented else {
return unauthorized_response();
};
if provided.len() != expected.len() {
return unauthorized_response();
}
if constant_time_eq(provided.as_bytes(), expected.as_bytes()) {
next.run(request).await
} else {
unauthorized_response()
}
}
pub fn require_auth_layer<S>(router: Router<S>, config: AuthConfig) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
let auth_enforced = config.is_enforced();
let state = Arc::new(config);
let router = router.layer(middleware::from_fn_with_state(state, auth_middleware));
if !auth_enforced {
return router;
}
let governor_config = GovernorConfigBuilder::default()
.per_millisecond(AUTH_RATE_LIMIT_REPLENISH_MS)
.burst_size(AUTH_RATE_LIMIT_BURST)
.finish()
.expect("auth rate limit config is non-zero");
router.layer(GovernorLayer::new(governor_config))
}
pub fn proxy_rate_limit_warning(host: std::net::IpAddr) -> Option<&'static str> {
if host.is_loopback() {
None
} else {
Some(
"Rate limit on /api/* is peer-IP / local-first and NOT proxy-aware. \
Behind a reverse proxy every user shares the proxy's bucket, so a single \
noisy client can starve others with 429. Proxy-aware key extraction \
(trusted-header opt-in) is tracked as a follow-up.",
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn token_source_probe_describe_never_leaks_value() {
assert_eq!(
TokenSourceProbe::Env.describe(),
"env (AICX_HTTP_AUTH_TOKEN)"
);
let file = TokenSourceProbe::File(std::path::PathBuf::from("/x/auth-token"));
assert!(file.describe().starts_with("file:"));
assert!(file.describe().contains("/x/auth-token"));
let would = TokenSourceProbe::WouldGenerate(std::path::PathBuf::from("/x/auth-token"));
assert!(would.describe().contains("would generate"));
}
#[test]
fn probe_token_source_detects_env_without_generating() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
clear_env();
unsafe {
std::env::set_var("AICX_HTTP_AUTH_TOKEN", "probe-test-token");
}
assert_eq!(probe_token_source(), TokenSourceProbe::Env);
clear_env();
}
fn clear_env() {
unsafe {
std::env::remove_var("AICX_HTTP_AUTH_TOKEN");
}
}
#[test]
fn test_proxy_rate_limit_warning_is_silent_on_loopback() {
let v4 = std::net::IpAddr::from([127u8, 0, 0, 1]);
assert!(super::proxy_rate_limit_warning(v4).is_none());
let v6 = std::net::IpAddr::from([0u16, 0, 0, 0, 0, 0, 0, 1]);
assert!(super::proxy_rate_limit_warning(v6).is_none());
}
#[test]
fn test_proxy_rate_limit_warning_fires_for_non_loopback_bind() {
let v4 = std::net::IpAddr::from([0u8, 0, 0, 0]);
let msg = super::proxy_rate_limit_warning(v4)
.expect("non-loopback bind must emit proxy rate-limit warning");
assert!(
msg.contains("peer-IP") && msg.contains("proxy"),
"warning must reference peer-IP / proxy contract: {msg}"
);
let tailscale = std::net::IpAddr::from([100u8, 64, 0, 1]);
assert!(super::proxy_rate_limit_warning(tailscale).is_some());
}
#[test]
fn test_generate_token_shape_and_uniqueness_sanity() {
let first = generate_token().expect("generate first token");
let second = generate_token().expect("generate second token");
assert_eq!(first.len(), 64, "32 bytes hex-encoded = 64 chars");
assert_eq!(second.len(), 64, "32 bytes hex-encoded = 64 chars");
assert!(first.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert!(second.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert_ne!(first, second, "two CSPRNG tokens should differ");
}
#[test]
fn test_load_auth_token_from_env() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_env();
unsafe {
std::env::set_var("AICX_HTTP_AUTH_TOKEN", "from-env-token");
}
let cfg = load_auth_config(None, true).expect("load env token");
assert_eq!(cfg.token.as_deref(), Some("from-env-token"));
assert_eq!(cfg.source, AuthSource::Env);
clear_env();
}
#[test]
fn test_load_auth_token_from_file_with_mode_0600() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_env();
let tmp = std::env::temp_dir().join(format!(
"aicx-auth-test-{}-{}.token",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
std::fs::write(&tmp, "file-token-value\n").expect("write tmp token");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&tmp).expect("stat tmp").permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&tmp, perms).expect("chmod tmp");
}
let token = std::fs::read_to_string(&tmp).expect("read tmp");
assert_eq!(token.trim(), "file-token-value");
assert!(constant_time_eq(
token.trim().as_bytes(),
b"file-token-value"
));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&tmp)
.expect("stat tmp")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "file should be mode 0600");
}
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_load_auth_token_generates_when_missing() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_env();
let tmp_dir = std::env::temp_dir().join(format!(
"aicx-auth-gen-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
let tmp_path = tmp_dir.join("auth-token");
let token = generate_token().expect("generate token");
assert_eq!(token.len(), 64, "32 bytes hex-encoded = 64 chars");
persist_token_file(&tmp_path, &token).expect("persist token");
assert!(tmp_path.exists());
let on_disk = std::fs::read_to_string(&tmp_path).expect("read persisted");
assert_eq!(on_disk.trim(), token);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&tmp_path)
.expect("stat persisted")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "persisted token must be 0600");
}
let _ = std::fs::remove_file(&tmp_path);
let _ = std::fs::remove_dir(&tmp_dir);
}
#[cfg(unix)]
#[test]
fn test_persist_token_file_adopts_existing_valid_token() {
let tmp_dir = std::env::temp_dir().join(format!(
"aicx-auth-existing-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
let tmp_path = tmp_dir.join("auth-token");
std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
std::fs::write(&tmp_path, "existing-token\n").expect("write existing token");
let outcome = persist_token_file(&tmp_path, "replacement-token")
.expect("AlreadyExists with valid content must recover via adoption");
match outcome {
TokenPersistOutcome::AdoptedExisting(token) => {
assert_eq!(token, "existing-token");
}
other => panic!("expected AdoptedExisting, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(&tmp_path).expect("read existing token"),
"existing-token\n"
);
let _ = std::fs::remove_file(&tmp_path);
let _ = std::fs::remove_dir(&tmp_dir);
}
#[cfg(unix)]
#[test]
fn test_persist_token_file_overwrites_empty_existing_file() {
use std::os::unix::fs::PermissionsExt;
let tmp_dir = std::env::temp_dir().join(format!(
"aicx-auth-empty-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
let tmp_path = tmp_dir.join("auth-token");
std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
std::fs::write(&tmp_path, " \n").expect("write empty token");
let fresh = generate_token().expect("generate replacement token");
let outcome = persist_token_file(&tmp_path, &fresh)
.expect("empty token file must be atomically replaced");
match outcome {
TokenPersistOutcome::Overwrote => {}
other => panic!("expected Overwrote, got {other:?}"),
}
let on_disk = std::fs::read_to_string(&tmp_path).expect("read replaced token");
assert_eq!(on_disk.trim(), fresh);
let mode = std::fs::metadata(&tmp_path)
.expect("stat replaced token")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, 0o600,
"atomic replace must preserve mode 0600 on the destination"
);
let leftovers: Vec<_> = std::fs::read_dir(&tmp_dir)
.expect("read tmp dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(".auth-token.tmp.")
})
.collect();
assert!(
leftovers.is_empty(),
"atomic replace left tempfiles behind: {leftovers:?}"
);
let _ = std::fs::remove_file(&tmp_path);
let _ = std::fs::remove_dir(&tmp_dir);
}
#[cfg(unix)]
#[test]
fn test_persist_token_file_first_writer_returns_created() {
let tmp_dir = std::env::temp_dir().join(format!(
"aicx-auth-firstwriter-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
let tmp_path = tmp_dir.join("auth-token");
let fresh = generate_token().expect("generate token");
let outcome = persist_token_file(&tmp_path, &fresh).expect("persist new token");
match outcome {
TokenPersistOutcome::Created => {}
other => panic!("expected Created, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(&tmp_path)
.expect("read persisted")
.trim(),
fresh
);
let _ = std::fs::remove_file(&tmp_path);
let _ = std::fs::remove_dir(&tmp_dir);
}
#[test]
fn test_constant_time_compare_rejects_short_mismatch() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"ab"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert!(!constant_time_eq(b"", b"x"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn test_disabled_config_passes_through_in_middleware() {
assert!(!AuthConfig::disabled().is_enforced());
}
#[test]
fn test_cli_override_wins() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_env();
unsafe {
std::env::set_var("AICX_HTTP_AUTH_TOKEN", "env-loser");
}
let cfg = load_auth_config(Some("cli-winner"), true).expect("cli override");
assert_eq!(cfg.token.as_deref(), Some("cli-winner"));
assert_eq!(cfg.source, AuthSource::Cli);
clear_env();
}
}