fix(plugins): check and update installed plugins with provenance guards
This commit is contained in:
1 parent
d715b16ba5
commit
cab674299a
26 files changed
+1180
-44
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user