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
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(tag = "command", rename_all = "snake_case")]
16pub enum IpcCommand {
17    Pause {
18        #[serde(default)]
19        monitor: Option<String>,
20    },
21    Resume {
22        #[serde(default)]
23        monitor: Option<String>,
24    },
25    Reload,
26    Seek {
27        timestamp_ms: u64,
28        #[serde(default)]
29        monitor: Option<String>,
30    },
31    Preview {
32        path: String,
33        effect: Option<crate::effect::Effect>,
34        duration_ms: Option<u32>,
35        #[serde(default)]
36        no_theme: bool,
37        #[serde(default)]
38        theme_override: Option<crate::types::ThemeProvider>,
39        #[serde(default)]
40        monitor: Option<String>,
41        #[serde(default)]
42        scaling_mode: Option<crate::types::ScalingMode>,
43    },
44    Stop,
45    Status,
46    Info {
47        #[serde(default)]
48        monitor: Option<String>,
49    },
50    MonitorList,
51    MonitorCurrent,
52    Blank {
53        #[serde(default)]
54        monitor: Option<String>,
55        #[serde(default)]
56        effect: Option<crate::effect::Effect>,
57        #[serde(default)]
58        duration_ms: Option<u32>,
59    },
60    Restore {
61        #[serde(default)]
62        monitor: Option<String>,
63        #[serde(default)]
64        effect: Option<crate::effect::Effect>,
65        #[serde(default)]
66        duration_ms: Option<u32>,
67    },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct IpcResponse {
72    pub success: bool,
73    pub message: Option<String>,
74}
75
76impl IpcResponse {
77    pub fn ok() -> Self {
78        Self {
79            success: true,
80            message: None,
81        }
82    }
83
84    pub fn err(message: impl Into<String>) -> Self {
85        Self {
86            success: false,
87            message: Some(message.into()),
88        }
89    }
90}
91
92/// Validates an IPC command before the daemon acts on it. Returns `Ok(())`
93/// when the command is well-formed, or a human-readable reason when it must
94/// be rejected. This runs before any filesystem, GPU, or theme work.
95pub fn validate_command(cmd: &IpcCommand) -> Result<(), String> {
96    fn check_monitor(monitor: &Option<String>) -> Result<(), String> {
97        if let Some(name) = monitor {
98            if name.is_empty() {
99                return Err("monitor name cannot be empty".to_string());
100            }
101            if name.len() > MAX_MONITOR_LEN {
102                return Err(format!("monitor name exceeds {MAX_MONITOR_LEN} bytes"));
103            }
104            if name.bytes().any(|b| b < 0x20 || b == 0x7f) {
105                return Err("monitor name contains control characters".to_string());
106            }
107            if name.contains('\n') || name.contains('\0') {
108                return Err("monitor name contains invalid characters".to_string());
109            }
110        }
111        Ok(())
112    }
113
114    fn check_duration(duration_ms: &Option<u32>) -> Result<(), String> {
115        if let Some(ms) = duration_ms
116            && *ms > MAX_DURATION_MS
117        {
118            return Err(format!(
119                "duration {ms}ms exceeds maximum {MAX_DURATION_MS}ms"
120            ));
121        }
122        Ok(())
123    }
124
125    fn check_effect(effect: &Option<crate::effect::Effect>) -> Result<(), String> {
126        if let Some(effect) = effect {
127            for value in effect_param_values(effect) {
128                if !value.is_finite() {
129                    return Err("effect parameter must be finite".to_string());
130                }
131                if value.abs() > 1_000_000.0 {
132                    return Err("effect parameter out of range".to_string());
133                }
134            }
135        }
136        Ok(())
137    }
138
139    fn check_path(path: &str) -> Result<(), String> {
140        if path.is_empty() {
141            return Err("wallpaper path cannot be empty".to_string());
142        }
143        if path.len() > MAX_IPC_PATH_LEN {
144            return Err(format!("wallpaper path exceeds {MAX_IPC_PATH_LEN} bytes"));
145        }
146        if path.bytes().any(|b| b == 0) {
147            return Err("wallpaper path contains NUL".to_string());
148        }
149        Ok(())
150    }
151
152    match cmd {
153        IpcCommand::Pause { monitor } | IpcCommand::Resume { monitor } => check_monitor(monitor),
154        IpcCommand::Reload
155        | IpcCommand::Stop
156        | IpcCommand::Status
157        | IpcCommand::MonitorList
158        | IpcCommand::MonitorCurrent => Ok(()),
159        IpcCommand::Seek {
160            timestamp_ms,
161            monitor,
162        } => {
163            check_monitor(monitor)?;
164            if *timestamp_ms > 24 * 3600 * 1000 {
165                return Err("seek timestamp exceeds 24h".to_string());
166            }
167            Ok(())
168        }
169        IpcCommand::Preview {
170            path,
171            effect,
172            duration_ms,
173            monitor,
174            ..
175        } => {
176            check_path(path)?;
177            check_monitor(monitor)?;
178            check_duration(duration_ms)?;
179            check_effect(effect)?;
180            Ok(())
181        }
182        IpcCommand::Info { monitor } => check_monitor(monitor),
183        IpcCommand::Blank {
184            monitor,
185            effect,
186            duration_ms,
187        }
188        | IpcCommand::Restore {
189            monitor,
190            effect,
191            duration_ms,
192        } => {
193            check_monitor(monitor)?;
194            check_duration(duration_ms)?;
195            check_effect(effect)?;
196            Ok(())
197        }
198    }
199}
200
201fn effect_param_values(effect: &crate::effect::Effect) -> Vec<f32> {
202    use crate::effect::Effect;
203    match effect {
204        Effect::Fade(p) => vec![p.from, p.to],
205        Effect::Wipe(p) => vec![p.softness, p.angle.unwrap_or(0.0)],
206        Effect::Slide(_) => vec![],
207        Effect::Wave(p) => vec![p.frequency, p.amplitude, p.angle.unwrap_or(0.0)],
208        Effect::Grow(_) | Effect::Outer(_) => vec![],
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn preview(path: &str) -> IpcCommand {
217        IpcCommand::Preview {
218            path: path.to_string(),
219            effect: None,
220            duration_ms: None,
221            no_theme: true,
222            theme_override: None,
223            monitor: None,
224            scaling_mode: None,
225        }
226    }
227
228    #[test]
229    fn accepts_well_formed_commands() {
230        assert!(validate_command(&preview("/tmp/wall.jpg")).is_ok());
231        assert!(validate_command(&IpcCommand::Status).is_ok());
232        assert!(
233            validate_command(&IpcCommand::Seek {
234                timestamp_ms: 1000,
235                monitor: Some("DP-1".to_string()),
236            })
237            .is_ok()
238        );
239    }
240
241    #[test]
242    fn rejects_empty_path_and_oversized_requests() {
243        assert!(validate_command(&preview("")).is_err());
244        assert!(validate_command(&preview(&"a".repeat(MAX_IPC_PATH_LEN + 1))).is_err());
245        assert!(
246            validate_command(&IpcCommand::Preview {
247                path: "/tmp/w.jpg".to_string(),
248                effect: None,
249                duration_ms: Some(MAX_DURATION_MS + 1),
250                no_theme: true,
251                theme_override: None,
252                monitor: None,
253                scaling_mode: None,
254            })
255            .is_err()
256        );
257    }
258
259    #[test]
260    fn rejects_bad_monitor_names() {
261        assert!(
262            validate_command(&IpcCommand::Pause {
263                monitor: Some("".to_string())
264            })
265            .is_err()
266        );
267        assert!(
268            validate_command(&IpcCommand::Pause {
269                monitor: Some("a".repeat(MAX_MONITOR_LEN + 1))
270            })
271            .is_err()
272        );
273        assert!(
274            validate_command(&IpcCommand::Pause {
275                monitor: Some("DP-1\n".to_string())
276            })
277            .is_err()
278        );
279        // Normal output names are accepted.
280        for name in ["DP-1", "HDMI-A-1", "eDP-1", "output-42"] {
281            assert!(
282                validate_command(&IpcCommand::Pause {
283                    monitor: Some(name.to_string())
284                })
285                .is_ok(),
286                "should accept {name}"
287            );
288        }
289    }
290
291    #[test]
292    fn rejects_non_finite_effect_params() {
293        let effect = crate::effect::Effect::Fade(crate::effect::FadeParams {
294            from: f32::NAN,
295            to: 1.0,
296            easing: crate::effect::Easing::Linear,
297        });
298        assert!(
299            validate_command(&IpcCommand::Preview {
300                path: "/tmp/w.jpg".to_string(),
301                effect: Some(effect),
302                duration_ms: None,
303                no_theme: true,
304                theme_override: None,
305                monitor: None,
306                scaling_mode: None,
307            })
308            .is_err()
309        );
310    }
311
312    #[test]
313    fn rejects_excessive_seek() {
314        assert!(
315            validate_command(&IpcCommand::Seek {
316                timestamp_ms: 25 * 3600 * 1000,
317                monitor: None,
318            })
319            .is_err()
320        );
321    }
322}