diff --git a/src/pairing/allow-from-store-file.ts b/src/pairing/allow-from-store-file.ts new file mode 100644 index 00000000000..af62a1f006a --- /dev/null +++ b/src/pairing/allow-from-store-file.ts @@ -0,0 +1,298 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; +import { resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { readJsonFileWithFallback } from "../plugin-sdk/json-store.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import type { PairingChannel } from "./pairing-store.types.js"; + +export type AllowFromStore = { + version: 1; + allowFrom: string[]; +}; + +type AllowFromReadCacheEntry = { + exists: boolean; + mtimeMs: number | null; + size: number | null; + entries: string[]; +}; + +type AllowFromStatLike = { mtimeMs: number; size: number } | null; + +type NormalizeAllowFromStore = (store: AllowFromStore) => string[]; + +const allowFromReadCache = new Map(); + +export function resolvePairingCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { + const stateDir = resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir)); + return resolveOAuthDir(env, stateDir); +} + +/** Sanitize channel ID for use in filenames (prevent path traversal). */ +export function safeChannelKey(channel: PairingChannel): string { + const raw = normalizeLowercaseStringOrEmpty(String(channel)); + if (!raw) { + throw new Error("invalid pairing channel"); + } + const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); + if (!safe || safe === "_") { + throw new Error("invalid pairing channel"); + } + return safe; +} + +function safeAccountKey(accountId: string): string { + const raw = normalizeLowercaseStringOrEmpty(accountId); + if (!raw) { + throw new Error("invalid pairing account id"); + } + const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); + if (!safe || safe === "_") { + throw new Error("invalid pairing account id"); + } + return safe; +} + +export function resolveAllowFromFilePath( + channel: PairingChannel, + env: NodeJS.ProcessEnv = process.env, + accountId?: string, +): string { + const base = safeChannelKey(channel); + const normalizedAccountId = normalizeOptionalString(accountId) ?? ""; + if (!normalizedAccountId) { + return path.join(resolvePairingCredentialsDir(env), `${base}-allowFrom.json`); + } + return path.join( + resolvePairingCredentialsDir(env), + `${base}-${safeAccountKey(normalizedAccountId)}-allowFrom.json`, + ); +} + +export function dedupePreserveOrder(entries: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const entry of entries) { + const normalized = normalizeOptionalString(entry) ?? ""; + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + out.push(normalized); + } + return out; +} + +export function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean { + return !normalizedAccountId || normalizedAccountId === DEFAULT_ACCOUNT_ID; +} + +export function resolveAllowFromAccountId(accountId?: string): string { + return normalizeLowercaseStringOrEmpty(accountId) || DEFAULT_ACCOUNT_ID; +} + +function cloneAllowFromCacheEntry(entry: AllowFromReadCacheEntry): AllowFromReadCacheEntry { + return { + exists: entry.exists, + mtimeMs: entry.mtimeMs, + size: entry.size, + entries: entry.entries.slice(), + }; +} + +function resolveAllowFromCacheKey(cacheNamespace: string, filePath: string): string { + return `${cacheNamespace}\u0000${filePath}`; +} + +export function setAllowFromFileReadCache(params: { + cacheNamespace: string; + filePath: string; + entry: AllowFromReadCacheEntry; +}): void { + allowFromReadCache.set( + resolveAllowFromCacheKey(params.cacheNamespace, params.filePath), + cloneAllowFromCacheEntry(params.entry), + ); +} + +function resolveAllowFromReadCacheHit(params: { + cacheNamespace: string; + filePath: string; + exists: boolean; + mtimeMs: number | null; + size: number | null; +}): AllowFromReadCacheEntry | null { + const cached = allowFromReadCache.get( + resolveAllowFromCacheKey(params.cacheNamespace, params.filePath), + ); + if (!cached) { + return null; + } + if (cached.exists !== params.exists) { + return null; + } + if (!params.exists) { + return cloneAllowFromCacheEntry(cached); + } + if (cached.mtimeMs !== params.mtimeMs || cached.size !== params.size) { + return null; + } + return cloneAllowFromCacheEntry(cached); +} + +function resolveAllowFromReadCacheOrMissing(params: { + cacheNamespace: string; + filePath: string; + stat: AllowFromStatLike; +}): { entries: string[]; exists: boolean } | null { + const cached = resolveAllowFromReadCacheHit({ + cacheNamespace: params.cacheNamespace, + filePath: params.filePath, + exists: Boolean(params.stat), + mtimeMs: params.stat?.mtimeMs ?? null, + size: params.stat?.size ?? null, + }); + if (cached) { + return { entries: cached.entries, exists: cached.exists }; + } + if (!params.stat) { + setAllowFromFileReadCache({ + cacheNamespace: params.cacheNamespace, + filePath: params.filePath, + entry: { + exists: false, + mtimeMs: null, + size: null, + entries: [], + }, + }); + return { entries: [], exists: false }; + } + return null; +} + +export async function readAllowFromFileWithExists(params: { + cacheNamespace: string; + filePath: string; + normalizeStore: NormalizeAllowFromStore; +}): Promise<{ entries: string[]; exists: boolean }> { + let stat: Awaited> | null = null; + try { + stat = await fs.promises.stat(params.filePath); + } catch (err) { + const code = (err as { code?: string }).code; + if (code !== "ENOENT") { + throw err; + } + } + + const cachedOrMissing = resolveAllowFromReadCacheOrMissing({ + cacheNamespace: params.cacheNamespace, + filePath: params.filePath, + stat, + }); + if (cachedOrMissing) { + return cachedOrMissing; + } + if (!stat) { + return { entries: [], exists: false }; + } + + const { value, exists } = await readJsonFileWithFallback(params.filePath, { + version: 1, + allowFrom: [], + }); + const entries = params.normalizeStore(value); + setAllowFromFileReadCache({ + cacheNamespace: params.cacheNamespace, + filePath: params.filePath, + entry: { + exists, + mtimeMs: stat.mtimeMs, + size: stat.size, + entries, + }, + }); + return { entries, exists }; +} + +export function readAllowFromFileSyncWithExists(params: { + cacheNamespace: string; + filePath: string; + normalizeStore: NormalizeAllowFromStore; +}): { entries: string[]; exists: boolean } { + let stat: fs.Stats | null = null; + try { + stat = fs.statSync(params.filePath); + } catch (err) { + const code = (err as { code?: string }).code; + if (code !== "ENOENT") { + return { entries: [], exists: false }; + } + } + + const cachedOrMissing = resolveAllowFromReadCacheOrMissing({ + cacheNamespace: params.cacheNamespace, + filePath: params.filePath, + stat, + }); + if (cachedOrMissing) { + return cachedOrMissing; + } + if (!stat) { + return { entries: [], exists: false }; + } + + let raw = ""; + try { + raw = fs.readFileSync(params.filePath, "utf8"); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return { entries: [], exists: false }; + } + return { entries: [], exists: false }; + } + + try { + const parsed = JSON.parse(raw) as AllowFromStore; + const entries = params.normalizeStore(parsed); + setAllowFromFileReadCache({ + cacheNamespace: params.cacheNamespace, + filePath: params.filePath, + entry: { + exists: true, + mtimeMs: stat.mtimeMs, + size: stat.size, + entries, + }, + }); + return { entries, exists: true }; + } catch { + setAllowFromFileReadCache({ + cacheNamespace: params.cacheNamespace, + filePath: params.filePath, + entry: { + exists: true, + mtimeMs: stat.mtimeMs, + size: stat.size, + entries: [], + }, + }); + return { entries: [], exists: true }; + } +} + +export function clearAllowFromFileReadCacheForNamespace(cacheNamespace: string): void { + for (const key of allowFromReadCache.keys()) { + if (key.startsWith(`${cacheNamespace}\u0000`)) { + allowFromReadCache.delete(key); + } + } +} diff --git a/src/pairing/allow-from-store-read.ts b/src/pairing/allow-from-store-read.ts index 45dce8dac0c..cf00de8be12 100644 --- a/src/pairing/allow-from-store-read.ts +++ b/src/pairing/allow-from-store-read.ts @@ -1,90 +1,17 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; -import { resolveRequiredHomeDir } from "../infra/home-dir.js"; -import { readJsonFileWithFallback } from "../plugin-sdk/json-store.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "../shared/string-coerce.js"; + clearAllowFromFileReadCacheForNamespace, + dedupePreserveOrder, + readAllowFromFileSyncWithExists, + readAllowFromFileWithExists, + resolveAllowFromAccountId, + resolveAllowFromFilePath, + shouldIncludeLegacyAllowFromEntries, + type AllowFromStore, +} from "./allow-from-store-file.js"; import type { PairingChannel } from "./pairing-store.types.js"; -type AllowFromStore = { - version: 1; - allowFrom: string[]; -}; - -type AllowFromReadCacheEntry = { - exists: boolean; - mtimeMs: number | null; - size: number | null; - entries: string[]; -}; - -type AllowFromStatLike = { mtimeMs: number; size: number } | null; - -const allowFromReadCache = new Map(); - -function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { - const stateDir = resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir)); - return resolveOAuthDir(env, stateDir); -} - -function safeChannelKey(channel: PairingChannel): string { - const raw = normalizeLowercaseStringOrEmpty(String(channel)); - if (!raw) { - throw new Error("invalid pairing channel"); - } - const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); - if (!safe || safe === "_") { - throw new Error("invalid pairing channel"); - } - return safe; -} - -function safeAccountKey(accountId: string): string { - const raw = normalizeLowercaseStringOrEmpty(accountId); - if (!raw) { - throw new Error("invalid pairing account id"); - } - const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); - if (!safe || safe === "_") { - throw new Error("invalid pairing account id"); - } - return safe; -} - -function resolveAllowFromPath( - channel: PairingChannel, - env: NodeJS.ProcessEnv = process.env, - accountId?: string, -): string { - const base = safeChannelKey(channel); - const normalizedAccountId = normalizeOptionalString(accountId) ?? ""; - if (!normalizedAccountId) { - return path.join(resolveCredentialsDir(env), `${base}-allowFrom.json`); - } - return path.join( - resolveCredentialsDir(env), - `${base}-${safeAccountKey(normalizedAccountId)}-allowFrom.json`, - ); -} - -function dedupePreserveOrder(entries: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const entry of entries) { - const normalized = normalizeOptionalString(entry) ?? ""; - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - out.push(normalized); - } - return out; -} +const ALLOW_FROM_STORE_READ_CACHE_NAMESPACE = "allow-from-store-read"; function normalizeRawAllowFromList(store: AllowFromStore): string[] { const list = Array.isArray(store.allowFrom) ? store.allowFrom : []; @@ -93,161 +20,25 @@ function normalizeRawAllowFromList(store: AllowFromStore): string[] { ); } -function cloneAllowFromCacheEntry(entry: AllowFromReadCacheEntry): AllowFromReadCacheEntry { - return { - exists: entry.exists, - mtimeMs: entry.mtimeMs, - size: entry.size, - entries: entry.entries.slice(), - }; -} - -function setAllowFromReadCache(filePath: string, entry: AllowFromReadCacheEntry): void { - allowFromReadCache.set(filePath, cloneAllowFromCacheEntry(entry)); -} - -function resolveAllowFromReadCacheHit(params: { - filePath: string; - exists: boolean; - mtimeMs: number | null; - size: number | null; -}): AllowFromReadCacheEntry | null { - const cached = allowFromReadCache.get(params.filePath); - if (!cached) { - return null; - } - if (cached.exists !== params.exists) { - return null; - } - if (!params.exists) { - return cloneAllowFromCacheEntry(cached); - } - if (cached.mtimeMs !== params.mtimeMs || cached.size !== params.size) { - return null; - } - return cloneAllowFromCacheEntry(cached); -} - -function resolveAllowFromReadCacheOrMissing( - filePath: string, - stat: AllowFromStatLike, -): { entries: string[]; exists: boolean } | null { - const cached = resolveAllowFromReadCacheHit({ - filePath, - exists: Boolean(stat), - mtimeMs: stat?.mtimeMs ?? null, - size: stat?.size ?? null, - }); - if (cached) { - return { entries: cached.entries, exists: cached.exists }; - } - if (!stat) { - setAllowFromReadCache(filePath, { - exists: false, - mtimeMs: null, - size: null, - entries: [], - }); - return { entries: [], exists: false }; - } - return null; -} - async function readAllowFromEntriesForPathWithExists( filePath: string, ): Promise<{ entries: string[]; exists: boolean }> { - let stat: Awaited> | null = null; - try { - stat = await fs.promises.stat(filePath); - } catch (err) { - const code = (err as { code?: string }).code; - if (code !== "ENOENT") { - throw err; - } - } - - const cachedOrMissing = resolveAllowFromReadCacheOrMissing(filePath, stat); - if (cachedOrMissing) { - return cachedOrMissing; - } - if (!stat) { - return { entries: [], exists: false }; - } - - const { value, exists } = await readJsonFileWithFallback(filePath, { - version: 1, - allowFrom: [], + return await readAllowFromFileWithExists({ + cacheNamespace: ALLOW_FROM_STORE_READ_CACHE_NAMESPACE, + filePath, + normalizeStore: normalizeRawAllowFromList, }); - const entries = normalizeRawAllowFromList(value); - setAllowFromReadCache(filePath, { - exists, - mtimeMs: stat.mtimeMs, - size: stat.size, - entries, - }); - return { entries, exists }; } function readAllowFromEntriesForPathSyncWithExists(filePath: string): { entries: string[]; exists: boolean; } { - let stat: fs.Stats | null = null; - try { - stat = fs.statSync(filePath); - } catch (err) { - const code = (err as { code?: string }).code; - if (code !== "ENOENT") { - return { entries: [], exists: false }; - } - } - - const cachedOrMissing = resolveAllowFromReadCacheOrMissing(filePath, stat); - if (cachedOrMissing) { - return cachedOrMissing; - } - if (!stat) { - return { entries: [], exists: false }; - } - - let raw = ""; - try { - raw = fs.readFileSync(filePath, "utf8"); - } catch (err) { - const code = (err as { code?: string }).code; - if (code === "ENOENT") { - return { entries: [], exists: false }; - } - return { entries: [], exists: false }; - } - - try { - const parsed = JSON.parse(raw) as AllowFromStore; - const entries = normalizeRawAllowFromList(parsed); - setAllowFromReadCache(filePath, { - exists: true, - mtimeMs: stat.mtimeMs, - size: stat.size, - entries, - }); - return { entries, exists: true }; - } catch { - setAllowFromReadCache(filePath, { - exists: true, - mtimeMs: stat.mtimeMs, - size: stat.size, - entries: [], - }); - return { entries: [], exists: true }; - } -} - -function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean { - return !normalizedAccountId || normalizedAccountId === DEFAULT_ACCOUNT_ID; -} - -function resolveAllowFromAccountId(accountId?: string): string { - return normalizeLowercaseStringOrEmpty(accountId) || DEFAULT_ACCOUNT_ID; + return readAllowFromFileSyncWithExists({ + cacheNamespace: ALLOW_FROM_STORE_READ_CACHE_NAMESPACE, + filePath, + normalizeStore: normalizeRawAllowFromList, + }); } export function resolveChannelAllowFromPath( @@ -255,14 +46,14 @@ export function resolveChannelAllowFromPath( env: NodeJS.ProcessEnv = process.env, accountId?: string, ): string { - return resolveAllowFromPath(channel, env, accountId); + return resolveAllowFromFilePath(channel, env, accountId); } export async function readLegacyChannelAllowFromStoreEntries( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): Promise { - const filePath = resolveAllowFromPath(channel, env); + const filePath = resolveAllowFromFilePath(channel, env); return (await readAllowFromEntriesForPathWithExists(filePath)).entries; } @@ -275,17 +66,17 @@ export async function readChannelAllowFromStoreEntries( if (!shouldIncludeLegacyAllowFromEntries(resolvedAccountId)) { return ( await readAllowFromEntriesForPathWithExists( - resolveAllowFromPath(channel, env, resolvedAccountId), + resolveAllowFromFilePath(channel, env, resolvedAccountId), ) ).entries; } const scopedEntries = ( await readAllowFromEntriesForPathWithExists( - resolveAllowFromPath(channel, env, resolvedAccountId), + resolveAllowFromFilePath(channel, env, resolvedAccountId), ) ).entries; const legacyEntries = ( - await readAllowFromEntriesForPathWithExists(resolveAllowFromPath(channel, env)) + await readAllowFromEntriesForPathWithExists(resolveAllowFromFilePath(channel, env)) ).entries; return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); } @@ -294,7 +85,7 @@ export function readLegacyChannelAllowFromStoreEntriesSync( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): string[] { - return readAllowFromEntriesForPathSyncWithExists(resolveAllowFromPath(channel, env)).entries; + return readAllowFromEntriesForPathSyncWithExists(resolveAllowFromFilePath(channel, env)).entries; } export function readChannelAllowFromStoreEntriesSync( @@ -305,18 +96,18 @@ export function readChannelAllowFromStoreEntriesSync( const resolvedAccountId = resolveAllowFromAccountId(accountId); if (!shouldIncludeLegacyAllowFromEntries(resolvedAccountId)) { return readAllowFromEntriesForPathSyncWithExists( - resolveAllowFromPath(channel, env, resolvedAccountId), + resolveAllowFromFilePath(channel, env, resolvedAccountId), ).entries; } const scopedEntries = readAllowFromEntriesForPathSyncWithExists( - resolveAllowFromPath(channel, env, resolvedAccountId), + resolveAllowFromFilePath(channel, env, resolvedAccountId), ).entries; const legacyEntries = readAllowFromEntriesForPathSyncWithExists( - resolveAllowFromPath(channel, env), + resolveAllowFromFilePath(channel, env), ).entries; return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); } export function clearAllowFromStoreReadCacheForTest(): void { - allowFromReadCache.clear(); + clearAllowFromFileReadCacheForNamespace(ALLOW_FROM_STORE_READ_CACHE_NAMESPACE); } diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index f1951121793..d7e6c3a17dd 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -1,12 +1,9 @@ import crypto from "node:crypto"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { getPairingAdapter } from "../channels/plugins/pairing.js"; import type { ChannelPairingAdapter } from "../channels/plugins/pairing.types.js"; -import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import { withFileLock as withPathLock } from "../infra/file-lock.js"; -import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { readJsonFileWithFallback, writeJsonFileAtomically } from "../plugin-sdk/json-store.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { @@ -15,6 +12,19 @@ import { normalizeOptionalString, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; +import { + clearAllowFromFileReadCacheForNamespace, + dedupePreserveOrder, + readAllowFromFileSyncWithExists, + readAllowFromFileWithExists, + resolveAllowFromAccountId, + resolveAllowFromFilePath, + resolvePairingCredentialsDir, + safeChannelKey, + setAllowFromFileReadCache, + shouldIncludeLegacyAllowFromEntries, + type AllowFromStore, +} from "./allow-from-store-file.js"; import type { PairingChannel } from "./pairing-store.types.js"; export type { PairingChannel } from "./pairing-store.types.js"; @@ -32,15 +42,7 @@ const PAIRING_STORE_LOCK_OPTIONS = { }, stale: 30_000, } as const; -type AllowFromReadCacheEntry = { - exists: boolean; - mtimeMs: number | null; - size: number | null; - entries: string[]; -}; -type AllowFromStatLike = { mtimeMs: number; size: number } | null; - -const allowFromReadCache = new Map(); +const PAIRING_ALLOW_FROM_CACHE_NAMESPACE = "pairing-store"; export type PairingRequest = { id: string; @@ -55,59 +57,8 @@ type PairingStore = { requests: PairingRequest[]; }; -type AllowFromStore = { - version: 1; - allowFrom: string[]; -}; - -function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { - const stateDir = resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir)); - return resolveOAuthDir(env, stateDir); -} - -/** Sanitize channel ID for use in filenames (prevent path traversal). */ -function safeChannelKey(channel: PairingChannel): string { - const raw = normalizeLowercaseStringOrEmpty(String(channel)); - if (!raw) { - throw new Error("invalid pairing channel"); - } - const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); - if (!safe || safe === "_") { - throw new Error("invalid pairing channel"); - } - return safe; -} - function resolvePairingPath(channel: PairingChannel, env: NodeJS.ProcessEnv = process.env): string { - return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`); -} - -function safeAccountKey(accountId: string): string { - const raw = normalizeLowercaseStringOrEmpty(accountId); - if (!raw) { - throw new Error("invalid pairing account id"); - } - const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_"); - if (!safe || safe === "_") { - throw new Error("invalid pairing account id"); - } - return safe; -} - -function resolveAllowFromPath( - channel: PairingChannel, - env: NodeJS.ProcessEnv = process.env, - accountId?: string, -): string { - const base = safeChannelKey(channel); - const normalizedAccountId = normalizeOptionalString(accountId) ?? ""; - if (!normalizedAccountId) { - return path.join(resolveCredentialsDir(env), `${base}-allowFrom.json`); - } - return path.join( - resolveCredentialsDir(env), - `${base}-${safeAccountKey(normalizedAccountId)}-allowFrom.json`, - ); + return path.join(resolvePairingCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`); } export function resolveChannelAllowFromPath( @@ -115,7 +66,7 @@ export function resolveChannelAllowFromPath( env: NodeJS.ProcessEnv = process.env, accountId?: string, ): string { - return resolveAllowFromPath(channel, env, accountId); + return resolveAllowFromFilePath(channel, env, accountId); } async function readJsonFile( @@ -270,16 +221,6 @@ function requestMatchesAccountId(entry: PairingRequest, normalizedAccountId: str return resolvePairingRequestAccountId(entry) === normalizedAccountId; } -function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean { - // Keep backward compatibility for legacy channel-scoped allowFrom only on default account. - // Non-default accounts should remain isolated to avoid cross-account implicit approvals. - return !normalizedAccountId || normalizedAccountId === DEFAULT_ACCOUNT_ID; -} - -function resolveAllowFromAccountId(accountId?: string): string { - return normalizePairingAccountId(accountId) || DEFAULT_ACCOUNT_ID; -} - function normalizeId(value: string | number): string { return normalizeStringifiedOptionalString(value) ?? ""; } @@ -306,20 +247,6 @@ function normalizeAllowFromInput(channel: PairingChannel, entry: string | number return normalizeAllowEntry(channel, normalizeId(entry)); } -function dedupePreserveOrder(entries: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const entry of entries) { - const normalized = normalizeOptionalString(entry) ?? ""; - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - out.push(normalized); - } - return out; -} - async function readAllowFromStateForPath( channel: PairingChannel, filePath: string, @@ -327,101 +254,15 @@ async function readAllowFromStateForPath( return (await readAllowFromStateForPathWithExists(channel, filePath)).entries; } -function cloneAllowFromCacheEntry(entry: AllowFromReadCacheEntry): AllowFromReadCacheEntry { - return { - exists: entry.exists, - mtimeMs: entry.mtimeMs, - size: entry.size, - entries: entry.entries.slice(), - }; -} - -function setAllowFromReadCache(filePath: string, entry: AllowFromReadCacheEntry): void { - allowFromReadCache.set(filePath, cloneAllowFromCacheEntry(entry)); -} - -function resolveAllowFromReadCacheHit(params: { - filePath: string; - exists: boolean; - mtimeMs: number | null; - size: number | null; -}): AllowFromReadCacheEntry | null { - const cached = allowFromReadCache.get(params.filePath); - if (!cached) { - return null; - } - if (cached.exists !== params.exists) { - return null; - } - if (!params.exists) { - return cloneAllowFromCacheEntry(cached); - } - if (cached.mtimeMs !== params.mtimeMs || cached.size !== params.size) { - return null; - } - return cloneAllowFromCacheEntry(cached); -} - -function resolveAllowFromReadCacheOrMissing( - filePath: string, - stat: AllowFromStatLike, -): { entries: string[]; exists: boolean } | null { - const cached = resolveAllowFromReadCacheHit({ - filePath, - exists: Boolean(stat), - mtimeMs: stat?.mtimeMs ?? null, - size: stat?.size ?? null, - }); - if (cached) { - return { entries: cached.entries, exists: cached.exists }; - } - if (!stat) { - setAllowFromReadCache(filePath, { - exists: false, - mtimeMs: null, - size: null, - entries: [], - }); - return { entries: [], exists: false }; - } - return null; -} - async function readAllowFromStateForPathWithExists( channel: PairingChannel, filePath: string, ): Promise<{ entries: string[]; exists: boolean }> { - let stat: Awaited> | null = null; - try { - stat = await fs.promises.stat(filePath); - } catch (err) { - const code = (err as { code?: string }).code; - if (code !== "ENOENT") { - throw err; - } - } - - const cachedOrMissing = resolveAllowFromReadCacheOrMissing(filePath, stat); - if (cachedOrMissing) { - return cachedOrMissing; - } - if (!stat) { - return { entries: [], exists: false }; - } - - const { value, exists } = await readJsonFile(filePath, { - version: 1, - allowFrom: [], + return await readAllowFromFileWithExists({ + cacheNamespace: PAIRING_ALLOW_FROM_CACHE_NAMESPACE, + filePath, + normalizeStore: (store) => normalizeAllowFromList(channel, store), }); - const entries = normalizeAllowFromList(channel, value); - // stat is guaranteed non-null here: resolveAllowFromReadCacheOrMissing returns early when stat is null. - setAllowFromReadCache(filePath, { - exists, - mtimeMs: stat.mtimeMs, - size: stat.size, - entries, - }); - return { entries, exists }; } function readAllowFromStateForPathSync(channel: PairingChannel, filePath: string): string[] { @@ -432,55 +273,11 @@ function readAllowFromStateForPathSyncWithExists( channel: PairingChannel, filePath: string, ): { entries: string[]; exists: boolean } { - let stat: fs.Stats | null = null; - try { - stat = fs.statSync(filePath); - } catch (err) { - const code = (err as { code?: string }).code; - if (code !== "ENOENT") { - return { entries: [], exists: false }; - } - } - - const cachedOrMissing = resolveAllowFromReadCacheOrMissing(filePath, stat); - if (cachedOrMissing) { - return cachedOrMissing; - } - if (!stat) { - return { entries: [], exists: false }; - } - - let raw = ""; - try { - raw = fs.readFileSync(filePath, "utf8"); - } catch (err) { - const code = (err as { code?: string }).code; - if (code === "ENOENT") { - return { entries: [], exists: false }; - } - return { entries: [], exists: false }; - } - // stat is guaranteed non-null here: resolveAllowFromReadCacheOrMissing returns early when stat is null. - try { - const parsed = JSON.parse(raw) as AllowFromStore; - const entries = normalizeAllowFromList(channel, parsed); - setAllowFromReadCache(filePath, { - exists: true, - mtimeMs: stat.mtimeMs, - size: stat.size, - entries, - }); - return { entries, exists: true }; - } catch { - // Keep parity with async reads: malformed JSON still means the file exists. - setAllowFromReadCache(filePath, { - exists: true, - mtimeMs: stat.mtimeMs, - size: stat.size, - entries: [], - }); - return { entries: [], exists: true }; - } + return readAllowFromFileSyncWithExists({ + cacheNamespace: PAIRING_ALLOW_FROM_CACHE_NAMESPACE, + filePath, + normalizeStore: (store) => normalizeAllowFromList(channel, store), + }); } async function readAllowFromState(params: { @@ -506,11 +303,15 @@ async function writeAllowFromState(filePath: string, allowFrom: string[]): Promi try { stat = await fs.promises.stat(filePath); } catch {} - setAllowFromReadCache(filePath, { - exists: true, - mtimeMs: stat?.mtimeMs ?? null, - size: stat?.size ?? null, - entries: allowFrom.slice(), + setAllowFromFileReadCache({ + cacheNamespace: PAIRING_ALLOW_FROM_CACHE_NAMESPACE, + filePath, + entry: { + exists: true, + mtimeMs: stat?.mtimeMs ?? null, + size: stat?.size ?? null, + entries: allowFrom.slice(), + }, }); } @@ -519,7 +320,7 @@ async function readNonDefaultAccountAllowFrom(params: { env: NodeJS.ProcessEnv; accountId: string; }): Promise { - const scopedPath = resolveAllowFromPath(params.channel, params.env, params.accountId); + const scopedPath = resolveAllowFromFilePath(params.channel, params.env, params.accountId); return await readAllowFromStateForPath(params.channel, scopedPath); } @@ -528,7 +329,7 @@ function readNonDefaultAccountAllowFromSync(params: { env: NodeJS.ProcessEnv; accountId: string; }): string[] { - const scopedPath = resolveAllowFromPath(params.channel, params.env, params.accountId); + const scopedPath = resolveAllowFromFilePath(params.channel, params.env, params.accountId); return readAllowFromStateForPathSync(params.channel, scopedPath); } @@ -540,7 +341,7 @@ async function updateAllowFromStoreEntry(params: { apply: (current: string[], normalized: string) => string[] | null; }): Promise<{ changed: boolean; allowFrom: string[] }> { const env = params.env ?? process.env; - const filePath = resolveAllowFromPath(params.channel, env, params.accountId); + const filePath = resolveAllowFromFilePath(params.channel, env, params.accountId); return await withFileLock( filePath, { version: 1, allowFrom: [] } satisfies AllowFromStore, @@ -567,7 +368,7 @@ export async function readLegacyChannelAllowFromStore( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): Promise { - const filePath = resolveAllowFromPath(channel, env); + const filePath = resolveAllowFromFilePath(channel, env); return await readAllowFromStateForPath(channel, filePath); } @@ -585,11 +386,11 @@ export async function readChannelAllowFromStore( accountId: resolvedAccountId, }); } - const scopedPath = resolveAllowFromPath(channel, env, resolvedAccountId); + const scopedPath = resolveAllowFromFilePath(channel, env, resolvedAccountId); const scopedEntries = await readAllowFromStateForPath(channel, scopedPath); // Backward compatibility: legacy channel-level allowFrom store was unscoped. // Keep honoring it for default account to prevent re-pair prompts after upgrades. - const legacyPath = resolveAllowFromPath(channel, env); + const legacyPath = resolveAllowFromFilePath(channel, env); const legacyEntries = await readAllowFromStateForPath(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); } @@ -598,7 +399,7 @@ export function readLegacyChannelAllowFromStoreSync( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): string[] { - const filePath = resolveAllowFromPath(channel, env); + const filePath = resolveAllowFromFilePath(channel, env); return readAllowFromStateForPathSync(channel, filePath); } @@ -616,15 +417,15 @@ export function readChannelAllowFromStoreSync( accountId: resolvedAccountId, }); } - const scopedPath = resolveAllowFromPath(channel, env, resolvedAccountId); + const scopedPath = resolveAllowFromFilePath(channel, env, resolvedAccountId); const scopedEntries = readAllowFromStateForPathSync(channel, scopedPath); - const legacyPath = resolveAllowFromPath(channel, env); + const legacyPath = resolveAllowFromFilePath(channel, env); const legacyEntries = readAllowFromStateForPathSync(channel, legacyPath); return dedupePreserveOrder([...scopedEntries, ...legacyEntries]); } export function clearPairingAllowFromReadCacheForTest(): void { - allowFromReadCache.clear(); + clearAllowFromFileReadCacheForNamespace(PAIRING_ALLOW_FROM_CACHE_NAMESPACE); } type AllowFromStoreEntryUpdateParams = {