use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use bamboo_plugin::manifest::Platform;
use bamboo_plugin::{
InstallDisposition, InstalledPlugin, PluginError, PluginInstaller, PluginManifest,
PluginResult, PluginSource,
};
#[derive(Debug, Clone)]
pub enum PluginSourceInput {
LocalDir(PathBuf),
LocalArchive(PathBuf),
Url(String),
}
#[derive(Debug)]
pub struct StagedPlugin {
pub manifest: PluginManifest,
pub plugin_dir: PathBuf,
pub source: PluginSource,
backup_dir: Option<PathBuf>,
}
impl StagedPlugin {
pub async fn commit(self) {
if let Some(backup) = self.backup_dir {
let _ = tokio::fs::remove_dir_all(&backup).await;
}
}
pub async fn rollback(self) {
let _ = tokio::fs::remove_dir_all(&self.plugin_dir).await;
if let Some(backup) = self.backup_dir {
let _ = tokio::fs::rename(&backup, &self.plugin_dir).await;
}
}
}
pub async fn stage_plugin_source(
input: PluginSourceInput,
plugins_root: &Path,
) -> PluginResult<StagedPlugin> {
stage_plugin_source_inner(input, plugins_root, MAX_DECOMPRESSED_BYTES).await
}
#[cfg(test)]
pub(crate) async fn stage_plugin_source_with_decompressed_cap(
input: PluginSourceInput,
plugins_root: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<StagedPlugin> {
stage_plugin_source_inner(input, plugins_root, max_decompressed_bytes).await
}
async fn stage_plugin_source_inner(
input: PluginSourceInput,
plugins_root: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<StagedPlugin> {
tokio::fs::create_dir_all(plugins_root).await?;
let staging_dir = plugins_root.join(format!(".staging-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&staging_dir).await?;
let staged = stage_into(&input, &staging_dir, max_decompressed_bytes).await;
let (manifest, source) = match staged {
Ok(pair) => pair,
Err(error) => {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(error);
}
};
if let Err(error) = manifest.validate() {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(error);
}
let plugin_dir = plugins_root.join(&manifest.id);
let backup_dir = if tokio::fs::try_exists(&plugin_dir).await.unwrap_or(false) {
let backup = plugins_root.join(format!(".backup-{}-{}", manifest.id, uuid::Uuid::new_v4()));
if let Err(error) = tokio::fs::rename(&plugin_dir, &backup).await {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(PluginError::Io(error));
}
Some(backup)
} else {
None
};
if let Err(rename_error) = tokio::fs::rename(&staging_dir, &plugin_dir).await {
if let Err(copy_error) = copy_dir_recursive(&staging_dir, &plugin_dir).await {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
if let Some(backup) = &backup_dir {
let _ = tokio::fs::rename(backup, &plugin_dir).await;
}
tracing::warn!(%rename_error, %copy_error, "failed to stage plugin bundle into place");
return Err(copy_error);
}
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
}
Ok(StagedPlugin {
manifest,
plugin_dir,
source,
backup_dir,
})
}
pub async fn install_plugin_from_source(
installer: &dyn PluginInstaller,
input: PluginSourceInput,
plugins_root: &Path,
disposition: InstallDisposition,
) -> PluginResult<InstalledPlugin> {
let staged = stage_plugin_source(input, plugins_root).await?;
let manifest = staged.manifest.clone();
let plugin_dir = staged.plugin_dir.clone();
let source = staged.source.clone();
match installer
.install(
&manifest,
&plugin_dir,
source,
disposition,
chrono::Utc::now(),
)
.await
{
Ok(entry) => {
staged.commit().await;
Ok(entry)
}
Err(error) => {
staged.rollback().await;
Err(error)
}
}
}
async fn stage_into(
input: &PluginSourceInput,
staging_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<(PluginManifest, PluginSource)> {
match input {
PluginSourceInput::LocalDir(path) => {
copy_dir_recursive(path, staging_dir).await?;
let manifest = read_and_parse_manifest(staging_dir).await?;
Ok((manifest, PluginSource::LocalDir { path: path.clone() }))
}
PluginSourceInput::LocalArchive(path) => {
let bytes = tokio::fs::read(path).await?;
let kind = detect_archive_kind(&path.to_string_lossy()).ok_or_else(|| {
PluginError::InvalidManifest(format!(
"unsupported archive extension for '{}': expected .zip/.tar.gz/.tgz",
path.display()
))
})?;
extract_archive(
bytes,
kind,
staging_dir.to_path_buf(),
max_decompressed_bytes,
)
.await?;
flatten_if_single_subdir(staging_dir).await?;
let manifest = read_and_parse_manifest(staging_dir).await?;
Ok((manifest, PluginSource::LocalArchive { path: path.clone() }))
}
PluginSourceInput::Url(url) => {
let manifest = fetch_manifest_bundle(url, staging_dir, max_decompressed_bytes).await?;
let sha256 =
fetch_and_place_artifact(&manifest, staging_dir, max_decompressed_bytes).await?;
Ok((
manifest,
PluginSource::Url {
url: url.clone(),
sha256,
},
))
}
}
}
async fn fetch_manifest_bundle(
url: &str,
staging_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<PluginManifest> {
let bytes = download_bytes(url).await?;
if let Some(kind) = detect_archive_kind(url) {
extract_archive(
bytes,
kind,
staging_dir.to_path_buf(),
max_decompressed_bytes,
)
.await?;
flatten_if_single_subdir(staging_dir).await?;
read_and_parse_manifest(staging_dir).await
} else {
let raw = String::from_utf8(bytes).map_err(|_| {
PluginError::InvalidManifest(format!("manifest at '{url}' is not valid UTF-8"))
})?;
tokio::fs::create_dir_all(staging_dir).await?;
tokio::fs::write(staging_dir.join("plugin.json"), &raw).await?;
PluginManifest::parse_str(&raw)
}
}
async fn fetch_and_place_artifact(
manifest: &PluginManifest,
staging_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<Option<String>> {
let Some(platform) = Platform::current() else {
return Ok(None);
};
let Some(artifact) = manifest.artifacts.get(platform.as_str()) else {
return Ok(None);
};
let bytes = download_bytes(&artifact.url).await?;
let actual_sha256 = sha256_hex(&bytes);
if !actual_sha256.eq_ignore_ascii_case(&artifact.sha256) {
return Err(PluginError::ArtifactVerificationFailed(format!(
"sha256 mismatch for '{}': manifest declares {}, downloaded bytes hash to {}",
artifact.url, artifact.sha256, actual_sha256
)));
}
let kind = detect_archive_kind(&artifact.url).ok_or_else(|| {
PluginError::InvalidManifest(format!(
"artifact url '{}' is not a .zip/.tar.gz/.tgz",
artifact.url
))
})?;
let scratch_dir = staging_dir.join(format!(".artifact-scratch-{}", platform.as_str()));
extract_archive(bytes, kind, scratch_dir.clone(), max_decompressed_bytes).await?;
let expected_name = if matches!(platform, Platform::Windows) {
format!("{}.exe", manifest.id)
} else {
manifest.id.clone()
};
let source_bin = scratch_dir.join(&expected_name);
if !tokio::fs::try_exists(&source_bin).await.unwrap_or(false) {
let _ = tokio::fs::remove_dir_all(&scratch_dir).await;
return Err(PluginError::InvalidManifest(format!(
"artifact archive for platform '{}' does not contain the expected root executable '{}'",
platform.as_str(),
expected_name
)));
}
let dest_dir = staging_dir.join("bin").join(platform.as_str());
tokio::fs::create_dir_all(&dest_dir).await?;
let dest_bin = dest_dir.join(&expected_name);
move_file(&source_bin, &dest_bin).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(&dest_bin).await?.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(&dest_bin, perms).await?;
}
let _ = tokio::fs::remove_dir_all(&scratch_dir).await;
Ok(Some(artifact.sha256.clone()))
}
async fn move_file(source: &Path, dest: &Path) -> PluginResult<()> {
if tokio::fs::rename(source, dest).await.is_ok() {
return Ok(());
}
let data = tokio::fs::read(source).await?;
tokio::fs::write(dest, data).await?;
tokio::fs::remove_file(source).await?;
Ok(())
}
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(reqwest::Client::new)
}
const MAX_DOWNLOAD_BYTES: u64 = 256 * 1024 * 1024;
const MAX_DECOMPRESSED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
async fn download_bytes(url: &str) -> PluginResult<Vec<u8>> {
use futures::StreamExt;
let response =
http_client().get(url).send().await.map_err(|error| {
PluginError::Registration(format!("failed to fetch '{url}': {error}"))
})?;
let response = response.error_for_status().map_err(|error| {
PluginError::Registration(format!("'{url}' returned an error status: {error}"))
})?;
if let Some(len) = response.content_length() {
if len > MAX_DOWNLOAD_BYTES {
return Err(PluginError::Registration(format!(
"'{url}' advertises a {len}-byte body, over the {MAX_DOWNLOAD_BYTES}-byte plugin \
download cap; refusing"
)));
}
}
let mut stream = response.bytes_stream();
let mut buffer: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| {
PluginError::Registration(format!("failed to read response body of '{url}': {error}"))
})?;
if buffer.len() as u64 + chunk.len() as u64 > MAX_DOWNLOAD_BYTES {
return Err(PluginError::Registration(format!(
"'{url}' streamed more than the {MAX_DOWNLOAD_BYTES}-byte plugin download cap; \
aborting"
)));
}
buffer.extend_from_slice(&chunk);
}
Ok(buffer)
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[derive(Debug, Clone, Copy)]
enum ArchiveKind {
Zip,
TarGz,
}
fn detect_archive_kind(name_or_url: &str) -> Option<ArchiveKind> {
let lower = name_or_url.to_ascii_lowercase();
let lower = lower.split(['?', '#']).next().unwrap_or(&lower).to_string();
if lower.ends_with(".zip") {
Some(ArchiveKind::Zip)
} else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
Some(ArchiveKind::TarGz)
} else {
None
}
}
async fn extract_archive(
bytes: Vec<u8>,
kind: ArchiveKind,
dest_dir: PathBuf,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
tokio::fs::create_dir_all(&dest_dir).await?;
tokio::task::spawn_blocking(move || match kind {
ArchiveKind::Zip => extract_zip_sync(&bytes, &dest_dir, max_decompressed_bytes),
ArchiveKind::TarGz => extract_targz_sync(&bytes, &dest_dir, max_decompressed_bytes),
})
.await
.map_err(|error| {
PluginError::Registration(format!("archive extraction task panicked: {error}"))
})?
}
fn copy_capped(
reader: &mut impl std::io::Read,
writer: &mut impl std::io::Write,
running_total: &mut u64,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
let mut buffer = [0u8; 64 * 1024];
loop {
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
return Ok(());
}
*running_total += bytes_read as u64;
if *running_total > max_decompressed_bytes {
return Err(PluginError::InvalidManifest(format!(
"archive expands to more than the {max_decompressed_bytes}-byte decompressed \
size cap ({running_total} bytes and counting); refusing to unpack (possible \
decompression bomb)"
)));
}
writer.write_all(&buffer[..bytes_read])?;
}
}
fn extract_zip_sync(
bytes: &[u8],
dest_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
use std::io::Cursor;
let cursor = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)
.map_err(|error| PluginError::InvalidManifest(format!("invalid zip archive: {error}")))?;
let mut total_decompressed_bytes: u64 = 0;
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| {
PluginError::InvalidManifest(format!("invalid zip entry at index {index}: {error}"))
})?;
let Some(relative_path) = file.enclosed_name() else {
return Err(PluginError::InvalidManifest(format!(
"zip entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
file.name()
)));
};
let out_path = dest_dir.join(&relative_path);
if file.is_dir() {
std::fs::create_dir_all(&out_path)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out_file = std::fs::File::create(&out_path)?;
if let Err(error) = copy_capped(
&mut file,
&mut out_file,
&mut total_decompressed_bytes,
max_decompressed_bytes,
) {
drop(out_file);
let _ = std::fs::remove_file(&out_path);
return Err(error);
}
drop(out_file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
}
}
}
Ok(())
}
fn extract_targz_sync(
bytes: &[u8],
dest_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
use flate2::read::GzDecoder;
use std::path::Component;
use tar::{Archive, EntryType};
let decoder = GzDecoder::new(bytes);
let mut archive = Archive::new(decoder);
let mut total_decompressed_bytes: u64 = 0;
for entry_result in archive.entries()? {
let mut entry = entry_result?;
let entry_type = entry.header().entry_type();
if matches!(entry_type, EntryType::Symlink | EntryType::Link) {
let link_target = entry
.link_name()
.ok()
.flatten()
.map(|path| path.display().to_string())
.unwrap_or_default();
return Err(PluginError::InvalidManifest(format!(
"tar entry '{}' is a {} (target '{link_target}') — plugin bundles must not ship \
links; refusing to unpack",
entry
.path()
.map(|p| p.display().to_string())
.unwrap_or_default(),
if entry_type == EntryType::Symlink {
"symlink"
} else {
"hardlink"
},
)));
}
let relative_path = entry.path()?.into_owned();
let is_unsafe = relative_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
if is_unsafe {
return Err(PluginError::InvalidManifest(format!(
"tar entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
relative_path.display()
)));
}
let out_path = dest_dir.join(&relative_path);
if entry_type.is_dir() {
std::fs::create_dir_all(&out_path)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out_file = std::fs::File::create(&out_path)?;
if let Err(error) = copy_capped(
&mut entry,
&mut out_file,
&mut total_decompressed_bytes,
max_decompressed_bytes,
) {
drop(out_file);
let _ = std::fs::remove_file(&out_path);
return Err(error);
}
drop(out_file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mode) = entry.header().mode() {
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
}
}
}
Ok(())
}
async fn read_and_parse_manifest(dir: &Path) -> PluginResult<PluginManifest> {
let manifest_path = dir.join("plugin.json");
let raw = tokio::fs::read_to_string(&manifest_path)
.await
.map_err(|_| {
PluginError::InvalidManifest(format!(
"no plugin.json found at '{}'",
manifest_path.display()
))
})?;
PluginManifest::parse_str(&raw)
}
async fn flatten_if_single_subdir(dir: &Path) -> PluginResult<()> {
if tokio::fs::try_exists(dir.join("plugin.json"))
.await
.unwrap_or(false)
{
return Ok(());
}
let mut entries = tokio::fs::read_dir(dir).await?;
let mut only_entry: Option<PathBuf> = None;
let mut count = 0usize;
while let Some(entry) = entries.next_entry().await? {
count += 1;
if count > 1 {
return Ok(());
}
only_entry = Some(entry.path());
}
let Some(candidate) = only_entry else {
return Ok(());
};
if !tokio::fs::symlink_metadata(&candidate).await?.is_dir() {
return Ok(());
}
let mut children = tokio::fs::read_dir(&candidate).await?;
while let Some(child) = children.next_entry().await? {
let dest = dir.join(child.file_name());
tokio::fs::rename(child.path(), dest).await?;
}
tokio::fs::remove_dir(&candidate).await?;
Ok(())
}
fn copy_dir_recursive<'a>(
source: &'a Path,
dest: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = PluginResult<()>> + Send + 'a>> {
Box::pin(async move {
tokio::fs::create_dir_all(dest).await?;
let mut entries = tokio::fs::read_dir(source).await?;
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
let dest_path = dest.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path).await?;
} else if file_type.is_file() {
tokio::fs::copy(entry.path(), &dest_path).await?;
}
}
Ok(())
})
}
#[cfg(test)]
mod tests;