From 2430acb4ba28dfef0a7ac551766e06fedf0651b7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:40:08 +0800 Subject: [PATCH] fix(config): write through symlinks instead of replacing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_atomic renamed its temp file over the given path. When that path is a symlink (config.json linked into a dotfiles repo), rename(2) replaces the link itself, so the first save — dragging the sidebar, running a palette command — silently turned it into a plain file and broke sync. Resolve the symlink chain first and write next to, and rename over, the file it finally points at. Covers every write_atomic caller (config.json, views.json, window.json, machines, presets, agent hooks). --- crates/tty7-core/src/core/config.rs | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 9585ef52..4a273ec6 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -1010,6 +1010,11 @@ pub fn write_atomic_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Re fn write_atomic_mode(path: &std::path::Path, bytes: &[u8], private: bool) -> std::io::Result<()> { use std::io::Write as _; + // Renaming over a symlink replaces the link itself, so a config.json + // symlinked into a dotfiles repo would silently turn into a plain file and + // stop syncing. Write next to (and rename over) the file it points at. + let resolved = resolve_symlinks(path); + let path = resolved.as_path(); let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); let tmp = dir.join(format!( ".{}.tmp.{}", @@ -1040,6 +1045,25 @@ fn write_atomic_mode(path: &std::path::Path, bytes: &[u8], private: bool) -> std } } +/// Follows `path` through any chain of symlinks to the file it finally names, +/// which need not exist yet. Gives up after a bounded number of hops, so a +/// symlink loop falls back to writing over the last link reached. +fn resolve_symlinks(path: &std::path::Path) -> PathBuf { + const MAX_HOPS: usize = 40; + + let mut resolved = path.to_path_buf(); + for _ in 0..MAX_HOPS { + let Ok(target) = std::fs::read_link(&resolved) else { + break; + }; + resolved = match resolved.parent() { + Some(parent) => parent.join(target), + None => target, + }; + } + resolved +} + pub fn config_dir_path() -> Option { config_dir() } @@ -1655,6 +1679,52 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn write_atomic_writes_through_a_symlink_instead_of_replacing_it() { + let dir = TestDir::new("symlink"); + let real = dir.path().join("dotfiles-config.json"); + std::fs::write(&real, b"old").unwrap(); + let link = dir.path().join("config.json"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!( + std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink(), + "config.json must stay a symlink" + ); + assert_eq!(std::fs::read(&real).unwrap(), b"new"); + } + + #[cfg(unix)] + #[test] + fn write_atomic_follows_relative_and_dangling_symlinks() { + let dir = TestDir::new("symlink-relative"); + std::fs::create_dir(dir.path().join("dotfiles")).unwrap(); + let link = dir.path().join("config.json"); + // Relative to the link's own directory, and not created yet. + std::os::unix::fs::symlink("dotfiles/config.json", &link).unwrap(); + write_atomic(&link, b"fresh").unwrap(); + assert!( + std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!( + std::fs::read(dir.path().join("dotfiles/config.json")).unwrap(), + b"fresh" + ); + let leftover: Vec<_> = std::fs::read_dir(dir.path().join("dotfiles")) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .collect(); + assert!(leftover.is_empty(), "temp file should be renamed away"); + } + #[test] fn write_atomic_replaces_contents_and_leaves_no_temp() { let dir = TestDir::new("atomic");