mirror of
https://github.com/pnpm/action-setup.git
synced 2026-08-13 16:01:31 +00:00
e6cb65ab2f
Saving in the post step left the whole job between the install and the upload. Anything running in that window — the job's tests, its build, a dependency's own install scripts — can rewrite the log on disk, and the job's own cache write would then publish a record claiming some other lockfile passed verification, for every later job to restore and trust. No cache credentials needed: the attacker rides the write the job performs anyway. The log is complete the moment the install finishes, so it is uploaded there. The post step still covers a job that installs in a step of its own, where that is the first point the log is known to be final; the save is idempotent across the two, and the process-local flags exist because main and post do not share state within a run.
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
import { restoreCache, saveCache } from '@actions/cache'
|
|
import { debug, getState, info, saveState, warning } from '@actions/core'
|
|
import { getExecOutput } from '@actions/exec'
|
|
import { existsSync } from 'fs'
|
|
import os from 'os'
|
|
import path from 'path'
|
|
import { removeWindowsExtendedPathPrefix } from '../windows-path'
|
|
|
|
/**
|
|
* Where pnpm v11+ memoizes which lockfile passed which supply-chain policies.
|
|
* A job without it re-checks every lockfile entry against the registry, which
|
|
* on a large repository costs more than the install.
|
|
*/
|
|
const VERIFICATION_CACHE_FILE = 'lockfile-verified.jsonl'
|
|
|
|
const PATH_STATE = 'lockfile_verification_cache_path'
|
|
const KEY_STATE = 'lockfile_verification_cache_key'
|
|
const STORED_STATE = 'lockfile_verification_cache_stored'
|
|
|
|
/**
|
|
* Where the log lives and under which key it belongs in the cache. Held in
|
|
* memory as well as in the action's state because the main and post steps run
|
|
* as separate processes, and state written by one is only readable by the
|
|
* other.
|
|
*/
|
|
let target: { cacheFilePath: string, key: string } | undefined
|
|
|
|
/** Whether this process already restored or saved the log. */
|
|
let stored = false
|
|
|
|
/**
|
|
* The verdict is only valid for the exact lockfile content it was recorded
|
|
* for, so this cache is keyed on the same lockfile hash as the store cache
|
|
* but restored without prefix fallback: an older entry could never be used.
|
|
*/
|
|
export async function restoreVerificationCache(lockfileHash: string): Promise<void> {
|
|
try {
|
|
const cacheFilePath = path.join(await getPnpmCacheDirectory(), VERIFICATION_CACHE_FILE)
|
|
const key = `pnpm-lockfile-verified-${process.env.RUNNER_OS}-${os.arch()}-${lockfileHash}`
|
|
target = { cacheFilePath, key }
|
|
saveState(PATH_STATE, cacheFilePath)
|
|
saveState(KEY_STATE, key)
|
|
debug(`Lockfile verification cache path is ${cacheFilePath}, key is ${key}`)
|
|
|
|
const restoredKey = await restoreCache([cacheFilePath], key)
|
|
if (!restoredKey) {
|
|
info('Lockfile verification cache is not found')
|
|
return
|
|
}
|
|
|
|
stored = true
|
|
saveState(STORED_STATE, 'true')
|
|
info(`Lockfile verification cache restored from key: ${restoredKey}`)
|
|
} catch (error) {
|
|
// The gate only costs time, never correctness — a job that cannot reuse
|
|
// a past verdict re-verifies and moves on.
|
|
warning(`Failed to restore the lockfile verification cache: ${(error as Error).message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploaded as soon as the install that produced the log finishes, rather than
|
|
* at the end of the job: whatever a job runs after installing — its tests, its
|
|
* build, a dependency's own scripts — can rewrite the log on disk, and the
|
|
* job's own cache write would then publish that for later jobs to trust.
|
|
*
|
|
* Safe to call more than once; the second call is a no-op.
|
|
*/
|
|
export async function saveVerificationCache(): Promise<void> {
|
|
if (stored || getState(STORED_STATE) === 'true') return
|
|
|
|
const cacheFilePath = target?.cacheFilePath ?? getState(PATH_STATE)
|
|
const key = target?.key ?? getState(KEY_STATE)
|
|
if (!cacheFilePath || !key || !existsSync(cacheFilePath)) return
|
|
|
|
try {
|
|
const cacheId = await saveCache([cacheFilePath], key)
|
|
if (cacheId === -1) return
|
|
stored = true
|
|
saveState(STORED_STATE, 'true')
|
|
info(`Lockfile verification cache saved with the key: ${key}`)
|
|
} catch (error) {
|
|
warning(`Failed to save the lockfile verification cache: ${(error as Error).message}`)
|
|
}
|
|
}
|
|
|
|
async function getPnpmCacheDirectory(): Promise<string> {
|
|
const { stdout } = await getExecOutput('pnpm config get cacheDir', undefined, {
|
|
silent: true,
|
|
ignoreReturnCode: true,
|
|
})
|
|
const configured = stdout.trim()
|
|
// `pnpm config get` reports settings, not defaults: an unset `cacheDir`
|
|
// prints `undefined` and the default has to be derived here.
|
|
if (configured && configured !== 'undefined') {
|
|
return removeWindowsExtendedPathPrefix(configured)
|
|
}
|
|
return defaultPnpmCacheDirectory()
|
|
}
|
|
|
|
/** Mirrors pnpm's own `cacheDir` default. */
|
|
function defaultPnpmCacheDirectory(): string {
|
|
const { XDG_CACHE_HOME, LOCALAPPDATA } = process.env
|
|
if (XDG_CACHE_HOME) return path.join(XDG_CACHE_HOME, 'pnpm')
|
|
|
|
const homeDir = os.homedir()
|
|
switch (process.platform) {
|
|
case 'darwin':
|
|
return path.join(homeDir, 'Library', 'Caches', 'pnpm')
|
|
case 'win32':
|
|
return LOCALAPPDATA ? path.join(LOCALAPPDATA, 'pnpm-cache') : path.join(homeDir, '.pnpm-cache')
|
|
default:
|
|
return path.join(homeDir, '.cache', 'pnpm')
|
|
}
|
|
}
|