1use std::path::{Path, PathBuf};
188use std::sync::OnceLock;
189
190use bamboo_config::PluginTrustConfig;
191use bamboo_plugin::manifest::Platform;
192#[cfg(test)]
193use bamboo_plugin::PluginInstaller;
194use bamboo_plugin::{
195 EventSinkPermissionGrants, InstallDisposition, InstalledPlugin, PluginError, PluginManifest,
196 PluginResult, PluginSource,
197};
198use ed25519_dalek::Verifier;
199
200use crate::plugin_installer::ServerPluginInstaller;
201use crate::tool_event_policy::{resolve_event_sink_grants, EventSinkGrantRequest};
202
203#[derive(Debug, Clone)]
205pub enum PluginSourceInput {
206 LocalDir(PathBuf),
208 LocalArchive(PathBuf),
211 Url {
233 url: String,
234 sha256: Option<String>,
235 allow_unverified: bool,
236 allow_untrusted_host: bool,
237 allow_unsigned: bool,
238 insecure: bool,
239 },
240}
241
242#[derive(Debug)]
247struct PreparedPlugin {
248 manifest: PluginManifest,
249 prepared_dir: PathBuf,
250 plugin_dir: PathBuf,
251 source: PluginSource,
252 candidate_identity: BundleIdentity,
253 _candidate_handle: std::fs::File,
257}
258
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
260struct BundleIdentity {
261 volume: u64,
262 file_id: [u8; 16],
263}
264
265#[derive(Debug)]
266struct BundleSnapshot {
267 path: PathBuf,
268 identity: BundleIdentity,
269 _handle: std::fs::File,
273}
274
275#[derive(Debug)]
276enum BundleRecovery {
277 Reconciled,
280 ManualRecoveryRequired(String),
283}
284
285impl BundleRecovery {
286 fn is_reconciled(&self) -> bool {
287 matches!(self, Self::Reconciled)
288 }
289
290 fn wrap_error(self, error: PluginError) -> PluginError {
291 match self {
292 Self::Reconciled => error,
293 Self::ManualRecoveryRequired(detail) => PluginError::Registration(format!(
294 "{error}; manual bundle recovery is required: {detail}"
295 )),
296 }
297 }
298}
299
300#[derive(Debug)]
301struct BundleTransactionFailure {
302 error: PluginError,
303 recovery: BundleRecovery,
304}
305
306impl BundleTransactionFailure {
307 fn into_plugin_error(self) -> PluginError {
308 self.recovery.wrap_error(self.error)
309 }
310}
311
312#[cfg(unix)]
313fn capture_bundle_directory(path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
314 use std::os::unix::fs::MetadataExt;
315
316 let handle: std::fs::File = rustix::fs::open(
317 path,
318 rustix::fs::OFlags::RDONLY
319 | rustix::fs::OFlags::DIRECTORY
320 | rustix::fs::OFlags::NOFOLLOW
321 | rustix::fs::OFlags::CLOEXEC,
322 rustix::fs::Mode::empty(),
323 )
324 .map_err(std::io::Error::from)?
325 .into();
326 let metadata = handle.metadata()?;
327 if !metadata.is_dir() {
328 return Err(std::io::Error::new(
329 std::io::ErrorKind::InvalidData,
330 "plugin bundle path must name a real directory",
331 ));
332 }
333 let mut file_id = [0; 16];
334 file_id[..8].copy_from_slice(&metadata.ino().to_ne_bytes());
335 let identity = BundleIdentity {
336 volume: metadata.dev(),
337 file_id,
338 };
339 Ok((handle, identity))
340}
341
342#[cfg(windows)]
343fn capture_bundle_directory(path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
344 use std::mem::{size_of, MaybeUninit};
345 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
346 use std::os::windows::io::AsRawHandle;
347 use windows_sys::Win32::Storage::FileSystem::{
348 FileIdInfo, GetFileInformationByHandleEx, FILE_ATTRIBUTE_REPARSE_POINT,
349 FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_SHARE_DELETE,
350 FILE_SHARE_READ, FILE_SHARE_WRITE,
351 };
352
353 let file = std::fs::OpenOptions::new()
354 .read(true)
355 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
356 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
357 .open(path)?;
358 let metadata = file.metadata()?;
359 if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
360 return Err(std::io::Error::new(
361 std::io::ErrorKind::InvalidData,
362 "plugin bundle path must name a real directory, not a reparse point",
363 ));
364 }
365 let mut identity = MaybeUninit::<FILE_ID_INFO>::zeroed();
366 let succeeded = unsafe {
370 GetFileInformationByHandleEx(
371 file.as_raw_handle(),
372 FileIdInfo,
373 identity.as_mut_ptr().cast(),
374 size_of::<FILE_ID_INFO>() as u32,
375 )
376 };
377 if succeeded == 0 {
378 return Err(std::io::Error::last_os_error());
379 }
380 let identity = unsafe { identity.assume_init() };
383 let identity = BundleIdentity {
384 volume: identity.VolumeSerialNumber,
385 file_id: identity.FileId.Identifier,
386 };
387 Ok((file, identity))
388}
389
390#[cfg(not(any(unix, windows)))]
391fn capture_bundle_directory(_path: &Path) -> std::io::Result<(std::fs::File, BundleIdentity)> {
392 Err(std::io::Error::new(
393 std::io::ErrorKind::Unsupported,
394 "identity-bound plugin activation is unavailable on this platform",
395 ))
396}
397
398fn bundle_directory_identity(path: &Path) -> std::io::Result<BundleIdentity> {
399 capture_bundle_directory(path).map(|(_handle, identity)| identity)
400}
401
402fn capture_optional_bundle_snapshot(path: &Path) -> std::io::Result<Option<BundleSnapshot>> {
403 match capture_bundle_directory(path) {
404 Ok((handle, identity)) => Ok(Some(BundleSnapshot {
405 path: path.to_path_buf(),
406 identity,
407 _handle: handle,
408 })),
409 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
410 Err(error) => Err(error),
411 }
412}
413
414fn retain_identity_bound_directory(
418 path: &Path,
419 expected: BundleIdentity,
420 prefix: &str,
421 context: &str,
422) {
423 let Some(parent) = path.parent() else {
424 tracing::warn!(path = %path.display(), %context, "transaction entry has no parent; retaining it in place");
425 return;
426 };
427 let retained = parent.join(format!(".{prefix}-{}", uuid::Uuid::new_v4()));
428 match rename_noreplace(path, &retained) {
429 Ok(()) => match bundle_directory_identity(&retained) {
430 Ok(identity) if identity == expected => tracing::warn!(
431 retained = %retained.display(),
432 %context,
433 "identity-verified transaction entry retained for operator cleanup"
434 ),
435 observed => {
436 let put_back = rename_noreplace(&retained, path);
437 tracing::warn!(
438 original = %path.display(),
439 retained = %retained.display(),
440 ?observed,
441 ?put_back,
442 %context,
443 "transaction entry changed identity; unknown replacement was preserved without deletion"
444 );
445 }
446 },
447 Err(error) if error.kind() == std::io::ErrorKind::NotFound => tracing::warn!(
448 path = %path.display(),
449 %context,
450 "identity-bound transaction entry disappeared before it could be retained"
451 ),
452 Err(error) => tracing::warn!(
453 path = %path.display(),
454 %error,
455 %context,
456 "failed to quarantine transaction entry; retaining it in place"
457 ),
458 }
459}
460
461fn retain_unverified_staging(path: &Path, context: &str) {
465 let Some(parent) = path.parent() else {
466 tracing::warn!(path = %path.display(), %context, "unverified staging entry has no parent; retaining it in place");
467 return;
468 };
469 let retained = parent.join(format!(".rejected-staging-{}", uuid::Uuid::new_v4()));
470 match rename_noreplace(path, &retained) {
471 Ok(()) => tracing::warn!(
472 retained = %retained.display(),
473 %context,
474 "rejected staging directory retained for operator cleanup"
475 ),
476 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
477 Err(error) => tracing::warn!(
478 path = %path.display(),
479 %error,
480 %context,
481 "failed to quarantine rejected staging directory; retaining it in place"
482 ),
483 }
484}
485
486fn restore_verified_backup(backup: &BundleSnapshot, plugin_dir: &Path) -> BundleRecovery {
487 match bundle_directory_identity(&backup.path) {
488 Ok(identity) if identity == backup.identity => {}
489 Ok(_) => {
490 return BundleRecovery::ManualRecoveryRequired(format!(
491 "the backup at '{}' changed identity and was not moved",
492 backup.path.display()
493 ));
494 }
495 Err(error) => {
496 return BundleRecovery::ManualRecoveryRequired(format!(
497 "the backup at '{}' could not be identity-verified and was not moved: {error}",
498 backup.path.display()
499 ));
500 }
501 }
502 if let Err(error) = rename_noreplace(&backup.path, plugin_dir) {
503 return BundleRecovery::ManualRecoveryRequired(format!(
504 "the previous bundle remains at '{}' because '{}' could not be restored without replacement: {error}",
505 backup.path.display(),
506 plugin_dir.display()
507 ));
508 }
509 match bundle_directory_identity(plugin_dir) {
510 Ok(identity) if identity == backup.identity => BundleRecovery::Reconciled,
511 Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
512 "the restored destination '{}' does not have the previous bundle identity",
513 plugin_dir.display()
514 )),
515 Err(error) => BundleRecovery::ManualRecoveryRequired(format!(
516 "the restored destination '{}' could not be identity-verified: {error}",
517 plugin_dir.display()
518 )),
519 }
520}
521
522impl PreparedPlugin {
523 fn retain_candidate(&self, context: &str) {
524 retain_identity_bound_directory(
525 &self.prepared_dir,
526 self.candidate_identity,
527 &format!("candidate-{}", self.manifest.id),
528 context,
529 );
530 }
531
532 fn capture_expected_live(&self) -> std::io::Result<Option<BundleSnapshot>> {
533 capture_optional_bundle_snapshot(&self.plugin_dir)
534 }
535
536 #[cfg(test)]
542 async fn activate(self) -> Result<StagedPlugin, BundleTransactionFailure> {
543 let expected_live = match self.capture_expected_live() {
544 Ok(snapshot) => snapshot,
545 Err(error) => {
546 self.retain_candidate("live snapshot capture failed before test activation");
547 return Err(BundleTransactionFailure {
548 error: PluginError::Io(error),
549 recovery: BundleRecovery::ManualRecoveryRequired(format!(
550 "the live destination '{}' could not be captured before activation",
551 self.plugin_dir.display()
552 )),
553 });
554 }
555 };
556 self.activate_inner(expected_live, ActivationFault::None)
557 .await
558 }
559
560 async fn activate_inner(
561 self,
562 expected_live: Option<BundleSnapshot>,
563 fault: ActivationFault,
564 ) -> Result<StagedPlugin, BundleTransactionFailure> {
565 match bundle_directory_identity(&self.prepared_dir) {
566 Ok(identity) if identity == self.candidate_identity => {}
567 observed => {
568 self.retain_candidate("prepared candidate changed identity before activation");
569 return Err(BundleTransactionFailure {
570 error: PluginError::Registration(format!(
571 "prepared plugin '{}' changed identity before activation ({observed:?})",
572 self.manifest.id
573 )),
574 recovery: BundleRecovery::ManualRecoveryRequired(
575 "the expected candidate and its replacement were preserved".to_string(),
576 ),
577 });
578 }
579 }
580
581 let backup = match expected_live {
582 Some(mut previous) => {
583 if previous.path != self.plugin_dir {
584 self.retain_candidate("expected live snapshot path was inconsistent");
585 return Err(BundleTransactionFailure {
586 error: PluginError::Registration(
587 "expected live snapshot did not name this plugin destination"
588 .to_string(),
589 ),
590 recovery: BundleRecovery::ManualRecoveryRequired(format!(
591 "the candidate at '{}' was retained without touching either live path",
592 self.prepared_dir.display()
593 )),
594 });
595 }
596 match bundle_directory_identity(&self.plugin_dir) {
597 Ok(identity) if identity == previous.identity => {}
598 observed => {
599 self.retain_candidate(
600 "live bundle changed after its pre-stop snapshot was captured",
601 );
602 return Err(BundleTransactionFailure {
603 error: PluginError::Registration(format!(
604 "live plugin '{}' no longer matches the exact pre-stop snapshot ({observed:?})",
605 self.manifest.id
606 )),
607 recovery: BundleRecovery::ManualRecoveryRequired(format!(
608 "the unexpected destination '{}' was left untouched",
609 self.plugin_dir.display()
610 )),
611 });
612 }
613 }
614 let Some(root) = self.plugin_dir.parent() else {
615 self.retain_candidate("plugin destination had no parent");
616 return Err(BundleTransactionFailure {
617 error: PluginError::InvalidManifest(
618 "plugin directory has no parent".to_string(),
619 ),
620 recovery: BundleRecovery::ManualRecoveryRequired(
621 "the previous bundle path had no parent".to_string(),
622 ),
623 });
624 };
625 let backup = root.join(format!(
626 ".backup-{}-{}",
627 self.manifest.id,
628 uuid::Uuid::new_v4()
629 ));
630 if let Err(error) = rename_noreplace(&self.plugin_dir, &backup) {
631 self.retain_candidate("previous bundle backup rename failed");
632 let recovery = match bundle_directory_identity(&self.plugin_dir) {
633 Ok(identity) if identity == previous.identity => BundleRecovery::Reconciled,
634 Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
635 "the destination '{}' changed identity while the backup rename failed",
636 self.plugin_dir.display()
637 )),
638 Err(verify_error) => BundleRecovery::ManualRecoveryRequired(format!(
639 "the backup rename failed and the previous bundle at '{}' could not be reverified: {verify_error}",
640 self.plugin_dir.display()
641 )),
642 };
643 return Err(BundleTransactionFailure {
644 error: PluginError::Io(error),
645 recovery,
646 });
647 }
648 match bundle_directory_identity(&backup) {
649 Ok(identity) if identity == previous.identity => {}
650 Ok(_) => {
651 self.retain_candidate("moved previous bundle changed identity");
652 return Err(BundleTransactionFailure {
653 error: PluginError::Registration(format!(
654 "the previous plugin bundle changed identity while moving to '{}'",
655 backup.display()
656 )),
657 recovery: BundleRecovery::ManualRecoveryRequired(format!(
658 "the ambiguous backup was preserved at '{}'",
659 backup.display()
660 )),
661 });
662 }
663 Err(error) => {
664 self.retain_candidate("moved previous bundle could not be verified");
665 return Err(BundleTransactionFailure {
666 error: PluginError::Io(error),
667 recovery: BundleRecovery::ManualRecoveryRequired(format!(
668 "the unverified backup was preserved at '{}'",
669 backup.display()
670 )),
671 });
672 }
673 }
674 previous.path = backup;
675 Some(previous)
676 }
677 None => match std::fs::symlink_metadata(&self.plugin_dir) {
678 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
679 Ok(_) => {
680 self.retain_candidate("unexpected fresh-install destination appeared");
681 return Err(BundleTransactionFailure {
682 error: PluginError::Registration(format!(
683 "plugin destination '{}' appeared after the no-live snapshot was captured",
684 self.plugin_dir.display()
685 )),
686 recovery: BundleRecovery::ManualRecoveryRequired(
687 "the unexpected destination was left untouched".to_string(),
688 ),
689 });
690 }
691 Err(error) => {
692 self.retain_candidate("fresh-install destination could not be inspected");
693 return Err(BundleTransactionFailure {
694 error: PluginError::Io(error),
695 recovery: BundleRecovery::ManualRecoveryRequired(format!(
696 "the destination '{}' could not be inspected",
697 self.plugin_dir.display()
698 )),
699 });
700 }
701 },
702 };
703
704 let rename_result = fault.install_destination(&self.plugin_dir).and_then(|()| {
705 if fault.fail_candidate_rename() {
706 Err(std::io::Error::other(
707 "injected prepared-plugin activation rename failure",
708 ))
709 } else {
710 rename_noreplace(&self.prepared_dir, &self.plugin_dir)
711 }
712 });
713 if let Err(rename_error) = rename_result {
714 self.retain_candidate("candidate publication failed");
722 let recovery = match &backup {
723 Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
724 None => match std::fs::symlink_metadata(&self.plugin_dir) {
725 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
726 BundleRecovery::Reconciled
727 }
728 Ok(_) => BundleRecovery::ManualRecoveryRequired(format!(
729 "an unexpected destination remains at '{}' and there was no previous bundle",
730 self.plugin_dir.display()
731 )),
732 Err(error) => BundleRecovery::ManualRecoveryRequired(format!(
733 "the destination '{}' could not be inspected after publication failed: {error}",
734 self.plugin_dir.display()
735 )),
736 },
737 };
738 return Err(BundleTransactionFailure {
739 error: PluginError::Registration(format!(
740 "failed to atomically activate prepared plugin '{}' with a no-replace rename: {rename_error}",
741 self.manifest.id
742 )),
743 recovery,
744 });
745 }
746
747 match bundle_directory_identity(&self.plugin_dir) {
748 Ok(identity) if identity == self.candidate_identity => {}
749 Ok(_) => {
750 return Err(BundleTransactionFailure {
751 error: PluginError::Registration(format!(
752 "activated plugin '{}' changed identity during publication",
753 self.manifest.id
754 )),
755 recovery: BundleRecovery::ManualRecoveryRequired(format!(
756 "the live destination '{}' and backup were preserved",
757 self.plugin_dir.display()
758 )),
759 });
760 }
761 Err(error) => {
762 return Err(BundleTransactionFailure {
763 error: PluginError::Io(error),
764 recovery: BundleRecovery::ManualRecoveryRequired(format!(
765 "the activated destination '{}' could not be identity-verified; its backup was preserved",
766 self.plugin_dir.display()
767 )),
768 });
769 }
770 }
771
772 Ok(StagedPlugin {
773 manifest: self.manifest,
774 plugin_dir: self.plugin_dir,
775 source: self.source,
776 candidate_identity: self.candidate_identity,
777 _candidate_handle: self._candidate_handle,
778 backup,
779 })
780 }
781
782 async fn discard(self) {
786 self.retain_candidate("prepared plugin candidate was discarded before activation");
787 }
788
789 #[cfg(test)]
790 async fn activate_with_fault(
791 self,
792 fault: ActivationFault,
793 ) -> Result<StagedPlugin, BundleTransactionFailure> {
794 let expected_live = match self.capture_expected_live() {
795 Ok(snapshot) => snapshot,
796 Err(error) => {
797 self.retain_candidate("live snapshot capture failed before faulted activation");
798 return Err(BundleTransactionFailure {
799 error: PluginError::Io(error),
800 recovery: BundleRecovery::ManualRecoveryRequired(format!(
801 "the live destination '{}' could not be captured before activation",
802 self.plugin_dir.display()
803 )),
804 });
805 }
806 };
807 self.activate_inner(expected_live, fault).await
808 }
809}
810
811#[derive(Debug)]
812enum ActivationFault {
813 None,
814 #[cfg(test)]
815 FailCandidateRename,
816 #[cfg(test)]
817 CreateDestinationDirectory,
818 #[cfg(all(test, unix))]
819 CreateDestinationSymlink(PathBuf),
820}
821
822impl ActivationFault {
823 fn fail_candidate_rename(&self) -> bool {
824 #[cfg(test)]
825 {
826 matches!(self, Self::FailCandidateRename)
827 }
828 #[cfg(not(test))]
829 {
830 false
831 }
832 }
833
834 fn install_destination(&self, _destination: &Path) -> std::io::Result<()> {
835 match self {
836 Self::None => Ok(()),
837 #[cfg(test)]
838 Self::FailCandidateRename => Ok(()),
839 #[cfg(test)]
840 Self::CreateDestinationDirectory => {
841 std::fs::create_dir(_destination)?;
842 std::fs::write(_destination.join("RACE_MARKER"), b"race-owned")?;
843 Ok(())
844 }
845 #[cfg(all(test, unix))]
846 Self::CreateDestinationSymlink(target) => {
847 std::os::unix::fs::symlink(target, _destination)?;
848 Ok(())
849 }
850 }
851 }
852}
853
854#[cfg(any(
859 target_os = "linux",
860 target_os = "android",
861 target_vendor = "apple",
862 target_os = "redox"
863))]
864fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
865 use std::os::fd::AsFd;
866
867 let source_parent = source
868 .parent()
869 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
870 let destination_parent = destination
871 .parent()
872 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
873 if source_parent != destination_parent {
874 return Err(std::io::Error::new(
875 std::io::ErrorKind::InvalidInput,
876 "prepared plugin activation paths must be siblings",
877 ));
878 }
879 let source_name = source
880 .file_name()
881 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
882 let destination_name = destination
883 .file_name()
884 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
885 let parent = std::fs::File::open(source_parent)?;
886 rustix::fs::renameat_with(
887 parent.as_fd(),
888 source_name,
889 parent.as_fd(),
890 destination_name,
891 rustix::fs::RenameFlags::NOREPLACE,
892 )
893 .map_err(std::io::Error::from)
894}
895
896#[cfg(windows)]
897fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> {
898 use std::os::windows::ffi::OsStrExt;
899 use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
900
901 let source_parent = source
902 .parent()
903 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
904 let destination_parent = destination
905 .parent()
906 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
907 if source_parent != destination_parent {
908 return Err(std::io::Error::new(
909 std::io::ErrorKind::InvalidInput,
910 "prepared plugin activation paths must be siblings",
911 ));
912 }
913
914 fn nul_terminated(path: &Path) -> std::io::Result<Vec<u16>> {
915 let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
916 if wide.contains(&0) {
917 return Err(std::io::Error::new(
918 std::io::ErrorKind::InvalidInput,
919 "plugin activation path contains an interior NUL",
920 ));
921 }
922 wide.push(0);
923 Ok(wide)
924 }
925
926 let source = nul_terminated(source)?;
927 let destination = nul_terminated(destination)?;
928 let result = unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), 0) };
931 if result == 0 {
932 Err(std::io::Error::last_os_error())
933 } else {
934 Ok(())
935 }
936}
937
938#[cfg(not(any(
939 windows,
940 target_os = "linux",
941 target_os = "android",
942 target_vendor = "apple",
943 target_os = "redox"
944)))]
945fn rename_noreplace(_source: &Path, _destination: &Path) -> std::io::Result<()> {
946 Err(std::io::Error::new(
947 std::io::ErrorKind::Unsupported,
948 "atomic no-replace plugin activation is unavailable on this platform",
949 ))
950}
951
952#[derive(Debug)]
956struct StagedPlugin {
957 manifest: PluginManifest,
958 plugin_dir: PathBuf,
959 source: PluginSource,
960 candidate_identity: BundleIdentity,
961 _candidate_handle: std::fs::File,
962 backup: Option<BundleSnapshot>,
963}
964
965#[derive(Debug)]
966enum RollbackFault {
967 None,
968 #[cfg(test)]
969 ReplaceDestinationDirectory,
970}
971
972impl RollbackFault {
973 fn install_destination(&self, _plugin_dir: &Path) -> std::io::Result<()> {
974 match self {
975 Self::None => Ok(()),
976 #[cfg(test)]
977 Self::ReplaceDestinationDirectory => {
978 let parent = _plugin_dir.parent().ok_or_else(|| {
979 std::io::Error::new(
980 std::io::ErrorKind::InvalidInput,
981 "plugin directory has no parent",
982 )
983 })?;
984 let displaced = parent.join(format!(
985 ".fault-displaced-candidate-{}",
986 uuid::Uuid::new_v4()
987 ));
988 rename_noreplace(_plugin_dir, &displaced)?;
989 std::fs::create_dir(_plugin_dir)?;
990 std::fs::write(_plugin_dir.join("RACE_MARKER"), b"race-owned")
991 }
992 }
993 }
994}
995
996impl StagedPlugin {
997 async fn commit(self) {
1002 let Some(backup) = self.backup else {
1003 return;
1004 };
1005 let Some(parent) = backup.path.parent() else {
1006 tracing::warn!(
1007 backup = %backup.path.display(),
1008 "committed plugin backup has no parent; leaving it for operator cleanup"
1009 );
1010 return;
1011 };
1012 let retired = parent.join(format!(
1013 ".retired-{}-{}",
1014 self.manifest.id,
1015 uuid::Uuid::new_v4()
1016 ));
1017 if let Err(error) = rename_noreplace(&backup.path, &retired) {
1018 tracing::warn!(
1019 %error,
1020 backup = %backup.path.display(),
1021 "failed to retire committed plugin backup; leaving it in place"
1022 );
1023 return;
1024 }
1025 match bundle_directory_identity(&retired) {
1026 Ok(identity) if identity == backup.identity => tracing::warn!(
1027 retired = %retired.display(),
1028 "committed plugin backup was retired and retained for operator cleanup"
1029 ),
1030 identity => {
1031 let restored = rename_noreplace(&retired, &backup.path);
1032 tracing::warn!(
1033 retired = %retired.display(),
1034 backup = %backup.path.display(),
1035 observed = ?identity,
1036 restore = ?restored,
1037 "retired plugin backup identity was ambiguous; preserved without deletion"
1038 );
1039 }
1040 }
1041 }
1042
1043 #[cfg(test)]
1049 async fn rollback(self) -> BundleRecovery {
1050 self.rollback_inner(RollbackFault::None).await
1051 }
1052
1053 async fn rollback_inner(self, fault: RollbackFault) -> BundleRecovery {
1054 if let Err(error) = fault.install_destination(&self.plugin_dir) {
1055 return BundleRecovery::ManualRecoveryRequired(format!(
1056 "rollback fault setup failed without deleting any bundle path: {error}"
1057 ));
1058 }
1059
1060 let Some(parent) = self.plugin_dir.parent() else {
1061 return BundleRecovery::ManualRecoveryRequired(
1062 "the live plugin path has no parent".to_string(),
1063 );
1064 };
1065 let quarantine = parent.join(format!(
1066 ".rollback-{}-{}",
1067 self.manifest.id,
1068 uuid::Uuid::new_v4()
1069 ));
1070 match rename_noreplace(&self.plugin_dir, &quarantine) {
1071 Ok(()) => {}
1072 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1073 return match &self.backup {
1074 Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
1075 None => BundleRecovery::Reconciled,
1076 };
1077 }
1078 Err(error) => {
1079 return BundleRecovery::ManualRecoveryRequired(format!(
1080 "the live destination '{}' could not be quarantined without replacement and was left untouched: {error}",
1081 self.plugin_dir.display()
1082 ));
1083 }
1084 }
1085
1086 match bundle_directory_identity(&quarantine) {
1087 Ok(identity) if identity == self.candidate_identity => {}
1088 observed => {
1089 let put_back = rename_noreplace(&quarantine, &self.plugin_dir);
1090 return BundleRecovery::ManualRecoveryRequired(format!(
1091 "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",
1092 if put_back.is_ok() {
1093 self.plugin_dir.display()
1094 } else {
1095 quarantine.display()
1096 }
1097 ));
1098 }
1099 }
1100
1101 let recovery = match &self.backup {
1102 Some(backup) => restore_verified_backup(backup, &self.plugin_dir),
1103 None => BundleRecovery::Reconciled,
1104 };
1105 if !recovery.is_reconciled() {
1106 return recovery;
1109 }
1110
1111 tracing::warn!(
1117 quarantine = %quarantine.display(),
1118 "failed plugin candidate was quarantined after rollback and retained for operator cleanup"
1119 );
1120 recovery
1121 }
1122}
1123
1124async fn prepare_plugin_source(
1128 input: PluginSourceInput,
1129 plugins_root: &Path,
1130 trust: &PluginTrustConfig,
1131) -> PluginResult<PreparedPlugin> {
1132 prepare_plugin_source_inner(input, plugins_root, trust, MAX_DECOMPRESSED_BYTES).await
1133}
1134
1135#[cfg(test)]
1139async fn stage_plugin_source(
1140 input: PluginSourceInput,
1141 plugins_root: &Path,
1142 trust: &PluginTrustConfig,
1143) -> PluginResult<StagedPlugin> {
1144 stage_plugin_source_inner(input, plugins_root, trust, MAX_DECOMPRESSED_BYTES).await
1145}
1146
1147#[cfg(test)]
1153async fn stage_plugin_source_with_decompressed_cap(
1154 input: PluginSourceInput,
1155 plugins_root: &Path,
1156 trust: &PluginTrustConfig,
1157 max_decompressed_bytes: u64,
1158) -> PluginResult<StagedPlugin> {
1159 stage_plugin_source_inner(input, plugins_root, trust, max_decompressed_bytes).await
1160}
1161
1162#[cfg(test)]
1163async fn stage_plugin_source_inner(
1164 input: PluginSourceInput,
1165 plugins_root: &Path,
1166 trust: &PluginTrustConfig,
1167 max_decompressed_bytes: u64,
1168) -> PluginResult<StagedPlugin> {
1169 prepare_plugin_source_inner(input, plugins_root, trust, max_decompressed_bytes)
1170 .await?
1171 .activate()
1172 .await
1173 .map_err(BundleTransactionFailure::into_plugin_error)
1174}
1175
1176async fn prepare_plugin_source_inner(
1177 input: PluginSourceInput,
1178 plugins_root: &Path,
1179 trust: &PluginTrustConfig,
1180 max_decompressed_bytes: u64,
1181) -> PluginResult<PreparedPlugin> {
1182 tokio::fs::create_dir_all(plugins_root).await?;
1183 let staging_dir = plugins_root.join(format!(".staging-{}", uuid::Uuid::new_v4()));
1184 tokio::fs::create_dir_all(&staging_dir).await?;
1185
1186 let staged = stage_into(&input, &staging_dir, trust, max_decompressed_bytes).await;
1187 let (manifest, source) = match staged {
1188 Ok(pair) => pair,
1189 Err(error) => {
1190 retain_unverified_staging(&staging_dir, "plugin source preparation failed");
1191 return Err(error);
1192 }
1193 };
1194
1195 if let Err(error) = manifest.validate() {
1196 retain_unverified_staging(&staging_dir, "prepared plugin manifest validation failed");
1197 return Err(error);
1198 }
1199
1200 let (candidate_handle, candidate_identity) = match capture_bundle_directory(&staging_dir) {
1201 Ok(snapshot) => snapshot,
1202 Err(error) => {
1203 retain_unverified_staging(&staging_dir, "prepared candidate identity capture failed");
1204 return Err(PluginError::Io(error));
1205 }
1206 };
1207 let plugin_dir = plugins_root.join(&manifest.id);
1208 Ok(PreparedPlugin {
1209 manifest,
1210 plugin_dir,
1211 prepared_dir: staging_dir,
1212 source,
1213 candidate_identity,
1214 _candidate_handle: candidate_handle,
1215 })
1216}
1217
1218pub async fn install_server_plugin_from_source(
1228 installer: &ServerPluginInstaller,
1229 input: PluginSourceInput,
1230 plugins_root: &Path,
1231 trust: &PluginTrustConfig,
1232 disposition: InstallDisposition,
1233 expected_plugin_id: Option<&str>,
1234) -> PluginResult<InstalledPlugin> {
1235 install_server_plugin_from_source_with_event_sink_grants(
1236 installer,
1237 input,
1238 plugins_root,
1239 trust,
1240 disposition,
1241 expected_plugin_id,
1242 None,
1243 )
1244 .await
1245}
1246
1247pub async fn install_server_plugin_from_source_with_event_sink_grants(
1253 installer: &ServerPluginInstaller,
1254 input: PluginSourceInput,
1255 plugins_root: &Path,
1256 trust: &PluginTrustConfig,
1257 disposition: InstallDisposition,
1258 expected_plugin_id: Option<&str>,
1259 requested_grants: Option<&[EventSinkGrantRequest]>,
1260) -> PluginResult<InstalledPlugin> {
1261 install_server_plugin_from_source_inner(
1262 installer,
1263 input,
1264 plugins_root,
1265 trust,
1266 disposition,
1267 expected_plugin_id,
1268 requested_grants,
1269 ServerSourceFault::None,
1270 )
1271 .await
1272}
1273
1274#[derive(Debug)]
1275enum ServerSourceFault {
1276 None,
1277 #[cfg(test)]
1278 ActivationRenameFailure,
1279 #[cfg(test)]
1280 ActivationDestinationDirectory,
1281 #[cfg(test)]
1282 ReplaceLiveAfterStop,
1283 #[cfg(test)]
1284 RollbackDestinationDirectory,
1285 #[cfg(test)]
1286 FinalProvenanceCommitFailure,
1287}
1288
1289impl ServerSourceFault {
1290 fn activation_fault(&self) -> ActivationFault {
1291 match self {
1292 Self::None => ActivationFault::None,
1293 #[cfg(test)]
1294 Self::ActivationRenameFailure => ActivationFault::FailCandidateRename,
1295 #[cfg(test)]
1296 Self::ActivationDestinationDirectory => ActivationFault::CreateDestinationDirectory,
1297 #[cfg(test)]
1298 Self::ReplaceLiveAfterStop => ActivationFault::None,
1299 #[cfg(test)]
1300 Self::RollbackDestinationDirectory => ActivationFault::None,
1301 #[cfg(test)]
1302 Self::FinalProvenanceCommitFailure => ActivationFault::None,
1303 }
1304 }
1305
1306 fn after_stop(&self, _plugin_dir: &Path) -> std::io::Result<()> {
1307 match self {
1308 #[cfg(test)]
1309 Self::ReplaceLiveAfterStop => {
1310 let parent = _plugin_dir.parent().ok_or_else(|| {
1311 std::io::Error::new(
1312 std::io::ErrorKind::InvalidInput,
1313 "plugin directory has no parent",
1314 )
1315 })?;
1316 let displaced =
1317 parent.join(format!(".fault-displaced-live-{}", uuid::Uuid::new_v4()));
1318 rename_noreplace(_plugin_dir, &displaced)?;
1319 std::fs::create_dir(_plugin_dir)?;
1320 std::fs::write(_plugin_dir.join("RACE_MARKER"), b"race-owned")
1321 }
1322 _ => Ok(()),
1323 }
1324 }
1325
1326 fn injected_install_error(&self) -> Option<PluginError> {
1327 match self {
1328 #[cfg(test)]
1329 Self::RollbackDestinationDirectory => Some(PluginError::Registration(
1330 "injected install failure before rollback destination race".to_string(),
1331 )),
1332 _ => None,
1333 }
1334 }
1335
1336 fn rollback_fault(&self) -> RollbackFault {
1337 match self {
1338 #[cfg(test)]
1339 Self::RollbackDestinationDirectory => RollbackFault::ReplaceDestinationDirectory,
1340 _ => RollbackFault::None,
1341 }
1342 }
1343
1344 #[cfg(test)]
1345 fn fail_final_provenance_commit(&self) -> bool {
1346 matches!(self, Self::FinalProvenanceCommitFailure)
1347 }
1348}
1349
1350fn stopped_upgrade_failure(error: PluginError, stopped_services: &[String]) -> PluginError {
1351 if stopped_services.is_empty() {
1352 return error;
1353 }
1354 PluginError::Registration(format!(
1355 "{error}; upgrade failed after stopping service(s) [{}]; automatic restart is disabled, so they remain stopped pending manual recovery",
1356 stopped_services.join(", ")
1357 ))
1358}
1359
1360#[cfg(test)]
1361async fn install_server_plugin_from_source_with_fault(
1362 installer: &ServerPluginInstaller,
1363 input: PluginSourceInput,
1364 plugins_root: &Path,
1365 trust: &PluginTrustConfig,
1366 disposition: InstallDisposition,
1367 expected_plugin_id: Option<&str>,
1368 fault: ServerSourceFault,
1369) -> PluginResult<InstalledPlugin> {
1370 install_server_plugin_from_source_inner(
1371 installer,
1372 input,
1373 plugins_root,
1374 trust,
1375 disposition,
1376 expected_plugin_id,
1377 None,
1378 fault,
1379 )
1380 .await
1381}
1382
1383async fn install_server_plugin_from_source_inner(
1384 installer: &ServerPluginInstaller,
1385 input: PluginSourceInput,
1386 plugins_root: &Path,
1387 trust: &PluginTrustConfig,
1388 disposition: InstallDisposition,
1389 expected_plugin_id: Option<&str>,
1390 requested_grants: Option<&[EventSinkGrantRequest]>,
1391 fault: ServerSourceFault,
1392) -> PluginResult<InstalledPlugin> {
1393 let prepared = prepare_plugin_source(input, plugins_root, trust).await?;
1394 if let Some(expected_plugin_id) = expected_plugin_id {
1395 if prepared.manifest.id != expected_plugin_id {
1396 let manifest_id = prepared.manifest.id.clone();
1397 prepared.discard().await;
1398 return Err(PluginError::InvalidManifest(format!(
1399 "path id '{expected_plugin_id}' does not match the source's manifest id '{manifest_id}'"
1400 )));
1401 }
1402 }
1403
1404 let plugin_id = prepared.manifest.id.clone();
1405 let guard = installer.begin_operation().await;
1406 let previous = match installer
1407 .preflight_prepared_candidate(
1408 &prepared.manifest,
1409 &prepared.prepared_dir,
1410 disposition,
1411 &guard,
1412 )
1413 .await
1414 {
1415 Ok(previous) => previous,
1416 Err(error) => {
1417 prepared.discard().await;
1418 return Err(error);
1419 }
1420 };
1421 let event_sink_grants: EventSinkPermissionGrants = match resolve_event_sink_grants(
1422 &prepared.manifest,
1423 previous.as_ref().map(|entry| &entry.registered),
1424 requested_grants,
1425 ) {
1426 Ok(grants) => grants,
1427 Err(error) => {
1428 prepared.discard().await;
1429 return Err(error);
1430 }
1431 };
1432 if disposition == InstallDisposition::Upgrade {
1433 let Some(previous) = previous.as_ref() else {
1434 prepared.discard().await;
1435 return Err(PluginError::Registration(format!(
1436 "upgrade for '{plugin_id}' has no unique previous provenance row"
1437 )));
1438 };
1439 if previous.plugin_dir != prepared.plugin_dir {
1440 let fixed = prepared.plugin_dir.display().to_string();
1441 let recorded = previous.plugin_dir.display().to_string();
1442 prepared.discard().await;
1443 return Err(PluginError::Registration(format!(
1444 "upgrade for '{plugin_id}' requires previous provenance at fixed bundle path '{fixed}', but installed.json records '{recorded}'"
1445 )));
1446 }
1447 }
1448
1449 let expected_live = match prepared.capture_expected_live() {
1453 Ok(snapshot) => snapshot,
1454 Err(error) => {
1455 prepared.discard().await;
1456 return Err(PluginError::Registration(format!(
1457 "could not capture the live plugin bundle before service shutdown: {error}"
1458 )));
1459 }
1460 };
1461 match disposition {
1462 InstallDisposition::Upgrade if expected_live.is_none() => {
1463 prepared.discard().await;
1464 return Err(PluginError::Registration(format!(
1465 "upgrade for '{plugin_id}' requires an exact live bundle at '{}', but none existed before service shutdown",
1466 plugins_root.join(&plugin_id).display()
1467 )));
1468 }
1469 InstallDisposition::FailIfInstalled if expected_live.is_some() => {
1470 prepared.discard().await;
1471 return Err(PluginError::Registration(format!(
1472 "fresh install expected no live bundle at '{}', but an existing destination was captured; manual bundle recovery is required",
1473 plugins_root.join(&plugin_id).display()
1474 )));
1475 }
1476 _ => {}
1477 }
1478
1479 let stopped_services = if disposition == InstallDisposition::Upgrade {
1480 installer.stop_services_for_upgrade(&plugin_id).await
1481 } else {
1482 Vec::new()
1483 };
1484 if let Err(error) = fault.after_stop(&prepared.plugin_dir) {
1485 prepared.discard().await;
1486 return Err(stopped_upgrade_failure(
1487 PluginError::Registration(format!(
1488 "failed while exercising the post-stop source transaction boundary: {error}; manual bundle recovery is required"
1489 )),
1490 &stopped_services,
1491 ));
1492 }
1493 let staged = match prepared
1494 .activate_inner(expected_live, fault.activation_fault())
1495 .await
1496 {
1497 Ok(staged) => staged,
1498 Err(failure) => {
1499 let error = failure.into_plugin_error();
1500 return Err(stopped_upgrade_failure(error, &stopped_services));
1501 }
1502 };
1503
1504 let manifest = staged.manifest.clone();
1505 let plugin_dir = staged.plugin_dir.clone();
1506 let source = staged.source.clone();
1507 let install_result = match fault.injected_install_error() {
1508 Some(error) => Err(error),
1509 None => {
1510 #[cfg(test)]
1511 {
1512 if fault.fail_final_provenance_commit() {
1513 installer
1514 .install_with_operation_failing_final_commit(
1515 &manifest,
1516 &plugin_dir,
1517 source,
1518 disposition,
1519 chrono::Utc::now(),
1520 Some(&event_sink_grants),
1521 &guard,
1522 )
1523 .await
1524 } else {
1525 installer
1526 .install_with_operation_and_event_sink_grants(
1527 &manifest,
1528 &plugin_dir,
1529 source,
1530 disposition,
1531 chrono::Utc::now(),
1532 &event_sink_grants,
1533 &guard,
1534 )
1535 .await
1536 }
1537 }
1538 #[cfg(not(test))]
1539 {
1540 installer
1541 .install_with_operation_and_event_sink_grants(
1542 &manifest,
1543 &plugin_dir,
1544 source,
1545 disposition,
1546 chrono::Utc::now(),
1547 &event_sink_grants,
1548 &guard,
1549 )
1550 .await
1551 }
1552 }
1553 };
1554 match install_result {
1555 Ok(entry) => {
1556 staged.commit().await;
1557 Ok(entry)
1558 }
1559 Err(error) => {
1560 let recovery = staged.rollback_inner(fault.rollback_fault()).await;
1561 Err(stopped_upgrade_failure(
1562 recovery.wrap_error(error),
1563 &stopped_services,
1564 ))
1565 }
1566 }
1567}
1568
1569#[cfg(test)]
1573async fn install_plugin_from_source(
1574 installer: &dyn PluginInstaller,
1575 input: PluginSourceInput,
1576 plugins_root: &Path,
1577 trust: &PluginTrustConfig,
1578 disposition: InstallDisposition,
1579) -> PluginResult<InstalledPlugin> {
1580 let staged = stage_plugin_source(input, plugins_root, trust).await?;
1581 let manifest = staged.manifest.clone();
1582 let plugin_dir = staged.plugin_dir.clone();
1583 let source = staged.source.clone();
1584
1585 match installer
1586 .install(
1587 &manifest,
1588 &plugin_dir,
1589 source,
1590 disposition,
1591 chrono::Utc::now(),
1592 )
1593 .await
1594 {
1595 Ok(entry) => {
1596 staged.commit().await;
1597 Ok(entry)
1598 }
1599 Err(error) => {
1600 let recovery = staged.rollback().await;
1601 Err(recovery.wrap_error(error))
1602 }
1603 }
1604}
1605
1606async fn stage_into(
1607 input: &PluginSourceInput,
1608 staging_dir: &Path,
1609 trust: &PluginTrustConfig,
1610 max_decompressed_bytes: u64,
1611) -> PluginResult<(PluginManifest, PluginSource)> {
1612 match input {
1613 PluginSourceInput::LocalDir(path) => {
1614 copy_dir_recursive(path, staging_dir).await?;
1615 let manifest = read_and_parse_manifest(staging_dir).await?;
1616 Ok((manifest, PluginSource::LocalDir { path: path.clone() }))
1617 }
1618 PluginSourceInput::LocalArchive(path) => {
1619 let bytes = tokio::fs::read(path).await?;
1620 let kind = detect_archive_kind(&path.to_string_lossy()).ok_or_else(|| {
1621 PluginError::InvalidManifest(format!(
1622 "unsupported archive extension for '{}': expected .zip/.tar.gz/.tgz",
1623 path.display()
1624 ))
1625 })?;
1626 extract_archive(
1627 bytes,
1628 kind,
1629 staging_dir.to_path_buf(),
1630 max_decompressed_bytes,
1631 )
1632 .await?;
1633 flatten_if_single_subdir(staging_dir).await?;
1634 let manifest = read_and_parse_manifest(staging_dir).await?;
1635 Ok((manifest, PluginSource::LocalArchive { path: path.clone() }))
1636 }
1637 PluginSourceInput::Url {
1638 url,
1639 sha256,
1640 allow_unverified,
1641 allow_untrusted_host,
1642 allow_unsigned,
1643 insecure,
1644 } => {
1645 let flags = UrlTrustFlags {
1646 sha256: sha256.as_deref(),
1647 allow_unverified: *allow_unverified,
1648 allow_untrusted_host: *allow_untrusted_host,
1649 allow_unsigned: *allow_unsigned,
1650 insecure: *insecure,
1651 };
1652 let fetched =
1653 fetch_manifest_bundle(url, flags, trust, staging_dir, max_decompressed_bytes)
1654 .await?;
1655
1656 if !fetched.manifest.provides.services.is_empty() && fetched.signed_by.is_none() {
1672 return Err(PluginError::UnsignedOrUntrustedSignature(format!(
1673 "refusing to install plugin '{}' from '{url}': it declares `provides.services` \
1674 (long-running service plugins are the highest-trust artifact kind) but its \
1675 bundle is unsigned or its signature does not verify against a trusted key — \
1676 `--allow-unsigned`/`--insecure` and `plugin_trust.enforcement: off` are NOT \
1677 honoured for a services-declaring manifest; publish a signature from a \
1678 trusted key instead",
1679 fetched.manifest.id
1680 )));
1681 }
1682
1683 fetch_and_place_artifact(&fetched.manifest, staging_dir, max_decompressed_bytes)
1690 .await?;
1691 Ok((
1692 fetched.manifest,
1693 PluginSource::Url {
1694 url: url.clone(),
1695 sha256: fetched.verified_sha256,
1696 allow_unverified: *allow_unverified,
1697 allow_untrusted_host: *allow_untrusted_host,
1698 allow_unsigned: *allow_unsigned,
1699 signed_by: fetched.signed_by,
1700 insecure: fetched.insecure_aggregate,
1708 },
1709 ))
1710 }
1711 }
1712}
1713
1714struct UrlTrustFlags<'a> {
1725 sha256: Option<&'a str>,
1726 allow_unverified: bool,
1727 allow_untrusted_host: bool,
1728 allow_unsigned: bool,
1729 insecure: bool,
1736}
1737
1738struct FetchedBundle {
1740 manifest: PluginManifest,
1741 verified_sha256: Option<String>,
1744 signed_by: Option<String>,
1748 insecure_aggregate: bool,
1754}
1755
1756async fn fetch_manifest_bundle(
1804 url: &str,
1805 flags: UrlTrustFlags<'_>,
1806 trust: &PluginTrustConfig,
1807 staging_dir: &Path,
1808 max_decompressed_bytes: u64,
1809) -> PluginResult<FetchedBundle> {
1810 let UrlTrustFlags {
1811 sha256,
1812 allow_unverified,
1813 allow_untrusted_host,
1814 allow_unsigned,
1815 insecure,
1816 } = flags;
1817
1818 let insecure_aggregate = insecure || trust.enforcement_is_off();
1825 if insecure_aggregate {
1826 tracing::warn!(
1827 %url,
1828 "installing plugin from '{url}' with ALL trust checks disabled (insecure) — host \
1829 allowlist, signature and checksum-required-by-default are all skipped for this \
1830 install (a supplied --sha256, if any, is still verified)"
1831 );
1832 }
1833 let allow_untrusted_host = allow_untrusted_host || insecure_aggregate;
1834 let allow_unsigned = allow_unsigned || insecure_aggregate;
1835 let allow_unverified = allow_unverified || insecure_aggregate;
1836
1837 if !trust.is_host_trusted(url) {
1839 if !allow_untrusted_host {
1840 return Err(PluginError::UntrustedHost(format!(
1841 "refusing to install plugin bundle from '{url}': its host is not in the \
1842 `plugin_trust.trusted_hosts` allowlist (config.json) — add a matching \
1843 host+path prefix there, or explicitly accept the risk (CLI: \
1844 `--allow-untrusted-host`; HTTP: `\"allow_untrusted_host\": true`)"
1845 )));
1846 }
1847 tracing::warn!(
1848 %url,
1849 "installing plugin bundle from a host outside `plugin_trust.trusted_hosts` \
1850 (allow_untrusted_host opt-out)"
1851 );
1852 }
1853
1854 let bytes_will_be_authenticated = !allow_unsigned || sha256.is_some();
1868 let client = if bytes_will_be_authenticated {
1869 http_client_following_redirects()
1870 } else {
1871 http_client_no_redirects()
1872 };
1873
1874 let bytes = download_bytes(client, url, MAX_DOWNLOAD_BYTES).await?;
1875
1876 let signed_by = fetch_and_verify_signature(client, url, &bytes, &trust.trusted_keys).await;
1879 if signed_by.is_none() {
1880 if !allow_unsigned {
1881 return Err(PluginError::UnsignedOrUntrustedSignature(format!(
1882 "refusing to install plugin bundle from '{url}': it is unsigned, or its \
1883 '{url}.sig' does not verify against any key in `plugin_trust.trusted_keys` \
1884 (config.json) — publish a signature from a trusted key, or explicitly accept \
1885 the risk (CLI: `--allow-unsigned`; HTTP: `\"allow_unsigned\": true`)"
1886 )));
1887 }
1888 tracing::warn!(
1889 %url,
1890 "installing an unsigned (or untrusted-signature) plugin bundle (allow_unsigned opt-out)"
1891 );
1892 }
1893
1894 if sha256.is_none() && !allow_unverified && signed_by.is_none() {
1897 return Err(PluginError::ChecksumRequired(format!(
1898 "refusing to install plugin bundle from '{url}' without a checksum — pass the \
1899 bundle's sha256 (from the release page / a trusted source) to verify it before \
1900 install (CLI: `--sha256 <hex>`; HTTP: `\"sha256\": \"<hex>\"` on the url source), \
1901 or explicitly accept the risk of an unverified download (CLI: \
1902 `--allow-unverified`; HTTP: `\"allow_unverified\": true`)"
1903 )));
1904 }
1905
1906 let verified_sha256 = match sha256 {
1907 Some(expected) => {
1908 let actual = sha256_hex(&bytes);
1909 if !actual.eq_ignore_ascii_case(expected) {
1910 return Err(PluginError::BundleVerificationFailed(format!(
1911 "sha256 mismatch for plugin bundle '{url}': expected {expected}, downloaded \
1912 bytes hash to {actual} — refusing to unpack (the bundle may be tampered, \
1913 corrupted, or the wrong sha256 was supplied)"
1914 )));
1915 }
1916 Some(actual)
1917 }
1918 None => {
1919 if signed_by.is_none() {
1920 tracing::warn!(
1921 %url,
1922 "installing plugin bundle from a URL with no checksum verification \
1923 (allow_unverified opt-out) — the download is trusted on HTTPS alone"
1924 );
1925 }
1926 None
1927 }
1928 };
1929
1930 let manifest = if let Some(kind) = detect_archive_kind(url) {
1931 extract_archive(
1932 bytes,
1933 kind,
1934 staging_dir.to_path_buf(),
1935 max_decompressed_bytes,
1936 )
1937 .await?;
1938 flatten_if_single_subdir(staging_dir).await?;
1939 read_and_parse_manifest(staging_dir).await?
1940 } else {
1941 let raw = String::from_utf8(bytes).map_err(|_| {
1942 PluginError::InvalidManifest(format!("manifest at '{url}' is not valid UTF-8"))
1943 })?;
1944 tokio::fs::create_dir_all(staging_dir).await?;
1945 tokio::fs::write(staging_dir.join("plugin.json"), &raw).await?;
1946 PluginManifest::parse_str(&raw)?
1947 };
1948
1949 Ok(FetchedBundle {
1950 manifest,
1951 verified_sha256,
1952 signed_by,
1953 insecure_aggregate,
1954 })
1955}
1956
1957async fn fetch_and_verify_signature(
1967 client: &reqwest::Client,
1968 url: &str,
1969 bundle_bytes: &[u8],
1970 trusted_keys: &[bamboo_config::TrustedKey],
1971) -> Option<String> {
1972 let sig_url = format!("{url}.sig");
1979 let sig_bytes = download_bytes(client, &sig_url, MAX_SIGNATURE_DOWNLOAD_BYTES)
1980 .await
1981 .ok()?;
1982 let sig_text = String::from_utf8(sig_bytes).ok()?;
1983 let sig_raw = hex::decode(sig_text.trim()).ok()?;
1984 let sig_array: [u8; 64] = sig_raw.try_into().ok()?;
1985 let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
1986
1987 for key in trusted_keys {
1988 if !key.algorithm.eq_ignore_ascii_case("ed25519") {
1989 continue;
1990 }
1991 let Ok(pub_raw) = hex::decode(&key.public_key) else {
1992 continue;
1993 };
1994 let Ok(pub_array) = <[u8; 32]>::try_from(pub_raw.as_slice()) else {
1995 continue;
1996 };
1997 let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pub_array) else {
1998 continue;
1999 };
2000 if verifying_key.verify(bundle_bytes, &signature).is_ok() {
2001 return Some(key.label.clone());
2002 }
2003 }
2004 None
2005}
2006
2007async fn fetch_and_place_artifact(
2023 manifest: &PluginManifest,
2024 staging_dir: &Path,
2025 max_decompressed_bytes: u64,
2026) -> PluginResult<()> {
2027 let Some(platform) = Platform::current() else {
2028 return Ok(());
2029 };
2030 let Some(artifact) = manifest.artifacts.get(platform.as_str()) else {
2031 return Ok(());
2032 };
2033
2034 let bytes = download_bytes(
2039 http_client_following_redirects(),
2040 &artifact.url,
2041 MAX_DOWNLOAD_BYTES,
2042 )
2043 .await?;
2044 let actual_sha256 = sha256_hex(&bytes);
2045 if !actual_sha256.eq_ignore_ascii_case(&artifact.sha256) {
2046 return Err(PluginError::ArtifactVerificationFailed(format!(
2047 "sha256 mismatch for '{}': manifest declares {}, downloaded bytes hash to {}",
2048 artifact.url, artifact.sha256, actual_sha256
2049 )));
2050 }
2051
2052 let kind = detect_archive_kind(&artifact.url).ok_or_else(|| {
2053 PluginError::InvalidManifest(format!(
2054 "artifact url '{}' is not a .zip/.tar.gz/.tgz",
2055 artifact.url
2056 ))
2057 })?;
2058
2059 let scratch_dir = staging_dir.join(format!(".artifact-scratch-{}", platform.as_str()));
2060 extract_archive(bytes, kind, scratch_dir.clone(), max_decompressed_bytes).await?;
2061
2062 let expected_name = if matches!(platform, Platform::Windows) {
2063 format!("{}.exe", manifest.id)
2064 } else {
2065 manifest.id.clone()
2066 };
2067 let source_bin = scratch_dir.join(&expected_name);
2068 if !tokio::fs::try_exists(&source_bin).await.unwrap_or(false) {
2069 return Err(PluginError::InvalidManifest(format!(
2070 "artifact archive for platform '{}' does not contain the expected root executable '{}'",
2071 platform.as_str(),
2072 expected_name
2073 )));
2074 }
2075
2076 let dest_dir = staging_dir.join("bin").join(platform.as_str());
2077 tokio::fs::create_dir_all(&dest_dir).await?;
2078 let dest_bin = dest_dir.join(&expected_name);
2079 move_file(&source_bin, &dest_bin).await?;
2080
2081 #[cfg(unix)]
2082 {
2083 use std::os::unix::fs::PermissionsExt;
2084 let mut perms = tokio::fs::metadata(&dest_bin).await?.permissions();
2085 perms.set_mode(0o755);
2086 tokio::fs::set_permissions(&dest_bin, perms).await?;
2087 }
2088
2089 tokio::fs::remove_dir(&scratch_dir).await.map_err(|error| {
2090 PluginError::InvalidManifest(format!(
2091 "artifact archive for platform '{}' must contain only the expected root executable '{}': {error}",
2092 platform.as_str(),
2093 expected_name
2094 ))
2095 })?;
2096 Ok(())
2097}
2098
2099async fn move_file(source: &Path, dest: &Path) -> PluginResult<()> {
2101 if tokio::fs::rename(source, dest).await.is_ok() {
2102 return Ok(());
2103 }
2104 let data = tokio::fs::read(source).await?;
2105 tokio::fs::write(dest, data).await?;
2106 tokio::fs::remove_file(source).await?;
2107 Ok(())
2108}
2109
2110fn http_client_following_redirects() -> &'static reqwest::Client {
2125 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
2126 CLIENT.get_or_init(|| {
2127 reqwest::Client::builder()
2128 .redirect(reqwest::redirect::Policy::limited(10))
2129 .build()
2130 .expect("a reqwest client with only a redirect policy set always builds")
2131 })
2132}
2133
2134fn http_client_no_redirects() -> &'static reqwest::Client {
2143 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
2144 CLIENT.get_or_init(|| {
2145 reqwest::Client::builder()
2146 .redirect(reqwest::redirect::Policy::none())
2147 .build()
2148 .expect("a reqwest client with only a redirect policy set always builds")
2149 })
2150}
2151
2152const MAX_DOWNLOAD_BYTES: u64 = 256 * 1024 * 1024;
2157
2158const MAX_SIGNATURE_DOWNLOAD_BYTES: u64 = 4 * 1024;
2165
2166const MAX_DECOMPRESSED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
2180
2181async fn download_bytes(
2193 client: &reqwest::Client,
2194 url: &str,
2195 max_bytes: u64,
2196) -> PluginResult<Vec<u8>> {
2197 use futures::StreamExt;
2198
2199 let response =
2200 client.get(url).send().await.map_err(|error| {
2201 PluginError::Registration(format!("failed to fetch '{url}': {error}"))
2202 })?;
2203
2204 if response.status().is_redirection() {
2205 let status = response.status();
2206 let location = response
2211 .headers()
2212 .get(reqwest::header::LOCATION)
2213 .and_then(|value| value.to_str().ok())
2214 .map(str::to_string);
2215 let target = location.as_deref().unwrap_or("(unspecified)");
2216 return Err(PluginError::RedirectRefused(format!(
2217 "refused to follow an HTTP redirect ({status}) from '{url}' to '{target}': for an \
2218 unverified install (no signature, no checksum) the approved host must serve the \
2219 bytes directly, so redirects are not followed — install from the canonical/final \
2220 URL, or provide a signature / `--sha256` (which authenticates the bytes regardless \
2221 of which host serves them), or add the redirect target's host to \
2222 `plugin_trust.trusted_hosts`"
2223 )));
2224 }
2225
2226 let response = response.error_for_status().map_err(|error| {
2227 PluginError::Registration(format!("'{url}' returned an error status: {error}"))
2228 })?;
2229
2230 if let Some(len) = response.content_length() {
2233 if len > max_bytes {
2234 return Err(PluginError::Registration(format!(
2235 "'{url}' advertises a {len}-byte body, over the {max_bytes}-byte download cap; \
2236 refusing"
2237 )));
2238 }
2239 }
2240
2241 let mut stream = response.bytes_stream();
2244 let mut buffer: Vec<u8> = Vec::new();
2245 while let Some(chunk) = stream.next().await {
2246 let chunk = chunk.map_err(|error| {
2247 PluginError::Registration(format!("failed to read response body of '{url}': {error}"))
2248 })?;
2249 if buffer.len() as u64 + chunk.len() as u64 > max_bytes {
2250 return Err(PluginError::Registration(format!(
2251 "'{url}' streamed more than the {max_bytes}-byte download cap; aborting"
2252 )));
2253 }
2254 buffer.extend_from_slice(&chunk);
2255 }
2256 Ok(buffer)
2257}
2258
2259fn sha256_hex(bytes: &[u8]) -> String {
2260 use sha2::{Digest, Sha256};
2261 let mut hasher = Sha256::new();
2262 hasher.update(bytes);
2263 hex::encode(hasher.finalize())
2264}
2265
2266#[derive(Debug, Clone, Copy)]
2271enum ArchiveKind {
2272 Zip,
2273 TarGz,
2274}
2275
2276fn detect_archive_kind(name_or_url: &str) -> Option<ArchiveKind> {
2277 let lower = name_or_url.to_ascii_lowercase();
2278 let lower = lower.split(['?', '#']).next().unwrap_or(&lower).to_string();
2281 if lower.ends_with(".zip") {
2282 Some(ArchiveKind::Zip)
2283 } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
2284 Some(ArchiveKind::TarGz)
2285 } else {
2286 None
2287 }
2288}
2289
2290async fn extract_archive(
2297 bytes: Vec<u8>,
2298 kind: ArchiveKind,
2299 dest_dir: PathBuf,
2300 max_decompressed_bytes: u64,
2301) -> PluginResult<()> {
2302 tokio::fs::create_dir_all(&dest_dir).await?;
2303 tokio::task::spawn_blocking(move || match kind {
2304 ArchiveKind::Zip => extract_zip_sync(&bytes, &dest_dir, max_decompressed_bytes),
2305 ArchiveKind::TarGz => extract_targz_sync(&bytes, &dest_dir, max_decompressed_bytes),
2306 })
2307 .await
2308 .map_err(|error| {
2309 PluginError::Registration(format!("archive extraction task panicked: {error}"))
2310 })?
2311}
2312
2313fn copy_capped(
2324 reader: &mut impl std::io::Read,
2325 writer: &mut impl std::io::Write,
2326 running_total: &mut u64,
2327 max_decompressed_bytes: u64,
2328) -> PluginResult<()> {
2329 let mut buffer = [0u8; 64 * 1024];
2330 loop {
2331 let bytes_read = reader.read(&mut buffer)?;
2332 if bytes_read == 0 {
2333 return Ok(());
2334 }
2335 *running_total += bytes_read as u64;
2336 if *running_total > max_decompressed_bytes {
2337 return Err(PluginError::InvalidManifest(format!(
2338 "archive expands to more than the {max_decompressed_bytes}-byte decompressed \
2339 size cap ({running_total} bytes and counting); refusing to unpack (possible \
2340 decompression bomb)"
2341 )));
2342 }
2343 writer.write_all(&buffer[..bytes_read])?;
2344 }
2345}
2346
2347fn extract_zip_sync(
2348 bytes: &[u8],
2349 dest_dir: &Path,
2350 max_decompressed_bytes: u64,
2351) -> PluginResult<()> {
2352 use std::io::Cursor;
2353
2354 let cursor = Cursor::new(bytes);
2355 let mut archive = zip::ZipArchive::new(cursor)
2356 .map_err(|error| PluginError::InvalidManifest(format!("invalid zip archive: {error}")))?;
2357
2358 let mut total_decompressed_bytes: u64 = 0;
2359
2360 for index in 0..archive.len() {
2361 let mut file = archive.by_index(index).map_err(|error| {
2362 PluginError::InvalidManifest(format!("invalid zip entry at index {index}: {error}"))
2363 })?;
2364 let Some(relative_path) = file.enclosed_name() else {
2368 return Err(PluginError::InvalidManifest(format!(
2369 "zip entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
2370 file.name()
2371 )));
2372 };
2373 let out_path = dest_dir.join(&relative_path);
2374 if file.is_dir() {
2375 std::fs::create_dir_all(&out_path)?;
2376 continue;
2377 }
2378 if let Some(parent) = out_path.parent() {
2379 std::fs::create_dir_all(parent)?;
2380 }
2381 let mut out_file = std::fs::File::create(&out_path)?;
2382 if let Err(error) = copy_capped(
2383 &mut file,
2384 &mut out_file,
2385 &mut total_decompressed_bytes,
2386 max_decompressed_bytes,
2387 ) {
2388 drop(out_file);
2389 let _ = std::fs::remove_file(&out_path);
2395 return Err(error);
2396 }
2397 drop(out_file);
2398
2399 #[cfg(unix)]
2400 {
2401 use std::os::unix::fs::PermissionsExt;
2402 if let Some(mode) = file.unix_mode() {
2403 std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
2404 }
2405 }
2406 }
2407 Ok(())
2408}
2409
2410fn extract_targz_sync(
2411 bytes: &[u8],
2412 dest_dir: &Path,
2413 max_decompressed_bytes: u64,
2414) -> PluginResult<()> {
2415 use flate2::read::GzDecoder;
2416 use std::path::Component;
2417 use tar::{Archive, EntryType};
2418
2419 let decoder = GzDecoder::new(bytes);
2420 let mut archive = Archive::new(decoder);
2421 let mut total_decompressed_bytes: u64 = 0;
2422 for entry_result in archive.entries()? {
2423 let mut entry = entry_result?;
2424
2425 let entry_type = entry.header().entry_type();
2441 if matches!(entry_type, EntryType::Symlink | EntryType::Link) {
2442 let link_target = entry
2443 .link_name()
2444 .ok()
2445 .flatten()
2446 .map(|path| path.display().to_string())
2447 .unwrap_or_default();
2448 return Err(PluginError::InvalidManifest(format!(
2449 "tar entry '{}' is a {} (target '{link_target}') — plugin bundles must not ship \
2450 links; refusing to unpack",
2451 entry
2452 .path()
2453 .map(|p| p.display().to_string())
2454 .unwrap_or_default(),
2455 if entry_type == EntryType::Symlink {
2456 "symlink"
2457 } else {
2458 "hardlink"
2459 },
2460 )));
2461 }
2462
2463 let relative_path = entry.path()?.into_owned();
2464 let is_unsafe = relative_path.components().any(|component| {
2465 matches!(
2466 component,
2467 Component::ParentDir | Component::RootDir | Component::Prefix(_)
2468 )
2469 });
2470 if is_unsafe {
2471 return Err(PluginError::InvalidManifest(format!(
2472 "tar entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
2473 relative_path.display()
2474 )));
2475 }
2476 let out_path = dest_dir.join(&relative_path);
2477
2478 if entry_type.is_dir() {
2484 std::fs::create_dir_all(&out_path)?;
2485 continue;
2486 }
2487
2488 if let Some(parent) = out_path.parent() {
2489 std::fs::create_dir_all(parent)?;
2490 }
2491 let mut out_file = std::fs::File::create(&out_path)?;
2492 if let Err(error) = copy_capped(
2493 &mut entry,
2494 &mut out_file,
2495 &mut total_decompressed_bytes,
2496 max_decompressed_bytes,
2497 ) {
2498 drop(out_file);
2499 let _ = std::fs::remove_file(&out_path);
2504 return Err(error);
2505 }
2506 drop(out_file);
2507
2508 #[cfg(unix)]
2511 {
2512 use std::os::unix::fs::PermissionsExt;
2513 if let Ok(mode) = entry.header().mode() {
2514 std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
2515 }
2516 }
2517 }
2518 Ok(())
2519}
2520
2521async fn read_and_parse_manifest(dir: &Path) -> PluginResult<PluginManifest> {
2526 let manifest_path = dir.join("plugin.json");
2527 let raw = tokio::fs::read_to_string(&manifest_path)
2528 .await
2529 .map_err(|_| {
2530 PluginError::InvalidManifest(format!(
2531 "no plugin.json found at '{}'",
2532 manifest_path.display()
2533 ))
2534 })?;
2535 PluginManifest::parse_str(&raw)
2536}
2537
2538async fn flatten_if_single_subdir(dir: &Path) -> PluginResult<()> {
2546 if tokio::fs::try_exists(dir.join("plugin.json"))
2547 .await
2548 .unwrap_or(false)
2549 {
2550 return Ok(());
2551 }
2552
2553 let mut entries = tokio::fs::read_dir(dir).await?;
2554 let mut only_entry: Option<PathBuf> = None;
2555 let mut count = 0usize;
2556 while let Some(entry) = entries.next_entry().await? {
2557 count += 1;
2558 if count > 1 {
2559 return Ok(());
2560 }
2561 only_entry = Some(entry.path());
2562 }
2563 let Some(candidate) = only_entry else {
2564 return Ok(());
2565 };
2566 if !tokio::fs::symlink_metadata(&candidate).await?.is_dir() {
2574 return Ok(());
2575 }
2576
2577 let mut children = tokio::fs::read_dir(&candidate).await?;
2580 while let Some(child) = children.next_entry().await? {
2581 let dest = dir.join(child.file_name());
2582 tokio::fs::rename(child.path(), dest).await?;
2583 }
2584 tokio::fs::remove_dir(&candidate).await?;
2585 Ok(())
2586}
2587
2588fn copy_dir_recursive<'a>(
2590 source: &'a Path,
2591 dest: &'a Path,
2592) -> std::pin::Pin<Box<dyn std::future::Future<Output = PluginResult<()>> + Send + 'a>> {
2593 Box::pin(async move {
2594 tokio::fs::create_dir_all(dest).await?;
2595 let mut entries = tokio::fs::read_dir(source).await?;
2596 while let Some(entry) = entries.next_entry().await? {
2597 let file_type = entry.file_type().await?;
2598 let dest_path = dest.join(entry.file_name());
2599 if file_type.is_dir() {
2600 copy_dir_recursive(&entry.path(), &dest_path).await?;
2601 } else if file_type.is_file() {
2602 tokio::fs::copy(entry.path(), &dest_path).await?;
2603 }
2604 }
2608 Ok(())
2609 })
2610}
2611
2612#[cfg(test)]
2613mod tests;