aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSpicer Matthews <spicer@cloudmanic.com>2026-05-18 04:59:01 -0700
committerGitHub <noreply@github.com>2026-05-18 14:59:01 +0300
commit818b2c507ade7c817d5b6c1c4f825383807977bb (patch)
treeb752ea8f2d2f96c9ac323958c08f89c6bf3800d1
parent29e8817e1fb417968c6a5061af547139495b2e49 (diff)
fix: preserve symlinks when saving session.json (#147)
session.json is written via tmp + atomic rename. rename() replaces the symlink itself, so users who stow session.json from a dotfiles repo lose the link on every save. Resolve symlinks before placing the .tmp so the rename targets the canonical file and the symlink at the input path is preserved. Atomicity is preserved because the .tmp and the target stay in the same directory. refs #139
-rw-r--r--src/persist/io.rs89
1 files changed, 86 insertions, 3 deletions
diff --git a/src/persist/io.rs b/src/persist/io.rs
index 8cd25dfb..f8525bc5 100644
--- a/src/persist/io.rs
+++ b/src/persist/io.rs
@@ -8,14 +8,41 @@ fn session_path() -> PathBuf {
crate::session::data_dir().join("session.json")
}
+// Follow symlinks manually so a write through a (possibly dangling) symlink
+// lands on the target. `fs::canonicalize` requires the target to exist, which
+// excludes the dangling-symlink case stow users hit on the very first save.
+fn resolve_write_target(path: &Path) -> std::io::Result<PathBuf> {
+ let mut current = path.to_path_buf();
+ for _ in 0..16 {
+ let meta = match std::fs::symlink_metadata(&current) {
+ Ok(meta) => meta,
+ Err(_) => return Ok(current),
+ };
+ if !meta.file_type().is_symlink() {
+ return Ok(current);
+ }
+ let link = std::fs::read_link(&current)?;
+ current = if link.is_absolute() {
+ link
+ } else {
+ current
+ .parent()
+ .unwrap_or_else(|| Path::new("."))
+ .join(link)
+ };
+ }
+ Ok(current)
+}
+
pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> {
- if let Some(parent) = path.parent() {
+ let target = resolve_write_target(path)?;
+ if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(snapshot)?;
- let tmp_path = path.with_extension("json.tmp");
+ let tmp_path = target.with_extension("json.tmp");
std::fs::write(&tmp_path, &json)?;
- if let Err(err) = std::fs::rename(&tmp_path, path) {
+ if let Err(err) = std::fs::rename(&tmp_path, &target) {
let _ = std::fs::remove_file(&tmp_path);
return Err(err);
}
@@ -127,4 +154,60 @@ mod tests {
assert!(!path.exists());
}
+
+ #[cfg(unix)]
+ #[test]
+ fn save_to_path_preserves_existing_symlink() {
+ let target = temp_session_path("symlink-target");
+ let link = target.with_file_name("link.json");
+ save_to_path(&target, &empty_snapshot()).unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ let mut snap = empty_snapshot();
+ snap.selected = 7;
+ save_to_path(&link, &snap).unwrap();
+
+ assert!(std::fs::symlink_metadata(&link)
+ .unwrap()
+ .file_type()
+ .is_symlink());
+ let parsed = parse_snapshot(&std::fs::read_to_string(&target).unwrap()).unwrap();
+ assert_eq!(parsed.selected, 7);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn save_to_path_writes_through_dangling_symlink() {
+ let target = temp_session_path("dangling-target");
+ let link = target.with_file_name("link.json");
+ std::fs::create_dir_all(target.parent().unwrap()).unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ save_to_path(&link, &empty_snapshot()).unwrap();
+
+ assert!(std::fs::symlink_metadata(&link)
+ .unwrap()
+ .file_type()
+ .is_symlink());
+ assert!(target.exists());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn save_to_path_resolves_relative_symlink() {
+ let session = temp_session_path("relative-symlink");
+ let dir = session.parent().unwrap();
+ std::fs::create_dir_all(dir).unwrap();
+ let target = dir.join("real.json");
+ let link = dir.join("link.json");
+ std::os::unix::fs::symlink("real.json", &link).unwrap();
+
+ save_to_path(&link, &empty_snapshot()).unwrap();
+
+ assert!(std::fs::symlink_metadata(&link)
+ .unwrap()
+ .file_type()
+ .is_symlink());
+ assert!(target.exists());
+ }
}