|
2 | 2 | //! |
3 | 3 | //! These can be extracted to environment variables or a config file in the future. |
4 | 4 |
|
5 | | -use std::path::PathBuf; |
| 5 | +use std::io::Write; |
| 6 | +use std::path::{Path, PathBuf}; |
6 | 7 | use tauri::{AppHandle, Manager, Runtime}; |
7 | 8 |
|
8 | 9 | /// Icon size in pixels (32x32 for retina display) |
@@ -58,6 +59,47 @@ pub fn log_app_data_dir<R: Runtime>(app: &AppHandle<R>) { |
58 | 59 | } |
59 | 60 | } |
60 | 61 |
|
| 62 | +/// Writes `content` to `path` durably: write the bytes to `tmp`, fsync the temp file, rename |
| 63 | +/// it over `path`, then fsync the parent directory so the rename itself survives a power loss. |
| 64 | +/// |
| 65 | +/// `fs::write(tmp) + fs::rename(tmp, path)` alone is atomic against *process* death (the rename |
| 66 | +/// swaps the directory entry as a unit) but NOT against a power loss / hard crash: the rename can |
| 67 | +/// land in the filesystem journal while the temp's data blocks are still only in the page cache, |
| 68 | +/// leaving the destination zero-length or torn. The two fsyncs close that window. This mirrors the |
| 69 | +/// data-loss-class write discipline in `LocalPosixVolume::write_from_stream` (the |
| 70 | +/// copy/move path that survives eject/sleep/power-loss). |
| 71 | +/// |
| 72 | +/// The caller owns the temp-path convention (it picks `tmp`) so each store keeps its existing |
| 73 | +/// `cleanup_tmp_file` stale-temp recovery. The parent-directory fsync is best-effort: some |
| 74 | +/// filesystems reject opening a directory for fsync, so a failure there is logged and ignored |
| 75 | +/// rather than failing the whole write (the file's data is already durable at that point). |
| 76 | +pub fn durable_write_json(path: &Path, tmp: &Path, content: &str) -> std::io::Result<()> { |
| 77 | + { |
| 78 | + let mut file = std::fs::File::create(tmp)?; |
| 79 | + file.write_all(content.as_bytes())?; |
| 80 | + // fsync the temp's data + metadata before the rename so the bytes are on disk, not just |
| 81 | + // in the page cache, when the directory entry swaps. |
| 82 | + file.sync_all()?; |
| 83 | + } |
| 84 | + |
| 85 | + std::fs::rename(tmp, path)?; |
| 86 | + |
| 87 | + // Best-effort: fsync the parent directory so the rename (the new directory entry) is durable |
| 88 | + // too. Logged and ignored on failure, matching `LocalPosixVolume`'s parent-dir fsync. |
| 89 | + if let Some(parent) = path.parent() { |
| 90 | + match std::fs::File::open(parent).and_then(|dir| dir.sync_all()) { |
| 91 | + Ok(()) => {} |
| 92 | + Err(e) => log::debug!( |
| 93 | + target: "write_durability", |
| 94 | + "durable_write_json: parent dir fsync skipped for {}: {e}", |
| 95 | + parent.display() |
| 96 | + ), |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + Ok(()) |
| 101 | +} |
| 102 | + |
61 | 103 | // MCP Server Security Design: |
62 | 104 | // -------------------------- |
63 | 105 | // The MCP (Model Context Protocol) bridge allows AI assistants to control the app. |
@@ -93,4 +135,30 @@ mod tests { |
93 | 135 | // Falling through to the Tauri default is the documented behavior. |
94 | 136 | assert_eq!(data_dir_from_env(Some("")), None); |
95 | 137 | } |
| 138 | + |
| 139 | + #[test] |
| 140 | + fn durable_write_json_round_trips_content() { |
| 141 | + let dir = tempfile::tempdir().expect("create temp dir"); |
| 142 | + let path = dir.path().join("store.json"); |
| 143 | + let tmp = path.with_extension("json.tmp"); |
| 144 | + |
| 145 | + durable_write_json(&path, &tmp, r#"{"a":1}"#).expect("durable write"); |
| 146 | + |
| 147 | + let read_back = std::fs::read_to_string(&path).expect("read back"); |
| 148 | + assert_eq!(read_back, r#"{"a":1}"#); |
| 149 | + // The temp file must be consumed by the rename, not left behind. |
| 150 | + assert!(!tmp.exists(), "temp file should be renamed away, not left behind"); |
| 151 | + } |
| 152 | + |
| 153 | + #[test] |
| 154 | + fn durable_write_json_overwrites_existing_file() { |
| 155 | + let dir = tempfile::tempdir().expect("create temp dir"); |
| 156 | + let path = dir.path().join("store.json"); |
| 157 | + let tmp = path.with_extension("json.tmp"); |
| 158 | + |
| 159 | + std::fs::write(&path, "old contents that are much longer").expect("seed file"); |
| 160 | + durable_write_json(&path, &tmp, "new").expect("durable write"); |
| 161 | + |
| 162 | + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "new"); |
| 163 | + } |
96 | 164 | } |
0 commit comments