use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use super::sandbox::Permission;
use super::HookType;
#[derive(Debug, Clone)]
pub enum PluginLoadError {
FileNotFound(String),
InvalidFormat(String),
ManifestError(String),
IoError(String),
ValidationError(String),
SignatureInvalid(String),
}
impl std::fmt::Display for PluginLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginLoadError::FileNotFound(path) => write!(f, "File not found: {}", path),
PluginLoadError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
PluginLoadError::ManifestError(msg) => write!(f, "Manifest error: {}", msg),
PluginLoadError::IoError(msg) => write!(f, "IO error: {}", msg),
PluginLoadError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
PluginLoadError::SignatureInvalid(msg) => {
write!(f, "Signature verification failed: {}", msg)
}
}
}
}
impl std::error::Error for PluginLoadError {}
impl From<std::io::Error> for PluginLoadError {
fn from(err: std::io::Error) -> Self {
PluginLoadError::IoError(err.to_string())
}
}
impl From<PluginLoadError> for super::runtime::PluginError {
fn from(err: PluginLoadError) -> Self {
super::runtime::PluginError::LoadError(err.to_string())
}
}
#[derive(Debug, serde::Deserialize)]
struct ArtefactManifest {
schema_version: String,
name: String,
version: String,
description: String,
license: String,
hooks: Vec<String>,
wasm_sha256: String,
#[serde(default)]
#[allow(dead_code)]
signature_sha256: Option<String>,
#[serde(default)]
#[allow(dead_code)]
signature_algorithm: Option<String>,
#[serde(default)]
#[allow(dead_code)]
packed_at: String,
}
fn sha256_hex_local(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(bytes);
let mut s = String::with_capacity(64);
for b in digest.iter() {
s.push_str(&format!("{:02x}", b));
}
s
}
#[derive(Debug, Clone)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub license: String,
pub hooks: Vec<HookType>,
pub permissions: Vec<Permission>,
pub min_memory: usize,
pub max_memory: usize,
pub config_schema: HashMap<String, ConfigField>,
pub path: PathBuf,
}
impl Default for PluginManifest {
fn default() -> Self {
Self {
name: String::new(),
version: "0.0.0".to_string(),
description: String::new(),
author: String::new(),
license: String::new(),
hooks: Vec::new(),
permissions: Vec::new(),
min_memory: 1024 * 1024, max_memory: 64 * 1024 * 1024, config_schema: HashMap::new(),
path: PathBuf::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigField {
pub field_type: ConfigFieldType,
pub required: bool,
pub default: Option<String>,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigFieldType {
String,
Integer,
Float,
Boolean,
Array,
Object,
}
pub struct PluginLoader {
search_paths: Vec<PathBuf>,
allowed_extensions: Vec<String>,
signature_verifier: Option<SignatureVerifier>,
}
#[derive(Debug, Default)]
pub struct SignatureVerifier {
keys: Vec<(String, ed25519_dalek::VerifyingKey)>,
}
impl SignatureVerifier {
pub fn from_trust_root(dir: &Path) -> Result<Self, PluginLoadError> {
use base64::Engine as _;
let mut keys = Vec::new();
let entries = fs::read_dir(dir).map_err(|e| {
PluginLoadError::IoError(format!("trust-root {}: {}", dir.display(), e))
})?;
for entry in entries {
let entry = entry.map_err(|e| PluginLoadError::IoError(e.to_string()))?;
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("pub") {
continue;
}
let raw = fs::read_to_string(&p).map_err(|e| {
PluginLoadError::IoError(format!("read {}: {}", p.display(), e))
})?;
let raw = raw.trim();
let bytes = base64::engine::general_purpose::STANDARD
.decode(raw)
.map_err(|e| {
PluginLoadError::SignatureInvalid(format!(
"{} not valid base64: {}",
p.display(),
e
))
})?;
if bytes.len() != 32 {
return Err(PluginLoadError::SignatureInvalid(format!(
"{} should be 32 bytes (raw Ed25519 pubkey), got {}",
p.display(),
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
let key = ed25519_dalek::VerifyingKey::from_bytes(&arr).map_err(|e| {
PluginLoadError::SignatureInvalid(format!(
"{} not a valid Ed25519 pubkey: {}",
p.display(),
e
))
})?;
let label = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("(unknown)")
.to_string();
keys.push((label, key));
}
Ok(Self { keys })
}
pub fn verify(&self, wasm: &[u8], sig_b64: &str) -> Result<&str, PluginLoadError> {
use base64::Engine as _;
use ed25519_dalek::Verifier;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(sig_b64.trim())
.map_err(|e| {
PluginLoadError::SignatureInvalid(format!("base64 decode: {}", e))
})?;
if sig_bytes.len() != 64 {
return Err(PluginLoadError::SignatureInvalid(format!(
"signature should be 64 bytes, got {}",
sig_bytes.len()
)));
}
let mut arr = [0u8; 64];
arr.copy_from_slice(&sig_bytes);
let sig = ed25519_dalek::Signature::from_bytes(&arr);
for (label, key) in &self.keys {
if key.verify(wasm, &sig).is_ok() {
return Ok(label.as_str());
}
}
Err(PluginLoadError::SignatureInvalid(
"signature did not match any trusted key".to_string(),
))
}
pub fn key_count(&self) -> usize {
self.keys.len()
}
}
impl PluginLoader {
pub fn new() -> Self {
Self {
search_paths: Vec::new(),
allowed_extensions: vec![
"wasm".to_string(),
"gz".to_string(), ],
signature_verifier: None,
}
}
pub fn with_signature_verifier(mut self, verifier: SignatureVerifier) -> Self {
self.signature_verifier = Some(verifier);
self
}
pub fn add_search_path(&mut self, path: PathBuf) {
self.search_paths.push(path);
}
pub fn load(&self, path: &Path) -> Result<(PluginManifest, Vec<u8>), PluginLoadError> {
if !path.exists() {
return Err(PluginLoadError::FileNotFound(path.display().to_string()));
}
if path.extension().and_then(|e| e.to_str()) == Some("gz") {
return self.load_tar_gz(path);
}
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !self.allowed_extensions.contains(&extension.to_string()) {
return Err(PluginLoadError::InvalidFormat(format!(
"Invalid extension: {}. Allowed: {:?}",
extension, self.allowed_extensions
)));
}
let wasm_bytes = fs::read(path)?;
if wasm_bytes.len() < 8 || &wasm_bytes[0..4] != b"\x00asm" {
return Err(PluginLoadError::InvalidFormat(
"Invalid WASM file (bad magic number)".to_string(),
));
}
if let Some(ref verifier) = self.signature_verifier {
let sig_path = path.with_extension("sig");
if !sig_path.exists() {
return Err(PluginLoadError::SignatureInvalid(format!(
"{} requires a sidecar .sig file (trust root active)",
path.display()
)));
}
let sig_b64 = fs::read_to_string(&sig_path).map_err(|e| {
PluginLoadError::IoError(format!("read {}: {}", sig_path.display(), e))
})?;
let label = verifier.verify(&wasm_bytes, &sig_b64)?;
tracing::info!(
plugin = %path.display(),
signed_by = %label,
"plugin signature verified"
);
}
let manifest = self.load_manifest(path, &wasm_bytes)?;
Ok((manifest, wasm_bytes))
}
fn load_tar_gz(&self, path: &Path) -> Result<(PluginManifest, Vec<u8>), PluginLoadError> {
use std::io::{Cursor, Read};
let raw = fs::read(path)?;
let gz = flate2::read::GzDecoder::new(Cursor::new(raw));
let mut archive = tar::Archive::new(gz);
let mut manifest_json: Option<Vec<u8>> = None;
let mut wasm_bytes: Option<Vec<u8>> = None;
let mut sig_bytes: Option<Vec<u8>> = None;
let entries = archive.entries().map_err(|e| {
PluginLoadError::InvalidFormat(format!("tar entries: {}", e))
})?;
for entry in entries {
let mut entry = entry.map_err(|e| {
PluginLoadError::InvalidFormat(format!("tar entry: {}", e))
})?;
let entry_path = entry
.path()
.map_err(|e| PluginLoadError::InvalidFormat(format!("tar path: {}", e)))?
.to_string_lossy()
.to_string();
let mut buf = Vec::new();
entry.read_to_end(&mut buf).map_err(|e| {
PluginLoadError::IoError(format!("tar read entry: {}", e))
})?;
match entry_path.as_str() {
"manifest.json" => manifest_json = Some(buf),
"plugin.wasm" => wasm_bytes = Some(buf),
"plugin.sig" => sig_bytes = Some(buf),
_ => {}
}
}
let manifest_json = manifest_json.ok_or_else(|| {
PluginLoadError::InvalidFormat(
"artefact missing manifest.json".to_string(),
)
})?;
let wasm = wasm_bytes.ok_or_else(|| {
PluginLoadError::InvalidFormat("artefact missing plugin.wasm".to_string())
})?;
let art: ArtefactManifest = serde_json::from_slice(&manifest_json).map_err(|e| {
PluginLoadError::ManifestError(format!("manifest.json: {}", e))
})?;
let major_ok = art
.schema_version
.split('.')
.next()
.map(|m| m == "1")
.unwrap_or(false);
if !major_ok {
return Err(PluginLoadError::InvalidFormat(format!(
"unsupported artefact schema version: {}",
art.schema_version
)));
}
let actual_hash = sha256_hex_local(&wasm);
if actual_hash != art.wasm_sha256 {
return Err(PluginLoadError::InvalidFormat(format!(
"wasm sha256 mismatch: manifest claims {}, actual {}",
art.wasm_sha256, actual_hash
)));
}
if wasm.len() < 8 || &wasm[0..4] != b"\x00asm" {
return Err(PluginLoadError::InvalidFormat(
"artefact plugin.wasm has bad magic number".to_string(),
));
}
if let Some(ref verifier) = self.signature_verifier {
let sig = sig_bytes.ok_or_else(|| {
PluginLoadError::SignatureInvalid(
"artefact has no signature but trust root is active".into(),
)
})?;
let sig_str = std::str::from_utf8(&sig).map_err(|e| {
PluginLoadError::SignatureInvalid(format!(
"signature must be UTF-8 base64: {}",
e
))
})?;
let label = verifier.verify(&wasm, sig_str)?;
tracing::info!(
artefact = %path.display(),
signed_by = %label,
"plugin artefact signature verified"
);
}
let mut hooks = Vec::with_capacity(art.hooks.len());
for h in &art.hooks {
if let Some(t) = super::HookType::from_str(h) {
hooks.push(t);
}
}
let manifest = PluginManifest {
name: art.name,
version: art.version,
description: art.description,
author: String::new(),
license: art.license,
hooks,
permissions: vec![],
min_memory: 1024 * 1024,
max_memory: 64 * 1024 * 1024,
config_schema: HashMap::new(),
path: path.to_path_buf(),
};
Ok((manifest, wasm))
}
fn load_manifest(&self, wasm_path: &Path, wasm_bytes: &[u8]) -> Result<PluginManifest, PluginLoadError> {
let yaml_path = wasm_path.with_extension("yaml");
if yaml_path.exists() {
return self.parse_yaml_manifest(&yaml_path, wasm_path);
}
let json_path = wasm_path.with_extension("json");
if json_path.exists() {
return self.parse_json_manifest(&json_path, wasm_path);
}
if let Some(manifest) = self.extract_embedded_manifest(wasm_bytes, wasm_path)? {
return Ok(manifest);
}
Ok(self.generate_minimal_manifest(wasm_path))
}
fn parse_yaml_manifest(&self, yaml_path: &Path, wasm_path: &Path) -> Result<PluginManifest, PluginLoadError> {
let content = fs::read_to_string(yaml_path)?;
let mut manifest = PluginManifest::default();
manifest.path = wasm_path.to_path_buf();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
match key {
"name" => manifest.name = value.to_string(),
"version" => manifest.version = value.to_string(),
"description" => manifest.description = value.to_string(),
"author" => manifest.author = value.to_string(),
"license" => manifest.license = value.to_string(),
_ => {}
}
}
}
if let Some(hooks_start) = content.find("hooks:") {
let hooks_section = &content[hooks_start..];
for line in hooks_section.lines().skip(1) {
let line = line.trim();
if line.is_empty() || !line.starts_with('-') {
if !line.starts_with(' ') && !line.is_empty() {
break;
}
continue;
}
let hook_name = line.trim_start_matches('-').trim();
if let Some(hook) = HookType::from_str(hook_name) {
manifest.hooks.push(hook);
}
}
}
if let Some(perms_start) = content.find("permissions:") {
let perms_section = &content[perms_start..];
for line in perms_section.lines().skip(1) {
let line = line.trim();
if line.is_empty() || !line.starts_with('-') {
if !line.starts_with(' ') && !line.is_empty() {
break;
}
continue;
}
let perm_name = line.trim_start_matches('-').trim();
if let Some(perm) = Permission::from_str(perm_name) {
manifest.permissions.push(perm);
}
}
}
self.validate_manifest(&manifest)?;
Ok(manifest)
}
fn parse_json_manifest(&self, json_path: &Path, wasm_path: &Path) -> Result<PluginManifest, PluginLoadError> {
let content = fs::read_to_string(json_path)?;
let json: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| PluginLoadError::ManifestError(e.to_string()))?;
let mut manifest = PluginManifest::default();
manifest.path = wasm_path.to_path_buf();
if let Some(name) = json.get("name").and_then(|v| v.as_str()) {
manifest.name = name.to_string();
}
if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
manifest.version = version.to_string();
}
if let Some(description) = json.get("description").and_then(|v| v.as_str()) {
manifest.description = description.to_string();
}
if let Some(author) = json.get("author").and_then(|v| v.as_str()) {
manifest.author = author.to_string();
}
if let Some(license) = json.get("license").and_then(|v| v.as_str()) {
manifest.license = license.to_string();
}
if let Some(hooks) = json.get("hooks").and_then(|v| v.as_array()) {
for hook in hooks {
if let Some(hook_name) = hook.as_str() {
if let Some(hook_type) = HookType::from_str(hook_name) {
manifest.hooks.push(hook_type);
}
}
}
}
if let Some(perms) = json.get("permissions").and_then(|v| v.as_array()) {
for perm in perms {
if let Some(perm_name) = perm.as_str() {
if let Some(permission) = Permission::from_str(perm_name) {
manifest.permissions.push(permission);
}
}
}
}
if let Some(resources) = json.get("resources") {
if let Some(min_mem) = resources.get("min_memory").and_then(|v| v.as_str()) {
manifest.min_memory = parse_memory_size(min_mem);
}
if let Some(max_mem) = resources.get("max_memory").and_then(|v| v.as_str()) {
manifest.max_memory = parse_memory_size(max_mem);
}
}
self.validate_manifest(&manifest)?;
Ok(manifest)
}
fn extract_embedded_manifest(
&self,
_wasm_bytes: &[u8],
wasm_path: &Path,
) -> Result<Option<PluginManifest>, PluginLoadError> {
let _ = wasm_path;
Ok(None)
}
fn generate_minimal_manifest(&self, wasm_path: &Path) -> PluginManifest {
let name = wasm_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
PluginManifest {
name,
version: "0.0.0".to_string(),
description: "Auto-generated manifest".to_string(),
author: "Unknown".to_string(),
license: "Unknown".to_string(),
hooks: Vec::new(), permissions: Vec::new(),
min_memory: 1024 * 1024,
max_memory: 64 * 1024 * 1024,
config_schema: HashMap::new(),
path: wasm_path.to_path_buf(),
}
}
fn validate_manifest(&self, manifest: &PluginManifest) -> Result<(), PluginLoadError> {
if manifest.name.is_empty() {
return Err(PluginLoadError::ValidationError(
"Plugin name is required".to_string(),
));
}
if manifest.name.len() > 128 {
return Err(PluginLoadError::ValidationError(
"Plugin name too long (max 128 chars)".to_string(),
));
}
if !manifest.name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return Err(PluginLoadError::ValidationError(
"Plugin name must be alphanumeric (hyphens and underscores allowed)".to_string(),
));
}
if !manifest.version.chars().all(|c| c.is_numeric() || c == '.') {
return Err(PluginLoadError::ValidationError(
"Invalid version format (expected semver)".to_string(),
));
}
if manifest.min_memory > manifest.max_memory {
return Err(PluginLoadError::ValidationError(
"min_memory cannot exceed max_memory".to_string(),
));
}
if manifest.max_memory > 256 * 1024 * 1024 {
return Err(PluginLoadError::ValidationError(
"max_memory cannot exceed 256MB".to_string(),
));
}
Ok(())
}
pub fn discover(&self) -> Result<Vec<PathBuf>, PluginLoadError> {
let mut plugins = Vec::new();
for search_path in &self.search_paths {
if !search_path.exists() || !search_path.is_dir() {
continue;
}
for entry in fs::read_dir(search_path)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if self.allowed_extensions.contains(&extension.to_string()) {
plugins.push(path);
}
}
}
Ok(plugins)
}
}
impl Default for PluginLoader {
fn default() -> Self {
Self::new()
}
}
fn parse_memory_size(s: &str) -> usize {
let s = s.trim().to_uppercase();
if let Some(mb) = s.strip_suffix("MB") {
mb.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024
} else if let Some(kb) = s.strip_suffix("KB") {
kb.trim().parse::<usize>().unwrap_or(0) * 1024
} else if let Some(gb) = s.strip_suffix("GB") {
gb.trim().parse::<usize>().unwrap_or(0) * 1024 * 1024 * 1024
} else {
s.parse::<usize>().unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_load_error_display() {
let err = PluginLoadError::FileNotFound("/test.wasm".to_string());
assert!(err.to_string().contains("File not found"));
let err = PluginLoadError::ManifestError("invalid".to_string());
assert!(err.to_string().contains("Manifest error"));
}
#[test]
fn test_plugin_manifest_default() {
let manifest = PluginManifest::default();
assert!(manifest.name.is_empty());
assert_eq!(manifest.version, "0.0.0");
assert!(manifest.hooks.is_empty());
}
#[test]
fn test_plugin_loader_new() {
let loader = PluginLoader::new();
assert!(loader.search_paths.is_empty());
assert!(loader.allowed_extensions.contains(&"wasm".to_string()));
}
#[test]
fn test_parse_memory_size() {
assert_eq!(parse_memory_size("64MB"), 64 * 1024 * 1024);
assert_eq!(parse_memory_size("1024KB"), 1024 * 1024);
assert_eq!(parse_memory_size("1GB"), 1024 * 1024 * 1024);
assert_eq!(parse_memory_size("1048576"), 1048576);
}
#[test]
fn test_manifest_validation_empty_name() {
let loader = PluginLoader::new();
let manifest = PluginManifest::default();
let result = loader.validate_manifest(&manifest);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("name is required"));
}
#[test]
fn test_manifest_validation_invalid_memory() {
let loader = PluginLoader::new();
let mut manifest = PluginManifest::default();
manifest.name = "test-plugin".to_string();
manifest.min_memory = 100 * 1024 * 1024;
manifest.max_memory = 50 * 1024 * 1024;
let result = loader.validate_manifest(&manifest);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("min_memory"));
}
#[test]
fn test_manifest_validation_success() {
let loader = PluginLoader::new();
let mut manifest = PluginManifest::default();
manifest.name = "test-plugin".to_string();
let result = loader.validate_manifest(&manifest);
assert!(result.is_ok());
}
#[test]
fn test_generate_minimal_manifest() {
let loader = PluginLoader::new();
let path = PathBuf::from("/plugins/my-plugin.wasm");
let manifest = loader.generate_minimal_manifest(&path);
assert_eq!(manifest.name, "my-plugin");
assert_eq!(manifest.version, "0.0.0");
}
#[test]
fn test_config_field_type() {
assert_eq!(ConfigFieldType::String, ConfigFieldType::String);
assert_ne!(ConfigFieldType::String, ConfigFieldType::Integer);
}
use base64::Engine as _;
use ed25519_dalek::{Signer, SigningKey};
fn write_pub_key(dir: &Path, label: &str, key: &SigningKey) {
let pub_bytes = key.verifying_key().to_bytes();
let b64 = base64::engine::general_purpose::STANDARD.encode(pub_bytes);
std::fs::write(dir.join(format!("{label}.pub")), b64).unwrap();
}
fn make_signing_key() -> SigningKey {
let seed = [7u8; 32];
SigningKey::from_bytes(&seed)
}
#[test]
fn test_signature_verifier_accepts_matching_signature() {
let dir = tempfile::tempdir().unwrap();
let key = make_signing_key();
write_pub_key(dir.path(), "official", &key);
let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
assert_eq!(verifier.key_count(), 1);
let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
let sig = key.sign(wasm);
let sig_b64 =
base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
let label = verifier.verify(wasm, &sig_b64).unwrap();
assert_eq!(label, "official");
}
#[test]
fn test_signature_verifier_rejects_tampered_bytes() {
let dir = tempfile::tempdir().unwrap();
let key = make_signing_key();
write_pub_key(dir.path(), "official", &key);
let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
let sig = key.sign(wasm);
let sig_b64 =
base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
let tampered = b"\x00asm\x01\x00\x00\x00pretend-real-wasn"; let err = verifier.verify(tampered, &sig_b64).unwrap_err();
assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
}
#[test]
fn test_signature_verifier_rejects_unknown_signer() {
let dir = tempfile::tempdir().unwrap();
let trusted = make_signing_key();
write_pub_key(dir.path(), "official", &trusted);
let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
let attacker = SigningKey::from_bytes(&[0xAB; 32]);
let wasm = b"\x00asm\x01\x00\x00\x00pretend-real-wasm";
let sig = attacker.sign(wasm);
let sig_b64 =
base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
let err = verifier.verify(wasm, &sig_b64).unwrap_err();
assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
}
#[test]
fn test_signature_verifier_rejects_wrong_length_pubkey() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("bad.pub"),
base64::engine::general_purpose::STANDARD.encode([0u8; 31]),
)
.unwrap();
let err = SignatureVerifier::from_trust_root(dir.path()).unwrap_err();
assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
}
#[test]
fn test_signature_verifier_supports_multiple_keys() {
let dir = tempfile::tempdir().unwrap();
let k1 = SigningKey::from_bytes(&[1u8; 32]);
let k2 = SigningKey::from_bytes(&[2u8; 32]);
write_pub_key(dir.path(), "publisher-a", &k1);
write_pub_key(dir.path(), "publisher-b", &k2);
let verifier = SignatureVerifier::from_trust_root(dir.path()).unwrap();
assert_eq!(verifier.key_count(), 2);
let wasm = b"\x00asm\x01\x00\x00\x00abc";
let sig = k2.sign(wasm); let sig_b64 =
base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
let label = verifier.verify(wasm, &sig_b64).unwrap();
assert_eq!(label, "publisher-b");
}
#[test]
fn test_loader_with_verifier_rejects_unsigned_plugin() {
let dir = tempfile::tempdir().unwrap();
let wasm_path = dir.path().join("plugin.wasm");
std::fs::write(&wasm_path, b"\x00asm\x01\x00\x00\x00body").unwrap();
let trust_dir = tempfile::tempdir().unwrap();
let key = make_signing_key();
write_pub_key(trust_dir.path(), "official", &key);
let loader = PluginLoader::new()
.with_signature_verifier(SignatureVerifier::from_trust_root(trust_dir.path()).unwrap());
let err = loader.load(&wasm_path).unwrap_err();
assert!(
matches!(err, PluginLoadError::SignatureInvalid(_)),
"expected SignatureInvalid for missing .sig, got {:?}",
err
);
}
use flate2::write::GzEncoder;
use flate2::Compression;
use sha2::{Digest, Sha256};
fn fake_wasm(extra: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
v.extend_from_slice(extra);
v
}
fn sha256_hex(bytes: &[u8]) -> String {
let d = Sha256::digest(bytes);
let mut s = String::new();
for b in d.iter() {
s.push_str(&format!("{:02x}", b));
}
s
}
fn pack_tarball(
dir: &Path,
name: &str,
wasm: &[u8],
sig: Option<&[u8]>,
) -> std::path::PathBuf {
let manifest = serde_json::json!({
"schema_version": "1.0",
"name": name,
"version": "0.1.0",
"description": "test",
"license": "Apache-2.0",
"hooks": ["pre_query", "post_query"],
"wasm_sha256": sha256_hex(wasm),
"signature_sha256": sig.map(sha256_hex),
"signature_algorithm": sig.map(|_| "ed25519"),
"packed_at": "2026-04-25T13:00:00Z",
});
let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
let out_path = dir.join(format!("{}.tar.gz", name));
let f = std::fs::File::create(&out_path).unwrap();
let gz = GzEncoder::new(f, Compression::default());
let mut tar = tar::Builder::new(gz);
let mut put = |path: &str, body: &[u8]| {
let mut h = tar::Header::new_gnu();
h.set_path(path).unwrap();
h.set_size(body.len() as u64);
h.set_mode(0o644);
h.set_cksum();
tar.append(&h, body).unwrap();
};
put("manifest.json", &manifest_bytes);
put("plugin.wasm", wasm);
if let Some(s) = sig {
put("plugin.sig", s);
}
let gz = tar.into_inner().unwrap();
gz.finish().unwrap();
out_path
}
#[test]
fn test_loader_accepts_tar_gz_artefact_without_signature() {
let dir = tempfile::tempdir().unwrap();
let wasm = fake_wasm(b"unsigned");
let path = pack_tarball(dir.path(), "test-plugin", &wasm, None);
let loader = PluginLoader::new();
let (manifest, bytes) = loader.load(&path).unwrap();
assert_eq!(manifest.name, "test-plugin");
assert_eq!(manifest.version, "0.1.0");
assert_eq!(bytes, wasm);
assert!(manifest.hooks.contains(&super::super::HookType::PreQuery));
assert!(manifest.hooks.contains(&super::super::HookType::PostQuery));
}
#[test]
fn test_loader_rejects_tar_gz_with_wrong_wasm_hash() {
let dir = tempfile::tempdir().unwrap();
let real_wasm = fake_wasm(b"real");
let manifest = serde_json::json!({
"schema_version": "1.0",
"name": "x",
"version": "0.1.0",
"description": "",
"license": "Apache-2.0",
"hooks": [],
"wasm_sha256": "deadbeef".repeat(8), "packed_at": "2026-04-25T13:00:00Z",
});
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
let out_path = dir.path().join("bad.tar.gz");
let f = std::fs::File::create(&out_path).unwrap();
let gz = GzEncoder::new(f, Compression::default());
let mut tar = tar::Builder::new(gz);
let mut put = |path: &str, body: &[u8]| {
let mut h = tar::Header::new_gnu();
h.set_path(path).unwrap();
h.set_size(body.len() as u64);
h.set_mode(0o644);
h.set_cksum();
tar.append(&h, body).unwrap();
};
put("manifest.json", &manifest_bytes);
put("plugin.wasm", &real_wasm);
let gz = tar.into_inner().unwrap();
gz.finish().unwrap();
let loader = PluginLoader::new();
let err = loader.load(&out_path).unwrap_err();
match err {
PluginLoadError::InvalidFormat(msg) => {
assert!(msg.contains("sha256 mismatch"), "got {}", msg)
}
other => panic!("expected InvalidFormat, got {:?}", other),
}
}
#[test]
fn test_loader_rejects_tar_gz_unknown_schema_major() {
let dir = tempfile::tempdir().unwrap();
let wasm = fake_wasm(b"x");
let manifest = serde_json::json!({
"schema_version": "9.0",
"name": "x",
"version": "0.1.0",
"description": "",
"license": "Apache-2.0",
"hooks": [],
"wasm_sha256": sha256_hex(&wasm),
"packed_at": "2026-04-25T13:00:00Z",
});
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
let out_path = dir.path().join("future.tar.gz");
let f = std::fs::File::create(&out_path).unwrap();
let gz = GzEncoder::new(f, Compression::default());
let mut tar = tar::Builder::new(gz);
let mut put = |path: &str, body: &[u8]| {
let mut h = tar::Header::new_gnu();
h.set_path(path).unwrap();
h.set_size(body.len() as u64);
h.set_mode(0o644);
h.set_cksum();
tar.append(&h, body).unwrap();
};
put("manifest.json", &manifest_bytes);
put("plugin.wasm", &wasm);
let gz = tar.into_inner().unwrap();
gz.finish().unwrap();
let loader = PluginLoader::new();
let err = loader.load(&out_path).unwrap_err();
match err {
PluginLoadError::InvalidFormat(msg) => {
assert!(msg.contains("schema version"), "got {}", msg)
}
other => panic!("expected InvalidFormat, got {:?}", other),
}
}
#[test]
fn test_loader_tar_gz_signature_verifies_against_trust_root() {
let dir = tempfile::tempdir().unwrap();
let key = make_signing_key();
let wasm = fake_wasm(b"signed-body");
use ed25519_dalek::Signer;
let sig = key.sign(&wasm);
let sig_b64 = base64::engine::general_purpose::STANDARD
.encode(sig.to_bytes())
.into_bytes();
let path = pack_tarball(dir.path(), "signed-plugin", &wasm, Some(&sig_b64));
let trust_dir = tempfile::tempdir().unwrap();
write_pub_key(trust_dir.path(), "official", &key);
let loader = PluginLoader::new()
.with_signature_verifier(
SignatureVerifier::from_trust_root(trust_dir.path()).unwrap(),
);
let (manifest, bytes) = loader.load(&path).unwrap();
assert_eq!(manifest.name, "signed-plugin");
assert_eq!(bytes, wasm);
}
#[test]
fn test_loader_tar_gz_rejects_missing_signature_when_trust_root_active() {
let dir = tempfile::tempdir().unwrap();
let wasm = fake_wasm(b"unsigned");
let path = pack_tarball(dir.path(), "p", &wasm, None);
let trust_dir = tempfile::tempdir().unwrap();
let key = make_signing_key();
write_pub_key(trust_dir.path(), "official", &key);
let loader = PluginLoader::new()
.with_signature_verifier(
SignatureVerifier::from_trust_root(trust_dir.path()).unwrap(),
);
let err = loader.load(&path).unwrap_err();
assert!(matches!(err, PluginLoadError::SignatureInvalid(_)));
}
}