diff --git a/src/Runner.Worker/ActionManifestManager.cs b/src/Runner.Worker/ActionManifestManager.cs index 014c053aa..a70592381 100644 --- a/src/Runner.Worker/ActionManifestManager.cs +++ b/src/Runner.Worker/ActionManifestManager.cs @@ -316,6 +316,7 @@ namespace GitHub.Runner.Worker Schema = _actionManifestSchema, // TODO: Switch to real tracewriter for cutover TraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(), + AllowCaseFunction = false, }; // Expression values from execution context diff --git a/src/Runner.Worker/ActionManifestManagerLegacy.cs b/src/Runner.Worker/ActionManifestManagerLegacy.cs index 89d9ae8b5..c332efd2e 100644 --- a/src/Runner.Worker/ActionManifestManagerLegacy.cs +++ b/src/Runner.Worker/ActionManifestManagerLegacy.cs @@ -315,6 +315,7 @@ namespace GitHub.Runner.Worker maxBytes: 10 * 1024 * 1024), Schema = _actionManifestSchema, TraceWriter = executionContext.ToTemplateTraceWriter(), + AllowCaseFunction = false, }; // Expression values from execution context diff --git a/src/Sdk/DTExpressions2/Expressions2/ExpressionConstants.cs b/src/Sdk/DTExpressions2/Expressions2/ExpressionConstants.cs index 99e19debf..128200336 100644 --- a/src/Sdk/DTExpressions2/Expressions2/ExpressionConstants.cs +++ b/src/Sdk/DTExpressions2/Expressions2/ExpressionConstants.cs @@ -9,6 +9,7 @@ namespace GitHub.DistributedTask.Expressions2 { static ExpressionConstants() { + AddFunction("case", 3, Byte.MaxValue); AddFunction("contains", 2, 2); AddFunction("endsWith", 2, 2); AddFunction("format", 1, Byte.MaxValue); diff --git a/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs b/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs index 70725ba29..43b9b4eaf 100644 --- a/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs +++ b/src/Sdk/DTExpressions2/Expressions2/ExpressionParser.cs @@ -17,9 +17,10 @@ namespace GitHub.DistributedTask.Expressions2 String expression, ITraceWriter trace, IEnumerable namedValues, - IEnumerable functions) + IEnumerable functions, + Boolean allowCaseFunction = true) { - var context = new ParseContext(expression, trace, namedValues, functions); + var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction); context.Trace.Info($"Parsing expression: <{expression}>"); return CreateTree(context); } @@ -349,6 +350,10 @@ namespace GitHub.DistributedTask.Expressions2 { throw new ParseException(ParseExceptionKind.TooManyParameters, token: @operator, expression: context.Expression); } + else if (functionInfo.Name.Equals("case", StringComparison.OrdinalIgnoreCase) && function.Parameters.Count % 2 == 0) + { + throw new ParseException(ParseExceptionKind.EvenParameters, token: @operator, expression: context.Expression); + } } /// @@ -411,6 +416,12 @@ namespace GitHub.DistributedTask.Expressions2 String name, out IFunctionInfo functionInfo) { + if (String.Equals(name, "case", StringComparison.OrdinalIgnoreCase) && !context.AllowCaseFunction) + { + functionInfo = null; + return false; + } + return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) || context.ExtensionFunctions.TryGetValue(name, out functionInfo); } @@ -418,6 +429,7 @@ namespace GitHub.DistributedTask.Expressions2 private sealed class ParseContext { public Boolean AllowUnknownKeywords; + public Boolean AllowCaseFunction; public readonly String Expression; public readonly Dictionary ExtensionFunctions = new Dictionary(StringComparer.OrdinalIgnoreCase); public readonly Dictionary ExtensionNamedValues = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -433,7 +445,8 @@ namespace GitHub.DistributedTask.Expressions2 ITraceWriter trace, IEnumerable namedValues, IEnumerable functions, - Boolean allowUnknownKeywords = false) + Boolean allowUnknownKeywords = false, + Boolean allowCaseFunction = true) { Expression = expression ?? String.Empty; if (Expression.Length > ExpressionConstants.MaxLength) @@ -454,6 +467,7 @@ namespace GitHub.DistributedTask.Expressions2 LexicalAnalyzer = new LexicalAnalyzer(Expression); AllowUnknownKeywords = allowUnknownKeywords; + AllowCaseFunction = allowCaseFunction; } private class NoOperationTraceWriter : ITraceWriter diff --git a/src/Sdk/DTExpressions2/Expressions2/ParseException.cs b/src/Sdk/DTExpressions2/Expressions2/ParseException.cs index 19529a4be..aa9b5522b 100644 --- a/src/Sdk/DTExpressions2/Expressions2/ParseException.cs +++ b/src/Sdk/DTExpressions2/Expressions2/ParseException.cs @@ -29,6 +29,9 @@ namespace GitHub.DistributedTask.Expressions2 case ParseExceptionKind.TooManyParameters: description = "Too many parameters supplied"; break; + case ParseExceptionKind.EvenParameters: + description = "Even number of parameters supplied, requires an odd number of parameters"; + break; case ParseExceptionKind.UnexpectedEndOfExpression: description = "Unexpected end of expression"; break; diff --git a/src/Sdk/DTExpressions2/Expressions2/ParseExceptionKind.cs b/src/Sdk/DTExpressions2/Expressions2/ParseExceptionKind.cs index 4e4fc6a3d..183d87870 100644 --- a/src/Sdk/DTExpressions2/Expressions2/ParseExceptionKind.cs +++ b/src/Sdk/DTExpressions2/Expressions2/ParseExceptionKind.cs @@ -6,6 +6,7 @@ ExceededMaxLength, TooFewParameters, TooManyParameters, + EvenParameters, UnexpectedEndOfExpression, UnexpectedSymbol, UnrecognizedFunction, diff --git a/src/Sdk/DTExpressions2/Expressions2/Sdk/Functions/Case.cs b/src/Sdk/DTExpressions2/Expressions2/Sdk/Functions/Case.cs new file mode 100644 index 000000000..818cb6176 --- /dev/null +++ b/src/Sdk/DTExpressions2/Expressions2/Sdk/Functions/Case.cs @@ -0,0 +1,45 @@ +#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references + +using System; +using GitHub.Actions.Expressions.Data; + +namespace GitHub.DistributedTask.Expressions2.Sdk.Functions +{ + internal sealed class Case : Function + { + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + // Validate argument count - must be odd (pairs of predicate-result plus default) + if (Parameters.Count % 2 == 0) + { + throw new InvalidOperationException("case requires an odd number of arguments"); + } + + // Evaluate predicate-result pairs + for (var i = 0; i < Parameters.Count - 1; i += 2) + { + var predicate = Parameters[i].Evaluate(context); + + // Predicate must be a boolean + if (predicate.Kind != ValueKind.Boolean) + { + throw new InvalidOperationException("case predicate must evaluate to a boolean value"); + } + + // If predicate is true, return the corresponding result + if ((Boolean)predicate.Value) + { + var result = Parameters[i + 1].Evaluate(context); + return result.Value; + } + } + + // No predicate matched, return default (last argument) + var defaultResult = Parameters[Parameters.Count - 1].Evaluate(context); + return defaultResult.Value; + } + } +} diff --git a/src/Sdk/DTObjectTemplating/ObjectTemplating/TemplateContext.cs b/src/Sdk/DTObjectTemplating/ObjectTemplating/TemplateContext.cs index af8cf76ec..d93eda398 100644 --- a/src/Sdk/DTObjectTemplating/ObjectTemplating/TemplateContext.cs +++ b/src/Sdk/DTObjectTemplating/ObjectTemplating/TemplateContext.cs @@ -86,6 +86,12 @@ namespace GitHub.DistributedTask.ObjectTemplating internal ITraceWriter TraceWriter { get; set; } + /// + /// Gets or sets a value indicating whether the case expression function is allowed. + /// Defaults to true. Set to false to disable the case function. + /// + internal Boolean AllowCaseFunction { get; set; } = true; + private IDictionary FileIds { get diff --git a/src/Sdk/DTObjectTemplating/ObjectTemplating/Tokens/TemplateToken.cs b/src/Sdk/DTObjectTemplating/ObjectTemplating/Tokens/TemplateToken.cs index 21a8aab78..870163b76 100644 --- a/src/Sdk/DTObjectTemplating/ObjectTemplating/Tokens/TemplateToken.cs +++ b/src/Sdk/DTObjectTemplating/ObjectTemplating/Tokens/TemplateToken.cs @@ -57,7 +57,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -94,7 +94,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -123,7 +123,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -152,7 +152,7 @@ namespace GitHub.DistributedTask.ObjectTemplating.Tokens var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs index 40f6a1334..6c9654074 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs @@ -663,7 +663,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating var node = default(ExpressionNode); try { - node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode; + node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode; } catch (Exception ex) { diff --git a/src/Sdk/Expressions/ExpressionParser.cs b/src/Sdk/Expressions/ExpressionParser.cs index 9d0501476..fd7dd1b45 100644 --- a/src/Sdk/Expressions/ExpressionParser.cs +++ b/src/Sdk/Expressions/ExpressionParser.cs @@ -18,7 +18,7 @@ namespace GitHub.Actions.Expressions ITraceWriter trace, IEnumerable namedValues, IEnumerable functions, - Boolean allowCaseFunction = false) + Boolean allowCaseFunction = true) { var context = new ParseContext(expression, trace, namedValues, functions, allowCaseFunction: allowCaseFunction); context.Trace.Info($"Parsing expression: <{expression}>"); @@ -446,7 +446,7 @@ namespace GitHub.Actions.Expressions IEnumerable namedValues, IEnumerable functions, Boolean allowUnknownKeywords = false, - Boolean allowCaseFunction = false) + Boolean allowCaseFunction = true) { Expression = expression ?? String.Empty; if (Expression.Length > ExpressionConstants.MaxLength) @@ -482,4 +482,4 @@ namespace GitHub.Actions.Expressions } } } -} \ No newline at end of file +} diff --git a/src/Sdk/Expressions/Sdk/Functions/Case.cs b/src/Sdk/Expressions/Sdk/Functions/Case.cs new file mode 100644 index 000000000..bfdc209ea --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/Case.cs @@ -0,0 +1,45 @@ +#nullable disable // Consider removing in the future to minimize likelihood of NullReferenceException; refer https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references + +using System; +using GitHub.Actions.Expressions.Data; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + internal sealed class Case : Function + { + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + // Validate argument count - must be odd (pairs of predicate-result plus default) + if (Parameters.Count % 2 == 0) + { + throw new InvalidOperationException("case requires an odd number of arguments"); + } + + // Evaluate predicate-result pairs + for (var i = 0; i < Parameters.Count - 1; i += 2) + { + var predicate = Parameters[i].Evaluate(context); + + // Predicate must be a boolean + if (predicate.Kind != ValueKind.Boolean) + { + throw new InvalidOperationException("case predicate must evaluate to a boolean value"); + } + + // If predicate is true, return the corresponding result + if ((Boolean)predicate.Value) + { + var result = Parameters[i + 1].Evaluate(context); + return result.Value; + } + } + + // No predicate matched, return default (last argument) + var defaultResult = Parameters[Parameters.Count - 1].Evaluate(context); + return defaultResult.Value; + } + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs index 564adc03c..0c7a74564 100644 --- a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -1775,7 +1775,7 @@ namespace GitHub.Actions.WorkflowParser.Conversion var node = default(ExpressionNode); try { - node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode; + node = expressionParser.CreateTree(condition, null, namedValues, functions, allowCaseFunction: context.AllowCaseFunction) as ExpressionNode; } catch (Exception ex) { diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs index 8118b6b26..6e35e850b 100644 --- a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs @@ -113,6 +113,12 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating /// internal Boolean StrictJsonParsing { get; set; } + /// + /// Gets or sets a value indicating whether the case expression function is allowed. + /// Defaults to true. Set to false to disable the case function. + /// + internal Boolean AllowCaseFunction { get; set; } = true; + internal ITraceWriter TraceWriter { get; set; } private IDictionary FileIds diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs index 193bcba2f..0c1295ccb 100644 --- a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs @@ -55,7 +55,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -93,7 +93,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -123,7 +123,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes, @@ -153,7 +153,7 @@ namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens var originalBytes = context.Memory.CurrentBytes; try { - var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions, allowCaseFunction: context.AllowCaseFunction); var options = new EvaluationOptions { MaxMemory = context.Memory.MaxBytes,