Skip to main content

scarab_plugin_api/
navigation.rs

1//! Navigation API for plugins
2//!
3//! This module provides the navigation extension API that allows plugins to:
4//! - Enter and exit navigation modes (hint mode)
5//! - Register custom focusable regions in terminal content
6//! - Trigger navigation actions
7//!
8//! # Example
9//!
10//! ```ignore
11//! use scarab_plugin_api::navigation::{NavigationExt, PluginFocusable, PluginFocusableAction};
12//!
13//! fn my_plugin_hook(ctx: &PluginContext) -> Result<()> {
14//!     // Register a focusable URL in terminal content
15//!     ctx.register_focusable(PluginFocusable {
16//!         x: 10,
17//!         y: 5,
18//!         width: 20,
19//!         height: 1,
20//!         label: "GitHub".to_string(),
21//!         action: PluginFocusableAction::OpenUrl("https://github.com".to_string()),
22//!     })?;
23//!
24//!     // Enter hint mode programmatically
25//!     ctx.enter_hint_mode()?;
26//!
27//!     Ok(())
28//! }
29//! ```
30
31use thiserror::Error;
32
33use crate::error::Result;
34
35/// Security capabilities for plugin navigation APIs
36#[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/// Errors that can occur during navigation API validation
56#[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 &region.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
167/// Navigation extension trait for plugin contexts
168///
169/// Provides navigation-related operations that plugins can perform,
170/// such as entering/exiting navigation modes and registering focusable regions.
171///
172/// This trait is automatically implemented for `PluginContext` when the
173/// navigation feature is enabled.
174pub trait NavigationExt {
175    /// Enter hint mode to display navigation hints
176    ///
177    /// This triggers the hint mode UI, displaying labels for all focusable
178    /// elements in the terminal (URLs, file paths, registered regions, etc.).
179    ///
180    /// # Example
181    /// ```ignore
182    /// ctx.enter_hint_mode()?;
183    /// ```
184    fn enter_hint_mode(&self) -> Result<()>;
185
186    /// Exit navigation mode and return to normal mode
187    ///
188    /// Clears all hint labels and returns input handling to normal mode.
189    ///
190    /// # Example
191    /// ```ignore
192    /// ctx.exit_nav_mode()?;
193    /// ```
194    fn exit_nav_mode(&self) -> Result<()>;
195
196    /// Register a custom focusable region
197    ///
198    /// Allows plugins to register custom navigation targets that will appear
199    /// in hint mode alongside auto-detected URLs, file paths, etc.
200    ///
201    /// Returns a unique ID for this focusable that can be used to unregister it later.
202    ///
203    /// # Arguments
204    /// * `region` - The focusable region to register
205    ///
206    /// # Returns
207    /// Unique ID for this focusable region
208    ///
209    /// # Example
210    /// ```ignore
211    /// let id = ctx.register_focusable(PluginFocusable {
212    ///     x: 10,
213    ///     y: 5,
214    ///     width: 20,
215    ///     height: 1,
216    ///     label: "Click me".to_string(),
217    ///     action: PluginFocusableAction::Custom("my_action".to_string()),
218    /// })?;
219    /// ```
220    fn register_focusable(&self, region: PluginFocusable) -> Result<u64>;
221
222    /// Unregister a previously registered focusable region
223    ///
224    /// Removes a focusable region from the navigation system using its ID.
225    ///
226    /// # Arguments
227    /// * `id` - The ID returned from `register_focusable`
228    ///
229    /// # Example
230    /// ```ignore
231    /// ctx.unregister_focusable(focusable_id)?;
232    /// ```
233    fn unregister_focusable(&self, id: u64) -> Result<()>;
234}
235
236/// A plugin-registered focusable region
237///
238/// Represents a rectangular area in the terminal grid that can be
239/// focused and activated via hint mode. Plugins can register these
240/// to make custom UI elements or terminal content navigable.
241#[derive(Debug, Clone, PartialEq)]
242pub struct PluginFocusable {
243    /// Column position in terminal grid (0-based)
244    pub x: u16,
245
246    /// Row position in terminal grid (0-based)
247    pub y: u16,
248
249    /// Width in terminal cells
250    pub width: u16,
251
252    /// Height in terminal cells
253    pub height: u16,
254
255    /// Label to display for this focusable (used in hint mode)
256    pub label: String,
257
258    /// Action to perform when this focusable is activated
259    pub action: PluginFocusableAction,
260}
261
262/// Action to perform when a plugin focusable is activated
263///
264/// Defines what happens when a user activates a plugin-registered
265/// focusable region through hint mode.
266#[derive(Debug, Clone, PartialEq)]
267pub enum PluginFocusableAction {
268    /// Open a URL in the default browser
269    OpenUrl(String),
270
271    /// Open a file in the configured editor
272    OpenFile(String),
273
274    /// Custom plugin-defined action
275    ///
276    /// The plugin will receive a callback when this action is triggered,
277    /// allowing custom behavior to be implemented.
278    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}