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