1use serde::{Deserialize, Serialize};
2
3pub const MAX_IPC_BYTES: usize = 64 * 1024;
7pub const MAX_IPC_PATH_LEN: usize = 8192;
9pub const MAX_MONITOR_LEN: usize = 256;
11pub const MAX_DURATION_MS: u32 = 60_000;
13pub const MAX_VIDEO_FPS: u32 = 240;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "command", rename_all = "snake_case")]
18pub enum IpcCommand {
19 Pause {
20 #[serde(default)]
21 monitor: Option<String>,
22 },
23 Resume {
24 #[serde(default)]
25 monitor: Option<String>,
26 },
27 Reload,
28 Seek {
29 timestamp_ms: u64,
30 #[serde(default)]
31 monitor: Option<String>,
32 },
33 Preview {
34 path: String,
35 effect: Option<crate::effect::Effect>,
36 duration_ms: Option<u32>,
37 #[serde(default)]
38 fps: Option<u32>,
39 #[serde(default)]
40 no_theme: bool,
41 #[serde(default)]
42 theme_override: Option<crate::types::ThemeProvider>,
43 #[serde(default)]
44 monitor: Option<String>,
45 #[serde(default)]
46 scaling_mode: Option<crate::types::ScalingMode>,
47 },
48 Stop,
49 Status,
50 Info {
51 #[serde(default)]
52 monitor: Option<String>,
53 },
54 MonitorList,
55 MonitorCurrent,
56 Blank {
57 #[serde(default)]
58 monitor: Option<String>,
59 #[serde(default)]
60 effect: Option<crate::effect::Effect>,
61 #[serde(default)]
62 duration_ms: Option<u32>,
63 },
64 Restore {
65 #[serde(default)]
66 monitor: Option<String>,
67 #[serde(default)]
68 effect: Option<crate::effect::Effect>,
69 #[serde(default)]
70 duration_ms: Option<u32>,
71 },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct IpcResponse {
76 pub success: bool,
77 pub message: Option<String>,
78}
79
80impl IpcResponse {
81 pub fn ok() -> Self {
82 Self {
83 success: true,
84 message: None,
85 }
86 }
87
88 pub fn err(message: impl Into<String>) -> Self {
89 Self {
90 success: false,
91 message: Some(message.into()),
92 }
93 }
94}
95
96pub fn validate_command(cmd: &IpcCommand) -> Result<(), String> {
100 fn check_monitor(monitor: &Option<String>) -> Result<(), String> {
101 if let Some(name) = monitor {
102 if name.is_empty() {
103 return Err("monitor name cannot be empty".to_string());
104 }
105 if name.len() > MAX_MONITOR_LEN {
106 return Err(format!("monitor name exceeds {MAX_MONITOR_LEN} bytes"));
107 }
108 if name.bytes().any(|b| b < 0x20 || b == 0x7f) {
109 return Err("monitor name contains control characters".to_string());
110 }
111 if name.contains('\n') || name.contains('\0') {
112 return Err("monitor name contains invalid characters".to_string());
113 }
114 }
115 Ok(())
116 }
117
118 fn check_duration(duration_ms: &Option<u32>) -> Result<(), String> {
119 if let Some(ms) = duration_ms
120 && *ms > MAX_DURATION_MS
121 {
122 return Err(format!(
123 "duration {ms}ms exceeds maximum {MAX_DURATION_MS}ms"
124 ));
125 }
126 Ok(())
127 }
128
129 fn check_effect(effect: &Option<crate::effect::Effect>) -> Result<(), String> {
130 if let Some(effect) = effect {
131 for value in effect_param_values(effect) {
132 if !value.is_finite() {
133 return Err("effect parameter must be finite".to_string());
134 }
135 if value.abs() > 1_000_000.0 {
136 return Err("effect parameter out of range".to_string());
137 }
138 }
139 }
140 Ok(())
141 }
142
143 fn check_path(path: &str) -> Result<(), String> {
144 if path.is_empty() {
145 return Err("wallpaper path cannot be empty".to_string());
146 }
147 if path.len() > MAX_IPC_PATH_LEN {
148 return Err(format!("wallpaper path exceeds {MAX_IPC_PATH_LEN} bytes"));
149 }
150 if path.bytes().any(|b| b == 0) {
151 return Err("wallpaper path contains NUL".to_string());
152 }
153 Ok(())
154 }
155
156 match cmd {
157 IpcCommand::Pause { monitor } | IpcCommand::Resume { monitor } => check_monitor(monitor),
158 IpcCommand::Reload
159 | IpcCommand::Stop
160 | IpcCommand::Status
161 | IpcCommand::MonitorList
162 | IpcCommand::MonitorCurrent => Ok(()),
163 IpcCommand::Seek {
164 timestamp_ms,
165 monitor,
166 } => {
167 check_monitor(monitor)?;
168 if *timestamp_ms > 24 * 3600 * 1000 {
169 return Err("seek timestamp exceeds 24h".to_string());
170 }
171 Ok(())
172 }
173 IpcCommand::Preview {
174 path,
175 effect,
176 duration_ms,
177 fps,
178 monitor,
179 ..
180 } => {
181 check_path(path)?;
182 check_monitor(monitor)?;
183 check_duration(duration_ms)?;
184 check_effect(effect)?;
185 if let Some(fps) = fps
186 && (*fps == 0 || *fps > MAX_VIDEO_FPS)
187 {
188 return Err(format!("video fps must be between 1 and {MAX_VIDEO_FPS}"));
189 }
190 Ok(())
191 }
192 IpcCommand::Info { monitor } => check_monitor(monitor),
193 IpcCommand::Blank {
194 monitor,
195 effect,
196 duration_ms,
197 }
198 | IpcCommand::Restore {
199 monitor,
200 effect,
201 duration_ms,
202 } => {
203 check_monitor(monitor)?;
204 check_duration(duration_ms)?;
205 check_effect(effect)?;
206 Ok(())
207 }
208 }
209}
210
211fn effect_param_values(effect: &crate::effect::Effect) -> Vec<f32> {
212 use crate::effect::{Effect, Origin};
213
214 fn origin_values(origin: &Origin, values: &mut Vec<f32>) {
215 if let Origin::Custom(x, y) = origin {
216 values.push(*x);
217 values.push(*y);
218 }
219 }
220
221 let mut values = Vec::new();
222 match effect {
223 Effect::Fade(p) => values.extend([p.from, p.to]),
224 Effect::Wipe(p) => {
225 values.push(p.softness);
226 if let Some(angle) = p.angle {
227 values.push(angle);
228 }
229 }
230 Effect::Slide(_) => {}
231 Effect::Wave(p) => {
232 values.extend([p.frequency, p.amplitude]);
233 if let Some(angle) = p.angle {
234 values.push(angle);
235 }
236 origin_values(&p.origin, &mut values);
237 }
238 Effect::Grow(p) => origin_values(&p.origin, &mut values),
239 Effect::Outer(p) => origin_values(&p.origin, &mut values),
240 }
241 values
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 fn preview(path: &str) -> IpcCommand {
249 IpcCommand::Preview {
250 path: path.to_string(),
251 effect: None,
252 duration_ms: None,
253 fps: None,
254 no_theme: true,
255 theme_override: None,
256 monitor: None,
257 scaling_mode: None,
258 }
259 }
260
261 #[test]
262 fn accepts_well_formed_commands() {
263 assert!(validate_command(&preview("/tmp/wall.jpg")).is_ok());
264 assert!(validate_command(&IpcCommand::Status).is_ok());
265 assert!(
266 validate_command(&IpcCommand::Seek {
267 timestamp_ms: 1000,
268 monitor: Some("DP-1".to_string()),
269 })
270 .is_ok()
271 );
272 }
273
274 #[test]
275 fn rejects_empty_path_and_oversized_requests() {
276 assert!(validate_command(&preview("")).is_err());
277 assert!(validate_command(&preview(&"a".repeat(MAX_IPC_PATH_LEN + 1))).is_err());
278 assert!(
279 validate_command(&IpcCommand::Preview {
280 path: "/tmp/w.jpg".to_string(),
281 effect: None,
282 duration_ms: Some(MAX_DURATION_MS + 1),
283 fps: None,
284 no_theme: true,
285 theme_override: None,
286 monitor: None,
287 scaling_mode: None,
288 })
289 .is_err()
290 );
291 }
292
293 #[test]
294 fn rejects_bad_monitor_names() {
295 assert!(
296 validate_command(&IpcCommand::Pause {
297 monitor: Some("".to_string())
298 })
299 .is_err()
300 );
301 assert!(
302 validate_command(&IpcCommand::Pause {
303 monitor: Some("a".repeat(MAX_MONITOR_LEN + 1))
304 })
305 .is_err()
306 );
307 assert!(
308 validate_command(&IpcCommand::Pause {
309 monitor: Some("DP-1\n".to_string())
310 })
311 .is_err()
312 );
313 for name in ["DP-1", "HDMI-A-1", "eDP-1", "output-42"] {
315 assert!(
316 validate_command(&IpcCommand::Pause {
317 monitor: Some(name.to_string())
318 })
319 .is_ok(),
320 "should accept {name}"
321 );
322 }
323 }
324
325 #[test]
326 fn rejects_non_finite_effect_params() {
327 let effect = crate::effect::Effect::Fade(crate::effect::FadeParams {
328 from: f32::NAN,
329 to: 1.0,
330 easing: crate::effect::Easing::Linear,
331 });
332 assert!(
333 validate_command(&IpcCommand::Preview {
334 path: "/tmp/w.jpg".to_string(),
335 effect: Some(effect),
336 duration_ms: None,
337 fps: None,
338 no_theme: true,
339 theme_override: None,
340 monitor: None,
341 scaling_mode: None,
342 })
343 .is_err()
344 );
345 }
346
347 #[test]
348 fn rejects_non_finite_effect_origins() {
349 for origin in [
350 crate::effect::Origin::Custom(f32::NAN, 0.5),
351 crate::effect::Origin::Custom(0.5, f32::INFINITY),
352 ] {
353 let effect = crate::effect::Effect::Grow(crate::effect::GrowParams {
354 origin,
355 easing: crate::effect::Easing::Linear,
356 });
357 assert!(
358 validate_command(&IpcCommand::Preview {
359 path: "/tmp/w.jpg".to_string(),
360 effect: Some(effect),
361 duration_ms: None,
362 fps: None,
363 no_theme: true,
364 theme_override: None,
365 monitor: None,
366 scaling_mode: None,
367 })
368 .is_err()
369 );
370 }
371 }
372
373 #[test]
374 fn rejects_excessive_seek() {
375 assert!(
376 validate_command(&IpcCommand::Seek {
377 timestamp_ms: 25 * 3600 * 1000,
378 monitor: None,
379 })
380 .is_err()
381 );
382 }
383}