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;
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
92pub 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 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}