Files
runner/src/Sdk/Expressions/IExpressionNodeExtensions.cs
2025-11-07 20:18:52 +00:00

321 lines
13 KiB
C#

#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 System.Collections.Generic;
using System.Linq;
using GitHub.Actions.Expressions.Sdk;
using Index = GitHub.Actions.Expressions.Sdk.Operators.Index;
namespace GitHub.Actions.Expressions
{
public static class IExpressionNodeExtensions
{
/// <summary>
/// Returns the node and all descendant nodes
/// </summary>
public static IEnumerable<IExpressionNode> Traverse(this IExpressionNode node)
{
yield return node;
if (node is Container container && container.Parameters.Count > 0)
{
foreach (var parameter in container.Parameters)
{
foreach (var descendant in parameter.Traverse())
{
yield return descendant;
}
}
}
}
/// <summary>
/// Checks whether specific contexts or sub-properties of contexts are referenced.
/// If a conclusive determination cannot be made, then the pattern is considered matched.
/// For example, the expression "toJson(github)" matches the pattern "github.event" because
/// the value is passed to a function. Not enough information is known to determine whether
/// the function requires the sub-property. Therefore, assume it is required.
///
/// Patterns may contain wildcards to match any literal. For example, the pattern
/// "needs.*.outputs" will produce a match for the expression "needs.my-job.outputs.my-output".
/// </summary>
public static Boolean[] CheckReferencesContext(
this IExpressionNode tree,
params String[] patterns)
{
// The result is an array of booleans, one per pattern
var result = new Boolean[patterns.Length];
// Stores the match segments for each pattern. For example
// the patterns [ "github.event", "needs.*.outputs" ] would
// be stored as:
// [
// [
// NamedValue:github
// Literal:"event"
// ],
// [
// NamedValue:needs
// Wildcard:*
// Literal:"outputs"
// ]
// ]
var segmentedPatterns = default(Stack<IExpressionNode>[]);
// Walk the expression tree
var stack = new Stack<IExpressionNode>();
stack.Push(tree);
while (stack.Count > 0)
{
var node = stack.Pop();
// Attempt to match a named-value or index operator.
// Note, when entering this block, descendant nodes are only pushed
// to the stack for further processing under special conditions.
if (node is NamedValue || node is Index)
{
// Lazy initialize the pattern segments
if (segmentedPatterns is null)
{
segmentedPatterns = new Stack<IExpressionNode>[patterns.Length];
var parser = new ExpressionParser();
for (var i = 0; i < patterns.Length; i++)
{
var pattern = patterns[i];
var patternTree = parser.ValidateSyntax(pattern, null);
var patternSegments = GetMatchSegments(patternTree, out _);
if (patternSegments.Count == 0)
{
throw new InvalidOperationException($"Invalid context-match-pattern '{pattern}'");
}
segmentedPatterns[i] = patternSegments;
}
}
// Match
Match(node, segmentedPatterns, result, out var needsFurtherAnalysis);
// Push nested nodes that need further analysis
if (needsFurtherAnalysis?.Count > 0)
{
foreach (var nestedNode in needsFurtherAnalysis)
{
stack.Push(nestedNode);
}
}
}
// Push children of any other container node.
else if (node is Container container && container.Parameters.Count > 0)
{
foreach (var child in container.Parameters)
{
stack.Push(child);
}
}
}
return result;
}
// Attempts to match a node within a user-provided-expression against a set of patterns.
//
// For example consider the user-provided-expression "github.event.base_ref || github.event.before"
// The Match method would be called twice, once for the sub-expression "github.event.base_ref" and
// once for the sub-expression "github.event.before".
private static void Match(
IExpressionNode node,
Stack<IExpressionNode>[] patterns,
Boolean[] result,
out List<ExpressionNode> needsFurtherAnalysis)
{
var nodeSegments = GetMatchSegments(node, out needsFurtherAnalysis);
if (nodeSegments.Count == 0)
{
return;
}
var nodeNamedValue = nodeSegments.Peek() as NamedValue;
var originalNodeSegments = nodeSegments;
for (var i = 0; i < patterns.Length; i++)
{
var patternSegments = patterns[i];
var patternNamedValue = patternSegments.Peek() as NamedValue;
// Compare the named-value
if (String.Equals(nodeNamedValue.Name, patternNamedValue.Name, StringComparison.OrdinalIgnoreCase))
{
// Clone the stacks before mutating
nodeSegments = new Stack<IExpressionNode>(originalNodeSegments.Reverse()); // Push reverse to preserve order
nodeSegments.Pop();
patternSegments = new Stack<IExpressionNode>(patternSegments.Reverse()); // Push reverse to preserve order
patternSegments.Pop();
// Walk the stacks
while (true)
{
// Every pattern segment was matched
if (patternSegments.Count == 0)
{
result[i] = true;
break;
}
// Every node segment was matched. Treat the pattern as matched. There is not
// enough information to determine whether the property is required; assume it is.
// For example, consider the pattern "github.event" and the expression "toJson(github)".
// In this example the function requires the full structure of the named-value.
else if (nodeSegments.Count == 0)
{
result[i] = true;
break;
}
var nodeSegment = nodeSegments.Pop();
var patternSegment = patternSegments.Pop();
// The behavior of a wildcard varies depending on whether the left operand
// is an array or an object. For simplicity, treat the pattern as matched.
if (nodeSegment is Wildcard)
{
result[i] = true;
break;
}
// Treat a wildcard pattern segment as matching any literal segment
else if (patternSegment is Wildcard)
{
continue;
}
// Convert literals to string and compare
var nodeLiteral = nodeSegment as Literal;
var nodeEvaluationResult = EvaluationResult.CreateIntermediateResult(null, nodeLiteral.Value);
var nodeString = nodeEvaluationResult.ConvertToString();
var patternLiteral = patternSegment as Literal;
var patternEvaluationResult = EvaluationResult.CreateIntermediateResult(null, patternLiteral.Value);
var patternString = patternEvaluationResult.ConvertToString();
if (String.Equals(nodeString, patternString, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Convert to number and compare
var nodeNumber = nodeEvaluationResult.ConvertToNumber();
if (!Double.IsNaN(nodeNumber) && nodeNumber >= 0d && nodeNumber <= (Double)Int32.MaxValue)
{
var patternNumber = patternEvaluationResult.ConvertToNumber();
if (!Double.IsNaN(patternNumber) && patternNumber >= 0 && patternNumber <= (Double)Int32.MaxValue)
{
nodeNumber = Math.Floor(nodeNumber);
patternNumber = Math.Floor(patternNumber);
if (nodeNumber == patternNumber)
{
continue;
}
}
}
// Not matched
break;
}
}
}
}
// This function is used to convert a pattern or a user-provided-expression into a
// consistent structure for easy comparison. The result is a stack containing only
// nodes of type NamedValue, Literal, or Wildcard. All Index nodes are discarded.
//
// For example, consider the pattern "needs.*.outputs". The expression tree looks like:
// Index(
// Index(
// NamedValue:needs,
// Wildcard:*
// ),
// Literal:"outputs"
// )
// The result would be:
// [
// NamedValue:needs
// Wildcard:*
// Literal:"outputs"
// ]
//
// Any nested expression trees that require further analysis, are returned separately.
// For example, consider the expression "needs.build.outputs[github.event.base_ref]"
// The result would be:
// [
// NamedValue:needs
// Literal:"build"
// Literal:"outputs"
// ]
// And the nested expression tree "github.event.base_ref" would be tracked as needing
// further analysis.
private static Stack<IExpressionNode> GetMatchSegments(
IExpressionNode node,
out List<ExpressionNode> needsFurtherAnalysis)
{
var result = new Stack<IExpressionNode>();
needsFurtherAnalysis = new List<ExpressionNode>();
// Node is a named-value
if (node is NamedValue)
{
result.Push(node);
}
// Node is an index
else if (node is Index index)
{
while (true)
{
//
// Parameter 1
//
var parameter1 = index.Parameters[1];
// Treat anything other than literal as a wildcard
result.Push(parameter1 is Literal ? parameter1 : new Wildcard());
// Further analysis required by the caller if parameter 1 is a Function/Operator/NamedValue
if (parameter1 is Container || parameter1 is NamedValue)
{
needsFurtherAnalysis.Add(parameter1);
}
//
// Parameter 0
//
var parameter0 = index.Parameters[0];
// Parameter 0 is a named-value
if (parameter0 is NamedValue)
{
result.Push(parameter0);
break;
}
// Parameter 0 is an index
else if (parameter0 is Index index2)
{
index = index2;
}
// Otherwise clear
else
{
result.Clear();
// Further analysis required by the caller if parameter 0 is a Function/Operator
if (parameter0 is Container)
{
needsFurtherAnalysis.Add(parameter0);
}
break;
}
}
}
return result;
}
}
}