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 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    // -----------------------------------------------------------------
919    // SignatureVerifier tests
920    //
921    // We generate an Ed25519 keypair at runtime, write the public key
922    // into a temp trust-root dir, sign a fake .wasm, and check that
923    // the loader accepts the signed bytes and rejects tampered ones.
924    // -----------------------------------------------------------------
925
926    use base64::Engine as _;
927    use ed25519_dalek::{Signer, SigningKey};
928
929    /// Helper: write a single .pub file with `key`'s public component
930    /// into `dir/<label>.pub`. Returns `dir`.
931    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        // Deterministic seed → reproducible tests.
939        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"; // 'm' → 'n'
972        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        // Sign with a completely different key.
984        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        // 31 bytes — invalid Ed25519 length.
997        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); // signed by the SECOND publisher
1019        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    // -----------------------------------------------------------------
1046    // .tar.gz artefact loader tests (FU-28). Manually build a tarball
1047    // shaped like helios-plugin's output and feed it through load().
1048    // Avoids a workspace dep on the CLI crate.
1049    // -----------------------------------------------------------------
1050
1051    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        // Hooks parsed from string array.
1120        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        // Build a tarball where manifest.wasm_sha256 doesn't match
1128        // the actual wasm bytes.
1129        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),  // wrong hash
1138            "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        // Sign the wasm bytes with our test key.
1217        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}