Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/trust/path-trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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[],
Expand All @@ -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();
}
Expand Down
65 changes: 58 additions & 7 deletions tests/unit/path-trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,39 +240,90 @@ 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");
expect((await readPathTrustStore(home)).state).toBe("invalid");
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();
}
Expand Down
Loading