| 1 | #![allow(dead_code)] |
| 2 | |
| 3 | use std::collections::HashMap; |
| 4 | use std::fs; |
| 5 | use std::io; |
| 6 | #[cfg(unix)] |
| 7 | use std::os::unix::fs::PermissionsExt; |
| 8 | use std::path::Path; |
| 9 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; |
| 10 | |
| 11 | use crate::request::{RequestOwner, RequestRecord, RequestRegistry, RequestState}; |
| 12 | use crate::window::parse_optional_parent_window; |
| 13 | |
| 14 | pub fn load_registry(path: &Path, timeout: Duration) -> io::Result<RequestRegistry> { |
| 15 | let mut registry = RequestRegistry::new(timeout); |
| 16 | if !path.exists() { |
| 17 | return Ok(registry); |
| 18 | } |
| 19 | |
| 20 | let body = fs::read_to_string(path)?; |
| 21 | let now = Instant::now(); |
| 22 | |
| 23 | for (index, line) in body.lines().enumerate() { |
| 24 | if line.trim().is_empty() { |
| 25 | continue; |
| 26 | } |
| 27 | let record = parse_record_line(line).map_err(|error| { |
| 28 | io::Error::new( |
| 29 | io::ErrorKind::InvalidData, |
| 30 | format!("invalid request store line {}: {error}", index + 1), |
| 31 | ) |
| 32 | })?; |
| 33 | registry.restore_record(record, now).map_err(|error| { |
| 34 | io::Error::new( |
| 35 | io::ErrorKind::InvalidData, |
| 36 | format!("invalid request store line {}: {error}", index + 1), |
| 37 | ) |
| 38 | })?; |
| 39 | } |
| 40 | |
| 41 | Ok(registry) |
| 42 | } |
| 43 | |
| 44 | pub fn persist_registry(path: &Path, registry: &RequestRegistry) -> io::Result<()> { |
| 45 | let mut output = String::new(); |
| 46 | for record in registry.records() { |
| 47 | output.push_str(&format_record_line(&record)); |
| 48 | output.push('\n'); |
| 49 | } |
| 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())) |
| 85 | } |
| 86 | |
| 87 | fn format_record_line(record: &RequestRecord) -> String { |
| 88 | let app_id = record.owner.app_id.as_deref().unwrap_or("-"); |
| 89 | let parent_window = match record.parent_window { |
| 90 | Some(parent_window) => parent_window.as_str(), |
| 91 | None => "-".to_string(), |
| 92 | }; |
| 93 | |
| 94 | format!( |
| 95 | "id={}\tsender={}\tapp_id={}\tparent={}\tstate={}", |
| 96 | record.id, |
| 97 | record.owner.sender, |
| 98 | app_id, |
| 99 | parent_window, |
| 100 | record.state.as_str() |
| 101 | ) |
| 102 | } |
| 103 | |
| 104 | fn parse_record_line(line: &str) -> Result<RequestRecord, String> { |
| 105 | let fields = parse_fields(line)?; |
| 106 | |
| 107 | let id = required_field(&fields, "id")?.to_string(); |
| 108 | let sender = required_field(&fields, "sender")?.to_string(); |
| 109 | let app_id = optional_field(&fields, "app_id"); |
| 110 | let parent_window = parse_optional_parent_window(optional_field_ref(&fields, "parent")) |
| 111 | .map_err(|error| error.to_string())?; |
| 112 | let state = RequestState::parse(required_field(&fields, "state")?) |
| 113 | .ok_or_else(|| "invalid request state".to_string())?; |
| 114 | |
| 115 | Ok(RequestRecord { |
| 116 | id, |
| 117 | owner: RequestOwner::new(sender, app_id), |
| 118 | parent_window, |
| 119 | state, |
| 120 | }) |
| 121 | } |
| 122 | |
| 123 | fn parse_fields(line: &str) -> Result<HashMap<&str, &str>, String> { |
| 124 | let mut fields = HashMap::new(); |
| 125 | for token in line.split('\t') { |
| 126 | let (key, value) = token |
| 127 | .split_once('=') |
| 128 | .ok_or_else(|| format!("invalid token: {token}"))?; |
| 129 | if !matches!(key, "id" | "sender" | "app_id" | "parent" | "state") { |
| 130 | return Err(format!("unknown field: {key}")); |
| 131 | } |
| 132 | if fields.insert(key, value).is_some() { |
| 133 | return Err(format!("duplicate field: {key}")); |
| 134 | } |
| 135 | } |
| 136 | Ok(fields) |
| 137 | } |
| 138 | |
| 139 | fn required_field<'a>(fields: &'a HashMap<&str, &'a str>, key: &str) -> Result<&'a str, String> { |
| 140 | fields |
| 141 | .get(key) |
| 142 | .copied() |
| 143 | .ok_or_else(|| format!("missing field: {key}")) |
| 144 | } |
| 145 | |
| 146 | fn optional_field(fields: &HashMap<&str, &str>, key: &str) -> Option<String> { |
| 147 | optional_field_ref(fields, key).map(ToOwned::to_owned) |
| 148 | } |
| 149 | |
| 150 | fn optional_field_ref<'a>(fields: &'a HashMap<&str, &'a str>, key: &str) -> Option<&'a str> { |
| 151 | fields |
| 152 | .get(key) |
| 153 | .copied() |
| 154 | .and_then(|value| if value == "-" { None } else { Some(value) }) |
| 155 | } |
| 156 | |
| 157 | #[cfg(test)] |
| 158 | mod tests { |
| 159 | use super::{load_registry, persist_registry}; |
| 160 | use crate::request::{RequestOwner, RequestRegistry, RequestState}; |
| 161 | use crate::window::ParentWindowContext; |
| 162 | use std::fs; |
| 163 | use std::path::PathBuf; |
| 164 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; |
| 165 | |
| 166 | fn unique_temp_file() -> PathBuf { |
| 167 | let nanos = SystemTime::now() |
| 168 | .duration_since(UNIX_EPOCH) |
| 169 | .map_or(0, |duration| duration.as_nanos()); |
| 170 | std::env::temp_dir().join(format!("garwarp-request-store-{nanos}.state")) |
| 171 | } |
| 172 | |
| 173 | #[test] |
| 174 | fn persist_and_load_roundtrip() { |
| 175 | let path = unique_temp_file(); |
| 176 | let mut registry = RequestRegistry::new(Duration::from_secs(5)); |
| 177 | registry |
| 178 | .begin_at( |
| 179 | "req-1", |
| 180 | RequestOwner::new(":1.2", Some("org.test.App".to_string())), |
| 181 | Some(ParentWindowContext::X11 { window_id: 42 }), |
| 182 | Instant::now(), |
| 183 | ) |
| 184 | .expect("request should be created"); |
| 185 | registry |
| 186 | .transition( |
| 187 | "req-1", |
| 188 | &RequestOwner::new(":1.2", Some("org.test.App".to_string())), |
| 189 | RequestState::AwaitingUser, |
| 190 | ) |
| 191 | .expect("request should transition"); |
| 192 | |
| 193 | persist_registry(&path, ®istry).expect("registry should persist"); |
| 194 | |
| 195 | let loaded = load_registry(&path, Duration::from_secs(5)).expect("registry should load"); |
| 196 | assert_eq!(loaded.state("req-1"), Some(RequestState::AwaitingUser)); |
| 197 | assert_eq!( |
| 198 | loaded.parent_window("req-1"), |
| 199 | Some(Some(ParentWindowContext::X11 { window_id: 42 })) |
| 200 | ); |
| 201 | assert_eq!( |
| 202 | loaded.owner("req-1"), |
| 203 | Some(RequestOwner::new(":1.2", Some("org.test.App".to_string()))) |
| 204 | ); |
| 205 | |
| 206 | let _ = fs::remove_file(path); |
| 207 | } |
| 208 | |
| 209 | #[test] |
| 210 | fn invalid_lines_fail_to_load() { |
| 211 | let path = unique_temp_file(); |
| 212 | fs::write(&path, "id=req-1\tsender=:1.2\tstate=bogus\n") |
| 213 | .expect("test file should be written"); |
| 214 | |
| 215 | let loaded = load_registry(&path, Duration::from_secs(5)); |
| 216 | assert!(loaded.is_err()); |
| 217 | |
| 218 | let _ = fs::remove_file(path); |
| 219 | } |
| 220 | |
| 221 | #[test] |
| 222 | fn duplicate_fields_fail_to_load() { |
| 223 | let path = unique_temp_file(); |
| 224 | fs::write(&path, "id=req-1\tid=req-2\tsender=:1.2\tstate=pending\n") |
| 225 | .expect("test file should be written"); |
| 226 | |
| 227 | let loaded = load_registry(&path, Duration::from_secs(5)); |
| 228 | assert!(loaded.is_err()); |
| 229 | |
| 230 | let _ = fs::remove_file(path); |
| 231 | } |
| 232 | |
| 233 | #[test] |
| 234 | fn unknown_fields_fail_to_load() { |
| 235 | let path = unique_temp_file(); |
| 236 | fs::write( |
| 237 | &path, |
| 238 | "id=req-1\tsender=:1.2\tstate=pending\tunexpected=1\n", |
| 239 | ) |
| 240 | .expect("test file should be written"); |
| 241 | |
| 242 | let loaded = load_registry(&path, Duration::from_secs(5)); |
| 243 | assert!(loaded.is_err()); |
| 244 | |
| 245 | let _ = fs::remove_file(path); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn persist_overwrites_previous_contents() { |
| 250 | let path = unique_temp_file(); |
| 251 | let mut first = RequestRegistry::new(Duration::from_secs(5)); |
| 252 | first |
| 253 | .begin_at( |
| 254 | "req-first", |
| 255 | RequestOwner::new(":1.2", None), |
| 256 | None, |
| 257 | Instant::now(), |
| 258 | ) |
| 259 | .expect("request should be created"); |
| 260 | persist_registry(&path, &first).expect("first persist should succeed"); |
| 261 | |
| 262 | let mut second = RequestRegistry::new(Duration::from_secs(5)); |
| 263 | second |
| 264 | .begin_at( |
| 265 | "req-second", |
| 266 | RequestOwner::new(":1.3", Some("org.test.App".to_string())), |
| 267 | None, |
| 268 | Instant::now(), |
| 269 | ) |
| 270 | .expect("request should be created"); |
| 271 | persist_registry(&path, &second).expect("second persist should succeed"); |
| 272 | |
| 273 | let loaded = load_registry(&path, Duration::from_secs(5)).expect("registry should load"); |
| 274 | assert_eq!(loaded.state("req-first"), None); |
| 275 | assert_eq!(loaded.state("req-second"), Some(RequestState::Pending)); |
| 276 | |
| 277 | let _ = fs::remove_file(path); |
| 278 | } |
| 279 | } |
| 280 |