1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51pub struct PluginConfig {
52 #[serde(default)]
54 pub plugins: HashMap<String, PluginEntry>,
55 #[serde(default)]
57 pub hooks: HashMap<String, Vec<String>>,
58 #[serde(default)]
60 pub aliases: HashMap<String, String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PluginEntry {
66 #[serde(default = "default_true")]
68 pub enabled: bool,
69 pub path: Option<String>,
71 pub description: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub sha256: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub public_key: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub signature: Option<String>,
82 #[serde(default)]
84 pub trusted: bool,
85}
86
87fn default_true() -> bool {
88 true
89}
90
91#[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 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 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 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 pub fn get_alias(&self, name: &str) -> Option<&str> {
134 self.aliases.get(name).map(|s| s.as_str())
135 }
136}
137
138#[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
151pub 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
159fn 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#[allow(dead_code)]
186pub struct PluginManager {
187 config: PluginConfig,
188}
189
190#[allow(dead_code)]
191impl PluginManager {
192 pub fn new() -> Result<Self> {
194 let config = PluginConfig::load().unwrap_or_default();
195 Ok(Self { config })
196 }
197
198 pub fn discover_plugins(&self) -> Vec<DiscoveredPlugin> {
200 let start = std::time::Instant::now();
201 let mut plugins = Vec::new();
202
203 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 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 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 fn check_plugin_entry(&self, path: &Path) -> Option<DiscoveredPlugin> {
243 let file_name = path.file_name()?.to_str()?;
244
245 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 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 pub fn execute_plugin(&self, name: &str, args: &[&str]) -> Result<i32> {
273 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 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 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 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 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 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 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 pub fn trust_plugin(&self, name: &str) -> Result<String> {
369 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 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(¤t_hash);
400
401 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 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 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 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 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 fn run_hooks(&self, key: &str) -> Result<()> {
470 if let Some(hooks) = self.config.hooks.get(key) {
471 for hook_cmd in hooks {
472 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 fn parse_hook_command(&self, cmd: &str) -> Result<Vec<String>> {
499 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 if !args.is_empty() {
534 self.validate_hook_command(&args[0])?;
535 }
536
537 Ok(args)
538 }
539
540 fn validate_hook_command(&self, command: &str) -> Result<()> {
542 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 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 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 pub fn list_plugins(&self) -> Vec<DiscoveredPlugin> {
581 let mut all_plugins = self.discover_plugins();
582
583 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); 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 assert!(manager.config.plugins.is_empty());
679 }
680
681 #[test]
682 fn test_parse_hook_command_basic() {
683 let manager = PluginManager::default();
684
685 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 let result = manager.parse_hook_command("echo \"hello world\"").unwrap();
696 assert_eq!(result, vec!["echo", "hello world"]);
697
698 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 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 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()); }
726
727 #[test]
728 fn test_validate_hook_command_denied() {
729 let manager = PluginManager::default();
730
731 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 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 let result = manager.parse_hook_command("").unwrap();
767 assert!(result.is_empty());
768
769 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 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 let signing_key = SigningKey::generate(&mut OsRng);
808 let verifying_key = signing_key.verifying_key();
809
810 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 assert!(verify_ed25519_signature(tmp.path(), &sig_hex, &key_hex).is_ok());
819
820 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 let fake_key = "0".repeat(64); let fake_sig = "0".repeat(128); 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 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 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}