use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
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,
}
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,
}
}
}
#[derive(Serialize, Deserialize)]
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,
}
#[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 preserve_corrupt_state_file(state_path: &std::path::Path) {
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 = state_path.with_extension(format!(
"toml.corrupt.{timestamp}.{}.{unique}",
std::process::id()
));
let _ = std::fs::rename(state_path, backup);
}
impl UserConfig {
pub fn load() -> Self {
let _guard = SAVE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut base: Self = config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|contents| toml::from_str(&contents).unwrap_or_default())
.unwrap_or_default();
if let Some(state_p) = state_path() {
if let Ok(contents) = std::fs::read_to_string(&state_p) {
match toml::from_str::<StateFile>(&contents) {
Ok(state) => apply_state(&mut base, state),
Err(_) => preserve_corrupt_state_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 config_file = ConfigFile {
upload_enabled: self.upload_enabled,
watcher_debounce: self.watcher_debounce.clone(),
extraction_timeout_secs: self.extraction_timeout_secs,
wildcard_permissions: self.wildcard_permissions,
};
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(config_contents) = toml::to_string_pretty(&config_file) else {
return false;
};
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);
write_atomic(&state_path, &state_contents) && write_atomic(&config_path, &config_contents)
}
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,
}
}
#[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 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 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);
}
}