Skip to main content

raps_cli/
plugins.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Plugin and Extension System
5//!
6//! Provides a mechanism for extending RAPS CLI with external commands and hooks.
7//!
8//! # Plugin Types
9//!
10//! 1. **External Command Plugins**: Executables named `raps-<name>` in PATH
11//! 2. **Workflow Hooks**: Pre/post command hooks for automation
12//! 3. **Custom Command Groups**: User-defined command aliases and groups
13//!
14//! # Plugin Discovery
15//!
16//! External plugins are discovered by searching PATH for executables matching:
17//! - Windows: `raps-<name>.exe`
18//! - Unix: `raps-<name>`
19//!
20//! # Configuration
21//!
22//! Plugins are configured in `~/.config/raps/plugins.json`:
23//! ```json
24//! {
25//!   "plugins": {
26//!     "my-plugin": {
27//!       "enabled": true,
28//!       "path": "/path/to/raps-my-plugin"
29//!     }
30//!   },
31//!   "hooks": {
32//!     "pre_upload": ["echo 'Starting upload'"],
33//!     "post_translate": ["notify-send 'Translation complete'"]
34//!   },
35//!   "aliases": {
36//!     "quick-upload": "object upload --resume"
37//!   }
38//! }
39//! ```
40
41use anyhow::{Context, Result};
42use ed25519_dalek::{Signature, VerifyingKey};
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45use std::collections::HashMap;
46use std::path::{Path, PathBuf};
47use std::process::Command;
48
49/// Plugin configuration
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51pub struct PluginConfig {
52    /// Discovered and configured plugins
53    #[serde(default)]
54    pub plugins: HashMap<String, PluginEntry>,
55    /// Workflow hooks
56    #[serde(default)]
57    pub hooks: HashMap<String, Vec<String>>,
58    /// Command aliases
59    #[serde(default)]
60    pub aliases: HashMap<String, String>,
61}
62
63/// Individual plugin entry
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PluginEntry {
66    /// Whether the plugin is enabled
67    #[serde(default = "default_true")]
68    pub enabled: bool,
69    /// Path to the plugin executable (optional, auto-discovered if not set)
70    pub path: Option<String>,
71    /// Plugin description
72    pub description: Option<String>,
73    /// SHA-256 hash of the plugin binary (TOFU — trust on first use)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub sha256: Option<String>,
76    /// Ed25519 public key (hex-encoded) for signature verification
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub public_key: Option<String>,
79    /// Ed25519 signature (hex-encoded) of the plugin binary
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub signature: Option<String>,
82    /// Whether the user has explicitly trusted this plugin
83    #[serde(default)]
84    pub trusted: bool,
85}
86
87fn default_true() -> bool {
88    true
89}
90
91/// Discovered plugin information
92#[derive(Debug, Clone)]
93#[allow(dead_code)]
94pub struct DiscoveredPlugin {
95    pub name: String,
96    pub path: PathBuf,
97    pub enabled: bool,
98}
99
100#[allow(dead_code)]
101impl PluginConfig {
102    /// Load plugin configuration from file
103    pub fn load() -> Result<Self> {
104        let path = Self::config_path()?;
105        if !path.exists() {
106            return Ok(Self::default());
107        }
108        let content = std::fs::read_to_string(&path).context("Failed to read plugin config")?;
109        let config: Self =
110            serde_json::from_str(&content).context("Failed to parse plugin config")?;
111        Ok(config)
112    }
113
114    /// Save plugin configuration to file
115    pub fn save(&self) -> Result<()> {
116        let path = Self::config_path()?;
117        if let Some(parent) = path.parent() {
118            std::fs::create_dir_all(parent)?;
119        }
120        let content = serde_json::to_string_pretty(self)?;
121        std::fs::write(&path, content)?;
122        Ok(())
123    }
124
125    /// Get the config file path
126    fn config_path() -> Result<PathBuf> {
127        let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
128            .context("Failed to get project directories")?;
129        Ok(proj_dirs.config_dir().join("plugins.json"))
130    }
131
132    /// Get an alias command if defined
133    pub fn get_alias(&self, name: &str) -> Option<&str> {
134        self.aliases.get(name).map(|s| s.as_str())
135    }
136}
137
138/// Result of verifying a plugin's integrity
139#[derive(Debug)]
140pub struct PluginVerifyResult {
141    pub name: String,
142    pub path: PathBuf,
143    pub current_hash: String,
144    pub recorded_hash: Option<String>,
145    pub hash_match: bool,
146    pub has_signature: bool,
147    pub signature_valid: bool,
148    pub trusted: bool,
149}
150
151/// Compute SHA-256 hash of a file
152pub fn compute_binary_hash(path: &Path) -> Result<String> {
153    let data = std::fs::read(path)
154        .with_context(|| format!("Failed to read plugin binary: {}", path.display()))?;
155    let hash = Sha256::digest(&data);
156    Ok(hex::encode(hash))
157}
158
159/// Verify ed25519 signature of a plugin binary
160fn verify_ed25519_signature(path: &Path, sig_hex: &str, pubkey_hex: &str) -> Result<()> {
161    let data = std::fs::read(path)
162        .with_context(|| format!("Failed to read plugin binary: {}", path.display()))?;
163
164    let pubkey_bytes = hex::decode(pubkey_hex).context("Invalid public key hex")?;
165    let pubkey_array: [u8; 32] = pubkey_bytes
166        .try_into()
167        .map_err(|_| anyhow::anyhow!("Public key must be 32 bytes"))?;
168    let verifying_key =
169        VerifyingKey::from_bytes(&pubkey_array).context("Invalid ed25519 public key")?;
170
171    let sig_bytes = hex::decode(sig_hex).context("Invalid signature hex")?;
172    let sig_array: [u8; 64] = sig_bytes
173        .try_into()
174        .map_err(|_| anyhow::anyhow!("Signature must be 64 bytes"))?;
175    let signature = Signature::from_bytes(&sig_array);
176
177    verifying_key
178        .verify_strict(&data, &signature)
179        .context("Ed25519 signature verification failed")?;
180
181    Ok(())
182}
183
184/// Plugin manager for discovering and executing plugins
185#[allow(dead_code)]
186pub struct PluginManager {
187    config: PluginConfig,
188}
189
190#[allow(dead_code)]
191impl PluginManager {
192    /// Create a new plugin manager
193    pub fn new() -> Result<Self> {
194        let config = PluginConfig::load().unwrap_or_default();
195        Ok(Self { config })
196    }
197
198    /// Discover plugins in PATH
199    pub fn discover_plugins(&self) -> Vec<DiscoveredPlugin> {
200        let start = std::time::Instant::now();
201        let mut plugins = Vec::new();
202
203        // Get PATH environment variable
204        let mut paths: Vec<String> = if let Ok(path_var) = std::env::var("PATH") {
205            if cfg!(windows) {
206                path_var.split(';').map(|s| s.to_string()).collect()
207            } else {
208                path_var.split(':').map(|s| s.to_string()).collect()
209            }
210        } else {
211            Vec::new()
212        };
213
214        // Also check the directory where the current executable is located
215        if let Ok(exe_path) = std::env::current_exe()
216            && let Some(parent) = exe_path.parent()
217        {
218            paths.push(parent.to_string_lossy().to_string());
219        }
220
221        for dir in paths {
222            if let Ok(entries) = std::fs::read_dir(dir) {
223                for entry in entries.flatten() {
224                    if let Some(plugin) = self.check_plugin_entry(&entry.path()) {
225                        // Avoid duplicates
226                        if !plugins
227                            .iter()
228                            .any(|p: &DiscoveredPlugin| p.name == plugin.name)
229                        {
230                            plugins.push(plugin);
231                        }
232                    }
233                }
234            }
235        }
236
237        raps_kernel::profiler::mark_plugins_loaded(start.elapsed());
238        plugins
239    }
240
241    /// Check if a path is a raps plugin
242    fn check_plugin_entry(&self, path: &Path) -> Option<DiscoveredPlugin> {
243        let file_name = path.file_name()?.to_str()?;
244
245        // Check for raps-* pattern
246        let plugin_name = if cfg!(windows) {
247            if file_name.starts_with("raps-") && file_name.ends_with(".exe") {
248                Some(file_name.strip_prefix("raps-")?.strip_suffix(".exe")?)
249            } else {
250                None
251            }
252        } else {
253            file_name.strip_prefix("raps-")
254        }?;
255
256        // Check if enabled in config
257        let enabled = self
258            .config
259            .plugins
260            .get(plugin_name)
261            .map(|e| e.enabled)
262            .unwrap_or(true);
263
264        Some(DiscoveredPlugin {
265            name: plugin_name.to_string(),
266            path: path.to_path_buf(),
267            enabled,
268        })
269    }
270
271    /// Execute a plugin by name
272    pub fn execute_plugin(&self, name: &str, args: &[&str]) -> Result<i32> {
273        // Check configured plugins first
274        if let Some(entry) = self.config.plugins.get(name) {
275            if !entry.enabled {
276                anyhow::bail!("Plugin '{name}' is disabled");
277            }
278            if let Some(ref path) = entry.path {
279                return self.run_plugin(path, name, args);
280            }
281        }
282
283        // Try to find in discovered plugins
284        let discovered = self.discover_plugins();
285        if let Some(plugin) = discovered.iter().find(|p| p.name == name) {
286            return self.run_plugin(&plugin.path.to_string_lossy(), name, args);
287        }
288
289        anyhow::bail!("Plugin '{name}' not found")
290    }
291
292    /// Run a plugin executable after verifying integrity
293    fn run_plugin(&self, path: &str, name: &str, args: &[&str]) -> Result<i32> {
294        self.verify_plugin_integrity(name, Path::new(path))?;
295
296        let output = Command::new(path)
297            .args(args)
298            .status()
299            .with_context(|| format!("Failed to execute plugin: {}", path))?;
300
301        Ok(output.code().unwrap_or(-1))
302    }
303
304    /// Verify plugin integrity via TOFU hash and optional ed25519 signature.
305    ///
306    /// - First run: compute hash, record it, warn user
307    /// - Subsequent runs: verify hash matches recorded value
308    /// - If signature + public_key present: verify ed25519 signature
309    fn verify_plugin_integrity(&self, name: &str, path: &Path) -> Result<()> {
310        let current_hash = compute_binary_hash(path)?;
311
312        if let Some(entry) = self.config.plugins.get(name) {
313            // Check ed25519 signature if present
314            if let (Some(sig_hex), Some(key_hex)) = (&entry.signature, &entry.public_key) {
315                verify_ed25519_signature(path, sig_hex, key_hex).with_context(|| {
316                    format!("Plugin '{name}' signature verification failed — binary may have been tampered with")
317                })?;
318                return Ok(());
319            }
320
321            // Check TOFU hash
322            if let Some(ref recorded_hash) = entry.sha256 {
323                if *recorded_hash != current_hash {
324                    anyhow::bail!(
325                        "Plugin '{name}' binary has changed since it was trusted!\n\
326                         Recorded: {recorded_hash}\n\
327                         Current:  {current_hash}\n\
328                         Run `raps plugin trust {name}` to re-trust the new version."
329                    );
330                }
331                return Ok(());
332            }
333        }
334
335        // First execution — record hash (TOFU)
336        tracing::warn!(
337            plugin = name,
338            hash = %current_hash,
339            "First execution of plugin '{}' — recording SHA-256 hash (TOFU)",
340            name
341        );
342        eprintln!(
343            "Warning: First execution of plugin '{}'. SHA-256: {}",
344            name, current_hash
345        );
346
347        let mut config = PluginConfig::load().unwrap_or_default();
348        let entry = config
349            .plugins
350            .entry(name.to_string())
351            .or_insert(PluginEntry {
352                enabled: true,
353                path: Some(path.to_string_lossy().to_string()),
354                description: None,
355                sha256: None,
356                public_key: None,
357                signature: None,
358                trusted: false,
359            });
360        entry.sha256 = Some(current_hash);
361        entry.trusted = true;
362        let _ = config.save();
363
364        Ok(())
365    }
366
367    /// Trust a plugin by recording its current hash
368    pub fn trust_plugin(&self, name: &str) -> Result<String> {
369        // Find plugin path
370        let path = self.resolve_plugin_path(name)?;
371        let hash = compute_binary_hash(&path)?;
372
373        let mut config = PluginConfig::load().unwrap_or_default();
374        let entry = config
375            .plugins
376            .entry(name.to_string())
377            .or_insert(PluginEntry {
378                enabled: true,
379                path: Some(path.to_string_lossy().to_string()),
380                description: None,
381                sha256: None,
382                public_key: None,
383                signature: None,
384                trusted: false,
385            });
386        entry.sha256 = Some(hash.clone());
387        entry.trusted = true;
388        config.save()?;
389
390        Ok(hash)
391    }
392
393    /// Verify a plugin's integrity and return status
394    pub fn verify_plugin(&self, name: &str) -> Result<PluginVerifyResult> {
395        let path = self.resolve_plugin_path(name)?;
396        let current_hash = compute_binary_hash(&path)?;
397
398        if let Some(entry) = self.config.plugins.get(name) {
399            let hash_match = entry.sha256.as_ref() == Some(&current_hash);
400
401            // Check signature
402            if let (Some(sig_hex), Some(key_hex)) = (&entry.signature, &entry.public_key) {
403                let sig_ok = verify_ed25519_signature(&path, sig_hex, key_hex).is_ok();
404                return Ok(PluginVerifyResult {
405                    name: name.to_string(),
406                    path,
407                    current_hash,
408                    recorded_hash: entry.sha256.clone(),
409                    hash_match,
410                    has_signature: true,
411                    signature_valid: sig_ok,
412                    trusted: entry.trusted,
413                });
414            }
415
416            // Check TOFU hash
417            return Ok(PluginVerifyResult {
418                name: name.to_string(),
419                path,
420                current_hash,
421                recorded_hash: entry.sha256.clone(),
422                hash_match,
423                has_signature: false,
424                signature_valid: false,
425                trusted: entry.trusted,
426            });
427        }
428
429        Ok(PluginVerifyResult {
430            name: name.to_string(),
431            path,
432            current_hash,
433            recorded_hash: None,
434            hash_match: false,
435            has_signature: false,
436            signature_valid: false,
437            trusted: false,
438        })
439    }
440
441    /// Resolve a plugin name to its filesystem path
442    fn resolve_plugin_path(&self, name: &str) -> Result<PathBuf> {
443        if let Some(entry) = self.config.plugins.get(name)
444            && let Some(ref path) = entry.path
445        {
446            return Ok(PathBuf::from(path));
447        }
448        let discovered = self.discover_plugins();
449        discovered
450            .iter()
451            .find(|p| p.name == name)
452            .map(|p| p.path.clone())
453            .ok_or_else(|| anyhow::anyhow!("Plugin '{name}' not found"))
454    }
455
456    /// Run pre-command hooks
457    pub fn run_pre_hooks(&self, command: &str) -> Result<()> {
458        let hook_key = format!("pre_{}", command);
459        self.run_hooks(&hook_key)
460    }
461
462    /// Run post-command hooks
463    pub fn run_post_hooks(&self, command: &str) -> Result<()> {
464        let hook_key = format!("post_{}", command);
465        self.run_hooks(&hook_key)
466    }
467
468    /// Run hooks for a given key
469    fn run_hooks(&self, key: &str) -> Result<()> {
470        if let Some(hooks) = self.config.hooks.get(key) {
471            for hook_cmd in hooks {
472                // Parse the command to prevent shell injection
473                let parsed = self.parse_hook_command(hook_cmd)?;
474                if parsed.is_empty() {
475                    continue;
476                }
477
478                let mut cmd = Command::new(&parsed[0]);
479                if parsed.len() > 1 {
480                    cmd.args(&parsed[1..]);
481                }
482
483                match cmd.status() {
484                    Ok(s) if !s.success() => {
485                        tracing::warn!("Hook '{}' failed with exit code {:?}", hook_cmd, s.code());
486                    }
487                    Err(e) => {
488                        tracing::warn!("Hook '{}' failed to execute: {}", hook_cmd, e);
489                    }
490                    _ => {}
491                }
492            }
493        }
494        Ok(())
495    }
496
497    /// Parse a hook command safely, preventing shell injection
498    fn parse_hook_command(&self, cmd: &str) -> Result<Vec<String>> {
499        // Simple command parsing without shell interpretation
500        // This prevents command injection by not allowing shell metacharacters
501        let mut args = Vec::new();
502        let mut current = String::new();
503        let mut in_quotes = false;
504        let mut escape_next = false;
505
506        for ch in cmd.chars() {
507            if escape_next {
508                current.push(ch);
509                escape_next = false;
510            } else if ch == '\\' && in_quotes {
511                escape_next = true;
512            } else if ch == '"' {
513                in_quotes = !in_quotes;
514            } else if ch.is_whitespace() && !in_quotes {
515                if !current.is_empty() {
516                    args.push(current.clone());
517                    current.clear();
518                }
519            } else {
520                current.push(ch);
521            }
522        }
523
524        if !current.is_empty() {
525            args.push(current);
526        }
527
528        if in_quotes {
529            anyhow::bail!("Unclosed quote in hook command: {cmd}");
530        }
531
532        // Validate that the command is allowed (whitelist approach)
533        if !args.is_empty() {
534            self.validate_hook_command(&args[0])?;
535        }
536
537        Ok(args)
538    }
539
540    /// Validate that a hook command is allowed
541    fn validate_hook_command(&self, command: &str) -> Result<()> {
542        // Define allowed commands - this should be configurable
543        const ALLOWED_COMMANDS: &[&str] = &[
544            "echo",
545            "notify-send",
546            "curl",
547            "wget",
548            "git",
549            "npm",
550            "cargo",
551            "python",
552            "node",
553            "raps",
554        ];
555
556        // Check if command is in the allowed list or is a raps plugin
557        let cmd_name = Path::new(command)
558            .file_name()
559            .and_then(|n| n.to_str())
560            .unwrap_or(command);
561
562        if ALLOWED_COMMANDS.contains(&cmd_name) || cmd_name.starts_with("raps-") {
563            Ok(())
564        } else if command.contains('/') || command.contains('\\') {
565            // Allow absolute paths but warn about them
566            tracing::warn!(
567                "Hook uses absolute path '{}'. Consider adding to the allowed commands list.",
568                command
569            );
570            Ok(())
571        } else {
572            anyhow::bail!(
573                "Command '{}' is not in the allowed list. Add it to ALLOWED_COMMANDS if needed.",
574                command
575            )
576        }
577    }
578
579    /// List all discovered and configured plugins
580    pub fn list_plugins(&self) -> Vec<DiscoveredPlugin> {
581        let mut all_plugins = self.discover_plugins();
582
583        // Add configured plugins that weren't discovered
584        for (name, entry) in &self.config.plugins {
585            if !all_plugins.iter().any(|p| &p.name == name)
586                && let Some(ref path) = entry.path
587            {
588                all_plugins.push(DiscoveredPlugin {
589                    name: name.clone(),
590                    path: PathBuf::from(path),
591                    enabled: entry.enabled,
592                });
593            }
594        }
595
596        all_plugins
597    }
598}
599
600impl Default for PluginManager {
601    fn default() -> Self {
602        Self::new().unwrap_or_else(|_| Self {
603            config: PluginConfig::default(),
604        })
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn test_plugin_config_default() {
614        let config = PluginConfig::default();
615        assert!(config.plugins.is_empty());
616        assert!(config.hooks.is_empty());
617        assert!(config.aliases.is_empty());
618    }
619
620    #[test]
621    fn test_plugin_entry_default_enabled() {
622        let json = r#"{"path": "/usr/bin/raps-test"}"#;
623        let entry: PluginEntry = serde_json::from_str(json).unwrap();
624        assert!(entry.enabled); // default_true()
625        assert!(entry.sha256.is_none());
626        assert!(entry.public_key.is_none());
627        assert!(entry.signature.is_none());
628        assert!(!entry.trusted);
629    }
630
631    #[test]
632    fn test_plugin_config_serialization() {
633        let mut config = PluginConfig::default();
634        config
635            .aliases
636            .insert("up".to_string(), "object upload".to_string());
637        config.hooks.insert(
638            "pre_upload".to_string(),
639            vec!["echo 'starting'".to_string()],
640        );
641
642        let json = serde_json::to_string(&config).unwrap();
643        let parsed: PluginConfig = serde_json::from_str(&json).unwrap();
644
645        assert_eq!(parsed.aliases.get("up"), Some(&"object upload".to_string()));
646        assert_eq!(parsed.hooks.get("pre_upload").unwrap().len(), 1);
647    }
648
649    #[test]
650    fn test_get_alias() {
651        let mut config = PluginConfig::default();
652        config
653            .aliases
654            .insert("quick-up".to_string(), "object upload --resume".to_string());
655
656        assert_eq!(config.get_alias("quick-up"), Some("object upload --resume"));
657        assert_eq!(config.get_alias("nonexistent"), None);
658    }
659
660    #[test]
661    fn test_discovered_plugin_struct() {
662        let plugin = DiscoveredPlugin {
663            name: "test-plugin".to_string(),
664            path: PathBuf::from("/usr/bin/raps-test-plugin"),
665            enabled: true,
666        };
667
668        assert_eq!(plugin.name, "test-plugin");
669        assert!(plugin.enabled);
670    }
671
672    #[test]
673    fn test_plugin_manager_default() {
674        let manager = PluginManager {
675            config: PluginConfig::default(),
676        };
677        // Should not panic
678        assert!(manager.config.plugins.is_empty());
679    }
680
681    #[test]
682    fn test_parse_hook_command_basic() {
683        let manager = PluginManager::default();
684
685        // Test basic command parsing
686        let result = manager.parse_hook_command("echo hello").unwrap();
687        assert_eq!(result, vec!["echo", "hello"]);
688    }
689
690    #[test]
691    fn test_parse_hook_command_with_quotes() {
692        let manager = PluginManager::default();
693
694        // Test quoted arguments
695        let result = manager.parse_hook_command("echo \"hello world\"").unwrap();
696        assert_eq!(result, vec!["echo", "hello world"]);
697
698        // Test mixed quotes
699        let result = manager
700            .parse_hook_command("notify-send \"Build Complete\" success")
701            .unwrap();
702        assert_eq!(result, vec!["notify-send", "Build Complete", "success"]);
703    }
704
705    #[test]
706    fn test_parse_hook_command_unclosed_quote() {
707        let manager = PluginManager::default();
708
709        // Test unclosed quote error
710        let result = manager.parse_hook_command("echo \"unclosed quote");
711        assert!(result.is_err());
712        assert!(result.unwrap_err().to_string().contains("Unclosed quote"));
713    }
714
715    #[test]
716    fn test_validate_hook_command_allowed() {
717        let manager = PluginManager::default();
718
719        // Test allowed commands
720        assert!(manager.validate_hook_command("echo").is_ok());
721        assert!(manager.validate_hook_command("curl").is_ok());
722        assert!(manager.validate_hook_command("git").is_ok());
723        assert!(manager.validate_hook_command("raps").is_ok());
724        assert!(manager.validate_hook_command("raps-plugin").is_ok()); // raps- prefix
725    }
726
727    #[test]
728    fn test_validate_hook_command_denied() {
729        let manager = PluginManager::default();
730
731        // Test denied commands
732        let result = manager.validate_hook_command("rm");
733        assert!(result.is_err());
734        assert!(
735            result
736                .unwrap_err()
737                .to_string()
738                .contains("not in the allowed list")
739        );
740
741        let result = manager.validate_hook_command("sudo");
742        assert!(result.is_err());
743
744        let result = manager.validate_hook_command("sh");
745        assert!(result.is_err());
746    }
747
748    #[test]
749    fn test_validate_hook_command_absolute_path() {
750        let manager = PluginManager::default();
751
752        // Test absolute paths (should be allowed with warning)
753        assert!(manager.validate_hook_command("/usr/bin/echo").is_ok());
754        assert!(
755            manager
756                .validate_hook_command("C:\\Windows\\System32\\cmd.exe")
757                .is_ok()
758        );
759    }
760
761    #[test]
762    fn test_parse_hook_command_empty() {
763        let manager = PluginManager::default();
764
765        // Test empty command
766        let result = manager.parse_hook_command("").unwrap();
767        assert!(result.is_empty());
768
769        // Test whitespace only
770        let result = manager.parse_hook_command("   ").unwrap();
771        assert!(result.is_empty());
772    }
773
774    #[test]
775    fn test_compute_binary_hash() {
776        let tmp = tempfile::NamedTempFile::new().unwrap();
777        std::fs::write(tmp.path(), b"hello world").unwrap();
778        let hash = compute_binary_hash(tmp.path()).unwrap();
779        // SHA-256 of "hello world"
780        assert_eq!(
781            hash,
782            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
783        );
784    }
785
786    #[test]
787    fn test_compute_binary_hash_changes_on_modification() {
788        let tmp = tempfile::NamedTempFile::new().unwrap();
789        std::fs::write(tmp.path(), b"version 1").unwrap();
790        let hash1 = compute_binary_hash(tmp.path()).unwrap();
791
792        std::fs::write(tmp.path(), b"version 2").unwrap();
793        let hash2 = compute_binary_hash(tmp.path()).unwrap();
794
795        assert_ne!(hash1, hash2);
796    }
797
798    #[test]
799    fn test_ed25519_signature_verification() {
800        use ed25519_dalek::{Signer, SigningKey};
801        use rand::rngs::OsRng;
802
803        let tmp = tempfile::NamedTempFile::new().unwrap();
804        std::fs::write(tmp.path(), b"plugin binary content").unwrap();
805
806        // Generate a keypair
807        let signing_key = SigningKey::generate(&mut OsRng);
808        let verifying_key = signing_key.verifying_key();
809
810        // Sign the binary
811        let data = std::fs::read(tmp.path()).unwrap();
812        let signature: ed25519_dalek::Signature = signing_key.sign(&data);
813
814        let sig_hex = hex::encode(signature.to_bytes());
815        let key_hex = hex::encode(verifying_key.to_bytes());
816
817        // Verification should succeed
818        assert!(verify_ed25519_signature(tmp.path(), &sig_hex, &key_hex).is_ok());
819
820        // Tamper with the file
821        std::fs::write(tmp.path(), b"tampered content").unwrap();
822        assert!(verify_ed25519_signature(tmp.path(), &sig_hex, &key_hex).is_err());
823    }
824
825    #[test]
826    fn test_ed25519_invalid_signature_fails() {
827        let tmp = tempfile::NamedTempFile::new().unwrap();
828        std::fs::write(tmp.path(), b"plugin binary").unwrap();
829
830        // Valid key format but wrong signature
831        let fake_key = "0".repeat(64); // 32 zero bytes in hex
832        let fake_sig = "0".repeat(128); // 64 zero bytes in hex
833
834        // Should fail verification (bad key/signature)
835        assert!(verify_ed25519_signature(tmp.path(), &fake_sig, &fake_key).is_err());
836    }
837
838    #[test]
839    fn test_plugin_entry_with_trust_fields() {
840        let json = r#"{
841            "enabled": true,
842            "path": "/usr/bin/raps-test",
843            "sha256": "abc123",
844            "trusted": true,
845            "public_key": "deadbeef",
846            "signature": "cafebabe"
847        }"#;
848        let entry: PluginEntry = serde_json::from_str(json).unwrap();
849        assert_eq!(entry.sha256.as_deref(), Some("abc123"));
850        assert!(entry.trusted);
851        assert_eq!(entry.public_key.as_deref(), Some("deadbeef"));
852        assert_eq!(entry.signature.as_deref(), Some("cafebabe"));
853    }
854
855    #[test]
856    fn test_plugin_entry_trust_fields_optional() {
857        // Old-format JSON without trust fields should still parse
858        let json = r#"{"enabled": true}"#;
859        let entry: PluginEntry = serde_json::from_str(json).unwrap();
860        assert!(entry.sha256.is_none());
861        assert!(!entry.trusted);
862    }
863
864    #[test]
865    fn test_parse_hook_command_complex() {
866        let manager = PluginManager::default();
867
868        // Test complex command with multiple quoted sections
869        let result = manager
870            .parse_hook_command("raps object upload \"my file.txt\" --bucket \"test bucket\"")
871            .unwrap();
872        assert_eq!(
873            result,
874            vec![
875                "raps",
876                "object",
877                "upload",
878                "my file.txt",
879                "--bucket",
880                "test bucket"
881            ]
882        );
883    }
884}