Rust · 8957 bytes Raw Blame History
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, &registry).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