use std::io;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub const TOKEN_FILENAME: &str = "auth_token";
pub const MIN_TOKEN_LEN: usize = 32;
pub fn token_path(app_name: &str) -> Result<PathBuf> {
Ok(crate::data_dir::resolve_data_dir(app_name)?.join(TOKEN_FILENAME))
}
pub fn mint_token() -> String {
format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
pub fn ensure_token(app_name: &str) -> Result<String> {
let path = token_path(app_name)?;
if let Some(existing) = read_token_at(&path) {
return Ok(existing);
}
let token = mint_token();
write_token_0600(&path, &token)
.with_context(|| format!("write daemon credential to {}", path.display()))?;
Ok(token)
}
pub fn read_token(app_name: &str) -> Option<String> {
read_token_at(&token_path(app_name).ok()?)
}
pub fn read_token_at(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let trimmed = raw.trim();
if trimmed.len() < MIN_TOKEN_LEN {
return None;
}
Some(trimmed.to_string())
}
pub fn credentials_match(expected: &str, presented: &str) -> bool {
if expected.len() < MIN_TOKEN_LEN {
return false;
}
let (expected, presented) = (expected.as_bytes(), presented.as_bytes());
if expected.len() != presented.len() {
return false;
}
let mut diff = 0u8;
for (a, b) in expected.iter().zip(presented.iter()) {
diff |= a ^ b;
}
diff == 0
}
pub fn url_targets_loopback(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
if !parsed.username().is_empty() || parsed.password().is_some() {
return false;
}
match parsed.host() {
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
pub fn credential_for(app_name: &str, base_url: &str, env_var: &str) -> Option<String> {
if !url_targets_loopback(base_url) {
return None;
}
if let Ok(raw) = std::env::var(env_var) {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
read_token(app_name)
}
fn write_token_0600(path: &Path, token: &str) -> io::Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!(
"tmp.{}.{}",
std::process::id(),
&mint_token()[..16]
));
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let write_result = (|| -> io::Result<()> {
let mut f = opts.open(&tmp)?;
writeln!(f, "{token}")?;
f.sync_all()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, path)
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
write_result
}
#[cfg(test)]
mod daemon_token_tests {
use super::*;
#[test]
fn minted_tokens_are_long_and_distinct() {
let a = mint_token();
let b = mint_token();
assert_eq!(a.len(), 64, "expected 64 hex chars, got {}", a.len());
assert!(a.len() >= MIN_TOKEN_LEN);
assert_ne!(a, b, "two mints produced the same token");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn ensure_token_mints_then_reuses() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOKEN_FILENAME);
assert_eq!(read_token_at(&path), None);
let first = mint_token();
write_token_0600(&path, &first).expect("write");
assert_eq!(read_token_at(&path).as_deref(), Some(first.as_str()));
}
#[test]
fn read_token_at_rejects_short_and_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOKEN_FILENAME);
assert_eq!(read_token_at(&path), None, "missing file");
std::fs::write(&path, "").expect("write empty");
assert_eq!(read_token_at(&path), None, "empty file");
std::fs::write(&path, "short\n").expect("write short");
assert_eq!(read_token_at(&path), None, "under MIN_TOKEN_LEN");
std::fs::write(&path, " \n").expect("write blank");
assert_eq!(read_token_at(&path), None, "whitespace only");
}
#[test]
fn stored_token_round_trips_without_whitespace() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOKEN_FILENAME);
let token = mint_token();
write_token_0600(&path, &token).expect("write");
let raw = std::fs::read_to_string(&path).expect("read raw");
assert!(raw.ends_with('\n'), "writer must terminate the line");
assert_eq!(read_token_at(&path).as_deref(), Some(token.as_str()));
}
#[cfg(unix)]
#[test]
fn ensure_token_writes_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOKEN_FILENAME);
write_token_0600(&path, &mint_token()).expect("write");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600 at creation, got {mode:o}");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("widen");
write_token_0600(&path, &mint_token()).expect("rewrite");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected re-asserted 0600, got {mode:o}");
}
#[test]
fn ensure_token_rotates_a_too_short_stored_value() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOKEN_FILENAME);
std::fs::write(&path, "tooshort").expect("seed weak value");
assert_eq!(read_token_at(&path), None);
let fresh = mint_token();
write_token_0600(&path, &fresh).expect("rotate");
assert_eq!(read_token_at(&path).as_deref(), Some(fresh.as_str()));
}
#[test]
fn credentials_match_is_exact() {
let token = mint_token();
assert!(credentials_match(&token, &token));
assert!(!credentials_match(&token, &token[..token.len() - 1]));
assert!(!credentials_match(&token, &format!("{token}x")));
assert!(!credentials_match(&token, ""));
let mut wrong = token.clone().into_bytes();
wrong[0] ^= 0x01;
let wrong = String::from_utf8(wrong).expect("still ascii");
assert!(!credentials_match(&token, &wrong));
}
#[test]
fn an_empty_or_weak_expected_credential_never_matches() {
for expected in ["", " ", "short", &"a".repeat(MIN_TOKEN_LEN - 1)] {
assert!(
!credentials_match(expected, expected),
"expected {expected:?} must never verify, not even against itself"
);
assert!(!credentials_match(expected, ""));
assert!(!credentials_match(expected, &mint_token()));
}
let at_floor = "a".repeat(MIN_TOKEN_LEN);
assert!(credentials_match(&at_floor, &at_floor));
}
#[test]
fn url_targets_loopback_accepts_real_loopback() {
for url in [
"http://127.0.0.1:7882",
"http://127.0.0.1",
"http://127.9.9.9:7882",
"http://localhost:7882",
"http://LOCALHOST:7882",
"https://localhost",
"http://[::1]:7882",
] {
assert!(url_targets_loopback(url), "{url} must be treated as local");
}
}
#[test]
fn url_targets_loopback_rejects_userinfo_confusion() {
for url in [
"http://127.0.0.1:7882@attacker.example",
"http://127.0.0.1:7882@attacker.example/rpc",
"http://localhost@attacker.example",
"http://user:pass@attacker.example",
"http://user@127.0.0.1:7882",
"http://user:pass@localhost",
"http://example.test:7882",
"https://10.0.0.5:7882",
"http://192.168.1.4:7882",
"http://localhost.attacker.example",
"http://notlocalhost",
"",
"127.0.0.1:7882",
"://127.0.0.1",
] {
assert!(
!url_targets_loopback(url),
"{url} must NOT be treated as local"
);
}
}
#[test]
#[serial_test::serial]
fn credential_for_withholds_from_non_loopback() {
let _env = EnvVar::set("TRUSTY_TEST_DAEMON_TOKEN", &"a".repeat(64));
assert_eq!(
credential_for(
"trusty-test-app",
"http://127.0.0.1:7882@attacker.example",
"TRUSTY_TEST_DAEMON_TOKEN"
),
None
);
assert_eq!(
credential_for(
"trusty-test-app",
"http://127.0.0.1:7882",
"TRUSTY_TEST_DAEMON_TOKEN"
)
.as_deref(),
Some("a".repeat(64).as_str())
);
}
#[test]
#[serial_test::serial]
fn credential_for_prefers_the_env_override() {
let _env = EnvVar::set("TRUSTY_TEST_DAEMON_TOKEN", &"b".repeat(64));
assert_eq!(
credential_for(
"trusty-test-app",
"http://localhost:7882",
"TRUSTY_TEST_DAEMON_TOKEN"
)
.as_deref(),
Some("b".repeat(64).as_str())
);
}
#[test]
#[serial_test::serial]
fn credential_for_ignores_a_blank_override() {
let _env = EnvVar::set("TRUSTY_TEST_DAEMON_TOKEN", " ");
assert_ne!(
credential_for(
"trusty-test-app",
"http://127.0.0.1:7882",
"TRUSTY_TEST_DAEMON_TOKEN"
)
.as_deref(),
Some("")
);
}
#[test]
fn concurrent_writes_never_publish_a_partial_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(TOKEN_FILENAME);
write_token_0600(&path, &mint_token()).expect("seed");
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader = {
let (path, stop) = (path.clone(), stop.clone());
std::thread::spawn(move || {
let mut observations = 0u32;
while !stop.load(std::sync::atomic::Ordering::Relaxed) {
if let Ok(raw) = std::fs::read_to_string(&path) {
assert_eq!(
raw.trim().len(),
64,
"observed a partial credential file: {raw:?}"
);
observations += 1;
}
}
observations
})
};
for _ in 0..200 {
write_token_0600(&path, &mint_token()).expect("rewrite");
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let observations = reader.join().expect("reader thread");
assert!(observations > 0, "the reader never observed the file");
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.expect("read dir")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.contains("tmp"))
.collect();
assert!(
leftovers.is_empty(),
"scratch files left behind: {leftovers:?}"
);
}
struct EnvVar(&'static str);
impl EnvVar {
fn set(key: &'static str, value: &str) -> Self {
unsafe { std::env::set_var(key, value) };
Self(key)
}
}
impl Drop for EnvVar {
fn drop(&mut self) {
unsafe { std::env::remove_var(self.0) };
}
}
}