Skip to main content

app_json_settings/core/
save.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::Result;
8
9#[cfg(unix)]
10use std::os::unix::fs::OpenOptionsExt;
11#[cfg(windows)]
12use std::os::windows::ffi::OsStrExt;
13
14static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
15
16#[cfg(windows)]
17#[allow(non_snake_case)]
18#[link(name = "kernel32")]
19unsafe extern "system" {
20    fn MoveFileExW(existing_file_name: *const u16, new_file_name: *const u16, flags: u32) -> i32;
21}
22
23/// Strategy used when saving a settings file.
24#[derive(Debug, Clone, Copy, Eq, PartialEq)]
25pub enum SaveMode {
26    /// Write the new JSON to a temporary file, flush it, and replace the target.
27    ///
28    /// This is the default mode. It avoids exposing a partially written final
29    /// settings file if the process stops during the write step.
30    Atomic,
31
32    /// Write JSON directly to the final path.
33    ///
34    /// This mode matches the v2.2.0 behavior and is useful for unusual
35    /// filesystems, debugging, or applications that intentionally want direct
36    /// overwrite semantics.
37    Direct,
38}
39
40pub fn save_to_path(path: &Path, content: &str, mode: SaveMode) -> Result<()> {
41    if let Some(parent) = non_empty_parent(path) {
42        fs::create_dir_all(parent)?;
43    }
44
45    match mode {
46        SaveMode::Atomic => save_atomic(path, content),
47        SaveMode::Direct => save_direct(path, content),
48    }
49}
50
51fn save_direct(path: &Path, content: &str) -> Result<()> {
52    fs::write(path, content)?;
53    Ok(())
54}
55
56fn save_atomic(path: &Path, content: &str) -> Result<()> {
57    let (temp_path, mut temp_file) = create_temp_file(path)?;
58
59    let result = (|| -> io::Result<()> {
60        temp_file.write_all(content.as_bytes())?;
61        temp_file.sync_all()?;
62        drop(temp_file);
63
64        #[cfg(unix)]
65        apply_target_mode(&temp_path, path);
66
67        replace_file(&temp_path, path)?;
68        sync_parent_dir(path);
69        Ok(())
70    })();
71
72    if result.is_err() {
73        let _ = fs::remove_file(&temp_path);
74    }
75
76    result?;
77    Ok(())
78}
79
80/// Applies the existing target file's permission bits to the temporary file,
81/// on Unix, before the temporary file replaces it.
82///
83/// Best-effort and deliberately silent on failure: the temporary file was
84/// already created at `0600` (see [`create_temp_file`]), so any failure here
85/// leaves the result more restrictive than intended, never less. Filesystems
86/// that do not model permission bits (FAT, some network mounts) are expected
87/// to fail here; that is not a save failure.
88#[cfg(unix)]
89fn apply_target_mode(temp_path: &Path, target_path: &Path) {
90    if let Ok(metadata) = fs::metadata(target_path) {
91        let _ = fs::set_permissions(temp_path, metadata.permissions());
92    }
93}
94
95fn create_temp_file(target: &Path) -> io::Result<(PathBuf, File)> {
96    let parent = non_empty_parent(target).unwrap_or_else(|| Path::new("."));
97    let target_name = target
98        .file_name()
99        .map(|name| name.to_string_lossy())
100        .unwrap_or_else(|| "settings.json".into());
101
102    for attempt in 0..1000_u16 {
103        let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
104        let nanos = SystemTime::now()
105            .duration_since(UNIX_EPOCH)
106            .map(|duration| duration.as_nanos())
107            .unwrap_or(0);
108        let temp_name = format!(
109            ".{target_name}.tmp.{}.{}.{}.{}",
110            std::process::id(),
111            nanos,
112            counter,
113            attempt
114        );
115        let temp_path = parent.join(temp_name);
116
117        let mut open_options = OpenOptions::new();
118        open_options.write(true).create_new(true);
119        // Owner-only from creation, so the content is never briefly readable
120        // by other local users while it is being written. On non-Unix
121        // targets this has no effect; final-file permissions on Unix are
122        // applied separately in `apply_target_mode` / `save_atomic`.
123        #[cfg(unix)]
124        open_options.mode(0o600);
125
126        match open_options.open(&temp_path) {
127            Ok(file) => return Ok((temp_path, file)),
128            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
129            Err(error) => return Err(error),
130        }
131    }
132
133    Err(io::Error::new(
134        io::ErrorKind::AlreadyExists,
135        "could not create a unique temporary settings file",
136    ))
137}
138
139#[cfg(unix)]
140fn replace_file(temp_path: &Path, target_path: &Path) -> io::Result<()> {
141    fs::rename(temp_path, target_path)
142}
143
144#[cfg(windows)]
145fn replace_file(temp_path: &Path, target_path: &Path) -> io::Result<()> {
146    const MOVEFILE_REPLACE_EXISTING: u32 = 0x0000_0001;
147    const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008;
148
149    let old_path = wide_null_terminated(temp_path);
150    let new_path = wide_null_terminated(target_path);
151
152    // SAFETY: Both pointers are valid, null-terminated UTF-16 buffers that live
153    // for the duration of the call. The flags request an in-place replacement
154    // of the destination by a temporary file created in the same directory.
155    let ok = unsafe {
156        MoveFileExW(
157            old_path.as_ptr(),
158            new_path.as_ptr(),
159            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
160        )
161    };
162
163    if ok == 0 {
164        Err(io::Error::last_os_error())
165    } else {
166        Ok(())
167    }
168}
169
170#[cfg(windows)]
171fn wide_null_terminated(path: &Path) -> Vec<u16> {
172    path.as_os_str().encode_wide().chain([0]).collect()
173}
174
175#[cfg(not(any(unix, windows)))]
176fn replace_file(temp_path: &Path, target_path: &Path) -> io::Result<()> {
177    if target_path.exists() {
178        return Err(io::Error::new(
179            io::ErrorKind::Unsupported,
180            "atomic replacement is not implemented for this target; use SaveMode::Direct",
181        ));
182    }
183
184    fs::rename(temp_path, target_path)
185}
186
187fn non_empty_parent(path: &Path) -> Option<&Path> {
188    path.parent()
189        .filter(|parent| !parent.as_os_str().is_empty())
190}
191
192fn sync_parent_dir(path: &Path) {
193    if let Some(parent) = non_empty_parent(path) {
194        if let Ok(dir) = File::open(parent) {
195            let _ = dir.sync_all();
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests;