use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use std::sync::OnceLock;
use uuid::Uuid;
pub const PLUGIN_REGISTRY_SCHEMA: &str = "cephas.plugins.v1";
pub const PLUGIN_MODULE_SCHEMA: &str = "cephas.plugin-module.v1";
pub const PLUGIN_CHECK_SCHEMA: &str = "cephas.plugins.check.v1";
pub const PLUGIN_BACKEND_REGISTRY_SCHEMA: &str = "cephas.plugins-backend.registry.v1";
pub const PLUGIN_BACKEND_INGEST_REPORT_SCHEMA: &str = "cephas.plugins-backend.ingest-report.v1";
pub const DEFAULT_MAX_PLUGINS: usize = 128;
const MAX_PLUGIN_NAME_CHARS: usize = 80;
const MAX_PLUGIN_DESCRIPTION_CHARS: usize = 320;
const MAX_PLUGIN_VERSION_CHARS: usize = 32;
const MAX_PLUGIN_MODULE_ID_CHARS: usize = 64;
const MAX_PLUGIN_SOURCE_PATH_CHARS: usize = 160;
static BUILT_IN_PLUGINS: OnceLock<Vec<PluginManifest>> = OnceLock::new();
static BUNDLED_PLUGIN_MODULES: OnceLock<Vec<PluginModule>> = OnceLock::new();
const BUNDLED_PLUGIN_MANIFESTS: &[&str] = &[
include_str!("../plugins/reader-outline/plugin.json"),
include_str!("../plugins/media-compat/plugin.json"),
include_str!("../plugins/paper-theme-tools/plugin.json"),
include_str!("../plugins/agent-toolkit/plugin.json"),
include_str!("../plugins/privacy-rules/plugin.json"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginId(Uuid);
impl PluginId {
pub fn new_v7() -> Self {
Self(Uuid::now_v7())
}
pub fn as_uuid(&self) -> Uuid {
self.0
}
pub fn is_v7(&self) -> bool {
self.0.get_version_num() == 7
}
}
impl fmt::Display for PluginId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
impl FromStr for PluginId {
type Err = uuid::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Uuid::parse_str(value).map(Self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginKind {
ContentBlocker,
Theme,
Tool,
Privacy,
Media,
Agent,
Reader,
}
impl PluginKind {
pub fn label(self) -> &'static str {
match self {
Self::ContentBlocker => "Content blocker",
Self::Theme => "Theme",
Self::Tool => "Tool",
Self::Privacy => "Privacy",
Self::Media => "Media",
Self::Agent => "Agent",
Self::Reader => "Reader",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginCapability {
ContentScripts,
StyleSheets,
ThemeAssets,
NetworkFiltering,
ReaderArtifacts,
MediaHelpers,
AgentHooks,
Downloads,
Tabs,
}
impl PluginCapability {
pub fn label(self) -> &'static str {
match self {
Self::ContentScripts => "content scripts",
Self::StyleSheets => "style sheets",
Self::ThemeAssets => "theme assets",
Self::NetworkFiltering => "network filtering",
Self::ReaderArtifacts => "reader artifacts",
Self::MediaHelpers => "media helpers",
Self::AgentHooks => "agent hooks",
Self::Downloads => "downloads",
Self::Tabs => "tabs",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginPermission {
ReadActiveTab,
ModifyPages,
NetworkRequests,
StoreLocalData,
CaptureMedia,
UseAgentContext,
}
impl PluginPermission {
pub fn label(self) -> &'static str {
match self {
Self::ReadActiveTab => "read active tab",
Self::ModifyPages => "modify pages",
Self::NetworkRequests => "network requests",
Self::StoreLocalData => "store local data",
Self::CaptureMedia => "capture media",
Self::UseAgentContext => "use Agent context",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginStatus {
Planned,
Installed,
Enabled,
Disabled,
Blocked,
}
impl PluginStatus {
pub fn label(self) -> &'static str {
match self {
Self::Planned => "Planned",
Self::Installed => "Installed",
Self::Enabled => "Enabled",
Self::Disabled => "Disabled",
Self::Blocked => "Blocked",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: PluginId,
pub name: String,
pub description: String,
pub version: String,
pub kind: PluginKind,
pub status: PluginStatus,
pub built_in: bool,
#[serde(default)]
pub capabilities: Vec<PluginCapability>,
#[serde(default)]
pub permissions: Vec<PluginPermission>,
pub installed_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginModule {
pub schema: String,
pub module_id: String,
pub source_path: String,
pub manifest: PluginManifest,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PluginCheckReport {
pub schema: &'static str,
pub ok: bool,
pub installed_count: usize,
pub enabled_count: usize,
pub planned_count: usize,
pub bundled_count: usize,
pub available_count: usize,
pub max_plugins: usize,
pub error_count: usize,
pub warning_count: usize,
pub issues: Vec<PluginCheckIssue>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginCheckSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PluginCheckIssue {
pub severity: PluginCheckSeverity,
pub code: String,
pub plugin_id: Option<String>,
pub module_id: Option<String>,
pub message: String,
}
impl PluginManifest {
pub fn local(
name: impl Into<String>,
description: impl Into<String>,
version: impl Into<String>,
kind: PluginKind,
capabilities: Vec<PluginCapability>,
permissions: Vec<PluginPermission>,
) -> Self {
let now = Utc::now();
Self {
id: PluginId::new_v7(),
name: name.into(),
description: description.into(),
version: version.into(),
kind,
status: PluginStatus::Installed,
built_in: false,
capabilities,
permissions,
installed_at: now,
updated_at: now,
}
.sanitized()
}
fn planned_builtin(
id: &'static str,
name: &'static str,
description: &'static str,
kind: PluginKind,
capabilities: Vec<PluginCapability>,
permissions: Vec<PluginPermission>,
) -> Self {
let timestamp = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.expect("valid built-in plugin timestamp")
.with_timezone(&Utc);
Self {
id: PluginId(Uuid::parse_str(id).expect("built-in plugin UUID must be valid")),
name: name.to_string(),
description: description.to_string(),
version: "0.1.0".to_string(),
kind,
status: PluginStatus::Planned,
built_in: true,
capabilities,
permissions,
installed_at: timestamp,
updated_at: timestamp,
}
.sanitized()
}
fn sanitized(mut self) -> Self {
self.name = clipped_non_empty(&self.name, MAX_PLUGIN_NAME_CHARS, "Untitled plugin");
self.description = clipped_non_empty(
&self.description,
MAX_PLUGIN_DESCRIPTION_CHARS,
"No description provided.",
);
self.version = clipped_non_empty(&self.version, MAX_PLUGIN_VERSION_CHARS, "0.0.0");
dedupe_sorted(&mut self.capabilities);
dedupe_sorted(&mut self.permissions);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRegistry {
#[serde(default = "default_plugins_enabled")]
pub enabled: bool,
#[serde(default = "built_in_plugins")]
pub installed: Vec<PluginManifest>,
#[serde(default = "default_max_plugins")]
pub max_plugins: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginBackendRegistry {
pub schema: String,
pub generated_at: DateTime<Utc>,
pub registry_version: u64,
pub plugins: Vec<PluginManifest>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PluginBackendIngestReport {
pub schema: &'static str,
pub backend_schema: String,
pub registry_version: u64,
pub received: usize,
pub accepted: usize,
pub updated: usize,
pub rejected: Vec<PluginBackendRejection>,
pub installed_count: usize,
pub max_plugins: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PluginBackendRejection {
pub id: String,
pub name: String,
pub reason: String,
}
impl Default for PluginRegistry {
fn default() -> Self {
Self {
enabled: true,
installed: built_in_plugins(),
max_plugins: DEFAULT_MAX_PLUGINS,
}
}
}
impl PluginRegistry {
pub fn enforce_limits(&mut self) {
let minimum = built_in_plugins().len();
self.max_plugins = self.max_plugins.clamp(minimum, DEFAULT_MAX_PLUGINS);
self.ensure_builtin_plugins();
let mut seen = BTreeSet::new();
self.installed
.retain(|plugin| plugin.id.is_v7() && seen.insert(plugin.id));
self.installed = self
.installed
.drain(..)
.map(PluginManifest::sanitized)
.collect();
while self.installed.len() > self.max_plugins {
let Some(index) = self
.installed
.iter()
.enumerate()
.filter(|(_, plugin)| !plugin.built_in)
.min_by_key(|(_, plugin)| plugin.installed_at)
.map(|(index, _)| index)
else {
break;
};
self.installed.remove(index);
}
}
pub fn register(&mut self, manifest: PluginManifest) -> Result<PluginId, PluginRegistryError> {
if self.installed.iter().any(|plugin| plugin.id == manifest.id) {
return Err(PluginRegistryError::DuplicateId(manifest.id));
}
let id = manifest.id;
self.installed.push(manifest.sanitized());
self.enforce_limits();
Ok(id)
}
pub fn install_bundled(
&mut self,
plugin_id: PluginId,
) -> Result<PluginManifest, PluginRegistryError> {
if self.installed.iter().any(|plugin| plugin.id == plugin_id) {
return Err(PluginRegistryError::DuplicateId(plugin_id));
}
let mut manifest = bundled_plugin_manifest(plugin_id)
.ok_or(PluginRegistryError::UnknownBundledPlugin(plugin_id))?;
manifest.built_in = false;
manifest.status = PluginStatus::Installed;
manifest.installed_at = Utc::now();
manifest.updated_at = manifest.installed_at;
self.register(manifest.clone())?;
Ok(manifest)
}
pub fn install_all_available_bundled(
&mut self,
) -> Result<Vec<PluginManifest>, PluginRegistryError> {
let available = available_bundled_plugin_modules(self);
let mut installed = Vec::with_capacity(available.len());
for module in available {
installed.push(self.install_bundled(module.manifest.id)?);
}
Ok(installed)
}
pub fn sync_bundled(
&mut self,
plugin_id: PluginId,
) -> Result<PluginManifest, PluginRegistryError> {
let existing = self
.installed
.iter_mut()
.find(|plugin| plugin.id == plugin_id)
.ok_or(PluginRegistryError::NotInstalled(plugin_id))?;
if existing.built_in {
return Err(PluginRegistryError::BuiltInImmutable(plugin_id));
}
let mut manifest = bundled_plugin_manifest(plugin_id)
.ok_or(PluginRegistryError::UnknownBundledPlugin(plugin_id))?;
manifest.built_in = false;
manifest.status = existing.status;
manifest.installed_at = existing.installed_at;
manifest.updated_at = Utc::now();
*existing = manifest.sanitized();
Ok(existing.clone())
}
pub fn sync_all_installed_bundled(
&mut self,
) -> Result<Vec<PluginManifest>, PluginRegistryError> {
let plugin_ids = self
.installed
.iter()
.filter(|plugin| !plugin.built_in && bundled_plugin_manifest(plugin.id).is_some())
.map(|plugin| plugin.id)
.collect::<Vec<_>>();
let mut synced = Vec::with_capacity(plugin_ids.len());
for plugin_id in plugin_ids {
synced.push(self.sync_bundled(plugin_id)?);
}
Ok(synced)
}
pub fn set_status(
&mut self,
plugin_id: PluginId,
status: PluginStatus,
) -> Result<PluginManifest, PluginRegistryError> {
let plugin = self
.installed
.iter_mut()
.find(|plugin| plugin.id == plugin_id)
.ok_or(PluginRegistryError::NotInstalled(plugin_id))?;
if plugin.built_in {
return Err(PluginRegistryError::BuiltInImmutable(plugin_id));
}
if plugin.status == PluginStatus::Blocked && status == PluginStatus::Enabled {
return Err(PluginRegistryError::Blocked(plugin_id));
}
plugin.status = status;
plugin.updated_at = Utc::now();
Ok(plugin.clone())
}
pub fn remove(&mut self, plugin_id: PluginId) -> Result<PluginManifest, PluginRegistryError> {
let index = self
.installed
.iter()
.position(|plugin| plugin.id == plugin_id)
.ok_or(PluginRegistryError::NotInstalled(plugin_id))?;
if self.installed[index].built_in {
return Err(PluginRegistryError::BuiltInImmutable(plugin_id));
}
Ok(self.installed.remove(index))
}
pub fn is_installed(&self, plugin_id: PluginId) -> bool {
self.installed.iter().any(|plugin| plugin.id == plugin_id)
}
pub fn ingest_backend_registry(
&mut self,
backend: PluginBackendRegistry,
) -> Result<PluginBackendIngestReport, PluginBackendIngestError> {
if backend.schema != PLUGIN_BACKEND_REGISTRY_SCHEMA {
return Err(PluginBackendIngestError::UnsupportedSchema {
expected: PLUGIN_BACKEND_REGISTRY_SCHEMA,
actual: backend.schema,
});
}
if backend.plugins.len() > DEFAULT_MAX_PLUGINS {
return Err(PluginBackendIngestError::RegistryTooLarge {
max: DEFAULT_MAX_PLUGINS,
actual: backend.plugins.len(),
});
}
let mut report = PluginBackendIngestReport {
schema: PLUGIN_BACKEND_INGEST_REPORT_SCHEMA,
backend_schema: backend.schema,
registry_version: backend.registry_version,
received: backend.plugins.len(),
accepted: 0,
updated: 0,
rejected: Vec::new(),
installed_count: 0,
max_plugins: self.max_plugins,
};
let built_in_ids = built_in_plugin_ids();
for mut plugin in backend.plugins {
if !plugin.id.is_v7() {
report
.rejected
.push(plugin_rejection(&plugin, "plugin id must be UUIDv7"));
continue;
}
if plugin.built_in {
report.rejected.push(plugin_rejection(
&plugin,
"backend plugins cannot set built_in=true",
));
continue;
}
if built_in_ids.contains(&plugin.id) {
report
.rejected
.push(plugin_rejection(&plugin, "plugin id is reserved by Cephas"));
continue;
}
plugin.built_in = false;
if plugin.status == PluginStatus::Enabled {
plugin.status = PluginStatus::Installed;
}
if let Some(existing) = self
.installed
.iter_mut()
.find(|installed| installed.id == plugin.id)
{
let local_status = existing.status;
let installed_at = existing.installed_at;
plugin.status = local_status;
plugin.installed_at = installed_at;
*existing = plugin.sanitized();
report.updated += 1;
} else {
self.installed.push(plugin.sanitized());
report.accepted += 1;
}
}
self.enforce_limits();
report.installed_count = self.installed_count();
report.max_plugins = self.max_plugins;
Ok(report)
}
pub fn installed_count(&self) -> usize {
self.installed.len()
}
pub fn enabled_count(&self) -> usize {
self.installed
.iter()
.filter(|plugin| plugin.status == PluginStatus::Enabled)
.count()
}
pub fn planned_count(&self) -> usize {
self.installed
.iter()
.filter(|plugin| plugin.status == PluginStatus::Planned)
.count()
}
fn ensure_builtin_plugins(&mut self) {
let builtins = built_in_plugins();
let builtin_ids = builtins
.iter()
.map(|plugin| plugin.id)
.collect::<BTreeSet<_>>();
self.installed
.retain(|plugin| !builtin_ids.contains(&plugin.id));
self.installed.extend(builtins);
}
}
fn plugin_rejection(plugin: &PluginManifest, reason: impl Into<String>) -> PluginBackendRejection {
PluginBackendRejection {
id: plugin.id.to_string(),
name: clipped_non_empty(&plugin.name, MAX_PLUGIN_NAME_CHARS, "Untitled plugin"),
reason: reason.into(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginRegistryError {
DuplicateId(PluginId),
UnknownBundledPlugin(PluginId),
NotInstalled(PluginId),
BuiltInImmutable(PluginId),
Blocked(PluginId),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginBackendIngestError {
UnsupportedSchema {
expected: &'static str,
actual: String,
},
RegistryTooLarge {
max: usize,
actual: usize,
},
}
impl fmt::Display for PluginRegistryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateId(id) => write!(formatter, "plugin {id} is already installed"),
Self::UnknownBundledPlugin(id) => write!(formatter, "plugin {id} is not bundled"),
Self::NotInstalled(id) => write!(formatter, "plugin {id} is not installed"),
Self::BuiltInImmutable(id) => {
write!(formatter, "built-in plugin {id} cannot be changed")
}
Self::Blocked(id) => write!(formatter, "plugin {id} is blocked"),
}
}
}
impl std::error::Error for PluginRegistryError {}
impl fmt::Display for PluginBackendIngestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedSchema { expected, actual } => {
write!(
formatter,
"unsupported plugin backend schema {actual}; expected {expected}"
)
}
Self::RegistryTooLarge { max, actual } => {
write!(
formatter,
"plugin backend registry has {actual} plugins; maximum is {max}"
)
}
}
}
}
impl std::error::Error for PluginBackendIngestError {}
fn default_plugins_enabled() -> bool {
true
}
fn default_max_plugins() -> usize {
DEFAULT_MAX_PLUGINS
}
fn built_in_plugins() -> Vec<PluginManifest> {
BUILT_IN_PLUGINS.get_or_init(build_built_in_plugins).clone()
}
pub fn bundled_plugin_modules() -> Vec<PluginModule> {
BUNDLED_PLUGIN_MODULES
.get_or_init(load_bundled_plugin_modules)
.clone()
}
pub fn bundled_plugin_manifests() -> Vec<PluginManifest> {
bundled_plugin_modules()
.into_iter()
.map(|module| module.manifest)
.collect()
}
pub fn available_bundled_plugin_modules(registry: &PluginRegistry) -> Vec<PluginModule> {
bundled_plugin_modules()
.into_iter()
.filter(|module| !registry.is_installed(module.manifest.id))
.collect()
}
pub fn available_bundled_plugin_manifests(registry: &PluginRegistry) -> Vec<PluginManifest> {
available_bundled_plugin_modules(registry)
.into_iter()
.map(|module| module.manifest)
.collect()
}
pub fn bundled_plugin_manifest(plugin_id: PluginId) -> Option<PluginManifest> {
bundled_plugin_manifests()
.into_iter()
.find(|manifest| manifest.id == plugin_id)
}
pub fn bundled_plugin_module_for_manifest(plugin_id: PluginId) -> Option<PluginModule> {
bundled_plugin_modules()
.into_iter()
.find(|module| module.manifest.id == plugin_id)
}
pub fn bundled_plugin_id_for_module(module_id: &str) -> Option<PluginId> {
let module_id = module_id.trim();
bundled_plugin_modules()
.into_iter()
.find(|module| module.module_id == module_id)
.map(|module| module.manifest.id)
}
pub fn resolve_plugin_selector(selector: &str) -> Option<PluginId> {
let selector = selector.trim();
selector
.parse::<PluginId>()
.ok()
.or_else(|| bundled_plugin_id_for_module(selector))
}
pub fn check_plugin_registry(registry: &PluginRegistry) -> PluginCheckReport {
let bundled = bundled_plugin_modules();
let available = available_bundled_plugin_modules(registry);
let mut issues = Vec::new();
let builtins = built_in_plugins();
let builtin_ids = builtins
.iter()
.map(|plugin| plugin.id)
.collect::<BTreeSet<_>>();
if registry.max_plugins < builtins.len() || registry.max_plugins > DEFAULT_MAX_PLUGINS {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"invalid-max-plugins",
None,
None,
format!(
"max_plugins must be between {} and {}",
builtins.len(),
DEFAULT_MAX_PLUGINS
),
));
}
if registry.installed.len() > registry.max_plugins {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"registry-over-capacity",
None,
None,
format!(
"registry has {} records but max_plugins is {}",
registry.installed.len(),
registry.max_plugins
),
));
}
let mut installed_ids = BTreeSet::new();
for plugin in ®istry.installed {
if !installed_ids.insert(plugin.id) {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"duplicate-plugin-id",
Some(plugin.id),
None,
"plugin id appears more than once",
));
}
if !plugin.id.is_v7() {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"plugin-id-not-uuidv7",
Some(plugin.id),
None,
"plugin id must be UUIDv7",
));
}
if plugin.built_in && !builtin_ids.contains(&plugin.id) {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"unknown-built-in-plugin",
Some(plugin.id),
None,
"built_in=true is reserved for Cephas-owned API records",
));
}
if plugin.built_in && plugin.status != PluginStatus::Planned {
issues.push(plugin_check_issue(
PluginCheckSeverity::Warning,
"built-in-status-not-planned",
Some(plugin.id),
None,
"built-in API records should remain planned",
));
}
}
for builtin in &builtins {
if !registry
.installed
.iter()
.any(|plugin| plugin.id == builtin.id && plugin.built_in)
{
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"missing-built-in-plugin",
Some(builtin.id),
None,
"required Cephas built-in plugin API record is missing",
));
}
}
let mut module_ids = BTreeSet::new();
let mut module_plugin_ids = BTreeSet::new();
let mut bundled_by_plugin_id = BTreeMap::new();
for module in &bundled {
if module.schema != PLUGIN_MODULE_SCHEMA {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"invalid-module-schema",
Some(module.manifest.id),
Some(&module.module_id),
format!("module schema must be {PLUGIN_MODULE_SCHEMA}"),
));
}
if !valid_bundled_module_id(&module.module_id) {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"invalid-module-id",
Some(module.manifest.id),
Some(&module.module_id),
"module_id must be a lowercase ASCII slug",
));
}
if !valid_bundled_source_path(&module.source_path, &module.module_id) {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"invalid-module-source-path",
Some(module.manifest.id),
Some(&module.module_id),
"source_path must be plugins/<module_id>/plugin.json",
));
}
if !module.manifest.id.is_v7() {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"module-plugin-id-not-uuidv7",
Some(module.manifest.id),
Some(&module.module_id),
"bundled module plugin id must be UUIDv7",
));
}
if module.manifest.built_in {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"module-claims-built-in",
Some(module.manifest.id),
Some(&module.module_id),
"bundled modules must not set built_in=true",
));
}
if !module_ids.insert(module.module_id.clone()) {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"duplicate-module-id",
Some(module.manifest.id),
Some(&module.module_id),
"module_id appears more than once",
));
}
if !module_plugin_ids.insert(module.manifest.id) {
issues.push(plugin_check_issue(
PluginCheckSeverity::Error,
"duplicate-module-plugin-id",
Some(module.manifest.id),
Some(&module.module_id),
"bundled module plugin id appears more than once",
));
}
bundled_by_plugin_id.insert(module.manifest.id, module);
}
for plugin in ®istry.installed {
if plugin.built_in {
continue;
}
if let Some(module) = bundled_by_plugin_id.get(&plugin.id)
&& plugin_metadata_differs_from_module(plugin, &module.manifest)
{
issues.push(plugin_check_issue(
PluginCheckSeverity::Warning,
"bundled-metadata-drift",
Some(plugin.id),
Some(&module.module_id),
"installed bundled plugin metadata differs from project module; run plugins sync",
));
}
}
let error_count = issues
.iter()
.filter(|issue| issue.severity == PluginCheckSeverity::Error)
.count();
let warning_count = issues.len().saturating_sub(error_count);
PluginCheckReport {
schema: PLUGIN_CHECK_SCHEMA,
ok: error_count == 0,
installed_count: registry.installed_count(),
enabled_count: registry.enabled_count(),
planned_count: registry.planned_count(),
bundled_count: bundled.len(),
available_count: available.len(),
max_plugins: registry.max_plugins,
error_count,
warning_count,
issues,
}
}
fn plugin_metadata_differs_from_module(
installed: &PluginManifest,
module: &PluginManifest,
) -> bool {
installed.name != module.name
|| installed.description != module.description
|| installed.version != module.version
|| installed.kind != module.kind
|| installed.capabilities != module.capabilities
|| installed.permissions != module.permissions
}
fn plugin_check_issue(
severity: PluginCheckSeverity,
code: &str,
plugin_id: Option<PluginId>,
module_id: Option<&str>,
message: impl Into<String>,
) -> PluginCheckIssue {
PluginCheckIssue {
severity,
code: code.to_string(),
plugin_id: plugin_id.map(|id| id.to_string()),
module_id: module_id.map(str::to_string),
message: message.into(),
}
}
fn sanitize_plugin_module(mut module: PluginModule) -> PluginModule {
module.module_id = clipped_non_empty(
&module.module_id,
MAX_PLUGIN_MODULE_ID_CHARS,
"unnamed-module",
);
module.source_path = clipped_non_empty(
&module.source_path,
MAX_PLUGIN_SOURCE_PATH_CHARS,
"plugins/unknown/plugin.json",
);
module.manifest = module.manifest.sanitized();
module
}
fn validate_raw_plugin_module(module: &PluginModule) -> Result<(), String> {
if module.schema != PLUGIN_MODULE_SCHEMA {
return Err(format!("schema must be {PLUGIN_MODULE_SCHEMA}"));
}
if !valid_bundled_module_id(&module.module_id) {
return Err(
"module_id must be a lowercase ASCII slug within the module ID cap".to_string(),
);
}
if !valid_bundled_source_path(&module.source_path, &module.module_id) {
return Err("source_path must be plugins/<module_id>/plugin.json".to_string());
}
if module.source_path.chars().count() > MAX_PLUGIN_SOURCE_PATH_CHARS {
return Err("source_path exceeds the source path cap".to_string());
}
let manifest = &module.manifest;
if !manifest.id.is_v7() {
return Err("manifest id must be UUIDv7".to_string());
}
if !raw_plugin_string_is_valid(&manifest.name, MAX_PLUGIN_NAME_CHARS) {
return Err("manifest name must be non-empty and within the name cap".to_string());
}
if !raw_plugin_string_is_valid(&manifest.description, MAX_PLUGIN_DESCRIPTION_CHARS) {
return Err(
"manifest description must be non-empty and within the description cap".to_string(),
);
}
if !raw_plugin_string_is_valid(&manifest.version, MAX_PLUGIN_VERSION_CHARS) {
return Err("manifest version must be non-empty and within the version cap".to_string());
}
if manifest.built_in {
return Err("bundled modules must not set built_in=true".to_string());
}
if manifest.status != PluginStatus::Installed {
return Err("bundled modules must use installed status in raw metadata".to_string());
}
if has_duplicates(&manifest.capabilities) {
return Err("manifest capabilities must be unique before sanitization".to_string());
}
if has_duplicates(&manifest.permissions) {
return Err("manifest permissions must be unique before sanitization".to_string());
}
if manifest.updated_at < manifest.installed_at {
return Err("manifest updated_at must not be earlier than installed_at".to_string());
}
Ok(())
}
fn raw_plugin_string_is_valid(value: &str, max_chars: usize) -> bool {
let value = value.trim();
!value.is_empty() && value.chars().count() <= max_chars
}
fn has_duplicates<T: Ord + Copy>(values: &[T]) -> bool {
let mut seen = BTreeSet::new();
values.iter().any(|value| !seen.insert(*value))
}
fn valid_bundled_module_id(module_id: &str) -> bool {
!module_id.is_empty()
&& module_id.len() <= MAX_PLUGIN_MODULE_ID_CHARS
&& module_id
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
fn valid_bundled_source_path(source_path: &str, module_id: &str) -> bool {
source_path == format!("plugins/{module_id}/plugin.json")
}
fn load_bundled_plugin_modules() -> Vec<PluginModule> {
let mut seen = BTreeSet::new();
let mut module_ids = BTreeSet::new();
BUNDLED_PLUGIN_MANIFESTS
.iter()
.map(|json| {
let mut module: PluginModule =
serde_json::from_str(json).expect("bundled plugin module JSON must be valid");
validate_raw_plugin_module(&module).unwrap_or_else(|message| {
panic!(
"bundled plugin module {} has invalid raw metadata: {message}",
module.module_id
)
});
module = sanitize_plugin_module(module);
assert_eq!(
module.schema, PLUGIN_MODULE_SCHEMA,
"bundled plugin module schema must match"
);
assert!(
valid_bundled_module_id(&module.module_id),
"bundled plugin module IDs must be lowercase slug IDs"
);
assert!(
valid_bundled_source_path(&module.source_path, &module.module_id),
"bundled plugin source paths must point at plugins/<module>/plugin.json"
);
assert!(
module_ids.insert(module.module_id.clone()),
"bundled plugin module IDs must be unique"
);
assert!(
module.manifest.id.is_v7(),
"bundled plugin module IDs must be UUIDv7"
);
assert!(
seen.insert(module.manifest.id),
"bundled plugin module IDs must be unique"
);
module.manifest.built_in = false;
if module.manifest.status == PluginStatus::Enabled {
module.manifest.status = PluginStatus::Installed;
}
module
})
.collect()
}
fn build_built_in_plugins() -> Vec<PluginManifest> {
vec![
PluginManifest::planned_builtin(
"0194c6b5-6f20-7a31-9f0b-111111111111",
"Content Blocker Plugin API",
"UUIDv7 extension point for installable content blockers and declarative network filters.",
PluginKind::ContentBlocker,
vec![PluginCapability::NetworkFiltering],
vec![PluginPermission::NetworkRequests],
),
PluginManifest::planned_builtin(
"0194c6b5-6f20-7a32-9f0b-222222222222",
"Theme Pack Plugin API",
"UUIDv7 extension point for installable theme packs and local visual assets.",
PluginKind::Theme,
vec![PluginCapability::ThemeAssets, PluginCapability::StyleSheets],
vec![PluginPermission::StoreLocalData],
),
PluginManifest::planned_builtin(
"0194c6b5-6f20-7a33-9f0b-333333333333",
"Media Helper Plugin API",
"UUIDv7 extension point for media helpers, site compatibility shims, and playback diagnostics.",
PluginKind::Media,
vec![PluginCapability::MediaHelpers],
vec![
PluginPermission::ReadActiveTab,
PluginPermission::CaptureMedia,
],
),
PluginManifest::planned_builtin(
"0194c6b5-6f20-7a34-9f0b-444444444444",
"Agent Tool Plugin API",
"UUIDv7 extension point for audited Agent commands, local tools, and browser automation surfaces.",
PluginKind::Agent,
vec![PluginCapability::AgentHooks, PluginCapability::Tabs],
vec![
PluginPermission::UseAgentContext,
PluginPermission::ReadActiveTab,
],
),
PluginManifest::planned_builtin(
"0194c6b5-6f20-7a35-9f0b-555555555555",
"Reader Plugin API",
"UUIDv7 extension point for article extraction, summaries, outlines, and structured data handoff.",
PluginKind::Reader,
vec![PluginCapability::ReaderArtifacts],
vec![PluginPermission::ReadActiveTab],
),
]
}
fn built_in_plugin_ids() -> BTreeSet<PluginId> {
built_in_plugins()
.into_iter()
.map(|plugin| plugin.id)
.collect()
}
fn clipped_non_empty(value: &str, max_chars: usize, fallback: &str) -> String {
let value = value.trim();
if value.is_empty() {
return fallback.to_string();
}
value.chars().take(max_chars).collect()
}
fn dedupe_sorted<T>(values: &mut Vec<T>)
where
T: Ord + Copy,
{
values.sort();
values.dedup();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn built_in_plugin_ids_are_uuidv7_and_unique() {
let registry = PluginRegistry::default();
let mut ids = BTreeSet::new();
assert_eq!(registry.max_plugins, DEFAULT_MAX_PLUGINS);
assert!(!registry.installed.is_empty());
for plugin in registry.installed {
assert!(ids.insert(plugin.id));
assert_eq!(plugin.id.as_uuid().get_version_num(), 7);
assert!(plugin.built_in);
assert_eq!(plugin.status, PluginStatus::Planned);
assert!(!plugin.capabilities.is_empty());
}
}
#[test]
fn local_plugins_use_uuidv7_ids() {
let plugin = PluginManifest::local(
"Local Tool",
"Does useful local work",
"1.0.0",
PluginKind::Tool,
vec![PluginCapability::Downloads],
vec![PluginPermission::StoreLocalData],
);
assert_eq!(plugin.id.as_uuid().get_version_num(), 7);
assert!(!plugin.built_in);
assert_eq!(plugin.status, PluginStatus::Installed);
}
#[test]
fn bundled_plugin_modules_are_uuidv7_unique_and_installable() {
let modules = bundled_plugin_modules();
let mut ids = BTreeSet::new();
assert!(!modules.is_empty());
for module in modules {
assert_eq!(module.schema, PLUGIN_MODULE_SCHEMA);
assert!(valid_bundled_module_id(&module.module_id));
assert!(valid_bundled_source_path(
&module.source_path,
&module.module_id
));
assert!(module.manifest.id.is_v7());
assert!(ids.insert(module.manifest.id));
assert!(!module.manifest.built_in);
assert_eq!(module.manifest.status, PluginStatus::Installed);
assert!(!module.manifest.capabilities.is_empty());
}
}
#[test]
fn bundled_plugin_raw_metadata_is_valid_before_sanitization() {
for module_json in BUNDLED_PLUGIN_MANIFESTS {
let module: PluginModule = serde_json::from_str(module_json).unwrap();
validate_raw_plugin_module(&module).unwrap();
assert_eq!(
sanitize_plugin_module(module.clone()).module_id,
module.module_id
);
}
}
#[test]
fn raw_plugin_module_validation_rejects_sanitizable_mistakes() {
let mut module: PluginModule = serde_json::from_str(BUNDLED_PLUGIN_MANIFESTS[0]).unwrap();
module.module_id = "reader-outline".repeat(10);
module.source_path = format!("plugins/{}/plugin.json", module.module_id);
assert!(
validate_raw_plugin_module(&module)
.unwrap_err()
.contains("module_id")
);
let mut module: PluginModule = serde_json::from_str(BUNDLED_PLUGIN_MANIFESTS[0]).unwrap();
module.manifest.name = " ".to_string();
assert!(
validate_raw_plugin_module(&module)
.unwrap_err()
.contains("name")
);
let mut module: PluginModule = serde_json::from_str(BUNDLED_PLUGIN_MANIFESTS[0]).unwrap();
module
.manifest
.capabilities
.push(module.manifest.capabilities[0]);
assert!(
validate_raw_plugin_module(&module)
.unwrap_err()
.contains("capabilities")
);
}
#[test]
fn registry_installs_enables_disables_and_removes_bundled_plugins() {
let plugin_id = bundled_plugin_manifests().first().unwrap().id;
let mut registry = PluginRegistry::default();
let installed = registry.install_bundled(plugin_id).unwrap();
assert_eq!(installed.id, plugin_id);
assert!(registry.is_installed(plugin_id));
let enabled = registry
.set_status(plugin_id, PluginStatus::Enabled)
.unwrap();
assert_eq!(enabled.status, PluginStatus::Enabled);
assert_eq!(registry.enabled_count(), 1);
let disabled = registry
.set_status(plugin_id, PluginStatus::Disabled)
.unwrap();
assert_eq!(disabled.status, PluginStatus::Disabled);
let removed = registry.remove(plugin_id).unwrap();
assert_eq!(removed.id, plugin_id);
assert!(!registry.is_installed(plugin_id));
}
#[test]
fn bundled_availability_excludes_installed_plugins() {
let plugin_id = bundled_plugin_manifests().first().unwrap().id;
let mut registry = PluginRegistry::default();
let before = available_bundled_plugin_modules(®istry).len();
registry.install_bundled(plugin_id).unwrap();
let available = available_bundled_plugin_modules(®istry);
assert_eq!(available.len(), before - 1);
assert!(
!available
.iter()
.any(|module| module.manifest.id == plugin_id)
);
}
#[test]
fn registry_installs_all_available_bundled_plugins() {
let mut registry = PluginRegistry::default();
let available = available_bundled_plugin_modules(®istry).len();
let installed = registry.install_all_available_bundled().unwrap();
assert_eq!(installed.len(), available);
assert_eq!(available_bundled_plugin_modules(®istry).len(), 0);
assert_eq!(
registry
.installed
.iter()
.filter(|plugin| !plugin.built_in)
.count(),
available
);
}
#[test]
fn registry_syncs_bundled_metadata_without_changing_status() {
let module = bundled_plugin_modules().first().unwrap().clone();
let mut registry = PluginRegistry::default();
registry.install_bundled(module.manifest.id).unwrap();
registry
.set_status(module.manifest.id, PluginStatus::Enabled)
.unwrap();
let synced = registry.sync_bundled(module.manifest.id).unwrap();
assert_eq!(synced.id, module.manifest.id);
assert_eq!(synced.name, module.manifest.name);
assert_eq!(synced.status, PluginStatus::Enabled);
assert!(!synced.built_in);
}
#[test]
fn registry_syncs_all_installed_bundled_plugins() {
let mut registry = PluginRegistry::default();
let installed = registry.install_all_available_bundled().unwrap();
let synced = registry.sync_all_installed_bundled().unwrap();
assert_eq!(synced.len(), installed.len());
assert!(synced.iter().all(|plugin| !plugin.built_in));
}
#[test]
fn plugin_check_report_accepts_default_registry() {
let registry = PluginRegistry::default();
let report = check_plugin_registry(®istry);
assert!(report.ok);
assert_eq!(report.schema, PLUGIN_CHECK_SCHEMA);
assert_eq!(report.error_count, 0);
assert_eq!(report.bundled_count, bundled_plugin_modules().len());
assert_eq!(report.available_count, bundled_plugin_modules().len());
}
#[test]
fn plugin_check_report_detects_registry_errors_and_metadata_drift() {
let module = bundled_plugin_modules().first().unwrap().clone();
let mut registry = PluginRegistry::default();
registry.install_bundled(module.manifest.id).unwrap();
let drifted = registry
.installed
.iter_mut()
.find(|plugin| plugin.id == module.manifest.id)
.unwrap();
drifted.name = "Drifted module name".to_string();
let mut legacy = PluginManifest::local(
"Legacy",
"Legacy id",
"1.0.0",
PluginKind::Tool,
Vec::new(),
Vec::new(),
);
legacy.id = PluginId(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap());
registry.installed.push(legacy);
let report = check_plugin_registry(®istry);
assert!(!report.ok);
assert!(report.error_count > 0);
assert!(
report
.issues
.iter()
.any(|issue| issue.code == "plugin-id-not-uuidv7")
);
assert!(
report
.issues
.iter()
.any(|issue| issue.code == "bundled-metadata-drift")
);
}
#[test]
fn plugin_selectors_resolve_uuid_or_bundled_module_id() {
let module = bundled_plugin_modules().first().unwrap().clone();
assert_eq!(
resolve_plugin_selector(&module.manifest.id.to_string()),
Some(module.manifest.id)
);
assert_eq!(
resolve_plugin_selector(&module.module_id),
Some(module.manifest.id)
);
assert_eq!(resolve_plugin_selector("missing-module"), None);
}
#[test]
fn registry_caps_plugins_and_preserves_builtins() {
let built_in_count = built_in_plugins().len();
let mut registry = PluginRegistry {
max_plugins: built_in_count + 1,
..PluginRegistry::default()
};
let first_plugin = PluginManifest::local(
"First",
"Oldest local plugin",
"1.0.0",
PluginKind::Tool,
Vec::new(),
Vec::new(),
);
let first = first_plugin.id;
registry.register(first_plugin).unwrap();
registry
.register(PluginManifest::local(
"Second",
"Newest local plugin",
"1.0.0",
PluginKind::Tool,
Vec::new(),
Vec::new(),
))
.unwrap();
assert_eq!(registry.installed_count(), built_in_count + 1);
assert_eq!(
registry
.installed
.iter()
.filter(|plugin| plugin.built_in)
.count(),
built_in_count
);
assert!(!registry.installed.iter().any(|plugin| plugin.id == first));
}
#[test]
fn duplicate_plugin_ids_are_rejected() {
let mut registry = PluginRegistry::default();
let plugin = PluginManifest::local(
"Duplicate",
"Duplicate plugin",
"1.0.0",
PluginKind::Tool,
Vec::new(),
Vec::new(),
);
let duplicate = plugin.clone();
registry.register(plugin).unwrap();
let err = registry.register(duplicate).unwrap_err();
assert!(matches!(err, PluginRegistryError::DuplicateId(_)));
}
#[test]
fn registry_drops_non_uuidv7_plugin_ids() {
let mut plugin = PluginManifest::local(
"Legacy",
"Legacy non-v7 plugin",
"1.0.0",
PluginKind::Tool,
Vec::new(),
Vec::new(),
);
plugin.id = PluginId(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap());
let legacy_id = plugin.id;
let mut registry = PluginRegistry {
installed: vec![plugin],
..PluginRegistry::default()
};
registry.enforce_limits();
assert!(
!registry
.installed
.iter()
.any(|plugin| plugin.id == legacy_id)
);
assert!(registry.installed.iter().all(|plugin| plugin.id.is_v7()));
}
#[test]
fn backend_ingest_rejects_unsafe_plugin_records() {
let mut legacy = PluginManifest::local(
"Legacy",
"Legacy non-v7 plugin",
"1.0.0",
PluginKind::Tool,
Vec::new(),
Vec::new(),
);
legacy.name = "Legacy".repeat(40);
legacy.id = PluginId(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap());
let mut spoofed_builtin = PluginManifest::local(
"Spoof",
"Tries to look built in",
"1.0.0",
PluginKind::Theme,
Vec::new(),
Vec::new(),
);
spoofed_builtin.built_in = true;
let mut registry = PluginRegistry::default();
let report = registry
.ingest_backend_registry(PluginBackendRegistry {
schema: PLUGIN_BACKEND_REGISTRY_SCHEMA.to_string(),
generated_at: Utc::now(),
registry_version: 1,
plugins: vec![legacy, spoofed_builtin],
})
.unwrap();
assert_eq!(report.received, 2);
assert_eq!(report.accepted, 0);
assert_eq!(report.rejected.len(), 2);
assert!(
report
.rejected
.iter()
.any(|rejection| rejection.reason.contains("UUIDv7"))
);
assert!(
report
.rejected
.iter()
.any(|rejection| rejection.reason.contains("built_in"))
);
assert!(
report
.rejected
.iter()
.all(|rejection| rejection.name.chars().count() <= MAX_PLUGIN_NAME_CHARS)
);
}
#[test]
fn backend_ingest_does_not_silently_enable_plugins() {
let mut plugin = PluginManifest::local(
"Backend Theme",
"Remote theme metadata",
"1.0.0",
PluginKind::Theme,
vec![PluginCapability::ThemeAssets],
vec![PluginPermission::StoreLocalData],
);
plugin.status = PluginStatus::Enabled;
let plugin_id = plugin.id;
let mut registry = PluginRegistry::default();
let report = registry
.ingest_backend_registry(PluginBackendRegistry {
schema: PLUGIN_BACKEND_REGISTRY_SCHEMA.to_string(),
generated_at: Utc::now(),
registry_version: 2,
plugins: vec![plugin],
})
.unwrap();
let installed = registry
.installed
.iter()
.find(|installed| installed.id == plugin_id)
.unwrap();
assert_eq!(report.accepted, 1);
assert_eq!(installed.status, PluginStatus::Installed);
}
#[test]
fn backend_ingest_preserves_existing_local_status() {
let mut local = PluginManifest::local(
"Backend Tool",
"Initial metadata",
"1.0.0",
PluginKind::Tool,
Vec::new(),
Vec::new(),
);
local.status = PluginStatus::Disabled;
let plugin_id = local.id;
let installed_at = local.installed_at;
let mut registry = PluginRegistry::default();
registry.register(local).unwrap();
let mut update = PluginManifest::local(
"Backend Tool Updated",
"Updated metadata",
"2.0.0",
PluginKind::Tool,
vec![PluginCapability::Downloads],
Vec::new(),
);
update.id = plugin_id;
update.status = PluginStatus::Enabled;
let report = registry
.ingest_backend_registry(PluginBackendRegistry {
schema: PLUGIN_BACKEND_REGISTRY_SCHEMA.to_string(),
generated_at: Utc::now(),
registry_version: 3,
plugins: vec![update],
})
.unwrap();
let installed = registry
.installed
.iter()
.find(|installed| installed.id == plugin_id)
.unwrap();
assert_eq!(report.updated, 1);
assert_eq!(installed.name, "Backend Tool Updated");
assert_eq!(installed.status, PluginStatus::Disabled);
assert_eq!(installed.installed_at, installed_at);
}
#[test]
fn backend_ingest_rejects_unknown_schema() {
let mut registry = PluginRegistry::default();
let err = registry
.ingest_backend_registry(PluginBackendRegistry {
schema: "wrong.schema".to_string(),
generated_at: Utc::now(),
registry_version: 1,
plugins: Vec::new(),
})
.unwrap_err();
assert!(matches!(
err,
PluginBackendIngestError::UnsupportedSchema { .. }
));
}
#[test]
fn backend_ingest_rejects_oversized_registries_before_iteration() {
let mut registry = PluginRegistry::default();
let plugin = PluginManifest::local(
"Backend Tool",
"Remote metadata",
"1.0.0",
PluginKind::Tool,
Vec::new(),
Vec::new(),
);
let err = registry
.ingest_backend_registry(PluginBackendRegistry {
schema: PLUGIN_BACKEND_REGISTRY_SCHEMA.to_string(),
generated_at: Utc::now(),
registry_version: 1,
plugins: vec![plugin; DEFAULT_MAX_PLUGINS + 1],
})
.unwrap_err();
assert!(matches!(
err,
PluginBackendIngestError::RegistryTooLarge { .. }
));
}
}