From c040f9c9c34982182ca93d9c585ed45418a3f3af Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Thu, 17 Sep 2026 21:07:13 +0800 Subject: [PATCH] fix(plugins): allow reinstalling rolled-back marketplace versions --- .../plugins/PluginContributionsPanel.vue | 7 + apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../src/lib/plugins/pluginMarketplace.spec.ts | 10 + crates/dbx-core/src/plugins/installer.rs | 285 +++++++++++++++++- 11 files changed, 295 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue index cf1afd29e8..0e6074e380 100644 --- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue +++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue @@ -205,6 +205,10 @@ async function installMarketplaceListing(listing: MarketplacePluginListing) { selectPlugin(result.plugin.manifest.id); } catch (cause) { toast(cause instanceof Error ? cause.message : String(cause), 8000); + // A failed install can still have mutated the store (a partially replaced version directory, for + // instance), so re-read the installed list instead of leaving the card on state it may no longer + // describe. + installedPlugins.value = await api.listPlugins().catch(() => installedPlugins.value); } finally { marketplaceInstallingKey.value = ""; } @@ -870,6 +874,9 @@ onBeforeUnmount(() => {
{{ t("pluginPlatform.unsupportedTarget", { target: listing.target }) }} + + {{ t("pluginPlatform.installedVersionUpdatable", { installed: listing.installed.manifest.version, latest: listing.plugin.latestVersion }) }} {{ t("pluginPlatform.installedVersion", { version: listing.installed.manifest.version }) }} {{ listing.plugin.license || t("pluginPlatform.licenseUnknown") }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 56f138533e..f11444d20b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -184,6 +184,7 @@ export default { permissionsCount: "{count} permissions", unsupportedTarget: "Not available for {target}", installedVersion: "Installed v{version}", + installedVersionUpdatable: "Installed v{installed} · Update to v{latest}", licenseUnknown: "License not specified", sourceRepository: "Source repository", pluginHomepage: "Homepage", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 40b12a045f..cd5bb6091d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -187,6 +187,7 @@ export default withEnglishFallback({ permissionsCount: "{count} permisos", unsupportedTarget: "No disponible para {target}", installedVersion: "Instalado v{version}", + installedVersionUpdatable: "Instalado v{installed} · Actualizar a v{latest}", licenseUnknown: "Licencia no especificada", sourceRepository: "Repositorio de código fuente", pluginHomepage: "Página principal", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 5bb9e5c4f0..9e4397c5b5 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -186,6 +186,7 @@ export default withEnglishFallback({ permissionsCount: "{count} autorizzazioni", unsupportedTarget: "Non disponibile per {target}", installedVersion: "Installato v{version}", + installedVersionUpdatable: "Installato v{installed} · Aggiorna a v{latest}", licenseUnknown: "Licenza non specificata", sourceRepository: "Repository del codice sorgente", pluginHomepage: "Sito web", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 05e7bf871e..88c87d5115 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -187,6 +187,7 @@ export default withEnglishFallback({ permissionsCount: "権限 {count} 件", unsupportedTarget: "{target} では利用できません", installedVersion: "インストール済み v{version}", + installedVersionUpdatable: "インストール済み v{installed} · v{latest} に更新可能", licenseUnknown: "ライセンス未指定", sourceRepository: "ソースリポジトリ", pluginHomepage: "ホームページ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1265b5f369..c9569e2e6b 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -186,6 +186,7 @@ export default withEnglishFallback({ permissionsCount: "권한 {count}개", unsupportedTarget: "{target}에서는 사용할 수 없음", installedVersion: "설치된 버전 v{version}", + installedVersionUpdatable: "설치된 버전 v{installed} · v{latest}(으)로 업데이트 가능", licenseUnknown: "라이선스 미지정", sourceRepository: "소스 저장소", pluginHomepage: "홈페이지", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f49483f0be..9dc2b322b9 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -187,6 +187,7 @@ export default withEnglishFallback({ permissionsCount: "{count} permissões", unsupportedTarget: "Indisponível para {target}", installedVersion: "Instalado v{version}", + installedVersionUpdatable: "Instalado v{installed} · Atualizar para v{latest}", licenseUnknown: "Licença não informada", sourceRepository: "Repositório do código-fonte", pluginHomepage: "Página inicial", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 5f57670945..f87fcaf7d9 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -109,6 +109,7 @@ export default withEnglishFallback({ permissionsCount: "{count} 项权限", unsupportedTarget: "暂不支持 {target}", installedVersion: "已安装 v{version}", + installedVersionUpdatable: "已安装 v{installed} · 可更新至 v{latest}", licenseUnknown: "未声明许可证", sourceRepository: "源码仓库", pluginHomepage: "主页", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9b5b6fbb93..25f1c771ce 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -187,6 +187,7 @@ export default withEnglishFallback({ permissionsCount: "{count} 項權限", unsupportedTarget: "暫不支援 {target}", installedVersion: "已安裝 v{version}", + installedVersionUpdatable: "已安裝 v{installed} · 可更新至 v{latest}", licenseUnknown: "未指定授權條款", sourceRepository: "原始碼倉庫", pluginHomepage: "主頁", diff --git a/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts b/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts index 4bab5f9a19..cee1a7adb7 100644 --- a/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts @@ -65,6 +65,16 @@ describe("plugin marketplace listings", () => { expect(buildMarketplacePluginListings([unsupported], [], "en")[0].status).toBe("unsupported"); }); + it("keeps a newer installed version installed instead of offering a downgrade", () => { + const listings = buildMarketplacePluginListings([result], [installed("1.2.0")], "en"); + + // The catalog lags behind the installed build: the card must stay on the installed state. + expect(listings[0]).toMatchObject({ status: "installed", installed: { manifest: { version: "1.2.0" } } }); + // Only "update" swaps the card's bottom-left line to the two-version text, so this state renders the + // plain installed line for 1.2.0 rather than the catalog's older 1.1.0. + expect(listings[0].plugin.latestVersion).toBe("1.1.0"); + }); + it("uses a universal artifact when the current target has no exact artifact", () => { const universal = structuredClone(result); universal.target = "linux-x64"; diff --git a/crates/dbx-core/src/plugins/installer.rs b/crates/dbx-core/src/plugins/installer.rs index bbd461e525..c73e286801 100644 --- a/crates/dbx-core/src/plugins/installer.rs +++ b/crates/dbx-core/src/plugins/installer.rs @@ -380,19 +380,36 @@ impl PluginPackageInstaller { let activations_dir = container_dir.join(ACTIVATIONS_DIR); std::fs::create_dir_all(&versions_dir).map_err(|error| error.to_string())?; std::fs::create_dir_all(&activations_dir).map_err(|error| error.to_string())?; - let version_dir = versions_dir.join(version.to_string()); - if version_dir.exists() && !matches!(policy, PluginInstallPolicy::LocalDevelopment) { + let version_string = version.to_string(); + let version_dir = versions_dir.join(&version_string); + let current = read_latest_activation(&container_dir)?; + // Only the version the newest activation record resolves to *and* that is still a usable + // install counts as "already installed". Directory existence alone is not enough: rollback + // deliberately retains the version it replaces as the next rollback target, so an + // installed-but-inactive directory has to stay replaceable, and a broken active directory + // (missing or unparsable manifest) has to stay replaceable so Update can repair it. + if !matches!(policy, PluginInstallPolicy::LocalDevelopment) + && version_dir.exists() + && current.as_ref().is_some_and(|record| record.version == version_string) + && version_dir_is_usable(&version_dir, &manifest.id, &version_string) + { return Err(format!("Plugin '{}' version {} is already installed", manifest.id, version)); } - let current = read_latest_activation(&container_dir)?; - let version_string = version.to_string(); - let previous_version = current.as_ref().and_then(|record| { - if record.version == version_string { - record.previous_version.clone() - } else { - Some(record.version.clone()) - } - }); + let previous_version = current + .as_ref() + .and_then(|record| { + if record.version == version_string { + record.previous_version.clone() + } else { + Some(record.version.clone()) + } + }) + // Never record a rollback target that cannot be rolled back to: `rollback_locked` + // requires versions//manifest.json to have the matching identity. + .filter(|previous| { + previous != &version_string + && version_dir_is_usable(&versions_dir.join(previous), &manifest.id, previous) + }); let replaced_version_dir = if version_dir.exists() { let mut backup = versions_dir.join(format!(".{version_string}.replaced")); let mut suffix = 0u32; @@ -408,10 +425,13 @@ impl PluginPackageInstaller { None }; if let Err(error) = std::fs::rename(&package_dir, &version_dir) { + let mut message = format!("Failed to store plugin '{}' version {}: {error}", manifest.id, version); if let Some(backup) = &replaced_version_dir { - let _ = std::fs::rename(backup, &version_dir); + if let Err(restore) = std::fs::rename(backup, &version_dir) { + message.push_str(&format!("; previous copy kept at {}: {restore}", backup.display())); + } } - return Err(format!("Failed to store plugin '{}' version {}: {error}", manifest.id, version)); + return Err(message); } let activation = PluginActivationRecord { @@ -423,10 +443,13 @@ impl PluginPackageInstaller { }; if let Err(error) = write_activation_record(&container_dir, &activation) { let _ = std::fs::remove_dir_all(&version_dir); + let mut message = error; if let Some(backup) = &replaced_version_dir { - let _ = std::fs::rename(backup, &version_dir); + if let Err(restore) = std::fs::rename(backup, &version_dir) { + message.push_str(&format!("; previous copy kept at {}: {restore}", backup.display())); + } } - return Err(error); + return Err(message); } if let Some(backup) = replaced_version_dir { if let Err(error) = std::fs::remove_dir_all(backup) { @@ -482,6 +505,31 @@ impl PluginPackageInstaller { } } +/// `migrate_legacy_container` stores a migrated flat container under a synthetic +/// `0.0.0-legacy.` directory name while the manifest inside keeps its original version string, so +/// for those directories the name carries no version identity to compare against. Treating them as +/// unusable would drop them from `previous_version` and let `prune_plugin_history` delete the only copy +/// of that version. The plugin id check still applies to them: only the version identity is synthetic. +const LEGACY_STORAGE_VERSION_PREFIX: &str = "0.0.0-legacy."; + +fn is_legacy_storage_version(version: &str) -> bool { + version + .strip_prefix(LEGACY_STORAGE_VERSION_PREFIX) + .is_some_and(|suffix| suffix.len() == 12 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())) +} + +/// True when `versions/` has the manifest identity that this store location advertises. +/// `rollback_locked` rejects a parsed manifest whose id or version differs, so the installer must +/// not retain it as an active install or as a rollback target either. Legacy containers are the one +/// exception: their directory name is synthetic, so only the plugin id is checked against them. +fn version_dir_is_usable(version_dir: &Path, plugin_id: &str, version: &str) -> bool { + std::fs::read(version_dir.join("manifest.json")).is_ok_and(|raw| { + serde_json::from_slice::(&raw).is_ok_and(|manifest| { + manifest.id == plugin_id && (manifest.version == version || is_legacy_storage_version(version)) + }) + }) +} + pub(super) fn resolve_active_plugin_dir(container_dir: &Path) -> Result, String> { if container_dir.join("manifest.json").is_file() { return Ok(Some(container_dir.to_path_buf())); @@ -958,6 +1006,7 @@ fn sync_directory(_path: &Path) -> Result<(), String> { mod tests { use std::collections::{BTreeMap, BTreeSet}; use std::io::{Cursor, Write}; + use std::path::Path; use base64::Engine; use ed25519_dalek::{Signer, SigningKey}; @@ -1085,6 +1134,48 @@ mod tests { assert_eq!(std::fs::read(legacy_plugin.path.join("bin/plugin")).unwrap(), b"legacy"); } + #[test] + fn retains_a_legacy_version_directory_when_the_legacy_version_is_not_semver() { + let root = tempfile::tempdir().unwrap(); + let legacy = root.path().join("sample.hello"); + std::fs::create_dir_all(legacy.join("bin")).unwrap(); + std::fs::write(legacy.join("bin/plugin"), b"legacy").unwrap(); + std::fs::write( + legacy.join("manifest.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "id": "sample.hello", + "name": "Hello legacy", + "version": "0.9", + "protocol_version": 1, + "executable": "bin/plugin", + "drivers": [] + })) + .unwrap(), + ) + .unwrap(); + + let installer = + PluginPackageInstaller::with_trust_store(root.path().to_path_buf(), "0.5.67", PluginTrustStore::default()); + let installed = + installer.install_bytes(&package("1.0.0", None, false), PluginInstallPolicy::LocalDevelopment).unwrap(); + + // A non-semver legacy version is migrated under a synthetic `0.0.0-legacy.` directory that + // the manifest inside does not repeat, so the retained copy must still be recognised as the + // rollback target instead of being pruned away as an unknown version. + assert!( + installed.previous_version.is_some(), + "the migrated legacy version must be retained as the rollback target" + ); + let container = root.path().join("sample.hello"); + let versions = sorted_version_dirs(&container); + let legacy_storage_version = versions.iter().find(|version| version.as_str() != "1.0.0").unwrap(); + assert_eq!(installed.previous_version.as_deref(), Some(legacy_storage_version.as_str())); + let mut expected = vec!["1.0.0".to_string(), legacy_storage_version.clone()]; + expected.sort(); + assert_eq!(versions, expected); + assert!(container.join(VERSIONS_DIR).join(legacy_storage_version).join("manifest.json").is_file()); + } + #[test] fn strict_policy_requires_and_verifies_trusted_signature() { let root = tempfile::tempdir().unwrap(); @@ -1285,6 +1376,170 @@ mod tests { assert!(installer.install_bytes(output.get_ref(), PluginInstallPolicy::LocalDevelopment).is_err()); } + fn signed_installer(root: &Path, seed: [u8; 32]) -> (PluginPackageInstaller, SigningKey) { + let signing_key = SigningKey::from_bytes(&seed); + let mut keys = BTreeMap::new(); + keys.insert( + "sample-key".to_string(), + base64::engine::general_purpose::STANDARD.encode(signing_key.verifying_key().as_bytes()), + ); + let installer = PluginPackageInstaller::with_trust_store( + root.to_path_buf(), + "0.5.67", + PluginTrustStore::from_base64_keys(keys).unwrap(), + ); + (installer, signing_key) + } + + fn activation_record_count(container: &Path) -> usize { + std::fs::read_dir(container.join(ACTIVATIONS_DIR)) + .unwrap() + .filter(|entry| entry.as_ref().is_ok_and(|entry| is_activation_record_file(&entry.path()))) + .count() + } + + fn sorted_version_dirs(container: &Path) -> Vec { + let mut versions = std::fs::read_dir(container.join(VERSIONS_DIR)) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + versions.sort(); + versions + } + + #[test] + fn reinstalls_retained_rollback_version_after_rollback_under_signed_policy() { + let root = tempfile::tempdir().unwrap(); + let (installer, signing_key) = signed_installer(root.path(), [13u8; 32]); + let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false); + let expectation = |version: &str| PluginPackageExpectation { + id: "sample.hello".to_string(), + version: version.to_string(), + publisher: "sample".to_string(), + permissions: BTreeSet::from(["host.events".to_string()]), + signing_key_id: "sample-key".to_string(), + }; + + installer.install_marketplace_bytes(&signed("1.0.0"), &expectation("1.0.0")).unwrap(); + installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("1.1.0")).unwrap(); + assert_eq!(installer.rollback("sample.hello").unwrap().previous_version, "1.0.0"); + + // 1.1.0 is installed but inactive after the rollback: reinstalling it has to replace the + // retained directory instead of failing with "already installed". + let reinstalled = installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("1.1.0")).unwrap(); + + assert_eq!(reinstalled.previous_version.as_deref(), Some("1.0.0")); + let container = root.path().join("sample.hello"); + assert_eq!(sorted_version_dirs(&container), ["1.0.0", "1.1.0"]); + assert_eq!(activation_record_count(&container), 2); + let registry = PluginRegistry::new_with_app_version(root.path().to_path_buf(), "0.5.67"); + assert_eq!(registry.find_plugin("sample.hello").unwrap().unwrap().manifest.version, "1.1.0"); + assert_eq!(installer.rollback("sample.hello").unwrap().previous_version, "1.0.0"); + } + + #[test] + fn rejects_reinstall_of_active_version_without_touching_the_store() { + let root = tempfile::tempdir().unwrap(); + let (installer, signing_key) = signed_installer(root.path(), [19u8; 32]); + let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false); + + installer.install_bytes(&signed("1.0.0"), PluginInstallPolicy::LocalSigned).unwrap(); + let error = installer.install_bytes(&signed("1.0.0"), PluginInstallPolicy::LocalSigned).unwrap_err(); + + assert!(error.contains("is already installed"), "{error}"); + let container = root.path().join("sample.hello"); + assert_eq!(sorted_version_dirs(&container), ["1.0.0"]); + assert_eq!(activation_record_count(&container), 1); + let registry = PluginRegistry::new_with_app_version(root.path().to_path_buf(), "0.5.67"); + assert_eq!(registry.find_plugin("sample.hello").unwrap().unwrap().manifest.version, "1.0.0"); + } + + #[test] + fn reinstalls_active_version_whose_manifest_is_missing() { + let root = tempfile::tempdir().unwrap(); + let (installer, signing_key) = signed_installer(root.path(), [23u8; 32]); + let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false); + + installer.install_bytes(&signed("1.0.0"), PluginInstallPolicy::LocalSigned).unwrap(); + let container = root.path().join("sample.hello"); + let version_dir = container.join(VERSIONS_DIR).join("1.0.0"); + std::fs::remove_file(version_dir.join("manifest.json")).unwrap(); + + let repaired = installer.install_bytes(&signed("1.0.0"), PluginInstallPolicy::LocalSigned).unwrap(); + + assert_eq!(repaired.previous_version, None); + assert!(version_dir.join("manifest.json").is_file()); + assert_eq!(sorted_version_dirs(&container), ["1.0.0"]); + let registry = PluginRegistry::new_with_app_version(root.path().to_path_buf(), "0.5.67"); + assert_eq!(registry.find_plugin("sample.hello").unwrap().unwrap().manifest.version, "1.0.0"); + } + + #[test] + fn reinstalls_active_version_whose_manifest_identity_is_wrong() { + let root = tempfile::tempdir().unwrap(); + let (installer, signing_key) = signed_installer(root.path(), [27u8; 32]); + let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false); + + installer.install_bytes(&signed("1.0.0"), PluginInstallPolicy::LocalSigned).unwrap(); + let container = root.path().join("sample.hello"); + let version_dir = container.join(VERSIONS_DIR).join("1.0.0"); + let manifest_path = version_dir.join("manifest.json"); + let mismatched_manifest = std::fs::read_to_string(&manifest_path) + .unwrap() + .replace("\"version\": \"1.0.0\"", "\"version\": \"1.0.1\""); + std::fs::write(&manifest_path, mismatched_manifest).unwrap(); + + // A parseable manifest with the wrong identity cannot be activated or rolled back to, so + // it must be repaired rather than satisfying the already-installed guard. + let repaired = installer.install_bytes(&signed("1.0.0"), PluginInstallPolicy::LocalSigned).unwrap(); + + assert_eq!(repaired.previous_version, None); + let registry = PluginRegistry::new_with_app_version(root.path().to_path_buf(), "0.5.67"); + assert_eq!(registry.find_plugin("sample.hello").unwrap().unwrap().manifest.version, "1.0.0"); + } + + #[test] + fn does_not_record_a_rollback_target_whose_directory_is_missing() { + let root = tempfile::tempdir().unwrap(); + let (installer, signing_key) = signed_installer(root.path(), [29u8; 32]); + let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false); + + installer.install_bytes(&signed("1.0.0"), PluginInstallPolicy::LocalSigned).unwrap(); + installer.install_bytes(&signed("1.1.0"), PluginInstallPolicy::LocalSigned).unwrap(); + assert_eq!(installer.rollback("sample.hello").unwrap().previous_version, "1.0.0"); + + let container = root.path().join("sample.hello"); + std::fs::remove_dir_all(container.join(VERSIONS_DIR).join("1.0.0")).unwrap(); + + let recovered = installer.install_bytes(&signed("1.1.0"), PluginInstallPolicy::LocalSigned).unwrap(); + + assert_eq!(recovered.previous_version, None); + assert_eq!(sorted_version_dirs(&container), ["1.1.0"]); + let registry = PluginRegistry::new_with_app_version(root.path().to_path_buf(), "0.5.67"); + assert_eq!(registry.find_plugin("sample.hello").unwrap().unwrap().manifest.version, "1.1.0"); + assert!(installer.rollback("sample.hello").unwrap_err().contains("does not have a rollback version")); + } + + #[test] + fn reinstalls_orphan_version_directory_without_activation_record() { + let root = tempfile::tempdir().unwrap(); + let (installer, signing_key) = signed_installer(root.path(), [31u8; 32]); + let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false); + + installer.install_bytes(&signed("1.0.0"), PluginInstallPolicy::LocalSigned).unwrap(); + let container = root.path().join("sample.hello"); + let orphan = container.join(VERSIONS_DIR).join("1.1.0"); + std::fs::create_dir_all(&orphan).unwrap(); + std::fs::write(orphan.join("stale.txt"), b"interrupted").unwrap(); + + let installed = installer.install_bytes(&signed("1.1.0"), PluginInstallPolicy::LocalSigned).unwrap(); + + assert_eq!(installed.previous_version.as_deref(), Some("1.0.0")); + assert!(!orphan.join("stale.txt").exists()); + assert_eq!(sorted_version_dirs(&container), ["1.0.0", "1.1.0"]); + assert_eq!(activation_record_count(&container), 2); + } + fn package(version: &str, signer: Option<(&SigningKey, &str)>, tamper: bool) -> Vec { let manifest = serde_json::json!({ "manifest_version": 1,