fix(plugins): check and update installed plugins with provenance guards

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-21 00:09:50 +08:00
1 parent d715b16ba5
commit cab674299a
26 files changed
+1180 -44

No files matched your search

+22 -5
View File
@@ -27,9 +27,9 @@ pub use host::{
ActivePluginSession, PluginConnectionActionResult, PluginConnectionHandle, PluginHost, PluginRuntimeProxy,
};
pub use installer::{
PluginInstallPolicy, PluginInstallResponse, PluginInstallResult, PluginPackageInstaller, PluginRollbackResponse,
PluginRollbackResult, PluginSignatureStatus, PluginTrustStore, PluginTrustedKey, DBXP_EXTENSION,
MAX_PLUGIN_PACKAGE_BYTES, PLUGIN_CHECKSUMS_FILE, PLUGIN_SIGNATURE_FILE,
PluginInstallPolicy, PluginInstallProvenance, PluginInstallResponse, PluginInstallResult, PluginInstallSource,
PluginPackageInstaller, PluginRollbackResponse, PluginRollbackResult, PluginSignatureStatus, PluginTrustStore,
PluginTrustedKey, DBXP_EXTENSION, MAX_PLUGIN_PACKAGE_BYTES, PLUGIN_CHECKSUMS_FILE, PLUGIN_SIGNATURE_FILE,
};
pub use lifecycle::PluginLifecycle;
pub use marketplace::{
@@ -62,6 +62,10 @@ pub struct InstalledPlugin {
pub manifest: PluginManifest,
pub path: PathBuf,
pub compatibility: PluginCompatibility,
/// Where the active installation came from (repository / publisher / signing key / source).
/// `None` for installs recorded before provenance existed; those stay unconstrained until the
/// next install records fresh provenance.
pub provenance: Option<PluginInstallProvenance>,
}
#[derive(Debug, Clone, Serialize)]
@@ -69,6 +73,8 @@ pub struct InstalledPlugin {
pub struct InstalledPluginInfo {
pub manifest: PluginManifest,
pub compatibility: PluginCompatibilityInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub provenance: Option<PluginInstallProvenance>,
}
#[derive(Debug, Clone, Serialize)]
@@ -86,7 +92,13 @@ pub struct PluginCompatibilityInfo {
impl InstalledPlugin {
pub fn new(manifest: PluginManifest, path: PathBuf, app_version: &str) -> Self {
let compatibility = manifest.compatibility(&path, app_version);
Self { manifest, path, compatibility }
Self { manifest, path, compatibility, provenance: None }
}
/// Attaches the recorded install provenance (see `installer::read_container_provenance`).
pub fn with_provenance(mut self, provenance: Option<PluginInstallProvenance>) -> Self {
self.provenance = provenance;
self
}
pub fn info(&self) -> InstalledPluginInfo {
@@ -98,6 +110,7 @@ impl InstalledPlugin {
warnings: self.compatibility.warnings.clone(),
target: self.compatibility.target.clone(),
},
provenance: self.provenance.clone(),
}
}
}
@@ -221,7 +234,10 @@ impl PluginRegistry {
continue;
}
};
plugins.push(InstalledPlugin::new(manifest, path, &self.app_version));
plugins.push(
InstalledPlugin::new(manifest, path, &self.app_version)
.with_provenance(PluginPackageInstaller::read_container_provenance(&container_path)?),
);
}
plugins.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
Ok(plugins)
@@ -340,6 +356,7 @@ fn unavailable_plugin(path: PathBuf, error: String) -> InstalledPlugin {
target: Some(current_plugin_target()),
..PluginCompatibility::default()
},
provenance: None,
}
}
@@ -123,6 +123,41 @@ struct PluginPackageSignature {
signature: String,
}
/// Where an installed plugin came from. Recorded with every activation so an update can detect a
/// changed repository / publisher / signing key instead of silently replacing the plugin.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PluginInstallProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_key_id: Option<String>,
#[serde(default)]
pub source: PluginInstallSource,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum PluginInstallSource {
Marketplace,
Url,
File,
#[default]
Unknown,
}
/// The active installation of one plugin id: the semver activation version plus whatever
/// provenance was recorded when it was installed. Provenance is `None` for installs made before
/// provenance existed (and for legacy migrated containers); those stay unconstrained until the
/// next install records fresh provenance.
#[derive(Debug, Clone)]
pub(crate) struct PluginInstallIdentity {
pub version: String,
pub provenance: Option<PluginInstallProvenance>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginActivationRecord {
@@ -132,6 +167,8 @@ struct PluginActivationRecord {
previous_version: Option<String>,
package_sha256: String,
activated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<PluginInstallProvenance>,
}
pub struct PluginPackageInstaller {
@@ -145,11 +182,56 @@ pub struct PluginPackageInstaller {
pub(super) struct PluginPackageExpectation {
pub id: String,
pub version: String,
pub repository_id: Option<String>,
pub publisher: String,
pub permissions: BTreeSet<String>,
pub signing_key_id: String,
}
/// Read-only identity lookup for one plugin id under the store root: the activation version plus
/// the recorded provenance. Free function so the marketplace pre-flight can call it without
/// constructing an installer (which would load the user trust store as a side effect).
pub(crate) fn read_install_identity(root_dir: &Path, plugin_id: &str) -> Result<Option<PluginInstallIdentity>, String> {
validate_plugin_id(plugin_id)?;
let Some(record) = read_latest_activation(&root_dir.join(plugin_id))? else { return Ok(None) };
Ok(Some(PluginInstallIdentity { version: record.version, provenance: record.provenance }))
}
/// Rejects an update whose repository / publisher / signing key differs from the recorded
/// provenance, or that would downgrade below the active version, unless `allow` is set (the
/// user confirmed the change in the UI). The marketplace pre-flight calls this before any
/// download; `install_bytes_locked` re-checks under the store lock.
pub(crate) fn ensure_update_continuity(
provenance: &PluginInstallProvenance,
active_version: &str,
repository_id: Option<&str>,
publisher: &str,
signing_key_id: &str,
candidate_version: &str,
allow: bool,
) -> Result<(), String> {
// Compare against the activation version (always semver); a legacy manifest version string
// that fails to parse simply skips the downgrade check.
if let (Ok(active), Ok(candidate)) = (Version::parse(active_version), Version::parse(candidate_version)) {
if candidate < active && !allow {
return Err(format!(
"Plugin downgrade to version {candidate_version} is not allowed (installed {active_version})"
));
}
}
let repository_changed =
provenance.repository_id.as_deref().is_some_and(|recorded| Some(recorded) != repository_id);
let publisher_changed = provenance.publisher.as_deref().is_some_and(|recorded| recorded != publisher);
let key_changed = provenance.signing_key_id.as_deref().is_some_and(|recorded| recorded != signing_key_id);
if (repository_changed || publisher_changed || key_changed) && !allow {
return Err(
"Plugin update source change requires confirmation: the offering repository, publisher, or signing key differs from the recorded install"
.to_string(),
);
}
Ok(())
}
impl PluginTrustStore {
pub fn load(root_dir: &Path) -> Result<Self, String> {
let document = read_trust_document(root_dir)?;
@@ -291,22 +373,33 @@ impl PluginPackageInstaller {
}
pub fn install_bytes(&self, package: &[u8], policy: PluginInstallPolicy) -> Result<PluginInstallResult, String> {
self.install_bytes_with_expectation(package, policy, None)
self.install_bytes_with_expectation(package, policy, None, PluginInstallSource::File, false)
}
pub(super) fn install_marketplace_bytes(
&self,
package: &[u8],
expectation: &PluginPackageExpectation,
allow_source_change: bool,
) -> Result<PluginInstallResult, String> {
self.install_bytes_with_expectation(package, PluginInstallPolicy::LocalSigned, Some(expectation))
self.install_bytes_with_expectation(
package,
PluginInstallPolicy::LocalSigned,
Some(expectation),
PluginInstallSource::Marketplace,
allow_source_change,
)
}
fn install_bytes_with_expectation(
/// Shared entry point for every install path: `PluginPackageExpectation` (and therefore this
/// signature) stays inside the `plugins` module, exactly like the expectation type.
pub(super) fn install_bytes_with_expectation(
&self,
package: &[u8],
policy: PluginInstallPolicy,
expectation: Option<&PluginPackageExpectation>,
source: PluginInstallSource,
allow_source_change: bool,
) -> Result<PluginInstallResult, String> {
if package.len() > MAX_PLUGIN_PACKAGE_BYTES {
return Err(format!("Plugin package exceeds {MAX_PLUGIN_PACKAGE_BYTES} bytes"));
@@ -314,7 +407,7 @@ impl PluginPackageInstaller {
std::fs::create_dir_all(&self.root_dir).map_err(|error| error.to_string())?;
let lock = open_install_lock(&self.root_dir)?;
lock.lock_exclusive().map_err(|error| format!("Failed to lock plugin store: {error}"))?;
let result = self.install_bytes_locked(package, policy, expectation);
let result = self.install_bytes_locked(package, policy, expectation, source, allow_source_change);
let _ = FileExt::unlock(&lock);
result
}
@@ -343,11 +436,18 @@ impl PluginPackageInstaller {
result
}
/// Recorded provenance of one container's active installation, if any.
pub(super) fn read_container_provenance(container_dir: &Path) -> Result<Option<PluginInstallProvenance>, String> {
Ok(read_latest_activation(container_dir)?.and_then(|record| record.provenance))
}
fn install_bytes_locked(
&self,
package: &[u8],
policy: PluginInstallPolicy,
expectation: Option<&PluginPackageExpectation>,
source: PluginInstallSource,
allow_source_change: bool,
) -> Result<PluginInstallResult, String> {
let package_sha256 = sha256_hex(package);
let staging = tempfile::Builder::new()
@@ -393,6 +493,26 @@ impl PluginPackageInstaller {
let version_string = version.to_string();
let version_dir = versions_dir.join(&version_string);
let current = read_latest_activation(&container_dir)?;
// Update continuity (crate::plugins::installer::ensure_update_continuity): a marketplace
// update whose recorded repository / publisher / signing key differs from the offering one,
// or that would downgrade the active version, is rejected unless the user explicitly
// confirmed. Installs without recorded provenance stay unconstrained until the next
// install records fresh provenance.
if let (Some(expectation), Some(record)) = (expectation, current.as_ref()) {
if !matches!(policy, PluginInstallPolicy::LocalDevelopment) {
if let Some(provenance) = &record.provenance {
ensure_update_continuity(
provenance,
&record.version,
expectation.repository_id.as_deref(),
&expectation.publisher,
&expectation.signing_key_id,
&version_string,
allow_source_change,
)?;
}
}
}
// 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
@@ -444,12 +564,37 @@ impl PluginPackageInstaller {
return Err(message);
}
let provenance = match expectation {
Some(expectation) => PluginInstallProvenance {
repository_id: expectation.repository_id.clone(),
publisher: Some(expectation.publisher.clone()),
signing_key_id: Some(expectation.signing_key_id.clone()),
source: PluginInstallSource::Marketplace,
},
None => {
let previous = current.as_ref().and_then(|record| record.provenance.as_ref());
PluginInstallProvenance {
// A file/URL replacement carries the recorded repository forward so a
// non-marketplace overwrite cannot silently erase a marketplace install's
// source identity; publisher and signing key record what is actually
// being installed now.
repository_id: previous.and_then(|provenance| provenance.repository_id.clone()),
publisher: Some(manifest.publisher.clone()),
signing_key_id: match &signature {
PluginSignatureStatus::Trusted { key_id } => Some(key_id.clone()),
PluginSignatureStatus::Unsigned => None,
},
source,
}
}
};
let activation = PluginActivationRecord {
sequence: current.as_ref().map_or(1, |record| record.sequence.saturating_add(1)),
version: version_string,
previous_version: previous_version.clone(),
package_sha256: package_sha256.clone(),
activated_at: Utc::now().to_rfc3339(),
provenance: Some(provenance),
};
if let Err(error) = write_activation_record(&container_dir, &activation) {
let _ = std::fs::remove_dir_all(&version_dir);
@@ -469,7 +614,8 @@ impl PluginPackageInstaller {
if let Err(error) = prune_plugin_history(&container_dir, &activation) {
log::warn!("Failed to prune plugin '{}' install history: {error}", manifest.id);
}
let plugin = InstalledPlugin::new(manifest, version_dir, &self.app_version);
let plugin = InstalledPlugin::new(manifest, version_dir, &self.app_version)
.with_provenance(activation.provenance.clone());
Ok(PluginInstallResult { plugin, previous_version, package_sha256, signature, _update_guard: update_guard })
}
@@ -500,13 +646,18 @@ impl PluginPackageInstaller {
));
}
let target_record = read_latest_activation_for_version(&container_dir, &previous_version)?;
let plugin = plugin.with_provenance(target_record.as_ref().and_then(|record| record.provenance.clone()));
let activation = PluginActivationRecord {
sequence: current.sequence.saturating_add(1),
version: previous_version.clone(),
previous_version: Some(current.version),
package_sha256: target_record
.map_or_else(|| "legacy-unmanaged".to_string(), |record| record.package_sha256),
.as_ref()
.map_or_else(|| "legacy-unmanaged".to_string(), |record| record.package_sha256.clone()),
activated_at: Utc::now().to_rfc3339(),
// Roll the target version's own provenance forward: a rollback re-activates that
// version, so its recorded source identity is the truthful one to keep guarding with.
provenance: target_record.as_ref().and_then(|record| record.provenance.clone()),
};
write_activation_record(&container_dir, &activation)?;
if let Err(error) = prune_plugin_history(&container_dir, &activation) {
@@ -853,6 +1004,7 @@ fn migrate_legacy_container(container_dir: &Path) -> Result<(), String> {
previous_version: None,
package_sha256: "legacy-unmanaged".to_string(),
activated_at: Utc::now().to_rfc3339(),
provenance: None,
},
)
})();
@@ -1024,9 +1176,9 @@ mod tests {
use zip::write::SimpleFileOptions;
use super::{
is_activation_record_file, sha256_hex, validate_package_expectation, PluginInstallPolicy,
PluginPackageExpectation, PluginPackageInstaller, PluginSignatureStatus, PluginTrustStore, ACTIVATIONS_DIR,
PLUGIN_CHECKSUMS_FILE, PLUGIN_SIGNATURE_FILE, VERSIONS_DIR,
is_activation_record_file, read_install_identity, sha256_hex, validate_package_expectation,
PluginInstallPolicy, PluginPackageExpectation, PluginPackageInstaller, PluginSignatureStatus, PluginTrustStore,
ACTIVATIONS_DIR, PLUGIN_CHECKSUMS_FILE, PLUGIN_SIGNATURE_FILE, VERSIONS_DIR,
};
use crate::plugins::{PluginManifest, PluginRegistry};
@@ -1092,15 +1244,19 @@ mod tests {
.unwrap_err()
.contains("active operations"));
let expectation = PluginPackageExpectation {
repository_id: None,
id: "sample.hello".to_string(),
version: "1.1.0".to_string(),
publisher: "sample".to_string(),
permissions: BTreeSet::from(["host.events".to_string()]),
signing_key_id: "test".to_string(),
};
assert!(installer.install_marketplace_bytes(&bytes, &expectation).unwrap_err().contains("active operations"));
assert!(installer
.install_marketplace_bytes(&bytes, &expectation, false)
.unwrap_err()
.contains("active operations"));
drop(operation);
installer.install_marketplace_bytes(&bytes, &expectation).unwrap();
installer.install_marketplace_bytes(&bytes, &expectation, false).unwrap();
assert_eq!(registry.find_plugin("sample.hello").unwrap().unwrap().manifest.version, "1.1.0");
}
@@ -1287,6 +1443,7 @@ mod tests {
let trust = PluginTrustStore::from_base64_keys(keys).unwrap();
let installer = PluginPackageInstaller::with_trust_store(root.path().to_path_buf(), "0.5.67", trust);
let expectation = PluginPackageExpectation {
repository_id: None,
id: "sample.hello".to_string(),
version: "1.0.0".to_string(),
publisher: "sample".to_string(),
@@ -1294,10 +1451,10 @@ mod tests {
signing_key_id: "sample-key".to_string(),
};
let unsigned = package("1.0.0", None, false);
assert!(installer.install_marketplace_bytes(&unsigned, &expectation).is_err());
assert!(installer.install_marketplace_bytes(&unsigned, &expectation, false).is_err());
let signed = package("1.0.0", Some((&signing_key, "sample-key")), false);
let result = installer.install_marketplace_bytes(&signed, &expectation).unwrap();
let result = installer.install_marketplace_bytes(&signed, &expectation, false).unwrap();
assert_eq!(result.signature, PluginSignatureStatus::Trusted { key_id: "sample-key".to_string() });
}
@@ -1410,6 +1567,7 @@ mod tests {
.unwrap();
let signature = PluginSignatureStatus::Trusted { key_id: "sample-key".to_string() };
let expectation = PluginPackageExpectation {
repository_id: None,
id: "sample.hello".to_string(),
version: "1.0.0".to_string(),
publisher: "sample".to_string(),
@@ -1490,6 +1648,146 @@ mod tests {
(installer, signing_key)
}
#[test]
fn rejects_source_changed_update_unless_confirmed() {
let root = tempfile::tempdir().unwrap();
let (installer, signing_key) = signed_installer(root.path(), [21u8; 32]);
let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false);
let expectation = |repository: &str, version: &str| PluginPackageExpectation {
id: "sample.hello".to_string(),
version: version.to_string(),
repository_id: Some(repository.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("repo-a", "1.0.0"), false).unwrap();
// The recorded install came from repo-a; a repo-b update needs the explicit confirmation.
let rejected =
installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("repo-b", "1.1.0"), false).unwrap_err();
assert!(rejected.contains("source change requires confirmation"), "{rejected}");
assert_eq!(read_install_identity(root.path(), "sample.hello").unwrap().unwrap().version, "1.0.0");
let confirmed =
installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("repo-b", "1.1.0"), true).unwrap();
assert_eq!(
confirmed.plugin.provenance.as_ref().and_then(|provenance| provenance.repository_id.clone()).as_deref(),
Some("repo-b")
);
// A downgrade below the confirmed active version needs the same explicit confirmation.
let downgraded =
installer.install_marketplace_bytes(&signed("1.0.5"), &expectation("repo-b", "1.0.5"), false).unwrap_err();
assert!(downgraded.contains("downgrade"), "{downgraded}");
}
#[test]
fn rollback_carries_the_target_versions_provenance_forward() {
let root = tempfile::tempdir().unwrap();
let (installer, signing_key) = signed_installer(root.path(), [22u8; 32]);
let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false);
let expectation = |repository: &str, version: &str| PluginPackageExpectation {
id: "sample.hello".to_string(),
version: version.to_string(),
repository_id: Some(repository.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("repo-a", "1.0.0"), false).unwrap();
installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("repo-b", "1.1.0"), true).unwrap();
assert_eq!(installer.rollback("sample.hello").unwrap().previous_version, "1.0.0");
// After rolling back to the repo-a version, the recorded provenance is repo-a again, so a
// repo-a update proceeds without confirmation while repo-b still requires one.
let identity = read_install_identity(root.path(), "sample.hello").unwrap().unwrap();
assert_eq!(identity.version, "1.0.0");
assert_eq!(identity.provenance.and_then(|provenance| provenance.repository_id), Some("repo-a".to_string()));
installer.install_marketplace_bytes(&signed("1.2.0"), &expectation("repo-a", "1.2.0"), false).unwrap();
let rejected =
installer.install_marketplace_bytes(&signed("1.3.0"), &expectation("repo-b", "1.3.0"), false).unwrap_err();
assert!(rejected.contains("source change requires confirmation"), "{rejected}");
}
/// Rewrites the newest activation record without its `provenance` field: the on-disk shape an
/// activation written by a build that predates provenance has.
fn strip_latest_activation_provenance(container: &Path) {
let mut records = std::fs::read_dir(container.join(ACTIVATIONS_DIR))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| is_activation_record_file(path))
.collect::<Vec<_>>();
records.sort();
let path = records.pop().expect("activation record");
let mut value: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(value.as_object_mut().unwrap().remove("provenance").is_some());
std::fs::write(&path, serde_json::to_vec_pretty(&value).unwrap()).unwrap();
}
#[test]
fn a_pre_provenance_activation_stays_unconstrained_until_the_next_install() {
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);
let expectation = |repository: &str, version: &str| PluginPackageExpectation {
id: "sample.hello".to_string(),
version: version.to_string(),
repository_id: Some(repository.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("repo-a", "1.0.0"), false).unwrap();
strip_latest_activation_provenance(&root.path().join("sample.hello"));
// An activation record without provenance still parses (serde default) and records no source.
let identity = read_install_identity(root.path(), "sample.hello").unwrap().unwrap();
assert_eq!(identity.version, "1.0.0");
assert!(identity.provenance.is_none());
// Nothing was recorded, so the next install from any repository proceeds without a
// confirmation and starts recording provenance again.
let updated =
installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("repo-b", "1.1.0"), false).unwrap();
assert_eq!(
updated.plugin.provenance.and_then(|provenance| provenance.repository_id).as_deref(),
Some("repo-b")
);
}
#[test]
fn updating_a_plugin_leaves_its_data_directory_untouched() {
let root = tempfile::tempdir().unwrap();
// The registry root sits beside plugin-data/, exactly like the app layout, so
// `PluginRegistry::plugin_data_dir("sample.hello")` resolves to the path below.
let plugins_root = root.path().join("plugins");
let (installer, signing_key) = signed_installer(&plugins_root, [24u8; 32]);
let signed = |version: &str| package(version, Some((&signing_key, "sample-key")), false);
let expectation = |repository: &str, version: &str| PluginPackageExpectation {
id: "sample.hello".to_string(),
version: version.to_string(),
repository_id: Some(repository.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("repo-a", "1.0.0"), false).unwrap();
let data_dir = root.path().join("plugin-data").join("sample.hello");
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::write(data_dir.join("state.json"), b"{\"rows\":1}").unwrap();
installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("repo-a", "1.1.0"), false).unwrap();
installer.rollback("sample.hello").unwrap();
// Plugin user data lives outside the versioned container and must survive update + rollback.
assert_eq!(std::fs::read(data_dir.join("state.json")).unwrap(), b"{\"rows\":1}");
}
fn activation_record_count(container: &Path) -> usize {
std::fs::read_dir(container.join(ACTIVATIONS_DIR))
.unwrap()
@@ -1512,6 +1810,7 @@ mod tests {
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 {
repository_id: None,
id: "sample.hello".to_string(),
version: version.to_string(),
publisher: "sample".to_string(),
@@ -1519,13 +1818,13 @@ mod tests {
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();
installer.install_marketplace_bytes(&signed("1.0.0"), &expectation("1.0.0"), false).unwrap();
installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("1.1.0"), false).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();
let reinstalled = installer.install_marketplace_bytes(&signed("1.1.0"), &expectation("1.1.0"), false).unwrap();
assert_eq!(reinstalled.previous_version.as_deref(), Some("1.0.0"));
let container = root.path().join("sample.hello");
@@ -12,7 +12,7 @@ use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::installer::{validate_key_id, PluginPackageExpectation};
use super::installer::{ensure_update_continuity, validate_key_id, PluginPackageExpectation};
use super::manifest::{parse_host_network_permission, MAX_PLUGIN_NETWORK_ORIGINS};
use super::{
current_plugin_target, PluginInstallPolicy, PluginInstallResult, PluginPackageInstaller, PluginTrustStore,
@@ -155,6 +155,10 @@ pub struct PluginMarketplaceInstallRequest {
pub plugin_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// Set after the user explicitly confirmed a changed update source (repository / publisher /
/// signing key) or an intentional downgrade; the install is rejected without it.
#[serde(default)]
pub allow_source_change: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
@@ -408,6 +412,22 @@ impl PluginMarketplace {
let artifact = select_marketplace_artifact(version, &target).ok_or_else(|| {
format!("Plugin '{}' version '{}' does not support target '{}'", plugin.id, version.version, target)
})?;
// Read-only provenance pre-flight: reject a source-changed or downgraded update before any
// download or runtime teardown happens. install_bytes_locked re-checks under the lock.
let allow_source_change = request.allow_source_change;
if let Some(identity) = super::installer::read_install_identity(&self.root_dir, &request.plugin_id)? {
if let Some(provenance) = &identity.provenance {
ensure_update_continuity(
provenance,
&identity.version,
Some(&repository.id),
&plugin.publisher,
&artifact.signing_key_id,
requested_version,
allow_source_change,
)?;
}
}
let artifact_url =
Url::parse(&artifact.url).map_err(|error| format!("Invalid plugin artifact URL: {error}"))?;
let package = self.download_limited(artifact_url, MAX_PLUGIN_PACKAGE_BYTES, "Plugin package").await?;
@@ -416,6 +436,7 @@ impl PluginMarketplace {
let expectation = PluginPackageExpectation {
id: plugin.id.clone(),
version: version.version.clone(),
repository_id: Some(repository.id.clone()),
publisher: plugin.publisher.clone(),
permissions: plugin.permissions.iter().cloned().collect(),
signing_key_id: artifact.signing_key_id.clone(),
@@ -425,7 +446,7 @@ impl PluginMarketplace {
self.app_version.clone(),
trust_store,
))
.install_marketplace_bytes(&package, &expectation)
.install_marketplace_bytes(&package, &expectation, allow_source_change)
}
/// Downloads a .dbxp package from a direct http(s) URL and installs it with
@@ -467,7 +488,7 @@ impl PluginMarketplace {
on_progress(bytes.len() as u64, total);
}
self.guard_installer(PluginPackageInstaller::new(self.root_dir.clone(), self.app_version.clone())?)
.install_bytes(&bytes, policy)
.install_bytes_with_expectation(&bytes, policy, None, super::installer::PluginInstallSource::Url, false)
}
async fn download_limited(&self, url: Url, max_bytes: usize, label: &str) -> Result<Vec<u8>, String> {
@@ -1166,6 +1187,7 @@ mod tests {
repository_id: repository.id,
plugin_id: "marketplace.install".to_string(),
version: None,
allow_source_change: false,
})
.await
.unwrap();
@@ -1336,6 +1358,7 @@ mod tests {
repository_id: "not-configured".to_string(),
plugin_id: "marketplace.install".to_string(),
version: None,
allow_source_change: false,
})
.await
.unwrap_err();