Skip to content

Commit 97a8019

Browse files
committed
fix: distinct ID input was not escaped
1 parent 08325d2 commit 97a8019

File tree

7 files changed

+129
-19
lines changed

7 files changed

+129
-19
lines changed

src/main.spec.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ describe("main", () => {
5757
let utilsLogInfoForBranchNameResult: MockInstance<
5858
typeof utils.logInfoForBranchNameResult
5959
>;
60+
let utilsCreateDistinctIdRegexMock: MockInstance<
61+
typeof utils.createDistinctIdRegex
62+
>;
6063

6164
// Return Dispatch
6265
let returnDispatchGetRunIdAndUrlMock: MockInstance<
@@ -93,6 +96,7 @@ describe("main", () => {
9396
utils,
9497
"logInfoForBranchNameResult",
9598
);
99+
utilsCreateDistinctIdRegexMock = vi.spyOn(utils, "createDistinctIdRegex");
96100

97101
returnDispatchGetRunIdAndUrlMock = vi.spyOn(
98102
returnDispatch,
@@ -114,6 +118,7 @@ describe("main", () => {
114118
});
115119

116120
it("should successfully complete", async () => {
121+
const distinctIdRegex = new RegExp(testCfg.distinctId);
117122
const returnDispatchSuccessResult = {
118123
success: true,
119124
value: {
@@ -123,6 +128,7 @@ describe("main", () => {
123128
} as const;
124129

125130
utilsGetBranchNameMock.mockReturnValue(testBranch);
131+
utilsCreateDistinctIdRegexMock.mockReturnValue(distinctIdRegex);
126132
returnDispatchGetWorkflowIdMock.mockResolvedValue(0);
127133
returnDispatchGetRunIdAndUrlMock.mockResolvedValue(
128134
returnDispatchSuccessResult,
@@ -154,13 +160,17 @@ describe("main", () => {
154160
testBranch,
155161
testCfg.ref,
156162
);
163+
expect(utilsCreateDistinctIdRegexMock).toHaveBeenCalledOnce();
164+
expect(utilsCreateDistinctIdRegexMock).toHaveBeenCalledWith(
165+
testCfg.distinctId,
166+
);
157167

158168
// Get run ID
159169
expect(returnDispatchGetRunIdAndUrlMock).toHaveBeenCalledOnce();
160170
expect(returnDispatchGetRunIdAndUrlMock).toHaveBeenCalledWith({
161171
startTime: Date.now(),
162172
branch: testBranch,
163-
distinctId: testCfg.distinctId,
173+
distinctIdRegex: distinctIdRegex,
164174
workflow: testCfg.workflow,
165175
workflowId: 0,
166176
workflowTimeoutMs: testCfg.workflowTimeoutSeconds * 1000,

src/main.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
handleActionSuccess,
99
getRunIdAndUrl,
1010
} from "./return-dispatch.ts";
11-
import { getBranchName, logInfoForBranchNameResult } from "./utils.ts";
11+
import {
12+
createDistinctIdRegex,
13+
getBranchName,
14+
logInfoForBranchNameResult,
15+
} from "./utils.ts";
1216

1317
export async function main(): Promise<void> {
1418
try {
@@ -27,10 +31,12 @@ export async function main(): Promise<void> {
2731
const branch = getBranchName(config.ref);
2832
logInfoForBranchNameResult(branch, config.ref);
2933

34+
const distinctIdRegex = createDistinctIdRegex(config.distinctId);
35+
3036
const result = await getRunIdAndUrl({
3137
startTime,
3238
branch,
33-
distinctId: config.distinctId,
39+
distinctIdRegex,
3440
workflow: config.workflow,
3541
workflowId,
3642
workflowTimeoutMs: config.workflowTimeoutSeconds * 1000,

src/return-dispatch.spec.ts

+2-9
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,6 @@ import * as utils from "./utils.ts";
2929

3030
vi.mock("@actions/core");
3131
vi.mock("./api.ts");
32-
// vi.mock(import('./utils.ts'), async (importOriginal) => {
33-
// const mod = await importOriginal() // type is inferred
34-
// return {
35-
// ...mod,
36-
// // replace some exports
37-
// total: vi.fn(mod.sleep),
38-
// }
39-
// })
4032

4133
describe("return-dispatch", () => {
4234
const {
@@ -473,6 +465,7 @@ describe("return-dispatch", () => {
473465

474466
describe("getRunIdAndUrl", () => {
475467
const distinctId = crypto.randomUUID();
468+
const distinctIdRegex = new RegExp(distinctId);
476469
const workflow = "workflow.yml";
477470
const workflowId = 123;
478471
const branch: utils.BranchNameResult = Object.freeze({
@@ -483,7 +476,7 @@ describe("return-dispatch", () => {
483476
const defaultOpts: GetRunIdAndUrlOpts = {
484477
startTime: Date.now(),
485478
branch: branch,
486-
distinctId: distinctId,
479+
distinctIdRegex: distinctIdRegex,
487480
workflow: workflow,
488481
workflowId: workflowId,
489482
workflowTimeoutMs: 100,

src/return-dispatch.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ActionOutputs } from "./action.ts";
44
import * as api from "./api.ts";
55
import * as constants from "./constants.ts";
66
import type { Result } from "./types.ts";
7-
import { sleep, type BranchNameResult } from "./utils.ts";
7+
import { escapeRegExp, sleep, type BranchNameResult } from "./utils.ts";
88

99
export function shouldRetryOrThrow(
1010
error: Error,
@@ -126,20 +126,19 @@ export function handleActionFail(): void {
126126
export interface GetRunIdAndUrlOpts {
127127
startTime: number;
128128
branch: BranchNameResult;
129-
distinctId: string;
129+
distinctIdRegex: RegExp;
130130
workflow: string | number;
131131
workflowId: number;
132132
workflowTimeoutMs: number;
133133
}
134134
export async function getRunIdAndUrl({
135135
startTime,
136136
branch,
137-
distinctId,
137+
distinctIdRegex,
138138
workflow,
139139
workflowId,
140140
workflowTimeoutMs,
141141
}: GetRunIdAndUrlOpts): Promise<Result<{ id: number; url: string }>> {
142-
const distinctIdRegex = new RegExp(distinctId);
143142
const retryTimeout = Math.max(
144143
constants.WORKFLOW_FETCH_TIMEOUT_MS,
145144
workflowTimeoutMs,

src/test-utils/logging.mock.ts

+7
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { type MockInstance, vi, expect } from "vitest";
88
interface MockedLoggingFunctions {
99
coreDebugLogMock: MockInstance<(message: string) => void>;
1010
coreInfoLogMock: MockInstance<(message: string) => void>;
11+
coreWarningLogMock: MockInstance<(message: string) => void>;
1112
coreErrorLogMock: MockInstance<(message: string) => void>;
1213
assertOnlyCalled: (
1314
...coreLogMocks: MockInstance<(message: string) => void>[]
@@ -22,13 +23,18 @@ export function mockLoggingFunctions(): MockedLoggingFunctions {
2223
const coreInfoLogMock: MockInstance<typeof core.info> = vi
2324
.spyOn(core, "info")
2425
.mockImplementation(() => undefined);
26+
const coreWarningLogMock: MockInstance<typeof core.error> = vi.spyOn(
27+
core,
28+
"warning",
29+
);
2530
const coreErrorLogMock: MockInstance<typeof core.error> = vi
2631
.spyOn(core, "error")
2732
.mockImplementation(() => undefined);
2833

2934
const coreLogMockSet = new Set<MockInstance<(message: string) => void>>([
3035
coreDebugLogMock,
3136
coreInfoLogMock,
37+
coreWarningLogMock,
3238
coreErrorLogMock,
3339
]);
3440
const assertOnlyCalled = (
@@ -44,6 +50,7 @@ export function mockLoggingFunctions(): MockedLoggingFunctions {
4450
return {
4551
coreDebugLogMock,
4652
coreInfoLogMock,
53+
coreWarningLogMock,
4754
coreErrorLogMock,
4855
assertOnlyCalled,
4956
assertNoneCalled,

src/utils.spec.ts

+62-3
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,24 @@ import {
99
} from "vitest";
1010

1111
import { mockLoggingFunctions } from "./test-utils/logging.mock.ts";
12-
import { getBranchName, logInfoForBranchNameResult, sleep } from "./utils.ts";
12+
import {
13+
createDistinctIdRegex,
14+
escapeRegExp,
15+
getBranchName,
16+
logInfoForBranchNameResult,
17+
sleep,
18+
} from "./utils.ts";
1319

1420
vi.mock("@actions/core");
1521

1622
describe("utils", () => {
17-
const { coreDebugLogMock, coreInfoLogMock, assertOnlyCalled } =
18-
mockLoggingFunctions();
23+
const {
24+
coreDebugLogMock,
25+
coreInfoLogMock,
26+
coreWarningLogMock,
27+
assertOnlyCalled,
28+
assertNoneCalled,
29+
} = mockLoggingFunctions();
1930

2031
afterEach(() => {
2132
vi.resetAllMocks();
@@ -187,6 +198,54 @@ describe("utils", () => {
187198
await vi.advanceTimersByTimeAsync(1000);
188199

189200
await expect(sleepPromise).resolves.toBeUndefined();
201+
202+
assertNoneCalled();
203+
});
204+
});
205+
206+
describe("escapeRegExp", () => {
207+
const escaped = "\\^\\$\\.\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\";
208+
const unescaped = "^$.*+?()[]{}|\\";
209+
210+
it("should escape values", () => {
211+
expect(escapeRegExp(unescaped + unescaped)).toBe(escaped + escaped);
212+
assertNoneCalled();
213+
});
214+
215+
it("should handle strings with nothing to escape", () => {
216+
expect(escapeRegExp("abc")).toBe("abc");
217+
assertNoneCalled();
218+
});
219+
220+
it("should return an empty string for empty values", () => {
221+
expect(escapeRegExp("")).toEqual("");
222+
assertNoneCalled();
223+
});
224+
});
225+
226+
describe("createDistinctIdRegex", () => {
227+
it("should return a regex without warning if the input is safe", () => {
228+
expect(createDistinctIdRegex("test-cfg")).toStrictEqual(
229+
new RegExp("test-cfg"),
230+
);
231+
assertNoneCalled();
232+
});
233+
234+
it("should return a regex with warning if the input is required escaping", () => {
235+
const input = "test$.*+?()[]{}|\\cfg";
236+
const escapedInput = escapeRegExp(input);
237+
238+
const distinctId = createDistinctIdRegex(input);
239+
240+
// Behaviour
241+
expect(distinctId).toStrictEqual(new RegExp(escapedInput));
242+
243+
// Logging
244+
assertOnlyCalled(coreWarningLogMock);
245+
expect(coreWarningLogMock).toHaveBeenCalledOnce();
246+
expect(coreWarningLogMock.mock.calls[0]?.[0]).toMatchInlineSnapshot(
247+
`"Unescaped characters found in distinctId input, using: test\\$\\.\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\cfg"`,
248+
);
190249
});
191250
});
192251
});

src/utils.ts

+36
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,39 @@ export function logInfoForBranchNameResult(
6464
export function sleep(ms: number): Promise<void> {
6565
return new Promise<void>((resolve) => setTimeout(resolve, ms));
6666
}
67+
68+
/**
69+
* Used to match `RegExp`
70+
* [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns).
71+
*
72+
* https://github.com/lodash/lodash/blob/main/src/escapeRegExp.ts
73+
*/
74+
const reRegExpChar = /[\\^$.*+?()[\]{}|]/g;
75+
const reHasRegExpChar = RegExp(reRegExpChar.source);
76+
77+
/**
78+
* Escapes the `RegExp` special characters "^", "$", "\", ".", "*", "+",
79+
* "?", "(", ")", "[", "]", "{", "}", and "|" in `string`.
80+
*
81+
* https://github.com/lodash/lodash/blob/main/src/escapeRegExp.ts
82+
*/
83+
export function escapeRegExp(str: string): string {
84+
return reHasRegExpChar.test(str)
85+
? str.replace(reRegExpChar, "\\$&")
86+
: str || "";
87+
}
88+
89+
/**
90+
* If the input distinct ID contains unescaped characters, log the
91+
* escaped distinct ID as a warning.
92+
*/
93+
export function createDistinctIdRegex(distinctId: string): RegExp {
94+
const escapedDistinctId = escapeRegExp(distinctId);
95+
if (distinctId !== escapedDistinctId) {
96+
core.warning(
97+
`Unescaped characters found in distinctId input, using: ${escapedDistinctId}`,
98+
);
99+
}
100+
101+
return new RegExp(escapedDistinctId);
102+
}

0 commit comments

Comments
 (0)