Skip to main content

par_term_config/
atomic_save.rs

1//! Crash-safe atomic file writes for user data that cannot be reconstructed.
2//!
3//! Sessions, profiles, the dynamic-profile cache, `config.yaml`, the assistant
4//! history and the user's shell rc files are written through
5//! [`save_bytes_atomic`] and its wrappers. Each save serializes into a temporary
6//! file **in the same directory** as the target, fsyncs it, then renames it over
7//! the target. A crash or a full disk therefore leaves either the complete
8//! previous file or the complete new one — never a truncated mix of the two.
9//!
10//! This matters more than the usual "corrupt file" argument because of the
11//! recovery behaviour of the callers: a truncated session or profile file is
12//! read back as *empty*, which every loader treats as "no saved data" rather
13//! than as an error. The user loses the data with no diagnostic. Session save
14//! also runs at shutdown, when an abrupt kill is most likely. For a shell rc
15//! file the failure is worse still: the content is user-authored and
16//! unreconstructable, and a truncated `.zshrc` breaks their login shell.
17//!
18//! # Crate placement
19//!
20//! This lives in `par-term-config` (Layer 1, no internal dependencies) rather
21//! than in the root crate, because `par-term-config`, `par-term-settings-ui`
22//! and `par-term-update` all need it and none of them can depend on the root
23//! crate. The root crate re-exports it from `crate::atomic_save`.
24//!
25//! # Permissions (SEC-021)
26//!
27//! Two policies, chosen per call site:
28//!
29//! - [`save_bytes_atomic`] and its wrappers force mode `0o600`. Use these for
30//!   par-term's own state in par-term's own directories (`config.yaml`,
31//!   `state.yaml`, `arrangements.yaml`, `command_history.yaml`, the assistant
32//!   history and prompt store, the bundle manifest). They may hold secrets, and
33//!   nothing outside par-term reads them.
34//!
35//! - [`save_bytes_atomic_preserving_mode`] and its wrapper keep whatever mode
36//!   the target already has. Use these for files that belong to the *user*
37//!   rather than to par-term — shell rc files, GLSL shader sources, exports to
38//!   a path chosen in a save dialog. Forcing `0o600` on those is wrong: an rc
39//!   file may legitimately be group-readable, and a bundled `0o644` shader must
40//!   not be silently tightened just because the user edited it.
41//!
42//! In both cases the staging file is created `0o600` **before any bytes are
43//! written**, so contents are never briefly world-readable; the
44//! mode-preserving variant widens it back to the target's mode only once the
45//! payload is on disk. When the target does not exist there is no mode to
46//! preserve and `0o600` is kept: an rc file, a shader and a snippet export are
47//! all read only by the owning user, so `0o600` is never the unsafe direction.
48//!
49//! # Windows
50//!
51//! `std::fs::rename` maps to `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`, so
52//! replacing an existing target is supported — but it fails with a sharing
53//! violation while another process holds the target open (antivirus and search
54//! indexers do this routinely). The rename is retried with a short backoff; if
55//! it still fails the temporary file is removed and the error is returned, so
56//! the previous file survives and the failure is loud rather than lossy.
57
58use anyhow::{Context, Result};
59use serde::Serialize;
60use std::fs;
61use std::io::Write;
62use std::path::{Path, PathBuf};
63use std::sync::atomic::{AtomicU64, Ordering};
64
65/// Distinguishes concurrent saves to the same target within one process.
66static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
67
68/// What permissions the replaced file should end up with.
69#[derive(Clone, Copy)]
70enum ModePolicy {
71    /// Always `0o600`: par-term's own state, which may hold secrets.
72    Private,
73    /// Keep the target's current mode; `0o600` when the target is new.
74    PreserveTarget,
75}
76
77/// Build the temporary path used to stage a write to `path`.
78///
79/// Always a sibling of `path`: a cross-filesystem rename is a copy, which is
80/// not atomic, so the staging file must live in the target's own directory.
81fn temp_path_for(path: &Path) -> PathBuf {
82    let file_name = path
83        .file_name()
84        .map(|n| n.to_string_lossy().into_owned())
85        .unwrap_or_else(|| "par-term-save".to_string());
86    let unique = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
87    path.with_file_name(format!("{file_name}.tmp.{}.{unique}", std::process::id()))
88}
89
90/// The mode to give the staged file just before it is renamed over `target`.
91///
92/// `None` means "leave it at the `0o600` it was staged with".
93#[cfg(unix)]
94fn final_mode(target: &Path, policy: ModePolicy) -> Option<u32> {
95    use std::os::unix::fs::PermissionsExt;
96
97    match policy {
98        ModePolicy::Private => None,
99        // `symlink_metadata` deliberately: if the target is a symlink the
100        // rename replaces the link itself, so the link's own mode is what the
101        // caller had, not the mode of whatever it pointed at.
102        ModePolicy::PreserveTarget => fs::symlink_metadata(target)
103            .ok()
104            .map(|m| m.permissions().mode() & 0o7777)
105            .filter(|mode| *mode != 0o600),
106    }
107}
108
109/// Permissions are a Unix concept; on other platforms the staged file is
110/// renamed as-is under either policy.
111#[cfg(not(unix))]
112fn final_mode(_target: &Path, policy: ModePolicy) -> Option<u32> {
113    match policy {
114        ModePolicy::Private | ModePolicy::PreserveTarget => None,
115    }
116}
117
118/// Write `bytes` into the staging file and flush them all the way to disk.
119///
120/// `final_mode` is applied after the payload is written and before the caller
121/// renames, so the file is never more permissive than `0o600` while it is being
122/// filled in.
123#[cfg_attr(not(unix), allow(unused_variables))]
124fn write_and_sync(temp_path: &Path, bytes: &[u8], final_mode: Option<u32>) -> Result<()> {
125    let mut options = fs::OpenOptions::new();
126    options.write(true).create(true).truncate(true);
127
128    #[cfg(unix)]
129    {
130        use std::os::unix::fs::OpenOptionsExt;
131        options.mode(0o600);
132    }
133
134    let mut file = options
135        .open(temp_path)
136        .with_context(|| format!("Failed to create temporary file {temp_path:?}"))?;
137
138    // SEC-021: `mode()` above only applies when the open *creates* the file, so a
139    // stale staging file left by a crashed process would keep its old mode. Set
140    // it explicitly while the file is still empty — before the first byte is
141    // written, never after.
142    #[cfg(unix)]
143    {
144        use std::os::unix::fs::PermissionsExt;
145        file.set_permissions(fs::Permissions::from_mode(0o600))
146            .with_context(|| format!("Failed to restrict permissions on {temp_path:?}"))?;
147    }
148
149    file.write_all(bytes)
150        .with_context(|| format!("Failed to write temporary file {temp_path:?}"))?;
151
152    #[cfg(unix)]
153    if let Some(mode) = final_mode {
154        use std::os::unix::fs::PermissionsExt;
155        file.set_permissions(fs::Permissions::from_mode(mode))
156            .with_context(|| format!("Failed to restore permissions on {temp_path:?}"))?;
157    }
158
159    file.sync_all()
160        .with_context(|| format!("Failed to flush temporary file {temp_path:?} to disk"))?;
161    Ok(())
162}
163
164#[cfg(not(windows))]
165fn rename_into_place(from: &Path, to: &Path) -> Result<()> {
166    fs::rename(from, to).with_context(|| format!("Failed to rename {from:?} to {to:?}"))
167}
168
169#[cfg(windows)]
170fn rename_into_place(from: &Path, to: &Path) -> Result<()> {
171    const ATTEMPTS: u32 = 5;
172
173    let mut last_err = None;
174    for attempt in 0..ATTEMPTS {
175        match fs::rename(from, to) {
176            Ok(()) => return Ok(()),
177            Err(e) => {
178                last_err = Some(e);
179                if attempt + 1 < ATTEMPTS {
180                    std::thread::sleep(std::time::Duration::from_millis(
181                        50 * u64::from(attempt + 1),
182                    ));
183                }
184            }
185        }
186    }
187
188    Err(last_err.expect("loop body runs at least once")).with_context(|| {
189        format!(
190            "Failed to rename {from:?} to {to:?} after {ATTEMPTS} attempts; \
191             the target may be held open by another process"
192        )
193    })
194}
195
196/// Best-effort fsync of the parent directory so the rename itself is durable.
197#[cfg(unix)]
198fn sync_parent_dir(path: &Path) {
199    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty())
200        && let Ok(dir) = fs::File::open(parent)
201    {
202        let _ = dir.sync_all();
203    }
204}
205
206#[cfg(not(unix))]
207fn sync_parent_dir(_path: &Path) {}
208
209fn save_bytes_with_policy(path: &Path, bytes: &[u8], policy: ModePolicy) -> Result<()> {
210    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
211        fs::create_dir_all(parent)
212            .with_context(|| format!("Failed to create directory {parent:?}"))?;
213    }
214
215    let mode = final_mode(path, policy);
216    let temp_path = temp_path_for(path);
217
218    if let Err(e) = write_and_sync(&temp_path, bytes, mode) {
219        let _ = fs::remove_file(&temp_path);
220        return Err(e.context(format!(
221            "Failed to stage write for {path:?}; the previous file was left unchanged"
222        )));
223    }
224
225    if let Err(e) = rename_into_place(&temp_path, path) {
226        let _ = fs::remove_file(&temp_path);
227        return Err(e.context(format!(
228            "Failed to replace {path:?}; the previous file was left unchanged"
229        )));
230    }
231
232    sync_parent_dir(path);
233    Ok(())
234}
235
236/// Atomically replace `path` with `bytes`, forcing mode `0o600`.
237///
238/// Creates the parent directory if needed, stages the write in a sibling
239/// temporary file (mode `0o600` on Unix), fsyncs it, and renames it over the
240/// target. On any failure the staging file is removed and the previous
241/// contents of `path` are left untouched.
242///
243/// For files that belong to the user rather than to par-term, use
244/// [`save_bytes_atomic_preserving_mode`] instead.
245///
246/// # Errors
247///
248/// Returns an error if the parent directory cannot be created, if the staging
249/// file cannot be written, fsynced or permission-restricted, or if the rename
250/// fails.
251pub fn save_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
252    save_bytes_with_policy(path, bytes, ModePolicy::Private)
253}
254
255/// Atomically replace `path` with `contents`, forcing mode `0o600`.
256///
257/// See [`save_bytes_atomic`].
258///
259/// # Errors
260///
261/// As [`save_bytes_atomic`].
262pub fn save_string_atomic(path: &Path, contents: &str) -> Result<()> {
263    save_bytes_atomic(path, contents.as_bytes())
264}
265
266/// Serialize `value` as YAML and atomically replace `path` with it, forcing
267/// mode `0o600`.
268///
269/// Serialization happens before the target is touched, so a serialization
270/// failure cannot damage the existing file.
271///
272/// # Errors
273///
274/// Returns an error if `value` cannot be serialized to YAML, plus everything
275/// [`save_bytes_atomic`] can fail with.
276pub fn save_yaml_atomic<T>(path: &Path, value: &T) -> Result<()>
277where
278    T: Serialize + ?Sized,
279{
280    let yaml = serde_yaml_ng::to_string(value)
281        .with_context(|| format!("Failed to serialize data for {path:?}"))?;
282    save_string_atomic(path, &yaml)
283}
284
285/// Atomically replace `path` with `bytes`, keeping the target's current mode.
286///
287/// Identical to [`save_bytes_atomic`] except for permissions: the staged file
288/// is still created `0o600`, but immediately before the rename it is set back
289/// to whatever mode `path` already had. A target that does not exist yet is
290/// created `0o600`.
291///
292/// Use this for files par-term does not own: the user's shell rc files, GLSL
293/// shader sources, and exports written to a path chosen in a save dialog.
294/// Forcing `0o600` on those would silently change permissions the user chose.
295///
296/// # Errors
297///
298/// As [`save_bytes_atomic`]. Reading the target's current mode is best-effort:
299/// if it cannot be stat'ed the `0o600` staging mode is kept rather than failing
300/// the save.
301pub fn save_bytes_atomic_preserving_mode(path: &Path, bytes: &[u8]) -> Result<()> {
302    save_bytes_with_policy(path, bytes, ModePolicy::PreserveTarget)
303}
304
305/// Atomically replace `path` with `contents`, keeping the target's current mode.
306///
307/// See [`save_bytes_atomic_preserving_mode`].
308///
309/// # Errors
310///
311/// As [`save_bytes_atomic_preserving_mode`].
312pub fn save_string_atomic_preserving_mode(path: &Path, contents: &str) -> Result<()> {
313    save_bytes_atomic_preserving_mode(path, contents.as_bytes())
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use tempfile::tempdir;
320
321    /// A type whose `Serialize` impl always fails, to exercise the
322    /// "save failed partway" path without needing a full disk.
323    struct AlwaysFailsToSerialize;
324
325    impl Serialize for AlwaysFailsToSerialize {
326        fn serialize<S: serde::Serializer>(&self, _s: S) -> Result<S::Ok, S::Error> {
327            Err(serde::ser::Error::custom(
328                "deliberate serialization failure",
329            ))
330        }
331    }
332
333    fn dir_entries(dir: &Path) -> Vec<String> {
334        let mut names: Vec<String> = fs::read_dir(dir)
335            .expect("read_dir")
336            .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
337            .collect();
338        names.sort();
339        names
340    }
341
342    #[cfg(unix)]
343    fn mode_of(path: &Path) -> u32 {
344        use std::os::unix::fs::PermissionsExt;
345        fs::metadata(path).expect("metadata").permissions().mode() & 0o777
346    }
347
348    #[test]
349    fn temp_path_is_a_sibling_of_the_target() {
350        let target = Path::new("/some/dir/profiles.yaml");
351        let temp = temp_path_for(target);
352
353        // Same directory, so the rename never crosses a filesystem boundary.
354        assert_eq!(temp.parent(), target.parent());
355        assert_ne!(temp, target);
356        assert!(
357            temp.file_name()
358                .expect("temp has a file name")
359                .to_string_lossy()
360                .starts_with("profiles.yaml.tmp.")
361        );
362    }
363
364    #[test]
365    fn temp_paths_are_unique_within_a_process() {
366        let target = Path::new("/some/dir/profiles.yaml");
367        assert_ne!(temp_path_for(target), temp_path_for(target));
368    }
369
370    #[test]
371    fn save_creates_parent_directory() {
372        let temp = tempdir().expect("tempdir");
373        let path = temp.path().join("nested").join("dir").join("data.yaml");
374
375        save_string_atomic(&path, "hello").expect("save");
376        assert_eq!(fs::read_to_string(&path).expect("read"), "hello");
377    }
378
379    #[test]
380    fn save_replaces_existing_content_and_leaves_no_temp_file() {
381        let temp = tempdir().expect("tempdir");
382        let path = temp.path().join("data.yaml");
383
384        save_string_atomic(&path, "first").expect("first save");
385        save_string_atomic(&path, "second").expect("second save");
386
387        assert_eq!(fs::read_to_string(&path).expect("read"), "second");
388        assert_eq!(dir_entries(temp.path()), vec!["data.yaml".to_string()]);
389    }
390
391    #[test]
392    fn failed_save_leaves_the_previous_file_intact() {
393        let temp = tempdir().expect("tempdir");
394        let path = temp.path().join("data.yaml");
395
396        save_yaml_atomic(&path, &"good data".to_string()).expect("initial save");
397        let before = fs::read_to_string(&path).expect("read before");
398
399        let err =
400            save_yaml_atomic(&path, &AlwaysFailsToSerialize).expect_err("serialization must fail");
401        assert!(
402            err.to_string().contains("Failed to serialize"),
403            "unexpected error: {err:#}"
404        );
405
406        // The previous good content survives byte-for-byte, and no staging file
407        // is left behind for a later save to trip over.
408        assert_eq!(fs::read_to_string(&path).expect("read after"), before);
409        assert_eq!(dir_entries(temp.path()), vec!["data.yaml".to_string()]);
410    }
411
412    #[test]
413    fn stale_temp_file_does_not_break_a_later_save() {
414        let temp = tempdir().expect("tempdir");
415        let path = temp.path().join("data.yaml");
416
417        // Simulate a crash that left a staging file behind.
418        fs::write(temp.path().join("data.yaml.tmp.999999.0"), "garbage").expect("stale temp");
419
420        save_string_atomic(&path, "fresh").expect("save over a stale temp");
421        assert_eq!(fs::read_to_string(&path).expect("read"), "fresh");
422    }
423
424    #[test]
425    fn yaml_roundtrip() {
426        let temp = tempdir().expect("tempdir");
427        let path = temp.path().join("data.yaml");
428
429        let value = vec!["a".to_string(), "b".to_string()];
430        save_yaml_atomic(&path, &value).expect("save");
431
432        let loaded: Vec<String> =
433            serde_yaml_ng::from_str(&fs::read_to_string(&path).expect("read")).expect("parse");
434        assert_eq!(loaded, value);
435    }
436
437    #[cfg(unix)]
438    #[test]
439    fn completed_save_has_mode_0600() {
440        let temp = tempdir().expect("tempdir");
441        let path = temp.path().join("data.yaml");
442
443        save_string_atomic(&path, "secret").expect("save");
444
445        assert_eq!(mode_of(&path), 0o600);
446    }
447
448    #[cfg(unix)]
449    #[test]
450    fn save_tightens_a_world_readable_existing_file() {
451        use std::os::unix::fs::PermissionsExt;
452
453        let temp = tempdir().expect("tempdir");
454        let path = temp.path().join("data.yaml");
455
456        fs::write(&path, "old").expect("seed");
457        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod");
458
459        save_string_atomic(&path, "new").expect("save");
460
461        assert_eq!(mode_of(&path), 0o600);
462    }
463
464    #[cfg(unix)]
465    #[test]
466    fn staging_file_is_never_world_readable() {
467        let temp = tempdir().expect("tempdir");
468        let staging = temp.path().join("staged");
469
470        write_and_sync(&staging, b"secret", None).expect("stage");
471
472        assert_eq!(mode_of(&staging), 0o600);
473    }
474
475    #[cfg(unix)]
476    #[test]
477    fn staging_reuses_a_stale_temp_file_but_re_restricts_it() {
478        use std::os::unix::fs::PermissionsExt;
479
480        let temp = tempdir().expect("tempdir");
481        let staging = temp.path().join("staged");
482
483        // A crashed process could leave a staging file with a permissive mode.
484        fs::write(&staging, "stale").expect("seed");
485        fs::set_permissions(&staging, fs::Permissions::from_mode(0o666)).expect("chmod");
486
487        write_and_sync(&staging, b"secret", None).expect("stage");
488
489        assert_eq!(mode_of(&staging), 0o600);
490        assert_eq!(fs::read_to_string(&staging).expect("read"), "secret");
491    }
492
493    // ---- mode-preserving variant -------------------------------------------
494
495    #[cfg(unix)]
496    #[test]
497    fn preserving_save_keeps_a_group_readable_rc_file_group_readable() {
498        use std::os::unix::fs::PermissionsExt;
499
500        let temp = tempdir().expect("tempdir");
501        let path = temp.path().join(".zshrc");
502
503        fs::write(&path, "export PATH=/bin\n").expect("seed");
504        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("chmod");
505
506        save_string_atomic_preserving_mode(&path, "export PATH=/bin\nnew line\n").expect("save");
507
508        assert_eq!(mode_of(&path), 0o644, "the user's own mode must survive");
509        assert_eq!(
510            fs::read_to_string(&path).expect("read"),
511            "export PATH=/bin\nnew line\n"
512        );
513    }
514
515    #[cfg(unix)]
516    #[test]
517    fn preserving_save_keeps_an_executable_bit() {
518        use std::os::unix::fs::PermissionsExt;
519
520        let temp = tempdir().expect("tempdir");
521        let path = temp.path().join("script.sh");
522
523        fs::write(&path, "#!/bin/sh\n").expect("seed");
524        fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
525
526        save_string_atomic_preserving_mode(&path, "#!/bin/sh\necho hi\n").expect("save");
527
528        assert_eq!(mode_of(&path), 0o755);
529    }
530
531    #[cfg(unix)]
532    #[test]
533    fn preserving_save_creates_a_new_file_private() {
534        let temp = tempdir().expect("tempdir");
535        let path = temp.path().join("fresh.glsl");
536
537        save_string_atomic_preserving_mode(&path, "void main() {}").expect("save");
538
539        assert_eq!(
540            mode_of(&path),
541            0o600,
542            "with no target to preserve, 0600 is the safe default"
543        );
544    }
545
546    #[test]
547    fn preserving_save_is_still_atomic_and_leaves_no_temp_file() {
548        let temp = tempdir().expect("tempdir");
549        let path = temp.path().join(".bashrc");
550
551        save_string_atomic_preserving_mode(&path, "first").expect("first save");
552        save_string_atomic_preserving_mode(&path, "second").expect("second save");
553
554        assert_eq!(fs::read_to_string(&path).expect("read"), "second");
555        assert_eq!(dir_entries(temp.path()), vec![".bashrc".to_string()]);
556    }
557
558    #[test]
559    fn preserving_save_failure_leaves_the_previous_rc_file_intact() {
560        let temp = tempdir().expect("tempdir");
561        // A directory where the file belongs makes the rename fail after the
562        // payload is already written and fsynced.
563        let path = temp.path().join("blocked");
564        fs::create_dir(&path).expect("blocking directory");
565
566        let err = save_string_atomic_preserving_mode(&path, "payload")
567            .expect_err("renaming over a directory must fail");
568        assert!(
569            format!("{err:#}").contains("left unchanged"),
570            "unexpected error: {err:#}"
571        );
572
573        let leftovers: Vec<String> = dir_entries(temp.path())
574            .into_iter()
575            .filter(|n| n.contains(".tmp."))
576            .collect();
577        assert!(leftovers.is_empty(), "staging files left: {leftovers:?}");
578    }
579}