aboutsummaryrefslogtreecommitdiff
path: root/src
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 /src
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
Diffstat (limited to 'src')
-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());
+ }
}