use std::path::PathBuf;
use std::sync::Mutex;
use fs2::FileExt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UserConfig {
#[serde(default = "default_true")]
pub upload_enabled: bool,
#[serde(default)]
pub pending_upload: u64,
#[serde(default)]
pub last_upload_at: i64,
#[serde(default)]
pub last_worldwide_total: u64,
#[serde(default)]
pub last_worldwide_fetch_at: i64,
#[serde(default)]
pub last_flush_attempt_at: i64,
#[serde(default)]
pub cached_latest_version: String,
#[serde(default)]
pub last_version_check_at: i64,
#[serde(default)]
pub last_version_warning_at: i64,
#[serde(default)]
pub installed_agents: Vec<String>,
#[serde(default = "default_watcher_debounce", alias = "daemon_debounce")]
pub watcher_debounce: String,
#[serde(default)]
pub cached_country_flags: Vec<String>,
#[serde(default)]
pub last_flags_fetch_at: i64,
#[serde(default)]
pub last_pricing_fetch_at: i64,
#[serde(default)]
pub last_installed_version: String,
#[serde(default)]
pub previous_version: String,
#[serde(default = "default_extraction_timeout_secs")]
pub extraction_timeout_secs: u64,
#[serde(default)]
pub wildcard_permissions: bool,
#[serde(skip)]
pub(crate) loaded_config: Mutex<Option<ConfigFile>>,
}
const _: fn() = || {
fn assert_send_sync<T: Send + Sync + ?Sized>() {}
assert_send_sync::<UserConfig>();
};
fn default_true() -> bool {
true
}
fn default_watcher_debounce() -> String {
"2s".to_string()
}
fn default_extraction_timeout_secs() -> u64 {
60
}
impl Default for UserConfig {
fn default() -> Self {
Self {
upload_enabled: true,
pending_upload: 0,
last_upload_at: 0,
last_worldwide_total: 0,
last_worldwide_fetch_at: 0,
last_flush_attempt_at: 0,
cached_latest_version: String::new(),
last_version_check_at: 0,
last_version_warning_at: 0,
installed_agents: Vec::new(),
watcher_debounce: default_watcher_debounce(),
cached_country_flags: Vec::new(),
last_flags_fetch_at: 0,
last_pricing_fetch_at: 0,
last_installed_version: String::new(),
previous_version: String::new(),
extraction_timeout_secs: default_extraction_timeout_secs(),
wildcard_permissions: false,
loaded_config: Mutex::new(None),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ConfigFile {
#[serde(default = "default_true")]
upload_enabled: bool,
#[serde(default = "default_watcher_debounce", alias = "daemon_debounce")]
watcher_debounce: String,
#[serde(default = "default_extraction_timeout_secs")]
extraction_timeout_secs: u64,
#[serde(default)]
wildcard_permissions: bool,
}
impl ConfigFile {
fn from_config(c: &UserConfig) -> Self {
Self {
upload_enabled: c.upload_enabled,
watcher_debounce: c.watcher_debounce.clone(),
extraction_timeout_secs: c.extraction_timeout_secs,
wildcard_permissions: c.wildcard_permissions,
}
}
}
#[derive(Serialize, Deserialize, Default)]
struct StateFile {
#[serde(default)]
pending_upload: u64,
#[serde(default)]
last_upload_at: i64,
#[serde(default)]
last_worldwide_total: u64,
#[serde(default)]
last_worldwide_fetch_at: i64,
#[serde(default)]
last_flush_attempt_at: i64,
#[serde(default)]
cached_latest_version: String,
#[serde(default)]
last_version_check_at: i64,
#[serde(default)]
last_version_warning_at: i64,
#[serde(default)]
cached_country_flags: Vec<String>,
#[serde(default)]
last_flags_fetch_at: i64,
#[serde(default)]
last_pricing_fetch_at: i64,
#[serde(default)]
last_installed_version: String,
#[serde(default)]
previous_version: String,
#[serde(default)]
installed_agents: Vec<String>,
}
fn apply_state(config: &mut UserConfig, state: StateFile) {
config.pending_upload = state.pending_upload;
config.last_upload_at = state.last_upload_at;
config.last_worldwide_total = state.last_worldwide_total;
config.last_worldwide_fetch_at = state.last_worldwide_fetch_at;
config.last_flush_attempt_at = state.last_flush_attempt_at;
config.cached_latest_version = state.cached_latest_version;
config.last_version_check_at = state.last_version_check_at;
config.last_version_warning_at = state.last_version_warning_at;
config.cached_country_flags = state.cached_country_flags;
config.last_flags_fetch_at = state.last_flags_fetch_at;
config.last_pricing_fetch_at = state.last_pricing_fetch_at;
config.last_installed_version = state.last_installed_version;
config.previous_version = state.previous_version;
config.installed_agents = state.installed_agents;
}
static TMP_FILE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static SAVE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
const MAX_SYMLINK_HOPS: u32 = 40;
fn resolve_write_target(path: &std::path::Path) -> Option<PathBuf> {
if let Ok(canon) = std::fs::canonicalize(path) {
return Some(canon);
}
let mut current = path.to_path_buf();
for _ in 0..MAX_SYMLINK_HOPS {
let Ok(meta) = std::fs::symlink_metadata(¤t) else {
return Some(current);
};
if !meta.file_type().is_symlink() {
return Some(current);
}
let Ok(link_target) = std::fs::read_link(¤t) else {
return Some(current);
};
current = if link_target.is_absolute() {
link_target
} else {
current
.parent()
.map(|parent| parent.join(&link_target))
.unwrap_or(link_target)
};
if let Ok(canon) = std::fs::canonicalize(¤t) {
return Some(canon);
}
}
None
}
fn replace_via_backup(tmp_path: &std::path::Path, target: &std::path::Path, unique: u64) -> bool {
let backup = target.with_extension(format!("bak.{}.{unique}", std::process::id()));
if std::fs::rename(target, &backup).is_err() {
let _ = std::fs::remove_file(tmp_path);
return false;
}
if std::fs::rename(tmp_path, target).is_ok() {
let _ = std::fs::remove_file(&backup);
true
} else {
let _ = std::fs::rename(&backup, target);
let _ = std::fs::remove_file(tmp_path);
false
}
}
#[cfg(unix)]
fn set_tmp_file_mode(tmp_path: &std::path::Path, target: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(target).map_or(0o600, |m| m.permissions().mode() & 0o777);
std::fs::set_permissions(tmp_path, std::fs::Permissions::from_mode(mode)).is_ok()
}
fn write_atomic(path: &std::path::Path, contents: &str) -> bool {
let Some(target) = resolve_write_target(path) else {
return false;
};
let unique = TMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp_path = target.with_extension(format!("tmp.{}.{unique}", std::process::id()));
if std::fs::write(&tmp_path, contents).is_err() {
return false;
}
#[cfg(unix)]
if !set_tmp_file_mode(&tmp_path, &target) {
let _ = std::fs::remove_file(&tmp_path);
return false;
}
match std::fs::rename(&tmp_path, &target) {
Ok(()) => true,
Err(e) if cfg!(windows) && e.kind() == std::io::ErrorKind::AlreadyExists => {
replace_via_backup(&tmp_path, &target, unique)
}
Err(_) => {
let _ = std::fs::remove_file(&tmp_path);
false
}
}
}
#[cfg(test)]
thread_local! {
static TEST_HOME_OVERRIDE: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
fn set_test_home_dir(dir: Option<PathBuf>) {
TEST_HOME_OVERRIDE.with(|cell| *cell.borrow_mut() = dir);
}
fn tokensave_dir() -> Option<PathBuf> {
#[cfg(test)]
{
if let Some(dir) = TEST_HOME_OVERRIDE.with(|cell| cell.borrow().clone()) {
return Some(dir);
}
}
dirs::home_dir().map(|h| h.join(".tokensave"))
}
pub fn config_path() -> Option<PathBuf> {
tokensave_dir().map(|d| d.join("config.toml"))
}
pub fn state_path() -> Option<PathBuf> {
tokensave_dir().map(|d| d.join("state.toml"))
}
fn config_lock_path() -> Option<PathBuf> {
tokensave_dir().map(|d| d.join("config.toml.lock"))
}
fn preserve_corrupt_file(path: &std::path::Path) {
let target = resolve_write_target(path).unwrap_or_else(|| path.to_path_buf());
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let unique = TMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let backup = target.with_extension(format!(
"toml.corrupt.{timestamp}.{}.{unique}",
std::process::id()
));
let _ = std::fs::rename(&target, backup);
}
fn lock_config_file() -> Option<std::fs::File> {
let f = config_lock_path().and_then(|p| {
std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(p)
.ok()
})?;
f.lock_exclusive().ok()?;
Some(f)
}
impl UserConfig {
pub fn load() -> Self {
let _guard = SAVE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let config_p = config_path();
let config_lock = lock_config_file();
let raw = config_p.as_ref().and_then(|p| std::fs::read(p).ok());
let parsed = raw
.as_ref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|contents| toml::from_str::<Self>(contents).ok());
let mut base: Self = if let Some(mut cfg) = parsed {
cfg.loaded_config = Mutex::new(Some(ConfigFile::from_config(&cfg)));
cfg
} else {
if raw.is_some() {
if let Some(p) = &config_p {
preserve_corrupt_file(p);
}
}
Self::default()
};
if let Some(f) = &config_lock {
let _ = f.unlock();
}
if let Some(state_p) = state_path() {
if let Ok(bytes) = std::fs::read(&state_p) {
match std::str::from_utf8(&bytes)
.ok()
.and_then(|contents| toml::from_str::<StateFile>(contents).ok())
{
Some(state) => apply_state(&mut base, state),
None => preserve_corrupt_file(&state_p),
}
}
}
base
}
pub fn save(&self) -> bool {
let Some(config_path) = config_path() else {
return false;
};
let Some(state_path) = state_path() else {
return false;
};
if let Some(parent) = config_path.parent() {
if std::fs::create_dir_all(parent).is_err() {
return false;
}
}
let state_file = StateFile {
pending_upload: self.pending_upload,
last_upload_at: self.last_upload_at,
last_worldwide_total: self.last_worldwide_total,
last_worldwide_fetch_at: self.last_worldwide_fetch_at,
last_flush_attempt_at: self.last_flush_attempt_at,
cached_latest_version: self.cached_latest_version.clone(),
last_version_check_at: self.last_version_check_at,
last_version_warning_at: self.last_version_warning_at,
cached_country_flags: self.cached_country_flags.clone(),
last_flags_fetch_at: self.last_flags_fetch_at,
last_pricing_fetch_at: self.last_pricing_fetch_at,
last_installed_version: self.last_installed_version.clone(),
previous_version: self.previous_version.clone(),
installed_agents: self.installed_agents.clone(),
};
let Ok(state_contents) = toml::to_string_pretty(&state_file) else {
return false;
};
let _guard = SAVE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let lock_file = lock_config_file();
let config_file = self.merge_config_file();
let Ok(config_contents) = toml::to_string_pretty(&config_file) else {
if let Some(f) = &lock_file {
let _ = f.unlock();
}
return false;
};
let ok = write_atomic(&state_path, &state_contents)
&& write_atomic(&config_path, &config_contents);
if let Some(f) = &lock_file {
let _ = f.unlock();
}
if ok {
*self
.loaded_config
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some(ConfigFile::from_config(self));
}
ok
}
fn merge_config_file(&self) -> ConfigFile {
let self_file = ConfigFile::from_config(self);
let baseline = self
.loaded_config
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(base) = baseline.as_ref() else {
return self_file;
};
let Some(on_disk) = config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|contents| toml::from_str::<ConfigFile>(&contents).ok())
else {
return self_file;
};
ConfigFile {
upload_enabled: if self.upload_enabled == base.upload_enabled {
on_disk.upload_enabled
} else {
self.upload_enabled
},
watcher_debounce: if self.watcher_debounce == base.watcher_debounce {
on_disk.watcher_debounce
} else {
self.watcher_debounce.clone()
},
extraction_timeout_secs: if self.extraction_timeout_secs == base.extraction_timeout_secs
{
on_disk.extraction_timeout_secs
} else {
self.extraction_timeout_secs
},
wildcard_permissions: if self.wildcard_permissions == base.wildcard_permissions {
on_disk.wildcard_permissions
} else {
self.wildcard_permissions
},
}
}
pub fn is_fresh() -> bool {
config_path().is_none_or(|p| !p.exists())
}
}
pub fn parse_duration(s: &str) -> Option<std::time::Duration> {
let s = s.trim();
if let Some(secs) = s.strip_suffix('s') {
secs.trim()
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
} else if let Some(mins) = s.strip_suffix('m') {
mins.trim()
.parse::<u64>()
.ok()
.map(|m| std::time::Duration::from_secs(m * 60))
} else {
s.parse::<u64>().ok().map(std::time::Duration::from_secs)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::duration_suboptimal_units
)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn parse_duration_seconds() {
assert_eq!(parse_duration("15s"), Some(Duration::from_secs(15)));
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
assert_eq!(parse_duration(" 5s "), Some(Duration::from_secs(5)));
}
#[test]
fn parse_duration_minutes() {
assert_eq!(parse_duration("1m"), Some(Duration::from_secs(60)));
assert_eq!(parse_duration("2m"), Some(Duration::from_secs(120)));
}
#[test]
fn parse_duration_bare_number() {
assert_eq!(parse_duration("10"), Some(Duration::from_secs(10)));
}
#[test]
fn parse_duration_invalid() {
assert_eq!(parse_duration("abc"), None);
assert_eq!(parse_duration(""), None);
assert_eq!(parse_duration("1h"), None);
}
struct TestHome {
_dir: tempfile::TempDir,
}
impl Drop for TestHome {
fn drop(&mut self) {
set_test_home_dir(None);
}
}
fn test_home() -> TestHome {
let dir = tempfile::tempdir().expect("tempdir");
set_test_home_dir(Some(dir.path().to_path_buf()));
TestHome { _dir: dir }
}
fn sample_config() -> UserConfig {
UserConfig {
upload_enabled: false,
pending_upload: 42,
last_upload_at: 100,
last_worldwide_total: 999,
last_worldwide_fetch_at: 200,
last_flush_attempt_at: 300,
cached_latest_version: "1.2.3".to_string(),
last_version_check_at: 400,
last_version_warning_at: 500,
installed_agents: vec!["claude".to_string(), "cursor".to_string()],
watcher_debounce: "5s".to_string(),
cached_country_flags: vec!["us".to_string(), "cz".to_string()],
last_flags_fetch_at: 600,
last_pricing_fetch_at: 700,
last_installed_version: "1.2.2".to_string(),
previous_version: "1.2.1".to_string(),
extraction_timeout_secs: 30,
wildcard_permissions: true,
loaded_config: Mutex::new(None),
}
}
#[test]
fn round_trip_save_and_load() {
let _home = test_home();
let config = sample_config();
assert!(config.save());
let loaded = UserConfig::load();
assert_eq!(loaded.upload_enabled, config.upload_enabled);
assert_eq!(loaded.pending_upload, config.pending_upload);
assert_eq!(loaded.last_upload_at, config.last_upload_at);
assert_eq!(loaded.last_worldwide_total, config.last_worldwide_total);
assert_eq!(
loaded.last_worldwide_fetch_at,
config.last_worldwide_fetch_at
);
assert_eq!(loaded.last_flush_attempt_at, config.last_flush_attempt_at);
assert_eq!(loaded.cached_latest_version, config.cached_latest_version);
assert_eq!(loaded.last_version_check_at, config.last_version_check_at);
assert_eq!(
loaded.last_version_warning_at,
config.last_version_warning_at
);
assert_eq!(loaded.installed_agents, config.installed_agents);
assert_eq!(loaded.watcher_debounce, config.watcher_debounce);
assert_eq!(loaded.cached_country_flags, config.cached_country_flags);
assert_eq!(loaded.last_flags_fetch_at, config.last_flags_fetch_at);
assert_eq!(loaded.last_pricing_fetch_at, config.last_pricing_fetch_at);
assert_eq!(loaded.last_installed_version, config.last_installed_version);
assert_eq!(loaded.previous_version, config.previous_version);
assert_eq!(
loaded.extraction_timeout_secs,
config.extraction_timeout_secs
);
assert_eq!(loaded.wildcard_permissions, config.wildcard_permissions);
}
#[test]
fn save_splits_fields_across_files() {
let _home = test_home();
sample_config().save();
let config_contents =
std::fs::read_to_string(config_path().expect("config path")).expect("read config");
assert!(config_contents.contains("upload_enabled"));
assert!(!config_contents.contains("cached_latest_version"));
assert!(!config_contents.contains("pending_upload"));
let state_contents =
std::fs::read_to_string(state_path().expect("state path")).expect("read state");
assert!(state_contents.contains("pending_upload"));
assert!(state_contents.contains("cached_latest_version"));
assert!(!state_contents.contains("wildcard_permissions"));
}
#[test]
fn save_twice_overwrites_previous_files() {
let _home = test_home();
let mut config = sample_config();
assert!(config.save());
config.pending_upload = 4321;
config.wildcard_permissions = false;
assert!(config.save());
let loaded = UserConfig::load();
assert_eq!(loaded.pending_upload, 4321);
assert!(!loaded.wildcard_permissions);
}
#[test]
fn save_does_not_clobber_field_changed_by_another_process_after_load() {
let _home = test_home();
let mut config = sample_config();
config.wildcard_permissions = false;
assert!(config.save());
let a = UserConfig::load();
assert!(!a.wildcard_permissions);
let mut other = UserConfig::load();
other.wildcard_permissions = true;
assert!(other.save());
assert!(a.save());
let reloaded = UserConfig::load();
assert!(
reloaded.wildcard_permissions,
"save() clobbered another process's concurrent change to a field this process left untouched"
);
}
#[test]
fn save_writes_field_this_process_intentionally_changed() {
let _home = test_home();
let mut config = sample_config();
config.wildcard_permissions = false;
assert!(config.save());
let mut loaded = UserConfig::load();
loaded.wildcard_permissions = true;
assert!(loaded.save());
let reloaded = UserConfig::load();
assert!(
reloaded.wildcard_permissions,
"a field this process intentionally changed must still be written"
);
}
#[test]
fn save_advances_baseline_so_later_external_changes_are_not_reverted() {
let _home = test_home();
let mut config = sample_config();
config.wildcard_permissions = false;
assert!(config.save());
let mut long_lived = UserConfig::load();
long_lived.wildcard_permissions = true;
assert!(long_lived.save());
let mut other = UserConfig::load();
other.wildcard_permissions = false;
assert!(other.save());
long_lived.extraction_timeout_secs = 999;
assert!(long_lived.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.extraction_timeout_secs, 999);
assert!(
!reloaded.wildcard_permissions,
"save() re-wrote a field from a stale load()-time baseline instead of the advanced one, reverting a concurrent external change"
);
}
#[test]
fn save_advancing_baseline_to_merged_disk_value_does_not_revert_untouched_field_on_next_save() {
let _home = test_home();
let mut config = sample_config();
config.wildcard_permissions = false;
assert!(config.save());
let mut a = UserConfig::load();
let mut b = UserConfig::load();
b.wildcard_permissions = true;
assert!(b.save());
a.extraction_timeout_secs = 111;
assert!(a.save());
assert!(UserConfig::load().wildcard_permissions);
a.watcher_debounce = "45s".to_string();
assert!(a.save());
let reloaded = UserConfig::load();
assert_eq!(reloaded.watcher_debounce, "45s");
assert!(
reloaded.wildcard_permissions,
"a second consecutive save() reverted a field `a` never touched, because the baseline advanced to the merged/disk value instead of a's own in-memory value"
);
}
#[test]
fn config_lock_prevents_lost_updates_across_independent_file_handles() {
let dir = tempfile::tempdir().expect("tempdir");
let counter_path = dir.path().join("counter");
std::fs::write(&counter_path, "0").expect("seed counter");
let lock_path = dir.path().join("counter.lock");
let handles: Vec<_> = (0..16_u32)
.map(|_| {
let counter_path = counter_path.clone();
let lock_path = lock_path.clone();
std::thread::spawn(move || {
for _ in 0..25 {
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.expect("open lock file");
f.lock_exclusive().expect("acquire lock");
let contents = std::fs::read_to_string(&counter_path).expect("read");
let n: u64 = contents.trim().parse().expect("parse counter");
std::fs::write(&counter_path, (n + 1).to_string()).expect("write");
let _ = f.unlock();
}
})
})
.collect();
for h in handles {
h.join().expect("thread panicked");
}
let final_contents = std::fs::read_to_string(&counter_path).expect("read final");
let final_n: u64 = final_contents.trim().parse().expect("parse final counter");
assert_eq!(
final_n, 400,
"lock failed to exclude concurrent holders, losing increments"
);
}
#[test]
fn concurrent_saves_never_delete_destination_files() {
let dir = tempfile::tempdir().expect("tempdir");
let tokensave_dir = dir.path().to_path_buf();
let handles: Vec<_> = (0..8_u64)
.map(|i| {
let tokensave_dir = tokensave_dir.clone();
std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir));
let mut config = sample_config();
config.pending_upload = i;
assert!(config.save(), "save() should not fail under contention");
})
})
.collect();
for handle in handles {
handle.join().expect("saving thread panicked");
}
assert!(tokensave_dir.join("config.toml").exists());
assert!(tokensave_dir.join("state.toml").exists());
}
#[test]
fn concurrent_saves_do_not_mix_config_and_state() {
let dir = tempfile::tempdir().expect("tempdir");
let tokensave_dir = dir.path().to_path_buf();
let handles: Vec<_> = (0..8_u64)
.map(|i| {
let tokensave_dir = tokensave_dir.clone();
std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir));
for _ in 0..25 {
let mut config = sample_config();
config.pending_upload = i;
config.wildcard_permissions = i % 2 == 0;
assert!(config.save(), "save() should not fail under contention");
}
})
})
.collect();
for handle in handles {
handle.join().expect("saving thread panicked");
}
let tokensave_dir_for_check = tokensave_dir.clone();
let loaded = std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir_for_check));
UserConfig::load()
})
.join()
.expect("checker thread panicked");
assert_eq!(
loaded.wildcard_permissions,
loaded.pending_upload % 2 == 0,
"config.toml and state.toml came from different saves: pending_upload={}, wildcard_permissions={}",
loaded.pending_upload,
loaded.wildcard_permissions,
);
}
#[test]
fn load_never_returns_torn_config_state_pair_during_concurrent_saves() {
let dir = tempfile::tempdir().expect("tempdir");
let tokensave_dir = dir.path().to_path_buf();
let saver_handles: Vec<_> = (0..4_u64)
.map(|i| {
let tokensave_dir = tokensave_dir.clone();
std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir));
for iter in 0..25_u64 {
let mut config = sample_config();
if (i + iter) % 2 == 0 {
config.pending_upload = 0;
config.wildcard_permissions = false;
} else {
config.pending_upload = i + 1;
config.wildcard_permissions = true;
}
assert!(config.save(), "save() should not fail under contention");
}
})
})
.collect();
let loader_handles: Vec<_> = (0..4_u64)
.map(|_| {
let tokensave_dir = tokensave_dir.clone();
std::thread::spawn(move || {
set_test_home_dir(Some(tokensave_dir));
for _ in 0..50 {
let loaded = UserConfig::load();
assert_eq!(
loaded.wildcard_permissions,
loaded.pending_upload != 0,
"load() returned a torn config/state pair: pending_upload={}, wildcard_permissions={}",
loaded.pending_upload,
loaded.wildcard_permissions,
);
}
})
})
.collect();
for handle in saver_handles {
handle.join().expect("saving thread panicked");
}
for handle in loader_handles {
handle.join().expect("loading thread panicked");
}
}
#[test]
fn load_preserves_corrupt_state_file_instead_of_overwriting_it() {
let home = test_home();
let state_p = state_path().expect("state path");
if let Some(parent) = state_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
let corrupt_contents = "pending_upload = [not valid toml";
std::fs::write(&state_p, corrupt_contents).expect("seed corrupt state.toml");
let loaded = UserConfig::load();
assert_eq!(
loaded.pending_upload, 0,
"unrecoverable state fields should fall back to defaults"
);
assert!(
!state_p.exists(),
"corrupt state.toml should have been moved aside, not left at the original path"
);
let tokensave_dir = state_p.parent().expect("state parent").to_path_buf();
let backups: Vec<_> = std::fs::read_dir(&tokensave_dir)
.expect("read tokensave dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("state.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the corrupt state.toml"
);
let backup_path = backups[0].path();
assert_eq!(
std::fs::read_to_string(&backup_path).expect("read preserved backup"),
corrupt_contents
);
assert!(loaded.save());
assert!(state_p.exists());
assert_eq!(
std::fs::read_to_string(&backup_path).expect("read preserved backup after save"),
corrupt_contents
);
drop(home);
}
#[test]
fn load_preserves_corrupt_config_file_instead_of_overwriting_it() {
let home = test_home();
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
let corrupt_contents = "wildcard_permissions = [not valid toml";
std::fs::write(&config_p, corrupt_contents).expect("seed corrupt config.toml");
let loaded = UserConfig::load();
assert!(
!loaded.wildcard_permissions,
"unrecoverable config fields should fall back to defaults"
);
assert!(
!config_p.exists(),
"corrupt config.toml should have been moved aside, not left at the original path"
);
let tokensave_dir = config_p.parent().expect("config parent").to_path_buf();
let backups: Vec<_> = std::fs::read_dir(&tokensave_dir)
.expect("read tokensave dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("config.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the corrupt config.toml"
);
let backup_path = backups[0].path();
assert_eq!(
std::fs::read_to_string(&backup_path).expect("read preserved backup"),
corrupt_contents
);
assert!(loaded.save());
assert!(config_p.exists());
assert_eq!(
std::fs::read_to_string(&backup_path).expect("read preserved backup after save"),
corrupt_contents
);
drop(home);
}
#[test]
fn load_preserves_invalid_utf8_config_file_instead_of_overwriting_it() {
let home = test_home();
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
let corrupt_bytes: &[u8] = &[b'w', b'p', b'=', 0xFF, 0xFE, b'x'];
std::fs::write(&config_p, corrupt_bytes).expect("seed invalid-UTF-8 config.toml");
let loaded = UserConfig::load();
assert!(
!loaded.wildcard_permissions,
"unrecoverable config fields should fall back to defaults"
);
assert!(
!config_p.exists(),
"invalid-UTF-8 config.toml should have been moved aside, not left at the original path"
);
let tokensave_dir = config_p.parent().expect("config parent").to_path_buf();
let backups: Vec<_> = std::fs::read_dir(&tokensave_dir)
.expect("read tokensave dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("config.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the invalid-UTF-8 config.toml"
);
let backup_path = backups[0].path();
assert_eq!(
std::fs::read(&backup_path).expect("read preserved backup"),
corrupt_bytes
);
assert!(loaded.save());
assert!(config_p.exists());
assert_eq!(
std::fs::read(&backup_path).expect("read preserved backup after save"),
corrupt_bytes
);
drop(home);
}
#[test]
fn load_preserves_invalid_utf8_state_file_instead_of_overwriting_it() {
let home = test_home();
let state_p = state_path().expect("state path");
if let Some(parent) = state_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
let corrupt_bytes: &[u8] = &[b'p', b'u', b'=', 0xFF, 0xFE, b'x'];
std::fs::write(&state_p, corrupt_bytes).expect("seed invalid-UTF-8 state.toml");
let loaded = UserConfig::load();
assert_eq!(
loaded.pending_upload, 0,
"unrecoverable state fields should fall back to defaults"
);
assert!(
!state_p.exists(),
"invalid-UTF-8 state.toml should have been moved aside, not left at the original path"
);
let tokensave_dir = state_p.parent().expect("state parent").to_path_buf();
let backups: Vec<_> = std::fs::read_dir(&tokensave_dir)
.expect("read tokensave dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("state.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the invalid-UTF-8 state.toml"
);
let backup_path = backups[0].path();
assert_eq!(
std::fs::read(&backup_path).expect("read preserved backup"),
corrupt_bytes
);
assert!(loaded.save());
assert!(state_p.exists());
assert_eq!(
std::fs::read(&backup_path).expect("read preserved backup after save"),
corrupt_bytes
);
drop(home);
}
#[test]
fn load_blocks_on_config_lock_instead_of_racing_a_concurrent_writer() {
const HOLD: Duration = Duration::from_millis(300);
let dir = tempfile::tempdir().expect("tempdir");
let tokensave_dir = dir.path().to_path_buf();
set_test_home_dir(Some(tokensave_dir.clone()));
std::fs::create_dir_all(&tokensave_dir).expect("create tokensave dir");
let config_p = tokensave_dir.join("config.toml");
std::fs::write(&config_p, "wildcard_permissions = [not valid toml")
.expect("seed corrupt config.toml");
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let other_process = {
let tokensave_dir = tokensave_dir.clone();
let barrier = std::sync::Arc::clone(&barrier);
std::thread::spawn(move || {
let lock_p = tokensave_dir.join("config.toml.lock");
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_p)
.expect("open lock file");
f.lock_exclusive().expect("acquire lock");
barrier.wait();
std::thread::sleep(HOLD);
let _ = f.unlock();
})
};
barrier.wait();
let start = std::time::Instant::now();
let loaded = UserConfig::load();
let elapsed = start.elapsed();
other_process.join().expect("other_process thread panicked");
assert!(
elapsed >= HOLD / 2,
"load() returned in {elapsed:?} without waiting for the concurrent lock holder \
(held for {HOLD:?}) -- it isn't taking the cross-process config lock at all"
);
assert!(
!loaded.wildcard_permissions,
"unrecoverable config fields should fall back to defaults"
);
assert!(
!config_p.exists(),
"corrupt config.toml should have been moved aside only after the lock was free"
);
set_test_home_dir(None);
}
#[cfg(unix)]
#[test]
fn load_preserves_corrupt_config_file_through_symlink_without_detaching_it() {
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let repo_config = repo_dir.path().join("config.toml");
let corrupt_contents = "wildcard_permissions = [not valid toml";
std::fs::write(&repo_config, corrupt_contents).expect("seed corrupt repo config");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&repo_config, &config_p).expect("symlink config");
let loaded = UserConfig::load();
assert!(
!loaded.wildcard_permissions,
"unrecoverable config fields should fall back to defaults"
);
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata");
assert!(
meta.file_type().is_symlink(),
"the config.toml symlink itself should survive corrupt-file preservation"
);
assert!(
!repo_config.exists(),
"the corrupt content at the symlink's target should have been moved aside"
);
let backups: Vec<_> = std::fs::read_dir(repo_dir.path())
.expect("read repo dir")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("config.toml.corrupt.")
})
.collect();
assert_eq!(
backups.len(),
1,
"expected exactly one preserved backup of the corrupt repo config"
);
assert_eq!(
std::fs::read_to_string(backups[0].path()).expect("read preserved backup"),
corrupt_contents
);
assert!(loaded.save());
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata after save");
assert!(
meta.file_type().is_symlink(),
"save() should write through the dangling symlink, not replace it"
);
assert!(
repo_config.exists(),
"save() should recreate the repo target"
);
drop(home);
}
#[test]
fn migrates_legacy_mixed_config_file() {
let home = test_home();
let legacy = sample_config();
let legacy_toml = toml::to_string_pretty(&legacy).expect("serialize legacy config");
std::fs::write(config_path().expect("config path"), legacy_toml).expect("write legacy");
assert!(!state_path().expect("state path").exists());
let loaded = UserConfig::load();
assert_eq!(loaded.pending_upload, legacy.pending_upload);
assert_eq!(loaded.previous_version, legacy.previous_version);
assert_eq!(loaded.cached_latest_version, legacy.cached_latest_version);
assert!(loaded.save());
assert!(state_path().expect("state path").exists());
let config_contents =
std::fs::read_to_string(config_path().expect("config path")).expect("read config");
assert!(!config_contents.contains("pending_upload"));
assert!(!config_contents.contains("previous_version"));
drop(home);
}
#[test]
fn failed_state_write_does_not_lose_legacy_config_state() {
let home = test_home();
let legacy = sample_config();
let legacy_toml = toml::to_string_pretty(&legacy).expect("serialize legacy config");
let config_p = config_path().expect("config path");
std::fs::write(&config_p, &legacy_toml).expect("write legacy");
let state_p = state_path().expect("state path");
std::fs::create_dir_all(&state_p).expect("create state dir");
let loaded = UserConfig::load();
assert_eq!(loaded.pending_upload, legacy.pending_upload);
assert_eq!(loaded.previous_version, legacy.previous_version);
assert!(!loaded.save());
let config_contents = std::fs::read_to_string(&config_p).expect("read config");
assert_eq!(config_contents, legacy_toml);
drop(home);
}
#[test]
fn replace_via_backup_replaces_destination_on_success() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("state.toml");
std::fs::write(&target, "original").expect("seed target");
let tmp_path = dir.path().join("state.tmp.0");
std::fs::write(&tmp_path, "updated").expect("seed tmp");
assert!(replace_via_backup(&tmp_path, &target, 0));
assert_eq!(
std::fs::read_to_string(&target).expect("read target"),
"updated"
);
assert!(!tmp_path.exists(), "temp file should be consumed");
let leftover_count = std::fs::read_dir(dir.path())
.expect("read dir")
.filter_map(Result::ok)
.count();
assert_eq!(leftover_count, 1, "backup file was not cleaned up");
}
#[test]
fn replace_via_backup_restores_destination_if_replacement_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("state.toml");
std::fs::write(&target, "original").expect("seed target");
let missing_tmp_path = dir.path().join("state.tmp.missing");
assert!(!replace_via_backup(&missing_tmp_path, &target, 0));
assert_eq!(
std::fs::read_to_string(&target).expect("read target"),
"original",
"destination was lost instead of restored"
);
}
#[cfg(unix)]
#[test]
fn save_writes_through_config_symlink() {
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let repo_config = repo_dir.path().join("config.toml");
std::fs::write(&repo_config, "upload_enabled = true\n").expect("seed repo config");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&repo_config, &config_p).expect("symlink config");
assert!(sample_config().save());
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata");
assert!(
meta.file_type().is_symlink(),
"save() replaced the config.toml symlink with a regular file"
);
let repo_contents = std::fs::read_to_string(&repo_config).expect("read repo config");
assert!(repo_contents.contains("wildcard_permissions = true"));
drop(home);
}
#[cfg(unix)]
#[test]
fn save_writes_through_dangling_config_symlink() {
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let repo_config = repo_dir.path().join("config.toml");
assert!(!repo_config.exists(), "repo config must start absent");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&repo_config, &config_p).expect("symlink config");
assert!(sample_config().save());
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata");
assert!(
meta.file_type().is_symlink(),
"save() replaced the dangling config.toml symlink with a regular file"
);
let repo_contents = std::fs::read_to_string(&repo_config).expect("read repo config");
assert!(repo_contents.contains("wildcard_permissions = true"));
drop(home);
}
#[cfg(unix)]
#[test]
fn save_writes_through_nested_dangling_config_symlink() {
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let missing_target = repo_dir.path().join("missing-target");
assert!(!missing_target.exists(), "chain target must start absent");
let managed_config = repo_dir.path().join("managed-config");
std::os::unix::fs::symlink(&missing_target, &managed_config)
.expect("symlink managed-config -> missing-target");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&managed_config, &config_p)
.expect("symlink config.toml -> managed-config");
assert!(sample_config().save());
let config_meta = std::fs::symlink_metadata(&config_p).expect("config symlink metadata");
assert!(
config_meta.file_type().is_symlink(),
"save() replaced the config.toml symlink with a regular file"
);
let managed_meta =
std::fs::symlink_metadata(&managed_config).expect("managed-config symlink metadata");
assert!(
managed_meta.file_type().is_symlink(),
"save() replaced the managed-config symlink with a regular file"
);
let contents = std::fs::read_to_string(&missing_target).expect("read chain target");
assert!(contents.contains("wildcard_permissions = true"));
drop(home);
}
#[cfg(unix)]
#[test]
fn save_fails_on_cyclic_config_symlink() {
let home = test_home();
let config_p = config_path().expect("config path");
let other_p = config_p
.parent()
.expect("config parent")
.join("other-cycle-link");
std::os::unix::fs::symlink(&other_p, &config_p).expect("symlink config.toml -> other");
std::os::unix::fs::symlink(&config_p, &other_p).expect("symlink other -> config.toml");
assert!(
!sample_config().save(),
"save() must not succeed on a cyclic symlink chain"
);
let config_meta = std::fs::symlink_metadata(&config_p).expect("config symlink metadata");
assert!(
config_meta.file_type().is_symlink(),
"save() replaced the config.toml symlink with a regular file"
);
let other_meta = std::fs::symlink_metadata(&other_p).expect("other symlink metadata");
assert!(
other_meta.file_type().is_symlink(),
"save() replaced the other-cycle-link symlink with a regular file"
);
drop(home);
}
#[cfg(unix)]
fn mode_of(path: &std::path::Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.expect("metadata")
.permissions()
.mode()
& 0o777
}
#[cfg(unix)]
#[test]
fn save_preserves_existing_file_mode() {
use std::os::unix::fs::PermissionsExt;
let home = test_home();
assert!(sample_config().save());
let config_p = config_path().expect("config path");
let state_p = state_path().expect("state path");
std::fs::set_permissions(&config_p, std::fs::Permissions::from_mode(0o600))
.expect("chmod config.toml");
std::fs::set_permissions(&state_p, std::fs::Permissions::from_mode(0o600))
.expect("chmod state.toml");
assert!(sample_config().save());
assert_eq!(
mode_of(&config_p),
0o600,
"save() widened config.toml's mode away from the caller-set 0o600"
);
assert_eq!(
mode_of(&state_p),
0o600,
"save() widened state.toml's mode away from the caller-set 0o600"
);
drop(home);
}
#[cfg(unix)]
#[test]
fn save_creates_files_with_restrictive_mode() {
let home = test_home();
assert!(sample_config().save());
let config_p = config_path().expect("config path");
let state_p = state_path().expect("state path");
assert_eq!(
mode_of(&config_p),
0o600,
"a freshly created config.toml should default to 0o600"
);
assert_eq!(
mode_of(&state_p),
0o600,
"a freshly created state.toml should default to 0o600"
);
drop(home);
}
#[cfg(unix)]
#[test]
fn save_through_symlink_preserves_target_mode() {
use std::os::unix::fs::PermissionsExt;
let home = test_home();
let repo_dir = tempfile::tempdir().expect("repo tempdir");
let repo_config = repo_dir.path().join("config.toml");
std::fs::write(&repo_config, "upload_enabled = true\n").expect("seed repo config");
std::fs::set_permissions(&repo_config, std::fs::Permissions::from_mode(0o600))
.expect("chmod repo config.toml");
let config_p = config_path().expect("config path");
if let Some(parent) = config_p.parent() {
std::fs::create_dir_all(parent).expect("create tokensave dir");
}
std::os::unix::fs::symlink(&repo_config, &config_p).expect("symlink config");
assert!(sample_config().save());
let meta = std::fs::symlink_metadata(&config_p).expect("symlink metadata");
assert!(
meta.file_type().is_symlink(),
"save() replaced the config.toml symlink with a regular file"
);
assert_eq!(
mode_of(&repo_config),
0o600,
"save() widened the dotfiles repo's config.toml mode away from 0o600"
);
drop(home);
}
}