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).map_err(|e| {
233 PluginLoadError::IoError(format!("read {}: {}", p.display(), e))
234 })?;
235 let raw = raw.trim();
236 let bytes = base64::engine::general_purpose::STANDARD
237 .decode(raw)
238 .map_err(|e| {
239 PluginLoadError::SignatureInvalid(format!(
240 "{} not valid base64: {}",
241 p.display(),
242 e
243 ))
244 })?;
245 if bytes.len() != 32 {
246 return Err(PluginLoadError::SignatureInvalid(format!(
247 "{} should be 32 bytes (raw Ed25519 pubkey), got {}",
248 p.display(),
249 bytes.len()
250 )));
251 }
252 let mut arr = [0u8; 32];
253 arr.copy_from_slice(&bytes);
254 let key = ed25519_dalek::VerifyingKey::from_bytes(&arr).map_err(|e| {
255 PluginLoadError::SignatureInvalid(format!(
256 "{} not a valid Ed25519 pubkey: {}",
257 p.display(),
258 e
259 ))
260 })?;
261 let label = p
262 .file_stem()
263 .and_then(|s| s.to_str())
264 .unwrap_or("(unknown)")
265 .to_string();
266 keys.push((label, key));
267 }
268 Ok(Self { keys })
269 }
270
271 pub fn verify(&self, wasm: &[u8], sig_b64: &str) -> Result<&str, PluginLoadError> {
275 use base64::Engine as _;
276 use ed25519_dalek::Verifier;
277
278 let sig_bytes = base64::engine::general_purpose::STANDARD
279 .decode(sig_b64.trim())
280 .map_err(|e| {
281 PluginLoadError::SignatureInvalid(format!("base64 decode: {}", e))
282 })?;
283 if sig_bytes.len() != 64 {
284 return Err(PluginLoadError::SignatureInvalid(format!(
285 "signature should be 64 bytes, got {}",
286 sig_bytes.len()
287 )));
288 }
289 let mut arr = [0u8; 64];
290 arr.copy_from_slice(&sig_bytes);
291 let sig = ed25519_dalek::Signature::from_bytes(&arr);
292
293 for (label, key) in &self.keys {
294 if key.verify(wasm, &sig).is_ok() {
295 return Ok(label.as_str());
296 }
297 }
298 Err(PluginLoadError::SignatureInvalid(
299 "signature did not match any trusted key".to_string(),
300 ))
301 }
302
303 pub fn key_count(&self) -> usize {
306 self.keys.len()
307 }
308}
309
310impl PluginLoader {
311 pub fn new() -> Self {
315 Self {
316 search_paths: Vec::new(),
317 allowed_extensions: vec![
318 "wasm".to_string(),
319 "gz".to_string(), ],
321 signature_verifier: None,
322 }
323 }
324
325 pub fn with_signature_verifier(mut self, verifier: SignatureVerifier) -> Self {
328 self.signature_verifier = Some(verifier);
329 self
330 }
331
332 pub fn add_search_path(&mut self, path: PathBuf) {
334 self.search_paths.push(path);
335 }
336
337 pub fn load(&self, path: &Path) -> Result<(PluginManifest, Vec<u8>), PluginLoadError> {
346 if !path.exists() {
348 return Err(PluginLoadError::FileNotFound(path.display().to_string()));
349 }
350
351 if path.extension().and_then(|e| e.to_str()) == Some("gz") {
354 return self.load_tar_gz(path);
355 }
356
357 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
359 if !self.allowed_extensions.contains(&extension.to_string()) {
360 return Err(PluginLoadError::InvalidFormat(format!(
361 "Invalid extension: {}. Allowed: {:?}",
362 extension, self.allowed_extensions
363 )));
364 }
365
366 let wasm_bytes = fs::read(path)?;
368
369 if wasm_bytes.len() < 8 || &wasm_bytes[0..4] != b"\x00asm" {
371 return Err(PluginLoadError::InvalidFormat(
372 "Invalid WASM file (bad magic number)".to_string(),
373 ));
374 }
375
376 if let Some(ref verifier) = self.signature_verifier {
379 let sig_path = path.with_extension("sig");
380 if !sig_path.exists() {
381 return Err(PluginLoadError::SignatureInvalid(format!(
382 "{} requires a sidecar .sig file (trust root active)",
383 path.display()
384 )));
385 }
386 let sig_b64 = fs::read_to_string(&sig_path).map_err(|e| {
387 PluginLoadError::IoError(format!("read {}: {}", sig_path.display(), e))
388 })?;
389 let label = verifier.verify(&wasm_bytes, &sig_b64)?;
390 tracing::info!(
391 plugin = %path.display(),
392 signed_by = %label,
393 "plugin signature verified"
394 );
395 }
396
397 let manifest = self.load_manifest(path, &wasm_bytes)?;
399
400 Ok((manifest, wasm_bytes))
401 }
402
403 fn load_tar_gz(&self, path: &Path) -> Result<(PluginManifest, Vec<u8>), PluginLoadError> {
409 use std::io::{Cursor, Read};
410
411 let raw = fs::read(path)?;
412 let gz = flate2::read::GzDecoder::new(Cursor::new(raw));
413 let mut archive = tar::Archive::new(gz);
414
415 let mut manifest_json: Option<Vec<u8>> = None;
416 let mut wasm_bytes: Option<Vec<u8>> = None;
417 let mut sig_bytes: Option<Vec<u8>> = None;
418
419 let entries = archive.entries().map_err(|e| {
420 PluginLoadError::InvalidFormat(format!("tar entries: {}", e))
421 })?;
422 for entry in entries {
423 let mut entry = entry.map_err(|e| {
424 PluginLoadError::InvalidFormat(format!("tar entry: {}", e))
425 })?;
426 let entry_path = entry
427 .path()
428 .map_err(|e| PluginLoadError::InvalidFormat(format!("tar path: {}", e)))?
429 .to_string_lossy()
430 .to_string();
431 let mut buf = Vec::new();
432 entry.read_to_end(&mut buf).map_err(|e| {
433 PluginLoadError::IoError(format!("tar read entry: {}", e))
434 })?;
435 match entry_path.as_str() {
436 "manifest.json" => manifest_json = Some(buf),
437 "plugin.wasm" => wasm_bytes = Some(buf),
438 "plugin.sig" => sig_bytes = Some(buf),
439 _ => {}
440 }
441 }
442
443 let manifest_json = manifest_json.ok_or_else(|| {
444 PluginLoadError::InvalidFormat(
445 "artefact missing manifest.json".to_string(),
446 )
447 })?;
448 let wasm = wasm_bytes.ok_or_else(|| {
449 PluginLoadError::InvalidFormat("artefact missing plugin.wasm".to_string())
450 })?;
451
452 let art: ArtefactManifest = serde_json::from_slice(&manifest_json).map_err(|e| {
455 PluginLoadError::ManifestError(format!("manifest.json: {}", e))
456 })?;
457
458 let major_ok = art
460 .schema_version
461 .split('.')
462 .next()
463 .map(|m| m == "1")
464 .unwrap_or(false);
465 if !major_ok {
466 return Err(PluginLoadError::InvalidFormat(format!(
467 "unsupported artefact schema version: {}",
468 art.schema_version
469 )));
470 }
471
472 let actual_hash = sha256_hex_local(&wasm);
474 if actual_hash != art.wasm_sha256 {
475 return Err(PluginLoadError::InvalidFormat(format!(
476 "wasm sha256 mismatch: manifest claims {}, actual {}",
477 art.wasm_sha256, actual_hash
478 )));
479 }
480
481 if wasm.len() < 8 || &wasm[0..4] != b"\x00asm" {
485 return Err(PluginLoadError::InvalidFormat(
486 "artefact plugin.wasm has bad magic number".to_string(),
487 ));
488 }
489
490 if let Some(ref verifier) = self.signature_verifier {
492 let sig = sig_bytes.ok_or_else(|| {
493 PluginLoadError::SignatureInvalid(
494 "artefact has no signature but trust root is active".into(),
495 )
496 })?;
497 let sig_str = std::str::from_utf8(&sig).map_err(|e| {
498 PluginLoadError::SignatureInvalid(format!(
499 "signature must be UTF-8 base64: {}",
500 e
501 ))
502 })?;
503 let label = verifier.verify(&wasm, sig_str)?;
504 tracing::info!(
505 artefact = %path.display(),
506 signed_by = %label,
507 "plugin artefact signature verified"
508 );
509 }
510
511 let mut hooks = Vec::with_capacity(art.hooks.len());
514 for h in &art.hooks {
515 if let Some(t) = super::HookType::from_str(h) {
516 hooks.push(t);
517 }
518 }
519 let manifest = PluginManifest {
520 name: art.name,
521 version: art.version,
522 description: art.description,
523 author: String::new(),
524 license: art.license,
525 hooks,
526 permissions: vec![],
527 min_memory: 1024 * 1024,
528 max_memory: 64 * 1024 * 1024,
529 config_schema: HashMap::new(),
530 path: path.to_path_buf(),
531 };
532
533 Ok((manifest, wasm))
534 }
535
536 fn load_manifest(&self, wasm_path: &Path, wasm_bytes: &[u8]) -> Result<PluginManifest, PluginLoadError> {
538 let yaml_path = wasm_path.with_extension("yaml");
540 if yaml_path.exists() {
541 return self.parse_yaml_manifest(&yaml_path, wasm_path);
542 }
543
544 let json_path = wasm_path.with_extension("json");
546 if json_path.exists() {
547 return self.parse_json_manifest(&json_path, wasm_path);
548 }
549
550 if let Some(manifest) = self.extract_embedded_manifest(wasm_bytes, wasm_path)? {
552 return Ok(manifest);
553 }
554
555 Ok(self.generate_minimal_manifest(wasm_path))
557 }
558
559 fn parse_yaml_manifest(&self, yaml_path: &Path, wasm_path: &Path) -> Result<PluginManifest, PluginLoadError> {
561 let content = fs::read_to_string(yaml_path)?;
562
563 let mut manifest = PluginManifest::default();
565 manifest.path = wasm_path.to_path_buf();
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(&self, json_path: &Path, wasm_path: &Path) -> Result<PluginManifest, PluginLoadError> {
632 let content = fs::read_to_string(json_path)?;
633
634 let json: serde_json::Value = serde_json::from_str(&content)
636 .map_err(|e| PluginLoadError::ManifestError(e.to_string()))?;
637
638 let mut manifest = PluginManifest::default();
639 manifest.path = wasm_path.to_path_buf();
640
641 if let Some(name) = json.get("name").and_then(|v| v.as_str()) {
642 manifest.name = name.to_string();
643 }
644 if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
645 manifest.version = version.to_string();
646 }
647 if let Some(description) = json.get("description").and_then(|v| v.as_str()) {
648 manifest.description = description.to_string();
649 }
650 if let Some(author) = json.get("author").and_then(|v| v.as_str()) {
651 manifest.author = author.to_string();
652 }
653 if let Some(license) = json.get("license").and_then(|v| v.as_str()) {
654 manifest.license = license.to_string();
655 }
656
657 if let Some(hooks) = json.get("hooks").and_then(|v| v.as_array()) {
659 for hook in hooks {
660 if let Some(hook_name) = hook.as_str() {
661 if let Some(hook_type) = HookType::from_str(hook_name) {
662 manifest.hooks.push(hook_type);
663 }
664 }
665 }
666 }
667
668 if let Some(perms) = json.get("permissions").and_then(|v| v.as_array()) {
670 for perm in perms {
671 if let Some(perm_name) = perm.as_str() {
672 if let Some(permission) = Permission::from_str(perm_name) {
673 manifest.permissions.push(permission);
674 }
675 }
676 }
677 }
678
679 if let Some(resources) = json.get("resources") {
681 if let Some(min_mem) = resources.get("min_memory").and_then(|v| v.as_str()) {
682 manifest.min_memory = parse_memory_size(min_mem);
683 }
684 if let Some(max_mem) = resources.get("max_memory").and_then(|v| v.as_str()) {
685 manifest.max_memory = parse_memory_size(max_mem);
686 }
687 }
688
689 self.validate_manifest(&manifest)?;
690 Ok(manifest)
691 }
692
693 fn extract_embedded_manifest(
695 &self,
696 _wasm_bytes: &[u8],
697 wasm_path: &Path,
698 ) -> Result<Option<PluginManifest>, PluginLoadError> {
699 let _ = wasm_path;
704 Ok(None)
705 }
706
707 fn generate_minimal_manifest(&self, wasm_path: &Path) -> PluginManifest {
709 let name = wasm_path
710 .file_stem()
711 .and_then(|s| s.to_str())
712 .unwrap_or("unknown")
713 .to_string();
714
715 PluginManifest {
716 name,
717 version: "0.0.0".to_string(),
718 description: "Auto-generated manifest".to_string(),
719 author: "Unknown".to_string(),
720 license: "Unknown".to_string(),
721 hooks: Vec::new(), permissions: Vec::new(),
723 min_memory: 1024 * 1024,
724 max_memory: 64 * 1024 * 1024,
725 config_schema: HashMap::new(),
726 path: wasm_path.to_path_buf(),
727 }
728 }
729
730 fn validate_manifest(&self, manifest: &PluginManifest) -> Result<(), PluginLoadError> {
732 if manifest.name.is_empty() {
733 return Err(PluginLoadError::ValidationError(
734 "Plugin name is required".to_string(),
735 ));
736 }
737
738 if manifest.name.len() > 128 {
739 return Err(PluginLoadError::ValidationError(
740 "Plugin name too long (max 128 chars)".to_string(),
741 ));
742 }
743
744 if !manifest.name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
746 return Err(PluginLoadError::ValidationError(
747 "Plugin name must be alphanumeric (hyphens and underscores allowed)".to_string(),
748 ));
749 }
750
751 if !manifest.version.chars().all(|c| c.is_numeric() || c == '.') {
753 return Err(PluginLoadError::ValidationError(
754 "Invalid version format (expected semver)".to_string(),
755 ));
756 }
757
758 if manifest.min_memory > manifest.max_memory {
760 return Err(PluginLoadError::ValidationError(
761 "min_memory cannot exceed max_memory".to_string(),
762 ));
763 }
764
765 if manifest.max_memory > 256 * 1024 * 1024 {
766 return Err(PluginLoadError::ValidationError(
767 "max_memory cannot exceed 256MB".to_string(),
768 ));
769 }
770
771 Ok(())
772 }
773
774 pub fn discover(&self) -> Result<Vec<PathBuf>, PluginLoadError> {
776 let mut plugins = Vec::new();
777
778 for search_path in &self.search_paths {
779 if !search_path.exists() || !search_path.is_dir() {
780 continue;
781 }
782
783 for entry in fs::read_dir(search_path)? {
784 let entry = entry?;
785 let path = entry.path();
786
787 if !path.is_file() {
788 continue;
789 }
790
791 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
792 if self.allowed_extensions.contains(&extension.to_string()) {
793 plugins.push(path);
794 }
795 }
796 }
797
798 Ok(plugins)
799 }
800}
801
802impl Default for PluginLoader {
803 fn default() -> Self {
804 Self::new()
805 }
806}
807
808fn parse_memory_size(s: &str) -> usize {
810 let s = s.trim().to_uppercase();
811
812 if let Some(mb) = s.strip_suffix("MB") {
813 mb.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024
814 } else if let Some(kb) = s.strip_suffix("KB") {
815 kb.trim().parse::<usize>().unwrap_or(0) * 1024
816 } else if let Some(gb) = s.strip_suffix("GB") {
817 gb.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024 * 1024
818 } else {
819 s.parse::<usize>().unwrap_or(0)
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826
827 #[test]
828 fn test_plugin_load_error_display() {
829 let err = PluginLoadError::FileNotFound("/test.wasm".to_string());
830 assert!(err.to_string().contains("File not found"));
831
832 let err = PluginLoadError::ManifestError("invalid".to_string());
833 assert!(err.to_string().contains("Manifest error"));
834 }
835
836 #[test]
837 fn test_plugin_manifest_default() {
838 let manifest = PluginManifest::default();
839 assert!(manifest.name.is_empty());
840 assert_eq!(manifest.version, "0.0.0");
841 assert!(manifest.hooks.is_empty());
842 }
843
844 #[test]
845 fn test_plugin_loader_new() {
846 let loader = PluginLoader::new();
847 assert!(loader.search_paths.is_empty());
848 assert!(loader.allowed_extensions.contains(&"wasm".to_string()));
849 }
850
851 #[test]
852 fn test_parse_memory_size() {
853 assert_eq!(parse_memory_size("64MB"), 64 * 1024 * 1024);
854 assert_eq!(parse_memory_size("1024KB"), 1024 * 1024);
855 assert_eq!(parse_memory_size("1GB"), 1024 * 1024 * 1024);
856 assert_eq!(parse_memory_size("1048576"), 1048576);
857 }
858
859 #[test]
860 fn test_manifest_validation_empty_name() {
861 let loader = PluginLoader::new();
862 let manifest = PluginManifest::default();
863
864 let result = loader.validate_manifest(&manifest);
865 assert!(result.is_err());
866 assert!(result.unwrap_err().to_string().contains("name is required"));
867 }
868
869 #[test]
870 fn test_manifest_validation_invalid_memory() {
871 let loader = PluginLoader::new();
872 let mut manifest = PluginManifest::default();
873 manifest.name = "test-plugin".to_string();
874 manifest.min_memory = 100 * 1024 * 1024;
875 manifest.max_memory = 50 * 1024 * 1024;
876
877 let result = loader.validate_manifest(&manifest);
878 assert!(result.is_err());
879 assert!(result.unwrap_err().to_string().contains("min_memory"));
880 }
881
882 #[test]
883 fn test_manifest_validation_success() {
884 let loader = PluginLoader::new();
885 let mut manifest = PluginManifest::default();
886 manifest.name = "test-plugin".to_string();
887
888 let result = loader.validate_manifest(&manifest);
889 assert!(result.is_ok());
890 }
891
892 #[test]
893 fn test_generate_minimal_manifest() {
894 let loader = PluginLoader::new();
895 let path = PathBuf::from("/plugins/my-plugin.wasm");
896 let manifest = loader.generate_minimal_manifest(&path);
897
898 assert_eq!(manifest.name, "my-plugin");
899 assert_eq!(manifest.version, "0.0.0");
900 }
901
902 #[test]
903 fn test_config_field_type() {
904 assert_eq!(ConfigFieldType::String, ConfigFieldType::String);
905 assert_ne!(ConfigFieldType::String, ConfigFieldType::Integer);
906 }
907
908 use base64::Engine as _;
917 use ed25519_dalek::{Signer, SigningKey};
918
919 fn write_pub_key(dir: &Path, label: &str, key: &SigningKey) {
922 let pub_bytes = key.verifying_key().to_bytes();
923 let b64 = base64::engine::general_purpose::STANDARD.encode(pub_bytes);
924 std::fs::write(dir.join(format!("{label}.pub")), b64).unwrap();
925 }
926
927 fn make_signing_key() -> SigningKey {
928 let seed = [7u8; 32];
930 SigningKey::from_bytes(&seed)
931 }
932
933 #[test]
934 fn test_signature_verifier_accepts_matching_signature() {
935 let dir = tempfile::tempdir().unwrap();
936 let key = make_signing_key();
937 write_pub_key(dir.path(), "official", &key);
938
939 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
940 assert_eq!(verifier.key_count(), 1);
941
942 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
943 let sig = key.sign(wasm);
944 let sig_b64 =
945 base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
946
947 let label = verifier.verify(wasm, &sig_b64).unwrap();
948 assert_eq!(label, "official");
949 }
950
951 #[test]
952 fn test_signature_verifier_rejects_tampered_bytes() {
953 let dir = tempfile::tempdir().unwrap();
954 let key = make_signing_key();
955 write_pub_key(dir.path(), "official", &key);
956 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
957
958 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
959 let sig = key.sign(wasm);
960 let sig_b64 =
961 base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
962
963 let tampered = b"\x00asm\x01\x00\x00\x00pretend-real-wasn"; let err = verifier.verify(tampered, &sig_b64).unwrap_err();
965 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
966 }
967
968 #[test]
969 fn test_signature_verifier_rejects_unknown_signer() {
970 let dir = tempfile::tempdir().unwrap();
971 let trusted = make_signing_key();
972 write_pub_key(dir.path(), "official", &trusted);
973 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
974
975 let attacker = SigningKey::from_bytes(&[0xAB; 32]);
977 let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
978 let sig = attacker.sign(wasm);
979 let sig_b64 =
980 base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
981
982 let err = verifier.verify(wasm, &sig_b64).unwrap_err();
983 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
984 }
985
986 #[test]
987 fn test_signature_verifier_rejects_wrong_length_pubkey() {
988 let dir = tempfile::tempdir().unwrap();
989 std::fs::write(
991 dir.path().join("bad.pub"),
992 base64::engine::general_purpose::STANDARD.encode([0u8; 31]),
993 )
994 .unwrap();
995 let err = SignatureVerifier::from_trust_root(dir.path()).unwrap_err();
996 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
997 }
998
999 #[test]
1000 fn test_signature_verifier_supports_multiple_keys() {
1001 let dir = tempfile::tempdir().unwrap();
1002 let k1 = SigningKey::from_bytes(&[1u8; 32]);
1003 let k2 = SigningKey::from_bytes(&[2u8; 32]);
1004 write_pub_key(dir.path(), "publisher-a", &k1);
1005 write_pub_key(dir.path(), "publisher-b", &k2);
1006
1007 let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
1008 assert_eq!(verifier.key_count(), 2);
1009
1010 let wasm = b"\x00asm\x01\x00\x00\x00abc";
1011 let sig = k2.sign(wasm); let sig_b64 =
1013 base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
1014
1015 let label = verifier.verify(wasm, &sig_b64).unwrap();
1016 assert_eq!(label, "publisher-b");
1017 }
1018
1019 #[test]
1020 fn test_loader_with_verifier_rejects_unsigned_plugin() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let wasm_path = dir.path().join("plugin.wasm");
1023 std::fs::write(&wasm_path, b"\x00asm\x01\x00\x00\x00body").unwrap();
1024
1025 let trust_dir = tempfile::tempdir().unwrap();
1026 let key = make_signing_key();
1027 write_pub_key(trust_dir.path(), "official", &key);
1028
1029 let loader = PluginLoader::new()
1030 .with_signature_verifier(SignatureVerifier::from_trust_root(trust_dir.path()).unwrap());
1031 let err = loader.load(&wasm_path).unwrap_err();
1032 assert!(
1033 matches!(err, PluginLoadError::SignatureInvalid(_)),
1034 "expected SignatureInvalid for missing .sig, got {:?}",
1035 err
1036 );
1037 }
1038
1039 use flate2::write::GzEncoder;
1046 use flate2::Compression;
1047 use sha2::{Digest, Sha256};
1048
1049 fn fake_wasm(extra: &[u8]) -> Vec<u8> {
1050 let mut v = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1051 v.extend_from_slice(extra);
1052 v
1053 }
1054
1055 fn sha256_hex(bytes: &[u8]) -> String {
1056 let d = Sha256::digest(bytes);
1057 let mut s = String::new();
1058 for b in d.iter() {
1059 s.push_str(&format!("{:02x}", b));
1060 }
1061 s
1062 }
1063
1064 fn pack_tarball(
1065 dir: &Path,
1066 name: &str,
1067 wasm: &[u8],
1068 sig: Option<&[u8]>,
1069 ) -> std::path::PathBuf {
1070 let manifest = serde_json::json!({
1071 "schema_version": "1.0",
1072 "name": name,
1073 "version": "0.1.0",
1074 "description": "test",
1075 "license": "Apache-2.0",
1076 "hooks": ["pre_query", "post_query"],
1077 "wasm_sha256": sha256_hex(wasm),
1078 "signature_sha256": sig.map(sha256_hex),
1079 "signature_algorithm": sig.map(|_| "ed25519"),
1080 "packed_at": "2026-04-25T13:00:00Z",
1081 });
1082 let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
1083
1084 let out_path = dir.join(format!("{}.tar.gz", name));
1085 let f = std::fs::File::create(&out_path).unwrap();
1086 let gz = GzEncoder::new(f, Compression::default());
1087 let mut tar = tar::Builder::new(gz);
1088
1089 let mut put = |path: &str, body: &[u8]| {
1090 let mut h = tar::Header::new_gnu();
1091 h.set_path(path).unwrap();
1092 h.set_size(body.len() as u64);
1093 h.set_mode(0o644);
1094 h.set_cksum();
1095 tar.append(&h, body).unwrap();
1096 };
1097 put("manifest.json", &manifest_bytes);
1098 put("plugin.wasm", wasm);
1099 if let Some(s) = sig {
1100 put("plugin.sig", s);
1101 }
1102 let gz = tar.into_inner().unwrap();
1103 gz.finish().unwrap();
1104 out_path
1105 }
1106
1107 #[test]
1108 fn test_loader_accepts_tar_gz_artefact_without_signature() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let wasm = fake_wasm(b"unsigned");
1111 let path = pack_tarball(dir.path(), "test-plugin", &wasm, None);
1112
1113 let loader = PluginLoader::new();
1114 let (manifest, bytes) = loader.load(&path).unwrap();
1115 assert_eq!(manifest.name, "test-plugin");
1116 assert_eq!(manifest.version, "0.1.0");
1117 assert_eq!(bytes, wasm);
1118 assert!(manifest.hooks.contains(&super::super::HookType::PreQuery));
1120 assert!(manifest.hooks.contains(&super::super::HookType::PostQuery));
1121 }
1122
1123 #[test]
1124 fn test_loader_rejects_tar_gz_with_wrong_wasm_hash() {
1125 let dir = tempfile::tempdir().unwrap();
1126 let real_wasm = fake_wasm(b"real");
1129 let manifest = serde_json::json!({
1130 "schema_version": "1.0",
1131 "name": "x",
1132 "version": "0.1.0",
1133 "description": "",
1134 "license": "Apache-2.0",
1135 "hooks": [],
1136 "wasm_sha256": "deadbeef".repeat(8), "packed_at": "2026-04-25T13:00:00Z",
1138 });
1139 let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
1140 let out_path = dir.path().join("bad.tar.gz");
1141 let f = std::fs::File::create(&out_path).unwrap();
1142 let gz = GzEncoder::new(f, Compression::default());
1143 let mut tar = tar::Builder::new(gz);
1144 let mut put = |path: &str, body: &[u8]| {
1145 let mut h = tar::Header::new_gnu();
1146 h.set_path(path).unwrap();
1147 h.set_size(body.len() as u64);
1148 h.set_mode(0o644);
1149 h.set_cksum();
1150 tar.append(&h, body).unwrap();
1151 };
1152 put("manifest.json", &manifest_bytes);
1153 put("plugin.wasm", &real_wasm);
1154 let gz = tar.into_inner().unwrap();
1155 gz.finish().unwrap();
1156
1157 let loader = PluginLoader::new();
1158 let err = loader.load(&out_path).unwrap_err();
1159 match err {
1160 PluginLoadError::InvalidFormat(msg) => {
1161 assert!(msg.contains("sha256 mismatch"), "got {}", msg)
1162 }
1163 other => panic!("expected InvalidFormat, got {:?}", other),
1164 }
1165 }
1166
1167 #[test]
1168 fn test_loader_rejects_tar_gz_unknown_schema_major() {
1169 let dir = tempfile::tempdir().unwrap();
1170 let wasm = fake_wasm(b"x");
1171 let manifest = serde_json::json!({
1172 "schema_version": "9.0",
1173 "name": "x",
1174 "version": "0.1.0",
1175 "description": "",
1176 "license": "Apache-2.0",
1177 "hooks": [],
1178 "wasm_sha256": sha256_hex(&wasm),
1179 "packed_at": "2026-04-25T13:00:00Z",
1180 });
1181 let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
1182 let out_path = dir.path().join("future.tar.gz");
1183 let f = std::fs::File::create(&out_path).unwrap();
1184 let gz = GzEncoder::new(f, Compression::default());
1185 let mut tar = tar::Builder::new(gz);
1186 let mut put = |path: &str, body: &[u8]| {
1187 let mut h = tar::Header::new_gnu();
1188 h.set_path(path).unwrap();
1189 h.set_size(body.len() as u64);
1190 h.set_mode(0o644);
1191 h.set_cksum();
1192 tar.append(&h, body).unwrap();
1193 };
1194 put("manifest.json", &manifest_bytes);
1195 put("plugin.wasm", &wasm);
1196 let gz = tar.into_inner().unwrap();
1197 gz.finish().unwrap();
1198
1199 let loader = PluginLoader::new();
1200 let err = loader.load(&out_path).unwrap_err();
1201 match err {
1202 PluginLoadError::InvalidFormat(msg) => {
1203 assert!(msg.contains("schema version"), "got {}", msg)
1204 }
1205 other => panic!("expected InvalidFormat, got {:?}", other),
1206 }
1207 }
1208
1209 #[test]
1210 fn test_loader_tar_gz_signature_verifies_against_trust_root() {
1211 let dir = tempfile::tempdir().unwrap();
1212 let key = make_signing_key();
1213 let wasm = fake_wasm(b"signed-body");
1214
1215 use ed25519_dalek::Signer;
1217 let sig = key.sign(&wasm);
1218 let sig_b64 = base64::engine::general_purpose::STANDARD
1219 .encode(sig.to_bytes())
1220 .into_bytes();
1221
1222 let path = pack_tarball(dir.path(), "signed-plugin", &wasm, Some(&sig_b64));
1223
1224 let trust_dir = tempfile::tempdir().unwrap();
1225 write_pub_key(trust_dir.path(), "official", &key);
1226
1227 let loader = PluginLoader::new()
1228 .with_signature_verifier(
1229 SignatureVerifier::from_trust_root(trust_dir.path()).unwrap(),
1230 );
1231 let (manifest, bytes) = loader.load(&path).unwrap();
1232 assert_eq!(manifest.name, "signed-plugin");
1233 assert_eq!(bytes, wasm);
1234 }
1235
1236 #[test]
1237 fn test_loader_tar_gz_rejects_missing_signature_when_trust_root_active() {
1238 let dir = tempfile::tempdir().unwrap();
1239 let wasm = fake_wasm(b"unsigned");
1240 let path = pack_tarball(dir.path(), "p", &wasm, None);
1241
1242 let trust_dir = tempfile::tempdir().unwrap();
1243 let key = make_signing_key();
1244 write_pub_key(trust_dir.path(), "official", &key);
1245
1246 let loader = PluginLoader::new()
1247 .with_signature_verifier(
1248 SignatureVerifier::from_trust_root(trust_dir.path()).unwrap(),
1249 );
1250 let err = loader.load(&path).unwrap_err();
1251 assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
1252 }
1253}