use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use bamboo_config::PluginTrustConfig;
use bamboo_plugin::manifest::Platform;
#[cfg(test)]
use bamboo_plugin::PluginInstaller;
use bamboo_plugin::{
EventSinkPermissionGrants, InstallDisposition, InstalledPlugin, PluginError, PluginManifest,
PluginResult, PluginSource,
};
use ed25519_dalek::Verifier;
use crate::plugin_installer::ServerPluginInstaller;
use crate::tool_event_policy::{resolve_event_sink_grants, EventSinkGrantRequest};
#[derive(Debug, Clone)]
pub enum PluginSourceInput {
LocalDir(PathBuf),
LocalArchive(PathBuf),
Url {
url: String,
sha256: Option<String>,
allow_unverified: bool,
allow_untrusted_host: bool,
allow_unsigned: bool,
insecure: bool,
},
}
#[derive(Debug)]
struct PreparedPlugin {
manifest: PluginManifest,
prepared_dir: PathBuf,
plugin_dir: PathBuf,
source: PluginSource,
candidate_identity: BundleIdentity,
_candidate_handle: std::fs::File,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct BundleIdentity {
volume: u64,
file_id: [u8; 16],
}
#[derive(Debug)]
struct BundleSnapshot {
path: PathBuf,
identity: BundleIdentity,
_handle: std::fs::File,
}
#[derive(Debug)]
enum BundleRecovery {
Reconciled,
ManualRecoveryRequired(String),
}
impl BundleRecovery {
fn is_reconciled(&self) -> bool {
matches!(self, Self::Reconciled)
}
fn wrap_error(self, error: PluginError) -> PluginError {
match self {
Self::Reconciled => error,
Self::ManualRecoveryRequired(detail) => PluginError::Registration(format!(
"{error}; manual bundle recovery is required: {detail}"
)),
}
}
}
#[derive(Debug)]
struct BundleTransactionFailure {
error: PluginError,
recovery: BundleRecovery,
}
impl BundleTransactionFailure {
fn into_plugin_error(self) -> PluginError {
self.recovery.wrap_error(self.error)
}
}
#[cfg(unix)]
fn capture_bundle_directory(path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
use std::os::unix::fs::MetadataExt;
let handle: std::fs::File = rustix::fs::open(
path,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::DIRECTORY
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.map_err(std::io::Error::from)?
.into();
let metadata = handle.metadata()?;
if !metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"plugin bundle path must name a real directory",
));
}
let mut file_id = [0; 16];
file_id[..8].copy_from_slice(&metadata.ino().to_ne_bytes());
let identity = BundleIdentity {
volume: metadata.dev(),
file_id,
};
Ok((handle, identity))
}
#[cfg(windows)]
fn capture_bundle_directory(path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
use std::mem::{size_of, MaybeUninit};
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
FileIdInfo, GetFileInformationByHandleEx, FILE_ATTRIBUTE_REPARSE_POINT,
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_SHARE_DELETE,
FILE_SHARE_READ, FILE_SHARE_WRITE,
};
let file = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"plugin bundle path must name a real directory, not a reparse point",
));
}
let mut identity = MaybeUninit::<FILE_ID_INFO>::zeroed();
let succeeded = unsafe {
GetFileInformationByHandleEx(
file.as_raw_handle(),
FileIdInfo,
identity.as_mut_ptr().cast(),
size_of::<FILE_ID_INFO>() as u32,
)
};
if succeeded == 0 {
return Err(std::io::Error::last_os_error());
}
let identity = unsafe { identity.assume_init() };
let identity = BundleIdentity {
volume: identity.VolumeSerialNumber,
file_id: identity.FileId.Identifier,
};
Ok((file, identity))
}
#[cfg(not(any(unix, windows)))]
fn capture_bundle_directory(_path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"identity-bound plugin activation is unavailable on this platform",
))
}
fn bundle_directory_identity(path: &Path) -> std::io::Result<BundleIdentity> {
capture_bundle_directory(path).map(|(_handle, identity)| identity)
}
fn capture_optional_bundle_snapshot(path: &Path) -> std::io::Result<Option<BundleSnapshot>> {
match capture_bundle_directory(path) {
Ok((handle, identity)) => Ok(Some(BundleSnapshot {
path: path.to_path_buf(),
identity,
_handle: handle,
})),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn retain_identity_bound_directory(
path: &Path,
expected: BundleIdentity,
prefix: &str,
context: &str,
) {
let Some(parent) = path.parent() else {
tracing::warn!(path = %path.display(), %context, "transaction entry has no parent; retaining it in place");
return;
};
let retained = parent.join(format!(".{prefix}-{}", uuid::Uuid::new_v4()));
match rename_noreplace(path, &retained) {
Ok(()) => match bundle_directory_identity(&retained) {
Ok(identity) if identity == expected => tracing::warn!(
retained = %retained.display(),
%context,
"identity-verified transaction entry retained for operator cleanup"
),
observed => {
let put_back = rename_noreplace(&retained, path);
tracing::warn!(
original = %path.display(),
retained = %retained.display(),
?observed,
?put_back,
%context,
"transaction entry changed identity; unknown replacement was preserved without deletion"
);
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => tracing::warn!(
path = %path.display(),
%context,
"identity-bound transaction entry disappeared before it could be retained"
),
Err(error) => tracing::warn!(
path = %path.display(),
%error,
%context,
"failed to quarantine transaction entry; retaining it in place"
),
}
}
fn retain_unverified_staging(path: &Path, context: &str) {
let Some(parent) = path.parent() else {
tracing::warn!(path = %path.display(), %context, "unverified staging entry has no parent; retaining it in place");
return;
};
let retained = parent.join(format!(".rejected-staging-{}", uuid::Uuid::new_v4()));
match rename_noreplace(path, &retained) {
Ok(()) => tracing::warn!(
retained = %retained.display(),
%context,
"rejected staging directory retained for operator cleanup"
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(
path = %path.display(),
%error,
%context,
"failed to quarantine rejected staging directory; retaining it in place"
),
}
}
fn restore_verified_backup(backup: &BundleSnapshot, plugin_dir: &Path) -> BundleRecovery {
match bundle_directory_identity(&backup.path) {
Ok(identity) if identity == backup.identity => {}
Ok(_) => {
return BundleRecovery::ManualRecoveryRequired(format!(
"the backup at '{}' changed identity and was not moved",
backup.path.display()
));
}
Err(error) => {
return BundleRecovery::ManualRecoveryRequired(format!(
"the backup at '{}' could not be identity-verified and was not moved: {error}",
backup.path.display()
));
}
}
if let Err(error) = rename_noreplace(&backup.path, plugin_dir) {
return BundleRecovery::ManualRecoveryRequired(format!(
"the previous bundle remains at '{}' because '{}' could not be restored without replacement: {error}",
backup.path.display(),
plugin_dir.display()
));
}
match bundle_directory_identity(plugin_dir) {
Ok(identity) if identity == backup.identity => BundleRecovery::Reconciled,
Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
"the restored destination '{}' does not have the previous bundle identity",
plugin_dir.display()
)),
Err(error) => BundleRecovery::ManualRecoveryRequired(format!(
"the restored destination '{}' could not be identity-verified: {error}",
plugin_dir.display()
)),
}
}
impl PreparedPlugin {
fn retain_candidate(&self, context: &str) {
retain_identity_bound_directory(
&self.prepared_dir,
self.candidate_identity,
&format!("candidate-{}", self.manifest.id),
context,
);
}
fn capture_expected_live(&self) -> std::io::Result<Option<BundleSnapshot>> {
capture_optional_bundle_snapshot(&self.plugin_dir)
}
#[cfg(test)]
async fn activate(self) -> Result<StagedPlugin, BundleTransactionFailure> {
let expected_live = match self.capture_expected_live() {
Ok(snapshot) => snapshot,
Err(error) => {
self.retain_candidate("live snapshot capture failed before test activation");
return Err(BundleTransactionFailure {
error: PluginError::Io(error),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the live destination '{}' could not be captured before activation",
self.plugin_dir.display()
)),
});
}
};
self.activate_inner(expected_live, ActivationFault::None)
.await
}
async fn activate_inner(
self,
expected_live: Option<BundleSnapshot>,
fault: ActivationFault,
) -> Result<StagedPlugin, BundleTransactionFailure> {
match bundle_directory_identity(&self.prepared_dir) {
Ok(identity) if identity == self.candidate_identity => {}
observed => {
self.retain_candidate("prepared candidate changed identity before activation");
return Err(BundleTransactionFailure {
error: PluginError::Registration(format!(
"prepared plugin '{}' changed identity before activation ({observed:?})",
self.manifest.id
)),
recovery: BundleRecovery::ManualRecoveryRequired(
"the expected candidate and its replacement were preserved".to_string(),
),
});
}
}
let backup = match expected_live {
Some(mut previous) => {
if previous.path != self.plugin_dir {
self.retain_candidate("expected live snapshot path was inconsistent");
return Err(BundleTransactionFailure {
error: PluginError::Registration(
"expected live snapshot did not name this plugin destination"
.to_string(),
),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the candidate at '{}' was retained without touching either live path",
self.prepared_dir.display()
)),
});
}
match bundle_directory_identity(&self.plugin_dir) {
Ok(identity) if identity == previous.identity => {}
observed => {
self.retain_candidate(
"live bundle changed after its pre-stop snapshot was captured",
);
return Err(BundleTransactionFailure {
error: PluginError::Registration(format!(
"live plugin '{}' no longer matches the exact pre-stop snapshot ({observed:?})",
self.manifest.id
)),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the unexpected destination '{}' was left untouched",
self.plugin_dir.display()
)),
});
}
}
let Some(root) = self.plugin_dir.parent() else {
self.retain_candidate("plugin destination had no parent");
return Err(BundleTransactionFailure {
error: PluginError::InvalidManifest(
"plugin directory has no parent".to_string(),
),
recovery: BundleRecovery::ManualRecoveryRequired(
"the previous bundle path had no parent".to_string(),
),
});
};
let backup = root.join(format!(
".backup-{}-{}",
self.manifest.id,
uuid::Uuid::new_v4()
));
if let Err(error) = rename_noreplace(&self.plugin_dir, &backup) {
self.retain_candidate("previous bundle backup rename failed");
let recovery = match bundle_directory_identity(&self.plugin_dir) {
Ok(identity) if identity == previous.identity => BundleRecovery::Reconciled,
Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
"the destination '{}' changed identity while the backup rename failed",
self.plugin_dir.display()
)),
Err(verify_error) => BundleRecovery::ManualRecoveryRequired(format!(
"the backup rename failed and the previous bundle at '{}' could not be reverified: {verify_error}",
self.plugin_dir.display()
)),
};
return Err(BundleTransactionFailure {
error: PluginError::Io(error),
recovery,
});
}
match bundle_directory_identity(&backup) {
Ok(identity) if identity == previous.identity => {}
Ok(_) => {
self.retain_candidate("moved previous bundle changed identity");
return Err(BundleTransactionFailure {
error: PluginError::Registration(format!(
"the previous plugin bundle changed identity while moving to '{}'",
backup.display()
)),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the ambiguous backup was preserved at '{}'",
backup.display()
)),
});
}
Err(error) => {
self.retain_candidate("moved previous bundle could not be verified");
return Err(BundleTransactionFailure {
error: PluginError::Io(error),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the unverified backup was preserved at '{}'",
backup.display()
)),
});
}
}
previous.path = backup;
Some(previous)
}
None => match std::fs::symlink_metadata(&self.plugin_dir) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Ok(_) => {
self.retain_candidate("unexpected fresh-install destination appeared");
return Err(BundleTransactionFailure {
error: PluginError::Registration(format!(
"plugin destination '{}' appeared after the no-live snapshot was captured",
self.plugin_dir.display()
)),
recovery: BundleRecovery::ManualRecoveryRequired(
"the unexpected destination was left untouched".to_string(),
),
});
}
Err(error) => {
self.retain_candidate("fresh-install destination could not be inspected");
return Err(BundleTransactionFailure {
error: PluginError::Io(error),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the destination '{}' could not be inspected",
self.plugin_dir.display()
)),
});
}
},
};
let rename_result = fault.install_destination(&self.plugin_dir).and_then(|()| {
if fault.fail_candidate_rename() {
Err(std::io::Error::other(
"injected prepared-plugin activation rename failure",
))
} else {
rename_noreplace(&self.prepared_dir, &self.plugin_dir)
}
});
if let Err(rename_error) = rename_result {
self.retain_candidate("candidate publication failed");
let recovery = match &backup {
Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
None => match std::fs::symlink_metadata(&self.plugin_dir) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
BundleRecovery::Reconciled
}
Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
"an unexpected destination remains at '{}' and there was no previous bundle",
self.plugin_dir.display()
)),
Err(error) => BundleRecovery::ManualRecoveryRequired(format!(
"the destination '{}' could not be inspected after publication failed: {error}",
self.plugin_dir.display()
)),
},
};
return Err(BundleTransactionFailure {
error: PluginError::Registration(format!(
"failed to atomically activate prepared plugin '{}' with a no-replace rename: {rename_error}",
self.manifest.id
)),
recovery,
});
}
match bundle_directory_identity(&self.plugin_dir) {
Ok(identity) if identity == self.candidate_identity => {}
Ok(_) => {
return Err(BundleTransactionFailure {
error: PluginError::Registration(format!(
"activated plugin '{}' changed identity during publication",
self.manifest.id
)),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the live destination '{}' and backup were preserved",
self.plugin_dir.display()
)),
});
}
Err(error) => {
return Err(BundleTransactionFailure {
error: PluginError::Io(error),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the activated destination '{}' could not be identity-verified; its backup was preserved",
self.plugin_dir.display()
)),
});
}
}
Ok(StagedPlugin {
manifest: self.manifest,
plugin_dir: self.plugin_dir,
source: self.source,
candidate_identity: self.candidate_identity,
_candidate_handle: self._candidate_handle,
backup,
})
}
async fn discard(self) {
self.retain_candidate("prepared plugin candidate was discarded before activation");
}
#[cfg(test)]
async fn activate_with_fault(
self,
fault: ActivationFault,
) -> Result<StagedPlugin, BundleTransactionFailure> {
let expected_live = match self.capture_expected_live() {
Ok(snapshot) => snapshot,
Err(error) => {
self.retain_candidate("live snapshot capture failed before faulted activation");
return Err(BundleTransactionFailure {
error: PluginError::Io(error),
recovery: BundleRecovery::ManualRecoveryRequired(format!(
"the live destination '{}' could not be captured before activation",
self.plugin_dir.display()
)),
});
}
};
self.activate_inner(expected_live, fault).await
}
}
#[derive(Debug)]
enum ActivationFault {
None,
#[cfg(test)]
FailCandidateRename,
#[cfg(test)]
CreateDestinationDirectory,
#[cfg(all(test, unix))]
CreateDestinationSymlink(PathBuf),
}
impl ActivationFault {
fn fail_candidate_rename(&self) -> bool {
#[cfg(test)]
{
matches!(self, Self::FailCandidateRename)
}
#[cfg(not(test))]
{
false
}
}
fn install_destination(&self, _destination: &Path) -> std::io::Result<()> {
match self {
Self::None => Ok(()),
#[cfg(test)]
Self::FailCandidateRename => Ok(()),
#[cfg(test)]
Self::CreateDestinationDirectory => {
std::fs::create_dir(_destination)?;
std::fs::write(_destination.join("RACE_MARKER"), b"race-owned")?;
Ok(())
}
#[cfg(all(test, unix))]
Self::CreateDestinationSymlink(target) => {
std::os::unix::fs::symlink(target, _destination)?;
Ok(())
}
}
}
}
#[cfg(any(
target_os = "linux",
target_os = "android",
target_vendor = "apple",
target_os = "redox"
))]
fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
use std::os::fd::AsFd;
let source_parent = source
.parent()
.ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
let destination_parent = destination
.parent()
.ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
if source_parent != destination_parent {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"prepared plugin activation paths must be siblings",
));
}
let source_name = source
.file_name()
.ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
let destination_name = destination
.file_name()
.ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
let parent = std::fs::File::open(source_parent)?;
rustix::fs::renameat_with(
parent.as_fd(),
source_name,
parent.as_fd(),
destination_name,
rustix::fs::RenameFlags::NOREPLACE,
)
.map_err(std::io::Error::from)
}
#[cfg(windows)]
fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
let source_parent = source
.parent()
.ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
let destination_parent = destination
.parent()
.ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
if source_parent != destination_parent {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"prepared plugin activation paths must be siblings",
));
}
fn nul_terminated(path: &Path) -> std::io::Result<Vec<u16>> {
let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
if wide.contains(&0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"plugin activation path contains an interior NUL",
));
}
wide.push(0);
Ok(wide)
}
let source = nul_terminated(source)?;
let destination = nul_terminated(destination)?;
let result = unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), 0) };
if result == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(not(any(
windows,
target_os = "linux",
target_os = "android",
target_vendor = "apple",
target_os = "redox"
)))]
fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"atomic no-replace plugin activation is unavailable on this platform",
))
}
#[derive(Debug)]
struct StagedPlugin {
manifest: PluginManifest,
plugin_dir: PathBuf,
source: PluginSource,
candidate_identity: BundleIdentity,
_candidate_handle: std::fs::File,
backup: Option<BundleSnapshot>,
}
#[derive(Debug)]
enum RollbackFault {
None,
#[cfg(test)]
ReplaceDestinationDirectory,
}
impl RollbackFault {
fn install_destination(&self, _plugin_dir: &Path) -> std::io::Result<()> {
match self {
Self::None => Ok(()),
#[cfg(test)]
Self::ReplaceDestinationDirectory => {
let parent = _plugin_dir.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"plugin directory has no parent",
)
})?;
let displaced = parent.join(format!(
".fault-displaced-candidate-{}",
uuid::Uuid::new_v4()
));
rename_noreplace(_plugin_dir, &displaced)?;
std::fs::create_dir(_plugin_dir)?;
std::fs::write(_plugin_dir.join("RACE_MARKER"), b"race-owned")
}
}
}
}
impl StagedPlugin {
async fn commit(self) {
let Some(backup) = self.backup else {
return;
};
let Some(parent) = backup.path.parent() else {
tracing::warn!(
backup = %backup.path.display(),
"committed plugin backup has no parent; leaving it for operator cleanup"
);
return;
};
let retired = parent.join(format!(
".retired-{}-{}",
self.manifest.id,
uuid::Uuid::new_v4()
));
if let Err(error) = rename_noreplace(&backup.path, &retired) {
tracing::warn!(
%error,
backup = %backup.path.display(),
"failed to retire committed plugin backup; leaving it in place"
);
return;
}
match bundle_directory_identity(&retired) {
Ok(identity) if identity == backup.identity => tracing::warn!(
retired = %retired.display(),
"committed plugin backup was retired and retained for operator cleanup"
),
identity => {
let restored = rename_noreplace(&retired, &backup.path);
tracing::warn!(
retired = %retired.display(),
backup = %backup.path.display(),
observed = ?identity,
restore = ?restored,
"retired plugin backup identity was ambiguous; preserved without deletion"
);
}
}
}
#[cfg(test)]
async fn rollback(self) -> BundleRecovery {
self.rollback_inner(RollbackFault::None).await
}
async fn rollback_inner(self, fault: RollbackFault) -> BundleRecovery {
if let Err(error) = fault.install_destination(&self.plugin_dir) {
return BundleRecovery::ManualRecoveryRequired(format!(
"rollback fault setup failed without deleting any bundle path: {error}"
));
}
let Some(parent) = self.plugin_dir.parent() else {
return BundleRecovery::ManualRecoveryRequired(
"the live plugin path has no parent".to_string(),
);
};
let quarantine = parent.join(format!(
".rollback-{}-{}",
self.manifest.id,
uuid::Uuid::new_v4()
));
match rename_noreplace(&self.plugin_dir, &quarantine) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return match &self.backup {
Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
None => BundleRecovery::Reconciled,
};
}
Err(error) => {
return BundleRecovery::ManualRecoveryRequired(format!(
"the live destination '{}' could not be quarantined without replacement and was left untouched: {error}",
self.plugin_dir.display()
));
}
}
match bundle_directory_identity(&quarantine) {
Ok(identity) if identity == self.candidate_identity => {}
observed => {
let put_back = rename_noreplace(&quarantine, &self.plugin_dir);
return BundleRecovery::ManualRecoveryRequired(format!(
"the live destination was not this transaction's candidate ({observed:?}); the unexpected object was preserved at '{}' (put-back result: {put_back:?}) and the previous backup was not moved",
if put_back.is_ok() {
self.plugin_dir.display()
} else {
quarantine.display()
}
));
}
}
let recovery = match &self.backup {
Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
None => BundleRecovery::Reconciled,
};
if !recovery.is_reconciled() {
return recovery;
}
tracing::warn!(
quarantine = %quarantine.display(),
"failed plugin candidate was quarantined after rollback and retained for operator cleanup"
);
recovery
}
}
async fn prepare_plugin_source(
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
) -> PluginResult<PreparedPlugin> {
prepare_plugin_source_inner(input, plugins_root, trust, MAX_DECOMPRESSED_BYTES).await
}
#[cfg(test)]
async fn stage_plugin_source(
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
) -> PluginResult<StagedPlugin> {
stage_plugin_source_inner(input, plugins_root, trust, MAX_DECOMPRESSED_BYTES).await
}
#[cfg(test)]
async fn stage_plugin_source_with_decompressed_cap(
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
max_decompressed_bytes: u64,
) -> PluginResult<StagedPlugin> {
stage_plugin_source_inner(input, plugins_root, trust, max_decompressed_bytes).await
}
#[cfg(test)]
async fn stage_plugin_source_inner(
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
max_decompressed_bytes: u64,
) -> PluginResult<StagedPlugin> {
prepare_plugin_source_inner(input, plugins_root, trust, max_decompressed_bytes)
.await?
.activate()
.await
.map_err(BundleTransactionFailure::into_plugin_error)
}
async fn prepare_plugin_source_inner(
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
max_decompressed_bytes: u64,
) -> PluginResult<PreparedPlugin> {
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, trust, max_decompressed_bytes).await;
let (manifest, source) = match staged {
Ok(pair) => pair,
Err(error) => {
retain_unverified_staging(&staging_dir, "plugin source preparation failed");
return Err(error);
}
};
if let Err(error) = manifest.validate() {
retain_unverified_staging(&staging_dir, "prepared plugin manifest validation failed");
return Err(error);
}
let (candidate_handle, candidate_identity) = match capture_bundle_directory(&staging_dir) {
Ok(snapshot) => snapshot,
Err(error) => {
retain_unverified_staging(&staging_dir, "prepared candidate identity capture failed");
return Err(PluginError::Io(error));
}
};
let plugin_dir = plugins_root.join(&manifest.id);
Ok(PreparedPlugin {
manifest,
plugin_dir,
prepared_dir: staging_dir,
source,
candidate_identity,
_candidate_handle: candidate_handle,
})
}
pub async fn install_server_plugin_from_source(
installer: &ServerPluginInstaller,
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
disposition: InstallDisposition,
expected_plugin_id: Option<&str>,
) -> PluginResult<InstalledPlugin> {
install_server_plugin_from_source_with_event_sink_grants(
installer,
input,
plugins_root,
trust,
disposition,
expected_plugin_id,
None,
)
.await
}
pub async fn install_server_plugin_from_source_with_event_sink_grants(
installer: &ServerPluginInstaller,
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
disposition: InstallDisposition,
expected_plugin_id: Option<&str>,
requested_grants: Option<&[EventSinkGrantRequest]>,
) -> PluginResult<InstalledPlugin> {
install_server_plugin_from_source_inner(
installer,
input,
plugins_root,
trust,
disposition,
expected_plugin_id,
requested_grants,
ServerSourceFault::None,
)
.await
}
#[derive(Debug)]
enum ServerSourceFault {
None,
#[cfg(test)]
ActivationRenameFailure,
#[cfg(test)]
ActivationDestinationDirectory,
#[cfg(test)]
ReplaceLiveAfterStop,
#[cfg(test)]
RollbackDestinationDirectory,
#[cfg(test)]
FinalProvenanceCommitFailure,
}
impl ServerSourceFault {
fn activation_fault(&self) -> ActivationFault {
match self {
Self::None => ActivationFault::None,
#[cfg(test)]
Self::ActivationRenameFailure => ActivationFault::FailCandidateRename,
#[cfg(test)]
Self::ActivationDestinationDirectory => ActivationFault::CreateDestinationDirectory,
#[cfg(test)]
Self::ReplaceLiveAfterStop => ActivationFault::None,
#[cfg(test)]
Self::RollbackDestinationDirectory => ActivationFault::None,
#[cfg(test)]
Self::FinalProvenanceCommitFailure => ActivationFault::None,
}
}
fn after_stop(&self, _plugin_dir: &Path) -> std::io::Result<()> {
match self {
#[cfg(test)]
Self::ReplaceLiveAfterStop => {
let parent = _plugin_dir.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"plugin directory has no parent",
)
})?;
let displaced =
parent.join(format!(".fault-displaced-live-{}", uuid::Uuid::new_v4()));
rename_noreplace(_plugin_dir, &displaced)?;
std::fs::create_dir(_plugin_dir)?;
std::fs::write(_plugin_dir.join("RACE_MARKER"), b"race-owned")
}
_ => Ok(()),
}
}
fn injected_install_error(&self) -> Option<PluginError> {
match self {
#[cfg(test)]
Self::RollbackDestinationDirectory => Some(PluginError::Registration(
"injected install failure before rollback destination race".to_string(),
)),
_ => None,
}
}
fn rollback_fault(&self) -> RollbackFault {
match self {
#[cfg(test)]
Self::RollbackDestinationDirectory => RollbackFault::ReplaceDestinationDirectory,
_ => RollbackFault::None,
}
}
#[cfg(test)]
fn fail_final_provenance_commit(&self) -> bool {
matches!(self, Self::FinalProvenanceCommitFailure)
}
}
fn stopped_upgrade_failure(error: PluginError, stopped_services: &[String]) -> PluginError {
if stopped_services.is_empty() {
return error;
}
PluginError::Registration(format!(
"{error}; upgrade failed after stopping service(s) [{}]; automatic restart is disabled, so they remain stopped pending manual recovery",
stopped_services.join(", ")
))
}
#[cfg(test)]
async fn install_server_plugin_from_source_with_fault(
installer: &ServerPluginInstaller,
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
disposition: InstallDisposition,
expected_plugin_id: Option<&str>,
fault: ServerSourceFault,
) -> PluginResult<InstalledPlugin> {
install_server_plugin_from_source_inner(
installer,
input,
plugins_root,
trust,
disposition,
expected_plugin_id,
None,
fault,
)
.await
}
async fn install_server_plugin_from_source_inner(
installer: &ServerPluginInstaller,
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
disposition: InstallDisposition,
expected_plugin_id: Option<&str>,
requested_grants: Option<&[EventSinkGrantRequest]>,
fault: ServerSourceFault,
) -> PluginResult<InstalledPlugin> {
let prepared = prepare_plugin_source(input, plugins_root, trust).await?;
if let Some(expected_plugin_id) = expected_plugin_id {
if prepared.manifest.id != expected_plugin_id {
let manifest_id = prepared.manifest.id.clone();
prepared.discard().await;
return Err(PluginError::InvalidManifest(format!(
"path id '{expected_plugin_id}' does not match the source's manifest id '{manifest_id}'"
)));
}
}
let plugin_id = prepared.manifest.id.clone();
let guard = installer.begin_operation().await;
let previous = match installer
.preflight_prepared_candidate(
&prepared.manifest,
&prepared.prepared_dir,
disposition,
&guard,
)
.await
{
Ok(previous) => previous,
Err(error) => {
prepared.discard().await;
return Err(error);
}
};
let event_sink_grants: EventSinkPermissionGrants = match resolve_event_sink_grants(
&prepared.manifest,
previous.as_ref().map(|entry| &entry.registered),
requested_grants,
) {
Ok(grants) => grants,
Err(error) => {
prepared.discard().await;
return Err(error);
}
};
if disposition == InstallDisposition::Upgrade {
let Some(previous) = previous.as_ref() else {
prepared.discard().await;
return Err(PluginError::Registration(format!(
"upgrade for '{plugin_id}' has no unique previous provenance row"
)));
};
if previous.plugin_dir != prepared.plugin_dir {
let fixed = prepared.plugin_dir.display().to_string();
let recorded = previous.plugin_dir.display().to_string();
prepared.discard().await;
return Err(PluginError::Registration(format!(
"upgrade for '{plugin_id}' requires previous provenance at fixed bundle path '{fixed}', but installed.json records '{recorded}'"
)));
}
}
let expected_live = match prepared.capture_expected_live() {
Ok(snapshot) => snapshot,
Err(error) => {
prepared.discard().await;
return Err(PluginError::Registration(format!(
"could not capture the live plugin bundle before service shutdown: {error}"
)));
}
};
match disposition {
InstallDisposition::Upgrade if expected_live.is_none() => {
prepared.discard().await;
return Err(PluginError::Registration(format!(
"upgrade for '{plugin_id}' requires an exact live bundle at '{}', but none existed before service shutdown",
plugins_root.join(&plugin_id).display()
)));
}
InstallDisposition::FailIfInstalled if expected_live.is_some() => {
prepared.discard().await;
return Err(PluginError::Registration(format!(
"fresh install expected no live bundle at '{}', but an existing destination was captured; manual bundle recovery is required",
plugins_root.join(&plugin_id).display()
)));
}
_ => {}
}
let stopped_services = if disposition == InstallDisposition::Upgrade {
installer.stop_services_for_upgrade(&plugin_id).await
} else {
Vec::new()
};
if let Err(error) = fault.after_stop(&prepared.plugin_dir) {
prepared.discard().await;
return Err(stopped_upgrade_failure(
PluginError::Registration(format!(
"failed while exercising the post-stop source transaction boundary: {error}; manual bundle recovery is required"
)),
&stopped_services,
));
}
let staged = match prepared
.activate_inner(expected_live, fault.activation_fault())
.await
{
Ok(staged) => staged,
Err(failure) => {
let error = failure.into_plugin_error();
return Err(stopped_upgrade_failure(error, &stopped_services));
}
};
let manifest = staged.manifest.clone();
let plugin_dir = staged.plugin_dir.clone();
let source = staged.source.clone();
let install_result = match fault.injected_install_error() {
Some(error) => Err(error),
None => {
#[cfg(test)]
{
if fault.fail_final_provenance_commit() {
installer
.install_with_operation_failing_final_commit(
&manifest,
&plugin_dir,
source,
disposition,
chrono::Utc::now(),
Some(&event_sink_grants),
&guard,
)
.await
} else {
installer
.install_with_operation_and_event_sink_grants(
&manifest,
&plugin_dir,
source,
disposition,
chrono::Utc::now(),
&event_sink_grants,
&guard,
)
.await
}
}
#[cfg(not(test))]
{
installer
.install_with_operation_and_event_sink_grants(
&manifest,
&plugin_dir,
source,
disposition,
chrono::Utc::now(),
&event_sink_grants,
&guard,
)
.await
}
}
};
match install_result {
Ok(entry) => {
staged.commit().await;
Ok(entry)
}
Err(error) => {
let recovery = staged.rollback_inner(fault.rollback_fault()).await;
Err(stopped_upgrade_failure(
recovery.wrap_error(error),
&stopped_services,
))
}
}
}
#[cfg(test)]
async fn install_plugin_from_source(
installer: &dyn PluginInstaller,
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
disposition: InstallDisposition,
) -> PluginResult<InstalledPlugin> {
let staged = stage_plugin_source(input, plugins_root, trust).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) => {
let recovery = staged.rollback().await;
Err(recovery.wrap_error(error))
}
}
}
async fn stage_into(
input: &PluginSourceInput,
staging_dir: &Path,
trust: &PluginTrustConfig,
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,
sha256,
allow_unverified,
allow_untrusted_host,
allow_unsigned,
insecure,
} => {
let flags = UrlTrustFlags {
sha256: sha256.as_deref(),
allow_unverified: *allow_unverified,
allow_untrusted_host: *allow_untrusted_host,
allow_unsigned: *allow_unsigned,
insecure: *insecure,
};
let fetched =
fetch_manifest_bundle(url, flags, trust, staging_dir, max_decompressed_bytes)
.await?;
if !fetched.manifest.provides.services.is_empty() && fetched.signed_by.is_none() {
return Err(PluginError::UnsignedOrUntrustedSignature(format!(
"refusing to install plugin '{}' from '{url}': it declares `provides.services` \
(long-running service plugins are the highest-trust artifact kind) but its \
bundle is unsigned or its signature does not verify against a trusted key — \
`--allow-unsigned`/`--insecure` and `plugin_trust.enforcement: off` are NOT \
honoured for a services-declaring manifest; publish a signature from a \
trusted key instead",
fetched.manifest.id
)));
}
fetch_and_place_artifact(&fetched.manifest, staging_dir, max_decompressed_bytes)
.await?;
Ok((
fetched.manifest,
PluginSource::Url {
url: url.clone(),
sha256: fetched.verified_sha256,
allow_unverified: *allow_unverified,
allow_untrusted_host: *allow_untrusted_host,
allow_unsigned: *allow_unsigned,
signed_by: fetched.signed_by,
insecure: fetched.insecure_aggregate,
},
))
}
}
}
struct UrlTrustFlags<'a> {
sha256: Option<&'a str>,
allow_unverified: bool,
allow_untrusted_host: bool,
allow_unsigned: bool,
insecure: bool,
}
struct FetchedBundle {
manifest: PluginManifest,
verified_sha256: Option<String>,
signed_by: Option<String>,
insecure_aggregate: bool,
}
async fn fetch_manifest_bundle(
url: &str,
flags: UrlTrustFlags<'_>,
trust: &PluginTrustConfig,
staging_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<FetchedBundle> {
let UrlTrustFlags {
sha256,
allow_unverified,
allow_untrusted_host,
allow_unsigned,
insecure,
} = flags;
let insecure_aggregate = insecure || trust.enforcement_is_off();
if insecure_aggregate {
tracing::warn!(
%url,
"installing plugin from '{url}' with ALL trust checks disabled (insecure) — host \
allowlist, signature and checksum-required-by-default are all skipped for this \
install (a supplied --sha256, if any, is still verified)"
);
}
let allow_untrusted_host = allow_untrusted_host || insecure_aggregate;
let allow_unsigned = allow_unsigned || insecure_aggregate;
let allow_unverified = allow_unverified || insecure_aggregate;
if !trust.is_host_trusted(url) {
if !allow_untrusted_host {
return Err(PluginError::UntrustedHost(format!(
"refusing to install plugin bundle from '{url}': its host is not in the \
`plugin_trust.trusted_hosts` allowlist (config.json) — add a matching \
host+path prefix there, or explicitly accept the risk (CLI: \
`--allow-untrusted-host`; HTTP: `\"allow_untrusted_host\": true`)"
)));
}
tracing::warn!(
%url,
"installing plugin bundle from a host outside `plugin_trust.trusted_hosts` \
(allow_untrusted_host opt-out)"
);
}
let bytes_will_be_authenticated = !allow_unsigned || sha256.is_some();
let client = if bytes_will_be_authenticated {
http_client_following_redirects()
} else {
http_client_no_redirects()
};
let bytes = download_bytes(client, url, MAX_DOWNLOAD_BYTES).await?;
let signed_by = fetch_and_verify_signature(client, url, &bytes, &trust.trusted_keys).await;
if signed_by.is_none() {
if !allow_unsigned {
return Err(PluginError::UnsignedOrUntrustedSignature(format!(
"refusing to install plugin bundle from '{url}': it is unsigned, or its \
'{url}.sig' does not verify against any key in `plugin_trust.trusted_keys` \
(config.json) — publish a signature from a trusted key, or explicitly accept \
the risk (CLI: `--allow-unsigned`; HTTP: `\"allow_unsigned\": true`)"
)));
}
tracing::warn!(
%url,
"installing an unsigned (or untrusted-signature) plugin bundle (allow_unsigned opt-out)"
);
}
if sha256.is_none() && !allow_unverified && signed_by.is_none() {
return Err(PluginError::ChecksumRequired(format!(
"refusing to install plugin bundle from '{url}' without a checksum — pass the \
bundle's sha256 (from the release page / a trusted source) to verify it before \
install (CLI: `--sha256 <hex>`; HTTP: `\"sha256\": \"<hex>\"` on the url source), \
or explicitly accept the risk of an unverified download (CLI: \
`--allow-unverified`; HTTP: `\"allow_unverified\": true`)"
)));
}
let verified_sha256 = match sha256 {
Some(expected) => {
let actual = sha256_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
return Err(PluginError::BundleVerificationFailed(format!(
"sha256 mismatch for plugin bundle '{url}': expected {expected}, downloaded \
bytes hash to {actual} — refusing to unpack (the bundle may be tampered, \
corrupted, or the wrong sha256 was supplied)"
)));
}
Some(actual)
}
None => {
if signed_by.is_none() {
tracing::warn!(
%url,
"installing plugin bundle from a URL with no checksum verification \
(allow_unverified opt-out) — the download is trusted on HTTPS alone"
);
}
None
}
};
let manifest = 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)?
};
Ok(FetchedBundle {
manifest,
verified_sha256,
signed_by,
insecure_aggregate,
})
}
async fn fetch_and_verify_signature(
client: &reqwest::Client,
url: &str,
bundle_bytes: &[u8],
trusted_keys: &[bamboo_config::TrustedKey],
) -> Option<String> {
let sig_url = format!("{url}.sig");
let sig_bytes = download_bytes(client, &sig_url, MAX_SIGNATURE_DOWNLOAD_BYTES)
.await
.ok()?;
let sig_text = String::from_utf8(sig_bytes).ok()?;
let sig_raw = hex::decode(sig_text.trim()).ok()?;
let sig_array: [u8; 64] = sig_raw.try_into().ok()?;
let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
for key in trusted_keys {
if !key.algorithm.eq_ignore_ascii_case("ed25519") {
continue;
}
let Ok(pub_raw) = hex::decode(&key.public_key) else {
continue;
};
let Ok(pub_array) = <[u8; 32]>::try_from(pub_raw.as_slice()) else {
continue;
};
let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pub_array) else {
continue;
};
if verifying_key.verify(bundle_bytes, &signature).is_ok() {
return Some(key.label.clone());
}
}
None
}
async fn fetch_and_place_artifact(
manifest: &PluginManifest,
staging_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
let Some(platform) = Platform::current() else {
return Ok(());
};
let Some(artifact) = manifest.artifacts.get(platform.as_str()) else {
return Ok(());
};
let bytes = download_bytes(
http_client_following_redirects(),
&artifact.url,
MAX_DOWNLOAD_BYTES,
)
.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) {
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?;
}
tokio::fs::remove_dir(&scratch_dir).await.map_err(|error| {
PluginError::InvalidManifest(format!(
"artifact archive for platform '{}' must contain only the expected root executable '{}': {error}",
platform.as_str(),
expected_name
))
})?;
Ok(())
}
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_following_redirects() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.expect("a reqwest client with only a redirect policy set always builds")
})
}
fn http_client_no_redirects() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("a reqwest client with only a redirect policy set always builds")
})
}
const MAX_DOWNLOAD_BYTES: u64 = 256 * 1024 * 1024;
const MAX_SIGNATURE_DOWNLOAD_BYTES: u64 = 4 * 1024;
const MAX_DECOMPRESSED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
async fn download_bytes(
client: &reqwest::Client,
url: &str,
max_bytes: u64,
) -> PluginResult<Vec<u8>> {
use futures::StreamExt;
let response =
client.get(url).send().await.map_err(|error| {
PluginError::Registration(format!("failed to fetch '{url}': {error}"))
})?;
if response.status().is_redirection() {
let status = response.status();
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let target = location.as_deref().unwrap_or("(unspecified)");
return Err(PluginError::RedirectRefused(format!(
"refused to follow an HTTP redirect ({status}) from '{url}' to '{target}': for an \
unverified install (no signature, no checksum) the approved host must serve the \
bytes directly, so redirects are not followed — install from the canonical/final \
URL, or provide a signature / `--sha256` (which authenticates the bytes regardless \
of which host serves them), or add the redirect target's host to \
`plugin_trust.trusted_hosts`"
)));
}
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_bytes {
return Err(PluginError::Registration(format!(
"'{url}' advertises a {len}-byte body, over the {max_bytes}-byte 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_bytes {
return Err(PluginError::Registration(format!(
"'{url}' streamed more than the {max_bytes}-byte 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;