diff options
| author | Spicer Matthews <spicer@cloudmanic.com> | 2026-05-18 04:59:01 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-18 14:59:01 +0300 |
| commit | 818b2c507ade7c817d5b6c1c4f825383807977bb (patch) | |
| tree | b752ea8f2d2f96c9ac323958c08f89c6bf3800d1 /src | |
| parent | 29e8817e1fb417968c6a5061af547139495b2e49 (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.rs | 89 |
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(¤t) { + Ok(meta) => meta, + Err(_) => return Ok(current), + }; + if !meta.file_type().is_symlink() { + return Ok(current); + } + let link = std::fs::read_link(¤t)?; + 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()); + } } |
