diff --git a/packages/cache/__tests__/cacheHttpClient.test.ts b/packages/cache/__tests__/cacheHttpClient.test.ts index e2201cd1cb..bdb9e68dd0 100644 --- a/packages/cache/__tests__/cacheHttpClient.test.ts +++ b/packages/cache/__tests__/cacheHttpClient.test.ts @@ -1,10 +1,10 @@ import {downloadCache} from '../src/internal/cacheHttpClient' -import {getCacheVersion} from '../src/internal/cacheUtils' -import {CompressionMethod} from '../src/internal/constants' -import * as downloadUtils from '../src/internal/downloadUtils' +import {getCacheVersion} from '../src/internal/shared/cacheUtils' +import {CompressionMethod} from '../src/internal/shared/constants' +import * as downloadUtils from '../src/internal/shared/downloadUtils' import {DownloadOptions, getDownloadOptions} from '../src/options' -jest.mock('../src/internal/downloadUtils') +jest.mock('../src/internal/shared/downloadUtils') test('getCacheVersion does not mutate arguments', async () => { const paths = ['node_modules'] diff --git a/packages/cache/__tests__/cacheUtils.test.ts b/packages/cache/__tests__/cacheUtils.test.ts index fad045b47e..2adddd5cc3 100644 --- a/packages/cache/__tests__/cacheUtils.test.ts +++ b/packages/cache/__tests__/cacheUtils.test.ts @@ -1,6 +1,6 @@ import {promises as fs} from 'fs' import * as path from 'path' -import * as cacheUtils from '../src/internal/cacheUtils' +import * as cacheUtils from '../src/internal/shared/cacheUtils' beforeEach(() => { jest.resetModules() diff --git a/packages/cache/__tests__/config.test.ts b/packages/cache/__tests__/config.test.ts index 52d86d3620..0f454eff51 100644 --- a/packages/cache/__tests__/config.test.ts +++ b/packages/cache/__tests__/config.test.ts @@ -1,4 +1,4 @@ -import * as config from '../src/internal/config' +import * as config from '../src/internal/shared/config' beforeEach(() => { jest.resetModules() diff --git a/packages/cache/__tests__/downloadUtils.test.ts b/packages/cache/__tests__/downloadUtils.test.ts index 4cc089af89..067303f64d 100644 --- a/packages/cache/__tests__/downloadUtils.test.ts +++ b/packages/cache/__tests__/downloadUtils.test.ts @@ -1,5 +1,5 @@ import * as core from '@actions/core' -import {DownloadProgress} from '../src/internal/downloadUtils' +import {DownloadProgress} from '../src/internal/shared/downloadUtils' test('download progress tracked correctly', () => { const progress = new DownloadProgress(1000) diff --git a/packages/cache/__tests__/requestUtils.test.ts b/packages/cache/__tests__/requestUtils.test.ts index 05fc573bdf..46dc0e804a 100644 --- a/packages/cache/__tests__/requestUtils.test.ts +++ b/packages/cache/__tests__/requestUtils.test.ts @@ -1,6 +1,6 @@ -import {retry, retryTypedResponse} from '../src/internal/requestUtils' +import {retry, retryTypedResponse} from '../src/internal/shared/requestUtils' import {HttpClientError} from '@actions/http-client' -import * as requestUtils from '../src/internal/requestUtils' +import * as requestUtils from '../src/internal/shared/requestUtils' interface ITestResponse { statusCode: number diff --git a/packages/cache/__tests__/restoreCache.test.ts b/packages/cache/__tests__/restoreCache.test.ts index 7992490e5a..f4c1e16362 100644 --- a/packages/cache/__tests__/restoreCache.test.ts +++ b/packages/cache/__tests__/restoreCache.test.ts @@ -2,13 +2,16 @@ import * as core from '@actions/core' import * as path from 'path' import {restoreCache} from '../src/cache' import * as cacheHttpClient from '../src/internal/cacheHttpClient' -import * as cacheUtils from '../src/internal/cacheUtils' -import {CacheFilename, CompressionMethod} from '../src/internal/constants' +import * as cacheUtils from '../src/internal/shared/cacheUtils' +import { + CacheFilename, + CompressionMethod +} from '../src/internal/shared/constants' import {ArtifactCacheEntry} from '../src/internal/contracts' import * as tar from '../src/internal/tar' jest.mock('../src/internal/cacheHttpClient') -jest.mock('../src/internal/cacheUtils') +jest.mock('../src/internal/shared/cacheUtils') jest.mock('../src/internal/tar') beforeAll(() => { @@ -19,7 +22,7 @@ beforeAll(() => { jest.spyOn(core, 'error').mockImplementation(() => {}) jest.spyOn(cacheUtils, 'getCacheFileName').mockImplementation(cm => { - const actualUtils = jest.requireActual('../src/internal/cacheUtils') + const actualUtils = jest.requireActual('../src/internal/shared/cacheUtils') return actualUtils.getCacheFileName(cm) }) }) diff --git a/packages/cache/__tests__/restoreCacheV2.test.ts b/packages/cache/__tests__/restoreCacheV2.test.ts index 485b8aebce..a6006d4e8e 100644 --- a/packages/cache/__tests__/restoreCacheV2.test.ts +++ b/packages/cache/__tests__/restoreCacheV2.test.ts @@ -1,17 +1,20 @@ import * as core from '@actions/core' import * as path from 'path' import * as tar from '../src/internal/tar' -import * as config from '../src/internal/config' -import * as cacheUtils from '../src/internal/cacheUtils' +import * as config from '../src/internal/shared/config' +import * as cacheUtils from '../src/internal/shared/cacheUtils' import * as cacheHttpClient from '../src/internal/cacheHttpClient' import {restoreCache} from '../src/cache' -import {CacheFilename, CompressionMethod} from '../src/internal/constants' +import { + CacheFilename, + CompressionMethod +} from '../src/internal/shared/constants' import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client' import {DownloadOptions} from '../src/options' jest.mock('../src/internal/cacheHttpClient') -jest.mock('../src/internal/cacheUtils') -jest.mock('../src/internal/config') +jest.mock('../src/internal/shared/cacheUtils') +jest.mock('../src/internal/shared/config') jest.mock('../src/internal/tar') let logDebugMock: jest.SpyInstance @@ -25,7 +28,7 @@ beforeAll(() => { jest.spyOn(core, 'error').mockImplementation(() => {}) jest.spyOn(cacheUtils, 'getCacheFileName').mockImplementation(cm => { - const actualUtils = jest.requireActual('../src/internal/cacheUtils') + const actualUtils = jest.requireActual('../src/internal/shared/cacheUtils') return actualUtils.getCacheFileName(cm) }) diff --git a/packages/cache/__tests__/saveCache.test.ts b/packages/cache/__tests__/saveCache.test.ts index e5ed695b1f..29a7b98139 100644 --- a/packages/cache/__tests__/saveCache.test.ts +++ b/packages/cache/__tests__/saveCache.test.ts @@ -2,9 +2,12 @@ import * as core from '@actions/core' import * as path from 'path' import {saveCache} from '../src/cache' import * as cacheHttpClient from '../src/internal/cacheHttpClient' -import * as cacheUtils from '../src/internal/cacheUtils' -import * as config from '../src/internal/config' -import {CacheFilename, CompressionMethod} from '../src/internal/constants' +import * as cacheUtils from '../src/internal/shared/cacheUtils' +import * as config from '../src/internal/shared/config' +import { + CacheFilename, + CompressionMethod +} from '../src/internal/shared/constants' import * as tar from '../src/internal/tar' import {TypedResponse} from '@actions/http-client/lib/interfaces' import { @@ -14,8 +17,8 @@ import { import {HttpClientError} from '@actions/http-client' jest.mock('../src/internal/cacheHttpClient') -jest.mock('../src/internal/cacheUtils') -jest.mock('../src/internal/config') +jest.mock('../src/internal/shared/cacheUtils') +jest.mock('../src/internal/shared/config') jest.mock('../src/internal/tar') beforeAll(() => { @@ -25,7 +28,7 @@ beforeAll(() => { jest.spyOn(core, 'warning').mockImplementation(() => {}) jest.spyOn(core, 'error').mockImplementation(() => {}) jest.spyOn(cacheUtils, 'getCacheFileName').mockImplementation(cm => { - const actualUtils = jest.requireActual('../src/internal/cacheUtils') + const actualUtils = jest.requireActual('../src/internal/shared/cacheUtils') return actualUtils.getCacheFileName(cm) }) jest.spyOn(cacheUtils, 'resolvePaths').mockImplementation(async filePaths => { diff --git a/packages/cache/__tests__/saveCacheV2.test.ts b/packages/cache/__tests__/saveCacheV2.test.ts index e96c2ac9da..977535817f 100644 --- a/packages/cache/__tests__/saveCacheV2.test.ts +++ b/packages/cache/__tests__/saveCacheV2.test.ts @@ -1,9 +1,12 @@ import * as core from '@actions/core' import * as path from 'path' import {saveCache} from '../src/cache' -import * as cacheUtils from '../src/internal/cacheUtils' -import {CacheFilename, CompressionMethod} from '../src/internal/constants' -import * as config from '../src/internal/config' +import * as cacheUtils from '../src/internal/shared/cacheUtils' +import { + CacheFilename, + CompressionMethod +} from '../src/internal/shared/constants' +import * as config from '../src/internal/shared/config' import * as tar from '../src/internal/tar' import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client' import * as cacheHttpClient from '../src/internal/cacheHttpClient' diff --git a/packages/cache/__tests__/tar.test.ts b/packages/cache/__tests__/tar.test.ts index 4145d9a946..211824c8fd 100644 --- a/packages/cache/__tests__/tar.test.ts +++ b/packages/cache/__tests__/tar.test.ts @@ -8,9 +8,9 @@ import { ManifestFilename, SystemTarPathOnWindows, TarFilename -} from '../src/internal/constants' +} from '../src/internal/shared/constants' import * as tar from '../src/internal/tar' -import * as utils from '../src/internal/cacheUtils' +import * as utils from '../src/internal/shared/cacheUtils' // eslint-disable-next-line @typescript-eslint/no-require-imports import fs = require('fs') diff --git a/packages/cache/__tests__/uploadUtils.test.ts b/packages/cache/__tests__/uploadUtils.test.ts index 2f0b8b554f..1f8b0733fc 100644 --- a/packages/cache/__tests__/uploadUtils.test.ts +++ b/packages/cache/__tests__/uploadUtils.test.ts @@ -1,4 +1,4 @@ -import * as uploadUtils from '../src/internal/uploadUtils' +import * as uploadUtils from '../src/internal/shared/uploadUtils' import {TransferProgressEvent} from '@azure/ms-rest-js' test('upload progress tracked correctly', () => { diff --git a/packages/cache/__tests__/util.test.ts b/packages/cache/__tests__/util.test.ts index 3ba3bba744..a2e147a2f2 100644 --- a/packages/cache/__tests__/util.test.ts +++ b/packages/cache/__tests__/util.test.ts @@ -1,4 +1,4 @@ -import {maskSigUrl, maskSecretUrls} from '../src/internal/shared/util' +import {maskSigUrl, maskSecretUrls} from '../src/internal/shared/requestUtils' import {setSecret, debug} from '@actions/core' jest.mock('@actions/core') diff --git a/packages/cache/src/cache.ts b/packages/cache/src/cache.ts index f7b2d1937e..4374c5f0dc 100644 --- a/packages/cache/src/cache.ts +++ b/packages/cache/src/cache.ts @@ -1,9 +1,9 @@ import * as core from '@actions/core' import * as path from 'path' -import * as utils from './internal/cacheUtils' +import * as utils from './internal/shared/cacheUtils' import * as cacheHttpClient from './internal/cacheHttpClient' -import * as cacheTwirpClient from './internal/shared/cacheTwirpClient' -import {getCacheServiceVersion, isGhes} from './internal/config' +import * as cacheTwirpClient from './internal/cacheTwirpClient' +import {getCacheServiceVersion, isGhes} from './internal/shared/config' import {DownloadOptions, UploadOptions} from './options' import {createTar, extractTar, listTar} from './internal/tar' import { @@ -12,7 +12,7 @@ import { FinalizeCacheEntryUploadResponse, GetCacheEntryDownloadURLRequest } from './generated/results/api/v1/cache' -import {CacheFileSizeLimit} from './internal/constants' +import {CacheFileSizeLimit} from './internal/shared/constants' export class ValidationError extends Error { constructor(message: string) { super(message) diff --git a/packages/cache/src/internal/cacheHttpClient.ts b/packages/cache/src/internal/cacheHttpClient.ts index 2470555bb1..e62636c92f 100644 --- a/packages/cache/src/internal/cacheHttpClient.ts +++ b/packages/cache/src/internal/cacheHttpClient.ts @@ -7,8 +7,8 @@ import { } from '@actions/http-client/lib/interfaces' import * as fs from 'fs' import {URL} from 'url' -import * as utils from './cacheUtils' -import {uploadCacheArchiveSDK} from './uploadUtils' +import * as utils from './shared/cacheUtils' +import {uploadCacheArchiveSDK} from './shared/uploadUtils' import { ArtifactCacheEntry, InternalCacheOptions, @@ -22,7 +22,7 @@ import { downloadCacheHttpClient, downloadCacheHttpClientConcurrent, downloadCacheStorageSDK -} from './downloadUtils' +} from './shared/downloadUtils' import { DownloadOptions, UploadOptions, @@ -33,8 +33,8 @@ import { isSuccessStatusCode, retryHttpClientResponse, retryTypedResponse -} from './requestUtils' -import {getCacheServiceURL} from './config' +} from './shared/requestUtils' +import {getCacheServiceURL} from './shared/config' import {getUserAgentString} from './shared/user-agent' function getCacheApiUrl(resource: string): string { diff --git a/packages/cache/src/internal/cacheTwirpClient.ts b/packages/cache/src/internal/cacheTwirpClient.ts new file mode 100644 index 0000000000..7ac9e65706 --- /dev/null +++ b/packages/cache/src/internal/cacheTwirpClient.ts @@ -0,0 +1,206 @@ +import {info, debug} from '@actions/core' +import {getUserAgentString} from './shared/user-agent' +import {NetworkError, UsageError} from './shared/errors' +import {getCacheServiceURL} from './shared/config' +import {getRuntimeToken} from './shared/cacheUtils' +import {BearerCredentialHandler} from '@actions/http-client/lib/auth' +import {HttpClient, HttpClientResponse, HttpCodes} from '@actions/http-client' +import {CacheServiceClientJSON} from '../generated/results/api/v1/cache.twirp-client' +import {maskSecretUrls} from './shared/requestUtils' + +// The twirp http client must implement this interface +interface Rpc { + request( + service: string, + method: string, + contentType: 'application/json' | 'application/protobuf', + data: object | Uint8Array + ): Promise +} + +/** + * This class is a wrapper around the CacheServiceClientJSON class generated by Twirp. + * + * It adds retry logic to the request method, which is not present in the generated client. + * + * This class is used to interact with cache service v2. + */ +class CacheServiceClient implements Rpc { + private httpClient: HttpClient + private baseUrl: string + private maxAttempts = 5 + private baseRetryIntervalMilliseconds = 3000 + private retryMultiplier = 1.5 + + constructor( + userAgent: string, + maxAttempts?: number, + baseRetryIntervalMilliseconds?: number, + retryMultiplier?: number + ) { + const token = getRuntimeToken() + this.baseUrl = getCacheServiceURL() + if (maxAttempts) { + this.maxAttempts = maxAttempts + } + if (baseRetryIntervalMilliseconds) { + this.baseRetryIntervalMilliseconds = baseRetryIntervalMilliseconds + } + if (retryMultiplier) { + this.retryMultiplier = retryMultiplier + } + + this.httpClient = new HttpClient(userAgent, [ + new BearerCredentialHandler(token) + ]) + } + + // This function satisfies the Rpc interface. It is compatible with the JSON + // JSON generated client. + async request( + service: string, + method: string, + contentType: 'application/json' | 'application/protobuf', + data: object | Uint8Array + ): Promise { + const url = new URL(`/twirp/${service}/${method}`, this.baseUrl).href + debug(`[Request] ${method} ${url}`) + const headers = { + 'Content-Type': contentType + } + try { + const {body} = await this.retryableRequest(async () => + this.httpClient.post(url, JSON.stringify(data), headers) + ) + + return body + } catch (error) { + throw new Error(`Failed to ${method}: ${error.message}`) + } + } + + async retryableRequest( + operation: () => Promise + ): Promise<{response: HttpClientResponse; body: object}> { + let attempt = 0 + let errorMessage = '' + let rawBody = '' + while (attempt < this.maxAttempts) { + let isRetryable = false + + try { + const response = await operation() + const statusCode = response.message.statusCode + rawBody = await response.readBody() + debug(`[Response] - ${response.message.statusCode}`) + debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`) + const body = JSON.parse(rawBody) + maskSecretUrls(body) + debug(`Body: ${JSON.stringify(body, null, 2)}`) + if (this.isSuccessStatusCode(statusCode)) { + return {response, body} + } + isRetryable = this.isRetryableHttpStatusCode(statusCode) + errorMessage = `Failed request: (${statusCode}) ${response.message.statusMessage}` + if (body.msg) { + if (UsageError.isUsageErrorMessage(body.msg)) { + throw new UsageError() + } + + errorMessage = `${errorMessage}: ${body.msg}` + } + } catch (error) { + if (error instanceof SyntaxError) { + debug(`Raw Body: ${rawBody}`) + } + + if (error instanceof UsageError) { + throw error + } + + if (NetworkError.isNetworkErrorCode(error?.code)) { + throw new NetworkError(error?.code) + } + + isRetryable = true + errorMessage = error.message + } + + if (!isRetryable) { + throw new Error(`Received non-retryable error: ${errorMessage}`) + } + + if (attempt + 1 === this.maxAttempts) { + throw new Error( + `Failed to make request after ${this.maxAttempts} attempts: ${errorMessage}` + ) + } + + const retryTimeMilliseconds = + this.getExponentialRetryTimeMilliseconds(attempt) + info( + `Attempt ${attempt + 1} of ${ + this.maxAttempts + } failed with error: ${errorMessage}. Retrying request in ${retryTimeMilliseconds} ms...` + ) + await this.sleep(retryTimeMilliseconds) + attempt++ + } + + throw new Error(`Request failed`) + } + + isSuccessStatusCode(statusCode?: number): boolean { + if (!statusCode) return false + return statusCode >= 200 && statusCode < 300 + } + + isRetryableHttpStatusCode(statusCode?: number): boolean { + if (!statusCode) return false + + const retryableStatusCodes = [ + HttpCodes.BadGateway, + HttpCodes.GatewayTimeout, + HttpCodes.InternalServerError, + HttpCodes.ServiceUnavailable, + HttpCodes.TooManyRequests + ] + + return retryableStatusCodes.includes(statusCode) + } + + async sleep(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)) + } + + getExponentialRetryTimeMilliseconds(attempt: number): number { + if (attempt < 0) { + throw new Error('attempt should be a positive integer') + } + + if (attempt === 0) { + return this.baseRetryIntervalMilliseconds + } + + const minTime = + this.baseRetryIntervalMilliseconds * this.retryMultiplier ** attempt + const maxTime = minTime * this.retryMultiplier + + // returns a random number between minTime and maxTime (exclusive) + return Math.trunc(Math.random() * (maxTime - minTime) + minTime) + } +} + +export function internalCacheTwirpClient(options?: { + maxAttempts?: number + retryIntervalMs?: number + retryMultiplier?: number +}): CacheServiceClientJSON { + const client = new CacheServiceClient( + getUserAgentString(), + options?.maxAttempts, + options?.retryIntervalMs, + options?.retryMultiplier + ) + return new CacheServiceClientJSON(client) +} diff --git a/packages/cache/src/internal/contracts.d.ts b/packages/cache/src/internal/contracts.d.ts index 6fcd9427c8..8aa44e25a2 100644 --- a/packages/cache/src/internal/contracts.d.ts +++ b/packages/cache/src/internal/contracts.d.ts @@ -1,4 +1,4 @@ -import {CompressionMethod} from './constants' +import {CompressionMethod} from './shared/constants' import {TypedResponse} from '@actions/http-client/lib/interfaces' import {HttpClientError} from '@actions/http-client' diff --git a/packages/cache/src/internal/shared/cacheTwirpClient.ts b/packages/cache/src/internal/shared/cacheTwirpClient.ts index f6c2af61b3..0c35a67a07 100644 --- a/packages/cache/src/internal/shared/cacheTwirpClient.ts +++ b/packages/cache/src/internal/shared/cacheTwirpClient.ts @@ -1,12 +1,12 @@ import {info, debug} from '@actions/core' import {getUserAgentString} from './user-agent' import {NetworkError, UsageError} from './errors' -import {getCacheServiceURL} from '../config' -import {getRuntimeToken} from '../cacheUtils' +import {getCacheServiceURL} from '../shared/config' +import {getRuntimeToken} from '../shared/utils' import {BearerCredentialHandler} from '@actions/http-client/lib/auth' import {HttpClient, HttpClientResponse, HttpCodes} from '@actions/http-client' import {CacheServiceClientJSON} from '../../generated/results/api/v1/cache.twirp-client' -import {maskSecretUrls} from './util' +import {maskSecretUrls} from './requestUtils' // The twirp http client must implement this interface interface Rpc { diff --git a/packages/cache/src/internal/cacheUtils.ts b/packages/cache/src/internal/shared/cacheUtils.ts similarity index 100% rename from packages/cache/src/internal/cacheUtils.ts rename to packages/cache/src/internal/shared/cacheUtils.ts diff --git a/packages/cache/src/internal/config.ts b/packages/cache/src/internal/shared/config.ts similarity index 100% rename from packages/cache/src/internal/config.ts rename to packages/cache/src/internal/shared/config.ts diff --git a/packages/cache/src/internal/constants.ts b/packages/cache/src/internal/shared/constants.ts similarity index 100% rename from packages/cache/src/internal/constants.ts rename to packages/cache/src/internal/shared/constants.ts diff --git a/packages/cache/src/internal/downloadUtils.ts b/packages/cache/src/internal/shared/downloadUtils.ts similarity index 99% rename from packages/cache/src/internal/downloadUtils.ts rename to packages/cache/src/internal/shared/downloadUtils.ts index de57ed78ff..dbe87ebd1d 100644 --- a/packages/cache/src/internal/downloadUtils.ts +++ b/packages/cache/src/internal/shared/downloadUtils.ts @@ -9,7 +9,7 @@ import * as util from 'util' import * as utils from './cacheUtils' import {SocketTimeout} from './constants' -import {DownloadOptions} from '../options' +import {DownloadOptions} from '../../options' import {retryHttpClientResponse} from './requestUtils' import {AbortController} from '@azure/abort-controller' diff --git a/packages/cache/src/internal/requestUtils.ts b/packages/cache/src/internal/shared/requestUtils.ts similarity index 57% rename from packages/cache/src/internal/requestUtils.ts rename to packages/cache/src/internal/shared/requestUtils.ts index 043c8a7cef..78113c79c4 100644 --- a/packages/cache/src/internal/requestUtils.ts +++ b/packages/cache/src/internal/shared/requestUtils.ts @@ -5,7 +5,8 @@ import { HttpClientResponse } from '@actions/http-client' import {DefaultRetryDelay, DefaultRetryAttempts} from './constants' -import {ITypedResponseWithError} from './contracts' +import {ITypedResponseWithError} from '../contracts' +import {debug, setSecret} from '@actions/core' export function isSuccessStatusCode(statusCode?: number): boolean { if (!statusCode) { @@ -136,3 +137,78 @@ export async function retryHttpClientResponse( delay ) } + +/** + * Masks the `sig` parameter in a URL and sets it as a secret. + * + * @param url - The URL containing the signature parameter to mask + * @remarks + * This function attempts to parse the provided URL and identify the 'sig' query parameter. + * If found, it registers both the raw and URL-encoded signature values as secrets using + * the Actions `setSecret` API, which prevents them from being displayed in logs. + * + * The function handles errors gracefully if URL parsing fails, logging them as debug messages. + * + * @example + * ```typescript + * // Mask a signature in an Azure SAS token URL + * maskSigUrl('https://example.blob.core.windows.net/container/file.txt?sig=abc123&se=2023-01-01'); + * ``` + */ +export function maskSigUrl(url: string): void { + if (!url) return + try { + const parsedUrl = new URL(url) + const signature = parsedUrl.searchParams.get('sig') + if (signature) { + setSecret(signature) + setSecret(encodeURIComponent(signature)) + } + } catch (error) { + debug( + `Failed to parse URL: ${url} ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} + +/** + * Masks sensitive information in URLs containing signature parameters. + * Currently supports masking 'sig' parameters in the 'signed_upload_url' + * and 'signed_download_url' properties of the provided object. + * + * @param body - The object should contain a signature + * @remarks + * This function extracts URLs from the object properties and calls maskSigUrl + * on each one to redact sensitive signature information. The function doesn't + * modify the original object; it only marks the signatures as secrets for + * logging purposes. + * + * @example + * ```typescript + * const responseBody = { + * signed_upload_url: 'https://blob.core.windows.net/?sig=abc123', + * signed_download_url: 'https://blob.core/windows.net/?sig=def456' + * }; + * maskSecretUrls(responseBody); + * ``` + */ +export function maskSecretUrls(body: Record | null): void { + if (typeof body !== 'object' || body === null) { + debug('body is not an object or is null') + return + } + if ( + 'signed_upload_url' in body && + typeof body.signed_upload_url === 'string' + ) { + maskSigUrl(body.signed_upload_url) + } + if ( + 'signed_download_url' in body && + typeof body.signed_download_url === 'string' + ) { + maskSigUrl(body.signed_download_url) + } +} diff --git a/packages/cache/src/internal/uploadUtils.ts b/packages/cache/src/internal/shared/uploadUtils.ts similarity index 97% rename from packages/cache/src/internal/uploadUtils.ts rename to packages/cache/src/internal/shared/uploadUtils.ts index 1b4f7af0d1..84777f17da 100644 --- a/packages/cache/src/internal/uploadUtils.ts +++ b/packages/cache/src/internal/shared/uploadUtils.ts @@ -6,8 +6,8 @@ import { BlockBlobParallelUploadOptions } from '@azure/storage-blob' import {TransferProgressEvent} from '@azure/ms-rest-js' -import {InvalidResponseError} from './shared/errors' -import {UploadOptions} from '../options' +import {InvalidResponseError} from './errors' +import {UploadOptions} from '../../options' /** * Class for tracking the upload state and displaying stats. diff --git a/packages/cache/src/internal/shared/util.ts b/packages/cache/src/internal/shared/util.ts deleted file mode 100644 index 36d2ebfdce..0000000000 --- a/packages/cache/src/internal/shared/util.ts +++ /dev/null @@ -1,76 +0,0 @@ -import {debug, setSecret} from '@actions/core' - -/** - * Masks the `sig` parameter in a URL and sets it as a secret. - * - * @param url - The URL containing the signature parameter to mask - * @remarks - * This function attempts to parse the provided URL and identify the 'sig' query parameter. - * If found, it registers both the raw and URL-encoded signature values as secrets using - * the Actions `setSecret` API, which prevents them from being displayed in logs. - * - * The function handles errors gracefully if URL parsing fails, logging them as debug messages. - * - * @example - * ```typescript - * // Mask a signature in an Azure SAS token URL - * maskSigUrl('https://example.blob.core.windows.net/container/file.txt?sig=abc123&se=2023-01-01'); - * ``` - */ -export function maskSigUrl(url: string): void { - if (!url) return - try { - const parsedUrl = new URL(url) - const signature = parsedUrl.searchParams.get('sig') - if (signature) { - setSecret(signature) - setSecret(encodeURIComponent(signature)) - } - } catch (error) { - debug( - `Failed to parse URL: ${url} ${ - error instanceof Error ? error.message : String(error) - }` - ) - } -} - -/** - * Masks sensitive information in URLs containing signature parameters. - * Currently supports masking 'sig' parameters in the 'signed_upload_url' - * and 'signed_download_url' properties of the provided object. - * - * @param body - The object should contain a signature - * @remarks - * This function extracts URLs from the object properties and calls maskSigUrl - * on each one to redact sensitive signature information. The function doesn't - * modify the original object; it only marks the signatures as secrets for - * logging purposes. - * - * @example - * ```typescript - * const responseBody = { - * signed_upload_url: 'https://blob.core.windows.net/?sig=abc123', - * signed_download_url: 'https://blob.core/windows.net/?sig=def456' - * }; - * maskSecretUrls(responseBody); - * ``` - */ -export function maskSecretUrls(body: Record | null): void { - if (typeof body !== 'object' || body === null) { - debug('body is not an object or is null') - return - } - if ( - 'signed_upload_url' in body && - typeof body.signed_upload_url === 'string' - ) { - maskSigUrl(body.signed_upload_url) - } - if ( - 'signed_download_url' in body && - typeof body.signed_download_url === 'string' - ) { - maskSigUrl(body.signed_download_url) - } -} diff --git a/packages/cache/src/internal/shared/utils.ts b/packages/cache/src/internal/shared/utils.ts new file mode 100644 index 0000000000..b244bf4256 --- /dev/null +++ b/packages/cache/src/internal/shared/utils.ts @@ -0,0 +1,168 @@ +import {debug, setSecret} from '@actions/core' +import * as core from '@actions/core' +import * as exec from '@actions/exec' +import * as glob from '@actions/glob' +import * as io from '@actions/io' +import * as crypto from 'crypto' +import * as fs from 'fs' +import * as path from 'path' +import * as semver from 'semver' +import * as util from 'util' +import { + CacheFilename, + CompressionMethod, + GnuTarPathOnWindows +} from './constants' + +const versionSalt = '1.0' + +// From https://github.com/actions/toolkit/blob/main/packages/tool-cache/src/tool-cache.ts#L23 +export async function createTempDirectory(): Promise { + const IS_WINDOWS = process.platform === 'win32' + + let tempDirectory: string = process.env['RUNNER_TEMP'] || '' + + if (!tempDirectory) { + let baseLocation: string + if (IS_WINDOWS) { + // On Windows use the USERPROFILE env variable + baseLocation = process.env['USERPROFILE'] || 'C:\\' + } else { + if (process.platform === 'darwin') { + baseLocation = '/Users' + } else { + baseLocation = '/home' + } + } + tempDirectory = path.join(baseLocation, 'actions', 'temp') + } + + const dest = path.join(tempDirectory, crypto.randomUUID()) + await io.mkdirP(dest) + return dest +} + +export function getArchiveFileSizeInBytes(filePath: string): number { + return fs.statSync(filePath).size +} + +export async function resolvePaths(patterns: string[]): Promise { + const paths: string[] = [] + const workspace = process.env['GITHUB_WORKSPACE'] ?? process.cwd() + const globber = await glob.create(patterns.join('\n'), { + implicitDescendants: false + }) + + for await (const file of globber.globGenerator()) { + const relativeFile = path + .relative(workspace, file) + .replace(new RegExp(`\\${path.sep}`, 'g'), '/') + core.debug(`Matched: ${relativeFile}`) + // Paths are made relative so the tar entries are all relative to the root of the workspace. + if (relativeFile === '') { + // path.relative returns empty string if workspace and file are equal + paths.push('.') + } else { + paths.push(`${relativeFile}`) + } + } + + return paths +} + +export async function unlinkFile(filePath: fs.PathLike): Promise { + return util.promisify(fs.unlink)(filePath) +} + +async function getVersion( + app: string, + additionalArgs: string[] = [] +): Promise { + let versionOutput = '' + additionalArgs.push('--version') + core.debug(`Checking ${app} ${additionalArgs.join(' ')}`) + try { + await exec.exec(`${app}`, additionalArgs, { + ignoreReturnCode: true, + silent: true, + listeners: { + stdout: (data: Buffer): string => (versionOutput += data.toString()), + stderr: (data: Buffer): string => (versionOutput += data.toString()) + } + }) + } catch (err) { + core.debug(err.message) + } + + versionOutput = versionOutput.trim() + core.debug(versionOutput) + return versionOutput +} + +// Use zstandard if possible to maximize cache performance +export async function getCompressionMethod(): Promise { + const versionOutput = await getVersion('zstd', ['--quiet']) + const version = semver.clean(versionOutput) + core.debug(`zstd version: ${version}`) + + if (versionOutput === '') { + return CompressionMethod.Gzip + } else { + return CompressionMethod.ZstdWithoutLong + } +} + +export function getCacheFileName(compressionMethod: CompressionMethod): string { + return compressionMethod === CompressionMethod.Gzip + ? CacheFilename.Gzip + : CacheFilename.Zstd +} + +export async function getGnuTarPathOnWindows(): Promise { + if (fs.existsSync(GnuTarPathOnWindows)) { + return GnuTarPathOnWindows + } + const versionOutput = await getVersion('tar') + return versionOutput.toLowerCase().includes('gnu tar') ? io.which('tar') : '' +} + +export function assertDefined(name: string, value?: T): T { + if (value === undefined) { + throw Error(`Expected ${name} but value was undefiend`) + } + + return value +} + +export function getCacheVersion( + paths: string[], + compressionMethod?: CompressionMethod, + enableCrossOsArchive = false +): string { + // don't pass changes upstream + const components = paths.slice() + + // Add compression method to cache version to restore + // compressed cache as per compression method + if (compressionMethod) { + components.push(compressionMethod) + } + + // Only check for windows platforms if enableCrossOsArchive is false + if (process.platform === 'win32' && !enableCrossOsArchive) { + components.push('windows-only') + } + + // Add salt to cache version to support breaking changes in cache entry + components.push(versionSalt) + + return crypto.createHash('sha256').update(components.join('|')).digest('hex') +} + +export function getRuntimeToken(): string { + const token = process.env['ACTIONS_RUNTIME_TOKEN'] + if (!token) { + throw new Error('Unable to get the ACTIONS_RUNTIME_TOKEN env variable') + } + return token +} diff --git a/packages/cache/src/internal/tar.ts b/packages/cache/src/internal/tar.ts index adf610694f..bebf9b5fd6 100644 --- a/packages/cache/src/internal/tar.ts +++ b/packages/cache/src/internal/tar.ts @@ -2,7 +2,7 @@ import {exec} from '@actions/exec' import * as io from '@actions/io' import {existsSync, writeFileSync} from 'fs' import * as path from 'path' -import * as utils from './cacheUtils' +import * as utils from './shared/cacheUtils' import {ArchiveTool} from './contracts' import { CompressionMethod, @@ -10,7 +10,7 @@ import { ArchiveToolType, TarFilename, ManifestFilename -} from './constants' +} from './shared/constants' const IS_WINDOWS = process.platform === 'win32'