1use thiserror::Error;
32
33use crate::error::Result;
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct PluginNavCapabilities {
38 pub can_enter_hint_mode: bool,
39 pub can_register_focusables: bool,
40 pub max_focusables: usize,
41 pub can_trigger_actions: bool,
42}
43
44impl Default for PluginNavCapabilities {
45 fn default() -> Self {
46 Self {
47 can_enter_hint_mode: true,
48 can_register_focusables: true,
49 max_focusables: 50,
50 can_trigger_actions: true,
51 }
52 }
53}
54
55#[derive(Error, Debug, Clone, PartialEq, Eq)]
57pub enum ValidationError {
58 #[error("Focusable coordinates out of bounds: x={x}, y={y} (max: {max})")]
59 CoordinatesOutOfBounds { x: u16, y: u16, max: u16 },
60 #[error("Invalid focusable dimensions: width={width}, height={height}")]
61 InvalidDimensions { width: u16, height: u16 },
62 #[error("Dangerous URL protocol detected: {protocol}")]
63 DangerousProtocol { protocol: String },
64 #[error("Malformed URL: {url}")]
65 MalformedUrl { url: String },
66 #[error("Dangerous file path pattern: {path}")]
67 DangerousPath { path: String },
68 #[error("Invalid label: {reason}")]
69 InvalidLabel { reason: String },
70}
71
72pub fn validate_focusable(region: &PluginFocusable) -> std::result::Result<(), ValidationError> {
73 const MAX_COORDINATE: u16 = 1000;
74 if region.x >= MAX_COORDINATE || region.y >= MAX_COORDINATE {
75 return Err(ValidationError::CoordinatesOutOfBounds {
76 x: region.x,
77 y: region.y,
78 max: MAX_COORDINATE,
79 });
80 }
81 if region.width == 0 || region.height == 0 {
82 return Err(ValidationError::InvalidDimensions {
83 width: region.width,
84 height: region.height,
85 });
86 }
87 if region.width > MAX_COORDINATE || region.height > MAX_COORDINATE {
88 return Err(ValidationError::InvalidDimensions {
89 width: region.width,
90 height: region.height,
91 });
92 }
93 if region.label.is_empty() {
94 return Err(ValidationError::InvalidLabel {
95 reason: "Label cannot be empty".to_string(),
96 });
97 }
98 if region.label.len() > 256 {
99 return Err(ValidationError::InvalidLabel {
100 reason: format!("Label too long: {} chars (max: 256)", region.label.len()),
101 });
102 }
103 match ®ion.action {
104 PluginFocusableAction::OpenUrl(url) => validate_url(url)?,
105 PluginFocusableAction::OpenFile(path) => validate_file_path(path)?,
106 PluginFocusableAction::Custom(_) => {}
107 }
108 Ok(())
109}
110
111fn validate_url(url: &str) -> std::result::Result<(), ValidationError> {
112 let url_lower = url.to_lowercase();
113 const DANGEROUS_PROTOCOLS: &[&str] = &["javascript:", "data:", "vbscript:", "about:", "blob:"];
114 for protocol in DANGEROUS_PROTOCOLS {
115 if url_lower.starts_with(protocol) {
116 return Err(ValidationError::DangerousProtocol {
117 protocol: protocol.to_string(),
118 });
119 }
120 }
121 if !url_lower.starts_with("http://")
122 && !url_lower.starts_with("https://")
123 && !url_lower.starts_with("file://")
124 {
125 return Err(ValidationError::MalformedUrl {
126 url: url.to_string(),
127 });
128 }
129 if url.len() < 10 {
130 return Err(ValidationError::MalformedUrl {
131 url: url.to_string(),
132 });
133 }
134 Ok(())
135}
136
137fn validate_file_path(path: &str) -> std::result::Result<(), ValidationError> {
138 if path.contains("..") {
139 return Err(ValidationError::DangerousPath {
140 path: path.to_string(),
141 });
142 }
143 if path.is_empty() {
144 return Err(ValidationError::DangerousPath {
145 path: "empty path".to_string(),
146 });
147 }
148 let path_lower = path.to_lowercase();
149 const SENSITIVE_PATTERNS: &[&str] = &[
150 "/etc/passwd",
151 "/etc/shadow",
152 "/proc/",
153 "/sys/",
154 "\\.ssh",
155 "/root/",
156 ];
157 for pattern in SENSITIVE_PATTERNS {
158 if path_lower.contains(pattern) {
159 return Err(ValidationError::DangerousPath {
160 path: path.to_string(),
161 });
162 }
163 }
164 Ok(())
165}
166
167pub trait NavigationExt {
175 fn enter_hint_mode(&self) -> Result<()>;
185
186 fn exit_nav_mode(&self) -> Result<()>;
195
196 fn register_focusable(&self, region: PluginFocusable) -> Result<u64>;
221
222 fn unregister_focusable(&self, id: u64) -> Result<()>;
234}
235
236#[derive(Debug, Clone, PartialEq)]
242pub struct PluginFocusable {
243 pub x: u16,
245
246 pub y: u16,
248
249 pub width: u16,
251
252 pub height: u16,
254
255 pub label: String,
257
258 pub action: PluginFocusableAction,
260}
261
262#[derive(Debug, Clone, PartialEq)]
267pub enum PluginFocusableAction {
268 OpenUrl(String),
270
271 OpenFile(String),
273
274 Custom(String),
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_plugin_focusable_creation() {
287 let focusable = PluginFocusable {
288 x: 10,
289 y: 5,
290 width: 20,
291 height: 1,
292 label: "Test".to_string(),
293 action: PluginFocusableAction::OpenUrl("https://example.com".to_string()),
294 };
295
296 assert_eq!(focusable.x, 10);
297 assert_eq!(focusable.y, 5);
298 assert_eq!(focusable.width, 20);
299 assert_eq!(focusable.height, 1);
300 assert_eq!(focusable.label, "Test");
301 }
302
303 #[test]
304 fn test_focusable_action_equality() {
305 let action1 = PluginFocusableAction::OpenUrl("https://example.com".to_string());
306 let action2 = PluginFocusableAction::OpenUrl("https://example.com".to_string());
307 let action3 = PluginFocusableAction::OpenFile("/path/to/file".to_string());
308
309 assert_eq!(action1, action2);
310 assert_ne!(action1, action3);
311 }
312
313 #[test]
314 fn test_focusable_clone() {
315 let focusable = PluginFocusable {
316 x: 10,
317 y: 5,
318 width: 20,
319 height: 1,
320 label: "Test".to_string(),
321 action: PluginFocusableAction::Custom("my_action".to_string()),
322 };
323
324 let cloned = focusable.clone();
325 assert_eq!(focusable, cloned);
326 }
327
328 #[test]
329 fn test_validate_focusable_valid() {
330 let focusable = PluginFocusable {
331 x: 10,
332 y: 5,
333 width: 20,
334 height: 1,
335 label: "GitHub".to_string(),
336 action: PluginFocusableAction::OpenUrl("https://github.com".to_string()),
337 };
338 assert!(validate_focusable(&focusable).is_ok());
339 }
340
341 #[test]
342 fn test_validate_focusable_out_of_bounds() {
343 let focusable = PluginFocusable {
344 x: 1000,
345 y: 5,
346 width: 20,
347 height: 1,
348 label: "Test".to_string(),
349 action: PluginFocusableAction::OpenUrl("https://example.com".to_string()),
350 };
351 assert!(matches!(
352 validate_focusable(&focusable).unwrap_err(),
353 ValidationError::CoordinatesOutOfBounds { .. }
354 ));
355 }
356
357 #[test]
358 fn test_validate_url_dangerous_protocol() {
359 assert!(validate_url("javascript:alert('xss')").is_err());
360 assert!(validate_url("data:text/html,<script>alert('xss')</script>").is_err());
361 }
362
363 #[test]
364 fn test_validate_file_path_traversal() {
365 assert!(validate_file_path("../../../etc/passwd").is_err());
366 }
367}