use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use crate::network_policy::NetworkPolicy;
use super::install::{self, PluginInstallOutcome, PluginInstallSource, PluginUpdateResult};
use super::registry::PluginRegistry;
use super::types::PluginScope;
#[derive(Debug, Clone)]
pub enum PluginMutationRequest {
Install { source: PluginInstallSource },
InstallExact {
source: PluginInstallSource,
expected_content_hash: String,
},
Update { selector: String },
Uninstall { selector: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginMutationOutcome {
Installed,
Updated,
NoChange,
Uninstalled,
NeedsApproval(String),
NetworkDenied(String),
}
#[derive(Debug, Clone)]
pub struct PluginMutationReceipt {
pub name: String,
pub path: Option<PathBuf>,
pub content_hash: Option<String>,
pub installed_content_hash: Option<String>,
pub outcome: PluginMutationOutcome,
}
pub struct PluginMutationContext<'a> {
pub network: &'a NetworkPolicy,
pub max_size: u64,
}
pub async fn execute(
request: PluginMutationRequest,
ctx: &PluginMutationContext<'_>,
registry: &mut PluginRegistry,
) -> Result<PluginMutationReceipt> {
match request {
PluginMutationRequest::Install { source } => {
install_plugin(source, None, ctx, registry).await
}
PluginMutationRequest::InstallExact {
source,
expected_content_hash,
} => install_plugin(source, Some(expected_content_hash.as_str()), ctx, registry).await,
PluginMutationRequest::Update { selector } => update_plugin(&selector, ctx, registry).await,
PluginMutationRequest::Uninstall { selector } => uninstall_plugin(&selector, registry),
}
}
fn user_plugins_dir(registry: &PluginRegistry) -> Result<PathBuf> {
registry
.user_plugins_dir()
.map(PathBuf::from)
.context("plugin registry has no user plugins root; install is fail-closed")
}
async fn install_plugin(
source: PluginInstallSource,
expected_content_hash: Option<&str>,
ctx: &PluginMutationContext<'_>,
registry: &mut PluginRegistry,
) -> Result<PluginMutationReceipt> {
let plugins_dir = user_plugins_dir(registry)?;
let name_conflict = |name: &str| -> Option<String> {
registry.get(name).map(|existing| {
format!(
"plugin name '{name}' is already used by the {} bundle at {}; \
choose a different name or remove that bundle first",
existing.scope.as_str(),
existing.canonical_root.display()
)
})
};
let outcome = match expected_content_hash {
Some(expected) => {
install::install_with_expected_content_hash(
source,
&plugins_dir,
ctx.max_size,
ctx.network,
&name_conflict,
expected,
)
.await?
}
None => {
install::install(
source,
&plugins_dir,
ctx.max_size,
ctx.network,
false,
&name_conflict,
)
.await?
}
};
Ok(match outcome {
PluginInstallOutcome::Installed(installed) => PluginMutationReceipt {
name: installed.name,
path: Some(installed.path),
content_hash: Some(installed.content_hash),
installed_content_hash: Some(installed.installed_content_hash),
outcome: PluginMutationOutcome::Installed,
},
PluginInstallOutcome::NeedsApproval(host) => blocked(host, true),
PluginInstallOutcome::NetworkDenied(host) => blocked(host, false),
})
}
async fn update_plugin(
selector: &str,
ctx: &PluginMutationContext<'_>,
registry: &mut PluginRegistry,
) -> Result<PluginMutationReceipt> {
let plugin = registry
.get(selector)
.with_context(|| format!("Plugin bundle `{selector}` was not found"))?
.clone();
if plugin.scope != PluginScope::User {
bail!(
"only user-scope bundles installed via /plugin install can be updated; \
`{selector}` is a {} bundle",
plugin.scope.as_str()
);
}
let plugins_dir = user_plugins_dir(registry)?;
let outcome = install::update(plugin.name(), &plugins_dir, ctx.max_size, ctx.network).await?;
Ok(match outcome {
PluginUpdateResult::NoChange => PluginMutationReceipt {
name: plugin.name().to_string(),
path: None,
content_hash: None,
installed_content_hash: None,
outcome: PluginMutationOutcome::NoChange,
},
PluginUpdateResult::Updated(installed) => PluginMutationReceipt {
name: installed.name,
path: Some(installed.path),
content_hash: Some(installed.content_hash),
installed_content_hash: Some(installed.installed_content_hash),
outcome: PluginMutationOutcome::Updated,
},
PluginUpdateResult::NeedsApproval(host) => blocked(host, true),
PluginUpdateResult::NetworkDenied(host) => blocked(host, false),
})
}
fn uninstall_plugin(
selector: &str,
registry: &mut PluginRegistry,
) -> Result<PluginMutationReceipt> {
let plugin = registry
.get(selector)
.with_context(|| format!("Plugin bundle `{selector}` was not found"))?
.clone();
if plugin.scope != PluginScope::User {
bail!(
"refusing to uninstall the {} bundle `{selector}`; remove it from its own root",
plugin.scope.as_str()
);
}
if plugin.enabled {
bail!("plugin `{selector}` is enabled; disable it first with /plugin disable {selector}");
}
let plugins_dir = user_plugins_dir(registry)?;
install::uninstall(plugin.name(), &plugins_dir)?;
registry
.prune_state_entry(selector)
.map_err(anyhow::Error::msg)?;
Ok(PluginMutationReceipt {
name: plugin.name().to_string(),
path: None,
content_hash: None,
installed_content_hash: None,
outcome: PluginMutationOutcome::Uninstalled,
})
}
fn blocked(host: String, needs_approval: bool) -> PluginMutationReceipt {
PluginMutationReceipt {
name: String::new(),
path: None,
content_hash: None,
installed_content_hash: None,
outcome: if needs_approval {
PluginMutationOutcome::NeedsApproval(host)
} else {
PluginMutationOutcome::NetworkDenied(host)
},
}
}