mirror of
https://github.com/actions/cache.git
synced 2026-06-06 09:04:21 +00:00
Merge c855662eeb into 27d5ce7f10
This commit is contained in:
commit
2b49858bbf
25 changed files with 201060 additions and 164352 deletions
282
.github/workflows/path-validation-e2e.yml
vendored
Normal file
282
.github/workflows/path-validation-e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
name: Path Validation E2E
|
||||
|
||||
# Manually triggered only — this matrix runs 39 jobs with multiple cache
|
||||
# saves/restores per job, which is too expensive to run on every PR. Maintainers
|
||||
# should dispatch it from the Actions tab against a PR branch whenever changes
|
||||
# touch the path-validation codepath (src/restoreImpl.ts, src/utils/actionUtils.ts,
|
||||
# or the bundled @actions/cache version).
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# This workflow validates client-side path validation for action/cache restores.
|
||||
# It is intentionally structured as two phases:
|
||||
# 1. The "good cache" phase saves a legitimate cache, then restores it under
|
||||
# each strict-paths value and asserts extraction succeeded.
|
||||
# 2. The "poisoned cache" phase manufactures a tar archive that contains an
|
||||
# entry whose path resolves outside the declared `path` input. It uploads
|
||||
# that archive directly to the cache backend via the toolkit's internal
|
||||
# APIs (not via this action), then attempts to restore it via this action
|
||||
# under each strict-paths value and asserts the expected behavior:
|
||||
# - off: the malicious entry is extracted (validation disabled).
|
||||
# - warn: the malicious entry is extracted but a workflow warning is logged.
|
||||
# - error: the malicious entry is rejected (no extraction).
|
||||
#
|
||||
# NOTE: The poisoned-cache phase relies on a small Node.js helper script
|
||||
# (__tests__/e2e/save-poisoned-cache.mjs) that the workflow invokes. Rather
|
||||
# than fabricating a tar archive by hand, the helper calls the toolkit's
|
||||
# `@actions/cache.saveCache()` with the declared `path` AND one or more extra
|
||||
# paths that escape it; the toolkit packs everything into a normal cache
|
||||
# archive. The action's later restore step declares only the legitimate
|
||||
# `path`, so the extra entries become "escape" entries that the client-side
|
||||
# validation should reject (or warn about) per the configured strict-paths
|
||||
# mode.
|
||||
|
||||
jobs:
|
||||
good-cache:
|
||||
name: 'Restore legitimate cache (strict-paths=${{ matrix.strict-paths }})'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022]
|
||||
strict-paths: [off, warn, error]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Generate legitimate cache files
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p path-validation-cache
|
||||
for i in 1 2 3 4 5; do
|
||||
echo "file-${i}-${{ matrix.os }}-${{ matrix.strict-paths }}" > "path-validation-cache/file-${i}.txt"
|
||||
done
|
||||
|
||||
- name: Save legitimate cache
|
||||
uses: ./
|
||||
with:
|
||||
key: path-validation-good-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
|
||||
path: path-validation-cache
|
||||
|
||||
- name: Remove local files
|
||||
shell: bash
|
||||
run: rm -rf path-validation-cache
|
||||
|
||||
- name: Restore legitimate cache (should succeed under all modes)
|
||||
id: restore
|
||||
uses: ./
|
||||
with:
|
||||
key: path-validation-good-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
|
||||
path: path-validation-cache
|
||||
strict-paths: ${{ matrix.strict-paths }}
|
||||
fail-on-cache-invalid: true
|
||||
|
||||
- name: Verify legitimate cache extracted
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
|
||||
echo "::error::Expected cache-hit=true but got '${{ steps.restore.outputs.cache-hit }}'"
|
||||
exit 1
|
||||
fi
|
||||
for i in 1 2 3 4 5; do
|
||||
if [ ! -f "path-validation-cache/file-${i}.txt" ]; then
|
||||
echo "::error::Missing expected file path-validation-cache/file-${i}.txt"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Legitimate cache restored successfully under strict-paths=${{ matrix.strict-paths }}"
|
||||
|
||||
poisoned-cache:
|
||||
name: 'Restore poisoned cache (strict-paths=${{ matrix.strict-paths }})'
|
||||
needs: good-cache
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022]
|
||||
strict-paths: [off, warn, error]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# Build a tar.zst archive containing one legitimate entry plus one entry
|
||||
# whose path escapes the declared `path` input. We then save it to the
|
||||
# cache backend using the toolkit's saveCache() with the same path the
|
||||
# restore step uses, so the archive header roots and declared paths match
|
||||
# what the action expects.
|
||||
- name: Generate poisoned archive locally
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p path-validation-cache
|
||||
echo "legitimate" > path-validation-cache/legit.txt
|
||||
# The escape file is created at the working-dir level (one above the
|
||||
# declared path), simulating an attacker who built the cache from a
|
||||
# workspace where the relative path "../escape.txt" pointed outside
|
||||
# the declared `path: path-validation-cache` input.
|
||||
echo "should-be-rejected" > escape.txt
|
||||
|
||||
- name: Save poisoned cache via toolkit helper
|
||||
shell: bash
|
||||
env:
|
||||
POISONED_KEY: path-validation-bad-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
|
||||
run: |
|
||||
node __tests__/e2e/save-poisoned-cache.mjs \
|
||||
"$POISONED_KEY" \
|
||||
path-validation-cache \
|
||||
../escape.txt
|
||||
|
||||
- name: Remove staged files (force restore to re-extract)
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf path-validation-cache escape.txt
|
||||
|
||||
- name: Restore poisoned cache
|
||||
id: restore
|
||||
continue-on-error: true
|
||||
uses: ./
|
||||
with:
|
||||
key: path-validation-bad-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
|
||||
path: path-validation-cache
|
||||
strict-paths: ${{ matrix.strict-paths }}
|
||||
# Always treat integrity failures as misses for this E2E so we can
|
||||
# assert on outputs rather than job failure.
|
||||
fail-on-cache-invalid: false
|
||||
|
||||
- name: Assert behavior for strict-paths=off (no validation)
|
||||
if: matrix.strict-paths == 'off'
|
||||
shell: bash
|
||||
run: |
|
||||
# In off mode the malicious entry IS extracted.
|
||||
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
|
||||
echo "::error::Expected cache-hit=true for strict-paths=off"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "escape.txt" ]; then
|
||||
echo "::error::Expected the malicious entry 'escape.txt' to be extracted in 'off' mode"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: strict-paths=off extracted the cache without validation (expected legacy behavior)."
|
||||
|
||||
- name: Assert behavior for strict-paths=warn (warn but extract)
|
||||
if: matrix.strict-paths == 'warn'
|
||||
shell: bash
|
||||
run: |
|
||||
# In warn mode the cache IS extracted but a warning should be logged.
|
||||
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
|
||||
echo "::error::Expected cache-hit=true for strict-paths=warn"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "escape.txt" ]; then
|
||||
echo "::error::Expected the malicious entry 'escape.txt' to be extracted in 'warn' mode (warn does not reject)"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: strict-paths=warn extracted the cache (a workflow warning should be visible in the action log above)."
|
||||
|
||||
- name: Assert behavior for strict-paths=error (reject)
|
||||
if: matrix.strict-paths == 'error'
|
||||
shell: bash
|
||||
run: |
|
||||
# In error mode the action should treat the cache as a miss
|
||||
# (because fail-on-cache-invalid: false).
|
||||
if [ -f "escape.txt" ]; then
|
||||
echo "::error::Malicious entry 'escape.txt' was extracted in 'error' mode (validation failed open)"
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "path-validation-cache/legit.txt" ]; then
|
||||
echo "::error::Cache was extracted in 'error' mode (validation should have rejected the entire archive)"
|
||||
exit 1
|
||||
fi
|
||||
# The discarded cache must look identical to a regular cache miss
|
||||
# to downstream `if:` checks (see issue #1466), so `cache-hit` is
|
||||
# intentionally NOT set (empty string), NOT 'false'.
|
||||
if [ -n "${{ steps.restore.outputs.cache-hit }}" ]; then
|
||||
echo "::error::Expected cache-hit to be unset for rejected cache in 'error' mode (got '${{ steps.restore.outputs.cache-hit }}')"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: strict-paths=error rejected the poisoned cache and treated it as a miss."
|
||||
|
||||
poisoned-cache-fail-on-invalid:
|
||||
name: 'Reject poisoned cache (fail-on-cache-invalid=true)'
|
||||
needs: poisoned-cache
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Generate poisoned archive locally
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p path-validation-cache
|
||||
echo "legitimate" > path-validation-cache/legit.txt
|
||||
echo "should-be-rejected" > escape.txt
|
||||
|
||||
- name: Save poisoned cache via toolkit helper
|
||||
shell: bash
|
||||
env:
|
||||
POISONED_KEY: path-validation-fail-${{ matrix.os }}-${{ github.run_id }}
|
||||
run: |
|
||||
node __tests__/e2e/save-poisoned-cache.mjs \
|
||||
"$POISONED_KEY" \
|
||||
path-validation-cache \
|
||||
../escape.txt
|
||||
|
||||
- name: Remove staged files
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf path-validation-cache escape.txt
|
||||
|
||||
- name: Attempt restore (expected to fail the workflow)
|
||||
id: restore
|
||||
continue-on-error: true
|
||||
uses: ./
|
||||
with:
|
||||
key: path-validation-fail-${{ matrix.os }}-${{ github.run_id }}
|
||||
path: path-validation-cache
|
||||
strict-paths: error
|
||||
fail-on-cache-invalid: true
|
||||
|
||||
- name: Assert the restore step failed
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ steps.restore.outcome }}" != "failure" ]; then
|
||||
echo "::error::Expected the restore step to fail when fail-on-cache-invalid=true and the cache is rejected (got outcome='${{ steps.restore.outcome }}')"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: restore step failed as expected when fail-on-cache-invalid=true."
|
||||
|
|
@ -90,6 +90,11 @@ If you are using a `self-hosted` Windows runner, `GNU tar` and `zstd` are requir
|
|||
* `enableCrossOsArchive` - An optional boolean when enabled, allows Windows runners to save or restore caches that can be restored or saved respectively on other platforms. Default: `false`
|
||||
* `fail-on-cache-miss` - Fail the workflow if cache entry is not found. Default: `false`
|
||||
* `lookup-only` - If true, only checks if cache entry exists and skips download. Does not change save cache behavior. Default: `false`
|
||||
* `strict-paths` - Client-side path-validation strictness applied when extracting a restored cache. Helps protect against some forms of cache poisoning attacks. Valid values:
|
||||
* `off` - Disable path validation entirely (legacy behavior). Skipping validation may slightly improve performance for very large cache archives, but is not recommended for best security.
|
||||
* `warn` *(current default)* - Pre-scan the archive and emit a workflow warning if any entry would resolve outside the declared `path` inputs. The cache is still extracted.
|
||||
* `error` *(future default)* - Pre-scan the archive and reject it (without extracting) if any entry would resolve outside the declared `path` inputs.
|
||||
* `fail-on-cache-invalid` - Fail the workflow when a restored cache is rejected by client-side validation (entries that escape the declared paths, or an archive that cannot be parsed). Only applies when `strict-paths: error` is set; the `off` and `warn` modes never reject a cache. When `false` (default) the rejected cache is treated as a cache miss.
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@
|
|||
|
||||
## Changelog
|
||||
|
||||
### 5.1.0
|
||||
|
||||
- Add path validation for restored caches.
|
||||
- New `strict-paths` input (`off`, `warn` *(default)*, `error`) pre-scans the archive entry list before extraction and reports or rejects entries that would resolve outside the declared `path` inputs.
|
||||
- New `fail-on-cache-invalid` input controls whether a rejected cache archive fails the workflow (`true`) or is treated as a cache miss (`false`, default).
|
||||
- Bump `@actions/cache` toolkit dependency to `^6.1.0` (introduces the validation surface and bumps `tar` to v7.5.15).
|
||||
|
||||
### 5.0.4
|
||||
|
||||
- Bump `minimatch` to v3.1.5 (fixes ReDoS via globstar patterns)
|
||||
|
|
|
|||
119
__tests__/__mocks__/actions-cache.ts
Normal file
119
__tests__/__mocks__/actions-cache.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Local CommonJS stub for the `@actions/cache` toolkit package.
|
||||
*
|
||||
* The published toolkit is an ESM-only package, which Jest's CJS resolver
|
||||
* cannot load directly. The action's runtime is bundled by `@vercel/ncc`
|
||||
* (which handles ESM deps), but Jest tests run uncompiled and therefore
|
||||
* need a CJS-compatible surface to import.
|
||||
*
|
||||
* This file re-implements just the public surface that the action's source
|
||||
* code imports, with no-op implementations. Tests use `jest.spyOn` or
|
||||
* `jest.mock("@actions/cache")` to override the implementations as needed.
|
||||
*
|
||||
* Wired up via `moduleNameMapper` in `jest.config.js`.
|
||||
*
|
||||
* Types are pulled from the real `@actions/cache` package via type-only
|
||||
* imports, so a TypeScript build (via `tsc --noEmit` or ts-jest) verifies
|
||||
* that the stub's runtime surface still satisfies the real package's
|
||||
* signatures — a signature drift (renamed parameter, added property,
|
||||
* changed return type) will surface here as a compile error rather than
|
||||
* as a silent test-only behavior change. `import type` is fully erased at
|
||||
* compile time, so the Jest `moduleNameMapper` redirect for this file is
|
||||
* not affected at runtime (no self-referential require loop).
|
||||
*/
|
||||
|
||||
import type * as Cache from "@actions/cache";
|
||||
|
||||
// Re-export the toolkit's types so consumers of this stub and consumers of
|
||||
// the real package see identical types — there is no second source of truth.
|
||||
export type {
|
||||
CacheIntegrityErrorCode,
|
||||
DownloadOptions,
|
||||
PathValidationMode,
|
||||
PathValidationViolation,
|
||||
UploadOptions
|
||||
} from "@actions/cache";
|
||||
|
||||
// Each `typeof Cache.X` annotation forces the local implementation to be
|
||||
// assignable to the real package's exported signature.
|
||||
|
||||
export const ValidationError: typeof Cache.ValidationError = class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
};
|
||||
|
||||
export const ReserveCacheError: typeof Cache.ReserveCacheError = class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ReserveCacheError";
|
||||
}
|
||||
};
|
||||
|
||||
export const FinalizeCacheError: typeof Cache.FinalizeCacheError = class extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "FinalizeCacheError";
|
||||
}
|
||||
};
|
||||
|
||||
export const CacheIntegrityError: typeof Cache.CacheIntegrityError = class extends Error {
|
||||
readonly code: Cache.CacheIntegrityErrorCode;
|
||||
readonly violations?: Cache.PathValidationViolation[];
|
||||
constructor(
|
||||
code: Cache.CacheIntegrityErrorCode,
|
||||
message: string,
|
||||
violations?: Cache.PathValidationViolation[]
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CacheIntegrityError";
|
||||
this.code = code;
|
||||
this.violations = violations;
|
||||
}
|
||||
};
|
||||
|
||||
export const isFeatureAvailable: typeof Cache.isFeatureAvailable = () => true;
|
||||
|
||||
function checkKey(key: string): void {
|
||||
if (key.length > 512) {
|
||||
throw new ValidationError(
|
||||
`Key Validation Error: ${key} cannot be larger than 512 characters.`
|
||||
);
|
||||
}
|
||||
const regex = /^[^,]*$/;
|
||||
if (!regex.test(key)) {
|
||||
throw new ValidationError(
|
||||
`Key Validation Error: ${key} cannot contain commas.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const restoreCache: typeof Cache.restoreCache = async (
|
||||
_paths,
|
||||
primaryKey,
|
||||
restoreKeys,
|
||||
_options,
|
||||
_enableCrossOsArchive
|
||||
) => {
|
||||
const keys = [primaryKey, ...(restoreKeys ?? [])];
|
||||
if (keys.length > 10) {
|
||||
throw new ValidationError(
|
||||
`Key Validation Error: Keys are limited to a maximum of 10.`
|
||||
);
|
||||
}
|
||||
for (const key of keys) {
|
||||
checkKey(key);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const saveCache: typeof Cache.saveCache = async (
|
||||
_paths,
|
||||
key,
|
||||
_options,
|
||||
_enableCrossOsArchive
|
||||
) => {
|
||||
checkKey(key);
|
||||
return -1;
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import * as cache from "@actions/cache";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { Events, RefKey } from "../src/constants";
|
||||
import { Events, Inputs, RefKey } from "../src/constants";
|
||||
import * as actionUtils from "../src/utils/actionUtils";
|
||||
import * as testUtils from "../src/utils/testUtils";
|
||||
|
||||
|
|
@ -265,3 +265,58 @@ test("isGhes returns true when the GITHUB_SERVER_URL environment variable is set
|
|||
process.env["GITHUB_SERVER_URL"] = "https://src.onpremise.fabrikam.com";
|
||||
expect(actionUtils.isGhes()).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("getPathValidationInput", () => {
|
||||
const inputEnv = `INPUT_${Inputs.StrictPaths.toUpperCase()}`;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[inputEnv];
|
||||
// Re-mock getInput so the each-test environment reads the input env var
|
||||
jest.spyOn(core, "getInput").mockImplementation((name, options) => {
|
||||
return jest.requireActual("@actions/core").getInput(name, options);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env[inputEnv];
|
||||
});
|
||||
|
||||
test("returns 'warn' when input is unset", () => {
|
||||
expect(actionUtils.getPathValidationInput()).toBe("warn");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["off", "off"],
|
||||
["warn", "warn"],
|
||||
["error", "error"],
|
||||
["OFF", "off"],
|
||||
["Warn", "warn"],
|
||||
["ERROR", "error"]
|
||||
])("normalizes %s to %s", (input, expected) => {
|
||||
process.env[inputEnv] = input;
|
||||
expect(actionUtils.getPathValidationInput()).toBe(expected);
|
||||
});
|
||||
|
||||
test("falls back to 'warn' for unrecognized values and emits a workflow warning", () => {
|
||||
process.env[inputEnv] = "strict";
|
||||
// Suppress the real implementation so the warning does not pollute
|
||||
// the Jest log, and assert it was emitted via core.warning so it
|
||||
// surfaces as a real `::warning::` workflow annotation.
|
||||
const warningSpy = jest
|
||||
.spyOn(core, "warning")
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
expect(actionUtils.getPathValidationInput()).toBe("warn");
|
||||
expect(warningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unrecognized value for strict-paths")
|
||||
);
|
||||
} finally {
|
||||
warningSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("treats empty string as default 'warn'", () => {
|
||||
process.env[inputEnv] = "";
|
||||
expect(actionUtils.getPathValidationInput()).toBe("warn");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
__tests__/e2e/save-poisoned-cache.mjs
Normal file
45
__tests__/e2e/save-poisoned-cache.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// @ts-check
|
||||
/**
|
||||
* save-poisoned-cache.mjs
|
||||
*
|
||||
* Helper script used by the path-validation E2E workflow to upload a cache
|
||||
* archive that contains entries outside the declared `path` inputs. This
|
||||
* simulates a poisoned cache that would have been produced by a build job
|
||||
* that had write access to the workspace's parent directory (the canonical
|
||||
* cache-poisoning scenario being defended against).
|
||||
*
|
||||
* Usage:
|
||||
* node save-poisoned-cache.mjs <cache-key> <declared-path> [extra-path ...]
|
||||
*
|
||||
* The script invokes `@actions/cache.saveCache()` with the declared path(s)
|
||||
* AND extra paths that escape the workspace. The toolkit's saveCache packs
|
||||
* everything into the archive, so the resulting cache entry will contain
|
||||
* "escape" entries that resolve outside the declared `path` when the action's
|
||||
* `restore` step later extracts it (because the restore step only declares the
|
||||
* legitimate `path`).
|
||||
*
|
||||
* Important: this script is NOT shipped to users. It is purely a test fixture
|
||||
* generator used by the E2E workflow to validate that the action's client-side
|
||||
* validation correctly rejects (or warns about) such caches.
|
||||
*/
|
||||
|
||||
import * as cache from '@actions/cache';
|
||||
|
||||
const [, , key, ...paths] = process.argv;
|
||||
|
||||
if (!key || paths.length === 0) {
|
||||
console.error(
|
||||
'Usage: node save-poisoned-cache.mjs <cache-key> <path> [extra-path ...]'
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(`Saving poisoned cache with key="${key}" paths=${JSON.stringify(paths)}`);
|
||||
|
||||
try {
|
||||
const cacheId = await cache.saveCache(paths, key);
|
||||
console.log(`Saved poisoned cache (cacheId=${cacheId})`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to save poisoned cache: ${err?.message ?? err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -34,6 +34,11 @@ beforeAll(() => {
|
|||
return actualUtils.getInputAsBool(name, options);
|
||||
}
|
||||
);
|
||||
|
||||
jest.spyOn(actionUtils, "getPathValidationInput").mockImplementation(() => {
|
||||
const actualUtils = jest.requireActual("../src/utils/actionUtils");
|
||||
return actualUtils.getPathValidationInput();
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -79,7 +84,8 @@ test("restore with no cache found", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -122,7 +128,8 @@ test("restore with restore keys and no cache found", async () => {
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -164,7 +171,8 @@ test("restore with cache found for key", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -209,7 +217,8 @@ test("restore with cache found for restore key", async () => {
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -254,7 +263,8 @@ test("Fail restore when fail on cache miss is enabled and primary + restore keys
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -297,7 +307,8 @@ test("restore when fail on cache miss is enabled and primary key doesn't match r
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -343,7 +354,8 @@ test("restore with fail on cache miss disabled and no cache found", async () =>
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ beforeAll(() => {
|
|||
return actualUtils.getInputAsBool(name, options);
|
||||
}
|
||||
);
|
||||
|
||||
jest.spyOn(actionUtils, "getPathValidationInput").mockImplementation(() => {
|
||||
const actualUtils = jest.requireActual("../src/utils/actionUtils");
|
||||
return actualUtils.getPathValidationInput();
|
||||
});
|
||||
|
||||
jest.spyOn(actionUtils, "logWarning").mockImplementation(message => {
|
||||
const actualUtils = jest.requireActual("../src/utils/actionUtils");
|
||||
return actualUtils.logWarning(message);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -127,7 +137,8 @@ test("restore on GHES with AC available ", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -181,7 +192,8 @@ test("restore with too many keys should fail", async () => {
|
|||
key,
|
||||
restoreKeys,
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -207,7 +219,8 @@ test("restore with large key should fail", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -233,7 +246,8 @@ test("restore with invalid key should fail", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -268,7 +282,8 @@ test("restore with no cache found", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -309,7 +324,8 @@ test("restore with restore keys and no cache found", async () => {
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -349,7 +365,8 @@ test("restore with cache found for key", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -391,7 +408,8 @@ test("restore with cache found for restore key", async () => {
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -432,7 +450,8 @@ test("restore with lookup-only set", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: true
|
||||
lookupOnly: true,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -465,3 +484,246 @@ test("restore failure with earlyExit should call process exit", async () => {
|
|||
);
|
||||
expect(processExitMock).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path validation tests
|
||||
//
|
||||
// These tests verify that the action correctly forwards the `strict-paths`
|
||||
// input to the @actions/cache toolkit and handles `CacheIntegrityError`
|
||||
// rejections according to the `fail-on-cache-invalid` input.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("restore defaults strict-paths to 'warn' and forwards it to restoreCache", async () => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({ path, key });
|
||||
|
||||
const restoreCacheMock = jest
|
||||
.spyOn(cache, "restoreCache")
|
||||
.mockResolvedValueOnce(key);
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCacheMock).toHaveBeenCalledWith(
|
||||
[path],
|
||||
key,
|
||||
[],
|
||||
{ lookupOnly: false, pathValidation: "warn" },
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["off", "warn", "error"])(
|
||||
"restore forwards strict-paths value '%s' to restoreCache",
|
||||
async value => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({ path, key, strictPaths: value });
|
||||
|
||||
const restoreCacheMock = jest
|
||||
.spyOn(cache, "restoreCache")
|
||||
.mockResolvedValueOnce(key);
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCacheMock).toHaveBeenCalledWith(
|
||||
[path],
|
||||
key,
|
||||
[],
|
||||
{ lookupOnly: false, pathValidation: value },
|
||||
false
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test("restore falls back to 'warn' when strict-paths input is unrecognized", async () => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({ path, key, strictPaths: "STRICT" });
|
||||
|
||||
const restoreCacheMock = jest
|
||||
.spyOn(cache, "restoreCache")
|
||||
.mockResolvedValueOnce(key);
|
||||
// getPathValidationInput() emits the misconfiguration notice via
|
||||
// core.warning() so it surfaces as a real `::warning::` workflow
|
||||
// annotation. Suppress the real implementation to keep the Jest log
|
||||
// clean while asserting it was called.
|
||||
const warningMock = jest
|
||||
.spyOn(core, "warning")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
expect(restoreCacheMock).toHaveBeenCalledWith(
|
||||
[path],
|
||||
key,
|
||||
[],
|
||||
{ lookupOnly: false, pathValidation: "warn" },
|
||||
false
|
||||
);
|
||||
expect(warningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unrecognized value for strict-paths")
|
||||
);
|
||||
});
|
||||
|
||||
test("restore treats CacheIntegrityError as a cache miss by default", async () => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({ path, key, strictPaths: "error" });
|
||||
|
||||
const integrityError = new Error("entries escape declared paths");
|
||||
integrityError.name = "CacheIntegrityError";
|
||||
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
|
||||
|
||||
const restoreCacheMock = jest
|
||||
.spyOn(cache, "restoreCache")
|
||||
.mockRejectedValueOnce(integrityError);
|
||||
const setOutputMock = jest.spyOn(core, "setOutput");
|
||||
const failedMock = jest.spyOn(core, "setFailed");
|
||||
// Suppress the real logWarning so the discarded-cache warning does not
|
||||
// pollute test output. beforeEach's jest.restoreAllMocks() handles
|
||||
// cross-test cleanup.
|
||||
const logWarningMock = jest
|
||||
.spyOn(actionUtils, "logWarning")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
expect(restoreCacheMock).toHaveBeenCalledTimes(1);
|
||||
// Intentionally NOT set: a discarded cache must look identical to a
|
||||
// regular cache miss to downstream `if:` checks (see issue #1466).
|
||||
const cacheHitCalls = setOutputMock.mock.calls.filter(
|
||||
c => c[0] === "cache-hit"
|
||||
);
|
||||
expect(cacheHitCalls).toHaveLength(0);
|
||||
expect(failedMock).not.toHaveBeenCalled();
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("PATH_VIOLATION")
|
||||
);
|
||||
});
|
||||
|
||||
test("restore fails when CacheIntegrityError is raised and fail-on-cache-invalid is true", async () => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({
|
||||
path,
|
||||
key,
|
||||
strictPaths: "error",
|
||||
failOnCacheInvalid: true
|
||||
});
|
||||
|
||||
const integrityError = new Error("entry escapes workspace");
|
||||
integrityError.name = "CacheIntegrityError";
|
||||
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
|
||||
|
||||
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
|
||||
const failedMock = jest.spyOn(core, "setFailed");
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
expect(failedMock).toHaveBeenCalledTimes(1);
|
||||
expect(failedMock.mock.calls[0][0]).toContain("integrity validation");
|
||||
expect(failedMock.mock.calls[0][0]).toContain("PATH_VIOLATION");
|
||||
});
|
||||
|
||||
test("restore propagates non-integrity errors normally", async () => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({ path, key });
|
||||
|
||||
const networkError = new Error("Network timeout");
|
||||
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(networkError);
|
||||
const failedMock = jest.spyOn(core, "setFailed");
|
||||
const logWarningMock = jest
|
||||
.spyOn(actionUtils, "logWarning")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
expect(failedMock).toHaveBeenCalledWith("Network timeout");
|
||||
expect(logWarningMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("integrity")
|
||||
);
|
||||
});
|
||||
|
||||
test("restore parse-error integrity failure also treated as miss by default", async () => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({ path, key, strictPaths: "error" });
|
||||
|
||||
const parseError = new Error("malformed gzip header");
|
||||
parseError.name = "CacheIntegrityError";
|
||||
(parseError as Error & { code?: string }).code = "PARSE_ERROR";
|
||||
|
||||
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(parseError);
|
||||
const setOutputMock = jest.spyOn(core, "setOutput");
|
||||
const failedMock = jest.spyOn(core, "setFailed");
|
||||
const logWarningMock = jest
|
||||
.spyOn(actionUtils, "logWarning")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
const cacheHitCalls = setOutputMock.mock.calls.filter(
|
||||
c => c[0] === "cache-hit"
|
||||
);
|
||||
expect(cacheHitCalls).toHaveLength(0);
|
||||
expect(failedMock).not.toHaveBeenCalled();
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("PARSE_ERROR")
|
||||
);
|
||||
});
|
||||
|
||||
test("restore tolerates CacheIntegrityError without explicit code", async () => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({ path, key, strictPaths: "error" });
|
||||
|
||||
const integrityError = new Error("bad archive");
|
||||
integrityError.name = "CacheIntegrityError";
|
||||
// intentionally no .code property
|
||||
|
||||
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
|
||||
const setOutputMock = jest.spyOn(core, "setOutput");
|
||||
const logWarningMock = jest
|
||||
.spyOn(actionUtils, "logWarning")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
const cacheHitCalls = setOutputMock.mock.calls.filter(
|
||||
c => c[0] === "cache-hit"
|
||||
);
|
||||
expect(cacheHitCalls).toHaveLength(0);
|
||||
expect(logWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("unknown")
|
||||
);
|
||||
});
|
||||
|
||||
test("restore does not set cache-hit output when integrity error is rethrown", async () => {
|
||||
const path = "node_modules";
|
||||
const key = "node-test";
|
||||
testUtils.setInputs({
|
||||
path,
|
||||
key,
|
||||
strictPaths: "error",
|
||||
failOnCacheInvalid: true
|
||||
});
|
||||
|
||||
const integrityError = new Error("rejected");
|
||||
integrityError.name = "CacheIntegrityError";
|
||||
(integrityError as Error & { code?: string }).code = "PATH_VIOLATION";
|
||||
|
||||
jest.spyOn(cache, "restoreCache").mockRejectedValueOnce(integrityError);
|
||||
const setOutputMock = jest.spyOn(core, "setOutput");
|
||||
|
||||
await restoreImpl(new StateProvider());
|
||||
|
||||
// setOutput should NOT have been called with cache-hit at all in this path
|
||||
const cacheHitCalls = setOutputMock.mock.calls.filter(
|
||||
c => c[0] === "cache-hit"
|
||||
);
|
||||
expect(cacheHitCalls).toHaveLength(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ beforeAll(() => {
|
|||
.getInputAsBool(name, options);
|
||||
}
|
||||
);
|
||||
|
||||
jest.spyOn(actionUtils, "getPathValidationInput").mockImplementation(() => {
|
||||
const actualUtils = jest.requireActual("../src/utils/actionUtils");
|
||||
return actualUtils.getPathValidationInput();
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -80,7 +85,8 @@ test("restore with no cache found", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -122,7 +128,8 @@ test("restore with restore keys and no cache found", async () => {
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -161,7 +168,8 @@ test("restore with cache found for key", async () => {
|
|||
key,
|
||||
[],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
@ -204,7 +212,8 @@ test("restore with cache found for restore key", async () => {
|
|||
key,
|
||||
[restoreKey],
|
||||
{
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: "warn"
|
||||
},
|
||||
false
|
||||
);
|
||||
|
|
|
|||
17
action.yml
17
action.yml
|
|
@ -34,6 +34,23 @@ inputs:
|
|||
save-always does not work as intended and will be removed in a future release.
|
||||
A separate `actions/cache/restore` step should be used instead.
|
||||
See https://github.com/actions/cache/tree/main/save#always-save-cache for more details.
|
||||
strict-paths:
|
||||
description: |
|
||||
Controls client-side validation of cache archive entry paths before extraction.
|
||||
'off' disables validation (legacy behavior). 'warn' logs a single warning when any
|
||||
entry would resolve outside the declared `path` inputs and still extracts the cache.
|
||||
'error' rejects the cache with a CacheIntegrityError and skips extraction entirely.
|
||||
Default is 'warn'.
|
||||
default: 'warn'
|
||||
required: false
|
||||
fail-on-cache-invalid:
|
||||
description: |
|
||||
Fail the workflow if the restored cache is rejected by client-side path validation
|
||||
(entries that escape the declared paths, or an archive that cannot be parsed).
|
||||
Only applies when `strict-paths` is 'error'; the 'off' and 'warn' modes never
|
||||
reject a cache. When 'false' (default), a rejected cache is treated as a cache miss.
|
||||
default: 'false'
|
||||
required: false
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: 'A boolean value to indicate an exact match was found for the primary key'
|
||||
|
|
|
|||
90961
dist/restore-only/index.js
vendored
90961
dist/restore-only/index.js
vendored
File diff suppressed because one or more lines are too long
90961
dist/restore/index.js
vendored
90961
dist/restore/index.js
vendored
File diff suppressed because one or more lines are too long
90935
dist/save-only/index.js
vendored
90935
dist/save-only/index.js
vendored
File diff suppressed because one or more lines are too long
90935
dist/save/index.js
vendored
90935
dist/save/index.js
vendored
File diff suppressed because one or more lines are too long
239
docs/path-validation-test-plan.md
Normal file
239
docs/path-validation-test-plan.md
Normal 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 logs the rejection and returns without setting the `cache-hit` output (intentionally unset to match regular cache-miss semantics — see issue #1466) |
|
||||
| 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`.
|
||||
|
|
@ -9,6 +9,13 @@ module.exports = {
|
|||
transform: {
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
// The @actions/cache toolkit (v6+) is ESM-only and cannot be loaded by
|
||||
// Jest's CommonJS resolver. For unit tests we redirect imports to a
|
||||
// local CJS-compatible stub that exposes the same surface; production
|
||||
// builds (tsc + ncc) use the real ESM package directly.
|
||||
moduleNameMapper: {
|
||||
"^@actions/cache$": "<rootDir>/__tests__/__mocks__/actions-cache.ts"
|
||||
},
|
||||
verbose: true
|
||||
};
|
||||
|
||||
|
|
|
|||
398
package-lock.json
generated
398
package-lock.json
generated
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"name": "cache",
|
||||
"version": "5.0.4",
|
||||
"version": "5.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cache",
|
||||
"version": "5.0.4",
|
||||
"version": "5.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/cache": "^5.0.5",
|
||||
"@actions/cache": "file:../actions-toolkit/packages/cache/actions-cache-6.1.0.tgz",
|
||||
"@actions/core": "^2.0.3",
|
||||
"@actions/exec": "^2.0.0",
|
||||
"@actions/io": "^2.0.0"
|
||||
|
|
@ -39,21 +39,68 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@actions/cache": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@actions/cache/-/cache-5.0.5.tgz",
|
||||
"integrity": "sha512-jiQSg0gfd+C2KPgcmdCOq7dCuCIQQWQ4b1YfGIRaaA9w7PJbRwTOcCz4LiFEUnqZGf0ha/8OKL3BeNwetHzYsQ==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "file:../actions-toolkit/packages/cache/actions-cache-6.1.0.tgz",
|
||||
"integrity": "sha512-nty8KpfavtFXlCdR27I1kp3XNE1oKol1m74qEz6uBZDdPkU2MeG89bKnQEtCCrYdMkaa2e2iIobqYuCwpqFI/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^2.0.0",
|
||||
"@actions/exec": "^2.0.0",
|
||||
"@actions/glob": "^0.5.1",
|
||||
"@actions/http-client": "^3.0.2",
|
||||
"@actions/io": "^2.0.0",
|
||||
"@azure/abort-controller": "^1.1.0",
|
||||
"@azure/core-rest-pipeline": "^1.22.0",
|
||||
"@azure/storage-blob": "^12.29.1",
|
||||
"@actions/core": "^3.0.1",
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/glob": "^0.6.1",
|
||||
"@actions/http-client": "^4.0.1",
|
||||
"@actions/io": "^3.0.2",
|
||||
"@azure/core-rest-pipeline": "^1.23.0",
|
||||
"@azure/storage-blob": "^12.31.0",
|
||||
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
||||
"semver": "^6.3.1"
|
||||
"semver": "^7.7.4",
|
||||
"tar": "^7.5.15"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/cache/node_modules/@actions/core": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz",
|
||||
"integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/http-client": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/cache/node_modules/@actions/exec": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
|
||||
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/io": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/cache/node_modules/@actions/http-client": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz",
|
||||
"integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tunnel": "^0.0.6",
|
||||
"undici": "^6.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/cache/node_modules/@actions/io": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
|
||||
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@actions/cache/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
|
|
@ -76,15 +123,50 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@actions/glob": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.5.1.tgz",
|
||||
"integrity": "sha512-+dv/t2aKQdKp9WWSp+1yIXVJzH5Q38M0Mta26pzIbeec14EcIleMB7UU6N7sNgbEuYfyuVGpE5pOKjl6j1WXkA==",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.6.1.tgz",
|
||||
"integrity": "sha512-K4+2Ac5ILcf2ySdJCha+Pop9NcKjxqCL4xL4zI50dgB2PbXgC0+AcP011xfH4Of6b4QEJJg8dyZYv7zl4byTsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/core": "^2.0.3",
|
||||
"@actions/core": "^3.0.0",
|
||||
"minimatch": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/glob/node_modules/@actions/core": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz",
|
||||
"integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/exec": "^3.0.0",
|
||||
"@actions/http-client": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/glob/node_modules/@actions/exec": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz",
|
||||
"integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/io": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/glob/node_modules/@actions/http-client": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz",
|
||||
"integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tunnel": "^0.0.6",
|
||||
"undici": "^6.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/glob/node_modules/@actions/io": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz",
|
||||
"integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@actions/http-client": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-3.0.2.tgz",
|
||||
|
|
@ -102,15 +184,15 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@azure/abort-controller": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz",
|
||||
"integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.2.0"
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-auth": {
|
||||
|
|
@ -127,18 +209,6 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-auth/node_modules/@azure/abort-controller": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-client": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
|
||||
|
|
@ -157,42 +227,20 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-client/node_modules/@azure/abort-controller": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-http-compat": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz",
|
||||
"integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz",
|
||||
"integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
"@azure/core-client": "^1.10.0",
|
||||
"@azure/core-rest-pipeline": "^1.22.0"
|
||||
"@azure/abort-controller": "^2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-http-compat/node_modules/@azure/abort-controller": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"peerDependencies": {
|
||||
"@azure/core-client": "^1.10.0",
|
||||
"@azure/core-rest-pipeline": "^1.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-lro": {
|
||||
|
|
@ -210,18 +258,6 @@
|
|||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-lro/node_modules/@azure/abort-controller": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-paging": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz",
|
||||
|
|
@ -235,9 +271,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline": {
|
||||
"version": "1.22.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
|
||||
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz",
|
||||
"integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
|
|
@ -245,25 +281,13 @@
|
|||
"@azure/core-tracing": "^1.3.0",
|
||||
"@azure/core-util": "^1.13.0",
|
||||
"@azure/logger": "^1.3.0",
|
||||
"@typespec/ts-http-runtime": "^0.3.0",
|
||||
"@typespec/ts-http-runtime": "^0.3.4",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-rest-pipeline/node_modules/@azure/abort-controller": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-tracing": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
|
||||
|
|
@ -290,25 +314,13 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-util/node_modules/@azure/abort-controller": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-xml": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.0.tgz",
|
||||
"integrity": "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==",
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.1.tgz",
|
||||
"integrity": "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^5.0.7",
|
||||
"fast-xml-parser": "^5.5.9",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -329,9 +341,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@azure/storage-blob": {
|
||||
"version": "12.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.30.0.tgz",
|
||||
"integrity": "sha512-peDCR8blSqhsAKDbpSP/o55S4sheNwSrblvCaHUZ5xUI73XA7ieUGGwrONgD/Fng0EoDe1VOa3fAQ7+WGB3Ocg==",
|
||||
"version": "12.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.31.0.tgz",
|
||||
"integrity": "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
|
|
@ -345,7 +357,7 @@
|
|||
"@azure/core-util": "^1.11.0",
|
||||
"@azure/core-xml": "^1.4.5",
|
||||
"@azure/logger": "^1.1.4",
|
||||
"@azure/storage-common": "^12.2.0",
|
||||
"@azure/storage-common": "^12.3.0",
|
||||
"events": "^3.0.0",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
|
|
@ -353,22 +365,10 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/storage-blob/node_modules/@azure/abort-controller": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/storage-common": {
|
||||
"version": "12.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.2.0.tgz",
|
||||
"integrity": "sha512-YZLxiJ3vBAAnFbG3TFuAMUlxZRexjQX5JDQxOkFGb6e2TpoxH3xyHI6idsMe/QrWtj41U/KoqBxlayzhS+LlwA==",
|
||||
"version": "12.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.3.0.tgz",
|
||||
"integrity": "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
|
|
@ -385,18 +385,6 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/storage-common/node_modules/@azure/abort-controller": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
||||
|
|
@ -994,6 +982,18 @@
|
|||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||
|
|
@ -1453,6 +1453,18 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
|
@ -2595,6 +2607,15 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
|
|
@ -3742,9 +3763,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3753,13 +3774,14 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"xml-naming": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
|
||||
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz",
|
||||
"integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3768,9 +3790,11 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.1.3",
|
||||
"strnum": "^2.1.2"
|
||||
"@nodable/entities": "^2.1.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.3.0",
|
||||
"xml-naming": "^0.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
|
|
@ -5843,6 +5867,27 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -6159,9 +6204,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
|
||||
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -6694,6 +6739,7 @@
|
|||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
|
@ -7071,9 +7117,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
|
||||
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -7124,6 +7170,31 @@
|
|||
"url": "https://opencollective.com/synckit"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tar/node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||
|
|
@ -7736,6 +7807,21 @@
|
|||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cache",
|
||||
"version": "5.0.4",
|
||||
"version": "5.1.0",
|
||||
"private": true,
|
||||
"description": "Cache dependencies and build outputs",
|
||||
"main": "dist/restore/index.js",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"author": "GitHub",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/cache": "^5.0.5",
|
||||
"@actions/cache": "^6.1.0",
|
||||
"@actions/core": "^2.0.3",
|
||||
"@actions/exec": "^2.0.0",
|
||||
"@actions/io": "^2.0.0"
|
||||
|
|
@ -51,4 +51,4 @@
|
|||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ The restore action restores a cache. It works similarly to the `cache` action ex
|
|||
* `restore-keys` - An ordered list of prefix-matched keys to use for restoring stale cache if no cache hit occurred for key.
|
||||
* `fail-on-cache-miss` - Fail the workflow if cache entry is not found. Default: `false`
|
||||
* `lookup-only` - If true, only checks if cache entry exists and skips download. Default: `false`
|
||||
* `strict-paths` - Client-side path-validation strictness applied when extracting a restored cache. Helps protect against some forms of cache poisoning attacks. Valid values:
|
||||
* `off` - Disable path validation entirely (legacy behavior). Skipping validation may slightly improve performance for very large cache archives, but is not recommended for best security.
|
||||
* `warn` *(current default)* - Pre-scan the archive and emit a workflow warning if any entry would resolve outside the declared `path` inputs. The cache is still extracted.
|
||||
* `error` *(future default)* - Pre-scan the archive and reject it (without extracting) if any entry would resolve outside the declared `path` inputs.
|
||||
* `fail-on-cache-invalid` - Fail the workflow when a restored cache is rejected by client-side validation (entries that escape the declared paths, or an archive that cannot be parsed). Only applies when `strict-paths: error` is set; the `off` and `warn` modes never reject a cache. When `false` (default) the rejected cache is treated as a cache miss.
|
||||
|
||||
### Outputs
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,23 @@ inputs:
|
|||
description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache'
|
||||
default: 'false'
|
||||
required: false
|
||||
strict-paths:
|
||||
description: |
|
||||
Controls client-side validation of cache archive entry paths before extraction.
|
||||
'off' disables validation (legacy behavior). 'warn' logs a single warning when any
|
||||
entry would resolve outside the declared `path` inputs and still extracts the cache.
|
||||
'error' rejects the cache with a CacheIntegrityError and skips extraction entirely.
|
||||
Default is 'warn'.
|
||||
default: 'warn'
|
||||
required: false
|
||||
fail-on-cache-invalid:
|
||||
description: |
|
||||
Fail the workflow if the restored cache is rejected by client-side path validation
|
||||
(entries that escape the declared paths, or an archive that cannot be parsed).
|
||||
Only applies when `strict-paths` is 'error'; the 'off' and 'warn' modes never
|
||||
reject a cache. When 'false' (default), a rejected cache is treated as a cache miss.
|
||||
default: 'false'
|
||||
required: false
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: 'A boolean value to indicate an exact match was found for the primary key'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ export enum Inputs {
|
|||
UploadChunkSize = "upload-chunk-size", // Input for cache, save action
|
||||
EnableCrossOsArchive = "enableCrossOsArchive", // Input for cache, restore, save action
|
||||
FailOnCacheMiss = "fail-on-cache-miss", // Input for cache, restore action
|
||||
LookupOnly = "lookup-only" // Input for cache, restore action
|
||||
LookupOnly = "lookup-only", // Input for cache, restore action
|
||||
StrictPaths = "strict-paths", // Input for cache, restore action
|
||||
FailOnCacheInvalid = "fail-on-cache-invalid" // Input for cache, restore action
|
||||
}
|
||||
|
||||
export enum Outputs {
|
||||
|
|
|
|||
|
|
@ -41,15 +41,53 @@ export async function restoreImpl(
|
|||
);
|
||||
const failOnCacheMiss = utils.getInputAsBool(Inputs.FailOnCacheMiss);
|
||||
const lookupOnly = utils.getInputAsBool(Inputs.LookupOnly);
|
||||
|
||||
const cacheKey = await cache.restoreCache(
|
||||
cachePaths,
|
||||
primaryKey,
|
||||
restoreKeys,
|
||||
{ lookupOnly: lookupOnly },
|
||||
enableCrossOsArchive
|
||||
const pathValidation = utils.getPathValidationInput();
|
||||
const failOnCacheInvalid = utils.getInputAsBool(
|
||||
Inputs.FailOnCacheInvalid
|
||||
);
|
||||
|
||||
let cacheKey: string | undefined;
|
||||
try {
|
||||
cacheKey = await cache.restoreCache(
|
||||
cachePaths,
|
||||
primaryKey,
|
||||
restoreKeys,
|
||||
{ lookupOnly: lookupOnly, pathValidation: pathValidation },
|
||||
enableCrossOsArchive
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// The toolkit throws CacheIntegrityError when client-side path
|
||||
// validation rejects the archive (in 'error' mode) or when the
|
||||
// archive cannot be parsed. Detect by name/code so we don't have
|
||||
// to take a hard dependency on the class identity (which may not
|
||||
// round-trip across module boundaries in all bundlers).
|
||||
if (err instanceof Error && err.name === "CacheIntegrityError") {
|
||||
const code = (err as Error & { code?: string }).code;
|
||||
if (failOnCacheInvalid) {
|
||||
// Preserve the toolkit's original error via `Error.cause`.
|
||||
// (Assigned after construction because this project's
|
||||
// tsconfig targets ES6.)
|
||||
const failure = new Error(
|
||||
`Restored cache failed integrity validation (${
|
||||
code ?? "unknown"
|
||||
}): ${err.message}`
|
||||
);
|
||||
(failure as Error & { cause?: unknown }).cause = err;
|
||||
throw failure;
|
||||
}
|
||||
// Treat as a cache miss. Intentionally do NOT set the
|
||||
// `cache-hit` output here, to preserve the same downstream
|
||||
// semantics as a regular miss (see issue #1466).
|
||||
utils.logWarning(
|
||||
`Restored cache failed integrity validation (${
|
||||
code ?? "unknown"
|
||||
}) and was discarded: ${err.message}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!cacheKey) {
|
||||
// `cache-hit` is intentionally not set to `false` here to preserve existing behavior
|
||||
// See https://github.com/actions/cache/issues/1466
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as cache from "@actions/cache";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
import { RefKey } from "../constants";
|
||||
import { Inputs, RefKey } from "../constants";
|
||||
|
||||
export function isGhes(): boolean {
|
||||
const ghUrl = new URL(
|
||||
|
|
@ -66,6 +66,28 @@ export function getInputAsBool(
|
|||
return result.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `strict-paths` input and coerce it to a value the `@actions/cache`
|
||||
* toolkit understands. Unknown values default to `'warn'` (a best-effort
|
||||
* recovery — we don't want a typo to silently disable client-side validation)
|
||||
* and a workflow warning annotation is emitted so the user notices.
|
||||
*
|
||||
* Uses `core.warning()` directly (rather than this module's `logWarning()`
|
||||
* helper, which routes through `core.info()`) so an input misconfiguration
|
||||
* surfaces as a real `::warning::` annotation in the run summary.
|
||||
*/
|
||||
export function getPathValidationInput(): "off" | "warn" | "error" {
|
||||
const raw = core.getInput(Inputs.StrictPaths) || "warn";
|
||||
const normalized = raw.toLowerCase();
|
||||
if (normalized === "off" || normalized === "warn" || normalized === "error") {
|
||||
return normalized;
|
||||
}
|
||||
core.warning(
|
||||
`Unrecognized value for strict-paths: "${raw}". Falling back to "warn". Valid values are: off, warn, error.`
|
||||
);
|
||||
return "warn";
|
||||
}
|
||||
|
||||
export function isCacheFeatureAvailable(): boolean {
|
||||
if (cache.isFeatureAvailable()) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ interface CacheInput {
|
|||
enableCrossOsArchive?: boolean;
|
||||
failOnCacheMiss?: boolean;
|
||||
lookupOnly?: boolean;
|
||||
strictPaths?: string;
|
||||
failOnCacheInvalid?: boolean;
|
||||
}
|
||||
|
||||
export function setInputs(input: CacheInput): void {
|
||||
|
|
@ -32,6 +34,13 @@ export function setInputs(input: CacheInput): void {
|
|||
setInput(Inputs.FailOnCacheMiss, input.failOnCacheMiss.toString());
|
||||
input.lookupOnly !== undefined &&
|
||||
setInput(Inputs.LookupOnly, input.lookupOnly.toString());
|
||||
input.strictPaths !== undefined &&
|
||||
setInput(Inputs.StrictPaths, input.strictPaths);
|
||||
input.failOnCacheInvalid !== undefined &&
|
||||
setInput(
|
||||
Inputs.FailOnCacheInvalid,
|
||||
input.failOnCacheInvalid.toString()
|
||||
);
|
||||
}
|
||||
|
||||
export function clearInputs(): void {
|
||||
|
|
@ -42,4 +51,6 @@ export function clearInputs(): void {
|
|||
delete process.env[getInputName(Inputs.EnableCrossOsArchive)];
|
||||
delete process.env[getInputName(Inputs.FailOnCacheMiss)];
|
||||
delete process.env[getInputName(Inputs.LookupOnly)];
|
||||
delete process.env[getInputName(Inputs.StrictPaths)];
|
||||
delete process.env[getInputName(Inputs.FailOnCacheInvalid)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,5 +59,5 @@
|
|||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
},
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
"exclude": ["node_modules", "**/*.test.ts", "__tests__"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue