mirror of https://github.com/actions/cache
Merge c855662eeb into 27d5ce7f10
This commit is contained in:
commit
2b49858bbf
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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…
Reference in New Issue