diff --git a/lib/entry-points.js b/lib/entry-points.js index 11a8c4c291..c91850e7a7 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -148660,20 +148660,32 @@ function initializeEnvironment(version) { core2.exportVariable("CODEQL_ACTION_FEATURE_WILL_UPLOAD" /* FEATURE_WILL_UPLOAD */, "true"); core2.exportVariable("CODEQL_ACTION_VERSION" /* VERSION */, version); } -function getRequiredEnvParam(paramName) { - const value = process.env[paramName]; +function getEnv(env = process.env) { + return { + getRequired: (name) => getRequiredEnvVar(env, name), + getOptional: (name) => getOptionalEnvVarFrom(env, name) + }; +} +function getRequiredEnvVar(env, paramName) { + const value = env[paramName]; if (value === void 0 || value.length === 0) { throw new Error(`${paramName} environment variable must be set`); } return value; } -function getOptionalEnvVar(paramName) { - const value = process.env[paramName]; +function getRequiredEnvParam(paramName) { + return getRequiredEnvVar(process.env, paramName); +} +function getOptionalEnvVarFrom(env, paramName) { + const value = env[paramName]; if (value?.trim().length === 0) { return void 0; } return value; } +function getOptionalEnvVar(paramName) { + return getOptionalEnvVarFrom(process.env, paramName); +} var HTTPError = class extends Error { status; constructor(message, status) { @@ -149025,7 +149037,7 @@ var getOptionalInput = function(name) { }; function getTemporaryDirectory() { const value = process.env["CODEQL_ACTION_TEMP"]; - return value !== void 0 && value !== "" ? value : getRequiredEnvParam("RUNNER_TEMP"); + return value !== void 0 && value !== "" ? value : getRequiredEnvParam("RUNNER_TEMP" /* RUNNER_TEMP */); } var PR_DIFF_RANGE_JSON_FILENAME = "pr-diff-range.json"; function getDiffRangesJsonFilePath() { @@ -149035,19 +149047,19 @@ function getActionVersion() { return "4.36.3"; } function getWorkflowEventName() { - return getRequiredEnvParam("GITHUB_EVENT_NAME"); + return getRequiredEnvParam("GITHUB_EVENT_NAME" /* GITHUB_EVENT_NAME */); } function isRunningLocalAction() { const relativeScriptPath = getRelativeScriptPath(); return relativeScriptPath.startsWith("..") || path2.isAbsolute(relativeScriptPath); } function getRelativeScriptPath() { - const runnerTemp = getRequiredEnvParam("RUNNER_TEMP"); + const runnerTemp = getRequiredEnvParam("RUNNER_TEMP" /* RUNNER_TEMP */); const actionsDirectory = path2.join(path2.dirname(runnerTemp), "_actions"); return path2.relative(actionsDirectory, __filename); } function getWorkflowEvent() { - const eventJsonFile = getRequiredEnvParam("GITHUB_EVENT_PATH"); + const eventJsonFile = getRequiredEnvParam("GITHUB_EVENT_PATH" /* GITHUB_EVENT_PATH */); try { return JSON.parse(fs2.readFileSync(eventJsonFile, "utf-8")); } catch (e) { @@ -149104,31 +149116,33 @@ function getUploadValue(input) { } } function getWorkflowRunID() { - const workflowRunIdString = getRequiredEnvParam("GITHUB_RUN_ID"); + const workflowRunIdString = getRequiredEnvParam("GITHUB_RUN_ID" /* GITHUB_RUN_ID */); const workflowRunID = parseInt(workflowRunIdString, 10); if (Number.isNaN(workflowRunID)) { throw new Error( - `GITHUB_RUN_ID must define a non NaN workflow run ID. Current value is ${workflowRunIdString}` + `${"GITHUB_RUN_ID" /* GITHUB_RUN_ID */} must define a non NaN workflow run ID. Current value is ${workflowRunIdString}` ); } if (workflowRunID < 0) { throw new Error( - `GITHUB_RUN_ID must be a non-negative integer. Current value is ${workflowRunIdString}` + `${"GITHUB_RUN_ID" /* GITHUB_RUN_ID */} must be a non-negative integer. Current value is ${workflowRunIdString}` ); } return workflowRunID; } function getWorkflowRunAttempt() { - const workflowRunAttemptString = getRequiredEnvParam("GITHUB_RUN_ATTEMPT"); + const workflowRunAttemptString = getRequiredEnvParam( + "GITHUB_RUN_ATTEMPT" /* GITHUB_RUN_ATTEMPT */ + ); const workflowRunAttempt = parseInt(workflowRunAttemptString, 10); if (Number.isNaN(workflowRunAttempt)) { throw new Error( - `GITHUB_RUN_ATTEMPT must define a non NaN workflow run attempt. Current value is ${workflowRunAttemptString}` + `${"GITHUB_RUN_ATTEMPT" /* GITHUB_RUN_ATTEMPT */} must define a non NaN workflow run attempt. Current value is ${workflowRunAttemptString}` ); } if (workflowRunAttempt <= 0) { throw new Error( - `GITHUB_RUN_ATTEMPT must be a positive integer. Current value is ${workflowRunAttemptString}` + `${"GITHUB_RUN_ATTEMPT" /* GITHUB_RUN_ATTEMPT */} must be a positive integer. Current value is ${workflowRunAttemptString}` ); } return workflowRunAttempt; @@ -149439,8 +149453,8 @@ function createApiClientWithDetails(apiDetails, { allowExternal = false } = {}) function getApiDetails() { return { auth: getRequiredInput("token"), - url: getRequiredEnvParam("GITHUB_SERVER_URL"), - apiURL: getRequiredEnvParam("GITHUB_API_URL") + url: getRequiredEnvParam("GITHUB_SERVER_URL" /* GITHUB_SERVER_URL */), + apiURL: getRequiredEnvParam("GITHUB_API_URL" /* GITHUB_API_URL */) }; } function getApiClient() { @@ -151039,7 +151053,7 @@ function getInvalidConfigFileMessage(configFile, messages) { } function getConfigFileRepoFormatInvalidMessage(configFile) { let error3 = `The configuration file "${configFile}" is not a supported remote file reference.`; - error3 += " Expected format //@"; + error3 += " Expected format [/][/][@]"; return error3; } function getConfigFileFormatInvalidMessage(configFile) { @@ -151420,6 +151434,90 @@ function parseUserConfig(logger, pathInput, contents, validateConfig) { } } +// src/config/remote-file.ts +var DEFAULT_CONFIG_FILE_NAME = ".github/codeql-action.yaml"; +var DEFAULT_CONFIG_FILE_REF = "main"; +function getDefaultOwner(env) { + const currentRepoNwo = env.getRequired("GITHUB_REPOSITORY" /* GITHUB_REPOSITORY */); + const nwoParts = currentRepoNwo.split("/"); + if (nwoParts.length !== 2 || nwoParts[0].trim().length === 0) { + throw new Error( + `Expected ${"GITHUB_REPOSITORY" /* GITHUB_REPOSITORY */} to contain a name with owner, but got '${currentRepoNwo}'.` + ); + } + return nwoParts[0].trim(); +} +function parseRemoteFileAddress(env, configFile) { + const format = new RegExp( + "^((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?$" + ); + const pieces = format.exec(configFile.trim()); + const repo = pieces?.groups?.repo?.trim(); + if (!pieces?.groups || !repo || repo.length === 0) { + throw new ConfigurationError( + getConfigFileRepoFormatInvalidMessage(configFile) + ); + } + const owner = pieces.groups.owner?.trim(); + const path29 = pieces.groups.path?.trim(); + const ref = pieces.groups.ref?.trim(); + if (path29?.startsWith("/")) { + throw new ConfigurationError( + `The path component of '${configFile}' cannot be an absolute path.` + ); + } + return { + owner: owner || getDefaultOwner(env), + repo, + path: path29 || DEFAULT_CONFIG_FILE_NAME, + ref: ref || DEFAULT_CONFIG_FILE_REF + }; +} + +// src/config/file.ts +function getConfigFileInput(logger, actions, repositoryProperties) { + const input = actions.getOptionalInput("config-file"); + if (input !== void 0) { + logger.info(`Using configuration file input from workflow: ${input}`); + return input; + } + const propertyValue = repositoryProperties["github-codeql-config-file" /* CONFIG_FILE */]; + if (propertyValue !== void 0 && propertyValue.trim().length > 0) { + logger.info( + `Using configuration file input from repository property: ${propertyValue}` + ); + return propertyValue; + } + return void 0; +} +async function getRemoteConfig(logger, configFile, apiDetails, validateConfig) { + const address = parseRemoteFileAddress(getEnv(), configFile); + const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ + owner: address.owner, + repo: address.repo, + path: address.path, + ref: address.ref + }); + let fileContents; + if ("content" in response.data && response.data.content !== void 0) { + fileContents = response.data.content; + } else if (Array.isArray(response.data)) { + throw new ConfigurationError( + getConfigFileDirectoryGivenMessage(configFile) + ); + } else { + throw new ConfigurationError( + getConfigFileFormatInvalidMessage(configFile) + ); + } + return parseUserConfig( + logger, + configFile, + Buffer.from(fileContents, "base64").toString("binary"), + validateConfig + ); +} + // src/diagnostics.ts var import_fs = require("fs"); var import_path = __toESM(require("path")); @@ -152863,41 +152961,6 @@ function getLocalConfig(logger, configFile, validateConfig) { validateConfig ); } -async function getRemoteConfig(logger, configFile, apiDetails, validateConfig) { - const format = new RegExp( - "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)" - ); - const pieces = format.exec(configFile); - if (pieces?.groups === void 0 || pieces.length < 5) { - throw new ConfigurationError( - getConfigFileRepoFormatInvalidMessage(configFile) - ); - } - const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ - owner: pieces.groups.owner, - repo: pieces.groups.repo, - path: pieces.groups.path, - ref: pieces.groups.ref - }); - let fileContents; - if ("content" in response.data && response.data.content !== void 0) { - fileContents = response.data.content; - } else if (Array.isArray(response.data)) { - throw new ConfigurationError( - getConfigFileDirectoryGivenMessage(configFile) - ); - } else { - throw new ConfigurationError( - getConfigFileFormatInvalidMessage(configFile) - ); - } - return parseUserConfig( - logger, - configFile, - Buffer.from(fileContents, "base64").toString("binary"), - validateConfig - ); -} function getPathToParsedConfigFile(tempDir) { return path10.join(tempDir, "config"); } @@ -160713,7 +160776,7 @@ var import_async = __toESM(require_async(), 1); var import_path6 = require("path"); // node_modules/archiver/lib/error.js -var import_util28 = __toESM(require("util"), 1); +var import_util30 = __toESM(require("util"), 1); var ERROR_CODES = { ABORTED: "archive was aborted", DIRECTORYDIRPATHREQUIRED: "diretory dirpath argument must be a non-empty string value", @@ -160738,7 +160801,7 @@ function ArchiverError(code, data) { this.code = code; this.data = data; } -import_util28.default.inherits(ArchiverError, Error); +import_util30.default.inherits(ArchiverError, Error); // node_modules/archiver/lib/core.js var import_readable_stream2 = __toESM(require_ours(), 1); @@ -163682,23 +163745,6 @@ var github3 = __toESM(require_github()); var io7 = __toESM(require_io()); var semver10 = __toESM(require_semver2()); -// src/config/file.ts -function getConfigFileInput(logger, actions, repositoryProperties) { - const input = actions.getOptionalInput("config-file"); - if (input !== void 0) { - logger.info(`Using configuration file input from workflow: ${input}`); - return input; - } - const propertyValue = repositoryProperties["github-codeql-config-file" /* CONFIG_FILE */]; - if (propertyValue !== void 0 && propertyValue.trim().length > 0) { - logger.info( - `Using configuration file input from repository property: ${propertyValue}` - ); - return propertyValue; - } - return void 0; -} - // src/workflow.ts var fs27 = __toESM(require("fs")); var path23 = __toESM(require("path")); diff --git a/src/actions-util.ts b/src/actions-util.ts index dea22d5c57..d7fbacbf3e 100644 --- a/src/actions-util.ts +++ b/src/actions-util.ts @@ -21,6 +21,28 @@ import { */ declare const __CODEQL_ACTION_VERSION__: string; +/** + * Enumerates known GitHub Actions environment variables that we expect + * to be set in a GitHub Actions environment. + */ +export enum ActionsEnvVars { + GITHUB_ACTION_REPOSITORY = "GITHUB_ACTION_REPOSITORY", + GITHUB_API_URL = "GITHUB_API_URL", + GITHUB_EVENT_NAME = "GITHUB_EVENT_NAME", + GITHUB_EVENT_PATH = "GITHUB_EVENT_PATH", + GITHUB_JOB = "GITHUB_JOB", + GITHUB_REF = "GITHUB_REF", + GITHUB_REPOSITORY = "GITHUB_REPOSITORY", + GITHUB_RUN_ATTEMPT = "GITHUB_RUN_ATTEMPT", + GITHUB_RUN_ID = "GITHUB_RUN_ID", + GITHUB_SERVER_URL = "GITHUB_SERVER_URL", + GITHUB_SHA = "GITHUB_SHA", + GITHUB_WORKFLOW = "GITHUB_WORKFLOW", + RUNNER_NAME = "RUNNER_NAME", + RUNNER_OS = "RUNNER_OS", + RUNNER_TEMP = "RUNNER_TEMP", +} + /** * Abstracts over GitHub Actions functions so that we do not have to stub * global functions in tests. @@ -65,7 +87,7 @@ export function getTemporaryDirectory(): string { const value = process.env["CODEQL_ACTION_TEMP"]; return value !== undefined && value !== "" ? value - : getRequiredEnvParam("RUNNER_TEMP"); + : getRequiredEnvParam(ActionsEnvVars.RUNNER_TEMP); } const PR_DIFF_RANGE_JSON_FILENAME = "pr-diff-range.json"; @@ -84,7 +106,7 @@ export function getActionVersion(): string { * This will be "dynamic" for default setup workflow runs. */ export function getWorkflowEventName() { - return getRequiredEnvParam("GITHUB_EVENT_NAME"); + return getRequiredEnvParam(ActionsEnvVars.GITHUB_EVENT_NAME); } /** @@ -104,14 +126,14 @@ export function isRunningLocalAction(): boolean { * This can be used to get the Action's name or tell if we're running a local Action. */ function getRelativeScriptPath(): string { - const runnerTemp = getRequiredEnvParam("RUNNER_TEMP"); + const runnerTemp = getRequiredEnvParam(ActionsEnvVars.RUNNER_TEMP); const actionsDirectory = path.join(path.dirname(runnerTemp), "_actions"); return path.relative(actionsDirectory, __filename); } /** Returns the contents of `GITHUB_EVENT_PATH` as a JSON object. */ export function getWorkflowEvent(): any { - const eventJsonFile = getRequiredEnvParam("GITHUB_EVENT_PATH"); + const eventJsonFile = getRequiredEnvParam(ActionsEnvVars.GITHUB_EVENT_PATH); try { return JSON.parse(fs.readFileSync(eventJsonFile, "utf-8")); } catch (e) { @@ -181,16 +203,16 @@ export function getUploadValue(input: string | undefined): UploadKind { * Get the workflow run ID. */ export function getWorkflowRunID(): number { - const workflowRunIdString = getRequiredEnvParam("GITHUB_RUN_ID"); + const workflowRunIdString = getRequiredEnvParam(ActionsEnvVars.GITHUB_RUN_ID); const workflowRunID = parseInt(workflowRunIdString, 10); if (Number.isNaN(workflowRunID)) { throw new Error( - `GITHUB_RUN_ID must define a non NaN workflow run ID. Current value is ${workflowRunIdString}`, + `${ActionsEnvVars.GITHUB_RUN_ID} must define a non NaN workflow run ID. Current value is ${workflowRunIdString}`, ); } if (workflowRunID < 0) { throw new Error( - `GITHUB_RUN_ID must be a non-negative integer. Current value is ${workflowRunIdString}`, + `${ActionsEnvVars.GITHUB_RUN_ID} must be a non-negative integer. Current value is ${workflowRunIdString}`, ); } return workflowRunID; @@ -200,16 +222,18 @@ export function getWorkflowRunID(): number { * Get the workflow run attempt number. */ export function getWorkflowRunAttempt(): number { - const workflowRunAttemptString = getRequiredEnvParam("GITHUB_RUN_ATTEMPT"); + const workflowRunAttemptString = getRequiredEnvParam( + ActionsEnvVars.GITHUB_RUN_ATTEMPT, + ); const workflowRunAttempt = parseInt(workflowRunAttemptString, 10); if (Number.isNaN(workflowRunAttempt)) { throw new Error( - `GITHUB_RUN_ATTEMPT must define a non NaN workflow run attempt. Current value is ${workflowRunAttemptString}`, + `${ActionsEnvVars.GITHUB_RUN_ATTEMPT} must define a non NaN workflow run attempt. Current value is ${workflowRunAttemptString}`, ); } if (workflowRunAttempt <= 0) { throw new Error( - `GITHUB_RUN_ATTEMPT must be a positive integer. Current value is ${workflowRunAttemptString}`, + `${ActionsEnvVars.GITHUB_RUN_ATTEMPT} must be a positive integer. Current value is ${workflowRunAttemptString}`, ); } return workflowRunAttempt; diff --git a/src/api-client.ts b/src/api-client.ts index 4a061d4828..16714b4804 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -2,7 +2,11 @@ import * as core from "@actions/core"; import * as githubUtils from "@actions/github/lib/utils"; import * as retry from "@octokit/plugin-retry"; -import { getActionVersion, getRequiredInput } from "./actions-util"; +import { + ActionsEnvVars, + getActionVersion, + getRequiredInput, +} from "./actions-util"; import { EnvVar } from "./environment"; import { Logger } from "./logging"; import { getRepositoryNwo, RepositoryNwo } from "./repository"; @@ -70,8 +74,8 @@ function createApiClientWithDetails( export function getApiDetails(): GitHubApiDetails { return { auth: getRequiredInput("token"), - url: getRequiredEnvParam("GITHUB_SERVER_URL"), - apiURL: getRequiredEnvParam("GITHUB_API_URL"), + url: getRequiredEnvParam(ActionsEnvVars.GITHUB_SERVER_URL), + apiURL: getRequiredEnvParam(ActionsEnvVars.GITHUB_API_URL), }; } diff --git a/src/config-utils.test.ts b/src/config-utils.test.ts index 27de780ad5..830defa712 100644 --- a/src/config-utils.test.ts +++ b/src/config-utils.test.ts @@ -424,34 +424,6 @@ test.serial("load input outside of workspace", async (t) => { }); }); -test.serial("load non-local input with invalid repo syntax", async (t) => { - return await withTmpDir(async (tempDir) => { - // no filename given, just a repo - const configFile = "octo-org/codeql-config@main"; - - try { - await configUtils.initConfig( - createFeatures([]), - createTestInitConfigInputs({ - configFile, - tempDir, - workspacePath: tempDir, - }), - ); - throw new Error("initConfig did not throw error"); - } catch (err) { - t.deepEqual( - err, - new ConfigurationError( - errorMessages.getConfigFileRepoFormatInvalidMessage( - "octo-org/codeql-config@main", - ), - ), - ); - } - }); -}); - test.serial("load non-existent input", async (t) => { return await withTmpDir(async (tempDir) => { const languagesInput = "javascript"; diff --git a/src/config-utils.ts b/src/config-utils.ts index 972734877a..39e612674a 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -27,6 +27,7 @@ import { parseUserConfig, UserConfig, } from "./config/db-config"; +import { getRemoteConfig } from "./config/file"; import { addNoLanguageDiagnostic, makeTelemetryDiagnostic, @@ -1369,54 +1370,6 @@ function getLocalConfig( ); } -async function getRemoteConfig( - logger: Logger, - configFile: string, - apiDetails: api.GitHubApiCombinedDetails, - validateConfig: boolean, -): Promise { - // retrieve the various parts of the config location, and ensure they're present - const format = new RegExp( - "(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)", - ); - const pieces = format.exec(configFile); - // 5 = 4 groups + the whole expression - if (pieces?.groups === undefined || pieces.length < 5) { - throw new ConfigurationError( - errorMessages.getConfigFileRepoFormatInvalidMessage(configFile), - ); - } - - const response = await api - .getApiClientWithExternalAuth(apiDetails) - .rest.repos.getContent({ - owner: pieces.groups.owner, - repo: pieces.groups.repo, - path: pieces.groups.path, - ref: pieces.groups.ref, - }); - - let fileContents: string; - if ("content" in response.data && response.data.content !== undefined) { - fileContents = response.data.content; - } else if (Array.isArray(response.data)) { - throw new ConfigurationError( - errorMessages.getConfigFileDirectoryGivenMessage(configFile), - ); - } else { - throw new ConfigurationError( - errorMessages.getConfigFileFormatInvalidMessage(configFile), - ); - } - - return parseUserConfig( - logger, - configFile, - Buffer.from(fileContents, "base64").toString("binary"), - validateConfig, - ); -} - /** * Get the file path where the parsed config will be stored. */ diff --git a/src/config/file.ts b/src/config/file.ts index 24613dc557..f178386512 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -1,9 +1,15 @@ import { ActionsEnv } from "../actions-util"; +import * as api from "../api-client"; +import * as errorMessages from "../error-messages"; import { RepositoryProperties, RepositoryPropertyName, } from "../feature-flags/properties"; import { Logger } from "../logging"; +import { ConfigurationError, getEnv } from "../util"; + +import { parseUserConfig, UserConfig } from "./db-config"; +import { parseRemoteFileAddress } from "./remote-file"; /** * Gets the value that is configured for the configuration file, if any. @@ -32,3 +38,51 @@ export function getConfigFileInput( return undefined; } + +/** + * Attempts to fetch a `UserConfig` from a remote `address`. + * + * @param logger The logger to use. + * @param configFile The remote address of the configuration file. + * @param apiDetails Information about how to connect to the API. + * @param validateConfig Whether to validate the configuration. + * + * @returns The `UserConfig`, if it could be fetched and parsed successfully. + */ +export async function getRemoteConfig( + logger: Logger, + configFile: string, + apiDetails: api.GitHubApiCombinedDetails, + validateConfig: boolean, +): Promise { + const address = parseRemoteFileAddress(getEnv(), configFile); + + const response = await api + .getApiClientWithExternalAuth(apiDetails) + .rest.repos.getContent({ + owner: address.owner, + repo: address.repo, + path: address.path, + ref: address.ref, + }); + + let fileContents: string; + if ("content" in response.data && response.data.content !== undefined) { + fileContents = response.data.content; + } else if (Array.isArray(response.data)) { + throw new ConfigurationError( + errorMessages.getConfigFileDirectoryGivenMessage(configFile), + ); + } else { + throw new ConfigurationError( + errorMessages.getConfigFileFormatInvalidMessage(configFile), + ); + } + + return parseUserConfig( + logger, + configFile, + Buffer.from(fileContents, "base64").toString("binary"), + validateConfig, + ); +} diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts new file mode 100644 index 0000000000..36b68fd552 --- /dev/null +++ b/src/config/remote-file.test.ts @@ -0,0 +1,180 @@ +import test from "ava"; +import sinon from "sinon"; + +import { ActionsEnvVars } from "../actions-util"; +import { getTestEnv } from "../testing-utils"; +import { ConfigurationError } from "../util"; + +import { + DEFAULT_CONFIG_FILE_NAME, + DEFAULT_CONFIG_FILE_REF, + parseRemoteFileAddress, + RemoteFileAddress, +} from "./remote-file"; + +test("parseRemoteFileAddress accepts full remote addresses", async (t) => { + const env = getTestEnv(); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner /repo/path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/ repo/path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo /path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/ path@ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path @ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@ ref"), { + owner: "owner", + repo: "repo", + path: "path", + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual( + parseRemoteFileAddress(env, "owner/repo/path/to/codeql.yml@ref/feature"), + { + owner: "owner", + repo: "repo", + path: "path/to/codeql.yml", + ref: "ref/feature", + } satisfies RemoteFileAddress, + ); + + t.deepEqual( + parseRemoteFileAddress( + env, + " owner/repo/path/to/codeql.yml@ref/feature ", + ), + { + owner: "owner", + repo: "repo", + path: "path/to/codeql.yml", + ref: "ref/feature", + } satisfies RemoteFileAddress, + ); +}); + +test("parseRemoteFileAddress accepts remote address without an owner", async (t) => { + const env = getTestEnv(); + const owner = "test-owner"; + const getRequired = sinon.stub(env, "getRequired"); + getRequired + .withArgs(ActionsEnvVars.GITHUB_REPOSITORY) + .returns(`${owner}/current-repo`); + + t.deepEqual(parseRemoteFileAddress(env, "repo@ref"), { + owner, + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "repo"), { + owner, + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); +}); + +test("parseRemoteFileAddress throws for invalid `GITHUB_REPOSITORY`", async (t) => { + const env = getTestEnv(); + const getRequired = sinon.stub(env, "getRequired"); + getRequired.withArgs(ActionsEnvVars.GITHUB_REPOSITORY).returns(`not-valid`); + + t.throws(() => parseRemoteFileAddress(env, "repo@ref"), { + instanceOf: Error, + }); +}); + +test("parseRemoteFileAddress accepts remote address without a path", async (t) => { + const env = getTestEnv(); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo@ref"), { + owner: "owner", + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: "ref", + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo"), { + owner: "owner", + repo: "repo", + path: DEFAULT_CONFIG_FILE_NAME, + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); +}); + +test("parseRemoteFileAddress accepts remote address without a ref", async (t) => { + const env = getTestEnv(); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path"), { + owner: "owner", + repo: "repo", + path: "path", + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); + + t.deepEqual(parseRemoteFileAddress(env, "owner/repo/path@"), { + owner: "owner", + repo: "repo", + path: "path", + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); +}); + +test("parseRemoteFileAddress rejects invalid values", async (t) => { + const env = getTestEnv(); + const owner = "owner"; + const getRequired = sinon.stub(env, "getRequired"); + getRequired + .withArgs(ActionsEnvVars.GITHUB_REPOSITORY) + .returns(`${owner}/current-repo`); + + t.throws(() => parseRemoteFileAddress(env, " "), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "repo//absolute"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, "/repo@ref"), { + instanceOf: ConfigurationError, + }); + t.throws(() => parseRemoteFileAddress(env, " /repo@ref"), { + instanceOf: ConfigurationError, + }); +}); diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts new file mode 100644 index 0000000000..a4f8ea890a --- /dev/null +++ b/src/config/remote-file.ts @@ -0,0 +1,84 @@ +import { ActionsEnvVars } from "../actions-util"; +import { Env } from "../environment"; +import * as errorMessages from "../error-messages"; +import { ConfigurationError } from "../util"; + +/** Represents remote file addresses. */ +export interface RemoteFileAddress { + /** The owner of the repository. */ + owner: string; + /** The repository name. */ + repo: string; + /** The path of the file. */ + path: string; + /** The ref of the repository. */ + ref: string; +} + +/** The default file path to use in configuration file shorthands. */ +export const DEFAULT_CONFIG_FILE_NAME = ".github/codeql-action.yaml"; + +/** The default ref to use in configuration file shorthands. */ +export const DEFAULT_CONFIG_FILE_REF = "main"; + +/** Extracts the owner from the `GITHUB_REPOSITORY` environment variable. */ +function getDefaultOwner(env: Env): string { + const currentRepoNwo = env.getRequired(ActionsEnvVars.GITHUB_REPOSITORY); + const nwoParts = currentRepoNwo.split("/"); + + if (nwoParts.length !== 2 || nwoParts[0].trim().length === 0) { + // This shouldn't happen, so we should throw if `GITHUB_REPOSITORY` doesn't match + // our expectations. + throw new Error( + `Expected ${ActionsEnvVars.GITHUB_REPOSITORY} to contain a name with owner, but got '${currentRepoNwo}'.`, + ); + } + + return nwoParts[0].trim(); +} + +/** + * Attempts to parse `configFile` into an array of `RemoteFileAddress` components. + * + * @param env The current environment variables. + * @param configFile The string to try and parse. + * @returns The successful result of executing the regex. + * @throws `ConfigurationError` if the format of `configFile` is not valid. + */ +export function parseRemoteFileAddress( + env: Env, + configFile: string, +): RemoteFileAddress { + // retrieve the various parts of the config location, and ensure they're present + const format = new RegExp( + "^((?[^/]+)/)?(?[^/@]+)(/(?[^@]+))?(@(?.*))?$", + ); + const pieces = format.exec(configFile.trim()); + + const repo: string | undefined = pieces?.groups?.repo?.trim(); + + // Check that the regular expression matched and that we have at least the repo name. + if (!pieces?.groups || !repo || repo.length === 0) { + throw new ConfigurationError( + errorMessages.getConfigFileRepoFormatInvalidMessage(configFile), + ); + } + + const owner: string | undefined = pieces.groups.owner?.trim(); + const path: string | undefined = pieces.groups.path?.trim(); + const ref: string | undefined = pieces.groups.ref?.trim(); + + // Ensure that the path is a relative path. + if (path?.startsWith("/")) { + throw new ConfigurationError( + `The path component of '${configFile}' cannot be an absolute path.`, + ); + } + + return { + owner: owner || getDefaultOwner(env), + repo, + path: path || DEFAULT_CONFIG_FILE_NAME, + ref: ref || DEFAULT_CONFIG_FILE_REF, + }; +} diff --git a/src/environment.ts b/src/environment.ts index c3f54ebd27..c0ca050b03 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -160,3 +160,11 @@ export enum EnvVar { /** Used by Code Scanning Risk Assessment to communicate the assessment ID to the CodeQL Action. */ RISK_ASSESSMENT_ID = "CODEQL_ACTION_RISK_ASSESSMENT_ID", } + +/** A wrapper around an environment, to allow abstracting away from `process.env` in tests. */ +export interface Env { + /** Tries to get the value for `name` and throws if there isn't one. */ + getRequired(name: string): string; + /** Gets the value for `name`, or `undefined` if it isn't set or empty. */ + getOptional(name: string): string | undefined; +} diff --git a/src/error-messages.ts b/src/error-messages.ts index 578ec69733..b45d9562b1 100644 --- a/src/error-messages.ts +++ b/src/error-messages.ts @@ -34,7 +34,7 @@ export function getConfigFileRepoFormatInvalidMessage( configFile: string, ): string { let error = `The configuration file "${configFile}" is not a supported remote file reference.`; - error += " Expected format //@"; + error += " Expected format [/][/][@]"; return error; } diff --git a/src/testing-utils.ts b/src/testing-utils.ts index 2660c21a69..22ed1e21a5 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -10,7 +10,7 @@ import test, { import nock from "nock"; import * as sinon from "sinon"; -import { ActionsEnv, getActionVersion } from "./actions-util"; +import { ActionsEnv, ActionsEnvVars, getActionVersion } from "./actions-util"; import { AnalysisKind } from "./analyses"; import * as apiClient from "./api-client"; import { GitHubApiDetails } from "./api-client"; @@ -18,6 +18,7 @@ import { CachingKind } from "./caching-utils"; import * as codeql from "./codeql"; import { Config } from "./config-utils"; import * as defaults from "./defaults.json"; +import { Env } from "./environment"; import { CodeQLDefaultVersionInfo, Feature, @@ -29,6 +30,7 @@ import { OverlayDatabaseMode } from "./overlay/overlay-database-mode"; import { DEFAULT_DEBUG_ARTIFACT_NAME, DEFAULT_DEBUG_DATABASE_NAME, + getEnv, GitHubVariant, GitHubVersion, HTTPError, @@ -172,6 +174,11 @@ export function makeMacro( return wrapper; } +export function getTestEnv(): Env { + const testEnv: NodeJS.ProcessEnv = {}; + return getEnv(testEnv); +} + /** * Gets an `ActionsEnv` instance for use in tests. */ @@ -200,7 +207,7 @@ export const DEFAULT_ACTIONS_VARS = { GITHUB_WORKFLOW: "test-workflow", RUNNER_NAME: "my-runner", RUNNER_OS: "Linux", -} as const satisfies Record; +} as const satisfies Partial>; /** Partial mappings from GitHub Actions environment variables to values. */ export type ActionVarOverrides = Partial< diff --git a/src/util.ts b/src/util.ts index 200d68d2c2..ed8daaa08d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -13,7 +13,7 @@ import * as apiCompatibility from "./api-compatibility.json"; import type { CodeQL, VersionInfo } from "./codeql"; import type { Pack } from "./config/db-config"; import type { Config } from "./config-utils"; -import { EnvVar } from "./environment"; +import { Env, EnvVar } from "./environment"; import * as json from "./json"; import { Language } from "./languages"; import { Logger } from "./logging"; @@ -566,11 +566,22 @@ export function initializeEnvironment(version: string) { core.exportVariable(EnvVar.VERSION, version); } +/** Gets an `Env` instance for `env`, which is `process.env` by default. */ +export function getEnv(env: NodeJS.ProcessEnv = process.env): Env { + return { + getRequired: (name) => getRequiredEnvVar(env, name), + getOptional: (name) => getOptionalEnvVarFrom(env, name), + }; +} + /** - * Get an environment parameter, but throw an error if it is not set. + * Gets an environment variable, but throws an error if it is not set. */ -export function getRequiredEnvParam(paramName: string): string { - const value = process.env[paramName]; +export function getRequiredEnvVar( + env: NodeJS.ProcessEnv, + paramName: string, +): string { + const value = env[paramName]; if (value === undefined || value.length === 0) { throw new Error(`${paramName} environment variable must be set`); } @@ -578,16 +589,33 @@ export function getRequiredEnvParam(paramName: string): string { } /** - * Get an environment variable, but return `undefined` if it is not set or empty. + * Get an environment parameter, but throw an error if it is not set. */ -export function getOptionalEnvVar(paramName: string): string | undefined { - const value = process.env[paramName]; +export function getRequiredEnvParam(paramName: string): string { + return getRequiredEnvVar(process.env, paramName); +} + +/** + * Gets an environment variable, but returns `undefined` if it is not set or empty. + */ +export function getOptionalEnvVarFrom( + env: NodeJS.ProcessEnv, + paramName: string, +): string | undefined { + const value = env[paramName]; if (value?.trim().length === 0) { return undefined; } return value; } +/** + * Get an environment variable, but return `undefined` if it is not set or empty. + */ +export function getOptionalEnvVar(paramName: string): string | undefined { + return getOptionalEnvVarFrom(process.env, paramName); +} + export class HTTPError extends Error { public status: number;