diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29ff668..06aa6a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,8 +40,9 @@ jobs: version: "1.104.0" - run: sam --version | grep -F 1.104.0 - - name: Test official installer (pinned version; should use cache) + - name: Test official installer (pinned version; no published digest, so uncached) uses: ./ + id: installer-pinned-cached with: use-installer: true version: "1.104.0" @@ -56,6 +57,36 @@ jobs: sam --version | grep -F "$version" shell: bash + # A version with a published SHA256 digest can be cached, so the second + # invocation must restore it and pass integrity verification. This is the + # only coverage of the verified cache-hit path against the real cache + # service; unit tests mock @actions/cache and cannot exercise it. + - name: Test verified cache (cold) + uses: ./ + id: cache-cold + with: + use-installer: true + version: "1.166.2" + - run: sam --version | grep -F 1.166.2 + + - name: Test verified cache (warm; must hit cache) + uses: ./ + id: cache-warm + with: + use-installer: true + version: "1.166.2" + - run: sam --version | grep -F 1.166.2 + - name: Assert cache was used + run: | + echo "cold cache-hit=${{ steps.cache-cold.outputs.cache-hit }}" + echo "warm cache-hit=${{ steps.cache-warm.outputs.cache-hit }}" + test "${{ steps.cache-warm.outputs.cache-hit }}" = "true" + + # 1.104.0 predates published asset digests, so it cannot be verified and + # must therefore never be served from the cache. + - name: Assert unverifiable version does not use the cache + run: test "${{ steps.installer-pinned-cached.outputs.cache-hit }}" != "true" + integ: strategy: fail-fast: false diff --git a/README.md b/README.md index 2461a2c..75f7d30 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ jobs: - uses: aws-actions/setup-sam@v3 with: use-installer: true - token: ${{ secrets.GITHUB_TOKEN }} - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.ASSUME_ROLE_ARN }} @@ -65,13 +64,11 @@ You can use [`actions/setup-python`](https://github.com/actions/setup-python) to ### `token` -> **Note** -> -> It is recommended to use token to have higher rate limit. Default [unauthenticated users](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users) without a token will have a lesser rate limit enforced. +The GITHUB Authentication token used to call the GITHUB release APIs, which `use-installer: true` reads to resolve the version to install and to obtain the published archive digest it verifies against. -The GITHUB Authentication token to use for calling the GITHUB [Get the latest release](https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release) API. Defaults to call the API as unauthenticated request if not specified. +On `github.com` this defaults to the job's [`GITHUB_TOKEN`](https://docs.github.com/en/actions/security-guides/automatic-token-authentication), so the per-repository rate limit applies rather than the much smaller [unauthenticated limit](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users), which is shared per IP across runners. You do not normally need to set it. -The parameter can accept either [`GITHUB_TOKEN`](https://docs.github.com/en/actions/security-guides/automatic-token-authentication) or [`PAT(Personal Access Token)`](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) tokens. +The parameter can accept either `GITHUB_TOKEN` or a [PAT (Personal Access Token)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). ## Security diff --git a/action.yml b/action.yml index 5004417..3df2118 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,16 @@ inputs: token: description: "Authentication token to be used to call GITHUB Apis" required: false + # The installer path reads the published archive digest from the release + # API on every run, so an unauthenticated request (60/hour per IP, shared + # across runners) can be exhausted by unrelated jobs and leave nothing to + # verify against. Default to the job's token so the limit is per repository + # instead. api.github.com is hardcoded, so a GitHub Enterprise Server token + # would be both useless here and sent off-instance - send nothing there. + default: ${{ github.server_url == 'https://github.com' && github.token || '' }} +outputs: + cache-hit: + description: "Whether AWS SAM CLI was restored from the GitHub Actions cache and passed integrity verification" runs: using: node24 main: dist/index.js diff --git a/dist/index.js b/dist/index.js index 269aec8..4f2649e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -7,6 +7,7 @@ const fs = __nccwpck_require__(79896); const path = __nccwpck_require__(16928); const os = __nccwpck_require__(70857); +const crypto = __nccwpck_require__(76982); const core = __nccwpck_require__(37484); const exec = __nccwpck_require__(95236); @@ -131,60 +132,228 @@ function isSemver(s) { } /** - * Get latest SAM CLI version from https://api.github.com/repos/aws/aws-sam-cli/releases/latest + * The cache key prefix. * + * Earlier versions of this action cached the extracted install directory and + * trusted it without verification. This action caches the release archive + * instead and checks it against the digest published on the GitHub release + * before use. The prefix records that entries under it are only ever consumed + * after verification, and keeps entries written by the previous, unverified + * layout - including any that were tampered with - from being restored here. + */ +const CACHE_KEY_PREFIX = "sam-cli-verified"; + +/** + * Returns the release archive name for an architecture. + * + * @param {string} arch - The architecture (x86_64 or arm64). + */ +function archiveName(arch) { + return `aws-sam-cli-linux-${arch}.zip`; +} + +/** + * Fetches release metadata from the GitHub API. + * + * @param {string} url - The release API URL. * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The parsed release payload. + * @throws {Error} Throws if the request fails. */ -async function getLatestReleaseTag(token) { - const url = "https://api.github.com/repos/aws/aws-sam-cli/releases/latest"; +async function fetchRelease(url, token) { const client = new httpm.HttpClient("setup-sam"); let httpHeaders = undefined; - try { - //By default, GITHUB API calls will be made as unauthenticated requests. If token is present, it will be made as authenticated requests. - if (token) { - httpHeaders = { - Authorization: `Bearer ${token}`, - }; - } + // By default, GITHUB API calls will be made as unauthenticated requests. If + // token is present, it will be made as authenticated requests. + if (token) { + httpHeaders = { + Authorization: `Bearer ${token}`, + }; + } - const response = await client.get(url, httpHeaders); - if (response.message.statusCode !== 200) { - throw new Error( - `Failed to fetch data: ${response.message.statusCode} ${response.message.statusMessage}`, - ); - } + const response = await client.get(url, httpHeaders); + if (response.message.statusCode !== 200) { + throw new Error( + `Failed to fetch data: ${response.message.statusCode} ${response.message.statusMessage}`, + ); + } + + return JSON.parse(await response.readBody()); +} - const data = JSON.parse(await response.readBody()); - return data.tag_name.substring(1); +/** + * Get the latest SAM CLI release from + * https://api.github.com/repos/aws/aws-sam-cli/releases/latest + * + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The release payload, or null on failure. + */ +async function getLatestRelease(token) { + const url = "https://api.github.com/repos/aws/aws-sam-cli/releases/latest"; + + try { + return await fetchRelease(url, token); } catch (error) { core.warning("Unable to get SAM CLI's latest release tag ", error); + return null; + } +} + +/** + * Get a specific SAM CLI release by tag. + * + * @param {string} version - The SAM CLI version (x.y.z). + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The release payload, or null on failure. + */ +async function getReleaseByTag(version, token) { + const url = `https://api.github.com/repos/aws/aws-sam-cli/releases/tags/v${version}`; + + try { + return await fetchRelease(url, token); + } catch (error) { + core.warning( + `Unable to get release metadata for v${version}: ${error.message}`, + ); + return null; + } +} + +/** + * Extracts the published SHA256 digest of the release archive. + * + * GitHub only populates the asset `digest` field for releases published after + * mid-2025, so this returns an empty string for older versions. + * + * @param {object} release - The release payload. + * @param {string} arch - The architecture (x86_64 or arm64). + * @returns {string} The lowercase hex digest, or "" when unavailable. + */ +function publishedSha256(release, arch) { + if (!release || !Array.isArray(release.assets)) { return ""; } + + const asset = release.assets.find((a) => a.name === archiveName(arch)); + if (!asset || !asset.digest) { + return ""; + } + + const match = /^sha256:([0-9a-f]{64})$/i.exec(String(asset.digest).trim()); + return match ? match[1].toLowerCase() : ""; +} + +/** + * Computes the SHA256 digest of a file. + * + * @param {string} filePath - The file to hash. + * @returns {Promise} The lowercase hex digest. + */ +function fileSha256(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fs.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); } /** - * Downloads and caches SAM CLI. + * Verifies an archive against the published digest. + * + * @param {string} archivePath - The archive to verify. + * @param {string} expectedSha256 - The expected lowercase hex digest. + * @returns {Promise} Whether the archive matches. + */ +async function verifyArchive(archivePath, expectedSha256) { + try { + const actual = await fileSha256(archivePath); + if (actual === expectedSha256) { + return true; + } + core.warning( + `SHA256 mismatch: expected ${expectedSha256}, calculated ${actual}`, + ); + return false; + } catch (error) { + core.warning(`Unable to verify archive: ${error.message}`); + return false; + } +} + +/** + * Extracts an archive to a new temporary directory. + * + * @param {string} archivePath - The archive to extract. + * @returns {Promise} The directory SAM CLI is installed in. + */ +async function extractArchive(archivePath) { + const tempDir = mkdirTemp(); + await tc.extractZip(archivePath, tempDir); + return path.join(tempDir, "dist"); +} + +/** + * Downloads SAM CLI, verifies it, and caches the verified archive. * * @param {string} version - The SAM CLI version. * @param {string} arch - The architecture (x86_64 or arm64). - * @param {string} installDir - The installation directory. + * @param {string} expectedSha256 - The expected lowercase hex digest. + * @param {string} archivePath - Where to store the archive. * @param {string} cacheKey - The cache key. + * @param {boolean} saveToCache - Whether to save the verified archive. False + * when an entry is already known to exist under `cacheKey`: entries are + * immutable, so the save would be rejected after first archiving ~95 MB. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadAndCache(version, arch, installDir, cacheKey) { - const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/aws-sam-cli-linux-${arch}.zip`; +async function downloadVerifyAndCache( + version, + arch, + expectedSha256, + archivePath, + cacheKey, + saveToCache, +) { + const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}`; try { const toolPath = await tc.downloadTool(url); - await tc.extractZip(toolPath, installDir); - const binDir = path.join(installDir, "dist"); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.copyFileSync(toolPath, archivePath); - try { - await cache.saveCache([installDir], cacheKey); - core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); - } catch (error) { - core.warning(`Failed to save to cache: ${error.message}`); + if (!(await verifyArchive(archivePath, expectedSha256))) { + core.setFailed( + `Downloaded AWS SAM CLI ${version} does not match its published SHA256 digest. Refusing to use it.`, + ); + return ""; + } + + const binDir = await extractArchive(archivePath); + + if (saveToCache) { + try { + // saveCache handles every failure except ValidationError itself: + // it logs the reason at a level that fits the error and returns -1. + // So the return value, not an exception, says whether the entry was + // written. -1 covers several unrelated causes (another job writing + // the same key, a read-only cache, a service error), so report the + // outcome without guessing which one applied. + const cacheId = await cache.saveCache([archivePath], cacheKey); + if (cacheId === -1) { + core.info( + `AWS SAM CLI ${version} was not saved to the GitHub Actions cache`, + ); + } else { + core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); + } + } catch (error) { + // Only ValidationError reaches here. + core.warning( + `Failed to save AWS SAM CLI ${version} to the GitHub Actions cache: ${error.message}`, + ); + } } return binDir; @@ -195,13 +364,19 @@ async function downloadAndCache(version, arch, installDir, cacheKey) { } /** - * Downloads SAM CLI without caching. + * Downloads SAM CLI without caching it. + * + * Used when no published digest is available to verify against, so the archive + * must not be trusted across runs. * * @param {string} arch - The architecture (x86_64 or arm64). + * @param {string} [version] - The SAM CLI version; latest when omitted. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadWithoutCache(arch) { - const url = `https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${arch}.zip`; +async function downloadWithoutCache(arch, version) { + const url = version + ? `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}` + : `https://github.com/aws/aws-sam-cli/releases/latest/download/${archiveName(arch)}`; const tempDir = mkdirTemp(); try { @@ -244,11 +419,23 @@ async function installUsingNativeInstaller(inputVersion, token) { return ""; } - // Determine version + const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; + + // Determine version, and fetch the release metadata we need for the + // integrity check in the same request. let version = inputVersion; - if (!version) { - version = await getLatestReleaseTag(token); - if (!version || !isSemver(version)) { + let release = null; + if (version) { + release = await getReleaseByTag(version, token); + } else { + release = await getLatestRelease(token); + const tag = + release && typeof release.tag_name === "string" + ? release.tag_name.substring(1) + : ""; + if (isSemver(tag)) { + version = tag; + } else { core.info( "Unable to determine version. Downloading latest without caching.", ); @@ -258,7 +445,6 @@ async function installUsingNativeInstaller(inputVersion, token) { } // Validate ARM64 version requirement - const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; if (version && os.arch() === "arm64" && semverLt(version, "1.104.0")) { core.setFailed( "ARM64 installer is only available for versions 1.104.0 and above", @@ -266,31 +452,65 @@ async function installUsingNativeInstaller(inputVersion, token) { return ""; } - // Try cache if we have a version - if (version) { - // Include ImageOS in cache key to isolate caches between runner images - const imageOS = process.env.ImageOS || "unknown"; - const cacheKey = `sam-cli-${os.platform()}-${imageOS}-${arch}-${version}`; - const installDir = path.join(os.homedir(), ".sam-cli-cache", version); + // Download without caching (no version) + if (!version) { + return await downloadWithoutCache(arch); + } - try { - const cacheHit = await cache.restoreCache([installDir], cacheKey); - if (cacheHit) { + // Only cache what we can verify. Restored cache content is writable by + // anything that can write to a cache scope the job reads, so without a + // published digest to check it against there is no safe way to trust it. + const expectedSha256 = publishedSha256(release, arch); + if (!expectedSha256) { + core.info( + `No published SHA256 digest is available for v${version}, so the GitHub Actions cache cannot be used safely. Downloading the official archive instead.`, + ); + return await downloadWithoutCache(arch, version); + } + + // Include ImageOS in cache key to isolate caches between runner images + const imageOS = process.env.ImageOS || "unknown"; + const cacheKey = `${CACHE_KEY_PREFIX}-${os.platform()}-${imageOS}-${arch}-${version}`; + const archivePath = path.join( + os.homedir(), + ".sam-cli-cache", + version, + archiveName(arch), + ); + + // Set when an entry already exists under the key but failed verification. + // It cannot be overwritten, so there is no point trying to save over it. + let entryRejected = false; + + try { + const cacheHit = await cache.restoreCache([archivePath], cacheKey); + if (cacheHit) { + if (await verifyArchive(archivePath, expectedSha256)) { core.info( `Using cached AWS SAM CLI ${version} from GitHub Actions cache`, ); - return path.join(installDir, "dist"); + core.setOutput("cache-hit", true); + return await extractArchive(archivePath); } - } catch (error) { - core.warning(`Failed to restore from cache: ${error.message}`); + entryRejected = true; + core.warning( + `Cached AWS SAM CLI ${version} does not match its published SHA256 digest, so the cached copy is being ignored and the official archive will be downloaded instead. ` + + `GitHub Actions cache entries are immutable, so this entry cannot be overwritten and will be skipped on every run until it is deleted: ` + + `gh cache delete '${cacheKey}'`, + ); } - - // Download and cache - return await downloadAndCache(version, arch, installDir, cacheKey); + } catch (error) { + core.warning(`Failed to restore from cache: ${error.message}`); } - // Download without caching (no version) - return await downloadWithoutCache(arch); + return await downloadVerifyAndCache( + version, + arch, + expectedSha256, + archivePath, + cacheKey, + !entryRejected, + ); } async function setup() { @@ -301,6 +521,8 @@ async function setup() { const useInstaller = core.getBooleanInput("use-installer"); const token = getInput("token", /^.*$/, ""); + core.setOutput("cache-hit", false); + const binPath = useInstaller ? await installUsingNativeInstaller(version, token) : await installSamCli(python, version); diff --git a/lib/setup.js b/lib/setup.js index 5012442..745dfd1 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -1,6 +1,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +const crypto = require("crypto"); const core = require("@actions/core"); const exec = require("@actions/exec"); @@ -125,60 +126,228 @@ function isSemver(s) { } /** - * Get latest SAM CLI version from https://api.github.com/repos/aws/aws-sam-cli/releases/latest + * The cache key prefix. * + * Earlier versions of this action cached the extracted install directory and + * trusted it without verification. This action caches the release archive + * instead and checks it against the digest published on the GitHub release + * before use. The prefix records that entries under it are only ever consumed + * after verification, and keeps entries written by the previous, unverified + * layout - including any that were tampered with - from being restored here. + */ +const CACHE_KEY_PREFIX = "sam-cli-verified"; + +/** + * Returns the release archive name for an architecture. + * + * @param {string} arch - The architecture (x86_64 or arm64). + */ +function archiveName(arch) { + return `aws-sam-cli-linux-${arch}.zip`; +} + +/** + * Fetches release metadata from the GitHub API. + * + * @param {string} url - The release API URL. * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The parsed release payload. + * @throws {Error} Throws if the request fails. */ -async function getLatestReleaseTag(token) { - const url = "https://api.github.com/repos/aws/aws-sam-cli/releases/latest"; +async function fetchRelease(url, token) { const client = new httpm.HttpClient("setup-sam"); let httpHeaders = undefined; - try { - //By default, GITHUB API calls will be made as unauthenticated requests. If token is present, it will be made as authenticated requests. - if (token) { - httpHeaders = { - Authorization: `Bearer ${token}`, - }; - } + // By default, GITHUB API calls will be made as unauthenticated requests. If + // token is present, it will be made as authenticated requests. + if (token) { + httpHeaders = { + Authorization: `Bearer ${token}`, + }; + } - const response = await client.get(url, httpHeaders); - if (response.message.statusCode !== 200) { - throw new Error( - `Failed to fetch data: ${response.message.statusCode} ${response.message.statusMessage}`, - ); - } + const response = await client.get(url, httpHeaders); + if (response.message.statusCode !== 200) { + throw new Error( + `Failed to fetch data: ${response.message.statusCode} ${response.message.statusMessage}`, + ); + } + + return JSON.parse(await response.readBody()); +} + +/** + * Get the latest SAM CLI release from + * https://api.github.com/repos/aws/aws-sam-cli/releases/latest + * + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The release payload, or null on failure. + */ +async function getLatestRelease(token) { + const url = "https://api.github.com/repos/aws/aws-sam-cli/releases/latest"; - const data = JSON.parse(await response.readBody()); - return data.tag_name.substring(1); + try { + return await fetchRelease(url, token); } catch (error) { core.warning("Unable to get SAM CLI's latest release tag ", error); + return null; + } +} + +/** + * Get a specific SAM CLI release by tag. + * + * @param {string} version - The SAM CLI version (x.y.z). + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The release payload, or null on failure. + */ +async function getReleaseByTag(version, token) { + const url = `https://api.github.com/repos/aws/aws-sam-cli/releases/tags/v${version}`; + + try { + return await fetchRelease(url, token); + } catch (error) { + core.warning( + `Unable to get release metadata for v${version}: ${error.message}`, + ); + return null; + } +} + +/** + * Extracts the published SHA256 digest of the release archive. + * + * GitHub only populates the asset `digest` field for releases published after + * mid-2025, so this returns an empty string for older versions. + * + * @param {object} release - The release payload. + * @param {string} arch - The architecture (x86_64 or arm64). + * @returns {string} The lowercase hex digest, or "" when unavailable. + */ +function publishedSha256(release, arch) { + if (!release || !Array.isArray(release.assets)) { + return ""; + } + + const asset = release.assets.find((a) => a.name === archiveName(arch)); + if (!asset || !asset.digest) { return ""; } + + const match = /^sha256:([0-9a-f]{64})$/i.exec(String(asset.digest).trim()); + return match ? match[1].toLowerCase() : ""; +} + +/** + * Computes the SHA256 digest of a file. + * + * @param {string} filePath - The file to hash. + * @returns {Promise} The lowercase hex digest. + */ +function fileSha256(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fs.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +/** + * Verifies an archive against the published digest. + * + * @param {string} archivePath - The archive to verify. + * @param {string} expectedSha256 - The expected lowercase hex digest. + * @returns {Promise} Whether the archive matches. + */ +async function verifyArchive(archivePath, expectedSha256) { + try { + const actual = await fileSha256(archivePath); + if (actual === expectedSha256) { + return true; + } + core.warning( + `SHA256 mismatch: expected ${expectedSha256}, calculated ${actual}`, + ); + return false; + } catch (error) { + core.warning(`Unable to verify archive: ${error.message}`); + return false; + } +} + +/** + * Extracts an archive to a new temporary directory. + * + * @param {string} archivePath - The archive to extract. + * @returns {Promise} The directory SAM CLI is installed in. + */ +async function extractArchive(archivePath) { + const tempDir = mkdirTemp(); + await tc.extractZip(archivePath, tempDir); + return path.join(tempDir, "dist"); } /** - * Downloads and caches SAM CLI. + * Downloads SAM CLI, verifies it, and caches the verified archive. * * @param {string} version - The SAM CLI version. * @param {string} arch - The architecture (x86_64 or arm64). - * @param {string} installDir - The installation directory. + * @param {string} expectedSha256 - The expected lowercase hex digest. + * @param {string} archivePath - Where to store the archive. * @param {string} cacheKey - The cache key. + * @param {boolean} saveToCache - Whether to save the verified archive. False + * when an entry is already known to exist under `cacheKey`: entries are + * immutable, so the save would be rejected after first archiving ~95 MB. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadAndCache(version, arch, installDir, cacheKey) { - const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/aws-sam-cli-linux-${arch}.zip`; +async function downloadVerifyAndCache( + version, + arch, + expectedSha256, + archivePath, + cacheKey, + saveToCache, +) { + const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}`; try { const toolPath = await tc.downloadTool(url); - await tc.extractZip(toolPath, installDir); - const binDir = path.join(installDir, "dist"); - - try { - await cache.saveCache([installDir], cacheKey); - core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); - } catch (error) { - core.warning(`Failed to save to cache: ${error.message}`); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.copyFileSync(toolPath, archivePath); + + if (!(await verifyArchive(archivePath, expectedSha256))) { + core.setFailed( + `Downloaded AWS SAM CLI ${version} does not match its published SHA256 digest. Refusing to use it.`, + ); + return ""; + } + + const binDir = await extractArchive(archivePath); + + if (saveToCache) { + try { + // saveCache handles every failure except ValidationError itself: + // it logs the reason at a level that fits the error and returns -1. + // So the return value, not an exception, says whether the entry was + // written. -1 covers several unrelated causes (another job writing + // the same key, a read-only cache, a service error), so report the + // outcome without guessing which one applied. + const cacheId = await cache.saveCache([archivePath], cacheKey); + if (cacheId === -1) { + core.info( + `AWS SAM CLI ${version} was not saved to the GitHub Actions cache`, + ); + } else { + core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); + } + } catch (error) { + // Only ValidationError reaches here. + core.warning( + `Failed to save AWS SAM CLI ${version} to the GitHub Actions cache: ${error.message}`, + ); + } } return binDir; @@ -189,13 +358,19 @@ async function downloadAndCache(version, arch, installDir, cacheKey) { } /** - * Downloads SAM CLI without caching. + * Downloads SAM CLI without caching it. + * + * Used when no published digest is available to verify against, so the archive + * must not be trusted across runs. * * @param {string} arch - The architecture (x86_64 or arm64). + * @param {string} [version] - The SAM CLI version; latest when omitted. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadWithoutCache(arch) { - const url = `https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${arch}.zip`; +async function downloadWithoutCache(arch, version) { + const url = version + ? `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}` + : `https://github.com/aws/aws-sam-cli/releases/latest/download/${archiveName(arch)}`; const tempDir = mkdirTemp(); try { @@ -238,11 +413,23 @@ async function installUsingNativeInstaller(inputVersion, token) { return ""; } - // Determine version + const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; + + // Determine version, and fetch the release metadata we need for the + // integrity check in the same request. let version = inputVersion; - if (!version) { - version = await getLatestReleaseTag(token); - if (!version || !isSemver(version)) { + let release = null; + if (version) { + release = await getReleaseByTag(version, token); + } else { + release = await getLatestRelease(token); + const tag = + release && typeof release.tag_name === "string" + ? release.tag_name.substring(1) + : ""; + if (isSemver(tag)) { + version = tag; + } else { core.info( "Unable to determine version. Downloading latest without caching.", ); @@ -252,7 +439,6 @@ async function installUsingNativeInstaller(inputVersion, token) { } // Validate ARM64 version requirement - const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; if (version && os.arch() === "arm64" && semverLt(version, "1.104.0")) { core.setFailed( "ARM64 installer is only available for versions 1.104.0 and above", @@ -260,31 +446,65 @@ async function installUsingNativeInstaller(inputVersion, token) { return ""; } - // Try cache if we have a version - if (version) { - // Include ImageOS in cache key to isolate caches between runner images - const imageOS = process.env.ImageOS || "unknown"; - const cacheKey = `sam-cli-${os.platform()}-${imageOS}-${arch}-${version}`; - const installDir = path.join(os.homedir(), ".sam-cli-cache", version); - - try { - const cacheHit = await cache.restoreCache([installDir], cacheKey); - if (cacheHit) { + // Download without caching (no version) + if (!version) { + return await downloadWithoutCache(arch); + } + + // Only cache what we can verify. Restored cache content is writable by + // anything that can write to a cache scope the job reads, so without a + // published digest to check it against there is no safe way to trust it. + const expectedSha256 = publishedSha256(release, arch); + if (!expectedSha256) { + core.info( + `No published SHA256 digest is available for v${version}, so the GitHub Actions cache cannot be used safely. Downloading the official archive instead.`, + ); + return await downloadWithoutCache(arch, version); + } + + // Include ImageOS in cache key to isolate caches between runner images + const imageOS = process.env.ImageOS || "unknown"; + const cacheKey = `${CACHE_KEY_PREFIX}-${os.platform()}-${imageOS}-${arch}-${version}`; + const archivePath = path.join( + os.homedir(), + ".sam-cli-cache", + version, + archiveName(arch), + ); + + // Set when an entry already exists under the key but failed verification. + // It cannot be overwritten, so there is no point trying to save over it. + let entryRejected = false; + + try { + const cacheHit = await cache.restoreCache([archivePath], cacheKey); + if (cacheHit) { + if (await verifyArchive(archivePath, expectedSha256)) { core.info( `Using cached AWS SAM CLI ${version} from GitHub Actions cache`, ); - return path.join(installDir, "dist"); + core.setOutput("cache-hit", true); + return await extractArchive(archivePath); } - } catch (error) { - core.warning(`Failed to restore from cache: ${error.message}`); + entryRejected = true; + core.warning( + `Cached AWS SAM CLI ${version} does not match its published SHA256 digest, so the cached copy is being ignored and the official archive will be downloaded instead. ` + + `GitHub Actions cache entries are immutable, so this entry cannot be overwritten and will be skipped on every run until it is deleted: ` + + `gh cache delete '${cacheKey}'`, + ); } - - // Download and cache - return await downloadAndCache(version, arch, installDir, cacheKey); + } catch (error) { + core.warning(`Failed to restore from cache: ${error.message}`); } - // Download without caching (no version) - return await downloadWithoutCache(arch); + return await downloadVerifyAndCache( + version, + arch, + expectedSha256, + archivePath, + cacheKey, + !entryRejected, + ); } async function setup() { @@ -295,6 +515,8 @@ async function setup() { const useInstaller = core.getBooleanInput("use-installer"); const token = getInput("token", /^.*$/, ""); + core.setOutput("cache-hit", false); + const binPath = useInstaller ? await installUsingNativeInstaller(version, token) : await installSamCli(python, version); diff --git a/test/setup.test.js b/test/setup.test.js index fe7c0ec..1b504fe 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -5,6 +5,7 @@ jest.mock("@actions/tool-cache"); jest.mock("@actions/cache"); const os = require("os"); +const path = require("path"); const core = require("@actions/core"); const exec = require("@actions/exec"); @@ -91,220 +92,483 @@ test.each([ await expect(setup).rejects.toThrow(Error); }); +// --------------------------------------------------------------------------- +// Native installer / cache integrity +// +// The action caches the release archive (not the extracted directory) and +// verifies it against the SHA256 digest published on the GitHub release before +// trusting it. Restored cache content is writable by anything that can write +// to a cache scope the job reads, so these tests pin the verification +// behaviour. +// --------------------------------------------------------------------------- + +const fs = require("fs"); +const crypto = require("crypto"); + +function sha256(buffer) { + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +const GOOD_ARCHIVE = Buffer.from("official aws-sam-cli release archive"); +const GOOD_SHA256 = sha256(GOOD_ARCHIVE); +const OTHER_ARCHIVE = Buffer.from("some other archive contents"); + +/** A digest belonging to a different asset of the same release. */ +const DECOY_SHA256 = sha256(Buffer.from("a different architecture's archive")); + +/** + * Builds a GitHub release payload. + * + * Real releases carry an archive per architecture plus installers for other + * platforms, so the fixture lists several assets and gives the one named by + * `arch` the digest under test. Every other asset carries a decoy digest, so a + * lookup that picks the wrong asset - by suffix, by position, or by ignoring + * the architecture - fails verification instead of passing by coincidence. + * + * GitHub only populates `digest` for releases published after mid-2025, so + * `digest: null` models an older release. + * + * @param {string} tag - The release tag, e.g. "v1.166.2". + * @param {string} arch - The architecture whose asset carries `digest`. + * @param {string|null} digest - Hex SHA256, or null to model an older release. + */ +function releaseBody(tag, arch, digest) { + const other = arch === "arm64" ? "x86_64" : "arm64"; + const decoy = digest ? `sha256:${DECOY_SHA256}` : null; + + return JSON.stringify({ + tag_name: tag, + assets: [ + // Deliberately listed before the asset under test so that picking the + // first entry, rather than matching on name, is caught. + { name: `aws-sam-cli-linux-${other}.zip`, digest: decoy }, + { name: `aws-sam-cli-linux-${other}.zip.sig`, digest: decoy }, + { + name: `aws-sam-cli-linux-${arch}.zip`, + digest: digest ? `sha256:${digest}` : null, + }, + { name: `aws-sam-cli-linux-${arch}.zip.sig`, digest: decoy }, + { name: "aws-sam-cli-macos-arm64.pkg", digest: decoy }, + { name: "AWS_SAM_CLI_64_PY3.msi", digest: decoy }, + ], + }); +} + +function mockRelease(tag, arch, digest) { + return jest.spyOn(httpm.HttpClient.prototype, "get").mockResolvedValue({ + message: { statusCode: 200 }, + readBody: () => releaseBody(tag, arch, digest), + }); +} + +let fakeHome; + +beforeEach(() => { + fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "setup-sam-home-")); + jest.spyOn(os, "homedir").mockReturnValue(fakeHome); +}); + +afterEach(() => { + fs.rmSync(fakeHome, { recursive: true, force: true }); +}); + +function archivePathFor(version, arch) { + return path.join( + fakeHome, + ".sam-cli-cache", + version, + `aws-sam-cli-linux-${arch}.zip`, + ); +} + +/** Simulates restoreCache writing the entry back to disk. */ +function seedCache(version, arch, contents) { + const target = archivePathFor(version, arch); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + return target; +} + +/** Simulates downloadTool by returning a real file on disk. */ +function stubDownload(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "setup-sam-dl-")); + const file = path.join(dir, "download.zip"); + fs.writeFileSync(file, contents); + tc.downloadTool = jest.fn().mockResolvedValue(file); + return file; +} + +function useInstaller(version) { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce(version); +} + test.each([ - ["x64", "x86_64", "1.104.567"], - ["arm64", "arm64", "1.104.0"], - ["x64", "x86_64", "1.23.456"], - ["arm64", "arm64", "1.135.0"], + ["x64", "x86_64", "1.166.2"], + ["arm64", "arm64", "1.166.2"], + ["x64", "x86_64", "1.140.0"], + ["arm64", "arm64", "1.140.0"], ])( - "when use-installer enabled and version specified and cached version exists, uses cached version %s", - async (inputArch, expectedArch, input) => { + "cache hit whose archive matches the published digest is used (%s %s %s)", + async (inputArch, expectedArch, version) => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue(inputArch); + process.env.ImageOS = "ubuntu24"; - // Mock ImageOS environment variable - const originalImageOS = process.env.ImageOS; - process.env.ImageOS = "ubuntu22"; - - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce(input); + useInstaller(version); + mockRelease(`v${version}`, expectedArch, GOOD_SHA256); + seedCache(version, expectedArch, GOOD_ARCHIVE); - cache.restoreCache = jest.fn().mockResolvedValueOnce("cache-key-hit"); + cache.restoreCache = jest.fn().mockResolvedValue("cache-key-hit"); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + tc.downloadTool = jest.fn(); await setup(); - expect(cache.restoreCache).toHaveBeenCalledTimes(1); expect(cache.restoreCache).toHaveBeenCalledWith( - expect.arrayContaining([expect.stringContaining(".sam-cli-cache")]), - `sam-cli-linux-ubuntu22-${expectedArch}-${input}`, + [archivePathFor(version, expectedArch)], + `sam-cli-verified-linux-ubuntu24-${expectedArch}-${version}`, ); - expect(cache.saveCache).toHaveBeenCalledTimes(0); - expect(tc.downloadTool).toHaveBeenCalledTimes(0); - - expect(core.addPath).toHaveBeenCalled(); - - // Restore original ImageOS - if (originalImageOS === undefined) { - delete process.env.ImageOS; - } else { - process.env.ImageOS = originalImageOS; - } + expect(tc.downloadTool).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(tc.extractZip).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenCalledWith("cache-hit", true); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); }, ); +test("cache entry that fails verification is ignored and the official archive is downloaded", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + // The cache holds something other than the official archive. + seedCache("1.166.2", "x86_64", OTHER_ARCHIVE); + + cache.restoreCache = jest.fn().mockResolvedValue("cache-key-hit"); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("does not match its published SHA256 digest"), + ); + // Falls back to the official download rather than trusting the cache. + expect(tc.downloadTool).toHaveBeenCalledWith( + "https://github.com/aws/aws-sam-cli/releases/download/v1.166.2/aws-sam-cli-linux-x86_64.zip", + ); + expect(core.setOutput).toHaveBeenCalledWith("cache-hit", false); + expect(core.setOutput).not.toHaveBeenCalledWith("cache-hit", true); + // The key is already taken by the entry that was just rejected, and entries + // are immutable, so a save could only fail after archiving ~95 MB. + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); +}); + +test("cache miss downloads, verifies, then caches the archive", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + expect(tc.downloadTool).toHaveBeenCalledTimes(1); + expect(cache.saveCache).toHaveBeenCalledWith( + [archivePathFor("1.166.2", "x86_64")], + "sam-cli-verified-linux-ubuntu24-x86_64-1.166.2", + ); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); +}); + +test("downloaded archive that fails verification is not used or cached", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + // The download itself does not match the published digest. + stubDownload(OTHER_ARCHIVE); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining("does not match its published SHA256 digest"), + ); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(core.addPath).not.toHaveBeenCalled(); +}); + test.each([ - ["x64", "x86_64", "1.104.567"], - ["arm64", "arm64", "1.104.0"], - ["x64", "x86_64", "1.23.456"], - ["arm64", "arm64", "1.135.0"], + ["x64", "x86_64"], + ["arm64", "arm64"], ])( - "when use-installer enabled and version specified and cached version does not exist, downloads and caches version %s", - async (inputArch, expectedArch, input) => { + "version without a published digest is downloaded without using the cache (%s)", + async (inputArch, expectedArch) => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue(inputArch); + process.env.ImageOS = "ubuntu24"; - // Mock ImageOS environment variable - const originalImageOS = process.env.ImageOS; - process.env.ImageOS = "ubuntu22"; - - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce(input); + useInstaller("1.104.0"); + // Releases published before mid-2025 carry no asset digest. + mockRelease("v1.104.0", expectedArch, null); - cache.restoreCache = jest.fn().mockResolvedValueOnce(undefined); - cache.saveCache = jest.fn().mockResolvedValueOnce(1); - tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); - tc.downloadTool = jest - .fn() - .mockResolvedValueOnce("/path/to/downloaded/sam"); + cache.restoreCache = jest.fn(); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); - expect(cache.restoreCache).toHaveBeenCalledTimes(1); - expect(tc.downloadTool).toHaveBeenCalledTimes(1); - expect(tc.extractZip).toHaveBeenCalledTimes(1); - expect(cache.saveCache).toHaveBeenCalledTimes(1); - expect(cache.saveCache).toHaveBeenCalledWith( - expect.arrayContaining([expect.stringContaining(".sam-cli-cache")]), - `sam-cli-linux-ubuntu22-${expectedArch}-${input}`, + expect(cache.restoreCache).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(tc.downloadTool).toHaveBeenCalledWith( + `https://github.com/aws/aws-sam-cli/releases/download/v1.104.0/aws-sam-cli-linux-${expectedArch}.zip`, ); + expect(core.setOutput).toHaveBeenCalledWith("cache-hit", false); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); + }, +); - expect(core.addPath).toHaveBeenCalled(); +test.each([ + ["not prefixed with sha256", "0".repeat(64)], + ["a different algorithm", `sha512:${"0".repeat(64)}`], + ["too short to be a sha256", "sha256:abc123"], + ["not hex", `sha256:${"z".repeat(64)}`], + ["empty", ""], +])( + "digest that is %s is treated as unusable, so the cache is not used", + async (_label, rawDigest) => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; - // Restore original ImageOS - if (originalImageOS === undefined) { - delete process.env.ImageOS; - } else { - process.env.ImageOS = originalImageOS; - } + useInstaller("1.166.2"); + jest.spyOn(httpm.HttpClient.prototype, "get").mockResolvedValue({ + message: { statusCode: 200 }, + readBody: () => + JSON.stringify({ + tag_name: "v1.166.2", + assets: [{ name: "aws-sam-cli-linux-x86_64.zip", digest: rawDigest }], + }), + }); + + cache.restoreCache = jest.fn(); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + // An unparseable digest is no digest at all: there is nothing to verify + // against, so the cache must be bypassed rather than trusted. + expect(cache.restoreCache).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); }, ); -test("when use-installer enabled and version is too old for ARM, fails on ARM architecture", async () => { +test("warning for a rejected entry says it is ignored and how to clear it", async () => { jest.spyOn(os, "platform").mockReturnValue("linux"); - jest.spyOn(os, "arch").mockReturnValue("arm64"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce("1.103.0"); + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + seedCache("1.166.2", "x86_64", OTHER_ARCHIVE); + + cache.restoreCache = jest.fn().mockResolvedValue("cache-key-hit"); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); - expect(core.setFailed).toHaveBeenCalledWith( - "ARM64 installer is only available for versions 1.104.0 and above", + const key = "sam-cli-verified-linux-ubuntu24-x86_64-1.166.2"; + + // The entry is ignored, not discarded: it cannot be overwritten and will be + // restored and rejected on every run, so the warning must say how to clear it. + const mismatchWarning = core.warning.mock.calls + .map(([message]) => String(message)) + .find((message) => message.includes("does not match its published")); + expect(mismatchWarning).toBeDefined(); + expect(mismatchWarning).not.toMatch(/discard/i); + expect(mismatchWarning).toContain(`gh cache delete '${key}'`); +}); + +test("save that the cache service declines is not reported as a success", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + // What @actions/cache actually does when it cannot save (key already + // reserved, read-only cache, service error): log the reason itself and + // return -1. It does not throw. + cache.saveCache = jest.fn().mockResolvedValue(-1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + const infos = core.info.mock.calls.map(([message]) => String(message)); + expect(infos).toContain( + "AWS SAM CLI 1.166.2 was not saved to the GitHub Actions cache", ); - expect(tc.downloadTool).not.toHaveBeenCalled(); + expect(infos).not.toContain( + "Cached AWS SAM CLI 1.166.2 to GitHub Actions cache", + ); + // -1 has several unrelated causes, so it must not be blamed on an existing + // entry or come with advice to delete one. + const messages = [ + ...infos, + ...core.warning.mock.calls.map(([m]) => String(m)), + ]; + expect(messages.some((m) => m.includes("gh cache delete"))).toBe(false); + // The run still succeeds on the verified download. + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); +}); + +test("save that fails validation is reported as a warning and does not fail the run", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + // ValidationError is the one error saveCache rethrows. + const validationError = new Error("Key Validation Error"); + validationError.name = "ValidationError"; + cache.saveCache = jest.fn().mockRejectedValue(validationError); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Key Validation Error"), + ); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); +}); + +test("release metadata lookup failure falls back to an uncached download", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + jest + .spyOn(httpm.HttpClient.prototype, "get") + .mockRejectedValue(new Error("Mocked exception")); + + cache.restoreCache = jest.fn(); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + expect(cache.restoreCache).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(tc.downloadTool).toHaveBeenCalledWith( + "https://github.com/aws/aws-sam-cli/releases/download/v1.166.2/aws-sam-cli-linux-x86_64.zip", + ); + expect(core.addPath).toHaveBeenCalledTimes(1); }); test.each([ ["x64", "x86_64"], ["arm64", "arm64"], ])( - "when use-installer enabled and version not specified, cache latest version (Linux %s)", + "no version specified resolves latest and caches the verified archive (%s)", async (inputArch, expectedArch) => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue(inputArch); + process.env.ImageOS = "ubuntu24"; - // Mock ImageOS environment variable - const originalImageOS = process.env.ImageOS; - process.env.ImageOS = "ubuntu22"; + useInstaller(""); + mockRelease("v1.166.2", expectedArch, GOOD_SHA256); - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce(""); - - jest.spyOn(httpm.HttpClient.prototype, "get").mockResolvedValue({ - message: { statusCode: 200 }, - readBody: () => { - return `{ "tag_name": "v1.139.0" }`; - }, - }); - - cache.restoreCache = jest.fn().mockResolvedValueOnce(undefined); - cache.saveCache = jest.fn().mockResolvedValueOnce(1); - tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); - tc.downloadTool = jest - .fn() - .mockResolvedValueOnce("/path/to/downloaded/sam"); + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); expect(tc.downloadTool).toHaveBeenCalledWith( - `https://github.com/aws/aws-sam-cli/releases/download/v1.139.0/aws-sam-cli-linux-${expectedArch}.zip`, + `https://github.com/aws/aws-sam-cli/releases/download/v1.166.2/aws-sam-cli-linux-${expectedArch}.zip`, ); - - expect(cache.restoreCache).toHaveBeenCalledTimes(1); - expect(cache.saveCache).toHaveBeenCalledTimes(1); - expect(core.addPath).toHaveBeenCalled(); - - // Restore original ImageOS - if (originalImageOS === undefined) { - delete process.env.ImageOS; - } else { - process.env.ImageOS = originalImageOS; - } + expect(cache.saveCache).toHaveBeenCalledWith( + [archivePathFor("1.166.2", expectedArch)], + `sam-cli-verified-linux-ubuntu24-${expectedArch}-1.166.2`, + ); + expect(core.addPath).toHaveBeenCalledTimes(1); }, ); test.each([ { arch: "x64", - releaseTagVersion: "v1.110.0", - input: { userInstaller: true }, - expected: { - arch: "x86_64", - latestVersion: "v1.110.0", - headers: undefined, - }, + input: { token: undefined }, + expected: { headers: undefined }, }, { arch: "x64", - releaseTagVersion: "v1.110.0", - input: { userInstaller: true, token: "" }, - expected: { - arch: "x86_64", - latestVersion: "v1.110.0", - headers: undefined, - }, + input: { token: "" }, + expected: { headers: undefined }, }, { arch: "x64", - releaseTagVersion: "v1.110.0", - input: { userInstaller: true, token: "1234abc" }, - expected: { - arch: "x86_64", - latestVersion: "v1.110.0", - headers: { - Authorization: "Bearer 1234abc", - }, - }, + input: { token: "1234abc" }, + expected: { headers: { Authorization: "Bearer 1234abc" } }, }, ])("github api request %o", async (test) => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue(test.arch); + process.env.ImageOS = "ubuntu24"; - // Mock ImageOS environment variable - const originalImageOS = process.env.ImageOS; - process.env.ImageOS = "ubuntu22"; - - const getMock = jest - .spyOn(httpm.HttpClient.prototype, "get") - .mockResolvedValue({ - message: { statusCode: 200 }, - readBody: () => { - return `{ "tag_name": "${test.releaseTagVersion}" }`; - }, - }); - - core.getBooleanInput = jest.fn().mockReturnValue(test.input.userInstaller); + core.getBooleanInput = jest.fn().mockReturnValue(true); core.getInput = jest .fn() .mockReturnValueOnce("") .mockReturnValueOnce("python3") .mockReturnValueOnce(test.input.token); - cache.restoreCache = jest.fn().mockResolvedValueOnce(undefined); - cache.saveCache = jest.fn().mockResolvedValueOnce(1); - tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); - tc.downloadTool = jest.fn().mockResolvedValueOnce("/path/to/downloaded/sam"); + const getMock = mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); @@ -312,68 +576,73 @@ test.each([ expect.anything(), test.expected.headers, ); - - expect(tc.downloadTool).toHaveBeenCalledWith( - `https://github.com/aws/aws-sam-cli/releases/download/${test.expected.latestVersion}/aws-sam-cli-linux-${test.expected.arch}.zip`, - ); - - expect(cache.restoreCache).toHaveBeenCalledTimes(1); - expect(cache.saveCache).toHaveBeenCalledTimes(1); - expect(core.addPath).toHaveBeenCalled(); - - // Restore original ImageOS - if (originalImageOS === undefined) { - delete process.env.ImageOS; - } else { - process.env.ImageOS = originalImageOS; - } + expect(core.addPath).toHaveBeenCalledTimes(1); }); test.each([ ["x64", "x86_64"], ["arm64", "arm64"], ])( - "when use-installer enabled and version not specified, downloads latest version when getLatestReleaseTag failed (Linux %s)", - async (archInput, expectedArch) => { + "latest release lookup failure downloads latest without caching (%s)", + async (inputArch, expectedArch) => { jest.spyOn(os, "platform").mockReturnValue("linux"); - jest.spyOn(os, "arch").mockReturnValue(archInput); + jest.spyOn(os, "arch").mockReturnValue(inputArch); + + useInstaller(""); jest .spyOn(httpm.HttpClient.prototype, "get") - .mockRejectedValueOnce(new Error("Mocked exception")); - - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce(""); + .mockRejectedValue(new Error("Mocked exception")); - tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); - tc.downloadTool = jest - .fn() - .mockResolvedValueOnce("/path/to/downloaded/sam"); + cache.restoreCache = jest.fn(); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); expect(tc.downloadTool).toHaveBeenCalledWith( `https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${expectedArch}.zip`, ); - - expect(cache.restoreCache).toHaveBeenCalledTimes(0); - expect(cache.saveCache).toHaveBeenCalledTimes(0); - expect(core.addPath).toHaveBeenCalled(); + expect(cache.restoreCache).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(core.addPath).toHaveBeenCalledTimes(1); }, ); +test("version too old for ARM fails before any download or cache access", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("arm64"); + + useInstaller("1.103.0"); + mockRelease("v1.103.0", "arm64", GOOD_SHA256); + + cache.restoreCache = jest.fn(); + tc.downloadTool = jest.fn(); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + "ARM64 installer is only available for versions 1.104.0 and above", + ); + expect(tc.downloadTool).not.toHaveBeenCalled(); + expect(cache.restoreCache).not.toHaveBeenCalled(); +}); + test.each([["x64"], ["arm64"]])( - "when use-installer enabled but version is not in format x.y.z, not downloaded or checked in cache (Linux %s)", - async (archInput) => { + "version not in x.y.z format is neither downloaded nor looked up in cache (%s)", + async (inputArch) => { jest.spyOn(os, "platform").mockReturnValue("linux"); - jest.spyOn(os, "arch").mockReturnValue(archInput); + jest.spyOn(os, "arch").mockReturnValue(inputArch); core.getBooleanInput = jest.fn().mockReturnValue(true); + cache.restoreCache = jest.fn(); + tc.downloadTool = jest.fn(); for (const version of ["1.2", "1.*", "3"]) { core.getInput = jest.fn().mockReturnValueOnce(version); await setup(); - expect(tc.downloadTool).toHaveBeenCalledTimes(0); - expect(cache.restoreCache).toHaveBeenCalledTimes(0); + expect(tc.downloadTool).not.toHaveBeenCalled(); + expect(cache.restoreCache).not.toHaveBeenCalled(); } }, );