diff --git a/crates/engine/src/cmd/layout/plotstyle.rs b/crates/engine/src/cmd/layout/plotstyle.rs index b92b15826..48e22f5ce 100644 --- a/crates/engine/src/cmd/layout/plotstyle.rs +++ b/crates/engine/src/cmd/layout/plotstyle.rs @@ -332,9 +332,8 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result { Some(_path) => { #[cfg(not(target_arch = "wasm32"))] { - let tmp = format!("{_path}.cadcraft-tmp"); - std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?; - std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?; + crate::atomic_write::write_atomic(std::path::Path::new(_path), text.as_bytes()) + .map_err(|e| bad(cmd, format!("{_path}: {e}")))?; Ok(json!({ "name": name, "path": _path, "bytes": text.len() })) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 454b977ae..c8ea9b9a8 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -110,9 +110,7 @@ impl PlotterStore { write: |n, t| { let p = file(n)?; std::fs::create_dir_all(dir()?).map_err(|e| e.to_string())?; - let tmp = p.with_extension("json.cadcraft-tmp"); - std::fs::write(&tmp, t).map_err(|e| format!("{n}: {e}"))?; - std::fs::rename(&tmp, &p).map_err(|e| format!("{n}: {e}")) + crate::atomic_write::write_atomic(&p, t.as_bytes()).map_err(|e| format!("{n}: {e}")) }, delete: |n| std::fs::remove_file(file(n)?).map_err(|e| format!("{n}: {e}")), } diff --git a/crates/engine/tests/plot_styles.rs b/crates/engine/tests/plot_styles.rs index 06f8cb424..4ad0922c3 100644 --- a/crates/engine/tests/plot_styles.rs +++ b/crates/engine/tests/plot_styles.rs @@ -104,3 +104,30 @@ fn stylesmanager_creates_edits_saves_and_loads_tables() { // Undo takes a table edit back. s.execute("undo", &json!({})).unwrap(); } + +/// Issue #1928: saving a plot style table to a file goes through the atomic writer: a symlink +/// planted at the old fixed temporary name is left alone, and the file keeps its permissions. +#[cfg(unix)] +#[test] +fn plot_style_save_is_atomic_and_keeps_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("cadcraft-pstyle-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("office.stb.json"); + std::fs::write(&path, "old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let victim = dir.join("victim"); + std::fs::write(&victim, "keep").unwrap(); + std::os::unix::fs::symlink(&victim, dir.join("office.stb.json.cadcraft-tmp")).unwrap(); + + let mut s = Session::new(); + s.execute("stylesmanager", &json!({"action": "new", "name": "office.stb"})).unwrap(); + let p = path.to_string_lossy().to_string(); + s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb", "path": p})).unwrap(); + + assert_ne!(std::fs::read_to_string(&path).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = std::fs::remove_dir_all(&dir); +}