Skip to main content

wallr_common/
ipc.rs

1use serde::{Deserialize, Serialize};
2
3/// Maximum IPC request size. Prevents unbounded allocations from malformed
4/// or malicious clients. 64 KiB is far above any legitimate command
5/// (a path + effect params is typically < 2 KiB).
6pub const MAX_IPC_BYTES: usize = 64 * 1024;
7/// Maximum wallpaper path length accepted over IPC.
8pub const MAX_IPC_PATH_LEN: usize = 8192;
9/// Maximum monitor name length accepted over IPC.
10pub const MAX_MONITOR_LEN: usize = 256;
11/// Maximum transition duration accepted over IPC (60s).
12pub const MAX_DURATION_MS: u32 = 60_000;
13/// Maximum requested video presentation rate.
14pub 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
96/// Validates an IPC command before the daemon acts on it. Returns `Ok(())`
97/// when the command is well-formed, or a human-readable reason when it must
98/// be rejected. This runs before any filesystem, GPU, or theme work.
99pub 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        // Normal output names are accepted.
314        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}