use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use thiserror::Error;
use crate::network_policy::NetworkPolicy;
use crate::skills::install::{
self as skill_install, FetchOutcome, InstallSource, InstalledFromMarker, fetch_tarball,
sha256_hex, source_spec_string,
};
mod place;
mod stage;
mod tarball;
#[cfg(test)]
mod tests;
use place::{ensure_target_within_plugins_dir, finalize_install, plugin_target_path};
use stage::stage_local_copy;
use tarball::stage_tarball;
pub use crate::skills::install::INSTALLED_FROM_MARKER;
pub const DEFAULT_MAX_SIZE_BYTES: u64 = skill_install::DEFAULT_MAX_SIZE_BYTES;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginInstallSource {
LocalPath(PathBuf),
Remote(InstallSource),
}
impl PluginInstallSource {
pub fn parse(spec: &str) -> Result<Self> {
let trimmed = spec.trim();
if trimmed.is_empty() {
bail!("install source must not be empty");
}
if let Some(path) = trimmed.strip_prefix("path:") {
return Self::local(path);
}
if trimmed.starts_with("github:")
|| trimmed.starts_with("https://")
|| trimmed.starts_with("http://")
{
let source = InstallSource::parse(trimmed)?;
return match source {
InstallSource::GitHubRepo(_) | InstallSource::DirectUrl(_) => {
Ok(Self::Remote(source))
}
InstallSource::Registry(_) => {
unreachable!("prefixed specs never parse as a registry name")
}
};
}
Self::local(trimmed)
}
fn local(spec: &str) -> Result<Self> {
let trimmed = spec.trim();
if trimmed.is_empty() {
bail!("local install path must not be empty");
}
Ok(Self::LocalPath(PathBuf::from(trimmed)))
}
}
fn plugin_spec_string(source: &PluginInstallSource, canonical_source: Option<&Path>) -> String {
match source {
PluginInstallSource::LocalPath(_) => {
let path = canonical_source.expect("local installs record the canonical source");
format!("path:{}", path.display())
}
PluginInstallSource::Remote(remote) => source_spec_string(remote),
}
}
#[derive(Debug)]
pub enum PluginInstallOutcome {
Installed(InstalledPlugin),
NeedsApproval(String),
NetworkDenied(String),
}
#[derive(Debug, Clone)]
pub struct InstalledPlugin {
pub name: String,
pub path: PathBuf,
pub content_hash: String,
pub installed_content_hash: String,
pub source_checksum: String,
}
#[derive(Debug)]
pub enum PluginUpdateResult {
NoChange,
Updated(InstalledPlugin),
NeedsApproval(String),
NetworkDenied(String),
}
#[derive(Debug, Error)]
pub enum PluginInstallError {
#[error("entry escapes destination directory: {0}")]
PathTraversal(String),
#[error("bundle is too large; uncompressed total would exceed {limit} bytes")]
OversizedBundle { limit: u64 },
#[error(
"archive must contain exactly one plugin bundle root (a directory holding plugin.json, kimi.plugin.json, or plugin.toml); found {0} (install a single plugin bundle, not a mono-repo)"
)]
PluginTomlRoots(usize),
#[error("symlinks and hard links are not allowed in plugin bundles")]
SymlinkRejected,
#[error("plugin '{0}' is already installed; use /plugin update or uninstall it first")]
AlreadyInstalled(String),
#[error(
"plugin '{0}' was not installed via /plugin install (no .installed-from marker); refusing to touch the hand-placed bundle"
)]
NotInstalledHere(String),
}
pub async fn install(
source: PluginInstallSource,
user_plugins_dir: &Path,
max_size: u64,
network: &NetworkPolicy,
update: bool,
name_conflict: &dyn Fn(&str) -> Option<String>,
) -> Result<PluginInstallOutcome> {
install_inner(
source,
user_plugins_dir,
max_size,
network,
update,
name_conflict,
None,
)
.await
}
pub async fn install_with_expected_content_hash(
source: PluginInstallSource,
user_plugins_dir: &Path,
max_size: u64,
network: &NetworkPolicy,
name_conflict: &dyn Fn(&str) -> Option<String>,
expected_content_hash: &str,
) -> Result<PluginInstallOutcome> {
install_inner(
source,
user_plugins_dir,
max_size,
network,
false,
name_conflict,
Some(expected_content_hash),
)
.await
}
async fn install_inner(
source: PluginInstallSource,
user_plugins_dir: &Path,
max_size: u64,
network: &NetworkPolicy,
update: bool,
name_conflict: &dyn Fn(&str) -> Option<String>,
expected_content_hash: Option<&str>,
) -> Result<PluginInstallOutcome> {
match &source {
PluginInstallSource::LocalPath(path) => {
let staged = stage_local_copy(path, user_plugins_dir, max_size)?;
verify_expected_content_hash(&staged, expected_content_hash)?;
if let Some(conflict) = name_conflict(&staged.name) {
let _ = fs::remove_dir_all(&staged.staged_path);
bail!(conflict);
}
let canonical = path
.canonicalize()
.with_context(|| format!("failed to resolve {}", path.display()))?;
finalize_install(
staged,
&plugin_spec_string(&source, Some(&canonical)),
None,
"",
user_plugins_dir,
update,
)
}
PluginInstallSource::Remote(remote) => {
let (bytes, url) = match fetch_tarball(remote, network, max_size).await? {
FetchOutcome::Bytes { bytes, url } => (bytes, url),
FetchOutcome::NeedsApproval(host) => {
return Ok(PluginInstallOutcome::NeedsApproval(host));
}
FetchOutcome::Denied(host) => {
return Ok(PluginInstallOutcome::NetworkDenied(host));
}
};
install_remote_bytes(
remote,
&bytes,
&url,
user_plugins_dir,
max_size,
update,
name_conflict,
expected_content_hash,
)
}
}
}
fn verify_expected_content_hash(
staged: &stage::StagedPlugin,
expected_content_hash: Option<&str>,
) -> Result<()> {
let Some(expected) = expected_content_hash else {
return Ok(());
};
if staged.content_hash == expected {
return Ok(());
}
let actual = staged.content_hash.clone();
let _ = fs::remove_dir_all(&staged.staged_path);
bail!(
"plugin source changed after review: expected content hash {expected}, copied bytes hash is {actual}; nothing was installed"
)
}
#[allow(clippy::too_many_arguments)]
fn install_remote_bytes(
remote: &InstallSource,
bytes: &[u8],
url: &str,
user_plugins_dir: &Path,
max_size: u64,
update: bool,
name_conflict: &dyn Fn(&str) -> Option<String>,
expected_content_hash: Option<&str>,
) -> Result<PluginInstallOutcome> {
let checksum = sha256_hex(bytes);
let staged = stage_tarball(bytes, user_plugins_dir, max_size)?;
verify_expected_content_hash(&staged, expected_content_hash)?;
if let Some(conflict) = name_conflict(&staged.name) {
let _ = fs::remove_dir_all(&staged.staged_path);
bail!(conflict);
}
finalize_install(
staged,
&source_spec_string(remote),
Some(url),
&checksum,
user_plugins_dir,
update,
)
}
pub async fn update(
name: &str,
user_plugins_dir: &Path,
max_size: u64,
network: &NetworkPolicy,
) -> Result<PluginUpdateResult> {
let target = plugin_target_path(name, user_plugins_dir)?;
if target.exists() {
ensure_target_within_plugins_dir(&target, user_plugins_dir)?;
}
let marker_path = target.join(INSTALLED_FROM_MARKER);
if !marker_path.exists() {
return Err(PluginInstallError::NotInstalledHere(name.to_string()).into());
}
let marker_body = fs::read_to_string(&marker_path)
.with_context(|| format!("failed to read {}", marker_path.display()))?;
let marker: InstalledFromMarker = serde_json::from_str(&marker_body)
.with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?;
let source = PluginInstallSource::parse(&marker.spec)?;
let PluginInstallSource::Remote(remote) = source else {
bail!(
"plugin '{name}' was installed from a local path ({}) and cannot be updated from the network; \
reinstall it with /plugin install <path>",
marker.spec
);
};
let (bytes, url) = match fetch_tarball(&remote, network, max_size).await? {
FetchOutcome::Bytes { bytes, url } => (bytes, url),
FetchOutcome::NeedsApproval(host) => {
return Ok(PluginUpdateResult::NeedsApproval(host));
}
FetchOutcome::Denied(host) => return Ok(PluginUpdateResult::NetworkDenied(host)),
};
if sha256_hex(&bytes) == marker.source_checksum() {
return Ok(PluginUpdateResult::NoChange);
}
let outcome = install_remote_bytes(
&remote,
&bytes,
&url,
user_plugins_dir,
max_size,
true,
&|_| None,
None,
)?;
match outcome {
PluginInstallOutcome::Installed(installed) => Ok(PluginUpdateResult::Updated(installed)),
PluginInstallOutcome::NeedsApproval(host) => Ok(PluginUpdateResult::NeedsApproval(host)),
PluginInstallOutcome::NetworkDenied(host) => Ok(PluginUpdateResult::NetworkDenied(host)),
}
}
pub fn uninstall(name: &str, user_plugins_dir: &Path) -> Result<()> {
let target = plugin_target_path(name, user_plugins_dir)?;
if !target.exists() {
bail!("plugin '{name}' is not installed at {}", target.display());
}
ensure_target_within_plugins_dir(&target, user_plugins_dir)?;
if !target.join(INSTALLED_FROM_MARKER).exists() {
return Err(PluginInstallError::NotInstalledHere(name.to_string()).into());
}
fs::remove_dir_all(&target)
.with_context(|| format!("failed to remove {}", target.display()))?;
Ok(())
}