1use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use super::sandbox::Permission;
10use super::HookType;
11
12#[derive(Debug, Clone)]
14pub enum PluginLoadError {
15 FileNotFound(String),
17
18 InvalidFormat(String),
20
21 ManifestError(String),
23
24 IoError(String),
26
27 ValidationError(String),
29
30 SignatureInvalid(String),
34}
35
36impl std::fmt::Display for PluginLoadError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 PluginLoadError::FileNotFound(path) => write!(f, "File not found: {}", path),
40 PluginLoadError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
41 PluginLoadError::ManifestError(msg) => write!(f, "Manifest error: {}", msg),
42 PluginLoadError::IoError(msg) => write!(f, "IO error: {}", msg),
43 PluginLoadError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
44 PluginLoadError::SignatureInvalid(msg) => {
45 write!(f, "Signature verification failed: {}", msg)
46 }
47 }
48 }
49}
50
51impl std::error::Error for PluginLoadError {}
52
53impl From<std::io::Error> for PluginLoadError {
54 fn from(err: std::io::Error) -> Self {
55 PluginLoadError::IoError(err.to_string())
56 }
57}
58
59impl From<PluginLoadError> for super::runtime::PluginError {
60 fn from(err: PluginLoadError) -> Self {
61 super::runtime::PluginError::LoadError(err.to_string())
62 }
63}
64
65#[derive(Debug, serde::Deserialize)]
70struct ArtefactManifest {
71 schema_version: String,
72 name: String,
73 version: String,
74 description: String,
75 license: String,
76 hooks: Vec<String>,
77 wasm_sha256: String,
78 #[serde(default)]
79 #[allow(dead_code)]
80 signature_sha256: Option<String>,
81 #[serde(default)]
82 #[allow(dead_code)]
83 signature_algorithm: Option<String>,
84 #[serde(default)]
85 #[allow(dead_code)]
86 packed_at: String,
87}
88
89fn sha256_hex_local(bytes: &[u8]) -> String {
90 use sha2::{Digest, Sha256};
91 let digest = Sha256::digest(bytes);
92 let mut s = String::with_capacity(64);
93 for b in digest.iter() {
94 s.push_str(&format!("{:02x}", b));
95 }
96 s
97}
98
99#[derive(Debug, Clone)]
101pub struct PluginManifest {
102 pub name: String,
104
105 pub version: String,
107
108 pub description: String,
110
111 pub author: String,
113
114 pub license: String,
116
117 pub hooks: Vec<HookType>,
119
120 pub permissions: Vec<Permission>,
122
123 pub min_memory: usize,
125
126 pub max_memory: usize,
128
129 pub config_schema: HashMap<String, ConfigField>,
131
132 pub path: PathBuf,
134}
135
136impl Default for PluginManifest {
137 fn default() -> Self {
138 Self {
139 name: String::new(),
140 version: "0.0.0".to_string(),
141 description: String::new(),
142 author: String::new(),
143 license: String::new(),
144 hooks: Vec::new(),
145 permissions: Vec::new(),
146 min_memory: 1024 * 1024, max_memory: 64 * 1024 * 1024, config_schema: HashMap::new(),
149 path: PathBuf::new(),
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
156pub struct ConfigField {
157 pub field_type: ConfigFieldType,
159
160 pub required: bool,
162
163 pub default: Option<String>,
165
166 pub description: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum ConfigFieldType {
173 String,
174 Integer,
175 Float,
176 Boolean,
177 Array,
178 Object,
179}
180
181pub struct PluginLoader {
183 search_paths: Vec<PathBuf>,
185
186 allowed_extensions: Vec<String>,
188
189 signature_verifier: Option<SignatureVerifier>,
195}
196
197#[derive(Debug, Default)]
208pub struct SignatureVerifier {
209 keys: Vec<(String, ed25519_dalek::VerifyingKey)>,
213}
214
215impl SignatureVerifier {
216 pub fn from_trust_root(dir: &Path) -> Result<Self, PluginLoadError> {
220 use base64::Engine as _;
221
222 let mut keys = Vec::new();
223 let entries = fs::read_dir(dir).map_err(|e| {
224 PluginLoadError::IoError(format!("trust-root {}: {}", dir.display(), e))
225 })?;
226 for entry in entries {
227 let entry = entry.map_err(|e| PluginLoadError::IoError(e.to_string()))?;
228 let p = entry.path();
229 if p.extension().and_then(|s| s.to_str()) != Some("pub") {
230 continue;
231 }
232 let raw = fs::read_to_string(&p)
233 .map_err(|e| PluginLoadError::IoError(format!("read {}: {}", p.display(), e)))?;
234 let raw = raw.trim();
235 let bytes = base64::engine::general_purpose::STANDARD
236 .decode(raw)
237 .map_err(|e| {
238 PluginLoadError::SignatureInvalid(format!(
239 "{} not valid base64: {}",
240 p.display(),
241 e
242 ))
243 })?;
244 if bytes.len() != 32 {
245 return Err(PluginLoadError::SignatureInvalid(format!(
246 "{} should be 32 bytes (raw Ed25519 pubkey), got {}",
247 p.display(),
248 bytes.len()
249 )));
250 }
251 let mut arr = [0u8; 32];
252 arr.copy_from_slice(&bytes);
253 let key = ed25519_dalek::VerifyingKey::from_bytes(&arr).map_err(|e| {
254 PluginLoadError::SignatureInvalid(format!(
255 "{} not a valid Ed25519 pubkey: {}",
256 p.display(),
257 e
258 ))
259 })?;
260 let label = p
261 .file_stem()
262 .and_then(|s| s.to_str())
263 .unwrap_or("(unknown)")
264 .to_string();
265 keys.push((label, key));
266 }
267 Ok(Self { keys })
268 }
269
270 pub fn verify(&self, wasm: &[u8], sig_b64: &str) -> Result<&str, PluginLoadError> {
274 use base64::Engine as _;
275 use ed25519_dalek::Verifier;
276
277 let sig_bytes = base64::engine::general_purpose::STANDARD
278 .decode(sig_b64.trim())
279 .map_err(|e| PluginLoadError::SignatureInvalid(format!("base64 decode: {}", e)))?;
280 if sig_bytes.len() != 64 {
281 return Err(PluginLoadError::SignatureInvalid(format!(
282 "signature should be 64 bytes, got {}",
283 sig_bytes.len()
284 )));
285 }
286 let mut arr = [0u8; 64];
287 arr.copy_from_slice(&sig_bytes);
288 let sig = ed25519_dalek::Signature::from_bytes(&arr);
289
290 for (label, key) in &self.keys {
291 if key.verify(wasm, &sig).is_ok() {
292 return Ok(label.as_str());
293 }
294 }
295 Err(PluginLoadError::SignatureInvalid(
296 "signature did not match any trusted key".to_string(),
297 ))
298 }
299
300 pub fn key_count(&self) -> usize {
303 self.keys.len()
304 }
305}
306
307impl PluginLoader {
308 pub fn new() -> Self {
312 Self {
313 search_paths: Vec::new(),
314 allowed_extensions: vec![
315 "wasm".to_string(),
316 "gz".to_string(), ],
318 signature_verifier: None,
319 }
320 }
321
322 pub fn with_signature_verifier(mut self, verifier: SignatureVerifier) -> Self {
325 self.signature_verifier = Some(verifier);
326 self
327 }
328
329 pub fn add_search_path(&mut self, path: PathBuf) {
331 self.search_paths.push(path);
332 }
333
334 pub fn load(&self, path: &Path) -> Result<(PluginManifest, Vec<u8>), PluginLoadError> {
343 if !path.exists() {
345 return Err(PluginLoadError::FileNotFound(path.display().to_string()));
346 }
347
348 if path.extension().and_then(|e| e.to_str()) == Some("gz") {
351 return self.load_tar_gz(path);
352 }
353
354 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
356 if !self.allowed_extensions.contains(&extension.to_string()) {
357 return Err(PluginLoadError::InvalidFormat(format!(
358 "Invalid extension: {}. Allowed: {:?}",
359 extension, self.allowed_extensions
360 )));
361 }
362
363 let wasm_bytes = fs::read(path)?;
365
366 if wasm_bytes.len() < 8 || &wasm_bytes[0..4] != b"\x00asm" {
368 return Err(PluginLoadError::InvalidFormat(
369 "Invalid WASM file (bad magic number)".to_string(),
370 ));
371 }
372
373 if let Some(ref verifier) = self.signature_verifier {
376 let sig_path = path.with_extension("sig");
377 if !sig_path.exists() {
378 return Err(PluginLoadError::SignatureInvalid(format!(
379 "{} requires a sidecar .sig file (trust root active)",
380 path.display()
381 )));
382 }
383 let sig_b64 = fs::read_to_string(&sig_path).map_err(|e| {
384 PluginLoadError::IoError(format!("read {}: {}", sig_path.display(), e))
385 })?;
386 let label = verifier.verify(&wasm_bytes, &sig_b64)?;
387 tracing::info!(
388 plugin = %path.display(),
389 signed_by = %label,
390 "plugin signature verified"
391 );
392 }
393
394 let manifest = self.load_manifest(path, &wasm_bytes)?;
396
397 Ok((manifest, wasm_bytes))
398 }
399
400 fn load_tar_gz(&self, path: &Path) -> Result<(PluginManifest, Vec<u8>), PluginLoadError> {
406 use std::io::{Cursor, Read};
407
408 let raw = fs::read(path)?;
409 let gz = flate2::read::GzDecoder::new(Cursor::new(raw));
410 let mut archive = tar::Archive::new(gz);
411
412 let mut manifest_json: Option<Vec<u8>> = None;
413 let mut wasm_bytes: Option<Vec<u8>> = None;
414 let mut sig_bytes: Option<Vec<u8>> = None;
415
416 let entries = archive
417 .entries()
418 .map_err(|e| PluginLoadError::InvalidFormat(format!("tar entries: {}", e)))?;
419 for entry in entries {
420 let mut entry =
421 entry.map_err(|e| PluginLoadError::InvalidFormat(format!("tar entry: {}", e)))?;
422 let entry_path = entry
423 .path()
424 .map_err(|e| PluginLoadError::InvalidFormat(format!("tar path: {}", e)))?
425 .to_string_lossy()
426 .to_string();
427 let mut buf = Vec::new();
428 entry
429 .read_to_end(&mut buf)
430 .map_err(|e| PluginLoadError::IoError(format!("tar read entry: {}", e)))?;
431 match entry_path.as_str() {
432 "manifest.json" => manifest_json = Some(buf),
433 "plugin.wasm" => wasm_bytes = Some(buf),
434 "plugin.sig" => sig_bytes = Some(buf),
435 _ => {}
436 }
437 }
438
439 let manifest_json = manifest_json.ok_or_else(|| {
440 PluginLoadError::InvalidFormat("artefact missing manifest.json".to_string())
441 })?;
442 let wasm = wasm_bytes.ok_or_else(|| {
443 PluginLoadError::InvalidFormat("artefact missing plugin.wasm".to_string())
444 })?;
445
446 let art: ArtefactManifest = serde_json::from_slice(&manifest_json)
449 .map_err(|e| PluginLoadError::ManifestError(format!("manifest.json: {}", e)))?;
450
451 let major_ok = art
453 .schema_version
454 .split('.')
455 .next()
456 .map(|m| m == "1")
457 .unwrap_or(false);
458 if !major_ok {
459 return Err(PluginLoadError::InvalidFormat(format!(
460 "unsupported artefact schema version: {}",
461 art.schema_version
462 )));
463 }
464
465 let actual_hash = sha256_hex_local(&wasm);
467 if actual_hash != art.wasm_sha256 {
468 return Err(PluginLoadError::InvalidFormat(format!(
469 "wasm sha256 mismatch: manifest claims {}, actual {}",
470 art.wasm_sha256, actual_hash
471 )));
472 }
473
474 if wasm.len() < 8 || &wasm[0..4] != b"\x00asm" {
478 return Err(PluginLoadError::InvalidFormat(
479 "artefact plugin.wasm has bad magic number".to_string(),
480 ));
481 }
482
483 if let Some(ref verifier) = self.signature_verifier {
485 let sig = sig_bytes.ok_or_else(|| {
486 PluginLoadError::SignatureInvalid(
487 "artefact has no signature but trust root is active".into(),
488 )
489 })?;
490 let sig_str = std::str::from_utf8(&sig).map_err(|e| {
491 PluginLoadError::SignatureInvalid(format!("signature must be UTF-8 base64: {}", e))
492 })?;
493 let label = verifier.verify(&wasm, sig_str)?;
494 tracing::info!(
495 artefact = %path.display(),
496 signed_by = %label,
497 "plugin artefact signature verified"
498 );
499 }
500
501 let mut hooks = Vec::with_capacity(art.hooks.len());
504 for h in &art.hooks {
505 if let Some(t) = super::HookType::from_str(h) {
506 hooks.push(t);
507 }
508 }
509 let manifest = PluginManifest {
510 name: art.name,
511 version: art.version,
512 description: art.description,
513 author: String::new(),
514 license: art.license,
515 hooks,
516 permissions: vec![],
517 min_memory: 1024 * 1024,
518 max_memory: 64 * 1024 * 1024,
519 config_schema: HashMap::new(),
520 path: path.to_path_buf(),
521 };
522
523 Ok((manifest, wasm))
524 }
525
526 fn load_manifest(
528 &self,
529 wasm_path: &Path,
530 wasm_bytes: &[u8],
531 ) -> Result<PluginManifest, PluginLoadError> {
532 let yaml_path = wasm_path.with_extension("yaml");
534 if yaml_path.exists() {
535 return self.parse_yaml_manifest(&yaml_path, wasm_path);
536 }
537
538 let json_path = wasm_path.with_extension("json");
540 if json_path.exists() {
541 return self.parse_json_manifest(&json_path, wasm_path);
542 }
543
544 if let Some(manifest) = self.extract_embedded_manifest(wasm_bytes, wasm_path)? {
546 return Ok(manifest);
547 }
548
549 Ok(self.generate_minimal_manifest(wasm_path))
551 }
552
553 fn parse_yaml_manifest(
555 &self,
556 yaml_path: &Path,
557 wasm_path: &Path,
558 ) -> Result<PluginManifest, PluginLoadError> {
559 let content = fs::read_to_string(yaml_path)?;
560
561 let mut manifest = PluginManifest {
563 path: wasm_path.to_path_buf(),
564 ..PluginManifest::default()
565 };
566
567 for line in content.lines() {
568 let line = line.trim();
569 if line.is_empty() || line.starts_with('#') {
570 continue;
571 }
572
573 if let Some((key, value)) = line.split_once(':') {
574 let key = key.trim();
575 let value = value.trim().trim_matches('"').trim_matches('\'');
576
577 match key {
578 "name" => manifest.name = value.to_string(),
579 "version" => manifest.version = value.to_string(),
580 "description" => manifest.description = value.to_string(),
581 "author" => manifest.author = value.to_string(),
582 "license" => manifest.license = value.to_string(),
583 _ => {}
584 }
585 }
586 }
587
588 if let Some(hooks_start) = content.find("hooks:") {
590 let hooks_section = &content[hooks_start..];
591 for line in hooks_section.lines().skip(1) {
592 let line = line.trim();
593 if line.is_empty() || !line.starts_with('-') {
594 if !line.starts_with(' ') && !line.is_empty() {
595 break;
596 }
597 continue;
598 }
599 let hook_name = line.trim_start_matches('-').trim();
600 if let Some(hook) = HookType::from_str(hook_name) {
601 manifest.hooks.push(hook);
602 }
603 }
604 }
605
606 if let Some(perms_start) = content.find("permissions:") {
608 let perms_section = &content[perms_start..];
609 for line in perms_section.lines().skip(1) {
610 let line = line.trim();
611 if line.is_empty() || !line.starts_with('-') {
612 if !line.starts_with(' ') && !line.is_empty() {
613 break;
614 }
615 continue;
616 }
617 let perm_name = line.trim_start_matches('-').trim();
618 if let Some(perm) = Permission::from_str(perm_name) {
619 manifest.permissions.push(perm);
620 }
621 }
622 }
623
624 self.validate_manifest(&manifest)?;
626
627 Ok(manifest)
628 }
629
630 fn parse_json_manifest(
632 &self,
633 json_path: &Path,
634 wasm_path: &Path,
635 ) -> Result<PluginManifest, PluginLoadError> {
636 let content = fs::read_to_string(json_path)?;
637
638 let json: serde_json::Value = serde_json::from_str(&content)
640 .map_err(|e| PluginLoadError::ManifestError(e.to_string()))?;
641
642 let mut manifest = PluginManifest {
643 path: wasm_path.to_path_buf(),
644 ..PluginManifest::default()
645 };
646
647 if let Some(name) = json.get("name").and_then(|v| v.as_str()) {
648 manifest.name = name.to_string();
649 }
650 if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
651 manifest.version = version.to_string();
652 }
653 if let Some(description) = json.get("description").and_then(|v| v.as_str()) {
654 manifest.description = description.to_string();
655 }
656 if let Some(author) = json.get("author").and_then(|v| v.as_str()) {
657 manifest.author = author.to_string();
658 }
659 if let Some(license) = json.get("license").and_then(|v| v.as_str()) {
660 manifest.license = license.to_string();
661 }
662
663 if let Some(hooks) = json.get("hooks").and_then(|v| v.as_array()) {
665 for hook in hooks {
666 if let Some(hook_name) = hook.as_str() {
667 if let Some(hook_type) = HookType::from_str(hook_name) {
668 manifest.hooks.push(hook_type);
669 }
670 }
671 }
672 }
673
674 if let Some(perms) = json.get("permissions").and_then(|v| v.as_array()) {
676 for perm in perms {
677 if let Some(perm_name) = perm.as_str() {
678 if let Some(permission) = Permission::from_str(perm_name) {
679 manifest.permissions.push(permission);
680 }
681 }
682 }
683 }
684
685 if let Some(resources) = json.get("resources") {
687 if let Some(min_mem) = resources.get("min_memory").and_then(|v| v.as_str()) {
688 manifest.min_memory = parse_memory_size(min_mem);
689 }
690 if let Some(max_mem) = resources.get("max_memory").and_then(|v| v.as_str()) {
691 manifest.max_memory = parse_memory_size(max_mem);
692 }
693 }
694
695 self.validate_manifest(&manifest)?;
696 Ok(manifest)
697 }
698
699 fn extract_embedded_manifest(
701 &self,
702 _wasm_bytes: &[u8],
703 wasm_path: &Path,
704 ) -> Result<Option<PluginManifest>, PluginLoadError> {
705 let _ = wasm_path;
710 Ok(None)
711 }
712
713 fn generate_minimal_manifest(&self, wasm_path: &Path) -> PluginManifest {
715 let name = wasm_path
716 .file_stem()
717 .and_then(|s| s.to_str())
718 .unwrap_or("unknown")
719 .to_string();
720
721 PluginManifest {
722 name,
723 version: "0.0.0".to_string(),
724 description: "Auto-generated manifest".to_string(),
725 author: "Unknown".to_string(),
726 license: "Unknown".to_string(),
727 hooks: Vec::new(), permissions: Vec::new(),
729 min_memory: 1024 * 1024,
730 max_memory: 64 * 1024 * 1024,
731 config_schema: HashMap::new(),
732 path: wasm_path.to_path_buf(),
733 }
734 }
735
736 fn validate_manifest(&self, manifest: &PluginManifest) -> Result<(), PluginLoadError> {
738 if manifest.name.is_empty() {
739 return Err(PluginLoadError::ValidationError(
740 "Plugin name is required".to_string(),
741 ));
742 }
743
744 if manifest.name.len() > 128 {
745 return Err(PluginLoadError::ValidationError(
746 "Plugin name too long (max 128 chars)".to_string(),
747 ));
748 }
749
750 if !manifest
752 .name
753 .chars()
754 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
755 {
756 return Err(PluginLoadError::ValidationError(
757 "Plugin name must be alphanumeric (hyphens and underscores allowed)".to_string(),
758 ));
759 }
760
761 if !manifest.version.chars().all(|c| c.is_numeric() || c == '.') {
763 return Err(PluginLoadError::ValidationError(
764 "Invalid version format (expected semver)".to_string(),
765 ));
766 }
767
768 if manifest.min_memory > manifest.max_memory {
770 return Err(PluginLoadError::ValidationError(
771 "min_memory cannot exceed max_memory".to_string(),
772 ));
773 }
774
775 if manifest.max_memory > 256 * 1024 * 1024 {
776 return Err(PluginLoadError::ValidationError(
777 "max_memory cannot exceed 256MB".to_string(),
778 ));
779 }
780
781 Ok(())
782 }
783
784 pub fn discover(&self) -> Result<Vec<PathBuf>, PluginLoadError> {
786 let mut plugins = Vec::new();
787
788 for search_path in &self.search_paths {
789 if !search_path.exists() || !search_path.is_dir() {
790 continue;
791 }
792
793 for entry in fs::read_dir(search_path)? {
794 let entry = entry?;
795 let path = entry.path();
796
797 if !path.is_file() {
798 continue;
799 }
800
801 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
802 if self.allowed_extensions.contains(&extension.to_string()) {
803 plugins.push(path);
804 }
805 }
806 }
807
808 Ok(plugins)
809 }
810}
811
812impl Default for PluginLoader {
813 fn default() -> Self {
814 Self::new()
815 }
816}
817
818fn parse_memory_size(s: &str) -> usize {
820 let s = s.trim().to_uppercase();
821
822 if let Some(mb) = s.strip_suffix("MB") {
823 mb.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024
824 } else if let Some(kb) = s.strip_suffix("KB") {
825 kb.trim().parse::<usize>().unwrap_or(0) * 1024
826 } else if let Some(gb) = s.strip_suffix("GB") {
827 gb.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024 * 1024
828 } else {
829 s.parse::<usize>().unwrap_or(0)
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[test]
838 fn test_plugin_load_error_display() {
839 let err = PluginLoadError::FileNotFound("/test.wasm".to_string());
840 assert!(err.to_string().contains("File not found"));
841
842 let err = PluginLoadError::ManifestError("invalid".to_string());
843 assert!(err.to_string().contains("Manifest error"));
844 }
845
846 #[test]
847 fn test_plugin_manifest_default() {
848 let manifest = PluginManifest::default();
849 assert!(manifest.name.is_empty());
850 assert_eq!(manifest.version, "0.0.0");
851 assert!(manifest.hooks.is_empty());
852 }
853
854 #[test]
855 fn test_plugin_loader_new() {
856 let loader = PluginLoader::new();
857 assert!(loader.search_paths.is_empty());
858 assert!(loader.allowed_extensions.contains(&"wasm".to_string()));
859 }
860
861 #[test]
862 fn test_parse_memory_size() {
863 assert_eq!(parse_memory_size("64MB"), 64 * 1024 * 1024);
864 assert_eq!(parse_memory_size("1024KB"), 1024 * 1024);
865 assert_eq!(parse_memory_size("1GB"), 1024 * 1024 * 1024);
866 assert_eq!(parse_memory_size("1048576"), 1048576);
867 }
868
869 #[test]
870 fn test_manifest_validation_empty_name() {
871 let loader = PluginLoader::new();
872 let manifest = PluginManifest::default();
873
874 let result = loader.validate_manifest(&manifest);
875 assert!(result.is_err());
876 assert!(result.unwrap_err().to_string().contains("name is required"));
877 }
878
879 #[test]
880 fn test_manifest_validation_invalid_memory() {
881 let loader = PluginLoader::new();
882 let manifest = PluginManifest {
883 name: "test-plugin".to_string(),
884 min_memory: 100 * 1024 * 1024,
885 max_memory: 50 * 1024 * 1024,
886 ..Default::default()
887 };
888
889 let result = loader.validate_manifest(&manifest);
890 assert!(result.is_err());
891 assert!(result.unwrap_err().to_string().contains("min_memory"));
892 }
893
894 #[test]
895 fn test_manifest_validation_success() {
896 let loader = PluginLoader::new();
897 let manifest = PluginManifest {
898 name: "test-plugin".to_string(),
899 ..Default::default()
900 };
901
902 let result = loader.validate_manifest(&manifest);
903 assert!(result.is_ok());
904 }
905
906 #[test]
907 fn test_generate_minimal_manifest() {
908 let loader = PluginLoader::new();
909 let path = PathBuf::from("/plugins/my-plugin.wasm");
910 let manifest = loader.generate_minimal_manifest(&path);
911
912 assert_eq!(manifest.name, "my-plugin");
913 assert_eq!(manifest.version, "0.0.0");
914 }
915
916 #[test]
917 fn test_config_field_type() {
918 assert_eq!(ConfigFieldType::String, ConfigFieldType::String);
919 assert_ne!(ConfigFieldType::String, ConfigFieldType::Integer);
920 }
921
922 use base64::Engine as _;
931 use ed25519_dalek::{Signer, SigningKey};
932
933 fn write_pub_key(dir: &Path, label: &str, key: &SigningKey) {
936 let pub_bytes = key.verifying_key().to_bytes();
937 let b64 = base64::engine::general_purpose::STANDARD.encode(pub_bytes);
938 std::fs::write(dir.join(format!("{label}.pub")), b64).unwrap();
939 }
940
941 fn make_signing_key() -> SigningKey {
942 let seed = [7u8; 32];
944 SigningKey::from_bytes(&seed)
945 }
946
947 #[test]
948 fn test_signature_verifier_accepts_matching_signature() {
949 let dir = tempfile::tempdir().unwrap();
950 let key = make_signing_key();
951 write_pub_key(dir.path(), "official", &key);
952
953 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
954 assert_eq!(verifier.key_count(), 1);
955
956 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
957 let sig = key.sign(wasm);
958 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
959
960 let label = verifier.verify(wasm, &sig_b64).unwrap();
961 assert_eq!(label, "official");
962 }
963
964 #[test]
965 fn test_signature_verifier_rejects_tampered_bytes() {
966 let dir = tempfile::tempdir().unwrap();
967 let key = make_signing_key();
968 write_pub_key(dir.path(), "official", &key);
969 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
970
971 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
972 let sig = key.sign(wasm);
973 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
974
975 let tampered = b"\x00asm\x01\x00\x00\x00pretend-real-wasn"; let err = verifier.verify(tampered, &sig_b64).unwrap_err();
977 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
978 }
979
980 #[test]
981 fn test_signature_verifier_rejects_unknown_signer() {
982 let dir = tempfile::tempdir().unwrap();
983 let trusted = make_signing_key();
984 write_pub_key(dir.path(), "official", &trusted);
985 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
986
987 let attacker = SigningKey::from_bytes(&[0xAB; 32]);
989 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
990 let sig = attacker.sign(wasm);
991 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
992
993 let err = verifier.verify(wasm, &sig_b64).unwrap_err();
994 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
995 }
996
997 #[test]
998 fn test_signature_verifier_rejects_wrong_length_pubkey() {
999 let dir = tempfile::tempdir().unwrap();
1000 std::fs::write(
1002 dir.path().join("bad.pub"),
1003 base64::engine::general_purpose::STANDARD.encode([0u8; 31]),
1004 )
1005 .unwrap();
1006 let err = SignatureVerifier::from_trust_root(dir.path()).unwrap_err();
1007 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
1008 }
1009
1010 #[test]
1011 fn test_signature_verifier_supports_multiple_keys() {
1012 let dir = tempfile::tempdir().unwrap();
1013 let k1 = SigningKey::from_bytes(&[1u8; 32]);
1014 let k2 = SigningKey::from_bytes(&[2u8; 32]);
1015 write_pub_key(dir.path(), "publisher-a", &k1);
1016 write_pub_key(dir.path(), "publisher-b", &k2);
1017
1018 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
1019 assert_eq!(verifier.key_count(), 2);
1020
1021 let wasm = b"\x00asm\x01\x00\x00\x00abc";
1022 let sig = k2.sign(wasm); let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
1024
1025 let label = verifier.verify(wasm, &sig_b64).unwrap();
1026 assert_eq!(label, "publisher-b");
1027 }
1028
1029 #[test]
1030 fn test_loader_with_verifier_rejects_unsigned_plugin() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let wasm_path = dir.path().join("plugin.wasm");
1033 std::fs::write(&wasm_path, b"\x00asm\x01\x00\x00\x00body").unwrap();
1034
1035 let trust_dir = tempfile::tempdir().unwrap();
1036 let key = make_signing_key();
1037 write_pub_key(trust_dir.path(), "official", &key);
1038
1039 let loader = PluginLoader::new()
1040 .with_signature_verifier(SignatureVerifier::from_trust_root(trust_dir.path()).unwrap());
1041 let err = loader.load(&wasm_path).unwrap_err();
1042 assert!(
1043 matches!(err, PluginLoadError::SignatureInvalid(_)),
1044 "expected SignatureInvalid for missing .sig, got {:?}",
1045 err
1046 );
1047 }
1048
1049 use flate2::write::GzEncoder;
1056 use flate2::Compression;
1057 use sha2::{Digest, Sha256};
1058
1059 fn fake_wasm(extra: &[u8]) -> Vec<u8> {
1060 let mut v = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1061 v.extend_from_slice(extra);
1062 v
1063 }
1064
1065 fn sha256_hex(bytes: &[u8]) -> String {
1066 let d = Sha256::digest(bytes);
1067 let mut s = String::new();
1068 for b in d.iter() {
1069 s.push_str(&format!("{:02x}", b));
1070 }
1071 s
1072 }
1073
1074 fn pack_tarball(dir: &Path, name: &str, wasm: &[u8], sig: Option<&[u8]>) -> std::path::PathBuf {
1075 let manifest = serde_json::json!({
1076 "schema_version": "1.0",
1077 "name": name,
1078 "version": "0.1.0",
1079 "description": "test",
1080 "license": "Apache-2.0",
1081 "hooks": ["pre_query", "post_query"],
1082 "wasm_sha256": sha256_hex(wasm),
1083 "signature_sha256": sig.map(sha256_hex),
1084 "signature_algorithm": sig.map(|_| "ed25519"),
1085 "packed_at": "2026-04-25T13:00:00Z",
1086 });
1087 let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
1088
1089 let out_path = dir.join(format!("{}.tar.gz", name));
1090 let f = std::fs::File::create(&out_path).unwrap();
1091 let gz = GzEncoder::new(f, Compression::default());
1092 let mut tar = tar::Builder::new(gz);
1093
1094 let mut put = |path: &str, body: &[u8]| {
1095 let mut h = tar::Header::new_gnu();
1096 h.set_path(path).unwrap();
1097 h.set_size(body.len() as u64);
1098 h.set_mode(0o644);
1099 h.set_cksum();
1100 tar.append(&h, body).unwrap();
1101 };
1102 put("manifest.json", &manifest_bytes);
1103 put("plugin.wasm", wasm);
1104 if let Some(s) = sig {
1105 put("plugin.sig", s);
1106 }
1107 let gz = tar.into_inner().unwrap();
1108 gz.finish().unwrap();
1109 out_path
1110 }
1111
1112 #[test]
1113 fn test_loader_accepts_tar_gz_artefact_without_signature() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let wasm = fake_wasm(b"unsigned");
1116 let path = pack_tarball(dir.path(), "test-plugin", &wasm, None);
1117
1118 let loader = PluginLoader::new();
1119 let (manifest, bytes) = loader.load(&path).unwrap();
1120 assert_eq!(manifest.name, "test-plugin");
1121 assert_eq!(manifest.version, "0.1.0");
1122 assert_eq!(bytes, wasm);
1123 assert!(manifest.hooks.contains(&super::super::HookType::PreQuery));
1125 assert!(manifest.hooks.contains(&super::super::HookType::PostQuery));
1126 }
1127
1128 #[test]
1129 fn test_loader_rejects_tar_gz_with_wrong_wasm_hash() {
1130 let dir = tempfile::tempdir().unwrap();
1131 let real_wasm = fake_wasm(b"real");
1134 let manifest = serde_json::json!({
1135 "schema_version": "1.0",
1136 "name": "x",
1137 "version": "0.1.0",
1138 "description": "",
1139 "license": "Apache-2.0",
1140 "hooks": [],
1141 "wasm_sha256": "deadbeef".repeat(8), "packed_at": "2026-04-25T13:00:00Z",
1143 });
1144 let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
1145 let out_path = dir.path().join("bad.tar.gz");
1146 let f = std::fs::File::create(&out_path).unwrap();
1147 let gz = GzEncoder::new(f, Compression::default());
1148 let mut tar = tar::Builder::new(gz);
1149 let mut put = |path: &str, body: &[u8]| {
1150 let mut h = tar::Header::new_gnu();
1151 h.set_path(path).unwrap();
1152 h.set_size(body.len() as u64);
1153 h.set_mode(0o644);
1154 h.set_cksum();
1155 tar.append(&h, body).unwrap();
1156 };
1157 put("manifest.json", &manifest_bytes);
1158 put("plugin.wasm", &real_wasm);
1159 let gz = tar.into_inner().unwrap();
1160 gz.finish().unwrap();
1161
1162 let loader = PluginLoader::new();
1163 let err = loader.load(&out_path).unwrap_err();
1164 match err {
1165 PluginLoadError::InvalidFormat(msg) => {
1166 assert!(msg.contains("sha256 mismatch"), "got {}", msg)
1167 }
1168 other => panic!("expected InvalidFormat, got {:?}", other),
1169 }
1170 }
1171
1172 #[test]
1173 fn test_loader_rejects_tar_gz_unknown_schema_major() {
1174 let dir = tempfile::tempdir().unwrap();
1175 let wasm = fake_wasm(b"x");
1176 let manifest = serde_json::json!({
1177 "schema_version": "9.0",
1178 "name": "x",
1179 "version": "0.1.0",
1180 "description": "",
1181 "license": "Apache-2.0",
1182 "hooks": [],
1183 "wasm_sha256": sha256_hex(&wasm),
1184 "packed_at": "2026-04-25T13:00:00Z",
1185 });
1186 let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
1187 let out_path = dir.path().join("future.tar.gz");
1188 let f = std::fs::File::create(&out_path).unwrap();
1189 let gz = GzEncoder::new(f, Compression::default());
1190 let mut tar = tar::Builder::new(gz);
1191 let mut put = |path: &str, body: &[u8]| {
1192 let mut h = tar::Header::new_gnu();
1193 h.set_path(path).unwrap();
1194 h.set_size(body.len() as u64);
1195 h.set_mode(0o644);
1196 h.set_cksum();
1197 tar.append(&h, body).unwrap();
1198 };
1199 put("manifest.json", &manifest_bytes);
1200 put("plugin.wasm", &wasm);
1201 let gz = tar.into_inner().unwrap();
1202 gz.finish().unwrap();
1203
1204 let loader = PluginLoader::new();
1205 let err = loader.load(&out_path).unwrap_err();
1206 match err {
1207 PluginLoadError::InvalidFormat(msg) => {
1208 assert!(msg.contains("schema version"), "got {}", msg)
1209 }
1210 other => panic!("expected InvalidFormat, got {:?}", other),
1211 }
1212 }
1213
1214 #[test]
1215 fn test_loader_tar_gz_signature_verifies_against_trust_root() {
1216 let dir = tempfile::tempdir().unwrap();
1217 let key = make_signing_key();
1218 let wasm = fake_wasm(b"signed-body");
1219
1220 use ed25519_dalek::Signer;
1222 let sig = key.sign(&wasm);
1223 let sig_b64 = base64::engine::general_purpose::STANDARD
1224 .encode(sig.to_bytes())
1225 .into_bytes();
1226
1227 let path = pack_tarball(dir.path(), "signed-plugin", &wasm, Some(&sig_b64));
1228
1229 let trust_dir = tempfile::tempdir().unwrap();
1230 write_pub_key(trust_dir.path(), "official", &key);
1231
1232 let loader = PluginLoader::new()
1233 .with_signature_verifier(SignatureVerifier::from_trust_root(trust_dir.path()).unwrap());
1234 let (manifest, bytes) = loader.load(&path).unwrap();
1235 assert_eq!(manifest.name, "signed-plugin");
1236 assert_eq!(bytes, wasm);
1237 }
1238
1239 #[test]
1240 fn test_loader_tar_gz_rejects_missing_signature_when_trust_root_active() {
1241 let dir = tempfile::tempdir().unwrap();
1242 let wasm = fake_wasm(b"unsigned");
1243 let path = pack_tarball(dir.path(), "p", &wasm, None);
1244
1245 let trust_dir = tempfile::tempdir().unwrap();
1246 let key = make_signing_key();
1247 write_pub_key(trust_dir.path(), "official", &key);
1248
1249 let loader = PluginLoader::new()
1250 .with_signature_verifier(SignatureVerifier::from_trust_root(trust_dir.path()).unwrap());
1251 let err = loader.load(&path).unwrap_err();
1252 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
1253 }
1254}