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 mut manifest = PluginManifest::default();
883 manifest.name = "test-plugin".to_string();
884 manifest.min_memory = 100 * 1024 * 1024;
885 manifest.max_memory = 50 * 1024 * 1024;
886
887 let result = loader.validate_manifest(&manifest);
888 assert!(result.is_err());
889 assert!(result.unwrap_err().to_string().contains("min_memory"));
890 }
891
892 #[test]
893 fn test_manifest_validation_success() {
894 let loader = PluginLoader::new();
895 let mut manifest = PluginManifest::default();
896 manifest.name = "test-plugin".to_string();
897
898 let result = loader.validate_manifest(&manifest);
899 assert!(result.is_ok());
900 }
901
902 #[test]
903 fn test_generate_minimal_manifest() {
904 let loader = PluginLoader::new();
905 let path = PathBuf::from("/plugins/my-plugin.wasm");
906 let manifest = loader.generate_minimal_manifest(&path);
907
908 assert_eq!(manifest.name, "my-plugin");
909 assert_eq!(manifest.version, "0.0.0");
910 }
911
912 #[test]
913 fn test_config_field_type() {
914 assert_eq!(ConfigFieldType::String, ConfigFieldType::String);
915 assert_ne!(ConfigFieldType::String, ConfigFieldType::Integer);
916 }
917
918 use base64::Engine as _;
927 use ed25519_dalek::{Signer, SigningKey};
928
929 fn write_pub_key(dir: &Path, label: &str, key: &SigningKey) {
932 let pub_bytes = key.verifying_key().to_bytes();
933 let b64 = base64::engine::general_purpose::STANDARD.encode(pub_bytes);
934 std::fs::write(dir.join(format!("{label}.pub")), b64).unwrap();
935 }
936
937 fn make_signing_key() -> SigningKey {
938 let seed = [7u8; 32];
940 SigningKey::from_bytes(&seed)
941 }
942
943 #[test]
944 fn test_signature_verifier_accepts_matching_signature() {
945 let dir = tempfile::tempdir().unwrap();
946 let key = make_signing_key();
947 write_pub_key(dir.path(), "official", &key);
948
949 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
950 assert_eq!(verifier.key_count(), 1);
951
952 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
953 let sig = key.sign(wasm);
954 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
955
956 let label = verifier.verify(wasm, &sig_b64).unwrap();
957 assert_eq!(label, "official");
958 }
959
960 #[test]
961 fn test_signature_verifier_rejects_tampered_bytes() {
962 let dir = tempfile::tempdir().unwrap();
963 let key = make_signing_key();
964 write_pub_key(dir.path(), "official", &key);
965 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
966
967 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
968 let sig = key.sign(wasm);
969 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
970
971 let tampered = b"\x00asm\x01\x00\x00\x00pretend-real-wasn"; let err = verifier.verify(tampered, &sig_b64).unwrap_err();
973 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
974 }
975
976 #[test]
977 fn test_signature_verifier_rejects_unknown_signer() {
978 let dir = tempfile::tempdir().unwrap();
979 let trusted = make_signing_key();
980 write_pub_key(dir.path(), "official", &trusted);
981 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
982
983 let attacker = SigningKey::from_bytes(&[0xAB; 32]);
985 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
986 let sig = attacker.sign(wasm);
987 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
988
989 let err = verifier.verify(wasm, &sig_b64).unwrap_err();
990 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
991 }
992
993 #[test]
994 fn test_signature_verifier_rejects_wrong_length_pubkey() {
995 let dir = tempfile::tempdir().unwrap();
996 std::fs::write(
998 dir.path().join("bad.pub"),
999 base64::engine::general_purpose::STANDARD.encode([0u8; 31]),
1000 )
1001 .unwrap();
1002 let err = SignatureVerifier::from_trust_root(dir.path()).unwrap_err();
1003 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
1004 }
1005
1006 #[test]
1007 fn test_signature_verifier_supports_multiple_keys() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let k1 = SigningKey::from_bytes(&[1u8; 32]);
1010 let k2 = SigningKey::from_bytes(&[2u8; 32]);
1011 write_pub_key(dir.path(), "publisher-a", &k1);
1012 write_pub_key(dir.path(), "publisher-b", &k2);
1013
1014 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
1015 assert_eq!(verifier.key_count(), 2);
1016
1017 let wasm = b"\x00asm\x01\x00\x00\x00abc";
1018 let sig = k2.sign(wasm); let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
1020
1021 let label = verifier.verify(wasm, &sig_b64).unwrap();
1022 assert_eq!(label, "publisher-b");
1023 }
1024
1025 #[test]
1026 fn test_loader_with_verifier_rejects_unsigned_plugin() {
1027 let dir = tempfile::tempdir().unwrap();
1028 let wasm_path = dir.path().join("plugin.wasm");
1029 std::fs::write(&wasm_path, b"\x00asm\x01\x00\x00\x00body").unwrap();
1030
1031 let trust_dir = tempfile::tempdir().unwrap();
1032 let key = make_signing_key();
1033 write_pub_key(trust_dir.path(), "official", &key);
1034
1035 let loader = PluginLoader::new()
1036 .with_signature_verifier(SignatureVerifier::from_trust_root(trust_dir.path()).unwrap());
1037 let err = loader.load(&wasm_path).unwrap_err();
1038 assert!(
1039 matches!(err, PluginLoadError::SignatureInvalid(_)),
1040 "expected SignatureInvalid for missing .sig, got {:?}",
1041 err
1042 );
1043 }
1044
1045 use flate2::write::GzEncoder;
1052 use flate2::Compression;
1053 use sha2::{Digest, Sha256};
1054
1055 fn fake_wasm(extra: &[u8]) -> Vec<u8> {
1056 let mut v = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1057 v.extend_from_slice(extra);
1058 v
1059 }
1060
1061 fn sha256_hex(bytes: &[u8]) -> String {
1062 let d = Sha256::digest(bytes);
1063 let mut s = String::new();
1064 for b in d.iter() {
1065 s.push_str(&format!("{:02x}", b));
1066 }
1067 s
1068 }
1069
1070 fn pack_tarball(dir: &Path, name: &str, wasm: &[u8], sig: Option<&[u8]>) -> std::path::PathBuf {
1071 let manifest = serde_json::json!({
1072 "schema_version": "1.0",
1073 "name": name,
1074 "version": "0.1.0",
1075 "description": "test",
1076 "license": "Apache-2.0",
1077 "hooks": ["pre_query", "post_query"],
1078 "wasm_sha256": sha256_hex(wasm),
1079 "signature_sha256": sig.map(sha256_hex),
1080 "signature_algorithm": sig.map(|_| "ed25519"),
1081 "packed_at": "2026-04-25T13:00:00Z",
1082 });
1083 let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
1084
1085 let out_path = dir.join(format!("{}.tar.gz", name));
1086 let f = std::fs::File::create(&out_path).unwrap();
1087 let gz = GzEncoder::new(f, Compression::default());
1088 let mut tar = tar::Builder::new(gz);
1089
1090 let mut put = |path: &str, body: &[u8]| {
1091 let mut h = tar::Header::new_gnu();
1092 h.set_path(path).unwrap();
1093 h.set_size(body.len() as u64);
1094 h.set_mode(0o644);
1095 h.set_cksum();
1096 tar.append(&h, body).unwrap();
1097 };
1098 put("manifest.json", &manifest_bytes);
1099 put("plugin.wasm", wasm);
1100 if let Some(s) = sig {
1101 put("plugin.sig", s);
1102 }
1103 let gz = tar.into_inner().unwrap();
1104 gz.finish().unwrap();
1105 out_path
1106 }
1107
1108 #[test]
1109 fn test_loader_accepts_tar_gz_artefact_without_signature() {
1110 let dir = tempfile::tempdir().unwrap();
1111 let wasm = fake_wasm(b"unsigned");
1112 let path = pack_tarball(dir.path(), "test-plugin", &wasm, None);
1113
1114 let loader = PluginLoader::new();
1115 let (manifest, bytes) = loader.load(&path).unwrap();
1116 assert_eq!(manifest.name, "test-plugin");
1117 assert_eq!(manifest.version, "0.1.0");
1118 assert_eq!(bytes, wasm);
1119 assert!(manifest.hooks.contains(&super::super::HookType::PreQuery));
1121 assert!(manifest.hooks.contains(&super::super::HookType::PostQuery));
1122 }
1123
1124 #[test]
1125 fn test_loader_rejects_tar_gz_with_wrong_wasm_hash() {
1126 let dir = tempfile::tempdir().unwrap();
1127 let real_wasm = fake_wasm(b"real");
1130 let manifest = serde_json::json!({
1131 "schema_version": "1.0",
1132 "name": "x",
1133 "version": "0.1.0",
1134 "description": "",
1135 "license": "Apache-2.0",
1136 "hooks": [],
1137 "wasm_sha256": "deadbeef".repeat(8), "packed_at": "2026-04-25T13:00:00Z",
1139 });
1140 let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
1141 let out_path = dir.path().join("bad.tar.gz");
1142 let f = std::fs::File::create(&out_path).unwrap();
1143 let gz = GzEncoder::new(f, Compression::default());
1144 let mut tar = tar::Builder::new(gz);
1145 let mut put = |path: &str, body: &[u8]| {
1146 let mut h = tar::Header::new_gnu();
1147 h.set_path(path).unwrap();
1148 h.set_size(body.len() as u64);
1149 h.set_mode(0o644);
1150 h.set_cksum();
1151 tar.append(&h, body).unwrap();
1152 };
1153 put("manifest.json", &manifest_bytes);
1154 put("plugin.wasm", &real_wasm);
1155 let gz = tar.into_inner().unwrap();
1156 gz.finish().unwrap();
1157
1158 let loader = PluginLoader::new();
1159 let err = loader.load(&out_path).unwrap_err();
1160 match err {
1161 PluginLoadError::InvalidFormat(msg) => {
1162 assert!(msg.contains("sha256 mismatch"), "got {}", msg)
1163 }
1164 other => panic!("expected InvalidFormat, got {:?}", other),
1165 }
1166 }
1167
1168 #[test]
1169 fn test_loader_rejects_tar_gz_unknown_schema_major() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let wasm = fake_wasm(b"x");
1172 let manifest = serde_json::json!({
1173 "schema_version": "9.0",
1174 "name": "x",
1175 "version": "0.1.0",
1176 "description": "",
1177 "license": "Apache-2.0",
1178 "hooks": [],
1179 "wasm_sha256": sha256_hex(&wasm),
1180 "packed_at": "2026-04-25T13:00:00Z",
1181 });
1182 let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
1183 let out_path = dir.path().join("future.tar.gz");
1184 let f = std::fs::File::create(&out_path).unwrap();
1185 let gz = GzEncoder::new(f, Compression::default());
1186 let mut tar = tar::Builder::new(gz);
1187 let mut put = |path: &str, body: &[u8]| {
1188 let mut h = tar::Header::new_gnu();
1189 h.set_path(path).unwrap();
1190 h.set_size(body.len() as u64);
1191 h.set_mode(0o644);
1192 h.set_cksum();
1193 tar.append(&h, body).unwrap();
1194 };
1195 put("manifest.json", &manifest_bytes);
1196 put("plugin.wasm", &wasm);
1197 let gz = tar.into_inner().unwrap();
1198 gz.finish().unwrap();
1199
1200 let loader = PluginLoader::new();
1201 let err = loader.load(&out_path).unwrap_err();
1202 match err {
1203 PluginLoadError::InvalidFormat(msg) => {
1204 assert!(msg.contains("schema version"), "got {}", msg)
1205 }
1206 other => panic!("expected InvalidFormat, got {:?}", other),
1207 }
1208 }
1209
1210 #[test]
1211 fn test_loader_tar_gz_signature_verifies_against_trust_root() {
1212 let dir = tempfile::tempdir().unwrap();
1213 let key = make_signing_key();
1214 let wasm = fake_wasm(b"signed-body");
1215
1216 use ed25519_dalek::Signer;
1218 let sig = key.sign(&wasm);
1219 let sig_b64 = base64::engine::general_purpose::STANDARD
1220 .encode(sig.to_bytes())
1221 .into_bytes();
1222
1223 let path = pack_tarball(dir.path(), "signed-plugin", &wasm, Some(&sig_b64));
1224
1225 let trust_dir = tempfile::tempdir().unwrap();
1226 write_pub_key(trust_dir.path(), "official", &key);
1227
1228 let loader = PluginLoader::new()
1229 .with_signature_verifier(SignatureVerifier::from_trust_root(trust_dir.path()).unwrap());
1230 let (manifest, bytes) = loader.load(&path).unwrap();
1231 assert_eq!(manifest.name, "signed-plugin");
1232 assert_eq!(bytes, wasm);
1233 }
1234
1235 #[test]
1236 fn test_loader_tar_gz_rejects_missing_signature_when_trust_root_active() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let wasm = fake_wasm(b"unsigned");
1239 let path = pack_tarball(dir.path(), "p", &wasm, None);
1240
1241 let trust_dir = tempfile::tempdir().unwrap();
1242 let key = make_signing_key();
1243 write_pub_key(trust_dir.path(), "official", &key);
1244
1245 let loader = PluginLoader::new()
1246 .with_signature_verifier(SignatureVerifier::from_trust_root(trust_dir.path()).unwrap());
1247 let err = loader.load(&path).unwrap_err();
1248 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
1249 }
1250}