sync default config

This commit is contained in:
FTCHD
2026-07-13 01:32:56 +03:00
parent 5ba82e91da
commit c1daa06445
11 changed files with 1456 additions and 24 deletions
+2
View File
@@ -876,6 +876,8 @@ pub fn run() {
zookeeper::update_path_pointer,
zookeeper::get_rclone_path_integration,
zookeeper::set_rclone_path_integration,
zookeeper::get_config_sync_status,
zookeeper::set_config_sync,
scheduler::scheduler_supported,
scheduler::scheduler_validate_cron,
scheduler::scheduler_register,
+941
View File
@@ -54,6 +54,21 @@ pub struct PathStatus {
pub warning: Option<String>,
}
/// Status of the system-config symlink. `managed` = the system path is a symlink we created;
/// `enabled` = it (or the circular direct-use case) currently points at the given app config.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigSyncStatus {
pub enabled: bool,
pub managed: bool,
pub system_path: String,
pub backup_path: Option<String>,
/// True when the file just moved aside was the app's own default config, so the caller can
/// re-point `defaultConfigPath` at `backup_path` instead of orphaning it.
pub default_backed_up: bool,
pub warning: Option<String>,
}
/// Tracks the currently-running daemon so kills can be marked intentional (suppressing
/// the crash dialog) and so a webview reload cannot orphan the process.
#[derive(Default)]
@@ -1020,6 +1035,461 @@ pub fn set_rclone_path_integration(
get_rclone_path_integration(app)
}
// ---------------------------------------------------------------------------
// Config sync
// ---------------------------------------------------------------------------
//
// Symlinks the system rclone config path -> the app's active config file so `rclone` invoked from
// a terminal shares the app's remotes. rclone follows this symlink for both read and write and
// preserves it (verified live). Mirrors the PATH-integration commands above.
/// The effective `XDG_CONFIG_HOME` for locating the terminal rclone's config. Honors the app
/// process's own environment (a GUI launched from a session that exported it — login file, systemd
/// user env, launchd — inherits it), and under Flatpak the host value Flatpak re-exports as
/// `HOST_XDG_CONFIG_HOME` (the sandbox's own XDG_CONFIG_HOME points at the per-app dir the host
/// terminal never reads). Returns None (→ `~/.config`) otherwise. We deliberately do NOT shell out to
/// discover a value set only in an interactive rc file: that is a rare niche, and if `~/.config` is
/// wrong the only effect is the terminal doesn't share the config — visible, no data loss.
fn resolved_xdg_config_home() -> Option<PathBuf> {
let var = if crate::is_flatpak() {
"HOST_XDG_CONFIG_HOME"
} else {
"XDG_CONFIG_HOME"
};
std::env::var_os(var)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
/// Where rclone looks for its config by default: `$XDG_CONFIG_HOME/rclone/rclone.conf` when that is
/// set (see `resolved_xdg_config_home`), else `$HOME/.config/rclone/rclone.conf`. Mirrors rclone's
/// own `makeConfigPath` — it deliberately ignores the legacy `~/.rclone.conf` and the exe-adjacent
/// file, and uses `~/.config` on every platform (including macOS and Windows).
fn system_config_path() -> Result<PathBuf, String> {
let base = match resolved_xdg_config_home() {
Some(v) => v,
None => dirs::home_dir()
.ok_or_else(|| "Could not resolve home directory".to_string())?
.join(".config"),
};
Ok(base.join("rclone").join("rclone.conf"))
}
/// True if two config paths denote the same file *without following a final-component symlink*:
/// same file name in the same directory (parents canonicalized, so parent symlinks and `.`/`..`
/// still resolve). Used for the "circular" test — `canonical()` alone would follow our own link to
/// its target and wrongly report every healthy link as circular.
fn same_location(a: &Path, b: &Path) -> bool {
match (a.parent(), b.parent(), a.file_name(), b.file_name()) {
(Some(pa), Some(pb), Some(na), Some(nb)) => na == nb && canonical(pa) == canonical(pb),
_ => canonical(a) == canonical(b),
}
}
/// True if the active config `path` IS, or resolves through a symlink to, the system `target`'s
/// location. This is the "circular / direct-use" test: installing system -> active when active
/// already resolves to system would close a loop (active -> system -> active) whose reads fail with
/// ELOOP. Canonicalizes the ACTIVE path (following its chain to the real file) and compares that by
/// LOCATION to the system path — NOT following the system path's own final symlink, so our own
/// healthy link system -> active is never mistaken for circular. canonicalize handles multi-hop and
/// its own ELOOP guard; the realistic cases are hop 0 (active IS the system file) and a single
/// dotfile symlink to it.
fn resolves_to_location(path: &Path, target: &Path) -> bool {
same_location(&canonical(path), target)
}
/// True if `link` is the config-sync symlink WE created, proven by a positive ownership marker: its
/// target equals the exact path we recorded when we last installed it (`syncConfigLinkTarget`,
/// persisted per host). Target *location* is not proof of ownership — a user's own symlink to an app
/// config must never be misattributed to us — so a link matches only against what we actually wrote.
/// A now-dangling target still matches (both sides fall back to the same lexical path), so our own
/// link to a since-deleted config is still reclaimed. No marker recorded → we own nothing.
fn config_link_is_ours(link: &Path, owned_link_target: Option<&str>) -> bool {
let (Ok(target), Some(owned)) = (std::fs::read_link(link), owned_link_target) else {
return false;
};
canonical(&target) == canonical(Path::new(owned))
}
/// `<path>.backup`, or the first free `<path>.backup.N`, so an existing backup is never clobbered.
/// Uses `symlink_metadata` so a broken backup symlink still counts as occupied.
fn next_backup_path(path: &Path) -> PathBuf {
let mut base = path.as_os_str().to_os_string();
base.push(".backup");
let base = PathBuf::from(base);
if std::fs::symlink_metadata(&base).is_err() {
return base;
}
let mut n = 1;
loop {
let mut s = path.as_os_str().to_os_string();
s.push(format!(".backup.{}", n));
let candidate = PathBuf::from(s);
if std::fs::symlink_metadata(&candidate).is_err() {
return candidate;
}
n += 1;
}
}
/// Creates a file symlink `link` -> `target`. Uses a real symlink on every platform (no copy
/// fallback); on Windows this needs Developer Mode or elevation, surfaced as an actionable error.
fn create_config_symlink(target: &Path, link: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(target, link).map_err(|e| e.to_string())
}
#[cfg(windows)]
{
use std::os::windows::fs::symlink_file;
symlink_file(target, link).map_err(|e| {
// ERROR_PRIVILEGE_NOT_HELD (1314): creating symlinks is privileged on Windows.
if e.raw_os_error() == Some(1314) {
"Creating the config symlink needs permission on Windows. Enable Developer Mode \
(Settings → Privacy & security → For developers) or run as administrator, then \
try again."
.to_string()
} else {
e.to_string()
}
})
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
Err("Config sync is not supported on this platform".to_string())
}
}
/// Moves `from` to `to`, falling back to copy+remove across filesystems (rename gives EXDEV between
/// the system config dir and app-local-data). For a same-dir move (the foreign `.backup`) rename
/// always succeeds, preserving a symlink as-is. Used for the foreign backup and for restoring either
/// kind of moved-aside entry on rollback. Callers ensure `to`'s parent exists and `to` is free.
fn move_file(from: &Path, to: &Path) -> Result<(), String> {
if std::fs::rename(from, to).is_ok() {
return Ok(());
}
std::fs::copy(from, to).map_err(|e| e.to_string())?;
std::fs::remove_file(from).map_err(|e| e.to_string())?;
Ok(())
}
/// Copies a config's *content* to `to`, leaving the source in place. `std::fs::copy` follows a
/// symlink, so the result is always a real file: a relative-target symlink is never reproduced in a
/// new directory (where its target would resolve differently, i.e. dangle), and a valid config is
/// never converted into a broken link. Used to displace the app's own default off the system path —
/// the relocated default must always be a usable file. The original is left for the caller's atomic
/// swap to replace, so the system path is never momentarily empty.
fn materialize_copy(from: &Path, to: &Path) -> Result<(), String> {
std::fs::copy(from, to).map_err(|e| e.to_string())?;
Ok(())
}
/// Copies the existing system entry aside to `to` WITHOUT removing the original, preserving its exact
/// nature: a symlink is recreated as a symlink (a foreign link stays a link), a real file is copied.
/// `to` is a same-directory sibling (`next_backup_path`), so even a relative symlink target still
/// resolves after recreation. Leaving the original in place lets the atomic swap replace it with no
/// empty-path window and makes rollback a simple discard of this copy.
fn copy_aside(from: &Path, to: &Path) -> Result<(), String> {
match std::fs::symlink_metadata(from) {
Ok(m) if m.file_type().is_symlink() => {
let target = std::fs::read_link(from).map_err(|e| e.to_string())?;
create_config_symlink(&target, to)
}
_ => {
std::fs::copy(from, to).map_err(|e| e.to_string())?;
Ok(())
}
}
}
/// A free app-private home for a default config displaced off the system path. Unlike a raw
/// `<file>.backup`, this keeps the `<dir>/rclone.conf` shape the rest of the app assumes when it
/// derives a config's folder — re-pinning `defaultConfigPath` here must never break config loading.
fn relocated_default_path(base: &Path) -> PathBuf {
let configs = base.join("configs");
let mut candidate = configs.join("default-synced").join("rclone.conf");
let mut n = 1;
// lexists (symlink_metadata), NOT exists(): exists() follows symlinks, so a *dangling* symlink at
// the candidate would look free and materialize_copy's std::fs::copy would then follow it and
// write through to the link's (out-of-app-data) target. symlink_metadata treats any existing
// entry — including a broken link — as occupied. Mirrors next_backup_path.
while std::fs::symlink_metadata(&candidate).is_ok() {
candidate = configs
.join(format!("default-synced.{}", n))
.join("rclone.conf");
n += 1;
}
candidate
}
/// Installs `link` -> `target`, replacing whatever is at `link` atomically: the new link is built at
/// a sibling temp path and renamed over `link`, so a create failure leaves the prior link intact
/// (no empty-config-path window). On Windows, `rename` cannot overwrite, so we remove-then-rename
/// the already-built temp link (a small window, acceptable for that secondary platform).
///
/// A fixed sibling temp name is safe: set_config_sync's process-wide Mutex means swaps never run
/// concurrently, and the pre-create remove clears any leftover from a crashed prior run.
fn atomic_symlink_swap(target: &Path, link: &Path) -> Result<(), String> {
let mut tmp_os = link.as_os_str().to_os_string();
tmp_os.push(".tmp-link");
let tmp = PathBuf::from(tmp_os);
let _ = std::fs::remove_file(&tmp); // clear any leftover from a crashed prior run
create_config_symlink(target, &tmp)?;
if std::fs::rename(&tmp, link).is_ok() {
return Ok(());
}
// Windows: destination must not exist for rename. The old link is only removed once our own
// replacement is already built, so we never end up with nothing.
let _ = std::fs::remove_file(link);
if let Err(e) = std::fs::rename(&tmp, link) {
let _ = std::fs::remove_file(&tmp);
return Err(e.to_string());
}
Ok(())
}
#[tauri::command]
pub fn get_config_sync_status(
app: AppHandle,
app_config_path: String,
owned_link_target: Option<String>,
) -> Result<ConfigSyncStatus, String> {
let _ = app;
let system = system_config_path()?;
config_sync_status(&system, &app_config_path, owned_link_target.as_deref())
}
/// Core of `get_config_sync_status`, parameterized on the system-config path so it can be driven
/// against a scratch filesystem in tests. `owned_link_target` is our persisted ownership marker.
fn config_sync_status(
system: &Path,
app_config_path: &str,
owned_link_target: Option<&str>,
) -> Result<ConfigSyncStatus, String> {
let system_path = system.to_string_lossy().to_string();
let app_path = Path::new(app_config_path);
let app_canon = canonical(app_path);
// Circular: the app's active config IS the system config file — either at the same location (it
// adopted the system rclone's native config) or via a symlink chain that resolves to it. It writes
// that file directly, so a terminal already shares it and there is no link to manage; installing
// one would only create a loop. Walks the ACTIVE config's chain (not the system path's own final
// symlink), so a healthy managed link system -> active is never mistaken for this case.
if resolves_to_location(app_path, system) {
return Ok(ConfigSyncStatus {
enabled: true,
managed: false,
system_path,
backup_path: None,
default_backed_up: false,
warning: None,
});
}
let is_symlink = std::fs::symlink_metadata(system)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if !is_symlink {
// A plain file (foreign config) or nothing at all — not synced, nothing we manage.
return Ok(ConfigSyncStatus {
enabled: false,
managed: false,
system_path,
backup_path: None,
default_backed_up: false,
warning: None,
});
}
let managed = config_link_is_ours(system, owned_link_target);
let target = std::fs::read_link(system).ok();
// Require the target to actually exist: canonical() falls back to the lexical path for a missing
// file, so a dangling link to a since-deleted active config would otherwise compare equal to the
// (also-lexical) active path and be reported as healthy, suppressing the missing-file warning.
let enabled = managed
&& target
.as_ref()
.map(|t| t.exists() && canonical(t) == app_canon)
.unwrap_or(false);
// A managed link that no longer points at the active config (its config was deleted, or the
// active config changed out-of-band) — flag it; the next reconcile/switch/restart re-points it.
let warning = if managed && !enabled {
match target.as_ref() {
Some(t) if !t.exists() => Some(format!(
"The synced config link points at a missing file ({}). It will be re-pointed to the active config on the next switch or restart.",
t.to_string_lossy()
)),
Some(_) => Some(
"The terminal config link points at a different config than the active one; it will be re-pointed on the next switch or restart."
.to_string(),
),
None => None,
}
} else {
None
};
Ok(ConfigSyncStatus {
enabled,
managed,
system_path,
backup_path: None,
default_backed_up: false,
warning,
})
}
#[tauri::command]
pub fn set_config_sync(
app: AppHandle,
enable: bool,
app_config_path: String,
owned_link_target: Option<String>,
default_config_path: Option<String>,
) -> Result<ConfigSyncStatus, String> {
// Serialize all mutations: concurrent reconciles (a startup heal racing a settings action) would
// otherwise both try to swap the single system path. A poisoned lock still yields the guard — we
// only guard filesystem ordering, and a panic mid-swap leaves recoverable on-disk state.
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let system = system_config_path()?;
let base = app_local_data(&app)?;
apply_config_sync(
&system,
&base,
enable,
&app_config_path,
owned_link_target.as_deref(),
default_config_path.as_deref(),
)
}
/// Core of `set_config_sync`, parameterized on the system-config path and app-local-data dir so it
/// can be driven against a scratch filesystem in tests. `owned_link_target` is our persisted
/// ownership marker (the target we last installed) — the sole proof that a link is ours.
fn apply_config_sync(
system: &Path,
base: &Path,
enable: bool,
app_config_path: &str,
owned_link_target: Option<&str>,
default_config_path: Option<&str>,
) -> Result<ConfigSyncStatus, String> {
let app_path = Path::new(app_config_path);
let mut backup_path: Option<String> = None;
let mut default_backed_up = false;
if enable {
// Circular case needs no link — the app config already IS (or resolves through a symlink
// chain to) the system file. Uses the same resolves_to_location walk as config_sync_status,
// so an active config that is a symlink to the system path is skipped here instead of getting
// a link installed on top of it (system -> active -> system would ELOOP). Kept consistent
// with the status call site; a plain same_location check would miss the symlink-chain case.
if !resolves_to_location(app_path, system) {
// Validate the target is a usable regular FILE (metadata follows symlinks): a dangling
// symlink or a directory would pass a bare symlink_metadata check yet install a
// broken/unusable link after moving a valid system config aside.
match std::fs::metadata(app_path) {
Ok(m) if m.is_file() => {}
_ => {
return Err(format!(
"The selected config file does not exist or is not a file: {}",
app_config_path
))
}
}
if let Some(parent) = system.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let occupied = std::fs::symlink_metadata(system).is_ok();
let ours = occupied && config_link_is_ours(system, owned_link_target);
// Already our link pointing at the active config — nothing to do (avoids churning a
// healthy link on every startup/switch reconcile).
if ours {
if let Ok(target) = std::fs::read_link(system) {
if canonical(&target) == canonical(app_path) {
return config_sync_status(system, app_config_path, Some(app_config_path));
}
}
} else if occupied {
// A foreign real file or symlink sits here. Preserve it — never destroy user data.
// If it is the app's OWN default config, relocate it into app-local-data under a
// valid `<dir>/rclone.conf` name and flag it so the caller re-points defaultConfigPath
// there (a raw `.backup` name would break the app's folder-derivation). Otherwise
// move it aside to rclone.conf.backup.
let is_default = default_config_path
.map(|dc| same_location(system, Path::new(dc)))
.unwrap_or(false);
let dest = if is_default {
default_backed_up = true;
relocated_default_path(base)
} else {
next_backup_path(system)
};
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
// Back the existing entry up by COPYING it aside, leaving the original in place: the
// atomic swap below replaces it via rename, so the system path is never momentarily
// empty. That closes a crash window (a crash between a move-aside and the swap would
// strand the system config in an unpersisted backup, leaving the path empty) and
// shrinks the external-writer race — a terminal `rclone` cannot drop a fresh config
// into an empty gap that no longer exists. The default is materialized to a real file
// (a relative symlink must not move cross-dir); a foreign entry is preserved exactly.
if is_default {
materialize_copy(system, &dest)?;
} else {
copy_aside(system, &dest)?;
}
backup_path = Some(dest.to_string_lossy().to_string());
}
// Install atomically over whatever is still at `system` (our own stale link, the
// preserved original, or nothing). On failure, undo: if the swap already removed the
// original (only its Windows fallback does) restore it from the backup copy; otherwise
// the original is untouched, so just discard the stray copy.
if let Err(e) = atomic_symlink_swap(app_path, system) {
if let Some(b) = backup_path.take() {
let b = Path::new(&b);
if std::fs::symlink_metadata(system).is_err() {
let _ = move_file(b, system);
} else {
let _ = std::fs::remove_file(b);
}
}
return Err(e);
}
}
} else {
// Disable: only remove a link we created (marker-proven); never touch foreign/user files,
// never restore backups.
if std::fs::symlink_metadata(system).is_ok()
&& config_link_is_ours(system, owned_link_target)
{
std::fs::remove_file(system).map_err(|e| e.to_string())?;
}
}
// Report post-operation ownership: on enable we now own a link to app_path; on disable we own
// nothing. This mirrors the marker the caller persists (syncConfigLinkTarget), so the returned
// status is immediately accurate without waiting for that write to round-trip.
let effective_marker = if enable { Some(app_config_path) } else { None };
let mut status = config_sync_status(system, app_config_path, effective_marker)?;
// Carry the freshly-made backup + relocation flag through — config_sync_status can't know.
if backup_path.is_some() {
status.backup_path = backup_path;
}
status.default_backed_up = default_backed_up;
Ok(status)
}
// ---- macOS PATH helpers ----
#[cfg(target_os = "macos")]
@@ -1246,3 +1716,474 @@ fn windows_broadcast_env_change() {
);
}
}
// Config-sync truth-model tests. They drive the real `apply_config_sync`/`config_sync_status`
// against a fully isolated scratch filesystem (system + app-local-data paths are passed in, so the
// user's real ~/.config is never touched).
#[cfg(all(test, unix))]
mod config_sync_tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("stray_cs_{}_{}", std::process::id(), name));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn is_symlink(p: &Path) -> bool {
std::fs::symlink_metadata(p)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
// A healthy managed link must report managed+enabled (NOT circular), so the UI does not disable
// the checkbox — and disable must then remove it. Ownership is proven by the marker.
#[test]
fn healthy_link_is_managed_not_circular() {
let root = scratch("healthy");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
let app_cfg = base.join("configs/default/rclone.conf");
write(&app_cfg, "[appremote]\n");
let app = app_cfg.to_str().unwrap();
// First enable: no prior marker.
let st = apply_config_sync(&system, &base, true, app, None, None).unwrap();
assert!(is_symlink(&system));
assert!(st.managed, "healthy link must be managed");
assert!(st.enabled);
assert!(st.backup_path.is_none());
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[appremote]\n");
// Status with the marker we now hold.
let st2 = config_sync_status(&system, app, Some(app)).unwrap();
assert!(st2.managed && st2.enabled);
let st3 = apply_config_sync(&system, &base, false, app, Some(app), None).unwrap();
assert!(!is_symlink(&system), "disable must remove the link");
assert!(!st3.managed && !st3.enabled);
}
// The genuine circular case (app config IS the system file) stays enabled+unmanaged and enable
// is a no-op that leaves the real file untouched.
#[test]
fn circular_direct_use() {
let root = scratch("circular");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
write(&system, "[native]\n");
let app = system.to_str().unwrap();
let st = config_sync_status(&system, app, None).unwrap();
assert!(st.enabled && !st.managed);
let st2 = apply_config_sync(&system, &base, true, app, None, None).unwrap();
assert!(!is_symlink(&system));
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[native]\n");
assert!(st2.enabled && !st2.managed && st2.backup_path.is_none());
}
// When the app's own default config lives at the system path, syncing a DIFFERENT config must
// preserve it (relocate) and flag default_backed_up so the caller re-points default.
#[test]
fn default_at_system_path_is_relocated() {
let root = scratch("default_reloc");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
write(&system, "[defaultremote]\n");
let work = base.join("configs/work/rclone.conf");
write(&work, "[workremote]\n");
let st = apply_config_sync(
&system,
&base,
true,
work.to_str().unwrap(),
None,
Some(system.to_str().unwrap()),
)
.unwrap();
assert!(st.default_backed_up, "must flag that the default was moved");
let relocated = st.backup_path.clone().expect("the default must be relocated");
// Invariant: the relocated default keeps a `<dir>/rclone.conf` name (NOT `.backup`) and
// lives under app-local-data, so re-pinning defaultConfigPath there never breaks loading.
assert!(
relocated.ends_with("rclone.conf"),
"relocated default must keep the rclone.conf filename, got {relocated}"
);
assert!(!relocated.ends_with(".backup"));
assert!(Path::new(&relocated).starts_with(&base));
assert!(!is_symlink(Path::new(&relocated)), "relocated default must be a real file");
assert_eq!(std::fs::read_to_string(&relocated).unwrap(), "[defaultremote]\n");
assert!(is_symlink(&system));
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[workremote]\n");
assert!(st.managed && st.enabled);
// Re-pin default at the relocated path, reconcile with default active (marker = the link we
// hold, → work): it must NOT relocate again and must re-point the system link at default.
let st2 = apply_config_sync(
&system,
&base,
true,
&relocated,
Some(work.to_str().unwrap()),
Some(&relocated),
)
.unwrap();
assert!(!st2.default_backed_up, "must not relocate an already-relocated default");
assert!(st2.backup_path.is_none());
assert!(st2.managed && st2.enabled);
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[defaultremote]\n");
}
// Relocating a default that is a RELATIVE symlink must materialize its content into a
// real file (moving the symlink would leave a relative target resolving against the wrong dir).
#[test]
fn relative_symlink_default_is_materialized() {
let root = scratch("rel_default");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
let actual = system.parent().unwrap().join("actual.conf");
write(&actual, "[realdefault]\n");
// system -> "actual.conf" (relative to the system dir).
std::fs::create_dir_all(system.parent().unwrap()).unwrap();
std::os::unix::fs::symlink("actual.conf", &system).unwrap();
let work = base.join("configs/work/rclone.conf");
write(&work, "[work]\n");
let st = apply_config_sync(
&system,
&base,
true,
work.to_str().unwrap(),
None,
Some(system.to_str().unwrap()),
)
.unwrap();
assert!(st.default_backed_up);
let relocated = st.backup_path.expect("relocated");
assert!(
!is_symlink(Path::new(&relocated)),
"relocated default must be a real file, not a moved symlink"
);
assert_eq!(std::fs::read_to_string(&relocated).unwrap(), "[realdefault]\n");
assert!(is_symlink(&system));
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[work]\n");
}
// An external-folder target (outside app-local-data) is owned via the marker: a re-point does
// not spuriously back up our own link, and disable removes it.
#[test]
fn external_sync_target_ownership() {
let root = scratch("external");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
let ext = root.join("external/rclone.conf");
write(&ext, "[extremote]\n");
let app_priv = base.join("configs/other/rclone.conf");
write(&app_priv, "[otherremote]\n");
let ext_s = ext.to_str().unwrap();
let priv_s = app_priv.to_str().unwrap();
let st = apply_config_sync(&system, &base, true, ext_s, None, None).unwrap();
assert!(st.managed, "external target link must be recognized as ours");
assert!(st.enabled && st.backup_path.is_none());
// Re-point to an app-private config; marker is the link we hold (→ext) → removed, not backed up.
let st2 = apply_config_sync(&system, &base, true, priv_s, Some(ext_s), None).unwrap();
assert!(st2.backup_path.is_none(), "re-point must not back up our own link");
assert!(st2.managed && st2.enabled);
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[otherremote]\n");
// Point back to external (marker now →app_priv), then disable (marker →ext): removed.
apply_config_sync(&system, &base, true, ext_s, Some(priv_s), None).unwrap();
let st3 = apply_config_sync(&system, &base, false, ext_s, Some(ext_s), None).unwrap();
assert!(!is_symlink(&system), "disable must remove the external-target link");
assert!(!st3.managed);
}
// Enabling for a missing / dangling / directory target must fail WITHOUT disturbing a
// good system config (no move-aside, no broken link, no backup).
#[test]
fn unusable_target_does_not_touch_system() {
let root = scratch("unusable_target");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
write(&system, "[existing]\n");
let assert_untouched = || {
assert!(!is_symlink(&system));
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[existing]\n");
assert!(!system.with_file_name("rclone.conf.backup").exists());
};
// (a) missing file
let ghost = base.join("configs/ghost/rclone.conf");
assert!(apply_config_sync(&system, &base, true, ghost.to_str().unwrap(), None, None).is_err());
assert_untouched();
// (b) dangling symlink target
let dangling = base.join("configs/dangling/rclone.conf");
std::fs::create_dir_all(dangling.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(base.join("nope/rclone.conf"), &dangling).unwrap();
assert!(
apply_config_sync(&system, &base, true, dangling.to_str().unwrap(), None, None).is_err(),
"a dangling symlink target must be rejected"
);
assert_untouched();
// (c) directory at the target path
let dir_target = base.join("configs/dir/rclone.conf");
std::fs::create_dir_all(&dir_target).unwrap();
assert!(
apply_config_sync(&system, &base, true, dir_target.to_str().unwrap(), None, None).is_err(),
"a directory target must be rejected"
);
assert_untouched();
}
// Deleting the active synced config leaves a dangling link. On the next reconcile it must be
// reclaimed as ours via the marker (removed / re-pointed) — NOT treated as foreign and backed up
// — even when the app-local-data path traverses a symlink (macOS /var -> /private/var), which
// makes canonical() fall back to a lexical path for the now-missing target.
#[test]
fn dangling_link_to_deleted_config_is_reclaimed() {
let root = scratch("dangling");
let base = root.join("appdata");
std::fs::create_dir_all(root.join("real_base")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(root.join("real_base"), &base).unwrap();
std::fs::create_dir_all(&base).ok();
let system = root.join("system/rclone/rclone.conf");
let deleted = base.join("configs/gone/rclone.conf");
write(&deleted, "[gone]\n");
let default_cfg = base.join("configs/default/rclone.conf");
write(&default_cfg, "[default]\n");
let gone_s = deleted.to_str().unwrap().to_string();
// Enable while 'gone' is active (marker →gone), then delete its directory (dangling link).
apply_config_sync(&system, &base, true, &gone_s, None, None).unwrap();
std::fs::remove_dir_all(base.join("configs/gone")).unwrap();
assert!(is_symlink(&system));
// The dangling link is still ours (marker →gone): managed + not enabled + warning.
let st = config_sync_status(&system, default_cfg.to_str().unwrap(), Some(&gone_s)).unwrap();
assert!(st.managed, "dangling own link must still be recognized as ours");
assert!(!st.enabled);
assert!(st.warning.is_some(), "a missing-target warning should be surfaced");
// Reconcile onto default: our link is reclaimed and re-pointed, NOT backed up.
let st2 =
apply_config_sync(&system, &base, true, default_cfg.to_str().unwrap(), Some(&gone_s), None)
.unwrap();
assert!(st2.backup_path.is_none(), "reclaiming our dangling link must not create a backup");
assert!(st2.managed && st2.enabled);
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[default]\n");
}
// A dangling link whose target IS the (now-deleted) active config must not report as healthy.
#[test]
fn dangling_link_to_deleted_active_is_not_healthy() {
let root = scratch("dangling_active");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
let active = base.join("configs/work/rclone.conf");
write(&active, "[work]\n");
let active_s = active.to_str().unwrap().to_string();
apply_config_sync(&system, &base, true, &active_s, None, None).unwrap();
std::fs::remove_dir_all(base.join("configs/work")).unwrap();
let st = config_sync_status(&system, &active_s, Some(&active_s)).unwrap();
assert!(st.managed, "still our link");
assert!(!st.enabled, "a dangling link to the deleted active config must not report enabled");
assert!(st.warning.is_some(), "the missing-file warning must be surfaced");
}
// A user's OWN symlink at the system path — even one pointing at an app config — must
// never be misattributed to us (no marker, or a marker for a different target) and so is
// preserved (backed up), never silently replaced or removed.
#[test]
fn user_symlink_is_not_ours_and_is_preserved() {
let root = scratch("user_link");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
// The user manually symlinked the system path at ONE OF THE APP'S OWN config files.
let app_work = base.join("configs/work/rclone.conf");
write(&app_work, "[work]\n");
std::fs::create_dir_all(system.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&app_work, &system).unwrap();
let active = base.join("configs/default/rclone.conf");
write(&active, "[default]\n");
let active_s = active.to_str().unwrap();
// No marker: the user's link is not ours even though its target is an app config.
let st = config_sync_status(&system, active_s, None).unwrap();
assert!(!st.managed, "a user's own symlink must NOT be classified as ours");
// Enabling for a different config must BACK UP the user's link, not silently replace it.
let st2 = apply_config_sync(&system, &base, true, active_s, None, None).unwrap();
let backup = st2.backup_path.expect("user's symlink must be backed up, not replaced");
assert!(is_symlink(Path::new(&backup)), "the backed-up entry is the user's symlink, preserved");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "[work]\n");
assert!(is_symlink(&system));
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[default]\n");
// Disable against a marker for a DIFFERENT target must not remove a user's link.
let root2 = scratch("user_link2");
let base2 = root2.join("appdata");
std::fs::create_dir_all(&base2).unwrap();
let system2 = root2.join("system/rclone/rclone.conf");
let user_target = root2.join("mine/rclone.conf");
write(&user_target, "[mine]\n");
std::fs::create_dir_all(system2.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&user_target, &system2).unwrap();
apply_config_sync(&system2, &base2, false, "/x/rclone.conf", Some("/y/rclone.conf"), None)
.unwrap();
assert!(is_symlink(&system2), "disable must not remove a link that isn't ours");
assert_eq!(std::fs::read_to_string(&system2).unwrap(), "[mine]\n");
}
// A foreign real file at the system path (not the default) is preserved via backup on enable.
#[test]
fn foreign_file_backed_up() {
let root = scratch("foreign");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
write(&system, "[foreign]\n");
let app_cfg = base.join("configs/default/rclone.conf");
write(&app_cfg, "[app]\n");
let st = apply_config_sync(
&system,
&base,
true,
app_cfg.to_str().unwrap(),
None,
Some("/some/other/path/rclone.conf"),
)
.unwrap();
let backup = st.backup_path.expect("foreign file backed up");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "[foreign]\n");
assert!(!st.default_backed_up, "not the default → no relocation flag");
assert!(is_symlink(&system));
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[app]\n");
}
// Ownership is proven by the target marker. A link whose target equals our recorded
// marker is treated as ours even if a user hand-made an identical link — because removing it on
// disable is exactly the requested end state (no link at the system path), the outcome is correct
// either way. (Contrast user_symlink_*: a link to a DIFFERENT target, or with no marker, is never
// ours and is preserved.)
#[test]
fn link_matching_marker_is_ours() {
let root = scratch("marker_owns");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
let app_cfg = base.join("configs/default/rclone.conf");
write(&app_cfg, "[app]\n");
let app = app_cfg.to_str().unwrap();
// A link at the system path pointing exactly at our marker target, created out-of-band.
std::fs::create_dir_all(system.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&app_cfg, &system).unwrap();
// With the marker == that target, it is ours (managed + enabled)...
let st = config_sync_status(&system, app, Some(app)).unwrap();
assert!(st.managed && st.enabled, "a link to our exact marker target is ours");
// ...and disable removes it — the requested end state (no link), regardless of who made it.
let st2 = apply_config_sync(&system, &base, false, app, Some(app), None).unwrap();
assert!(!is_symlink(&system) && !st2.managed, "disable clears a link matching our marker");
}
// A DANGLING symlink sitting at the first relocation candidate must be treated as
// occupied. exists() follows the link (missing target → "free"), which would make materialize_copy
// std::fs::copy THROUGH it and write outside app-data; symlink_metadata (lexists) counts it as
// taken, so relocation picks the next free slot and writes a real file instead.
#[test]
fn relocation_skips_dangling_candidate() {
let root = scratch("reloc_dangling");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let candidate = base.join("configs/default-synced/rclone.conf");
std::fs::create_dir_all(candidate.parent().unwrap()).unwrap();
let orphan_target = base.join("missing/elsewhere.conf");
std::os::unix::fs::symlink(&orphan_target, &candidate).unwrap();
let system = root.join("system/rclone/rclone.conf");
write(&system, "[defaultremote]\n");
let work = base.join("configs/work/rclone.conf");
write(&work, "[work]\n");
let st = apply_config_sync(
&system,
&base,
true,
work.to_str().unwrap(),
None,
Some(system.to_str().unwrap()),
)
.unwrap();
let relocated = st.backup_path.expect("relocated");
assert_ne!(Path::new(&relocated), candidate.as_path(), "must not reuse the dangling candidate");
assert!(
relocated.contains("default-synced.1"),
"should skip to the next free slot, got {relocated}"
);
assert!(!is_symlink(Path::new(&relocated)), "relocated default must be a real file");
assert_eq!(std::fs::read_to_string(&relocated).unwrap(), "[defaultremote]\n");
// The dangling candidate and its (never-existent) target are untouched — nothing written through it.
assert!(is_symlink(&candidate), "the dangling candidate link is left as-is");
assert!(!orphan_target.exists(), "must not have created the dangling link's target");
}
// An active config that is itself a symlink resolving to the system path must be treated as
// CIRCULAR on enable (not a manageable link). Installing system -> active would close a two-link
// cycle (active -> system -> active) whose reads fail with ELOOP, breaking both the terminal and
// the app. So enable is a no-op that leaves the real system file untouched. This can arise from a
// dotfile setup (an adopted default config symlinked to ~/.config/rclone/rclone.conf), not only
// deliberate sabotage.
#[test]
fn active_symlink_resolving_to_system_is_circular() {
let root = scratch("active_symlink_cycle");
let base = root.join("appdata");
std::fs::create_dir_all(&base).unwrap();
let system = root.join("system/rclone/rclone.conf");
write(&system, "[native]\n");
// An app config that is a symlink pointing at the system path.
let app_cfg = base.join("configs/aliased/rclone.conf");
std::fs::create_dir_all(app_cfg.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&system, &app_cfg).unwrap();
let app = app_cfg.to_str().unwrap();
// Status: circular (enabled, unmanaged) — the walk reaches the system location.
let st = config_sync_status(&system, app, None).unwrap();
assert!(st.enabled && !st.managed, "an active symlink resolving to system is circular");
// Enable is a no-op: no link installed (would ELOOP), real system file untouched, no backup.
let st2 = apply_config_sync(&system, &base, true, app, None, None).unwrap();
assert!(!is_symlink(&system), "must NOT install a link over the system file (would ELOOP)");
assert_eq!(std::fs::read_to_string(&system).unwrap(), "[native]\n");
assert!(st2.backup_path.is_none());
assert!(st2.enabled && !st2.managed);
}
}