feat: add path validation options to restore action

This commit is contained in:
Jason Ginchereau 2026-05-18 12:28:44 -10:00
parent 27d5ce7f10
commit dabc4c2ca1
25 changed files with 201047 additions and 164350 deletions

View file

@ -0,0 +1,239 @@
# Path Validation Test Plan (`actions/cache@v5.1+`)
This document describes the test coverage for the client-side path-validation
feature added to `actions/cache` v5.1.0. The validation feature itself lives
in the `@actions/cache` toolkit (v6.1.0+); this action wires the new
`strict-paths` and `fail-on-cache-invalid` inputs through to the toolkit and
handles the `CacheIntegrityError` it can throw.
The companion document in the toolkit repo
([`packages/cache/docs/path-validation-test-plan.md`](../../actions-toolkit/packages/cache/docs/path-validation-test-plan.md))
covers the **behavioral** tests for the validation engine itself. This document
focuses on the **action-layer** wiring tests.
## Summary
| Layer | Test file | Tests | What it validates |
| ------------------------------------ | -------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------ |
| Input parsing | `__tests__/actionUtils.test.ts` | 9 new | `getPathValidationInput()` normalizes input values, defaults to `warn`, warns on unknown values |
| restoreImpl integration | `__tests__/restoreImpl.test.ts` | 9 new | Forwards `strict-paths` to `cache.restoreCache`, handles `CacheIntegrityError` per `fail-on-cache-invalid` |
| Regression — existing forward tests | `__tests__/restoreImpl.test.ts` (updated) | 11 | Existing tests now assert the `pathValidation` field is present in the options object passed to `cache.restoreCache`|
| End-to-end (good cache) | `.github/workflows/path-validation-e2e.yml` | 18 | A legitimate cache restores successfully under all three `strict-paths` modes on every supported OS |
| End-to-end (poisoned cache) | `.github/workflows/path-validation-e2e.yml` | 18 | A poisoned cache is treated correctly per mode (extracted in `off`/`warn`, rejected in `error`) |
| End-to-end (fail-on-cache-invalid) | `.github/workflows/path-validation-e2e.yml` | 3 | When `fail-on-cache-invalid: true` and the cache is rejected, the restore step itself fails the workflow |
Total: **77 new/updated tests** at the action layer (29 unit + 39 E2E job runs + 9 input-parsing).
## Unit tests
### `__tests__/actionUtils.test.ts``getPathValidationInput()` (9 new)
Verifies the input-parsing helper that the action's `restoreImpl` uses to
translate the `strict-paths` workflow input into the literal type expected by
the toolkit.
| Test | Asserts |
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| returns `'warn'` when input is unset | Default behavior matches the action.yml default |
| normalizes `off` / `warn` / `error` / `OFF` / `Warn` / `ERROR` | Case-insensitive parsing of the three valid values |
| falls back to `'warn'` for unrecognized values and logs a warning | Typos don't silently disable validation; user gets a workflow warning via `core.info` |
| treats empty string as default `'warn'` | Defensive default in case the workflow runner passes an empty string |
### `__tests__/restoreImpl.test.ts` — Path-validation wiring (9 new)
Verifies `restoreImpl` forwards the input to the toolkit and handles its
errors per the `fail-on-cache-invalid` input.
| Test | Asserts |
| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| defaults strict-paths to `'warn'` and forwards it to `restoreCache` | Default option object contains `pathValidation: 'warn'` |
| `test.each(['off', 'warn', 'error'])` forwards each value to `restoreCache` | All three valid values reach the toolkit unchanged |
| falls back to `'warn'` when strict-paths input is unrecognized | Unknown values are coerced to `'warn'` and a warning is logged |
| treats `CacheIntegrityError` as a cache miss by default | When the toolkit throws `CacheIntegrityError` and `fail-on-cache-invalid: false`, action sets `cache-hit=false` and continues |
| fails when `CacheIntegrityError` is raised and `fail-on-cache-invalid: true` | When `fail-on-cache-invalid: true`, `core.setFailed()` is called with a message containing `integrity validation` and the code |
| propagates non-integrity errors normally | Network/auth errors still surface via `core.setFailed()` rather than being mis-classified as integrity failures |
| `PARSE_ERROR` integrity failure also treated as miss by default | Validation handles both `PATH_VIOLATION` and `PARSE_ERROR` codes identically |
| tolerates `CacheIntegrityError` without explicit `.code` | If the toolkit ever omits a code, the action still degrades gracefully (logs `unknown`) |
| does not set `cache-hit` output when integrity error is rethrown | When `fail-on-cache-invalid: true`, no `cache-hit` output is set (preserves existing miss semantics for downstream `if:` checks) |
### Detection strategy for `CacheIntegrityError`
The action detects integrity errors by **name** (`err.name === 'CacheIntegrityError'`) rather than `instanceof`. This is intentional:
* The `@actions/cache` toolkit is shipped as ESM, while the action's runtime is
bundled by `ncc` into CJS. Cross-module-system `instanceof` checks are
fragile (different module realms, two copies of the class).
* The toolkit guarantees `name === 'CacheIntegrityError'` via the class
constructor.
* The toolkit also attaches a stable `code` property whose value is one of
`'PARSE_ERROR' | 'PATH_VIOLATION' | 'CHECKSUM_MISMATCH'`. The action surfaces
this code in the workflow log so users can diagnose rejections.
This approach is covered by the **tolerates `CacheIntegrityError` without
explicit `.code`** test — even if the toolkit changes the shape of its error,
the action continues to recognize and handle it.
## End-to-end workflow
[`.github/workflows/path-validation-e2e.yml`](../.github/workflows/path-validation-e2e.yml)
runs three jobs:
### 1. `good-cache` — legitimate cache restores correctly under all modes (18 runs)
Matrix: `[ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022]` × `[off, warn, error]`.
For each combination:
1. Generates a directory `path-validation-cache/` with 5 small files.
2. Saves it using `actions/cache@./` with a unique key.
3. Removes the local directory.
4. Restores using `actions/cache@./` with `strict-paths: ${{ matrix.strict-paths }}` and `fail-on-cache-invalid: true`.
5. Asserts `cache-hit == 'true'` and all 5 files are present.
This validates that **enabling path validation does not regress legitimate
caches** — a critical false-positive check on every supported platform and
both archive formats (gnu-tar on Linux/macOS, bsdtar on Windows).
### 2. `poisoned-cache` — poisoned cache is handled per mode (18 runs)
Same matrix. For each combination:
1. Generates a directory `path-validation-cache/` with one legitimate file.
2. Generates an `escape.txt` file **outside** the declared `path` (in the workspace root, one level up from `path-validation-cache/`).
3. Uses the toolkit's `saveCache()` directly (via `__tests__/e2e/save-poisoned-cache.mjs`) to upload a cache that includes BOTH paths. This produces an archive whose entries, when extracted relative to the declared `path` on restore, would write `escape.txt` outside the declared path.
4. Removes local files.
5. Restores via `actions/cache@./` declaring **only** the legitimate `path` (this is what an unsuspecting downstream consumer would do).
6. Asserts per-mode:
* **`off`**: `cache-hit == 'true'`, `escape.txt` IS present (validation disabled = legacy behavior).
* **`warn`**: `cache-hit == 'true'`, `escape.txt` IS present (validation does not block extraction in warn mode), and a workflow warning should be visible in the log.
* **`error`**: `cache-hit == 'false'`, NEITHER `escape.txt` NOR `path-validation-cache/legit.txt` exists (the archive was rejected before any extraction).
This validates the **false-negative** axis — the path-validation logic
correctly identifies a real cross-path entry on every supported OS.
### 3. `poisoned-cache-fail-on-invalid` — rejected cache fails the workflow when configured to (3 runs)
Matrix: `[ubuntu-latest, macos-latest, windows-latest]`.
Same setup as job 2, but with `fail-on-cache-invalid: true`. The restore step
itself is expected to **fail** (step `outcome == 'failure'`). The job uses
`continue-on-error: true` on the restore step so we can assert on its outcome
rather than the job failing.
This validates the workflow-fail path that strict security-conscious users
will enable in production pipelines.
## Manual tests
Before tagging a release, run the following manual sanity checks:
### Manual test 1: existing workflow regressions
In a workflow file that uses `actions/cache@<branch>` (no path-validation
inputs set), run a normal cache save + restore cycle. Verify:
* The action behaves identically to v5.0.x.
* No new warnings appear in the log for a clean cache.
* `cache-hit` output is set correctly.
### Manual test 2: warn-mode visibility
Configure a workflow that uses `actions/cache@<branch>` with
`strict-paths: warn` (the default in v5.1+). Confirm that:
* For a clean cache, no warning is logged.
* For a poisoned cache (use the E2E helper to create one), a single `::warning::`
annotation appears in the workflow summary, mentioning the path violation
and the offending entry.
### Manual test 3: error mode with fail-on-cache-invalid
Configure a workflow:
```yaml
- uses: actions/cache@<branch>
with:
key: ...
path: ./build
strict-paths: error
fail-on-cache-invalid: true
```
Trigger a restore of a poisoned cache. Confirm:
* The workflow fails at the cache restore step.
* The failure annotation includes the code (e.g., `PATH_VIOLATION`) and a
short message describing the violation.
### Manual test 4: cross-OS cache restore (`enableCrossOsArchive: true`)
Save a cache on Linux with one path, restore it on Windows with the same
path under each `strict-paths` value. Confirm validation behaves the same
way across the OS boundary (this exercises both `gnu-tar` archive
production and `bsdtar`-on-Windows extraction).
### Manual test 5: large real-world cache
Restore a realistic, multi-gigabyte cache (e.g., a `node_modules`
deep-dependency tree) with `strict-paths: warn`. Measure:
* No measurable regression in restore wall time.
* No false-positive warnings for paths containing `..` segments that resolve
to valid in-workspace locations (e.g., symlinks inside the cache).
## What's NOT tested here
* **The validation algorithm itself.** Comprehensive tests for path
resolution, glob expansion, env-var substitution, and parse-error
classification live in the toolkit repo
([`packages/cache/__tests__/pathValidation.test.ts`](../../actions-toolkit/packages/cache/__tests__/pathValidation.test.ts),
[`listAndValidate.test.ts`](../../actions-toolkit/packages/cache/__tests__/listAndValidate.test.ts), and
[`tarPathValidation.test.ts`](../../actions-toolkit/packages/cache/__tests__/tarPathValidation.test.ts)).
* **`saveCache` validation.** This change adds restore-side validation only.
The save path does not validate the entries it creates (and does not need
to — saves operate on paths the user explicitly declared in this action's
inputs).
* **Server-side scanning.** This is a client-side defence-in-depth control;
it does not replace server-side cache-poisoning mitigations.
## How to run
```bash
# unit tests (Jest + ts-jest)
npm test
# rebuild distribution bundles
npm run build
# full lint + format + tests + build
npm run format-check && npm run lint && npm test && npm run build
```
For the E2E workflow, push the changes to a branch and trigger the
`Path Validation E2E` workflow. All 39 matrix entries should pass; any
matrix-entry failure indicates the validation logic disagrees with this
plan on a specific OS/archive-format combination.
## Local development note
The `@actions/cache` toolkit v6.1.0 used by this action is currently
unpublished. For local development:
1. From the toolkit repo: `cd packages/cache && npm pack`
2. From this repo: `npm install /path/to/actions-toolkit/packages/cache/actions-cache-6.1.0.tgz`
3. After running `npm install`, restore `package.json`'s
`"@actions/cache": "^6.1.0"` specifier (npm rewrites it to a `file:` URL
when installing from a tarball).
Once the toolkit is published to npm, `npm install` will resolve `^6.1.0`
directly from the registry and step 1-3 are no longer needed.
Jest cannot load the ESM-only toolkit directly. The `jest.config.js` file
includes a `moduleNameMapper` that redirects `@actions/cache` imports during
tests to a CJS stub at
[`__tests__/__mocks__/actions-cache.ts`](../__tests__/__mocks__/actions-cache.ts).
The stub re-implements just the surface the action consumes (with the same
validation behavior for keys and paths) so tests can spy/mock on it. The
production bundle (built by `ncc`) uses the real ESM module — verified by
grepping for `pathValidation` and `CacheIntegrityError` symbols in the
bundled `dist/restore/index.js`.