use super::{AuthorizedPluginComponent, PluginRuntimeLimits, ValidatedPluginRuntimeLimits};
use crate::{
fs_utils::EscapedDisplayText,
plugin::{
PluginCapabilities, PluginHandleGeneration, PluginHandleStore, PluginHostContext,
PluginHostStateMismatch, PluginIdentity, policy::PluginWitWorld,
},
};
use std::fmt::{Debug, Display, Formatter};
#[cfg(any(test, feature = "plugin-runtime"))]
use crate::plugin::host::PluginHostImportSnapshot;
#[cfg(feature = "plugin-runtime")]
use crate::plugin::{PluginBufferHandle, PluginViewHandle};
#[derive(Eq, PartialEq)]
pub struct PluginRuntimeSpec {
component: AuthorizedPluginComponent,
wit_world: PluginWitWorld,
capabilities: PluginCapabilities,
limits: ValidatedPluginRuntimeLimits,
}
impl PluginRuntimeSpec {
#[must_use]
pub(in crate::plugin) const fn new(
component: AuthorizedPluginComponent,
wit_world: PluginWitWorld,
capabilities: PluginCapabilities,
limits: ValidatedPluginRuntimeLimits,
) -> Self {
Self {
component,
wit_world,
capabilities,
limits,
}
}
#[must_use]
pub fn identity(&self) -> &str {
self.identity_proof().as_str()
}
#[must_use]
pub const fn identity_proof(&self) -> &PluginIdentity {
self.component.identity()
}
#[must_use]
pub fn component_bytes(&self) -> &[u8] {
self.component.bytes()
}
#[must_use]
pub const fn component_display_path(&self) -> &EscapedDisplayText {
self.component.display_path()
}
#[must_use]
pub const fn wit_world(&self) -> &'static str {
self.wit_world.as_str()
}
#[must_use]
pub const fn capabilities(&self) -> &PluginCapabilities {
&self.capabilities
}
#[must_use]
pub const fn limits(&self) -> PluginRuntimeLimits {
self.limits.as_config()
}
#[must_use]
pub(in crate::plugin) const fn validated_limits(&self) -> ValidatedPluginRuntimeLimits {
self.limits
}
}
impl Debug for PluginRuntimeSpec {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PluginRuntimeSpec")
.field("identity", &self.identity_proof())
.field("component", &self.component)
.field("wit_world", &self.wit_world.as_str())
.field("capabilities", &self.capabilities)
.field("limits", &self.limits)
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PluginRevocationReason {
DisabledByConfig,
GrantsChanged,
ComponentChanged,
RuntimeLimitsChanged,
HostShutdown,
RuntimeFailure,
}
impl PluginRevocationReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::DisabledByConfig => "disabled-by-config",
Self::GrantsChanged => "grants-changed",
Self::ComponentChanged => "component-changed",
Self::RuntimeLimitsChanged => "runtime-limits-changed",
Self::HostShutdown => "host-shutdown",
Self::RuntimeFailure => "runtime-failure",
}
}
}
impl Display for PluginRevocationReason {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PluginUnloadPolicy {
KeepLoaded,
Unload,
}
impl PluginUnloadPolicy {
#[must_use]
pub const fn for_revocation(reason: PluginRevocationReason) -> Self {
match reason {
PluginRevocationReason::HostShutdown
| PluginRevocationReason::DisabledByConfig
| PluginRevocationReason::GrantsChanged
| PluginRevocationReason::ComponentChanged
| PluginRevocationReason::RuntimeLimitsChanged
| PluginRevocationReason::RuntimeFailure => Self::Unload,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PluginInstanceLifecycle {
Active,
Revoked {
reason: PluginRevocationReason,
generation: PluginHandleGeneration,
},
}
#[derive(Eq, PartialEq)]
pub struct PluginInstanceState {
spec: PluginRuntimeSpec,
host: PluginHostContext,
handles: PluginHandleStore,
lifecycle: PluginInstanceLifecycle,
}
#[cfg(feature = "plugin-runtime")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::plugin) struct PluginUpdateHandles {
view: PluginViewHandle,
buffer: PluginBufferHandle,
}
#[cfg(feature = "plugin-runtime")]
impl PluginUpdateHandles {
#[must_use]
pub const fn view(self) -> PluginViewHandle {
self.view
}
#[must_use]
pub const fn buffer(self) -> PluginBufferHandle {
self.buffer
}
}
#[cfg(feature = "plugin-runtime")]
#[derive(Debug)]
pub(in crate::plugin) struct PluginActiveUpdate {
snapshot: PluginHostImportSnapshot,
handles: PluginUpdateHandles,
}
#[cfg(feature = "plugin-runtime")]
pub(in crate::plugin) struct PluginActiveUpdateParts {
pub snapshot: PluginHostImportSnapshot,
pub update_handles: PluginUpdateHandles,
}
#[cfg(feature = "plugin-runtime")]
impl PluginActiveUpdate {
#[must_use]
pub(in crate::plugin) fn into_parts(self) -> PluginActiveUpdateParts {
PluginActiveUpdateParts {
snapshot: self.snapshot,
update_handles: self.handles,
}
}
}
impl PluginInstanceState {
#[must_use]
pub fn new(spec: PluginRuntimeSpec) -> Self {
let identity = spec.identity_proof().clone();
let host = PluginHostContext::new(identity.clone(), spec.capabilities.clone());
let handles = PluginHandleStore::new(identity);
Self {
spec,
host,
handles,
lifecycle: PluginInstanceLifecycle::Active,
}
}
#[must_use]
pub fn identity(&self) -> &str {
self.spec.identity()
}
#[must_use]
pub const fn identity_proof(&self) -> &PluginIdentity {
self.spec.identity_proof()
}
#[must_use]
pub const fn lifecycle(&self) -> PluginInstanceLifecycle {
self.lifecycle
}
#[must_use]
pub const fn may_schedule(&self) -> bool {
matches!(self.lifecycle, PluginInstanceLifecycle::Active)
}
pub fn host_context(&self) -> Result<&PluginHostContext, PluginInstanceAccessError> {
self.ensure_active()?;
Ok(&self.host)
}
pub fn handles(&self) -> Result<&PluginHandleStore, PluginInstanceAccessError> {
self.ensure_active()?;
Ok(&self.handles)
}
pub fn handles_mut(&mut self) -> Result<&mut PluginHandleStore, PluginInstanceAccessError> {
self.ensure_active()?;
Ok(&mut self.handles)
}
#[cfg(test)]
pub(in crate::plugin) fn active_import_snapshot(
&self,
) -> Result<PluginHostImportSnapshot, PluginInstanceAccessError> {
self.ensure_active()?;
Ok(PluginHostImportSnapshot::new(&self.host, &self.handles)?)
}
#[cfg(feature = "plugin-runtime")]
pub(in crate::plugin) fn active_update(
&self,
) -> Result<PluginActiveUpdate, PluginInstanceAccessError> {
self.ensure_active()?;
let view = self
.handles
.first_view_handle()
.ok_or(PluginInstanceAccessError::MissingUpdateView)?;
let buffer = self
.handles
.first_buffer_handle()
.ok_or(PluginInstanceAccessError::MissingUpdateBuffer)?;
Ok(PluginActiveUpdate {
snapshot: PluginHostImportSnapshot::new(&self.host, &self.handles)?,
handles: PluginUpdateHandles { view, buffer },
})
}
pub fn revoke(&mut self, reason: PluginRevocationReason) -> PluginRevocationReport {
self.handles.revoke_all();
let generation = self.handles.generation();
self.lifecycle = PluginInstanceLifecycle::Revoked { reason, generation };
PluginRevocationReport {
identity: self.spec.identity_proof().clone(),
reason,
unload_policy: PluginUnloadPolicy::for_revocation(reason),
generation,
}
}
const fn ensure_active(&self) -> Result<(), PluginInstanceAccessError> {
match self.lifecycle {
PluginInstanceLifecycle::Active => Ok(()),
PluginInstanceLifecycle::Revoked { reason, generation } => {
Err(PluginInstanceAccessError::Revoked { reason, generation })
}
}
}
}
impl Debug for PluginInstanceState {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PluginInstanceState")
.field("identity", &self.identity())
.field("wit_world", &self.spec.wit_world())
.field("limits", &self.spec.validated_limits())
.field("lifecycle", &self.lifecycle)
.field("handle_generation", &self.handles.generation())
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PluginRevocationReport {
identity: PluginIdentity,
reason: PluginRevocationReason,
unload_policy: PluginUnloadPolicy,
generation: PluginHandleGeneration,
}
impl PluginRevocationReport {
#[must_use]
pub fn identity(&self) -> &str {
self.identity_proof().as_str()
}
#[must_use]
pub const fn identity_proof(&self) -> &PluginIdentity {
&self.identity
}
#[must_use]
pub const fn reason(&self) -> PluginRevocationReason {
self.reason
}
#[must_use]
pub const fn unload_policy(&self) -> PluginUnloadPolicy {
self.unload_policy
}
#[must_use]
pub const fn generation(&self) -> PluginHandleGeneration {
self.generation
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PluginInstanceAccessError {
HostStateMismatch(#[source] PluginHostStateMismatch),
Revoked {
reason: PluginRevocationReason,
generation: PluginHandleGeneration,
},
MissingUpdateView,
MissingUpdateBuffer,
}
impl std::fmt::Display for PluginInstanceAccessError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::HostStateMismatch(source) => {
write!(formatter, "plugin instance host state denied: {source}")
}
Self::Revoked { reason, generation } => write!(
formatter,
"plugin instance was revoked for {reason} at handle generation {}",
generation.as_u64()
),
Self::MissingUpdateView => formatter.write_str("plugin update lacks a view handle"),
Self::MissingUpdateBuffer => formatter.write_str("plugin update lacks a buffer handle"),
}
}
}
impl From<PluginHostStateMismatch> for PluginInstanceAccessError {
fn from(source: PluginHostStateMismatch) -> Self {
Self::HostStateMismatch(source)
}
}
#[cfg(test)]
mod tests {
use super::{
AuthorizedPluginComponent, PluginInstanceAccessError, PluginInstanceLifecycle,
PluginInstanceState, PluginRevocationReason, PluginRuntimeLimits, PluginRuntimeSpec,
PluginUnloadPolicy, PluginWitWorld, ValidatedPluginRuntimeLimits,
};
use crate::{
ecs::components::buffer::BufferEntity,
fs_utils::EscapedDisplayText,
plugin::{
PluginCapabilities, PluginHandleError, PluginIdentity, WorkspacePathGrant,
WorkspacePathRef,
},
text_stream::TextRevision,
};
use bevy::prelude::Entity;
#[test]
fn runtime_spec_defaults_to_bounded_execution() {
let spec = PluginRuntimeSpec::new(
component_fixture(b"\0asm"),
PluginWitWorld::current(),
PluginCapabilities::default(),
default_validated_limits(),
);
assert_ne!(
spec.limits(),
PluginRuntimeLimits {
max_memory_bytes: 0,
max_message_bytes: 0,
fuel_per_update: 0,
timeout_ms: 0,
max_intent_batches: 0,
}
);
assert_eq!(spec.limits().validate(), Ok(default_validated_limits()));
}
#[test]
fn runtime_spec_identity_is_derived_from_authorized_component() {
let component = AuthorizedPluginComponent::for_test(
PluginIdentity::try_new("component-owner").expect("identity"),
EscapedDisplayText::from_display_text("plugins/component-owner.wasm"),
b"\0asm".to_vec(),
);
let spec = PluginRuntimeSpec::new(
component,
PluginWitWorld::current(),
PluginCapabilities::default(),
default_validated_limits(),
);
assert_eq!(spec.identity(), "component-owner");
assert_eq!(spec.identity_proof().as_str(), "component-owner");
}
#[test]
fn runtime_spec_accepts_valid_custom_limits() {
let limits = PluginRuntimeLimits::try_new(32 * 1024 * 1024, 2 * 1024 * 1024, 10_000, 100)
.expect("limits should be below host caps");
let spec = PluginRuntimeSpec::new(
component_fixture(b"\0asm"),
PluginWitWorld::current(),
PluginCapabilities::default(),
limits.validate().expect("limits should validate"),
);
assert_eq!(spec.limits(), limits);
}
#[test]
fn plugin_instance_revocation_stops_scheduling_and_invalidates_handles() {
let mut instance = PluginInstanceState::new(runtime_spec_fixture(b"\0asm"));
let handle = instance
.handles_mut()
.expect("new instance should be active")
.issue_buffer(
BufferEntity(test_entity(7)),
Some(TextRevision::from(3)),
None,
)
.expect("handle issuance should fit");
let old_generation = instance
.handles()
.expect("new instance should be active")
.generation();
assert_eq!(instance.identity_proof().as_str(), "formatter");
let report = instance.revoke(PluginRevocationReason::GrantsChanged);
assert_eq!(report.identity(), "formatter");
assert_eq!(report.identity_proof().as_str(), "formatter");
assert_eq!(report.reason(), PluginRevocationReason::GrantsChanged);
assert_eq!(report.unload_policy(), PluginUnloadPolicy::Unload);
assert!(!instance.may_schedule());
assert_eq!(
instance.lifecycle(),
PluginInstanceLifecycle::Revoked {
reason: PluginRevocationReason::GrantsChanged,
generation: report.generation(),
}
);
assert_ne!(report.generation(), old_generation);
assert_eq!(
instance.host_context(),
Err(PluginInstanceAccessError::Revoked {
reason: PluginRevocationReason::GrantsChanged,
generation: report.generation(),
})
);
assert_eq!(
instance
.host_context()
.expect_err("revoked host context should fail")
.to_string(),
format!(
"plugin instance was revoked for grants-changed at handle generation {}",
report.generation().as_u64()
)
);
assert_eq!(
instance.handles(),
Err(PluginInstanceAccessError::Revoked {
reason: PluginRevocationReason::GrantsChanged,
generation: report.generation(),
})
);
assert_eq!(
instance.handles.resolve_buffer(handle),
Err(PluginHandleError::StaleGeneration {
handle: handle.shape(),
current: report.generation(),
})
);
}
#[test]
fn plugin_instance_exposes_active_host_context_only_before_revocation() {
let mut instance = PluginInstanceState::new(PluginRuntimeSpec::new(
component_fixture(b"\0asm"),
PluginWitWorld::current(),
PluginCapabilities {
workspace_observe: vec![WorkspacePathGrant::new("docs")],
..PluginCapabilities::default()
},
default_validated_limits(),
));
assert!(instance.may_schedule());
assert_eq!(
instance.host_context().expect("active host").identity(),
"formatter"
);
assert_eq!(
instance
.host_context()
.expect("active host")
.identity_proof()
.as_str(),
"formatter"
);
assert!(
instance
.host_context()
.expect("active host")
.capabilities()
.workspace_observe
.iter()
.any(|grant| grant.allows(
WorkspacePathRef::try_from("docs/arch.md").expect("path should validate")
))
);
let snapshot = instance
.active_import_snapshot()
.expect("active import snapshot");
assert_eq!(snapshot.identity_proof().as_str(), "formatter");
assert!(!format!("{snapshot:?}").contains("Entity"));
let report = instance.revoke(PluginRevocationReason::DisabledByConfig);
assert_eq!(
instance.host_context(),
Err(PluginInstanceAccessError::Revoked {
reason: PluginRevocationReason::DisabledByConfig,
generation: report.generation(),
})
);
assert_eq!(
instance.active_import_snapshot(),
Err(PluginInstanceAccessError::Revoked {
reason: PluginRevocationReason::DisabledByConfig,
generation: report.generation(),
})
);
}
#[cfg(feature = "plugin-runtime")]
#[test]
fn plugin_active_update_captures_snapshot_and_handles_together() {
let mut instance = PluginInstanceState::new(runtime_spec_fixture(b"\0asm"));
let view = crate::ecs::components::buffer::ViewEntity(test_entity(8));
let buffer = BufferEntity(test_entity(9));
let handles = instance
.handles_mut()
.expect("new instance should be active");
let view_handle = handles.issue_view(view).expect("view handle");
let buffer_handle = handles
.issue_buffer(buffer, Some(TextRevision::from(3)), Some(view))
.expect("buffer handle");
let parts = instance
.active_update()
.expect("active update should be available")
.into_parts();
assert_eq!(parts.snapshot.identity_proof().as_str(), "formatter");
assert_eq!(parts.update_handles.view(), view_handle);
assert_eq!(parts.update_handles.buffer(), buffer_handle);
assert!(!format!("{:?}", parts.snapshot).contains("Entity"));
}
#[cfg(feature = "plugin-runtime")]
#[test]
fn plugin_active_update_requires_view_and_buffer_handles() {
let mut instance = PluginInstanceState::new(runtime_spec_fixture(b"\0asm"));
assert_eq!(
instance
.active_update()
.expect_err("update without a view should fail"),
PluginInstanceAccessError::MissingUpdateView
);
let view = crate::ecs::components::buffer::ViewEntity(test_entity(10));
let _view_handle = instance
.handles_mut()
.expect("new instance should be active")
.issue_view(view)
.expect("view handle");
assert_eq!(
instance
.active_update()
.expect_err("update without a buffer should fail"),
PluginInstanceAccessError::MissingUpdateBuffer
);
let report = instance.revoke(PluginRevocationReason::HostShutdown);
assert_eq!(
instance
.active_update()
.expect_err("revoked update should fail"),
PluginInstanceAccessError::Revoked {
reason: PluginRevocationReason::HostShutdown,
generation: report.generation(),
}
);
}
#[test]
fn plugin_runtime_debug_redacts_component_bytes() {
let spec = runtime_spec_fixture(b"secret wasm bytes");
let debug = format!("{spec:?}");
assert!(debug.contains("display_path_byte_len"));
assert!(debug.contains("byte_len"));
assert!(!debug.contains("secret wasm bytes"));
assert!(!debug.contains("formatter.wasm"));
let instance = PluginInstanceState::new(spec);
let debug = format!("{instance:?}");
assert!(debug.contains("PluginInstanceState"));
assert!(debug.contains("handle_generation"));
assert!(!debug.contains("secret wasm bytes"));
assert!(!debug.contains("formatter.wasm"));
}
#[test]
fn revocation_policy_unloads_for_every_v1_reason() {
for reason in [
PluginRevocationReason::DisabledByConfig,
PluginRevocationReason::GrantsChanged,
PluginRevocationReason::ComponentChanged,
PluginRevocationReason::RuntimeLimitsChanged,
PluginRevocationReason::HostShutdown,
PluginRevocationReason::RuntimeFailure,
] {
assert_eq!(
PluginUnloadPolicy::for_revocation(reason),
PluginUnloadPolicy::Unload
);
}
}
#[test]
fn revocation_reasons_have_stable_redacted_text() {
for (reason, expected) in [
(
PluginRevocationReason::DisabledByConfig,
"disabled-by-config",
),
(PluginRevocationReason::GrantsChanged, "grants-changed"),
(
PluginRevocationReason::ComponentChanged,
"component-changed",
),
(
PluginRevocationReason::RuntimeLimitsChanged,
"runtime-limits-changed",
),
(PluginRevocationReason::HostShutdown, "host-shutdown"),
(PluginRevocationReason::RuntimeFailure, "runtime-failure"),
] {
assert_eq!(reason.as_str(), expected);
assert_eq!(reason.to_string(), expected);
}
}
fn identity() -> PluginIdentity {
PluginIdentity::try_new("formatter").expect("test identity should validate")
}
fn component_fixture(bytes: &[u8]) -> AuthorizedPluginComponent {
AuthorizedPluginComponent::for_test(
identity(),
EscapedDisplayText::from_display_text("plugins/formatter.wasm"),
bytes.to_vec(),
)
}
fn runtime_spec_fixture(bytes: &[u8]) -> PluginRuntimeSpec {
PluginRuntimeSpec::new(
component_fixture(bytes),
PluginWitWorld::current(),
PluginCapabilities::default(),
default_validated_limits(),
)
}
fn default_validated_limits() -> ValidatedPluginRuntimeLimits {
PluginRuntimeLimits::default()
.validate()
.expect("default limits should validate")
}
fn test_entity(index: u32) -> Entity {
Entity::from_raw_u32(index).expect("test entity index should be valid")
}
}