Skip to main content

heliosdb_proxy/plugins/
loader.rs

1//! Plugin Loader
2//!
3//! Loads WASM plugins from files and parses their manifests.
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use super::sandbox::Permission;
10use super::HookType;
11
12/// Error types for plugin loading
13#[derive(Debug, Clone)]
14pub enum PluginLoadError {
15    /// File not found
16    FileNotFound(String),
17
18    /// Invalid file format
19    InvalidFormat(String),
20
21    /// Manifest parsing error
22    ManifestError(String),
23
24    /// IO error
25    IoError(String),
26
27    /// Validation error
28    ValidationError(String),
29
30    /// Signature verification failed (Ed25519 over the .wasm bytes
31    /// did not match any trusted public key, or the signature blob
32    /// itself was malformed).
33    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/// Artefact manifest as it appears inside a `helios-plugin pack`
66/// `.tar.gz`. Mirrors `cli/src/manifest.rs::Manifest` exactly — kept
67/// here as a private deserialisation type so the proxy doesn't take a
68/// dep on the CLI crate.
69#[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/// Plugin manifest (from plugin.yaml or embedded in WASM)
100#[derive(Debug, Clone)]
101pub struct PluginManifest {
102    /// Plugin name
103    pub name: String,
104
105    /// Version
106    pub version: String,
107
108    /// Description
109    pub description: String,
110
111    /// Author
112    pub author: String,
113
114    /// License
115    pub license: String,
116
117    /// Supported hooks
118    pub hooks: Vec<HookType>,
119
120    /// Required permissions
121    pub permissions: Vec<Permission>,
122
123    /// Minimum memory requirement
124    pub min_memory: usize,
125
126    /// Maximum memory requirement
127    pub max_memory: usize,
128
129    /// Configuration schema
130    pub config_schema: HashMap<String, ConfigField>,
131
132    /// Plugin file path
133    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,      // 1MB
147            max_memory: 64 * 1024 * 1024, // 64MB
148            config_schema: HashMap::new(),
149            path: PathBuf::new(),
150        }
151    }
152}
153
154/// Configuration field schema
155#[derive(Debug, Clone)]
156pub struct ConfigField {
157    /// Field type
158    pub field_type: ConfigFieldType,
159
160    /// Whether field is required
161    pub required: bool,
162
163    /// Default value
164    pub default: Option<String>,
165
166    /// Description
167    pub description: String,
168}
169
170/// Configuration field types
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum ConfigFieldType {
173    String,
174    Integer,
175    Float,
176    Boolean,
177    Array,
178    Object,
179}
180
181/// Plugin loader
182pub struct PluginLoader {
183    /// Search paths for plugins
184    search_paths: Vec<PathBuf>,
185
186    /// Allowed extensions
187    allowed_extensions: Vec<String>,
188
189    /// Optional Ed25519 trust root. When `Some`, every loaded .wasm
190    /// must have a matching `.sig` sidecar verifiable against one of
191    /// these keys. When `None`, signatures are not checked (preserves
192    /// the dev-loop ergonomic of dropping unsigned `.wasm` files in
193    /// the plugin dir).
194    signature_verifier: Option<SignatureVerifier>,
195}
196
197/// Ed25519 signature verifier for plugin .wasm files.
198///
199/// Trust root format: a directory of `*.pub` files, each containing
200/// a base64-encoded 32-byte Ed25519 public key (one per trusted
201/// publisher). The .sig file format is base64 of the raw 64-byte
202/// Ed25519 signature over the .wasm bytes.
203///
204/// Wire shape is intentionally plain text + base64 — no PEM, no
205/// X.509, no JSON envelope — so operators can sign with `openssl
206/// pkeyutl -sign` or `signify` without bringing a CA story along.
207#[derive(Debug, Default)]
208pub struct SignatureVerifier {
209    /// (label, public_key) pairs. Label is the .pub filename (no
210    /// extension) and shows up in error messages so operators can
211    /// trace which key matched.
212    keys: Vec<(String, ed25519_dalek::VerifyingKey)>,
213}
214
215impl SignatureVerifier {
216    /// Build a verifier from a directory of `*.pub` files. Each file
217    /// must contain exactly one base64-encoded 32-byte Ed25519
218    /// public key. Whitespace at the start / end is tolerated.
219    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    /// Verify a signature blob (base64-encoded Ed25519 signature)
271    /// against the .wasm bytes. Returns Ok with the matching label
272    /// on success.
273    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    /// Number of trusted keys. Useful for diagnostics — a verifier
301    /// with zero keys rejects every signature.
302    pub fn key_count(&self) -> usize {
303        self.keys.len()
304    }
305}
306
307impl PluginLoader {
308    /// Create a new plugin loader. Accepts both raw `.wasm` files
309    /// (the dev-loop format) and packed `.tar.gz` artefacts (the
310    /// distribution format produced by `helios-plugin pack`).
311    pub fn new() -> Self {
312        Self {
313            search_paths: Vec::new(),
314            allowed_extensions: vec![
315                "wasm".to_string(),
316                "gz".to_string(), // for `.tar.gz` artefacts
317            ],
318            signature_verifier: None,
319        }
320    }
321
322    /// Attach a trust-root verifier. Once set, every load() call
323    /// requires a matching .sig sidecar; loads without one fail.
324    pub fn with_signature_verifier(mut self, verifier: SignatureVerifier) -> Self {
325        self.signature_verifier = Some(verifier);
326        self
327    }
328
329    /// Add a search path
330    pub fn add_search_path(&mut self, path: PathBuf) {
331        self.search_paths.push(path);
332    }
333
334    /// Load a plugin from a file path. Two accepted shapes:
335    ///
336    ///   1. Bare `.wasm` (the dev-loop format) — looks for a sidecar
337    ///      `.yaml` / `.json` manifest and, if a trust root is
338    ///      attached, a `.sig` sidecar.
339    ///   2. Packed `.tar.gz` artefact (the distribution format
340    ///      produced by `helios-plugin pack`) — manifest and signature
341    ///      are baked into the tarball; no sidecars needed.
342    pub fn load(&self, path: &Path) -> Result<(PluginManifest, Vec<u8>), PluginLoadError> {
343        // Check file exists
344        if !path.exists() {
345            return Err(PluginLoadError::FileNotFound(path.display().to_string()));
346        }
347
348        // Tarball path — distinct because manifest + signature live
349        // inside the artefact rather than as sidecars.
350        if path.extension().and_then(|e| e.to_str()) == Some("gz") {
351            return self.load_tar_gz(path);
352        }
353
354        // Check extension
355        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        // Read WASM bytes
364        let wasm_bytes = fs::read(path)?;
365
366        // Validate WASM magic number
367        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        // Signature check (when a trust root is configured). The .sig
374        // sidecar is required — no signature, no load.
375        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        // Try to load manifest from sidecar file
395        let manifest = self.load_manifest(path, &wasm_bytes)?;
396
397        Ok((manifest, wasm_bytes))
398    }
399
400    /// Load a plugin packed as a `.tar.gz` artefact (the format
401    /// `helios-plugin pack` produces). Reads `manifest.json` +
402    /// `plugin.wasm` + optional `plugin.sig` from the tarball,
403    /// verifies the wasm SHA-256 against the manifest, verifies the
404    /// signature against the configured trust root if set.
405    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        // Parse the artefact manifest. Field names mirror the helios-
447        // plugin CLI's Manifest type one-for-one.
448        let art: ArtefactManifest = serde_json::from_slice(&manifest_json)
449            .map_err(|e| PluginLoadError::ManifestError(format!("manifest.json: {}", e)))?;
450
451        // Major-version compatibility (today: only "1.x" understood).
452        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        // Validate wasm SHA-256.
466        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        // Validate WASM magic number too — the SHA check guarantees
475        // the bytes are intact, but a malicious manifest could
476        // advertise non-WASM bytes that hash correctly.
477        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        // Signature verification when trust root is attached.
484        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        // Build a PluginManifest from the artefact metadata. Hooks
502        // come over as strings; map them through HookType::from_str.
503        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    /// Load plugin manifest
527    fn load_manifest(
528        &self,
529        wasm_path: &Path,
530        wasm_bytes: &[u8],
531    ) -> Result<PluginManifest, PluginLoadError> {
532        // Try sidecar YAML manifest
533        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        // Try sidecar JSON manifest
539        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        // Try embedded manifest (custom section in WASM)
545        if let Some(manifest) = self.extract_embedded_manifest(wasm_bytes, wasm_path)? {
546            return Ok(manifest);
547        }
548
549        // Generate minimal manifest from filename
550        Ok(self.generate_minimal_manifest(wasm_path))
551    }
552
553    /// Parse YAML manifest
554    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        // Simple YAML parsing (in production, would use serde_yaml)
562        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        // Parse hooks section
589        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        // Parse permissions section
607        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        // Validate manifest
625        self.validate_manifest(&manifest)?;
626
627        Ok(manifest)
628    }
629
630    /// Parse JSON manifest
631    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        // Parse JSON
639        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        // Parse hooks
664        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        // Parse permissions
675        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        // Parse memory requirements
686        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    /// Extract embedded manifest from WASM custom section
700    fn extract_embedded_manifest(
701        &self,
702        _wasm_bytes: &[u8],
703        wasm_path: &Path,
704    ) -> Result<Option<PluginManifest>, PluginLoadError> {
705        // In a real implementation, would parse WASM custom sections
706        // looking for a "helios_manifest" section containing JSON
707
708        // For now, return None (no embedded manifest found)
709        let _ = wasm_path;
710        Ok(None)
711    }
712
713    /// Generate minimal manifest from filename
714    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(), // No hooks without manifest
728            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    /// Validate manifest
737    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        // Validate name format (alphanumeric + hyphens)
751        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        // Validate version format (semver-like)
762        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        // Validate memory requirements
769        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    /// Discover plugins in search paths
785    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
818/// Parse memory size string (e.g., "64MB", "1024KB")
819fn 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    // -----------------------------------------------------------------
923    // SignatureVerifier tests
924    //
925    // We generate an Ed25519 keypair at runtime, write the public key
926    // into a temp trust-root dir, sign a fake .wasm, and check that
927    // the loader accepts the signed bytes and rejects tampered ones.
928    // -----------------------------------------------------------------
929
930    use base64::Engine as _;
931    use ed25519_dalek::{Signer, SigningKey};
932
933    /// Helper: write a single .pub file with `key`'s public component
934    /// into `dir/<label>.pub`. Returns `dir`.
935    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        // Deterministic seed → reproducible tests.
943        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"; // 'm' → 'n'
976        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        // Sign with a completely different key.
988        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        // 31 bytes — invalid Ed25519 length.
1001        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); // signed by the SECOND publisher
1023        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    // -----------------------------------------------------------------
1050    // .tar.gz artefact loader tests (FU-28). Manually build a tarball
1051    // shaped like helios-plugin's output and feed it through load().
1052    // Avoids a workspace dep on the CLI crate.
1053    // -----------------------------------------------------------------
1054
1055    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        // Hooks parsed from string array.
1124        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        // Build a tarball where manifest.wasm_sha256 doesn't match
1132        // the actual wasm bytes.
1133        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),  // wrong hash
1142            "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        // Sign the wasm bytes with our test key.
1221        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}