use super::*;
use crate::git::{GitPluginConfig, GitPluginLoader, GitPluginSource};
use crate::loader::PluginLoader;
use crate::metadata::{MetadataStore, PluginMetadata};
use crate::remote::{RemotePluginConfig, RemotePluginLoader};
use crate::signature::{has_signature_file, SignatureVerifier, SIGNATURE_FILE};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub enum PluginSource {
Local(PathBuf),
Url {
url: String,
checksum: Option<String>,
},
Git(GitPluginSource),
Registry {
name: String,
version: Option<String>,
},
}
impl PluginSource {
pub fn parse(input: &str) -> LoaderResult<Self> {
let input = input.trim();
if input.starts_with("http://") || input.starts_with("https://") {
if input.contains(".git")
|| input.contains("github.com")
|| input.contains("gitlab.com")
{
let source = GitPluginSource::parse(input)?;
return Ok(PluginSource::Git(source));
}
return Ok(PluginSource::Url {
url: input.to_string(),
checksum: None,
});
}
if input.starts_with("git@") {
let source = GitPluginSource::parse(input)?;
return Ok(PluginSource::Git(source));
}
if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
return Ok(PluginSource::Local(PathBuf::from(input)));
}
let (name, version) = if let Some((n, v)) = input.split_once('@') {
(n.to_string(), Some(v.to_string()))
} else {
(input.to_string(), None)
};
Ok(PluginSource::Registry { name, version })
}
}
impl std::fmt::Display for PluginSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginSource::Local(path) => write!(f, "local:{}", path.display()),
PluginSource::Url { url, .. } => write!(f, "url:{}", url),
PluginSource::Git(source) => write!(f, "git:{}", source),
PluginSource::Registry { name, version } => {
if let Some(v) = version {
write!(f, "registry:{}@{}", name, v)
} else {
write!(f, "registry:{}", name)
}
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct InstallOptions {
pub force: bool,
pub skip_validation: bool,
pub allow_unsigned: bool,
pub expected_checksum: Option<String>,
}
pub struct PluginInstaller {
loader: PluginLoader,
remote_loader: RemotePluginLoader,
git_loader: GitPluginLoader,
config: PluginLoaderConfig,
metadata_store: std::sync::Arc<tokio::sync::RwLock<MetadataStore>>,
}
impl PluginInstaller {
pub fn new(loader_config: PluginLoaderConfig) -> LoaderResult<Self> {
let loader = PluginLoader::new(loader_config.clone());
let remote_loader = RemotePluginLoader::new(RemotePluginConfig::default())?;
let git_loader = GitPluginLoader::new(GitPluginConfig::default())?;
let metadata_dir = shellexpand::tilde("~/.mockforge/plugin-metadata");
let metadata_store = MetadataStore::new(PathBuf::from(metadata_dir.as_ref()));
Ok(Self {
loader,
remote_loader,
git_loader,
config: loader_config,
metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(metadata_store)),
})
}
pub async fn init(&self) -> LoaderResult<()> {
let mut store = self.metadata_store.write().await;
store.load().await
}
pub async fn install(
&self,
source_str: &str,
options: InstallOptions,
) -> LoaderResult<PluginId> {
let source = PluginSource::parse(source_str)?;
self.install_from_source(&source, options).await
}
pub async fn install_from_source(
&self,
source: &PluginSource,
options: InstallOptions,
) -> LoaderResult<PluginId> {
tracing::info!("Installing plugin from source: {}", source);
let plugin_dir = match source {
PluginSource::Local(path) => path.clone(),
PluginSource::Url { url, checksum } => {
let checksum_ref = checksum.as_deref().or(options.expected_checksum.as_deref());
self.remote_loader.download_with_checksum(url, checksum_ref).await?
}
PluginSource::Git(git_source) => self.git_loader.clone_from_git(git_source).await?,
PluginSource::Registry { name, version } => {
self.install_from_registry(name, version.as_deref(), &options).await?
}
};
self.enforce_plugin_signature(
&plugin_dir,
source,
options.allow_unsigned || self.config.allow_unsigned,
)?;
if !options.skip_validation {
self.loader.validate_plugin(&plugin_dir).await?;
}
let manifest = self.loader.validate_plugin(&plugin_dir).await?;
let plugin_id = manifest.info.id.clone();
if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
return Err(PluginLoaderError::already_loaded(plugin_id));
}
self.loader.load_plugin_from_path(&plugin_dir).await?;
let version = manifest.info.version.to_string();
let metadata = PluginMetadata::new(plugin_id.clone(), source.clone(), version);
let mut store = self.metadata_store.write().await;
store.save(metadata).await?;
tracing::info!("Plugin installed successfully: {}", plugin_id);
Ok(plugin_id)
}
async fn install_from_registry(
&self,
name: &str,
version: Option<&str>,
options: &InstallOptions,
) -> LoaderResult<PathBuf> {
let base_url = std::env::var("MOCKFORGE_PLUGIN_REGISTRY_URL")
.unwrap_or_else(|_| "https://registry.mockforge.dev".to_string());
let client = reqwest::Client::new();
let (download_url, checksum) = if let Some(v) = version {
let version_url = format!("{}/api/v1/plugins/{}/versions/{}", base_url, name, v);
let response = client.get(&version_url).send().await.map_err(|e| {
PluginLoaderError::load(format!(
"Failed to fetch registry version metadata for {}@{}: {}",
name, v, e
))
})?;
if !response.status().is_success() {
return Err(PluginLoaderError::load(format!(
"Registry lookup failed for {}@{}: {}",
name,
v,
response.status()
)));
}
let entry: RegistryVersionResponse = response.json().await.map_err(|e| {
PluginLoaderError::load(format!(
"Invalid registry response for {}@{}: {}",
name, v, e
))
})?;
(entry.download_url, entry.checksum)
} else {
let plugin_url = format!("{}/api/v1/plugins/{}", base_url, name);
let response = client.get(&plugin_url).send().await.map_err(|e| {
PluginLoaderError::load(format!(
"Failed to fetch registry plugin metadata for {}: {}",
name, e
))
})?;
if !response.status().is_success() {
return Err(PluginLoaderError::load(format!(
"Registry lookup failed for {}: {}",
name,
response.status()
)));
}
let entry: RegistryPluginResponse = response.json().await.map_err(|e| {
PluginLoaderError::load(format!("Invalid registry response for {}: {}", name, e))
})?;
let selected = select_registry_version(&entry).ok_or_else(|| {
PluginLoaderError::load(format!(
"No installable versions found for plugin '{}'",
name
))
})?;
(selected.download_url.clone(), selected.checksum.clone())
};
let checksum_ref = options.expected_checksum.as_deref().or(checksum.as_deref());
self.remote_loader.download_with_checksum(&download_url, checksum_ref).await
}
fn enforce_plugin_signature(
&self,
plugin_dir: &Path,
source: &PluginSource,
allow_unsigned: bool,
) -> LoaderResult<()> {
if !has_signature_file(plugin_dir) {
if allow_unsigned {
tracing::warn!(
source = %source,
"INSTALLING UNSIGNED PLUGIN: signature verification skipped because \
unsigned plugins were explicitly allowed. Only do this for local \
development plugins you built yourself."
);
return Ok(());
}
return Err(PluginLoaderError::security(format!(
"Plugin from {} is unsigned (no {}). Refusing to install. For a local \
development plugin, pass --allow-unsigned.",
source, SIGNATURE_FILE
)));
}
let strict = PluginLoaderConfig {
allow_unsigned: false,
..self.config.clone()
};
SignatureVerifier::new(&strict)
.verify_plugin_signature(plugin_dir)
.map_err(|e| {
PluginLoaderError::security(format!(
"Plugin signature verification failed for {}: {}",
source, e
))
})
}
pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
self.loader.unload_plugin(plugin_id).await?;
let mut store = self.metadata_store.write().await;
store.remove(plugin_id).await?;
Ok(())
}
pub async fn list_installed(&self) -> Vec<PluginId> {
self.loader.list_plugins().await
}
pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
self.update_with_options(plugin_id, false).await
}
pub async fn update_with_options(
&self,
plugin_id: &PluginId,
allow_unsigned: bool,
) -> LoaderResult<()> {
tracing::info!("Updating plugin: {}", plugin_id);
let metadata = {
let store = self.metadata_store.read().await;
store.get(plugin_id).cloned().ok_or_else(|| {
PluginLoaderError::load(format!(
"No installation metadata found for plugin {}. Cannot update.",
plugin_id
))
})?
};
tracing::info!("Updating plugin {} from source: {}", plugin_id, metadata.source);
if self.loader.get_plugin(plugin_id).await.is_some() {
self.loader.unload_plugin(plugin_id).await?;
}
let options = InstallOptions {
force: true,
skip_validation: false,
allow_unsigned,
expected_checksum: None,
};
let new_plugin_id = self.install_from_source(&metadata.source, options).await?;
if new_plugin_id != *plugin_id {
return Err(PluginLoaderError::load(format!(
"Plugin ID mismatch after update: expected {}, got {}",
plugin_id, new_plugin_id
)));
}
let new_manifest = self
.loader
.get_plugin(&new_plugin_id)
.await
.ok_or_else(|| PluginLoaderError::load("Failed to get updated plugin"))?
.manifest;
let mut store = self.metadata_store.write().await;
if let Some(meta) = store.get(plugin_id).cloned() {
let mut updated_meta = meta;
updated_meta.mark_updated(new_manifest.info.version.to_string());
store.save(updated_meta).await?;
}
tracing::info!("Plugin {} updated successfully", plugin_id);
Ok(())
}
pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
self.update_all_with_options(false).await
}
pub async fn update_all_with_options(
&self,
allow_unsigned: bool,
) -> LoaderResult<Vec<PluginId>> {
tracing::info!("Updating all plugins");
let plugin_ids = {
let store = self.metadata_store.read().await;
store.list()
};
if plugin_ids.is_empty() {
tracing::info!("No plugins found with metadata to update");
return Ok(Vec::new());
}
tracing::info!("Found {} plugins to update", plugin_ids.len());
let mut updated = Vec::new();
let mut failed = Vec::new();
for plugin_id in plugin_ids {
match self.update_with_options(&plugin_id, allow_unsigned).await {
Ok(_) => {
tracing::info!("Successfully updated plugin: {}", plugin_id);
updated.push(plugin_id);
}
Err(e) => {
tracing::warn!("Failed to update plugin {}: {}", plugin_id, e);
failed.push((plugin_id, e.to_string()));
}
}
}
tracing::info!(
"Plugin update complete: {} succeeded, {} failed",
updated.len(),
failed.len()
);
if !failed.is_empty() {
let failed_list = failed
.iter()
.map(|(id, err)| format!("{}: {}", id, err))
.collect::<Vec<_>>()
.join(", ");
tracing::warn!("Failed updates: {}", failed_list);
}
Ok(updated)
}
pub async fn clear_caches(&self) -> LoaderResult<()> {
self.remote_loader.clear_cache().await?;
self.git_loader.clear_cache().await?;
Ok(())
}
pub async fn get_cache_stats(&self) -> LoaderResult<CacheStats> {
let download_cache_size = self.remote_loader.get_cache_size()?;
let git_cache_size = self.git_loader.get_cache_size()?;
Ok(CacheStats {
download_cache_size,
git_cache_size,
total_size: download_cache_size + git_cache_size,
})
}
pub async fn get_plugin_metadata(&self, plugin_id: &PluginId) -> Option<PluginMetadata> {
let store = self.metadata_store.read().await;
store.get(plugin_id).cloned()
}
pub async fn list_plugins_with_metadata(&self) -> Vec<(PluginId, PluginMetadata)> {
let store = self.metadata_store.read().await;
store
.list()
.into_iter()
.filter_map(|id| store.get(&id).map(|meta| (id, meta.clone())))
.collect()
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegistryVersionResponse {
download_url: String,
#[serde(default)]
checksum: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegistryPluginResponse {
version: String,
versions: Vec<RegistryVersionResponseWithVersion>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegistryVersionResponseWithVersion {
version: String,
download_url: String,
#[serde(default)]
checksum: Option<String>,
#[serde(default)]
yanked: bool,
}
fn select_registry_version(
entry: &RegistryPluginResponse,
) -> Option<&RegistryVersionResponseWithVersion> {
if let Some(preferred) = entry.versions.iter().find(|v| v.version == entry.version && !v.yanked)
{
return Some(preferred);
}
entry.versions.iter().find(|v| !v.yanked)
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub download_cache_size: u64,
pub git_cache_size: u64,
pub total_size: u64,
}
impl CacheStats {
pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} bytes", bytes)
}
}
pub fn download_cache_formatted(&self) -> String {
Self::format_size(self.download_cache_size)
}
pub fn git_cache_formatted(&self) -> String {
Self::format_size(self.git_cache_size)
}
pub fn total_formatted(&self) -> String {
Self::format_size(self.total_size)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_source_parse_url() {
let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
assert!(matches!(source, PluginSource::Url { .. }));
}
#[test]
fn test_plugin_source_parse_git_https() {
let source = PluginSource::parse("https://github.com/user/repo").unwrap();
assert!(matches!(source, PluginSource::Git(_)));
}
#[test]
fn test_plugin_source_parse_git_ssh() {
let source = PluginSource::parse("git@github.com:user/repo.git").unwrap();
assert!(matches!(source, PluginSource::Git(_)));
}
#[test]
fn test_plugin_source_parse_gitlab() {
let source = PluginSource::parse("https://gitlab.com/user/repo").unwrap();
assert!(matches!(source, PluginSource::Git(_)));
}
#[test]
fn test_plugin_source_parse_local() {
let source = PluginSource::parse("/path/to/plugin").unwrap();
assert!(matches!(source, PluginSource::Local(_)));
let source = PluginSource::parse("./relative/path").unwrap();
assert!(matches!(source, PluginSource::Local(_)));
}
#[test]
fn test_plugin_source_parse_registry() {
let source = PluginSource::parse("auth-jwt").unwrap();
assert!(matches!(source, PluginSource::Registry { .. }));
let source = PluginSource::parse("auth-jwt@1.0.0").unwrap();
if let PluginSource::Registry { name, version } = source {
assert_eq!(name, "auth-jwt");
assert_eq!(version, Some("1.0.0".to_string()));
} else {
panic!("Expected Registry source");
}
}
#[test]
fn test_plugin_source_parse_registry_without_version() {
let source = PluginSource::parse("my-plugin").unwrap();
if let PluginSource::Registry { name, version } = source {
assert_eq!(name, "my-plugin");
assert!(version.is_none());
} else {
panic!("Expected Registry source");
}
}
#[test]
fn test_plugin_source_parse_url_with_checksum() {
let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
if let PluginSource::Url { url, checksum } = source {
assert_eq!(url, "https://example.com/plugin.zip");
assert!(checksum.is_none());
} else {
panic!("Expected URL source");
}
}
#[test]
fn test_plugin_source_parse_empty_string() {
let source = PluginSource::parse("").unwrap();
assert!(matches!(source, PluginSource::Registry { .. }));
}
#[test]
fn test_plugin_source_parse_whitespace() {
let source = PluginSource::parse(" https://example.com/plugin.zip ").unwrap();
assert!(matches!(source, PluginSource::Url { .. }));
}
#[test]
fn test_plugin_source_display() {
let source = PluginSource::Local(PathBuf::from("/tmp/plugin"));
assert_eq!(source.to_string(), "local:/tmp/plugin");
let source = PluginSource::Url {
url: "https://example.com/plugin.zip".to_string(),
checksum: None,
};
assert_eq!(source.to_string(), "url:https://example.com/plugin.zip");
let source = PluginSource::Registry {
name: "my-plugin".to_string(),
version: Some("1.0.0".to_string()),
};
assert_eq!(source.to_string(), "registry:my-plugin@1.0.0");
let source = PluginSource::Registry {
name: "my-plugin".to_string(),
version: None,
};
assert_eq!(source.to_string(), "registry:my-plugin");
}
#[test]
fn test_plugin_source_clone() {
let source = PluginSource::Local(PathBuf::from("/tmp"));
let cloned = source.clone();
assert_eq!(source.to_string(), cloned.to_string());
}
#[test]
fn test_install_options_default() {
let options = InstallOptions::default();
assert!(!options.force);
assert!(!options.skip_validation);
assert!(!options.allow_unsigned);
assert!(options.expected_checksum.is_none());
}
#[test]
fn test_install_options_with_force() {
let options = InstallOptions {
force: true,
..Default::default()
};
assert!(options.force);
}
#[test]
fn test_install_options_with_checksum() {
let options = InstallOptions {
expected_checksum: Some("abc123".to_string()),
..Default::default()
};
assert_eq!(options.expected_checksum, Some("abc123".to_string()));
}
#[test]
fn test_install_options_skip_validation() {
let options = InstallOptions {
skip_validation: true,
..Default::default()
};
assert!(options.skip_validation);
assert!(!options.allow_unsigned);
}
#[test]
fn test_install_options_clone() {
let options = InstallOptions {
force: true,
skip_validation: false,
allow_unsigned: false,
expected_checksum: Some("test".to_string()),
};
let cloned = options.clone();
assert_eq!(options.force, cloned.force);
assert_eq!(options.expected_checksum, cloned.expected_checksum);
}
#[test]
fn test_cache_stats_formatting() {
assert_eq!(CacheStats::format_size(512), "512 bytes");
assert_eq!(CacheStats::format_size(1024), "1.00 KB");
assert_eq!(CacheStats::format_size(1024 * 1024), "1.00 MB");
assert_eq!(CacheStats::format_size(1024 * 1024 * 1024), "1.00 GB");
}
#[test]
fn test_cache_stats_edge_cases() {
assert_eq!(CacheStats::format_size(0), "0 bytes");
assert_eq!(CacheStats::format_size(1), "1 bytes");
assert_eq!(CacheStats::format_size(1023), "1023 bytes");
assert_eq!(CacheStats::format_size(1025), "1.00 KB");
}
#[test]
fn test_cache_stats_large_values() {
let tb = 1024u64 * 1024 * 1024 * 1024;
assert!(CacheStats::format_size(tb).contains("GB"));
}
#[test]
fn test_cache_stats_formatted_methods() {
let stats = CacheStats {
download_cache_size: 1024 * 1024,
git_cache_size: 2 * 1024 * 1024,
total_size: 3 * 1024 * 1024,
};
assert_eq!(stats.download_cache_formatted(), "1.00 MB");
assert_eq!(stats.git_cache_formatted(), "2.00 MB");
assert_eq!(stats.total_formatted(), "3.00 MB");
}
#[test]
fn test_cache_stats_total_calculation() {
let stats = CacheStats {
download_cache_size: 100,
git_cache_size: 200,
total_size: 300,
};
assert_eq!(stats.total_size, stats.download_cache_size + stats.git_cache_size);
}
#[test]
fn test_cache_stats_clone() {
let stats = CacheStats {
download_cache_size: 1024,
git_cache_size: 2048,
total_size: 3072,
};
let cloned = stats.clone();
assert_eq!(stats.download_cache_size, cloned.download_cache_size);
assert_eq!(stats.git_cache_size, cloned.git_cache_size);
assert_eq!(stats.total_size, cloned.total_size);
}
#[test]
fn test_plugin_source_parse_http_url() {
let source = PluginSource::parse("http://example.com/plugin.zip").unwrap();
assert!(matches!(source, PluginSource::Url { .. }));
}
#[test]
fn test_plugin_source_parse_windows_path() {
let source = PluginSource::parse("C:\\Users\\plugin").unwrap();
assert!(matches!(source, PluginSource::Local(_)));
}
#[test]
fn test_plugin_source_parse_registry_with_special_chars() {
let source = PluginSource::parse("my-plugin-name_v2@2.0.0-beta").unwrap();
if let PluginSource::Registry { name, version } = source {
assert_eq!(name, "my-plugin-name_v2");
assert_eq!(version, Some("2.0.0-beta".to_string()));
} else {
panic!("Expected Registry source");
}
}
#[test]
fn test_plugin_source_parse_github_dotgit_in_url() {
let source = PluginSource::parse("https://github.com/user/repo.git").unwrap();
assert!(matches!(source, PluginSource::Git(_)));
}
#[test]
fn test_select_registry_version_prefers_current_non_yanked() {
let entry = RegistryPluginResponse {
version: "2.0.0".to_string(),
versions: vec![
RegistryVersionResponseWithVersion {
version: "1.0.0".to_string(),
download_url: "https://example.com/1.0.0.wasm".to_string(),
checksum: None,
yanked: false,
},
RegistryVersionResponseWithVersion {
version: "2.0.0".to_string(),
download_url: "https://example.com/2.0.0.wasm".to_string(),
checksum: Some("abc".to_string()),
yanked: false,
},
],
};
let selected = select_registry_version(&entry).expect("expected selected version");
assert_eq!(selected.version, "2.0.0");
assert_eq!(selected.download_url, "https://example.com/2.0.0.wasm");
}
mod signature_enforcement {
use super::*;
use crate::signature::PluginSignature;
use crate::signature_gen::{generate_ed25519_keypair, sign_plugin_ed25519};
use chrono::{Duration as ChronoDuration, Utc};
use tempfile::TempDir;
const KEY_ID: &str = "publisher-key";
struct Fixture {
_home: TempDir,
installer: PluginInstaller,
private_key: Vec<u8>,
}
fn installer_with(config: PluginLoaderConfig, home: &TempDir) -> PluginInstaller {
let remote_loader = RemotePluginLoader::new(RemotePluginConfig {
cache_dir: home.path().join("downloads"),
..Default::default()
})
.expect("temp cache dir is writable");
let git_loader = GitPluginLoader::new(GitPluginConfig {
cache_dir: home.path().join("git"),
..Default::default()
})
.expect("temp cache dir is writable");
PluginInstaller {
loader: PluginLoader::new(config.clone()),
remote_loader,
git_loader,
config,
metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(MetadataStore::new(
home.path().join("metadata"),
))),
}
}
fn fixture(tweak: impl FnOnce(&mut PluginLoaderConfig, &[u8])) -> Fixture {
let home = TempDir::new().expect("tempdir");
let (private_key, public_key) = generate_ed25519_keypair().expect("keygen");
let mut config = PluginLoaderConfig {
trusted_keys: vec![KEY_ID.to_string()],
..Default::default()
};
config.key_data.insert(KEY_ID.to_string(), public_key.clone());
tweak(&mut config, &public_key);
let installer = installer_with(config, &home);
Fixture {
_home: home,
installer,
private_key,
}
}
fn plugin_dir(wasm: &[u8]) -> TempDir {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join("plugin.yaml"), "id: sig-test\n").expect("write");
std::fs::write(dir.path().join("plugin.wasm"), wasm).expect("write");
dir
}
async fn install(
fx: &Fixture,
dir: &TempDir,
options: InstallOptions,
) -> LoaderResult<PluginId> {
let source = PluginSource::Local(dir.path().to_path_buf());
fx.installer.install_from_source(&source, options).await
}
fn assert_passed_signature_gate(result: LoaderResult<PluginId>) {
let err = result.expect_err("fixture manifest is not loadable");
assert!(!err.is_security_error(), "blocked by signature gate: {err}");
}
fn assert_blocked(result: LoaderResult<PluginId>, needle: &str) {
let err = result.expect_err("install must be blocked");
assert!(err.is_security_error(), "expected security error, got: {err}");
assert!(err.to_string().contains(needle), "unexpected error: {err}");
}
#[tokio::test]
async fn valid_signature_loads_exact_directory_outside_search_roots() {
use mockforge_plugin_core::{PluginAuthor, PluginInfo, PluginManifest, PluginVersion};
let fx = fixture(|_, _| {});
let dir = TempDir::new().unwrap();
let manifest = PluginManifest::new(PluginInfo::new(
PluginId::new("exact-signed-fixture"),
PluginVersion::new(1, 0, 0),
"Exact signed fixture",
"Owned loadable fixture",
PluginAuthor::new("Fixture"),
));
std::fs::write(
dir.path().join("plugin.yaml"),
serde_yaml::to_string(&manifest).unwrap(),
)
.unwrap();
std::fs::write(
dir.path().join("plugin.wasm"),
b"\0asm\x01\0\0\0\x05\x04\x01\x01\x01\x01\x07\x0a\x01\x06memory\x02\0",
)
.unwrap();
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).unwrap();
let id = install(&fx, &dir, InstallOptions::default()).await.unwrap();
let loaded = fx.installer.loader.get_plugin(&id).await.unwrap();
assert_eq!(loaded.manifest.info.name, "Exact signed fixture");
}
#[tokio::test]
async fn unsigned_plugin_is_blocked_by_default() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "unsigned");
}
#[tokio::test]
async fn skip_validation_does_not_bypass_signature() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
let options = InstallOptions {
skip_validation: true,
..Default::default()
};
assert_blocked(install(&fx, &dir, options).await, "unsigned");
}
#[tokio::test]
async fn explicit_allow_unsigned_installs_unsigned_plugin() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
let options = InstallOptions {
allow_unsigned: true,
..Default::default()
};
assert_passed_signature_gate(install(&fx, &dir, options).await);
}
#[tokio::test]
async fn valid_signature_passes() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
assert_passed_signature_gate(install(&fx, &dir, InstallOptions::default()).await);
}
#[tokio::test]
async fn tampered_wasm_is_blocked() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
std::fs::write(dir.path().join("plugin.wasm"), b"\0asm-evil").expect("write");
assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "modified");
}
#[tokio::test]
async fn injected_file_is_blocked() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
std::fs::create_dir(dir.path().join("assets")).expect("mkdir");
std::fs::write(dir.path().join("assets/extra.js"), b"evil()").expect("write");
assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "modified");
}
#[tokio::test]
async fn tampered_signature_is_blocked_even_with_allow_unsigned() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
std::fs::write(dir.path().join("plugin.wasm"), b"\0asm-evil").expect("write");
let options = InstallOptions {
allow_unsigned: true,
..Default::default()
};
assert_blocked(install(&fx, &dir, options).await, "modified");
}
#[tokio::test]
async fn wrong_key_is_blocked() {
let (_, other_public) = generate_ed25519_keypair().expect("keygen");
let fx = fixture(move |config, _| {
config.key_data.insert(KEY_ID.to_string(), other_public);
});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
assert_blocked(
install(&fx, &dir, InstallOptions::default()).await,
"Invalid signature",
);
}
#[tokio::test]
async fn untrusted_key_is_blocked() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), "someone-else", &fx.private_key).expect("sign");
assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "not in trusted");
}
#[tokio::test]
async fn expired_key_is_blocked() {
let fx = fixture(|config, _| {
config
.key_expires_at
.insert(KEY_ID.to_string(), Utc::now() - ChronoDuration::days(1));
});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "expired");
}
#[tokio::test]
async fn unexpired_key_passes() {
let fx = fixture(|config, _| {
config
.key_expires_at
.insert(KEY_ID.to_string(), Utc::now() + ChronoDuration::days(30));
});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
assert_passed_signature_gate(install(&fx, &dir, InstallOptions::default()).await);
}
#[tokio::test]
async fn revoked_key_is_blocked() {
let fx = fixture(|config, _| {
config.revoked_keys.push(KEY_ID.to_string());
});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "revoked");
}
#[tokio::test]
async fn signature_for_different_artifact_is_blocked() {
let fx = fixture(|_, _| {});
let signed = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(signed.path(), KEY_ID, &fx.private_key).expect("sign");
let other = plugin_dir(b"\0asm-other");
std::fs::copy(signed.path().join("plugin.sig"), other.path().join("plugin.sig"))
.expect("copy");
assert_blocked(install(&fx, &other, InstallOptions::default()).await, "different");
}
#[tokio::test]
async fn legacy_manifest_only_signature_is_blocked() {
let fx = fixture(|_, _| {});
let dir = plugin_dir(b"\0asm-good");
let mut sig = sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
sig.format_version = 1;
let json = serde_json::to_string(&sig).expect("serialize");
std::fs::write(dir.path().join("plugin.sig"), json).expect("write");
assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "format v1");
let mut raw: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join("plugin.sig")).expect("read"),
)
.expect("json");
raw.as_object_mut().expect("object").remove("format_version");
let parsed: PluginSignature = serde_json::from_value(raw).expect("parse");
assert_eq!(parsed.format_version, 1);
}
#[tokio::test]
async fn config_allow_unsigned_still_rejects_bad_signature() {
let fx = fixture(|config, _| {
config.allow_unsigned = true;
config.revoked_keys.push(KEY_ID.to_string());
});
let dir = plugin_dir(b"\0asm-good");
sign_plugin_ed25519(dir.path(), KEY_ID, &fx.private_key).expect("sign");
assert_blocked(install(&fx, &dir, InstallOptions::default()).await, "revoked");
}
}
#[test]
fn test_select_registry_version_falls_back_to_first_non_yanked() {
let entry = RegistryPluginResponse {
version: "2.0.0".to_string(),
versions: vec![
RegistryVersionResponseWithVersion {
version: "2.0.0".to_string(),
download_url: "https://example.com/2.0.0.wasm".to_string(),
checksum: None,
yanked: true,
},
RegistryVersionResponseWithVersion {
version: "1.9.0".to_string(),
download_url: "https://example.com/1.9.0.wasm".to_string(),
checksum: None,
yanked: false,
},
],
};
let selected = select_registry_version(&entry).expect("expected selected version");
assert_eq!(selected.version, "1.9.0");
}
}