use super::{PluginIdentity, WorkspacePathError};
use crate::fs_utils::{EscapedDisplayText, FileReadError, PathPolicyError};
use std::{
fmt::{Debug, Display, Formatter},
path::{Path, PathBuf},
};
#[derive(thiserror::Error)]
#[non_exhaustive]
pub enum PluginLoadError {
InvalidIdentity {
identity: String,
source: PluginIdentityError,
},
ManifestParse {
path: Option<PathBuf>,
source: serde_json::Error,
},
IdentityMismatch {
configured: PluginIdentity,
manifest: PluginIdentity,
},
WitWorldMismatch {
configured: String,
manifest: String,
},
UnsupportedWitWorld {
wit_world: String,
},
Disabled {
identity: PluginIdentity,
},
InvalidComponentPath {
identity: PluginIdentity,
source: PluginComponentPathError,
},
InvalidRuntimeLimits {
identity: PluginIdentity,
source: PluginRuntimeLimitError,
},
}
#[derive(thiserror::Error)]
#[non_exhaustive]
pub enum PluginManifestOpenError {
Read {
source: FileReadError,
},
Parse {
display_path: EscapedDisplayText,
source: serde_json::Error,
},
}
#[derive(thiserror::Error)]
#[non_exhaustive]
pub enum PluginComponentOpenError {
Read {
identity: PluginIdentity,
source: FileReadError,
},
}
#[derive(Clone, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PluginRegistryError {
InvalidIdentity {
identity: String,
source: PluginIdentityError,
},
DuplicateIdentity {
identity: PluginIdentity,
},
InvalidEnabledComponentPath {
identity: PluginIdentity,
source: PluginComponentPathError,
},
UnsupportedEnabledWitWorld {
identity: PluginIdentity,
wit_world: String,
},
InvalidEnabledRuntimeLimits {
identity: PluginIdentity,
source: PluginRuntimeLimitError,
},
}
impl Debug for PluginLoadError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidIdentity { identity, source } => formatter
.debug_struct("InvalidIdentity")
.field("identity_byte_len", &identity.len())
.field("source", source)
.finish(),
Self::ManifestParse { path, source } => formatter
.debug_struct("ManifestParse")
.field("path_byte_len", &path.as_deref().map(path_byte_len))
.field("source", &JsonErrorShape::from(source))
.finish(),
Self::IdentityMismatch {
configured,
manifest,
} => formatter
.debug_struct("IdentityMismatch")
.field("configured", configured)
.field("manifest", manifest)
.finish(),
Self::WitWorldMismatch {
configured,
manifest,
} => formatter
.debug_struct("WitWorldMismatch")
.field("configured_byte_len", &configured.len())
.field("manifest_byte_len", &manifest.len())
.finish(),
Self::UnsupportedWitWorld { wit_world } => formatter
.debug_struct("UnsupportedWitWorld")
.field("wit_world_byte_len", &wit_world.len())
.finish(),
Self::Disabled { identity } => formatter
.debug_struct("Disabled")
.field("identity", identity)
.finish(),
Self::InvalidComponentPath { identity, source } => formatter
.debug_struct("InvalidComponentPath")
.field("identity", identity)
.field("source", source)
.finish(),
Self::InvalidRuntimeLimits { identity, source } => formatter
.debug_struct("InvalidRuntimeLimits")
.field("identity", identity)
.field("source", source)
.finish(),
}
}
}
impl Debug for PluginManifestOpenError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Read { source } => formatter
.debug_struct("Read")
.field("source", &FileReadErrorShape::from(source))
.finish(),
Self::Parse {
display_path,
source,
} => formatter
.debug_struct("Parse")
.field("display_path_byte_len", &display_path.as_str().len())
.field("source", &JsonErrorShape::from(source))
.finish(),
}
}
}
impl Debug for PluginComponentOpenError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Read { identity, source } => formatter
.debug_struct("Read")
.field("identity", identity)
.field("source", &FileReadErrorShape::from(source))
.finish(),
}
}
}
impl Debug for PluginRegistryError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidIdentity { identity, source } => formatter
.debug_struct("InvalidIdentity")
.field("identity_byte_len", &identity.len())
.field("source", source)
.finish(),
Self::DuplicateIdentity { identity } => formatter
.debug_struct("DuplicateIdentity")
.field("identity", identity)
.finish(),
Self::InvalidEnabledComponentPath { identity, source } => formatter
.debug_struct("InvalidEnabledComponentPath")
.field("identity", identity)
.field("source", source)
.finish(),
Self::UnsupportedEnabledWitWorld {
identity,
wit_world,
} => formatter
.debug_struct("UnsupportedEnabledWitWorld")
.field("identity", identity)
.field("wit_world_byte_len", &wit_world.len())
.finish(),
Self::InvalidEnabledRuntimeLimits { identity, source } => formatter
.debug_struct("InvalidEnabledRuntimeLimits")
.field("identity", identity)
.field("source", source)
.finish(),
}
}
}
impl Display for PluginRegistryError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidIdentity { identity, source } => {
write!(
formatter,
"plugin identity {identity:?} is invalid: {source}"
)
}
Self::DuplicateIdentity { identity } => {
let identity = identity.as_str();
write!(
formatter,
"plugin identity {identity:?} is configured more than once"
)
}
Self::InvalidEnabledComponentPath { identity, source } => {
let identity = identity.as_str();
write!(
formatter,
"enabled plugin {identity:?} has invalid component path: {source}"
)
}
Self::UnsupportedEnabledWitWorld {
identity,
wit_world,
} => {
let identity = identity.as_str();
write!(
formatter,
"enabled plugin {identity:?} uses unsupported WIT world {wit_world:?}"
)
}
Self::InvalidEnabledRuntimeLimits { identity, source } => {
let identity = identity.as_str();
write!(
formatter,
"enabled plugin {identity:?} has invalid runtime limits: {source}"
)
}
}
}
}
impl Display for PluginLoadError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidIdentity { identity, source } => {
write!(
formatter,
"plugin identity {identity:?} is invalid: {source}"
)
}
Self::ManifestParse { path, source } => {
if let Some(path) = path {
write!(
formatter,
"failed to parse plugin manifest {}: {source}",
display_path(path)
)
} else {
write!(formatter, "failed to parse plugin manifest: {source}")
}
}
Self::IdentityMismatch {
configured,
manifest,
} => {
let configured = configured.as_str();
let manifest = manifest.as_str();
write!(
formatter,
"plugin identity mismatch: configured {configured:?}, manifest {manifest:?}"
)
}
Self::WitWorldMismatch {
configured,
manifest,
} => write!(
formatter,
"plugin WIT world mismatch: configured {configured:?}, manifest {manifest:?}"
),
Self::UnsupportedWitWorld { wit_world } => {
write!(formatter, "plugin WIT world {wit_world:?} is not supported")
}
Self::Disabled { identity } => {
let identity = identity.as_str();
write!(formatter, "plugin {identity:?} is disabled")
}
Self::InvalidComponentPath { identity, source } => {
let identity = identity.as_str();
write!(
formatter,
"plugin {identity:?} has invalid component path: {source}"
)
}
Self::InvalidRuntimeLimits { identity, source } => {
let identity = identity.as_str();
write!(
formatter,
"plugin {identity:?} has invalid runtime limits: {source}"
)
}
}
}
}
impl Display for PluginManifestOpenError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Read { source } => write!(formatter, "failed to open plugin manifest: {source}"),
Self::Parse {
display_path,
source,
} => write!(
formatter,
"failed to parse plugin manifest {}: {source}",
display_path.as_str()
),
}
}
}
impl Display for PluginComponentOpenError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Read { identity, source } => {
let identity = identity.as_str();
write!(
formatter,
"failed to open component for plugin {identity:?}: {source}"
)
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PluginIdentityError {
Empty,
TooLong,
InvalidStart,
InvalidCharacter,
}
impl Display for PluginIdentityError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => formatter.write_str("plugin identity must not be empty"),
Self::TooLong => formatter.write_str("plugin identity must be at most 128 bytes"),
Self::InvalidStart => formatter
.write_str("plugin identity must start with a lowercase ASCII letter or digit"),
Self::InvalidCharacter => formatter.write_str(
"plugin identity may contain only lowercase ASCII letters, digits, '.', '_' or '-'",
),
}
}
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum PluginRuntimeLimitField {
MaxMemoryBytes,
MaxMessageBytes,
FuelPerUpdate,
TimeoutMs,
MaxIntentBatches,
}
impl PluginRuntimeLimitField {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::MaxMemoryBytes => "max_memory_bytes",
Self::MaxMessageBytes => "max_message_bytes",
Self::FuelPerUpdate => "fuel_per_update",
Self::TimeoutMs => "timeout_ms",
Self::MaxIntentBatches => "max_intent_batches",
}
}
}
impl Display for PluginRuntimeLimitField {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
impl Debug for PluginRuntimeLimitField {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PluginRuntimeLimitError {
Zero {
field: PluginRuntimeLimitField,
},
TooLarge {
field: PluginRuntimeLimitField,
value: u64,
max: u64,
},
}
impl Display for PluginRuntimeLimitError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Zero { field } => {
write!(formatter, "plugin runtime limit {field} must be non-zero")
}
Self::TooLarge { field, value, max } => write!(
formatter,
"plugin runtime limit {field}={value} exceeds maximum {max}"
),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PluginComponentPathError {
Empty,
Absolute,
DotComponent,
EmptyComponent,
PlatformSeparator,
WindowsDrivePrefix,
}
impl Display for PluginComponentPathError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => formatter.write_str("plugin component path must not be empty"),
Self::Absolute => {
formatter.write_str("plugin component path must be workspace-relative")
}
Self::DotComponent => {
formatter.write_str("plugin component path must not contain . or .. components")
}
Self::EmptyComponent => {
formatter.write_str("plugin component path must not contain empty components")
}
Self::PlatformSeparator => {
formatter.write_str("plugin component path must use / separators")
}
Self::WindowsDrivePrefix => {
formatter.write_str("plugin component path must not use Windows drive prefixes")
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PluginAuthorizationError {
Denied {
identity: PluginIdentity,
capability: PluginCapabilityShape,
},
}
impl Display for PluginAuthorizationError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Denied {
identity,
capability,
} => {
let identity = identity.as_str();
write!(
formatter,
"plugin {identity:?} is not authorized for {capability}"
)
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum WitCapabilityRefError {
MissingPath {
capability: PluginCapabilityShape,
},
UnexpectedPath {
capability: PluginCapabilityShape,
},
InvalidPath {
capability: PluginCapabilityShape,
source: WorkspacePathError,
},
}
impl Display for WitCapabilityRefError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingPath { capability } => {
write!(formatter, "{capability} requires a workspace-relative path")
}
Self::UnexpectedPath { capability } => {
write!(
formatter,
"{capability} does not accept a workspace-relative path"
)
}
Self::InvalidPath { capability, source } => {
write!(formatter, "{capability} path is invalid: {source}")
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PluginCapabilityShape {
BufferObserve,
BufferProposeEdit,
WorkspaceObserve,
WorkspaceArtifactWrite,
StatusPublish,
}
impl Display for PluginCapabilityShape {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BufferObserve => formatter.write_str("buffer.observe"),
Self::BufferProposeEdit => formatter.write_str("buffer.propose_edit"),
Self::WorkspaceObserve => formatter.write_str("workspace.observe"),
Self::WorkspaceArtifactWrite => formatter.write_str("workspace.artifact_write"),
Self::StatusPublish => formatter.write_str("status.publish"),
}
}
}
fn display_path(path: &std::path::Path) -> String {
EscapedDisplayText::from_path(path).as_str().to_owned()
}
fn path_byte_len(path: &Path) -> usize {
path.to_string_lossy().len()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct JsonErrorShape {
category: JsonErrorCategory,
line: usize,
column: usize,
}
impl From<&serde_json::Error> for JsonErrorShape {
fn from(source: &serde_json::Error) -> Self {
Self {
category: JsonErrorCategory::from(source.classify()),
line: source.line(),
column: source.column(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum JsonErrorCategory {
Syntax,
Data,
Eof,
Io,
}
impl From<serde_json::error::Category> for JsonErrorCategory {
fn from(category: serde_json::error::Category) -> Self {
match category {
serde_json::error::Category::Syntax => Self::Syntax,
serde_json::error::Category::Data => Self::Data,
serde_json::error::Category::Eof => Self::Eof,
serde_json::error::Category::Io => Self::Io,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FileReadErrorShape {
Policy(PathPolicyErrorShape),
Missing,
Metadata,
UnsupportedFileType,
TooLarge {
size: u64,
max_size: u64,
},
Open,
Read,
}
impl From<&FileReadError> for FileReadErrorShape {
fn from(source: &FileReadError) -> Self {
match source {
FileReadError::Policy(source) => Self::Policy(PathPolicyErrorShape::from(source)),
FileReadError::Missing { .. } => Self::Missing,
FileReadError::Metadata { .. } => Self::Metadata,
FileReadError::UnsupportedFileType { .. } => Self::UnsupportedFileType,
FileReadError::TooLarge { size, max_size, .. } => Self::TooLarge {
size: *size,
max_size: *max_size,
},
FileReadError::Open { .. } => Self::Open,
FileReadError::Read { .. } => Self::Read,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PathPolicyErrorShape {
CurrentDir,
MissingParent,
Parent,
Unresolvable,
OutsideWorkspace,
}
impl From<&PathPolicyError> for PathPolicyErrorShape {
fn from(source: &PathPolicyError) -> Self {
match source {
PathPolicyError::CurrentDir { .. } => Self::CurrentDir,
PathPolicyError::MissingParent { .. } => Self::MissingParent,
PathPolicyError::Parent { .. } => Self::Parent,
PathPolicyError::Unresolvable { .. } => Self::Unresolvable,
PathPolicyError::OutsideWorkspace { .. } => Self::OutsideWorkspace,
}
}
}
#[cfg(test)]
mod tests {
use super::{
PluginAuthorizationError, PluginCapabilityShape, PluginComponentOpenError, PluginLoadError,
PluginManifestOpenError, PluginRegistryError, WitCapabilityRefError,
};
use crate::{
fs_utils::{EscapedDisplayText, FileReadError, PathPolicyError},
plugin::{
PluginComponentPathError, PluginIdentity, PluginIdentityError, PluginRuntimeLimitError,
PluginRuntimeLimitField, WorkspacePathError,
},
};
use std::{io, path::PathBuf};
#[test]
fn manifest_parse_display_escapes_hostile_path_text() {
let source = serde_json::from_slice::<serde_json::Value>(b"{")
.expect_err("fixture should be invalid json");
let error = PluginLoadError::ManifestParse {
path: Some(PathBuf::from("plugins/evil\n\u{202E}manifest.json")),
source,
};
let display = error.to_string();
assert!(display.contains("\\n"));
assert!(display.contains("\\u{202E}"));
assert!(!display.contains('\n'));
assert!(!display.contains('\u{202E}'));
}
#[test]
fn authorization_display_uses_capability_shape_not_guest_path() {
let error = PluginAuthorizationError::Denied {
identity: PluginIdentity::try_new("formatter").expect("identity"),
capability: PluginCapabilityShape::WorkspaceArtifactWrite,
};
let display = error.to_string();
assert!(display.contains("workspace.artifact_write"));
assert!(!display.contains("docs/private"));
assert!(!display.contains("secret"));
}
#[test]
fn wit_capability_path_errors_do_not_echo_malformed_guest_path() {
let error = WitCapabilityRefError::InvalidPath {
capability: PluginCapabilityShape::WorkspaceObserve,
source: WorkspacePathError::DotComponent,
};
let display = error.to_string();
assert_eq!(
display,
"workspace.observe path is invalid: workspace plugin paths must not contain . or .. components"
);
assert!(!display.contains("../secret"));
}
#[test]
fn invalid_identity_display_reports_reason_without_raw_payload_context() {
let error = PluginLoadError::InvalidIdentity {
identity: String::from("Bad\nsecret"),
source: PluginIdentityError::InvalidStart,
};
let display = error.to_string();
assert!(display.contains("InvalidStart") || display.contains("must start"));
assert!(display.contains("\\n"));
assert!(!display.contains('\n'));
}
#[test]
fn load_error_debug_redacts_untrusted_payloads() {
let parse_error = serde_json::from_slice::<serde_json::Value>(b"{")
.expect_err("fixture should be invalid json");
let manifest_parse = PluginLoadError::ManifestParse {
path: Some(PathBuf::from("plugins/secret-manifest.json")),
source: parse_error,
};
let invalid_identity = PluginLoadError::InvalidIdentity {
identity: String::from("Bad\nsecret-identity"),
source: PluginIdentityError::InvalidCharacter,
};
let unsupported_world = PluginLoadError::UnsupportedWitWorld {
wit_world: String::from("alma:secret/plugin@9.9.9"),
};
let world_mismatch = PluginLoadError::WitWorldMismatch {
configured: String::from("alma:editor/plugin@0.2.0"),
manifest: String::from("alma:secret/plugin@9.9.9"),
};
for debug in [
format!("{manifest_parse:?}"),
format!("{invalid_identity:?}"),
format!("{unsupported_world:?}"),
format!("{world_mismatch:?}"),
] {
assert!(!debug.contains("secret"));
assert!(!debug.contains("Bad\n"));
assert!(!debug.contains("alma:secret"));
}
assert!(format!("{manifest_parse:?}").contains("path_byte_len"));
assert!(format!("{invalid_identity:?}").contains("identity_byte_len"));
assert!(format!("{unsupported_world:?}").contains("wit_world_byte_len"));
assert!(format!("{world_mismatch:?}").contains("manifest_byte_len"));
}
#[test]
fn registry_error_debug_redacts_untrusted_config_payloads() {
let invalid_identity = PluginRegistryError::InvalidIdentity {
identity: String::from("Bad\nsecret-registry-identity"),
source: PluginIdentityError::InvalidCharacter,
};
let unsupported_world = PluginRegistryError::UnsupportedEnabledWitWorld {
identity: PluginIdentity::try_new("formatter").expect("identity"),
wit_world: String::from("alma:secret/plugin@9.9.9"),
};
let invalid_path = PluginRegistryError::InvalidEnabledComponentPath {
identity: PluginIdentity::try_new("formatter").expect("identity"),
source: PluginComponentPathError::DotComponent,
};
let invalid_limit = PluginRegistryError::InvalidEnabledRuntimeLimits {
identity: PluginIdentity::try_new("formatter").expect("identity"),
source: PluginRuntimeLimitError::TooLarge {
field: PluginRuntimeLimitField::MaxMemoryBytes,
value: 8,
max: 4,
},
};
let invalid_identity_debug = format!("{invalid_identity:?}");
let unsupported_world_debug = format!("{unsupported_world:?}");
let invalid_path_debug = format!("{invalid_path:?}");
let invalid_limit_debug = format!("{invalid_limit:?}");
assert!(invalid_identity_debug.contains("identity_byte_len"));
assert!(!invalid_identity_debug.contains("secret-registry-identity"));
assert!(!invalid_identity_debug.contains("Bad\n"));
assert!(unsupported_world_debug.contains("wit_world_byte_len"));
assert!(!unsupported_world_debug.contains("alma:secret"));
assert!(invalid_path_debug.contains("DotComponent"));
assert!(invalid_limit_debug.contains("max_memory_bytes"));
}
#[test]
fn open_error_debug_redacts_filesystem_sources() {
let manifest = PluginManifestOpenError::Read {
source: FileReadError::Policy(PathPolicyError::OutsideWorkspace {
path: PathBuf::from("/tmp/secret-manifest.json"),
workspace_root: PathBuf::from("/tmp/secret-workspace"),
}),
};
let component = PluginComponentOpenError::Read {
identity: PluginIdentity::try_new("formatter").expect("identity"),
source: FileReadError::Open {
path: PathBuf::from("/tmp/secret-component.wasm"),
source: io::Error::new(io::ErrorKind::PermissionDenied, "secret os detail"),
},
};
let parse_error = serde_json::from_slice::<serde_json::Value>(b"{")
.expect_err("fixture should be invalid json");
let parse = PluginManifestOpenError::Parse {
display_path: EscapedDisplayText::from_display_text("plugins/secret-manifest.json"),
source: parse_error,
};
let manifest_debug = format!("{manifest:?}");
let component_debug = format!("{component:?}");
let parse_debug = format!("{parse:?}");
assert!(manifest_debug.contains("OutsideWorkspace"));
assert!(!manifest_debug.contains("secret-manifest"));
assert!(!manifest_debug.contains("secret-workspace"));
assert!(component_debug.contains("Open"));
assert!(!component_debug.contains("secret-component"));
assert!(!component_debug.contains("secret os detail"));
assert!(parse_debug.contains("display_path_byte_len"));
assert!(!parse_debug.contains("secret-manifest"));
}
}