Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 62 additions & 12 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Copy link
Collaborator Author

@ANGkeith ANGkeith Sep 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this chunk in order to support the following scenario:

job:
  image: alpine
  variables:
    teststring: "/test/url"
    pattern: /test//
  script:
    - echo works
  rules:
    - if: '$teststring =~ $pattern' # gcl works
    - if: $teststring =~ /test//      # gcl does not work but it's expected to work

return `${value}`;
return `${expandWith.variable(name)}`;
}
}
);
Expand Down Expand Up @@ -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 <regex>
// https://regexr.com/85sjo
const pattern1 = /\s*(?<operator>(?:=~)|(?:!~))\s*(?<rhs>\/.*?\/)(?<flags>[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*(?<operator>(?:=~)|(?:!~))\s*"(?<rhs>[^"\\]*(?:\\.[^"\\]*)*)"/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 = /\/(?<pattern>.*)\/(?<flags>[igmsuy]*)/;
const _rhs = rhs.replace(regex, (_: string, pattern: string, flags: string) => {
const _pattern = pattern.replace(/(?<!\\)\//g, "\\/"); // escape potentially unescaped `/` that's in the pattern
return `/${_pattern}/${flags}`;
});
return `.match(new RegExp(${_rhs})) ${_operator} null`;
});

// Convert all null.match functions to false
evalStr = evalStr.replace(/null.match\(.+?\) != null/g, "false");
evalStr = evalStr.replace(/null.match\(.+?\) == null/g, "false");

return Boolean(eval(evalStr));
let res;
try {
res = eval(evalStr);
} catch (err) {
console.log(`
Error attempting to evaluate the following rules:
rules:
- if: '${expandedEvalStr}'
as
\`\`\`javascript
${evalStr}
\`\`\`
`);
throw err;
}
return Boolean(res);
}

static evaluateRuleExist (cwd: string, ruleExists: string[] | undefined): boolean {
Expand Down
108 changes: 108 additions & 0 deletions tests/rules-regex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@

import {Utils} from "../src/utils";
import {GitData} from "../src/git-data";
import {WriteStreamsMock} from "../src/write-streams";

let writeStreams;
let gitData: GitData;
//
beforeEach(async () => {
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");
});
});
});
8 changes: 4 additions & 4 deletions tests/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-/"};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rhs here should be a regex
image

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});
});
Expand Down