diff --git a/src/utils.ts b/src/utils.ts index d6145f43b..eff62d774 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -101,11 +101,7 @@ export class Utils { } else { const name = var1 || var2; assert(name, "unexpected unset capture group"); - let value = `${expandWith.variable(name)}`; - if (value.startsWith("\"/") && value.endsWith("/\"")) { - value = value.substring(1).slice(0, -1); - } - return `${value}`; + return `${expandWith.variable(name)}`; } } ); @@ -205,20 +201,74 @@ export class Utils { unescape: JSON.stringify("$"), variable: (name) => JSON.stringify(envs[name] ?? null).replaceAll("\\\\", "\\"), }); + const expandedEvalStr = evalStr; + + // Scenario when RHS is a + // https://regexr.com/85sjo + const pattern1 = /\s*(?(?:=~)|(?:!~))\s*(?\/.*?\/)(?[igmsuy]*)(\s|$|\))/g; + evalStr = evalStr.replace(pattern1, (_, operator, rhs, flags, remainingTokens) => { + let _operator; + switch (operator) { + case "=~": + _operator = "!="; + break; + case "!~": + _operator = "=="; + break; + default: + throw operator; + } + return `.match(${rhs}${flags})${remainingTokens} ${_operator} null`; + }); - // Convert =~ to match function - evalStr = evalStr.replace(/\s*=~\s*(\/.*?\/[igmsuy]*)(?:\s|$)/g, ".match($1) != null"); - evalStr = evalStr.replace(/\s*=~\s(.+?)(\)*?)(?:\s|$)/g, ".match(new RegExp($1)) != null$2"); // Without forward slashes + // Scenario when RHS is surrounded by double-quotes + // https://regexr.com/85t0g + const pattern2 = /\s*(?(?:=~)|(?:!~))\s*"(?[^"\\]*(?:\\.[^"\\]*)*)"/g; + evalStr = evalStr.replace(pattern2, (_, operator, rhs) => { + let _operator; + switch (operator) { + case "=~": + _operator = "!="; + break; + case "!~": + _operator = "=="; + break; + default: + throw operator; + } - // Convert !~ to match function - evalStr = evalStr.replace(/\s*!~\s*(\/.*?\/[igmsuy]*)(?:\s|$)/g, ".match($1) == null"); - evalStr = evalStr.replace(/\s*!~\s(.+?)(\)*?)(?:\s|$)/g, ".match(new RegExp($1)) == null$2"); // Without forward slashes + if (!/\/(.*)\/([\w]*)/.test(rhs)) { + throw Error(`RHS (${rhs}) must be a regex pattern. Do not rely on this behavior! +Refer to https://docs.gitlab.com/ee/ci/jobs/job_rules.html#unexpected-behavior-from-regular-expression-matching-with- for more info...`); + } + const regex = /\/(?.*)\/(?[igmsuy]*)/; + const _rhs = rhs.replace(regex, (_: string, pattern: string, flags: string) => { + const _pattern = pattern.replace(/(? { + writeStreams = new WriteStreamsMock(); + gitData = await GitData.init("tests", writeStreams); + jest.clearAllMocks(); +}); + +/* eslint-disable @typescript-eslint/quotes */ +const tests = [ + { + rule: '"foo" !~ /foo/', + jsExpression: '"foo".match(/foo/) == null', + evalResult: false, + }, + { + rule: '"foo" =~ /foo/', + jsExpression: '"foo".match(/foo/) != null', + evalResult: true, + }, + { + rule: '"foo"=~ /foo/', + jsExpression: '"foo".match(/foo/) != null', + evalResult: true, + }, + { + rule: '"foo"=~/foo/', + jsExpression: '"foo".match(/foo/) != null', + evalResult: true, + }, + { + rule: '"foo"=~ /foo/', + jsExpression: '"foo".match(/foo/) != null', + evalResult: true, + }, + { + rule: '"foo" =~ "/foo/"', + jsExpression: '"foo".match(new RegExp(/foo/)) != null', + evalResult: true, + }, + { + rule: '"test/url" =~ "/test/ur/"', + jsExpression: '"test/url".match(new RegExp(/test\\/ur/)) != null', + evalResult: true, + }, + { + rule: '"test/url" =~ "/test\\/ur/"', + jsExpression: '"test/url".match(new RegExp(/test\\/ur/)) != null', + evalResult: true, + }, + { + rule: '"test/url" =~ /test/ur/', + expectErr: true, + }, + { + rule: '"master" =~ /master$/', + jsExpression: '"master".match(/master$/) != null', + evalResult: true, + }, + { + rule: '"23" =~ "1234"', + expectErr: true, + }, + { + rule: '"23" =~ /1234/', + jsExpression: '"23".match(/1234/) != null', + evalResult: false, + }, +]; +/* eslint-enable @typescript-eslint/quotes */ + +describe("gitlab rules regex", () => { + tests.filter(t => !t.expectErr) + .forEach((t) => { + test(`- if: '${t.rule}'\n\t => ${t.evalResult}`, async () => { + const rules = [ {if: t.rule} ]; + const evalSpy = jest.spyOn(global, "eval"); + const evaluateRuleIfSpy = jest.spyOn(Utils, "evaluateRuleIf"); + + Utils.getRulesResult({cwd: "", rules, variables: {}}, gitData); + expect(evalSpy).toHaveBeenCalledWith(t.jsExpression); + expect(evaluateRuleIfSpy).toHaveReturnedWith(t.evalResult); + }); + }); +}); + +describe("gitlab rules regex [invalid]", () => { + tests.filter(t => t.expectErr) + .forEach((t) => { + test(`- if: '${t.rule}'\n\t => error`, async () => { + const rules = [ {if: t.rule} ]; + + try { + Utils.getRulesResult({cwd: "", rules, variables: {}}, gitData); + } catch (e) { + return; + } + + throw new Error("Error is expected but not thrown/caught"); + }); + }); +}); diff --git a/tests/rules.test.ts b/tests/rules.test.ts index 1f2bb903b..c1936ac5c 100644 --- a/tests/rules.test.ts +++ b/tests/rules.test.ts @@ -264,16 +264,16 @@ test("https://github.com/firecow/gitlab-ci-local/issues/350", () => { test("https://github.com/firecow/gitlab-ci-local/issues/300", () => { let rules, rulesResult, variables; rules = [ - {if: "$VAR1 && (($VAR3 =~ /ci-skip-job-/ && $VAR2 =~ $VAR3) || ($VAR3 =~ /ci-skip-stage-/ && $VAR2 =~ $VAR3))", when: "manual"}, + {if: "$VAR1 && (($VAR2 =~ /ci-skip-job-/ && $VAR2 =~ $VAR3) || ($VAR2 =~ /ci-skip-stage-/ && $VAR2 =~ $VAR3))", when: "manual"}, ]; - variables = {VAR1: "val", VAR2: "ci-skip-job-", VAR3: "ci-skip-job-"}; + variables = {VAR1: "val", VAR2: "ci-skip-job-", VAR3: "/ci-skip-job-/"}; rulesResult = Utils.getRulesResult({cwd: "", rules, variables}, gitData); expect(rulesResult).toEqual({when: "manual", allowFailure: false, variables: undefined}); rules = [ - {if: "$VAR1 && (($VAR3 =~ /ci-skip-job-/ && $VAR2 =~ $VAR3) || ($VAR3 =~ /ci-skip-stage-/ && $VAR2 =~ $VAR3))", when: "manual"}, + {if: "$VAR1 && (($VAR2 =~ /ci-skip-job-/ && $VAR2 =~ $VAR3) || ($VAR2 =~ /ci-skip-stage-/ && $VAR2 =~ $VAR3))", when: "manual"}, ]; - variables = {VAR1: "val", VAR2: "ci-skip-stage-", VAR3: "ci-skip-stage-"}; + variables = {VAR1: "val", VAR2: "ci-skip-stage-", VAR3: "/ci-skip-stage-/"}; rulesResult = Utils.getRulesResult({cwd: "", rules, variables}, gitData); expect(rulesResult).toEqual({when: "manual", allowFailure: false, variables: undefined}); });