Merge pull request #91 from github/cschleiden/improve-expression-validation
Validate expressions even when calling `fromJson`
This commit is contained in:
@@ -50,3 +50,5 @@ function errorDescription(typ: ErrorType): string {
|
|||||||
return "Unknown error";
|
return "Unknown error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ExpressionEvaluationError extends Error {}
|
||||||
|
|||||||
@@ -1,27 +1,42 @@
|
|||||||
import * as data from "./data";
|
import * as data from "./data";
|
||||||
|
import {ExpressionEvaluationError} from "./errors";
|
||||||
import {Evaluator} from "./evaluator";
|
import {Evaluator} from "./evaluator";
|
||||||
import {Lexer} from "./lexer";
|
import {Lexer} from "./lexer";
|
||||||
import {Parser} from "./parser";
|
import {Parser} from "./parser";
|
||||||
|
|
||||||
test("evaluator", () => {
|
describe("evaluator", () => {
|
||||||
const input = "foo['']";
|
const lexAndParse = (input: string) => {
|
||||||
|
const lexer = new Lexer(input);
|
||||||
|
const result = lexer.lex();
|
||||||
|
|
||||||
const lexer = new Lexer(input);
|
// Parse
|
||||||
const result = lexer.lex();
|
const parser = new Parser(result.tokens, ["foo"], []);
|
||||||
|
const expr = parser.parse();
|
||||||
|
return expr;
|
||||||
|
};
|
||||||
|
|
||||||
// Parse
|
it("basic evaluation", () => {
|
||||||
const parser = new Parser(result.tokens, ["foo"], []);
|
const expr = lexAndParse("foo['']");
|
||||||
const expr = parser.parse();
|
|
||||||
|
|
||||||
// Evaluate expression
|
// Evaluate expression
|
||||||
const evaluator = new Evaluator(
|
const evaluator = new Evaluator(
|
||||||
expr,
|
expr,
|
||||||
new data.Dictionary({
|
new data.Dictionary({
|
||||||
key: "foo",
|
key: "foo",
|
||||||
value: new data.Dictionary({key: "bar", value: new data.NumberData(42)})
|
value: new data.Dictionary({key: "bar", value: new data.NumberData(42)})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const eresult = evaluator.evaluate();
|
const eresult = evaluator.evaluate();
|
||||||
|
|
||||||
expect(eresult.kind).toBe(data.Kind.Null);
|
expect(eresult.kind).toBe(data.Kind.Null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handle runtime errors", () => {
|
||||||
|
const expr = lexAndParse("fromJson('test') == 123");
|
||||||
|
const evaluator = new Evaluator(expr, new data.Dictionary());
|
||||||
|
|
||||||
|
expect(() => evaluator.evaluate()).toThrowError(
|
||||||
|
new ExpressionEvaluationError("Error parsing JSON when evaluating fromJson")
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,14 +14,19 @@ import {
|
|||||||
import * as data from "./data";
|
import * as data from "./data";
|
||||||
import {FilteredArray} from "./filtered_array";
|
import {FilteredArray} from "./filtered_array";
|
||||||
import {wellKnownFunctions} from "./funcs";
|
import {wellKnownFunctions} from "./funcs";
|
||||||
|
import {FunctionDefinition} from "./funcs/info";
|
||||||
import {idxHelper} from "./idxHelper";
|
import {idxHelper} from "./idxHelper";
|
||||||
import {TokenType} from "./lexer";
|
import {TokenType} from "./lexer";
|
||||||
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
|
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
|
||||||
|
|
||||||
export class EvaluationError extends Error {}
|
|
||||||
|
|
||||||
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
||||||
constructor(private n: Expr, private context: data.Dictionary) {}
|
/**
|
||||||
|
* Creates a new evaluator
|
||||||
|
* @param n Parsed expression to evaluate
|
||||||
|
* @param context Context data to use
|
||||||
|
* @param functions Optional map of function implementations. If given, these will be preferred over the built-in functions.
|
||||||
|
*/
|
||||||
|
constructor(private n: Expr, private context: data.Dictionary, private functions?: Map<string, FunctionDefinition>) {}
|
||||||
|
|
||||||
public evaluate(): data.ExpressionData {
|
public evaluate(): data.ExpressionData {
|
||||||
return this.eval(this.n);
|
return this.eval(this.n);
|
||||||
@@ -109,7 +114,7 @@ export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
|||||||
try {
|
try {
|
||||||
idxResult = this.eval(ia.index);
|
idxResult = this.eval(ia.index);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`could not evaluate index for index access: ${e}`);
|
throw new Error(`could not evaluate index for index access: ${e}`, {cause: e});
|
||||||
}
|
}
|
||||||
idx = new idxHelper(false, idxResult);
|
idx = new idxHelper(false, idxResult);
|
||||||
}
|
}
|
||||||
@@ -150,16 +155,14 @@ export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
|||||||
// Evaluate arguments
|
// Evaluate arguments
|
||||||
const args = functionCall.args.map(arg => this.eval(arg));
|
const args = functionCall.args.map(arg => this.eval(arg));
|
||||||
|
|
||||||
return fcall(functionCall, args);
|
// Get function definitions
|
||||||
|
const functionName = functionCall.functionName.lexeme.toLowerCase();
|
||||||
|
const f = this.functions?.get(functionName) || wellKnownFunctions[functionName];
|
||||||
|
|
||||||
|
return f.call(...args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fcall(fc: FunctionCall, args: data.ExpressionData[]): data.ExpressionData {
|
|
||||||
const f = wellKnownFunctions[fc.functionName.lexeme.toLowerCase()];
|
|
||||||
|
|
||||||
return f.call(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filteredArrayAccess(fa: FilteredArray, idx: idxHelper): data.ExpressionData {
|
function filteredArrayAccess(fa: FilteredArray, idx: idxHelper): data.ExpressionData {
|
||||||
const result = new FilteredArray();
|
const result = new FilteredArray();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {ExpressionData} from "../data";
|
import {ExpressionData} from "../data";
|
||||||
import {reviver} from "../data/reviver";
|
import {reviver} from "../data/reviver";
|
||||||
|
import {ExpressionEvaluationError} from "../errors";
|
||||||
import {FunctionDefinition} from "./info";
|
import {FunctionDefinition} from "./info";
|
||||||
|
|
||||||
export const fromjson: FunctionDefinition = {
|
export const fromjson: FunctionDefinition = {
|
||||||
@@ -16,7 +17,10 @@ export const fromjson: FunctionDefinition = {
|
|||||||
throw new Error("empty input");
|
throw new Error("empty input");
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = JSON.parse(is, reviver);
|
try {
|
||||||
return d;
|
return JSON.parse(is, reviver);
|
||||||
|
} catch (e) {
|
||||||
|
throw new ExpressionEvaluationError("Error parsing JSON when evaluating fromJson", {cause: e});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ export {Expr} from "./ast";
|
|||||||
export {complete, CompletionItem} from "./completion";
|
export {complete, CompletionItem} from "./completion";
|
||||||
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
|
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
|
||||||
export * as data from "./data";
|
export * as data from "./data";
|
||||||
export {ExpressionError} from "./errors";
|
export {ExpressionError, ExpressionEvaluationError} from "./errors";
|
||||||
export {Evaluator} from "./evaluator";
|
export {Evaluator} from "./evaluator";
|
||||||
|
export {wellKnownFunctions} from "./funcs";
|
||||||
export {Lexer, Result} from "./lexer";
|
export {Lexer, Result} from "./lexer";
|
||||||
export {Parser} from "./parser";
|
export {Parser} from "./parser";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2022"],
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: step a
|
- name: step a
|
||||||
env:
|
env:
|
||||||
step_env: job_a_env
|
step_env: job_a_env
|
||||||
run: echo "hello \${{ env.step_env }}
|
run: echo "hello \${{ env.step_env }}
|
||||||
`;
|
`;
|
||||||
@@ -376,7 +376,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
a:
|
a:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
envjoba: job_a_env
|
envjoba: job_a_env
|
||||||
steps:
|
steps:
|
||||||
- name: step a
|
- name: step a
|
||||||
@@ -395,7 +395,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
a:
|
a:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
envjoba: job_a_env
|
envjoba: job_a_env
|
||||||
steps:
|
steps:
|
||||||
- name: step a
|
- name: step a
|
||||||
@@ -1185,6 +1185,9 @@ jobs:
|
|||||||
- run: echo "hello \${{ github.event.inputs.name }}"
|
- run: echo "hello \${{ github.event.inputs.name }}"
|
||||||
- run: echo "hello \${{ github.event.inputs.third-name }}"
|
- run: echo "hello \${{ github.event.inputs.third-name }}"
|
||||||
- run: echo "hello \${{ github.event.inputs.random }}"
|
- run: echo "hello \${{ github.event.inputs.random }}"
|
||||||
|
- run: echo \${{ fromJSON(inputs.random2) }}
|
||||||
|
- run: echo "hello \${{ inputs.random }}"
|
||||||
|
name: "\${{ fromJSON('test') == inputs.name }}"
|
||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
@@ -1202,6 +1205,34 @@ jobs:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
severity: DiagnosticSeverity.Warning
|
severity: DiagnosticSeverity.Warning
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Context access might be invalid: random2",
|
||||||
|
range: {
|
||||||
|
end: {
|
||||||
|
character: 47,
|
||||||
|
line: 20
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
character: 16,
|
||||||
|
line: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
severity: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Context access might be invalid: random",
|
||||||
|
range: {
|
||||||
|
end: {
|
||||||
|
character: 43,
|
||||||
|
line: 21
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
character: 23,
|
||||||
|
line: 21
|
||||||
|
}
|
||||||
|
},
|
||||||
|
severity: 2
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import {Evaluator, Lexer, Parser} from "@github/actions-expressions";
|
import {
|
||||||
|
data,
|
||||||
|
Evaluator,
|
||||||
|
ExpressionEvaluationError,
|
||||||
|
Lexer,
|
||||||
|
Parser,
|
||||||
|
wellKnownFunctions
|
||||||
|
} from "@github/actions-expressions";
|
||||||
import {Expr} from "@github/actions-expressions/ast";
|
import {Expr} from "@github/actions-expressions/ast";
|
||||||
import {
|
import {
|
||||||
convertWorkflowTemplate,
|
convertWorkflowTemplate,
|
||||||
@@ -202,7 +209,7 @@ async function validateExpression(
|
|||||||
try {
|
try {
|
||||||
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
|
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
|
||||||
|
|
||||||
const e = new Evaluator(expr, wrapDictionary(context));
|
const e = new Evaluator(expr, wrapDictionary(context), validatorFunctions);
|
||||||
e.evaluate();
|
e.evaluate();
|
||||||
|
|
||||||
// Any invalid context access would've thrown an error via the `ErrorDictionary`, for now we don't have to check the actual
|
// Any invalid context access would've thrown an error via the `ErrorDictionary`, for now we don't have to check the actual
|
||||||
@@ -214,7 +221,25 @@ async function validateExpression(
|
|||||||
severity: DiagnosticSeverity.Warning,
|
severity: DiagnosticSeverity.Warning,
|
||||||
range: mapRange(expression.range)
|
range: mapRange(expression.range)
|
||||||
});
|
});
|
||||||
|
} else if (e instanceof ExpressionEvaluationError) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Expression might be invalid: ${e.message}`,
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(expression.range)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom implementations for standard actions-expression functions used during validation. For example,
|
||||||
|
// for fromJson we'll most likely not have a valid input. In order to not throw, we'll always return an
|
||||||
|
// empty dictionary.
|
||||||
|
const validatorFunctions = new Map(
|
||||||
|
Object.entries({
|
||||||
|
fromjson: {
|
||||||
|
...wellKnownFunctions.fromjson,
|
||||||
|
call: () => new data.Dictionary()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user