import * as fs from "fs"; import * as path from "path"; import { getTemporaryDirectory } from "./actions-util"; import * as json from "./json"; /** * The name of the temporary file that backs the on-disk cache of * CLI responses between workflow steps. */ const COMMAND_CACHE_FILENAME = "codeql-action-command-cache.json"; /** The keys for cached CLI commands. */ export enum CommandCacheKey { Version = "version", ResolveLanguages = "resolveLanguages", } /** A single cached command output together with the CLI path it came from. */ interface CacheEntry { /** * The path to the CodeQL CLI that produced `output`. Persisted so that a * different step using a different CodeQL bundle doesn't pick up a stale * value. */ cmd: string; output: unknown; } /** * Tier 1: the in-process memo. Consulted first on every lookup and populated * whenever a value is read from the file (tier 2) or computed via the CLI * (tier 3). */ const inMemoryCache = new Map(); function getCommandCacheFilePath(): string { return path.join(getTemporaryDirectory(), COMMAND_CACHE_FILENAME); } /** * Reads and parses the temporary cache file. Best-effort: a missing, malformed, * or otherwise unreadable file is treated as an empty cache. */ function readCommandCacheFile(): Record { let contents: string; try { contents = fs.readFileSync(getCommandCacheFilePath(), "utf8"); } catch { return {}; } try { const parsed = json.parseString(contents); if (json.isObject(parsed)) { return parsed; } } catch { // Fall through and treat a malformed file as empty. } return {}; } /** * Persists the cache to the temporary file. Best-effort: a failure to write * just means a later step re-runs the CLI. */ function writeCommandCacheFile(data: Record): void { try { fs.writeFileSync(getCommandCacheFilePath(), JSON.stringify(data)); } catch { // Best-effort; ignore write failures. } } /** * Stores the output of a command under `key`, writing it to both the in-memory * memo (tier 1) and the temporary file (tier 2). * * Should only be called once per key within a single process; doing otherwise * indicates a logic error, since a value that has already been cached should be * served from the memo rather than recomputed. */ export function cacheCommandOutput( key: string, cmd: string, output: unknown, ): void { if (inMemoryCache.has(key)) { throw new Error( `cacheCommandOutput() should be called only once per key, but was called more than once for '${key}'.`, ); } const entry: CacheEntry = { cmd, output }; inMemoryCache.set(key, entry); const data = readCommandCacheFile(); data[key] = entry; writeCommandCacheFile(data); } /** * Returns the cached output for `key`, or `undefined` if it isn't cached. * * Resolves tier 1 (in-memory memo) first, then tier 2 (temporary file). A value * loaded from the file is ignored unless its `cmd` matches the optional `cmd` * argument, and it satisfies the optional `validate` type guard; valid values * are memoized into tier 1 before being returned. * * A return value of `undefined` signals the caller to fall back to tier 3 (the * CLI). */ export function getCachedCommandOutput( key: string, cmd?: string, validate?: (output: unknown) => output is T, ): T | undefined { // Tier 1: the in-memory variable. const memoized = inMemoryCache.get(key); if (memoized !== undefined) { return memoized.output as T; } // Tier 2: the temporary file persisted by an earlier step, if any. const entry = readCommandCacheFile()[key] as unknown; if ( !json.isObject(entry) || !json.isString(entry.cmd) || (cmd !== undefined && entry.cmd !== cmd) || (validate !== undefined && !validate(entry.output)) ) { return undefined; } // Memoize so subsequent lookups in this process hit tier 1. inMemoryCache.set(key, { cmd: entry.cmd, output: entry.output }); return entry.output as T; } /** * Clears the in-process memo (tier 1). Only for use in tests, which exercise * multiple "steps" within a single process. */ export function resetCachedCommandOutputs(): void { inMemoryCache.clear(); }