diff --git a/lib/entry-points.js b/lib/entry-points.js index b79a327ea7..91079c3f95 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -107111,41 +107111,6 @@ var require_tool_cache = __commonJS({ } }); -// node_modules/fast-deep-equal/index.js -var require_fast_deep_equal = __commonJS({ - "node_modules/fast-deep-equal/index.js"(exports2, module2) { - "use strict"; - module2.exports = function equal(a, b) { - if (a === b) return true; - if (a && b && typeof a == "object" && typeof b == "object") { - if (a.constructor !== b.constructor) return false; - var length, i, keys; - if (Array.isArray(a)) { - length = a.length; - if (length != b.length) return false; - for (i = length; i-- !== 0; ) - if (!equal(a[i], b[i])) return false; - return true; - } - if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags; - if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf(); - if (a.toString !== Object.prototype.toString) return a.toString() === b.toString(); - keys = Object.keys(a); - length = keys.length; - if (length !== Object.keys(b).length) return false; - for (i = length; i-- !== 0; ) - if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; - for (i = length; i-- !== 0; ) { - var key = keys[i]; - if (!equal(a[key], b[key])) return false; - } - return true; - } - return a !== a && b !== b; - }; - } -}); - // node_modules/follow-redirects/debug.js var require_debug3 = __commonJS({ "node_modules/follow-redirects/debug.js"(exports2, module2) { @@ -170661,6 +170626,7 @@ var import_undici = __toESM(require_undici()); // src/github.ts var GITHUB_DOTCOM_URL = "https://github.com"; +var GITHUB_DOTCOM_API_URL = "https://api.github.com"; function parseGitHubUrl(inputUrl) { const originalUrl = inputUrl; if (inputUrl.indexOf("://") === -1) { @@ -170695,8 +170661,17 @@ function getCurrentInstance(env) { apiURL: env.getRequired("GITHUB_API_URL" /* GITHUB_API_URL */) }; } +function getDotcomInstance() { + return { + url: GITHUB_DOTCOM_URL, + apiURL: GITHUB_DOTCOM_API_URL + }; +} // src/repository.ts +function isRepositoryNwoStr(val) { + return val.includes("/"); +} function getRepositoryNwo() { return getRepositoryNwoFromEnv("GITHUB_REPOSITORY"); } @@ -170757,6 +170732,37 @@ function makeProxyRequestOptions(dispatcher) { } }; } +var InstanceApiClient = class { + client; + apiDetails; + authorizationHeader; + constructor(apiDetails, clientOptions = {}) { + this.apiDetails = apiDetails; + this.client = createApiClientWithDetails(apiDetails, clientOptions); + const token = this.apiDetails.auth?.trim(); + if (token !== void 0 && token !== "") { + this.authorizationHeader = `token ${token}`; + } + } + /** + * Gets a value for the `Authorization` header for a request to `url`; or `undefined` if the + * `Authorization` header should not be set for `url`. + * + * @param logger The logger to use for debugging messages. + * @param url The URL for which we want to add an `Authorization` header. + */ + getAuthorizationHeaderFor(logger2, url2) { + if (this.authorizationHeader === void 0) { + return void 0; + } + if (url2.startsWith(`${this.apiDetails.url}/`) || this.apiDetails.apiURL && url2.startsWith(`${this.apiDetails.apiURL}/`)) { + logger2.debug(`Providing an authorization token.`); + return this.authorizationHeader; + } + logger2.debug(`Not using an authorization token.`); + return void 0; + } +}; function createApiClientWithDetails(apiDetails, { allowExternal = false, proxy = void 0 } = {}) { const auth2 = allowExternal && apiDetails.externalRepoAuth !== void 0 ? apiDetails.externalRepoAuth : apiDetails.auth; const retryingOctokit = githubUtils.GitHub.plugin(retry); @@ -170784,24 +170790,22 @@ function getApiDetails(env = getEnv()) { ...getCurrentInstance(env) }; } -function getApiClient(env = getEnv()) { - return createApiClientWithDetails(getApiDetails(env)); +function getDotcomApiDetails(env) { + if (env.getRequired("GITHUB_SERVER_URL" /* GITHUB_SERVER_URL */) === GITHUB_DOTCOM_URL) { + return getApiDetails(env); + } else { + return { + auth: void 0, + ...getDotcomInstance() + }; + } +} +function getApiClient(env = getEnv(), apiDetails) { + return createApiClientWithDetails(apiDetails ?? getApiDetails(env)); } function getApiClientWithExternalAuth(apiDetails, proxy) { return createApiClientWithDetails(apiDetails, { allowExternal: true, proxy }); } -function getAuthorizationHeaderFor(logger2, apiDetails, url2) { - const token = apiDetails.auth?.trim(); - if (token === void 0 || token === "") { - return void 0; - } - if (url2.startsWith(`${apiDetails.url}/`) || apiDetails.apiURL && url2.startsWith(`${apiDetails.apiURL}/`)) { - logger2.debug(`Providing an authorization token.`); - return `token ${token}`; - } - logger2.debug(`Not using an authorization token.`); - return void 0; -} var cachedGitHubVersion = void 0; async function getGitHubVersionFromApi(apiClient, apiDetails) { if (parseGitHubUrl(apiDetails.url) === GITHUB_DOTCOM_URL) { @@ -175318,7 +175322,6 @@ var fs14 = __toESM(require("fs")); var path13 = __toESM(require("path")); var import_perf_hooks4 = require("perf_hooks"); var toolcache3 = __toESM(require_tool_cache()); -var import_fast_deep_equal = __toESM(require_fast_deep_equal()); var semver9 = __toESM(require_semver2()); // src/codeql-bundle.ts @@ -175774,6 +175777,75 @@ function logPerLanguageBundleFallback({ logger: logger2 }, language, location) { ); } +// src/setup/repository.ts +var CODEQL_DEFAULT_ACTION_REPOSITORY = "github/codeql-action"; +function getCodeQLActionRepository(action) { + if (isRunningLocalAction(action.env)) { + action.logger.info( + "The CodeQL Action is checked out locally. Using the default CodeQL Action repository." + ); + return CODEQL_DEFAULT_ACTION_REPOSITORY; + } + const actionRepository = action.env.getRequired( + "GITHUB_ACTION_REPOSITORY" /* GITHUB_ACTION_REPOSITORY */ + ); + if (isRepositoryNwoStr(actionRepository)) { + return actionRepository; + } + throw new Error( + `The value of '${"GITHUB_ACTION_REPOSITORY" /* GITHUB_ACTION_REPOSITORY */}' is '${actionRepository}', which does not match the expected 'owner/repo' format.` + ); +} +var isSameDownloadSource = ([srcInstance, srcRepo], [otherInstance, otherRepo]) => { + return srcInstance.url === otherInstance.url && srcRepo === otherRepo; +}; +async function getCodeQLAssetDownloadURL(action, apiDetails, tagName, assetName, assetKind = "CodeQL bundle") { + const codeQLActionRepository = getCodeQLActionRepository(action); + const defaultDownloadSource = [ + getDotcomApiDetails(action.env), + CODEQL_DEFAULT_ACTION_REPOSITORY + ]; + const potentialDownloadSources = [ + // This GitHub instance, and this Action. + [apiDetails, codeQLActionRepository], + // This GitHub instance, and the canonical Action. + [apiDetails, CODEQL_DEFAULT_ACTION_REPOSITORY], + // GitHub.com, and the canonical Action. + defaultDownloadSource + ]; + const uniqueDownloadSources = potentialDownloadSources.filter( + (source, index2, self2) => { + return !self2.slice(0, index2).some((other) => isSameDownloadSource(source, other)); + } + ); + for (const [instance8, repository] of uniqueDownloadSources) { + if (isSameDownloadSource(defaultDownloadSource, [instance8, repository])) { + break; + } + const [repositoryOwner, repositoryName] = repository.split("/"); + try { + const release2 = await getApiClient(action.env, instance8).rest.repos.getReleaseByTag({ + owner: repositoryOwner, + repo: repositoryName, + tag: tagName + }); + for (const asset of release2.data.assets) { + if (asset.name === assetName) { + action.logger.info( + `Found ${assetKind} ${assetName} in ${repository} on ${instance8.apiURL} with URL ${asset.url}.` + ); + return asset.url; + } + } + } catch (e) { + action.logger.info( + `Looked for ${assetKind} ${assetName} in ${repository} on ${instance8.apiURL} but got error ${e}.` + ); + } + } + return `https://github.com/${CODEQL_DEFAULT_ACTION_REPOSITORY}/releases/download/${tagName}/${assetName}`; +} + // src/tar.ts var import_child_process = require("child_process"); var fs12 = __toESM(require("fs")); @@ -176165,64 +176237,11 @@ function writeToolcacheMarkerFile(extractedPath, logger2) { } // src/setup-codeql.ts -var CODEQL_DEFAULT_ACTION_REPOSITORY = "github/codeql-action"; var CODEQL_NIGHTLIES_REPOSITORY_OWNER = "dsp-testing"; var CODEQL_NIGHTLIES_REPOSITORY_NAME = "codeql-cli-nightlies"; var CODEQL_BUNDLE_VERSION_ALIAS = ["linked", "latest"]; var CODEQL_NIGHTLY_TOOLS_INPUTS = ["nightly", "nightly-latest"]; var CODEQL_TOOLCACHE_INPUT = "toolcache"; -function getCodeQLActionRepository(logger2) { - if (isRunningLocalAction()) { - logger2.info( - "The CodeQL Action is checked out locally. Using the default CodeQL Action repository." - ); - return CODEQL_DEFAULT_ACTION_REPOSITORY; - } - return getRequiredEnvParam("GITHUB_ACTION_REPOSITORY"); -} -async function getCodeQLBundleDownloadURL(tagName, apiDetails, codeQLBundleName, logger2) { - const codeQLActionRepository = getCodeQLActionRepository(logger2); - const potentialDownloadSources = [ - // This GitHub instance, and this Action. - [apiDetails.url, codeQLActionRepository], - // This GitHub instance, and the canonical Action. - [apiDetails.url, CODEQL_DEFAULT_ACTION_REPOSITORY], - // GitHub.com, and the canonical Action. - [GITHUB_DOTCOM_URL, CODEQL_DEFAULT_ACTION_REPOSITORY] - ]; - const uniqueDownloadSources = potentialDownloadSources.filter( - (source, index2, self2) => { - return !self2.slice(0, index2).some((other) => (0, import_fast_deep_equal.default)(source, other)); - } - ); - for (const downloadSource of uniqueDownloadSources) { - const [apiURL, repository] = downloadSource; - if (apiURL === GITHUB_DOTCOM_URL && repository === CODEQL_DEFAULT_ACTION_REPOSITORY) { - break; - } - const [repositoryOwner, repositoryName] = repository.split("/"); - try { - const release2 = await getApiClient().rest.repos.getReleaseByTag({ - owner: repositoryOwner, - repo: repositoryName, - tag: tagName - }); - for (const asset of release2.data.assets) { - if (asset.name === codeQLBundleName) { - logger2.info( - `Found CodeQL bundle ${codeQLBundleName} in ${repository} on ${apiURL} with URL ${asset.url}.` - ); - return asset.url; - } - } - } catch (e) { - logger2.info( - `Looked for CodeQL bundle ${codeQLBundleName} in ${repository} on ${apiURL} but got error ${e}.` - ); - } - } - return `https://github.com/${CODEQL_DEFAULT_ACTION_REPOSITORY}/releases/download/${tagName}/${codeQLBundleName}`; -} function tryGetBundleVersionFromTagName(tagName, logger2) { const match2 = tagName.match(/^codeql-bundle-(.+)$/); if (match2 === null || match2.length < 2) { @@ -176580,9 +176599,10 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, othe ); } compressionMethod = cliVersion2 !== void 0 && await useZstdBundle(cliVersion2, tarSupportsZstd) ? "zstd" : "gzip"; + const action = { env: getEnv(), logger: logger2 }; const platform2 = getBundlePlatform(); const perLanguageBundleLanguage = await getPerLanguageBundleLanguage( - { env: getEnv(), features, logger: logger2 }, + { ...action, features }, { rawLanguages, otherLanguagePacksReason, @@ -176592,11 +176612,11 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, othe variant } ); - const resolveBundleURL = (language) => getCodeQLBundleDownloadURL( - bundleTagName, + const resolveBundleURL = (language) => getCodeQLAssetDownloadURL( + action, apiDetails, - getCodeQLBundleName(compressionMethod, platform2, language), - logger2 + bundleTagName, + getCodeQLBundleName(compressionMethod, platform2, language) ); const combinedBundleURL = await resolveBundleURL(); if (perLanguageBundleLanguage !== void 0) { @@ -176640,7 +176660,8 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, othe cliVersion: cliVersion2, compressionMethod, sourceType: "download", - toolsVersion: resolvedVersion ?? "unknown" + toolsVersion: resolvedVersion ?? "unknown", + apiDetails }; } async function tryGetFallbackToolcacheVersion(cliVersion2, tagName, logger2) { @@ -176654,7 +176675,7 @@ async function tryGetFallbackToolcacheVersion(cliVersion2, tagName, logger2) { ); return fallbackVersion; } -var downloadCodeQL = async function(source, apiDetails, tarVersion, tempDir, logger2) { +var downloadCodeQL = async function(source, tarVersion, tempDir, logger2) { const { bundle, compressionMethod } = source; const codeqlURL = bundle.url; const parsedCodeQLURL = new URL(codeqlURL); @@ -176666,11 +176687,8 @@ var downloadCodeQL = async function(source, apiDetails, tarVersion, tempDir, log if (searchParams.has("token")) { logger2.debug("CodeQL tools URL contains an authorization token."); } else { - authorization = getAuthorizationHeaderFor( - logger2, - apiDetails, - codeqlURL - ); + const client = new InstanceApiClient(source.apiDetails); + authorization = client.getAuthorizationHeaderFor(logger2, codeqlURL); } const toolcacheDestination = getToolcacheDestination({ logger: logger2 }, source); const extractedBundlePath = toolcacheDestination.orElse( @@ -176792,7 +176810,6 @@ async function setupCodeQLBundle(toolsInput, apiDetails, tempDir, variant, defau const result = await downloadCodeQLBundle( { env: getEnv(), features, logger: logger2 }, source, - apiDetails, zstdAvailability.version, tempDir ); @@ -176812,19 +176829,13 @@ async function setupCodeQLBundle(toolsInput, apiDetails, tempDir, variant, defau toolsVersion: source.toolsVersion }; } -async function downloadCodeQLBundle(action, source, apiDetails, tarVersion, tempDir) { +async function downloadCodeQLBundle(action, source, tarVersion, tempDir) { const { bundle } = source; const { logger: logger2 } = action; await tryDeleteToolcacheBundles(action); const startTime = import_perf_hooks4.performance.now(); try { - return await downloadCodeQL( - source, - apiDetails, - tarVersion, - tempDir, - logger2 - ); + return await downloadCodeQL(source, tarVersion, tempDir, logger2); } catch (e) { if (bundle.kind !== "per-language" || bundle.combinedBundleURL === void 0 || asHTTPError(e)?.status !== 404) { throw e; @@ -176835,7 +176846,6 @@ async function downloadCodeQLBundle(action, source, apiDetails, tarVersion, temp ...source, bundle: { kind: "combined", url: bundle.combinedBundleURL } }, - apiDetails, tarVersion, tempDir, logger2 @@ -178461,7 +178471,7 @@ async function recordClearCleanupSizes(codeql, config, reports, logger2) { } } async function uploadBundledDatabase(repositoryNwo, language, commitOid, bundledDb, bundledDbSize, apiDetails) { - const client = getApiClient(); + const client = getApiClient(getEnv(), apiDetails); const uploadsUrl = new URL(parseGitHubUrl(apiDetails.url)); uploadsUrl.hostname = `uploads.${uploadsUrl.hostname}`; let uploadsBaseUrl = uploadsUrl.toString(); @@ -187786,8 +187796,8 @@ function getProxyPackage(platform2) { function getFallbackUrl(proxyPackage) { return `${UPDATEJOB_PROXY_URL_PREFIX}${proxyPackage}`; } -async function getReleaseByVersion(version) { - return getApiClient().rest.repos.getReleaseByTag({ +async function getReleaseByVersion(env, apiDetails, version) { + return getApiClient(env, apiDetails).rest.repos.getReleaseByTag({ owner: "github", repo: "codeql-action", tag: version @@ -187797,7 +187807,7 @@ async function getCliVersionFromFeatures(features) { const gitHubVersion = await getGitHubVersion(); return await features.getEnabledDefaultCliVersions(gitHubVersion.type); } -async function getDownloadUrl(action) { +async function getDownloadUrl(action, apiDetails) { let platform2 = getBundlePlatform(action.platform, action.arch); if (platform2 === void 0) { action.logger.warning( @@ -187814,7 +187824,11 @@ async function getDownloadUrl(action) { cliVersion, tagName: bundleVersion }; - const cliRelease = await getReleaseByVersion(versionInfo.tagName); + const cliRelease = await getReleaseByVersion( + action.env, + apiDetails, + versionInfo.tagName + ); for (const asset of cliRelease.data.assets) { if (asset.name === proxyPackage) { action.logger.info( @@ -187885,14 +187899,14 @@ function getProxyFilename() { } async function getProxyBinaryPath(action) { const logger2 = action.logger; + const apiDetails = getApiDetails(); + const client = new InstanceApiClient(apiDetails); const proxyFileName = getProxyFilename(); - const proxyInfo = await getDownloadUrl(action); + const proxyInfo = await getDownloadUrl(action, apiDetails); let proxyBin = toolcache4.find(proxyFileName, proxyInfo.version); if (!proxyBin) { - const apiDetails = getApiDetails(); - const authorization = getAuthorizationHeaderFor( + const authorization = client.getAuthorizationHeaderFor( logger2, - apiDetails, proxyInfo.url ); const temp = await downloadProxy(logger2, proxyInfo.url, authorization); diff --git a/package-lock.json b/package-lock.json index e404bc8a6a..42ba4bf1ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "@octokit/plugin-rest-endpoint-methods": "^18.0.0", "@octokit/plugin-retry": "^8.1.1", "archiver": "^8.0.0", - "fast-deep-equal": "^3.1.3", "follow-redirects": "^1.16.1", "get-folder-size": "^5.0.0", "https-proxy-agent": "^7.0.6", @@ -5802,6 +5801,7 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "dev": true, "license": "MIT" }, "node_modules/fast-diff": { diff --git a/package.json b/package.json index ade518b135..755d561f10 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "@octokit/plugin-rest-endpoint-methods": "^18.0.0", "@octokit/plugin-retry": "^8.1.1", "archiver": "^8.0.0", - "fast-deep-equal": "^3.1.3", "follow-redirects": "^1.16.1", "get-folder-size": "^5.0.0", "https-proxy-agent": "^7.0.6", diff --git a/src/actions-util.ts b/src/actions-util.ts index 62ab42440c..6db7146d10 100644 --- a/src/actions-util.ts +++ b/src/actions-util.ts @@ -101,7 +101,7 @@ export function getWorkflowEventName(env: Env = getEnv()) { * Returns whether the current workflow is executing a local copy of the Action, e.g. we're running * a workflow on the codeql-action repo itself. */ -export function isRunningLocalAction(env: Env = getEnv()): boolean { +export function isRunningLocalAction(env: ReadOnlyEnv = getEnv()): boolean { const relativeScriptPath = getRelativeScriptPath(env); return ( relativeScriptPath.startsWith("..") || path.isAbsolute(relativeScriptPath) @@ -113,7 +113,7 @@ export function isRunningLocalAction(env: Env = getEnv()): boolean { * * This can be used to get the Action's name or tell if we're running a local Action. */ -function getRelativeScriptPath(env: Env): string { +function getRelativeScriptPath(env: ReadOnlyEnv): string { const runnerTemp = env.getRequired(ActionsEnvVars.RUNNER_TEMP); const actionsDirectory = path.join(path.dirname(runnerTemp), "_actions"); return path.relative(actionsDirectory, __filename); diff --git a/src/api-client.test.ts b/src/api-client.test.ts index 2bb878f3b7..e2b37cefd9 100644 --- a/src/api-client.test.ts +++ b/src/api-client.test.ts @@ -1,5 +1,6 @@ import * as github from "@actions/github"; import * as githubUtils from "@actions/github/lib/utils"; +import * as retry from "@octokit/plugin-retry"; import test from "ava"; import * as sinon from "sinon"; import { ProxyAgent } from "undici"; @@ -308,70 +309,45 @@ test.serial( test("getAuthorizationHeaderFor - returns undefined if there's no token", (t) => { const logger = new RecordingLogger(); - t.is( - api.getAuthorizationHeaderFor( - logger, - { ...SAMPLE_DOTCOM_API_DETAILS, auth: undefined }, - `${GITHUB_DOTCOM_URL}/github`, - ), - undefined, - ); - t.is( - api.getAuthorizationHeaderFor( - logger, - { ...SAMPLE_DOTCOM_API_DETAILS, auth: "" }, - `${GITHUB_DOTCOM_URL}/github`, - ), - undefined, - ); - t.is( - api.getAuthorizationHeaderFor( - logger, - { ...SAMPLE_DOTCOM_API_DETAILS, auth: " " }, - `${GITHUB_DOTCOM_URL}/github`, - ), - undefined, - ); + for (const authValue of [undefined, "", " "]) { + const client = new api.InstanceApiClient({ + ...SAMPLE_DOTCOM_API_DETAILS, + auth: authValue, + }); + t.is( + client.getAuthorizationHeaderFor(logger, `${GITHUB_DOTCOM_URL}/github`), + undefined, + ); + } }); test("getAuthorizationHeaderFor - returns header value with token if url is allowed", (t) => { const logger = new RecordingLogger(); + const client = new api.InstanceApiClient({ + ...SAMPLE_DOTCOM_API_DETAILS, + }); - t.is( - api.getAuthorizationHeaderFor( - logger, - SAMPLE_DOTCOM_API_DETAILS, - `${GITHUB_DOTCOM_URL}/github`, - ), - `token ${SAMPLE_DOTCOM_API_DETAILS.auth}`, - ); - t.is( - api.getAuthorizationHeaderFor( - logger, - SAMPLE_DOTCOM_API_DETAILS, - `${GITHUB_DOTCOM_API_URL}/github`, - ), - `token ${SAMPLE_DOTCOM_API_DETAILS.auth}`, - ); + for (const url of [ + `${GITHUB_DOTCOM_URL}/github`, + `${GITHUB_DOTCOM_API_URL}/github`, + ]) { + t.is( + client.getAuthorizationHeaderFor(logger, url), + `token ${SAMPLE_DOTCOM_API_DETAILS.auth}`, + ); + } }); test("getAuthorizationHeaderFor - returns undefined if url is not allowed", (t) => { const logger = new RecordingLogger(); + const client = new api.InstanceApiClient({ + ...SAMPLE_DOTCOM_API_DETAILS, + }); - t.is( - api.getAuthorizationHeaderFor( - logger, - SAMPLE_DOTCOM_API_DETAILS, - `https://localhost/github`, - ), - undefined, - ); - t.is( - api.getAuthorizationHeaderFor( - logger, - SAMPLE_DOTCOM_API_DETAILS, - `https://api.localhost/github`, - ), - undefined, - ); + for (const url of [ + `https://localhost/github`, + `https://api.localhost/github`, + ]) { + t.is(client.getAuthorizationHeaderFor(logger, url), undefined); + } }); diff --git a/src/api-client.ts b/src/api-client.ts index b75aacbb79..2a6d2eb131 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -18,9 +18,11 @@ import { RegistryProxyVars, getEnv, exportEnvVar, + ActionsEnvVars, } from "./environment"; import { getCurrentInstance, + getDotcomInstance, GITHUB_DOTCOM_URL, parseGitHubUrl, type GitHubApiCombinedDetails, @@ -120,6 +122,61 @@ export function makeProxyRequestOptions( /** The type of GitHub API client we use. */ export type ApiClient = InstanceType; +/** + * Encapsulates a {@link GitHubApiCombinedDetails} along with operations which ensure that any credentials + * are only used with the GitHub instance that is represented by the {@link GitHubApiCombinedDetails} value. + */ +export class InstanceApiClient { + private readonly client: ApiClient; + private readonly apiDetails: GitHubApiCombinedDetails; + private readonly authorizationHeader: string | undefined; + + constructor( + apiDetails: GitHubApiCombinedDetails, + clientOptions: CreateApiClientOptions = {}, + ) { + this.apiDetails = apiDetails; + this.client = createApiClientWithDetails(apiDetails, clientOptions); + + // Compute the Authorization header value if we have a token. + const token = this.apiDetails.auth?.trim(); + if (token !== undefined && token !== "") { + this.authorizationHeader = `token ${token}`; + } + } + + /** + * Gets a value for the `Authorization` header for a request to `url`; or `undefined` if the + * `Authorization` header should not be set for `url`. + * + * @param logger The logger to use for debugging messages. + * @param url The URL for which we want to add an `Authorization` header. + */ + public getAuthorizationHeaderFor( + logger: Logger, + url: string, + ): string | undefined { + // We don't have to check the `url` if we don't have a token to begin with. + if (this.authorizationHeader === undefined) { + return undefined; + } + + // We only want to provide an authorization header if we are downloading + // from the same GitHub instance the Action is running on. + // This avoids leaking Enterprise tokens to dotcom. + if ( + url.startsWith(`${this.apiDetails.url}/`) || + (this.apiDetails.apiURL && url.startsWith(`${this.apiDetails.apiURL}/`)) + ) { + logger.debug(`Providing an authorization token.`); + return this.authorizationHeader; + } + + logger.debug(`Not using an authorization token.`); + return undefined; + } +} + /** Options for `createApiClientWithDetails`. */ interface CreateApiClientOptions { allowExternal?: boolean; @@ -169,8 +226,33 @@ export function getApiDetails(env: ReadOnlyEnv = getEnv()): GitHubApiDetails { }; } -export function getApiClient(env: ReadOnlyEnv = getEnv()) { - return createApiClientWithDetails(getApiDetails(env)); +/** + * Gets a `GitHubApiDetails` object describing the GitHub Dotcom instance. + * + * @param env The environment variables to read. + */ +export function getDotcomApiDetails(env: ReadOnlyEnv): GitHubApiDetails { + // If we are currently running on dotcom, then just call `getApiDetails` which will + // construct the appropriate `GitHubApiDetails` from the environment and include the + // value of the `token` input. The `token` input relates to the GitHub instance that + // we are running on only. Therefore, if we are not currently running on dotcom, + // then we construct `GitHubApiDetails` without the `token` value and using the + // hard-coded instance and API URLs. + if (env.getRequired(ActionsEnvVars.GITHUB_SERVER_URL) === GITHUB_DOTCOM_URL) { + return getApiDetails(env); + } else { + return { + auth: undefined, + ...getDotcomInstance(), + }; + } +} + +export function getApiClient( + env: ReadOnlyEnv = getEnv(), + apiDetails?: GitHubApiDetails, +) { + return createApiClientWithDetails(apiDetails ?? getApiDetails(env)); } export function getApiClientWithExternalAuth( @@ -180,42 +262,6 @@ export function getApiClientWithExternalAuth( return createApiClientWithDetails(apiDetails, { allowExternal: true, proxy }); } -/** - * Gets a value for the `Authorization` header for a request to `url`; or `undefined` if the - * `Authorization` header should not be set for `url`. - * - * @param logger The logger to use for debugging messages. - * @param apiDetails Details of the GitHub API we are using. - * @param url The URL for which we want to add an `Authorization` header. - * - * @returns The value for the `Authorization` header or `undefined` if it shouldn't be populated. - */ -export function getAuthorizationHeaderFor( - logger: Logger, - apiDetails: GitHubApiDetails, - url: string, -): string | undefined { - // We don't have to check the `url` if we don't have a token to begin with. - const token = apiDetails.auth?.trim(); - if (token === undefined || token === "") { - return undefined; - } - - // We only want to provide an authorization header if we are downloading - // from the same GitHub instance the Action is running on. - // This avoids leaking Enterprise tokens to dotcom. - if ( - url.startsWith(`${apiDetails.url}/`) || - (apiDetails.apiURL && url.startsWith(`${apiDetails.apiURL}/`)) - ) { - logger.debug(`Providing an authorization token.`); - return `token ${token}`; - } - - logger.debug(`Not using an authorization token.`); - return undefined; -} - let cachedGitHubVersion: GitHubVersion | undefined = undefined; export async function getGitHubVersionFromApi( diff --git a/src/codeql-bundle.ts b/src/codeql-bundle.ts index 2d262c848e..2b6da00291 100644 --- a/src/codeql-bundle.ts +++ b/src/codeql-bundle.ts @@ -1,3 +1,4 @@ +import type { GitHubApiDetails } from "./github"; import { BuiltInLanguage, parseBuiltInLanguage } from "./languages"; import { BundlePlatform } from "./platform"; import type { CompressionMethod } from "./tar"; @@ -17,6 +18,8 @@ export type CodeQLBundle = export interface CodeQLDownloadSource { /** Distinguishes downloads from local archives and cached installations. */ sourceType: "download"; + /** Information about the GitHub instance that contains the asset. */ + apiDetails: GitHubApiDetails; /** The bundle to download. */ bundle: CodeQLBundle; /** The compression format of the bundle archive. */ diff --git a/src/codeql.test.ts b/src/codeql.test.ts index e70dcd99f9..ff0a4d5b6f 100644 --- a/src/codeql.test.ts +++ b/src/codeql.test.ts @@ -34,6 +34,7 @@ import { makeVersionInfo, createTestConfig, makeMacro, + mockApiDetails, } from "./testing-utils"; import { ToolsDownloadStatusReport } from "./tools-download"; import * as util from "./util"; @@ -126,19 +127,6 @@ function mockReleaseApi({ }); } -function mockApiDetails(apiDetails: Required) { - // This is a workaround to mock `api.getApiDetails()` since it doesn't seem to be possible to - // mock this directly. The difficulty is that `getApiDetails()` is called locally in - // `api-client.ts`, but `sinon.stub(api, "getApiDetails")` only affects calls to - // `getApiDetails()` via an imported `api` module. - sinon - .stub(actionsUtil, "getRequiredInput") - .withArgs("token") - .returns(apiDetails.auth); - process.env["GITHUB_SERVER_URL"] = apiDetails.url; - process.env["GITHUB_API_URL"] = apiDetails.apiURL || ""; -} - async function stubCodeql(): Promise { const codeqlObject = await codeql.getCodeQLForTesting(); sinon.stub(codeqlObject, "getVersion").resolves(makeVersionInfo("2.17.6")); @@ -290,6 +278,7 @@ for (const { cliVersion, tagName, expectedToolcacheVersion } of [ async (t) => { await util.withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); const result = await installIntoToolcache({ cliVersion, @@ -470,6 +459,7 @@ test.serial( await util.withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); await installIntoToolcache({ tagName: "codeql-bundle-20200601", @@ -517,6 +507,7 @@ test.serial( await util.withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); await installIntoToolcache({ tagName: "codeql-bundle-20200601", diff --git a/src/database-upload.ts b/src/database-upload.ts index df3796e505..ec615c69df 100644 --- a/src/database-upload.ts +++ b/src/database-upload.ts @@ -267,7 +267,10 @@ async function uploadBundledDatabase( bundledDbSize: number, apiDetails: GitHubApiDetails, ): Promise { - const client = getApiClient(); + // Ensure that the `apiDetails` that `getApiClient` uses match the ones we were given here, + // because we use `apiDetails.auth` below which must be used with the same instance that + // `apiDetails` refers to. + const client = getApiClient(util.getEnv(), apiDetails); const uploadsUrl = new URL(parseGitHubUrl(apiDetails.url)); uploadsUrl.hostname = `uploads.${uploadsUrl.hostname}`; diff --git a/src/repository.ts b/src/repository.ts index f1e7f369bc..652521fe33 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -6,6 +6,14 @@ export interface RepositoryNwo { repo: string; } +/** An approximation of what a repository NWO string looks like. */ +export type RepositoryNwoStr = `${string}/${string}`; + +/** A basic check that `val` looks like a `RepositoryNwoStr`. */ +export function isRepositoryNwoStr(val: string): val is RepositoryNwoStr { + return val.includes("/"); +} + /** * Get the repository name with owner from the environment variable * `GITHUB_REPOSITORY`. diff --git a/src/setup-codeql.test.ts b/src/setup-codeql.test.ts index 3fef11e63e..875cbcbf1d 100644 --- a/src/setup-codeql.test.ts +++ b/src/setup-codeql.test.ts @@ -32,6 +32,7 @@ import { getTestEnv, initAllState, makeMacro, + mockApiDetails, mockBundleDownloadApi, setupActionsVars, setupTests, @@ -126,24 +127,6 @@ test.serial("convert to semver", (t) => { } }); -test.serial("getCodeQLActionRepository", (t) => { - const logger = getRunnerLogger(true); - - initializeEnvironment("1.2.3"); - - // isRunningLocalAction() === true - delete process.env["GITHUB_ACTION_REPOSITORY"]; - process.env["RUNNER_TEMP"] = path.dirname(__dirname); - const repoLocalRunner = setupCodeql.getCodeQLActionRepository(logger); - t.deepEqual(repoLocalRunner, "github/codeql-action"); - - // isRunningLocalAction() === false - sinon.stub(actionsUtil, "isRunningLocalAction").returns(false); - process.env["GITHUB_ACTION_REPOSITORY"] = "xxx/yyy"; - const repoEnv = setupCodeql.getCodeQLActionRepository(logger); - t.deepEqual(repoEnv, "xxx/yyy"); -}); - test.serial( "getCodeQLSource sets CLI version for a semver tagged bundle", async (t) => { @@ -173,6 +156,7 @@ test.serial( compressionMethod: "gzip", sourceType: "download", toolsVersion: "1.2.3", + apiDetails: SAMPLE_DOTCOM_API_DETAILS, } satisfies setupCodeql.CodeQLDownloadSource); }); }, @@ -239,6 +223,7 @@ for (const { await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); const source = await setupCodeql.getCodeQLSource( "linked", SAMPLE_DEFAULT_CLI_VERSION, @@ -273,6 +258,7 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); const source = await setupCodeql.getCodeQLSource( "latest", SAMPLE_DEFAULT_CLI_VERSION, @@ -316,6 +302,7 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); const result = await setupCodeql.setupCodeQLBundle( "linked", SAMPLE_DOTCOM_API_DETAILS, @@ -449,6 +436,7 @@ test.serial( compressionMethod: "zstd", sourceType: "download", toolsVersion: expectedVersion, + apiDetails: SAMPLE_DOTCOM_API_DETAILS, } satisfies setupCodeql.CodeQLToolsSource); const result = await setupCodeql.setupCodeQLBundle( @@ -497,6 +485,7 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir, { GITHUB_EVENT_NAME: "dynamic" }); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); const source = await setupCodeql.getCodeQLSource( undefined, @@ -521,6 +510,7 @@ test.serial( compressionMethod: "zstd", sourceType: "download", toolsVersion: expectedVersion, + apiDetails: SAMPLE_DOTCOM_API_DETAILS, } satisfies setupCodeql.CodeQLToolsSource); // Afterwards, ensure that we see the expected messages in the log. @@ -642,6 +632,7 @@ for (const toolsInput of ["nightly", "nightly-latest"]) { await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); const source = await setupCodeql.getCodeQLSource( toolsInput, SAMPLE_DEFAULT_CLI_VERSION, @@ -667,6 +658,7 @@ for (const toolsInput of ["nightly", "nightly-latest"]) { cliVersion: undefined, compressionMethod: "zstd", toolsVersion: "0.0.0-30260213", + apiDetails: SAMPLE_DOTCOM_API_DETAILS, } satisfies setupCodeql.CodeQLDownloadSource); t.true(latestNightlyRequest.calledOnce); }); @@ -819,6 +811,7 @@ for (const date of ["20200101", "30260213"]) { cliVersion: undefined, compressionMethod: "zstd", toolsVersion: `0.0.0-${date}`, + apiDetails: SAMPLE_DOTCOM_API_DETAILS, } satisfies setupCodeql.CodeQLDownloadSource); await t.throwsAsync( @@ -963,6 +956,7 @@ const toolcacheInputFallbackMacro = makeMacro({ await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); for (const [k, v] of Object.entries(environment)) { process.env[k] = v; @@ -1183,6 +1177,7 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); const source = await setupCodeql.getCodeQLSource( undefined, PER_LANGUAGE_CLI_VERSION, @@ -1225,6 +1220,7 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); for (const { otherLanguagePacksReason, features } of [ // The per-language feature is disabled. { otherLanguagePacksReason: undefined, features: createFeatures([]) }, @@ -1294,7 +1290,14 @@ for (const fallback of [false, true]) { request: { fetch: fetchRelease }, }); sinon.stub(api, "getApiClient").value(() => client); - const authorizationSpy = sinon.spy(api, "getAuthorizationHeaderFor"); + + const spyClient = new api.InstanceApiClient(SAMPLE_DOTCOM_API_DETAILS); + const authorizationSpy = sinon.spy( + spyClient, + "getAuthorizationHeaderFor", + ); + sinon.stub(api, "InstanceApiClient").returns(spyClient); + const extractStub = stubDownloadAndExtract(); if (fallback) { extractStub.onFirstCall().rejects(new HTTPError("Not Found", 404)); @@ -1304,6 +1307,7 @@ for (const fallback of [false, true]) { setupActionsVars(tmpDir, tmpDir, { GITHUB_ACTION_REPOSITORY: "codeql-testing/action-fork", }); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); const result = await setupCodeql.setupCodeQLBundle( undefined, SAMPLE_DOTCOM_API_DETAILS, @@ -1326,9 +1330,9 @@ for (const fallback of [false, true]) { t.is(extractStub.firstCall.args[0], assetURL); t.is(extractStub.lastCall.args[0], fallback ? combinedURL : assetURL); t.is(authorizationSpy.callCount, extractStub.callCount); - t.is(authorizationSpy.firstCall.args[2], assetURL); + t.is(authorizationSpy.firstCall.args[1], assetURL); t.is( - authorizationSpy.lastCall.args[2], + authorizationSpy.lastCall.args[1], fallback ? combinedURL : assetURL, ); t.is(extractStub.lastCall.args[3], "token token"); @@ -1571,6 +1575,7 @@ for (const error of [ await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); await t.throwsAsync( setupCodeql.setupCodeQLBundle( undefined, @@ -1871,8 +1876,8 @@ async function runDownloadCodeQL( cliVersion: CLEANUP_CLI_VERSION, sourceType: "download", toolsVersion: CLEANUP_CLI_VERSION, + apiDetails: SAMPLE_DOTCOM_API_DETAILS, }, - SAMPLE_DOTCOM_API_DETAILS, undefined, // tarVersion toolcacheRoot, // tempDir ); @@ -2277,6 +2282,7 @@ test.serial( async (t) => { await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); delete process.env[EnvVar.HAS_SET_UP_CODEQL]; stubDownloadAndExtract(); @@ -2312,6 +2318,7 @@ test.serial( await withTmpDir(async (tmpDir) => { setupActionsVars(tmpDir, tmpDir); + mockApiDetails(SAMPLE_DOTCOM_API_DETAILS); process.env[ActionsEnvVars.RUNNER_ENVIRONMENT] = "github-hosted"; delete process.env[EnvVar.HAS_SET_UP_CODEQL]; const staleDirectory = createToolcacheEntry( diff --git a/src/setup-codeql.ts b/src/setup-codeql.ts index c6504f7790..5186405fca 100644 --- a/src/setup-codeql.ts +++ b/src/setup-codeql.ts @@ -4,7 +4,6 @@ import * as path from "path"; import { performance } from "perf_hooks"; import * as toolcache from "@actions/tool-cache"; -import { default as deepEqual } from "fast-deep-equal"; import * as semver from "semver"; import { v4 as uuidV4 } from "uuid"; @@ -13,7 +12,6 @@ import { isAnalyzingPullRequest, isDynamicWorkflow, isGitHubHostedRunner, - isRunningLocalAction, } from "./actions-util"; import * as api from "./api-client"; import { @@ -36,7 +34,6 @@ import { Feature, FeatureEnablement, } from "./feature-flags"; -import { GITHUB_DOTCOM_URL } from "./github"; import { BuiltInLanguage } from "./languages"; import { Logger } from "./logging"; import { getCodeQlVersionsForOverlayBaseDatabases } from "./overlay/caching"; @@ -45,6 +42,7 @@ import { logPerLanguageBundleFallback, } from "./per-language-bundles"; import { getBundlePlatform } from "./platform"; +import { getCodeQLAssetDownloadURL } from "./setup/repository"; import * as tar from "./tar"; import { deleteToolcacheBundles, @@ -66,7 +64,6 @@ export enum ToolsSource { Download = "DOWNLOAD", } -const CODEQL_DEFAULT_ACTION_REPOSITORY = "github/codeql-action"; const CODEQL_NIGHTLIES_REPOSITORY_OWNER = "dsp-testing"; const CODEQL_NIGHTLIES_REPOSITORY_NAME = "codeql-cli-nightlies"; @@ -74,75 +71,6 @@ const CODEQL_BUNDLE_VERSION_ALIAS: string[] = ["linked", "latest"]; const CODEQL_NIGHTLY_TOOLS_INPUTS = ["nightly", "nightly-latest"]; const CODEQL_TOOLCACHE_INPUT = "toolcache"; -export function getCodeQLActionRepository(logger: Logger): string { - if (isRunningLocalAction()) { - // This handles the case where the Action does not come from an Action repository, - // e.g. our integration tests which use the Action code from the current checkout. - // In these cases, the GITHUB_ACTION_REPOSITORY environment variable is not set. - logger.info( - "The CodeQL Action is checked out locally. Using the default CodeQL Action repository.", - ); - return CODEQL_DEFAULT_ACTION_REPOSITORY; - } - - return util.getRequiredEnvParam("GITHUB_ACTION_REPOSITORY"); -} - -async function getCodeQLBundleDownloadURL( - tagName: string, - apiDetails: api.GitHubApiDetails, - codeQLBundleName: string, - logger: Logger, -): Promise { - const codeQLActionRepository = getCodeQLActionRepository(logger); - const potentialDownloadSources = [ - // This GitHub instance, and this Action. - [apiDetails.url, codeQLActionRepository], - // This GitHub instance, and the canonical Action. - [apiDetails.url, CODEQL_DEFAULT_ACTION_REPOSITORY], - // GitHub.com, and the canonical Action. - [GITHUB_DOTCOM_URL, CODEQL_DEFAULT_ACTION_REPOSITORY], - ]; - // We now filter out any duplicates. - // Duplicates will happen either because the GitHub instance is GitHub.com, or because the Action is not a fork. - const uniqueDownloadSources = potentialDownloadSources.filter( - (source, index, self) => { - return !self.slice(0, index).some((other) => deepEqual(source, other)); - }, - ); - for (const downloadSource of uniqueDownloadSources) { - const [apiURL, repository] = downloadSource; - // If we've reached the final case, short-circuit the API check since we know the bundle exists and is public. - if ( - apiURL === GITHUB_DOTCOM_URL && - repository === CODEQL_DEFAULT_ACTION_REPOSITORY - ) { - break; - } - const [repositoryOwner, repositoryName] = repository.split("/"); - try { - const release = await api.getApiClient().rest.repos.getReleaseByTag({ - owner: repositoryOwner, - repo: repositoryName, - tag: tagName, - }); - for (const asset of release.data.assets) { - if (asset.name === codeQLBundleName) { - logger.info( - `Found CodeQL bundle ${codeQLBundleName} in ${repository} on ${apiURL} with URL ${asset.url}.`, - ); - return asset.url; - } - } - } catch (e) { - logger.info( - `Looked for CodeQL bundle ${codeQLBundleName} in ${repository} on ${apiURL} but got error ${e}.`, - ); - } - } - return `https://github.com/${CODEQL_DEFAULT_ACTION_REPOSITORY}/releases/download/${tagName}/${codeQLBundleName}`; -} - function tryGetBundleVersionFromTagName( tagName: string, logger: Logger, @@ -721,9 +649,10 @@ export async function getCodeQLSource( ? "zstd" : "gzip"; + const action = { env: getEnv(), logger }; const platform = getBundlePlatform(); const perLanguageBundleLanguage = await getPerLanguageBundleLanguage( - { env: getEnv(), features, logger }, + { ...action, features }, { rawLanguages, otherLanguagePacksReason, @@ -736,11 +665,11 @@ export async function getCodeQLSource( // Resolves the combined or per-language bundle URL for the requested release. const resolveBundleURL = (language?: BuiltInLanguage) => - getCodeQLBundleDownloadURL( - bundleTagName, + getCodeQLAssetDownloadURL( + action, apiDetails, + bundleTagName, getCodeQLBundleName(compressionMethod, platform, language), - logger, ); const combinedBundleURL = await resolveBundleURL(); @@ -789,6 +718,7 @@ export async function getCodeQLSource( compressionMethod, sourceType: "download", toolsVersion: resolvedVersion ?? "unknown", + apiDetails, }; } @@ -817,7 +747,6 @@ async function tryGetFallbackToolcacheVersion( // be able to stub this function and have other functions in this file use that stub. export const downloadCodeQL = async function ( source: CodeQLDownloadSource, - apiDetails: api.GitHubApiDetails, tarVersion: tar.TarVersion | undefined, tempDir: string, logger: Logger, @@ -838,11 +767,8 @@ export const downloadCodeQL = async function ( if (searchParams.has("token")) { logger.debug("CodeQL tools URL contains an authorization token."); } else { - authorization = api.getAuthorizationHeaderFor( - logger, - apiDetails, - codeqlURL, - ); + const client = new api.InstanceApiClient(source.apiDetails); + authorization = client.getAuthorizationHeaderFor(logger, codeqlURL); } const toolcacheDestination = getToolcacheDestination({ logger }, source); @@ -1056,7 +982,6 @@ export async function setupCodeQLBundle( const result = await downloadCodeQLBundle( { env: getEnv(), features, logger }, source, - apiDetails, zstdAvailability.version, tempDir, ); @@ -1092,7 +1017,6 @@ export async function setupCodeQLBundle( export async function downloadCodeQLBundle( action: ActionState<["Logger", "ReadOnlyEnv", "FeatureFlags"]>, source: CodeQLDownloadSource, - apiDetails: api.GitHubApiDetails, tarVersion: tar.TarVersion | undefined, tempDir: string, ): Promise<{ @@ -1106,13 +1030,7 @@ export async function downloadCodeQLBundle( const startTime = performance.now(); try { - return await downloadCodeQL( - source, - apiDetails, - tarVersion, - tempDir, - logger, - ); + return await downloadCodeQL(source, tarVersion, tempDir, logger); } catch (e) { if ( bundle.kind !== "per-language" || @@ -1128,7 +1046,6 @@ export async function downloadCodeQLBundle( ...source, bundle: { kind: "combined", url: bundle.combinedBundleURL }, }, - apiDetails, tarVersion, tempDir, logger, diff --git a/src/setup/repository.test.ts b/src/setup/repository.test.ts new file mode 100644 index 0000000000..56eb74786f --- /dev/null +++ b/src/setup/repository.test.ts @@ -0,0 +1,34 @@ +import * as path from "path"; + +import test from "ava"; +import * as sinon from "sinon"; + +import * as actionsUtil from "../actions-util"; +import { ActionsEnvVars } from "../environment"; +import { callee, setupTests } from "../testing-utils"; +import { initializeEnvironment } from "../util"; + +import { getCodeQLActionRepository } from "./repository"; + +setupTests(test); + +test.serial("getCodeQLActionRepository", async (t) => { + initializeEnvironment("1.2.3"); + + const target = callee(getCodeQLActionRepository) + .withArgs() + .withEnv((env) => { + env.set(ActionsEnvVars.RUNNER_TEMP, path.dirname(__dirname)); + }); + + // isRunningLocalAction() === true + await target.passes(t.deepEqual, "github/codeql-action"); + + // isRunningLocalAction() === false + sinon.stub(actionsUtil, "isRunningLocalAction").returns(false); + await target + .withEnv((env) => { + env.set(ActionsEnvVars.GITHUB_ACTION_REPOSITORY, "xxx/yyy"); + }) + .passes(t.deepEqual, "xxx/yyy"); +}); diff --git a/src/setup/repository.ts b/src/setup/repository.ts new file mode 100644 index 0000000000..d845f09535 --- /dev/null +++ b/src/setup/repository.ts @@ -0,0 +1,143 @@ +import { ActionState } from "../action-common"; +import { isRunningLocalAction } from "../actions-util"; +import * as api from "../api-client"; +import { ActionsEnvVars } from "../environment"; +import { isRepositoryNwoStr, type RepositoryNwoStr } from "../repository"; + +/** The NWO of the standard CodeQL Action repository. */ +export const CODEQL_DEFAULT_ACTION_REPOSITORY = "github/codeql-action"; + +/** + * Retrieves the NWO of the repository that the CodeQL Action is loaded from. + * Typically, this will be `github/codeql-action`, but it may be something else for + * forks of the Action or in environments other than GitHub.com. + * + * If this function detects that we are running the Action locally (e.g. in tests), + * then we default to `CODEQL_DEFAULT_ACTION_REPOSITORY`. + */ +export function getCodeQLActionRepository( + action: ActionState<["ReadOnlyEnv", "Logger"]>, +): RepositoryNwoStr { + if (isRunningLocalAction(action.env)) { + // This handles the case where the Action does not come from an Action repository, + // e.g. our integration tests which use the Action code from the current checkout. + // In these cases, the GITHUB_ACTION_REPOSITORY environment variable is not set. + action.logger.info( + "The CodeQL Action is checked out locally. Using the default CodeQL Action repository.", + ); + return CODEQL_DEFAULT_ACTION_REPOSITORY; + } + + const actionRepository = action.env.getRequired( + ActionsEnvVars.GITHUB_ACTION_REPOSITORY, + ); + + if (isRepositoryNwoStr(actionRepository)) { + return actionRepository; + } + + throw new Error( + `The value of '${ActionsEnvVars.GITHUB_ACTION_REPOSITORY}' is '${actionRepository}', which does not match the expected 'owner/repo' format.`, + ); +} + +/**A download source is a pair of strings. */ +export type DownloadSource = [api.GitHubApiDetails, RepositoryNwoStr]; + +/** Decides if the two provided download sources are the same. */ +const isSameDownloadSource = ( + [srcInstance, srcRepo]: DownloadSource, + [otherInstance, otherRepo]: DownloadSource, +) => { + return srcInstance.url === otherInstance.url && srcRepo === otherRepo; +}; + +/** A download URL is represented as a string. */ +export type DownloadURL = string; + +/** + * Tries to find a download URL for `assetName` in a release tagged with `tagName`. + * + * Depending on where and how the CodeQL Action is running, we may be using different `apiDetails` and there may be + * different options for where to source CodeQL releases from. + * + * This function either returns the download URL for the asset for the first release we find, or + * defaults to the assumed download URL for the asset on the default CodeQL Action repository on GitHub.com. + * In the latter case, this function does not guarantee that the asset actually exists. + * + * @param action The Action state. + * @param apiDetails The details of the GitHub API in use. + * @param tagName The name of the release tag we want to obtain the asset from. + * @param assetName The name of the asset we should look for in the release. + * @param [assetKind="CodeQL bundle"] The kind of asset we are looking for to show in log messages. + * @returns A URL that we can use to download the asset. + */ +export async function getCodeQLAssetDownloadURL( + action: ActionState<["ReadOnlyEnv", "Logger"]>, + apiDetails: api.GitHubApiDetails, + tagName: string, + assetName: string, + assetKind: "CodeQL bundle" = "CodeQL bundle", +): Promise { + const codeQLActionRepository = getCodeQLActionRepository(action); + + // We construct the default GitHub.com source here, because `getDotcomApiDetails` + // may retrieve the `token` input if we are currently running on GitHub.com and + // we want to avoid doing that statically. + const defaultDownloadSource: DownloadSource = [ + api.getDotcomApiDetails(action.env), + CODEQL_DEFAULT_ACTION_REPOSITORY, + ]; + + const potentialDownloadSources: DownloadSource[] = [ + // This GitHub instance, and this Action. + [apiDetails, codeQLActionRepository], + // This GitHub instance, and the canonical Action. + [apiDetails, CODEQL_DEFAULT_ACTION_REPOSITORY], + // GitHub.com, and the canonical Action. + defaultDownloadSource, + ]; + + // We now filter out any duplicates. + // Duplicates will happen either because the GitHub instance is GitHub.com, or because the Action is not a fork. + const uniqueDownloadSources = potentialDownloadSources.filter( + (source, index, self) => { + return !self + .slice(0, index) + .some((other) => isSameDownloadSource(source, other)); + }, + ); + + for (const [instance, repository] of uniqueDownloadSources) { + // If we've reached the final case, short-circuit the API check since we know the asset exists and is public. + if (isSameDownloadSource(defaultDownloadSource, [instance, repository])) { + break; + } + + const [repositoryOwner, repositoryName] = repository.split("/"); + try { + const release = await api + .getApiClient(action.env, instance) + .rest.repos.getReleaseByTag({ + owner: repositoryOwner, + repo: repositoryName, + tag: tagName, + }); + + for (const asset of release.data.assets) { + if (asset.name === assetName) { + action.logger.info( + `Found ${assetKind} ${assetName} in ${repository} on ${instance.apiURL} with URL ${asset.url}.`, + ); + return asset.url; + } + } + } catch (e) { + action.logger.info( + `Looked for ${assetKind} ${assetName} in ${repository} on ${instance.apiURL} but got error ${e}.`, + ); + } + } + + return `https://github.com/${CODEQL_DEFAULT_ACTION_REPOSITORY}/releases/download/${tagName}/${assetName}`; +} diff --git a/src/start-proxy.test.ts b/src/start-proxy.test.ts index bd24897136..07d4afe641 100644 --- a/src/start-proxy.test.ts +++ b/src/start-proxy.test.ts @@ -24,6 +24,7 @@ import { makeMacro, makeTestToken, RecordingLogger, + SAMPLE_DOTCOM_API_DETAILS, setupTests, withRecordingLoggerAsync, } from "./testing-utils"; @@ -751,7 +752,10 @@ test.serial( logger, features, }); - const info = await startProxyExports.getDownloadUrl(state); + const info = await startProxyExports.getDownloadUrl( + state, + SAMPLE_DOTCOM_API_DETAILS, + ); t.is(info.version, startProxyExports.UPDATEJOB_PROXY_VERSION); t.is( @@ -777,7 +781,10 @@ test.serial( await withTmpDir(async (tempDir) => { const features = mockOfflineFeatures(tempDir, logger); const state = initAllState({ logger, features }); - const info = await startProxyExports.getDownloadUrl(state); + const info = await startProxyExports.getDownloadUrl( + state, + SAMPLE_DOTCOM_API_DETAILS, + ); t.is(info.version, startProxyExports.UPDATEJOB_PROXY_VERSION); t.is( @@ -802,7 +809,10 @@ test.serial( for (const assets of testAssets) { const stub = mockGetReleaseByTag(assets); - const info = await startProxyExports.getDownloadUrl(state); + const info = await startProxyExports.getDownloadUrl( + state, + SAMPLE_DOTCOM_API_DETAILS, + ); t.is(info.version, startProxyExports.UPDATEJOB_PROXY_VERSION); t.is( @@ -832,7 +842,10 @@ test.serial("getDownloadUrl returns matching release asset", async (t) => { await withTmpDir(async (tempDir) => { const features = mockOfflineFeatures(tempDir, logger); - const info = await startProxyExports.getDownloadUrl({ ...state, features }); + const info = await startProxyExports.getDownloadUrl( + { ...state, features }, + SAMPLE_DOTCOM_API_DETAILS, + ); t.is(info.version, defaults.cliVersion); t.is(info.url, "url-we-want"); @@ -961,6 +974,8 @@ test.serial( const logger = new RecordingLogger(); mockGetReleaseByTag(); + sinon.stub(apiClient, "getApiDetails").returns(SAMPLE_DOTCOM_API_DETAILS); + await withTmpDir(async (tempDir) => { const toolcachePath = "/path/to/proxy/dir"; sinon.stub(toolcache, "find").returns(toolcachePath); @@ -994,14 +1009,19 @@ test.serial( const toolcachePath = "/path/to/proxy/dir"; const find = sinon.stub(toolcache, "find").returns(""); - const getApiDetails = sinon.stub(apiClient, "getApiDetails").returns({ + const apiDetails = { auth: "", url: "", apiURL: "", - }); + } satisfies apiClient.GitHubApiDetails; + const getApiDetails = sinon + .stub(apiClient, "getApiDetails") + .returns(apiDetails); + const client = new apiClient.InstanceApiClient(apiDetails); const getAuthorizationHeaderFor = sinon - .stub(apiClient, "getAuthorizationHeaderFor") + .stub(client, "getAuthorizationHeaderFor") .returns(undefined); + sinon.stub(apiClient, "InstanceApiClient").returns(client); const archivePath = "/path/to/archive"; const downloadTool = sinon .stub(toolcache, "downloadTool") @@ -1066,14 +1086,19 @@ test.serial( await withTmpDir(async (tempDir) => { const toolcachePath = "/path/to/proxy/dir"; const find = sinon.stub(toolcache, "find").returns(""); - const getApiDetails = sinon.stub(apiClient, "getApiDetails").returns({ + const apiDetails = { auth: "", url: "", apiURL: "", - }); + } satisfies apiClient.GitHubApiDetails; + const getApiDetails = sinon + .stub(apiClient, "getApiDetails") + .returns(apiDetails); + const client = new apiClient.InstanceApiClient(apiDetails); const getAuthorizationHeaderFor = sinon - .stub(apiClient, "getAuthorizationHeaderFor") + .stub(client, "getAuthorizationHeaderFor") .returns(undefined); + sinon.stub(apiClient, "InstanceApiClient").returns(client); const archivePath = "/path/to/archive"; const downloadTool = sinon .stub(toolcache, "downloadTool") diff --git a/src/start-proxy.ts b/src/start-proxy.ts index 6221c9940f..41e55f8f01 100644 --- a/src/start-proxy.ts +++ b/src/start-proxy.ts @@ -7,12 +7,14 @@ import { ActionState } from "./action-common"; import { getApiClient, getApiDetails, - getAuthorizationHeaderFor, getGitHubVersion, + InstanceApiClient, + type GitHubApiDetails, } from "./api-client"; import * as artifactScanner from "./artifact-scanner"; import { Config } from "./config-utils"; import * as defaults from "./defaults.json"; +import type { ReadOnlyEnv } from "./environment"; import { CodeQLDefaultVersionInfo, Feature, @@ -395,8 +397,12 @@ export function getFallbackUrl(proxyPackage: string): string { * * @returns The response from the GitHub API. */ -async function getReleaseByVersion(version: string) { - return getApiClient().rest.repos.getReleaseByTag({ +async function getReleaseByVersion( + env: ReadOnlyEnv, + apiDetails: GitHubApiDetails, + version: string, +) { + return getApiClient(env, apiDetails).rest.repos.getReleaseByTag({ owner: "github", repo: "codeql-action", tag: version, @@ -419,7 +425,8 @@ async function getCliVersionFromFeatures( * @returns Returns the download URL and version of the proxy package we plan to use. */ export async function getDownloadUrl( - action: ActionState<["Base", "Logger", "FeatureFlags"]>, + action: ActionState<["Base", "ReadOnlyEnv", "Logger", "FeatureFlags"]>, + apiDetails: GitHubApiDetails, ): Promise<{ url: string; version: string }> { // Default to linux64 if we don't recognise the platform+arch pair. // This maintains the behaviour we had before switching to `getBundlePlatform` here. @@ -448,7 +455,11 @@ export async function getDownloadUrl( }; // Try to retrieve information about the CLI bundle release identified by `versionInfo`. - const cliRelease = await getReleaseByVersion(versionInfo.tagName); + const cliRelease = await getReleaseByVersion( + action.env, + apiDetails, + versionInfo.tagName, + ); // Search the release's assets to find the one we are looking for. for (const asset of cliRelease.data.assets) { @@ -569,18 +580,18 @@ export function getProxyFilename() { * @returns The path to the proxy binary. */ export async function getProxyBinaryPath( - action: ActionState<["Base", "Logger", "FeatureFlags"]>, + action: ActionState<["Base", "ReadOnlyEnv", "Logger", "FeatureFlags"]>, ): Promise { const logger = action.logger; + const apiDetails = getApiDetails(); + const client = new InstanceApiClient(apiDetails); const proxyFileName = getProxyFilename(); - const proxyInfo = await getDownloadUrl(action); + const proxyInfo = await getDownloadUrl(action, apiDetails); let proxyBin = toolcache.find(proxyFileName, proxyInfo.version); if (!proxyBin) { - const apiDetails = getApiDetails(); - const authorization = getAuthorizationHeaderFor( + const authorization = client.getAuthorizationHeaderFor( logger, - apiDetails, proxyInfo.url, ); const temp = await downloadProxy(logger, proxyInfo.url, authorization); diff --git a/src/testing-utils.ts b/src/testing-utils.ts index cd60318944..7ae83e906c 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -14,6 +14,7 @@ import * as sinon from "sinon"; import { ActionState, StateFeature } from "./action-common"; import { ActionsEnv, getActionVersion } from "./actions-util"; +import * as actionsUtil from "./actions-util"; import { AnalysisKind } from "./analyses"; import * as apiClient from "./api-client"; import { GitHubApiDetails } from "./api-client"; @@ -52,6 +53,19 @@ export const SAMPLE_DOTCOM_API_DETAILS = { apiURL: "https://api.github.com", }; +export function mockApiDetails(apiDetails: Required) { + // This is a workaround to mock `api.getApiDetails()` since it doesn't seem to be possible to + // mock this directly. The difficulty is that `getApiDetails()` is called locally in + // `api-client.ts`, but `sinon.stub(api, "getApiDetails")` only affects calls to + // `getApiDetails()` via an imported `api` module. + sinon + .stub(actionsUtil, "getRequiredInput") + .withArgs("token") + .returns(apiDetails.auth); + process.env["GITHUB_SERVER_URL"] = apiDetails.url; + process.env["GITHUB_API_URL"] = apiDetails.apiURL || ""; +} + export const LINKED_CLI_VERSION = { cliVersion: defaults.cliVersion, tagName: defaults.bundleVersion,