diff --git a/src/trust/path-trust.ts b/src/trust/path-trust.ts index c8da4e19c..f5bad4add 100644 --- a/src/trust/path-trust.ts +++ b/src/trust/path-trust.ts @@ -175,7 +175,7 @@ export async function revokePathPlugin( /** * One-shot migration: seed the global store from `settings.pluginPaths` when - * no valid store file exists yet. Every registered entry that resolves to a + * the store file is missing (first launch). Every registered entry that resolves to a * plugin on disk is granted — pluginPaths lives in the user's global settings, * so each entry was put there by the user (add-by-path or a hand edit) and * registration is taken as consent at the moment the store is created, even @@ -189,6 +189,13 @@ export async function revokePathPlugin( * (expand marketplaces, drop missing paths). Callers supply expansion so this * module stays free of the plugin loader. `onMigrated` fires only on the run * that seeds grants, so callers can surface the one-time event to the user. + * + * A corrupt store (`invalid`: unreadable, zero-byte, or malformed) refuses to + * seed: re-granting from `pluginPaths` here would undo an explicit revoke the + * moment the file becomes unreadable. Migration leaves the file untouched and + * returns no grants, so path plugins load metadata-only until the user either + * repairs the file (deleting it restores first-launch seeding) or re-consents + * explicitly through add-by-path / enable. */ export async function migratePathTrustFromPluginPaths( pluginPaths: string[], @@ -200,6 +207,12 @@ export async function migratePathTrustFromPluginPaths( if (existing.state === "valid") { return existing.store; } + if (existing.state === "invalid") { + if (pluginPaths.length > 0) { + logger.warn`refusing path-trust migration from a corrupt store at ${pathTrustPath(home)}: delete the file to re-seed from settings.pluginPaths or re-consent through add-by-path`; + } + return emptyStore(); + } if (pluginPaths.length === 0) { return emptyStore(); } diff --git a/tests/unit/path-trust.test.ts b/tests/unit/path-trust.test.ts index 5a999cc3b..7563181e0 100644 --- a/tests/unit/path-trust.test.ts +++ b/tests/unit/path-trust.test.ts @@ -240,26 +240,35 @@ describe("path-trust (global)", () => { } }); - test("a zero-byte store file is invalid and migration re-seeds it", async () => { + test("a zero-byte store file is invalid and migration refuses to seed it", async () => { const { home, cleanup } = await scratch(); try { await writeStoreFile(home, ""); expect((await readPathTrustStore(home)).state).toBe("invalid"); const plugin = join(home, "shared", "plugin"); + let resolveCalls = 0; + let migratedCalls = 0; const store = await migratePathTrustFromPluginPaths( [plugin], - async () => [plugin], + async (p) => { + resolveCalls += 1; + return [p]; + }, home, + { onMigrated: () => (migratedCalls += 1) }, ); - expect(isPathPluginTrusted(store, plugin)).toBe(true); - expect((await readPathTrustStore(home)).state).toBe("valid"); + expect(isPathPluginTrusted(store, plugin)).toBe(false); + expect(store.trustedPluginPaths).toEqual([]); + expect(resolveCalls).toBe(0); + expect(migratedCalls).toBe(0); + expect((await readPathTrustStore(home)).state).toBe("invalid"); } finally { await cleanup(); } }); - test("a corrupt store file is invalid, loads empty, and migration re-seeds it", async () => { + test("a corrupt store file is invalid, loads empty, and migration refuses to seed it", async () => { const { home, cleanup } = await scratch(); try { await writeStoreFile(home, "{not json"); @@ -267,12 +276,54 @@ describe("path-trust (global)", () => { expect((await loadPathTrust(home)).trustedPluginPaths).toEqual([]); const plugin = join(home, "shared", "plugin"); + let resolveCalls = 0; + let migratedCalls = 0; const store = await migratePathTrustFromPluginPaths( [plugin], - async () => [plugin], + async (p) => { + resolveCalls += 1; + return [p]; + }, home, + { onMigrated: () => (migratedCalls += 1) }, ); - expect(isPathPluginTrusted(store, plugin)).toBe(true); + expect(isPathPluginTrusted(store, plugin)).toBe(false); + expect(store.trustedPluginPaths).toEqual([]); + expect(resolveCalls).toBe(0); + expect(migratedCalls).toBe(0); + expect((await readPathTrustStore(home)).state).toBe("invalid"); + } finally { + await cleanup(); + } + }); + + test("revoke then corrupt: migration refuses to re-grant and leaves the store invalid", async () => { + const { home, cleanup } = await scratch(); + try { + const plugin = join(home, "shared", "plugin"); + await trustPathPlugin(plugin, home); + await revokePathPlugin(plugin, home); + expect((await readPathTrustStore(home)).state).toBe("valid"); + + await writeStoreFile(home, "{not json"); + expect((await readPathTrustStore(home)).state).toBe("invalid"); + + let resolveCalls = 0; + let migratedCalls = 0; + const store = await migratePathTrustFromPluginPaths( + [plugin], + async (p) => { + resolveCalls += 1; + return [p]; + }, + home, + { onMigrated: () => (migratedCalls += 1) }, + ); + expect(isPathPluginTrusted(store, plugin)).toBe(false); + expect(store.trustedPluginPaths).toEqual([]); + expect(resolveCalls).toBe(0); + expect(migratedCalls).toBe(0); + expect((await readPathTrustStore(home)).state).toBe("invalid"); } finally { await cleanup(); }