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

+6 -2
View File
@@ -17,10 +17,12 @@ export interface BatchOutcome {
/**
* Run `action` over `items` sequentially, recording per-item success/failure. A failing item is
* captured and the batch continues; nothing is rolled back. `nameOf` supplies a human label used
* in the summary. Empty input yields an empty outcome.
* in the summary. `onProgress` fires after every item (success or failure) so callers can render
* live "(current/total)" progress. Empty input yields an empty outcome.
*/
export async function runBatch<T>(items: readonly T[], nameOf: (item: T) => string, action: (item: T) => Promise<void>): Promise<BatchOutcome> {
export async function runBatch<T>(items: readonly T[], nameOf: (item: T) => string, action: (item: T) => Promise<void>, onProgress?: (completed: number, total: number) => void): Promise<BatchOutcome> {
const results: BatchItemOutcome[] = [];
let completed = 0;
for (const item of items) {
const name = nameOf(item);
try {
@@ -29,6 +31,8 @@ export async function runBatch<T>(items: readonly T[], nameOf: (item: T) => stri
} catch (cause) {
results.push({ name, ok: false, error: cause instanceof Error ? cause.message : String(cause) });
}
completed += 1;
onProgress?.(completed, items.length);
}
return {
results,
+75 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildMarketplacePluginListings, filterMarketplacePluginListings, marketplaceHomepageUrl, selectMarketplaceArtifact } from "./pluginMarketplace";
import { buildInstalledUpdateIndex, buildMarketplacePluginListings, compareVersions, filterMarketplacePluginListings, marketplaceHomepageUrl, pluginSourceChange, selectMarketplaceArtifact } from "./pluginMarketplace";
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
const result: PluginRepositoryCatalogResult = {
@@ -50,6 +50,17 @@ function installed(version: string): InstalledPlugin {
};
}
function repositoryResult(repositoryId: string, repositoryName: string, latestVersion: string, verified = true): PluginRepositoryCatalogResult {
const copy = structuredClone(result);
copy.repository = { id: repositoryId, name: repositoryName, kind: verified ? "official" : "custom", enabled: true, managed: !verified ? false : true };
copy.catalog!.repository = { id: repositoryId, name: repositoryName };
copy.catalog!.plugins[0].verified = verified;
// Catalog validation requires latestVersion to exist in versions, so keep both in sync.
copy.catalog!.plugins[0].latestVersion = latestVersion;
copy.catalog!.plugins[0].versions[0].version = latestVersion;
return copy;
}
describe("plugin marketplace listings", () => {
it("localizes, detects updates, and filters by repository", () => {
const listings = buildMarketplacePluginListings([result], [installed("1.0.0")], "zh-CN");
@@ -97,3 +108,66 @@ describe("plugin marketplace listings", () => {
expect(marketplaceHomepageUrl("https://github.com/dbxio/example", "https://dbxio.com/plugins/example")).toBe("https://dbxio.com/plugins/example");
});
});
describe("buildInstalledUpdateIndex", () => {
it("indexes only update-bearing listings by plugin id", () => {
const index = buildInstalledUpdateIndex(buildMarketplacePluginListings([result], [installed("1.0.0")], "en"));
expect(index.get("example.hello")).toMatchObject({ repositoryName: "DBX Marketplace" });
expect(index.get("example.hello")?.listing.plugin.latestVersion).toBe("1.1.0");
// Already up to date (or not installed) → no entry.
expect(buildInstalledUpdateIndex(buildMarketplacePluginListings([result], [installed("1.1.0")], "en")).size).toBe(0);
expect(buildInstalledUpdateIndex(buildMarketplacePluginListings([result], [], "en")).size).toBe(0);
});
it("resolves duplicate plugin ids to one source: verifiable repository first, then the higher version", () => {
const official = repositoryResult("dbx-official", "DBX Marketplace", "1.1.0", true);
const custom = repositoryResult("acme", "Acme Repo", "1.3.0", false);
// The unverified custom repository advertises a higher version, but the verifiable official
// repository wins so the installed tab cannot silently steer users to an unverified source.
const index = buildInstalledUpdateIndex(buildMarketplacePluginListings([custom, official], [installed("1.0.0")], "en"));
expect(index.get("example.hello")?.listing.repository.id).toBe("dbx-official");
const customA = repositoryResult("acme-a", "Acme A", "1.2.0", false);
const customB = repositoryResult("acme-b", "Acme B", "1.3.0", false);
const noVerifyIndex = buildInstalledUpdateIndex(buildMarketplacePluginListings([customA, customB], [installed("1.0.0")], "en"));
expect(noVerifyIndex.get("example.hello")?.listing.plugin.latestVersion).toBe("1.3.0");
});
});
describe("pluginSourceChange", () => {
it("returns null when nothing was recorded or the candidate matches the provenance", () => {
const noProvenance = buildMarketplacePluginListings([result], [installed("1.0.0")], "en")[0];
expect(pluginSourceChange(noProvenance)).toBeNull();
const withProvenance = installed("1.0.0");
withProvenance.provenance = { repositoryId: "dbx-official", publisher: "DBX", signingKeyId: "dbx.release", source: "marketplace" };
const same = buildMarketplacePluginListings([result], [withProvenance], "en")[0];
expect(pluginSourceChange(same)).toBeNull();
});
it("flags repository, publisher, and signing-key changes individually", () => {
const moved = installed("1.0.0");
moved.provenance = { repositoryId: "other-repo", publisher: "DBX", signingKeyId: "dbx.release", source: "marketplace" };
expect(pluginSourceChange(buildMarketplacePluginListings([result], [moved], "en")[0])).toEqual({ repositoryChanged: true, publisherChanged: false, signingKeyChanged: false });
const renamed = installed("1.0.0");
renamed.provenance = { repositoryId: "dbx-official", publisher: "Other", signingKeyId: "dbx.release", source: "marketplace" };
expect(pluginSourceChange(buildMarketplacePluginListings([result], [renamed], "en")[0])).toEqual({ repositoryChanged: false, publisherChanged: true, signingKeyChanged: false });
const rekeyed = installed("1.0.0");
rekeyed.provenance = { repositoryId: "dbx-official", publisher: "DBX", signingKeyId: "dbx.release-2023", source: "marketplace" };
expect(pluginSourceChange(buildMarketplacePluginListings([result], [rekeyed], "en")[0])).toEqual({ repositoryChanged: false, publisherChanged: false, signingKeyChanged: true });
});
});
describe("compareVersions", () => {
it("orders semver, ranks release above prerelease, and falls back for unparsable versions", () => {
expect(compareVersions("1.10.0", "1.9.0")).toBeGreaterThan(0);
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
expect(compareVersions("1.0.0", "1.0.0-beta")).toBeGreaterThan(0);
// Legacy migrated installs can carry non-semver manifest versions; the numeric-aware
// fallback must still rank the catalog version above them.
expect(compareVersions("1.1.0", "0.9")).toBeGreaterThan(0);
});
});
@@ -61,6 +61,55 @@ export function selectMarketplaceArtifact(artifacts: readonly PluginMarketplaceA
return artifacts.find((candidate) => candidate.target === target) || artifacts.find((candidate) => candidate.target === UNIVERSAL_PLUGIN_TARGET);
}
export interface InstalledPluginUpdateEntry {
listing: MarketplacePluginListing;
repositoryName: string;
}
// Same plugin id can be published by several enabled repositories. Prefer a repository allowed to
// verify the plugin (official/enterprise), then the higher latestVersion, so every consumer of the
// catalog join (marketplace cards, installed tab, batch) resolves the same single source.
function preferredUpdateListing(left: MarketplacePluginListing, right: MarketplacePluginListing): MarketplacePluginListing {
if (left.verified !== right.verified) return left.verified ? left : right;
return compareVersions(right.plugin.latestVersion, left.plugin.latestVersion) > 0 ? right : left;
}
/**
* Catalog join for the installed tab: update-bearing listings indexed by plugin id, with at most
* one entry per id (see preferredUpdateListing for the multi-repository collision rule).
*/
export function buildInstalledUpdateIndex(listings: readonly MarketplacePluginListing[]): Map<string, InstalledPluginUpdateEntry> {
const index = new Map<string, InstalledPluginUpdateEntry>();
for (const listing of listings) {
if (listing.status !== "update") continue;
const existing = index.get(listing.plugin.id);
if (existing && preferredUpdateListing(existing.listing, listing) === existing.listing) continue;
index.set(listing.plugin.id, { listing, repositoryName: listing.repository.name });
}
return index;
}
export interface PluginSourceChange {
repositoryChanged: boolean;
publisherChanged: boolean;
signingKeyChanged: boolean;
}
/**
* Compare the provenance recorded at install time against the candidate listing. Null means "no
* confirmation needed": nothing recorded yet (installs from before provenance existed stay
* unconstrained), or the recorded repository/publisher/signing key all match the candidate.
*/
export function pluginSourceChange(listing: MarketplacePluginListing): PluginSourceChange | null {
const provenance = listing.installed?.provenance;
if (!provenance) return null;
const repositoryChanged = !!provenance.repositoryId && provenance.repositoryId !== listing.repository.id;
const publisherChanged = !!provenance.publisher && !!listing.plugin.publisher && provenance.publisher !== listing.plugin.publisher;
const signingKeyChanged = !!provenance.signingKeyId && !!listing.artifact?.signingKeyId && provenance.signingKeyId !== listing.artifact.signingKeyId;
if (!repositoryChanged && !publisherChanged && !signingKeyChanged) return null;
return { repositoryChanged, publisherChanged, signingKeyChanged };
}
export function listingRepositoryCanVerify(repository: PluginRepository): boolean {
return repository.kind === "official" || repository.kind === "enterprise";
}
@@ -124,7 +173,37 @@ function marketplacePluginLocalization(plugin: PluginMarketplacePlugin, locale:
};
}
function compareVersions(left: string, right: string): number {
// Semver prerelease ordering: an identifier pair compares numerically when both are numeric,
// numeric ranks below alphanumeric, a shorter identifier list ranks lower, and a version WITHOUT
// a prerelease ranks above any prerelease. Plain localeCompare gets the last rule backwards
// ("~" collates below letters), which used to rank releases below their own betas.
function comparePrerelease(left: string, right: string): number {
if (left === right) return 0;
if (left === "~") return 1;
if (right === "~") return -1;
const leftIds = left.split(".");
const rightIds = right.split(".");
for (let index = 0; index < Math.max(leftIds.length, rightIds.length); index += 1) {
const leftId = leftIds[index];
const rightId = rightIds[index];
if (leftId === undefined) return -1;
if (rightId === undefined) return 1;
const leftNumber = Number(leftId);
const rightNumber = Number(rightId);
const leftNumeric = leftId !== "" && !Number.isNaN(leftNumber);
const rightNumeric = rightId !== "" && !Number.isNaN(rightNumber);
if (leftNumeric && rightNumeric) {
if (leftNumber !== rightNumber) return leftNumber - rightNumber;
} else if (leftNumeric !== rightNumeric) {
return leftNumeric ? -1 : 1;
} else if (leftId !== rightId) {
return leftId < rightId ? -1 : 1;
}
}
return 0;
}
export function compareVersions(left: string, right: string): number {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
if (!leftParts || !rightParts) return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" });
@@ -133,7 +212,7 @@ function compareVersions(left: string, right: string): number {
for (let index = 0; index < leftNumbers.length; index += 1) {
if (leftNumbers[index] !== rightNumbers[index]) return leftNumbers[index] - rightNumbers[index];
}
return leftParts[3].localeCompare(rightParts[3]);
return comparePrerelease(leftParts[3], rightParts[3]);
}
function parseVersion(version: string): [number, number, number, string] | null {