use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
const AUTHORITY_HINT_FILE: &str = "parslee-auth-authority.json";
static FORCE_UNKNOWN: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CredentialAuthorityState {
Unknown,
SignedOut,
Configured,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CredentialAuthorityHint {
pub state: CredentialAuthorityState,
pub generation: u64,
pub updated_at_unix_ms: u64,
}
impl CredentialAuthorityHint {
pub fn unknown() -> Self {
Self {
state: CredentialAuthorityState::Unknown,
generation: 0,
updated_at_unix_ms: 0,
}
}
pub fn signed_out(generation: u64, updated_at_unix_ms: u64) -> Self {
Self {
state: CredentialAuthorityState::SignedOut,
generation,
updated_at_unix_ms,
}
}
pub fn configured(generation: u64, updated_at_unix_ms: u64) -> Self {
Self {
state: CredentialAuthorityState::Configured,
generation,
updated_at_unix_ms,
}
}
}
pub(crate) struct AuthorityHintStore {
root: PathBuf,
}
impl AuthorityHintStore {
pub(crate) fn new(root: PathBuf) -> Self {
Self { root }
}
pub(crate) fn path(&self) -> PathBuf {
self.root.join(AUTHORITY_HINT_FILE)
}
pub(crate) fn load(&self) -> CredentialAuthorityHint {
let path = self.path();
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata,
_ => return CredentialAuthorityHint::unknown(),
};
if metadata.len() > 16 * 1024 {
return CredentialAuthorityHint::unknown();
}
std::fs::read(&path)
.ok()
.and_then(|raw| serde_json::from_slice(&raw).ok())
.unwrap_or_else(CredentialAuthorityHint::unknown)
}
pub(crate) fn publish(&self, hint: CredentialAuthorityHint) -> io::Result<()> {
use std::io::Write;
std::fs::create_dir_all(&self.root)?;
let temp_path = self.root.join(format!(
".{AUTHORITY_HINT_FILE}.{}.tmp",
uuid::Uuid::new_v4().simple()
));
let result = (|| {
let mut file = open_private_temp(&temp_path)?;
let body = serde_json::to_vec(&hint)
.map_err(|error| io::Error::other(format!("serialize authority hint: {error}")))?;
file.write_all(&body)?;
file.sync_all()?;
atomic_replace(&temp_path, &self.path())?;
sync_directory(&self.root)?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&temp_path);
}
result
}
pub(crate) fn discard(&self) -> io::Result<()> {
match std::fs::remove_file(self.path()) {
Ok(()) => sync_directory(&self.root),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
}
#[cfg(unix)]
fn open_private_temp(path: &Path) -> io::Result<std::fs::File> {
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
Ok(file)
}
#[cfg(not(unix))]
fn open_private_temp(path: &Path) -> io::Result<std::fs::File> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
}
#[cfg(not(windows))]
fn atomic_replace(source: &Path, destination: &Path) -> io::Result<()> {
std::fs::rename(source, destination)
}
#[cfg(windows)]
fn atomic_replace(source: &Path, destination: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
#[link(name = "kernel32")]
unsafe extern "system" {
fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
}
let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
let destination: Vec<u16> = destination
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect();
let replaced = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if replaced == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> io::Result<()> {
std::fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> io::Result<()> {
Ok(())
}
pub fn credential_authority_hint() -> CredentialAuthorityHint {
if FORCE_UNKNOWN.load(Ordering::Acquire) {
return CredentialAuthorityHint::unknown();
}
let Some(root) = car_home::root() else {
return CredentialAuthorityHint::unknown();
};
AuthorityHintStore::new(root).load()
}
pub(crate) fn publish_for_state(state: &crate::state::AuthStateV2) -> io::Result<()> {
let root = car_home::root().ok_or_else(|| {
FORCE_UNKNOWN.store(true, Ordering::Release);
io::Error::new(io::ErrorKind::NotFound, "CAR state root is unavailable")
})?;
let now = crate::epoch_millis();
let hint = if state.active.is_some() {
CredentialAuthorityHint::configured(state.generation, now)
} else {
CredentialAuthorityHint::signed_out(state.generation, now)
};
match AuthorityHintStore::new(root).publish(hint) {
Ok(()) => {
FORCE_UNKNOWN.store(false, Ordering::Release);
Ok(())
}
Err(error) => {
FORCE_UNKNOWN.store(true, Ordering::Release);
Err(error)
}
}
}
pub(crate) fn degrade_to_unknown() -> io::Result<()> {
let root = car_home::root().ok_or_else(|| {
FORCE_UNKNOWN.store(true, Ordering::Release);
io::Error::new(io::ErrorKind::NotFound, "CAR state root is unavailable")
})?;
let store = AuthorityHintStore::new(root);
if let Err(error) = store.publish(CredentialAuthorityHint::unknown()) {
FORCE_UNKNOWN.store(true, Ordering::Release);
if store.discard().is_err() {
FORCE_UNKNOWN.store(true, Ordering::Release);
}
return Err(error);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
const FAILURE_CHILD_ENV: &str = "CAR_AUTHORITY_HINT_FAILURE_CHILD";
#[cfg(unix)]
fn run_failed_degradation_scenario() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
std::env::set_var(car_home::ENV_VAR, dir.path());
let store = AuthorityHintStore::new(dir.path().to_path_buf());
store
.publish(CredentialAuthorityHint::configured(7, 1234))
.unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let mut signed_out = crate::state::AuthStateV2::signed_out();
signed_out.generation = 8;
assert!(publish_for_state(&signed_out).is_err());
assert!(degrade_to_unknown().is_err());
let during_failure = credential_authority_hint();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
signed_out.generation = 9;
publish_for_state(&signed_out).unwrap();
let after_recovery = credential_authority_hint();
std::env::remove_var(car_home::ENV_VAR);
assert_eq!(during_failure, CredentialAuthorityHint::unknown());
assert_eq!(after_recovery.state, CredentialAuthorityState::SignedOut);
assert_eq!(after_recovery.generation, 9);
}
#[cfg(unix)]
#[test]
fn failed_hint_degradation_forces_public_reads_unknown_until_success() {
if std::env::var_os(FAILURE_CHILD_ENV).is_some() {
run_failed_degradation_scenario();
return;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"authority_hint::tests::failed_hint_degradation_forces_public_reads_unknown_until_success",
"--nocapture",
])
.env(FAILURE_CHILD_ENV, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"isolated authority-hint regression failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn malformed_or_missing_hint_is_unknown_and_contains_no_identity() {
let dir = tempfile::tempdir().unwrap();
let store = AuthorityHintStore::new(dir.path().to_path_buf());
assert_eq!(store.load(), CredentialAuthorityHint::unknown());
std::fs::write(
store.path(),
br#"{"state":"configured","account_id":"forbidden"}"#,
)
.unwrap();
assert_eq!(store.load(), CredentialAuthorityHint::unknown());
}
#[test]
fn published_hint_is_atomic_private_and_token_free() {
let dir = tempfile::tempdir().unwrap();
let store = AuthorityHintStore::new(dir.path().to_path_buf());
store
.publish(CredentialAuthorityHint::configured(7, 1234))
.unwrap();
let body = std::fs::read_to_string(store.path()).unwrap();
assert!(!body.contains("token"));
assert!(!body.contains("account"));
assert_eq!(store.load().generation, 7);
store
.publish(CredentialAuthorityHint::signed_out(8, 2345))
.unwrap();
assert_eq!(store.load().generation, 8);
assert_eq!(store.load().state, CredentialAuthorityState::SignedOut);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(store.path())
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
assert!(
std::fs::read_dir(dir.path())
.unwrap()
.all(|entry| entry.unwrap().path() == store.path()),
"atomic publication must not leave staging files behind"
);
}
}