use std::path::{Path, PathBuf};
pub fn auth_to_copy_back(orig: &[u8], now_real: &[u8], after: &[u8]) -> bool {
after != orig
&& now_real == orig
&& !after.is_empty()
&& serde_json::from_slice::<serde_json::Value>(after).is_ok_and(|v| v.is_object())
}
pub fn may_refresh(auth: &[u8], now: i64) -> bool {
use base64::Engine as _;
let Ok(v) = serde_json::from_slice::<serde_json::Value>(auth) else {
return true;
};
let exp = v["tokens"]["access_token"]
.as_str()
.and_then(|t| t.split('.').nth(1))
.and_then(|p| {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(p.trim_end_matches('='))
.ok()
})
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|c| c["exp"].as_i64());
let last = v["last_refresh"]
.as_str()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.timestamp());
match (exp, last) {
(Some(exp), Some(last)) => exp <= now + 600 || last <= now - 7 * 86_400,
_ => true,
}
}
pub fn refresh_lock() -> &'static tokio::sync::Mutex<()> {
static L: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
L.get_or_init(tokio::sync::Mutex::default)
}
#[derive(Clone)]
pub struct Copied {
pub real: PathBuf,
pub ours: PathBuf,
orig: Vec<u8>,
}
impl Copied {
pub(crate) fn new(real: PathBuf, ours: PathBuf, orig: Vec<u8>) -> Self {
Self { real, ours, orig }
}
pub fn orig_len(&self) -> usize {
self.orig.len()
}
}
impl core::fmt::Debug for Copied {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Copied")
.field("real", &self.real)
.field("ours", &self.ours)
.field(
"orig",
&format_args!("[redacted; {} bytes]", self.orig.len()),
)
.finish()
}
}
pub async fn copy_in(from: &Path, home: &Path) -> std::io::Result<Copied> {
mkdir_private(home).await?;
let real = from.join("auth.json");
let orig = tokio::fs::read(&real).await?;
let ours = home.join("auth.json");
write_private_atomic(&ours, &orig).await?;
Ok(Copied { real, ours, orig })
}
pub async fn copy_back(c: &Copied) -> std::io::Result<bool> {
let after = match tokio::fs::read(&c.ours).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e),
};
let now_real = tokio::fs::read(&c.real).await?;
if !auth_to_copy_back(&c.orig, &now_real, &after) {
return Ok(false);
}
write_private_atomic(&c.real, &after).await?;
Ok(true)
}
pub async fn mkdir_private(p: &Path) -> std::io::Result<()> {
let mut b = tokio::fs::DirBuilder::new();
b.recursive(true).mode(0o700);
b.create(p).await
}
pub(crate) async fn write_private_atomic(p: &Path, data: &[u8]) -> std::io::Result<()> {
let name = p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
let tmp = p.with_file_name(format!(".{name}.codexwire-{}-{nanos}", std::process::id()));
let r = async {
let mut f = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.await?;
tokio::io::AsyncWriteExt::write_all(&mut f, data).await?;
f.sync_all().await?;
tokio::fs::rename(&tmp, p).await
}
.await;
if r.is_err() {
let _ = tokio::fs::remove_file(&tmp).await;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_back_decisions() {
let orig = br#"{"tokens":1}"#;
let new = br#"{"tokens":2}"#;
assert!(
auth_to_copy_back(orig, orig, new),
"refreshed, real unchanged"
);
assert!(!auth_to_copy_back(orig, orig, orig), "not refreshed");
assert!(
!auth_to_copy_back(orig, br#"{"tokens":3}"#, new),
"real changed meanwhile"
);
assert!(!auth_to_copy_back(orig, orig, b""), "emptied");
assert!(!auth_to_copy_back(orig, orig, b"not json"), "garbage");
assert!(!auth_to_copy_back(orig, orig, b"[1]"), "not an object");
}
#[test]
fn may_refresh_only_near_expiry() {
use base64::Engine as _;
let now = 1_800_000_000;
let auth = |exp: i64, last: &str| {
let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(format!(r#"{{"exp":{exp}}}"#));
format!(r#"{{"last_refresh":"{last}","tokens":{{"access_token":"h.{claims}.s"}}}}"#)
};
let recent = "2027-01-14T00:00:00Z"; assert!(!may_refresh(auth(now + 86_400, recent).as_bytes(), now));
assert!(may_refresh(auth(now + 60, recent).as_bytes(), now));
assert!(may_refresh(auth(now - 60, recent).as_bytes(), now));
assert!(may_refresh(
auth(now + 86_400, "2027-01-01T00:00:00Z").as_bytes(),
now
));
assert!(may_refresh(b"{}", now));
assert!(may_refresh(b"nope", now));
}
#[test]
fn debug_redacts_the_tokens() {
let c = Copied {
real: PathBuf::from("/r/auth.json"),
ours: PathBuf::from("/s/auth.json"),
orig: br#"{"tokens":{"access_token":"eyJsecret","refresh_token":"rt-secret"}}"#
.to_vec(),
};
let d = format!("{c:?}");
assert!(!d.contains("secret") && !d.contains("eyJ"), "{d}");
let n = c.orig_len();
assert!(d.contains(&format!("[redacted; {n} bytes]")), "{d}");
assert!(d.contains("/r/auth.json"), "{d}");
}
#[tokio::test]
async fn copies_in_and_back() {
use std::os::unix::fs::PermissionsExt as _;
let d = tempfile::tempdir().unwrap();
let real_home = d.path().join("real");
tokio::fs::create_dir(&real_home).await.unwrap();
tokio::fs::write(real_home.join("auth.json"), b"{\"a\":1}")
.await
.unwrap();
let home = d.path().join("session/home");
let c = copy_in(&real_home, &home).await.unwrap();
let mode = tokio::fs::metadata(&c.ours)
.await
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
assert!(!copy_back(&c).await.unwrap(), "untouched: nothing to copy");
tokio::fs::write(&c.ours, b"{\"a\":2}").await.unwrap();
assert!(copy_back(&c).await.unwrap());
assert_eq!(tokio::fs::read(&c.real).await.unwrap(), b"{\"a\":2}");
let c2 = copy_in(&real_home, &home).await.unwrap();
tokio::fs::write(&c2.real, b"{\"other\":1}").await.unwrap();
tokio::fs::write(&c2.ours, b"{\"a\":3}").await.unwrap();
assert!(!copy_back(&c2).await.unwrap());
assert_eq!(tokio::fs::read(&c2.real).await.unwrap(), b"{\"other\":1}");
}
}