@@ -3,8 +3,10 @@ |
| 3 | use std::collections::HashMap; | 3 | use std::collections::HashMap; |
| 4 | use std::fs; | 4 | use std::fs; |
| 5 | use std::io; | 5 | use std::io; |
| | 6 | +#[cfg(unix)] |
| | 7 | +use std::os::unix::fs::PermissionsExt; |
| 6 | use std::path::Path; | 8 | use std::path::Path; |
| 7 | -use std::time::{Duration, Instant}; | 9 | +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; |
| 8 | | 10 | |
| 9 | use crate::request::{RequestOwner, RequestRecord, RequestRegistry, RequestState}; | 11 | use crate::request::{RequestOwner, RequestRecord, RequestRegistry, RequestState}; |
| 10 | use crate::window::parse_optional_parent_window; | 12 | use crate::window::parse_optional_parent_window; |
@@ -45,7 +47,41 @@ pub fn persist_registry(path: &Path, registry: &RequestRegistry) -> io::Result<( |
| 45 | output.push_str(&format_record_line(&record)); | 47 | output.push_str(&format_record_line(&record)); |
| 46 | output.push('\n'); | 48 | output.push('\n'); |
| 47 | } | 49 | } |
| 48 | - fs::write(path, output) | 50 | + atomic_write(path, output.as_bytes()) |
| | 51 | +} |
| | 52 | + |
| | 53 | +fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> { |
| | 54 | + if let Some(parent) = path.parent() { |
| | 55 | + fs::create_dir_all(parent)?; |
| | 56 | + } |
| | 57 | + |
| | 58 | + let temp_path = unique_temp_path(path); |
| | 59 | + let mut file = fs::OpenOptions::new() |
| | 60 | + .create_new(true) |
| | 61 | + .write(true) |
| | 62 | + .open(&temp_path)?; |
| | 63 | + #[cfg(unix)] |
| | 64 | + { |
| | 65 | + file.set_permissions(fs::Permissions::from_mode(0o600))?; |
| | 66 | + } |
| | 67 | + io::Write::write_all(&mut file, data)?; |
| | 68 | + file.sync_all()?; |
| | 69 | + drop(file); |
| | 70 | + |
| | 71 | + fs::rename(&temp_path, path)?; |
| | 72 | + Ok(()) |
| | 73 | +} |
| | 74 | + |
| | 75 | +fn unique_temp_path(path: &Path) -> std::path::PathBuf { |
| | 76 | + let nanos = SystemTime::now() |
| | 77 | + .duration_since(UNIX_EPOCH) |
| | 78 | + .map_or(0, |duration| duration.as_nanos()); |
| | 79 | + let parent = path.parent().unwrap_or_else(|| Path::new(".")); |
| | 80 | + let file_name = path |
| | 81 | + .file_name() |
| | 82 | + .and_then(|name| name.to_str()) |
| | 83 | + .unwrap_or("state"); |
| | 84 | + parent.join(format!(".{file_name}.tmp-{}-{nanos}", std::process::id())) |
| 49 | } | 85 | } |
| 50 | | 86 | |
| 51 | fn format_record_line(record: &RequestRecord) -> String { | 87 | fn format_record_line(record: &RequestRecord) -> String { |
@@ -176,4 +212,36 @@ mod tests { |
| 176 | | 212 | |
| 177 | let _ = fs::remove_file(path); | 213 | let _ = fs::remove_file(path); |
| 178 | } | 214 | } |
| | 215 | + |
| | 216 | + #[test] |
| | 217 | + fn persist_overwrites_previous_contents() { |
| | 218 | + let path = unique_temp_file(); |
| | 219 | + let mut first = RequestRegistry::new(Duration::from_secs(5)); |
| | 220 | + first |
| | 221 | + .begin_at( |
| | 222 | + "req-first", |
| | 223 | + RequestOwner::new(":1.2", None), |
| | 224 | + None, |
| | 225 | + Instant::now(), |
| | 226 | + ) |
| | 227 | + .expect("request should be created"); |
| | 228 | + persist_registry(&path, &first).expect("first persist should succeed"); |
| | 229 | + |
| | 230 | + let mut second = RequestRegistry::new(Duration::from_secs(5)); |
| | 231 | + second |
| | 232 | + .begin_at( |
| | 233 | + "req-second", |
| | 234 | + RequestOwner::new(":1.3", Some("org.test.App".to_string())), |
| | 235 | + None, |
| | 236 | + Instant::now(), |
| | 237 | + ) |
| | 238 | + .expect("request should be created"); |
| | 239 | + persist_registry(&path, &second).expect("second persist should succeed"); |
| | 240 | + |
| | 241 | + let loaded = load_registry(&path, Duration::from_secs(5)).expect("registry should load"); |
| | 242 | + assert_eq!(loaded.state("req-first"), None); |
| | 243 | + assert_eq!(loaded.state("req-second"), Some(RequestState::Pending)); |
| | 244 | + |
| | 245 | + let _ = fs::remove_file(path); |
| | 246 | + } |
| 179 | } | 247 | } |