diff --git a/src/Runner.Common/Constants.cs b/src/Runner.Common/Constants.cs index 45ce81e0e..fafa773cb 100644 --- a/src/Runner.Common/Constants.cs +++ b/src/Runner.Common/Constants.cs @@ -172,6 +172,7 @@ namespace GitHub.Runner.Common public static readonly string ContainerActionRunnerTemp = "actions_container_action_runner_temp"; public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check"; public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check"; + public static readonly string CompareTemplateEvaluator = "actions_runner_compare_template_evaluator"; } // Node version migration related constants diff --git a/src/Runner.Worker/ExecutionContext.cs b/src/Runner.Worker/ExecutionContext.cs index 3410d1831..daba1a072 100644 --- a/src/Runner.Worker/ExecutionContext.cs +++ b/src/Runner.Worker/ExecutionContext.cs @@ -1306,10 +1306,14 @@ namespace GitHub.Runner.Worker UpdateGlobalStepsContext(); } + internal IPipelineTemplateEvaluator ToPipelineTemplateEvaluatorInternal(ObjectTemplating.ITraceWriter traceWriter = null) + { + return new PipelineTemplateEvaluatorWrapper(HostContext, this, traceWriter); + } + private static void NoOp() { } - } // The Error/Warning/etc methods are created as extension methods to simplify unit testing. @@ -1390,8 +1394,15 @@ namespace GitHub.Runner.Worker return new[] { new KeyValuePair(nameof(IExecutionContext), context) }; } - public static PipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null) + public static IPipelineTemplateEvaluator ToPipelineTemplateEvaluator(this IExecutionContext context, ObjectTemplating.ITraceWriter traceWriter = null) { + // Create wrapper? + if ((context.Global.Variables.GetBoolean(Constants.Runner.Features.CompareTemplateEvaluator) ?? false) || StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("ACTIONS_RUNNER_COMPARE_TEMPLATE_EVALUATOR"))) + { + return (context as ExecutionContext).ToPipelineTemplateEvaluatorInternal(traceWriter); + } + + // Legacy if (traceWriter == null) { traceWriter = context.ToTemplateTraceWriter(); diff --git a/src/Runner.Worker/Expressions/AlwaysFunction.cs b/src/Runner.Worker/Expressions/AlwaysFunction.cs index 1101e1917..7a39ae01e 100644 --- a/src/Runner.Worker/Expressions/AlwaysFunction.cs +++ b/src/Runner.Worker/Expressions/AlwaysFunction.cs @@ -22,4 +22,13 @@ namespace GitHub.Runner.Worker.Expressions return true; } } + + public sealed class NewAlwaysFunction : GitHub.Actions.Expressions.Sdk.Function + { + protected override Object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext context, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory) + { + resultMemory = null; + return true; + } + } } diff --git a/src/Runner.Worker/Expressions/CancelledFunction.cs b/src/Runner.Worker/Expressions/CancelledFunction.cs index ae676e8d6..b94ca7ce7 100644 --- a/src/Runner.Worker/Expressions/CancelledFunction.cs +++ b/src/Runner.Worker/Expressions/CancelledFunction.cs @@ -28,4 +28,18 @@ namespace GitHub.Runner.Worker.Expressions return jobStatus == ActionResult.Cancelled; } } + + public sealed class NewCancelledFunction : GitHub.Actions.Expressions.Sdk.Function + { + protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory) + { + resultMemory = null; + var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext; + ArgUtil.NotNull(templateContext, nameof(templateContext)); + var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext; + ArgUtil.NotNull(executionContext, nameof(executionContext)); + ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success; + return jobStatus == ActionResult.Cancelled; + } + } } diff --git a/src/Runner.Worker/Expressions/FailureFunction.cs b/src/Runner.Worker/Expressions/FailureFunction.cs index c1c5f1be4..121cfb5a3 100644 --- a/src/Runner.Worker/Expressions/FailureFunction.cs +++ b/src/Runner.Worker/Expressions/FailureFunction.cs @@ -39,4 +39,29 @@ namespace GitHub.Runner.Worker.Expressions } } } + + public sealed class NewFailureFunction : GitHub.Actions.Expressions.Sdk.Function + { + protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory) + { + resultMemory = null; + var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext; + ArgUtil.NotNull(templateContext, nameof(templateContext)); + var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext; + ArgUtil.NotNull(executionContext, nameof(executionContext)); + + // Decide based on 'action_status' for composite MAIN steps and 'job.status' for pre, post and job-level steps + var isCompositeMainStep = executionContext.IsEmbedded && executionContext.Stage == ActionRunStage.Main; + if (isCompositeMainStep) + { + ActionResult actionStatus = EnumUtil.TryParse(executionContext.GetGitHubContext("action_status")) ?? ActionResult.Success; + return actionStatus == ActionResult.Failure; + } + else + { + ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success; + return jobStatus == ActionResult.Failure; + } + } + } } diff --git a/src/Runner.Worker/Expressions/HashFilesFunction.cs b/src/Runner.Worker/Expressions/HashFilesFunction.cs index 5ad0d1209..ddd318c3c 100644 --- a/src/Runner.Worker/Expressions/HashFilesFunction.cs +++ b/src/Runner.Worker/Expressions/HashFilesFunction.cs @@ -143,4 +143,137 @@ namespace GitHub.Runner.Worker.Expressions } } } + + public sealed class NewHashFilesFunction : GitHub.Actions.Expressions.Sdk.Function + { + private const int _hashFileTimeoutSeconds = 120; + + protected sealed override Object EvaluateCore( + GitHub.Actions.Expressions.Sdk.EvaluationContext context, + out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory) + { + resultMemory = null; + var templateContext = context.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext; + ArgUtil.NotNull(templateContext, nameof(templateContext)); + templateContext.ExpressionValues.TryGetValue(PipelineTemplateConstants.GitHub, out var githubContextData); + ArgUtil.NotNull(githubContextData, nameof(githubContextData)); + var githubContext = githubContextData as GitHub.Actions.Expressions.Data.DictionaryExpressionData; + ArgUtil.NotNull(githubContext, nameof(githubContext)); + + if (!githubContext.TryGetValue(PipelineTemplateConstants.HostWorkspace, out var workspace)) + { + githubContext.TryGetValue(PipelineTemplateConstants.Workspace, out workspace); + } + ArgUtil.NotNull(workspace, nameof(workspace)); + + var workspaceData = workspace as GitHub.Actions.Expressions.Data.StringExpressionData; + ArgUtil.NotNull(workspaceData, nameof(workspaceData)); + + string githubWorkspace = workspaceData.Value; + + bool followSymlink = false; + List patterns = new(); + var firstParameter = true; + foreach (var parameter in Parameters) + { + var parameterString = parameter.Evaluate(context).ConvertToString(); + if (firstParameter) + { + firstParameter = false; + if (parameterString.StartsWith("--")) + { + if (string.Equals(parameterString, "--follow-symbolic-links", StringComparison.OrdinalIgnoreCase)) + { + followSymlink = true; + continue; + } + else + { + throw new ArgumentOutOfRangeException($"Invalid glob option {parameterString}, avaliable option: '--follow-symbolic-links'."); + } + } + } + + patterns.Add(parameterString); + } + + context.Trace.Info($"Search root directory: '{githubWorkspace}'"); + context.Trace.Info($"Search pattern: '{string.Join(", ", patterns)}'"); + + string binDir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); + string runnerRoot = new DirectoryInfo(binDir).Parent.FullName; + + string node = Path.Combine(runnerRoot, "externals", NodeUtil.GetInternalNodeVersion(), "bin", $"node{IOUtil.ExeExtension}"); + string hashFilesScript = Path.Combine(binDir, "hashFiles"); + var hashResult = string.Empty; + var p = new ProcessInvoker(new NewHashFilesTrace(context.Trace)); + p.ErrorDataReceived += ((_, data) => + { + if (!string.IsNullOrEmpty(data.Data) && data.Data.StartsWith("__OUTPUT__") && data.Data.EndsWith("__OUTPUT__")) + { + hashResult = data.Data.Substring(10, data.Data.Length - 20); + context.Trace.Info($"Hash result: '{hashResult}'"); + } + else + { + context.Trace.Info(data.Data); + } + }); + + p.OutputDataReceived += ((_, data) => + { + context.Trace.Info(data.Data); + }); + + var env = new Dictionary(); + if (followSymlink) + { + env["followSymbolicLinks"] = "true"; + } + env["patterns"] = string.Join(Environment.NewLine, patterns); + + using (var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(_hashFileTimeoutSeconds))) + { + try + { + int exitCode = p.ExecuteAsync(workingDirectory: githubWorkspace, + fileName: node, + arguments: $"\"{hashFilesScript.Replace("\"", "\\\"")}\"", + environment: env, + requireExitCodeZero: false, + cancellationToken: tokenSource.Token).GetAwaiter().GetResult(); + + if (exitCode != 0) + { + throw new InvalidOperationException($"hashFiles('{ExpressionUtility.StringEscape(string.Join(", ", patterns))}') failed. Fail to hash files under directory '{githubWorkspace}'"); + } + } + catch (OperationCanceledException) when (tokenSource.IsCancellationRequested) + { + throw new TimeoutException($"hashFiles('{ExpressionUtility.StringEscape(string.Join(", ", patterns))}') couldn't finish within {_hashFileTimeoutSeconds} seconds."); + } + + return hashResult; + } + } + + private sealed class NewHashFilesTrace : ITraceWriter + { + private GitHub.Actions.Expressions.ITraceWriter _trace; + + public NewHashFilesTrace(GitHub.Actions.Expressions.ITraceWriter trace) + { + _trace = trace; + } + public void Info(string message) + { + _trace.Info(message); + } + + public void Verbose(string message) + { + _trace.Info(message); + } + } + } } diff --git a/src/Runner.Worker/Expressions/SuccessFunction.cs b/src/Runner.Worker/Expressions/SuccessFunction.cs index 6fcc41b79..316d82a81 100644 --- a/src/Runner.Worker/Expressions/SuccessFunction.cs +++ b/src/Runner.Worker/Expressions/SuccessFunction.cs @@ -39,4 +39,29 @@ namespace GitHub.Runner.Worker.Expressions } } } + + public sealed class NewSuccessFunction : GitHub.Actions.Expressions.Sdk.Function + { + protected sealed override object EvaluateCore(GitHub.Actions.Expressions.Sdk.EvaluationContext evaluationContext, out GitHub.Actions.Expressions.Sdk.ResultMemory resultMemory) + { + resultMemory = null; + var templateContext = evaluationContext.State as GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateContext; + ArgUtil.NotNull(templateContext, nameof(templateContext)); + var executionContext = templateContext.State[nameof(IExecutionContext)] as IExecutionContext; + ArgUtil.NotNull(executionContext, nameof(executionContext)); + + // Decide based on 'action_status' for composite MAIN steps and 'job.status' for pre, post and job-level steps + var isCompositeMainStep = executionContext.IsEmbedded && executionContext.Stage == ActionRunStage.Main; + if (isCompositeMainStep) + { + ActionResult actionStatus = EnumUtil.TryParse(executionContext.GetGitHubContext("action_status")) ?? ActionResult.Success; + return actionStatus == ActionResult.Success; + } + else + { + ActionResult jobStatus = executionContext.JobContext.Status ?? ActionResult.Success; + return jobStatus == ActionResult.Success; + } + } + } } diff --git a/src/Runner.Worker/GlobalContext.cs b/src/Runner.Worker/GlobalContext.cs index 5a4c7babd..3caa3567e 100644 --- a/src/Runner.Worker/GlobalContext.cs +++ b/src/Runner.Worker/GlobalContext.cs @@ -29,5 +29,6 @@ namespace GitHub.Runner.Worker public bool WriteDebug { get; set; } public string InfrastructureFailureCategory { get; set; } public JObject ContainerHookState { get; set; } + public bool HasTemplateEvaluatorMismatch { get; set; } } } diff --git a/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs new file mode 100644 index 000000000..53742469b --- /dev/null +++ b/src/Runner.Worker/PipelineTemplateEvaluatorWrapper.cs @@ -0,0 +1,679 @@ +using System; +using System.Collections.Generic; +using GitHub.Actions.WorkflowParser; +using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines; +using GitHub.DistributedTask.Pipelines.ContextData; +using GitHub.DistributedTask.Pipelines.ObjectTemplating; +using GitHub.DistributedTask.WebApi; +using GitHub.Runner.Common; +using GitHub.Runner.Sdk; +using ObjectTemplating = GitHub.DistributedTask.ObjectTemplating; + +namespace GitHub.Runner.Worker +{ + internal sealed class PipelineTemplateEvaluatorWrapper : IPipelineTemplateEvaluator + { + private PipelineTemplateEvaluator _legacyEvaluator; + private WorkflowTemplateEvaluator _newEvaluator; + private IExecutionContext _context; + private Tracing _trace; + + public PipelineTemplateEvaluatorWrapper( + IHostContext hostContext, + IExecutionContext context, + ObjectTemplating.ITraceWriter traceWriter = null) + { + ArgUtil.NotNull(hostContext, nameof(hostContext)); + ArgUtil.NotNull(context, nameof(context)); + _context = context; + _trace = hostContext.GetTrace(nameof(PipelineTemplateEvaluatorWrapper)); + + if (traceWriter == null) + { + traceWriter = context.ToTemplateTraceWriter(); + } + + // Legacy evaluator + var schema = PipelineTemplateSchemaFactory.GetSchema(); + _legacyEvaluator = new PipelineTemplateEvaluator(traceWriter, schema, context.Global.FileTable) + { + MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly + }; + + // New evaluator + var newTraceWriter = new GitHub.Actions.WorkflowParser.ObjectTemplating.EmptyTraceWriter(); + _newEvaluator = new WorkflowTemplateEvaluator(newTraceWriter, context.Global.FileTable, features: null) + { + MaxErrorMessageLength = int.MaxValue, // Don't truncate error messages otherwise we might not scrub secrets correctly + }; + } + + public bool EvaluateStepContinueOnError( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateStepContinueOnError", + () => _legacyEvaluator.EvaluateStepContinueOnError(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateStepContinueOnError(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + (legacyResult, newResult) => legacyResult == newResult); + } + + public string EvaluateStepDisplayName( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateStepDisplayName", + () => _legacyEvaluator.EvaluateStepDisplayName(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateStepName(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + (legacyResult, newResult) => string.Equals(legacyResult, newResult, StringComparison.Ordinal)); + } + + public Dictionary EvaluateStepEnvironment( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions, + StringComparer keyComparer) + { + return EvaluateAndCompare( + "EvaluateStepEnvironment", + () => _legacyEvaluator.EvaluateStepEnvironment(token, contextData, expressionFunctions, keyComparer), + () => _newEvaluator.EvaluateStepEnvironment(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), keyComparer), + CompareStepEnvironment); + } + + public bool EvaluateStepIf( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions, + IEnumerable> expressionState) + { + return EvaluateAndCompare( + "EvaluateStepIf", + () => _legacyEvaluator.EvaluateStepIf(token, contextData, expressionFunctions, expressionState), + () => _newEvaluator.EvaluateStepIf(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions), expressionState), + (legacyResult, newResult) => legacyResult == newResult); + } + + public Dictionary EvaluateStepInputs( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateStepInputs", + () => _legacyEvaluator.EvaluateStepInputs(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateStepInputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + (legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "StepInputs")); + } + + public int EvaluateStepTimeout( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateStepTimeout", + () => _legacyEvaluator.EvaluateStepTimeout(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateStepTimeout(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + (legacyResult, newResult) => legacyResult == newResult); + } + + public GitHub.DistributedTask.Pipelines.JobContainer EvaluateJobContainer( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateJobContainer", + () => _legacyEvaluator.EvaluateJobContainer(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateJobContainer(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + CompareJobContainer); + } + + public Dictionary EvaluateJobOutput( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateJobOutput", + () => _legacyEvaluator.EvaluateJobOutput(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateJobOutputs(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + (legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "JobOutput")); + } + + public TemplateToken EvaluateEnvironmentUrl( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateEnvironmentUrl", + () => _legacyEvaluator.EvaluateEnvironmentUrl(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateJobEnvironmentUrl(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + CompareEnvironmentUrl); + } + + public Dictionary EvaluateJobDefaultsRun( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateJobDefaultsRun", + () => _legacyEvaluator.EvaluateJobDefaultsRun(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateJobDefaultsRun(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + (legacyResult, newResult) => CompareDictionaries(legacyResult, newResult, "JobDefaultsRun")); + } + + public IList> EvaluateJobServiceContainers( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateJobServiceContainers", + () => _legacyEvaluator.EvaluateJobServiceContainers(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateJobServiceContainers(ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + (legacyResult, newResult) => CompareJobServiceContainers(legacyResult, newResult)); + } + + public GitHub.DistributedTask.Pipelines.Snapshot EvaluateJobSnapshotRequest( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions) + { + return EvaluateAndCompare( + "EvaluateJobSnapshotRequest", + () => _legacyEvaluator.EvaluateJobSnapshotRequest(token, contextData, expressionFunctions), + () => _newEvaluator.EvaluateSnapshot(string.Empty, ConvertToken(token), ConvertData(contextData), ConvertFunctions(expressionFunctions)), + CompareSnapshot); + } + + private void RecordMismatch(string methodName) + { + if (!_context.Global.HasTemplateEvaluatorMismatch) + { + _context.Global.HasTemplateEvaluatorMismatch = true; + var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"TemplateEvaluatorMismatch: {methodName}" }; + _context.Global.JobTelemetry.Add(telemetry); + } + } + + private void RecordComparisonError(string errorDetails) + { + if (!_context.Global.HasTemplateEvaluatorMismatch) + { + _context.Global.HasTemplateEvaluatorMismatch = true; + var telemetry = new JobTelemetry { Type = JobTelemetryType.General, Message = $"TemplateEvaluatorComparisonError: {errorDetails}" }; + _context.Global.JobTelemetry.Add(telemetry); + } + } + + private TLegacy EvaluateAndCompare( + string methodName, + Func legacyEvaluator, + Func newEvaluator, + Func resultComparer) + { + // Legacy evaluator + var legacyException = default(Exception); + var legacyResult = default(TLegacy); + try + { + legacyResult = legacyEvaluator(); + } + catch (Exception ex) + { + legacyException = ex; + } + + // Compare with new evaluator + try + { + ArgUtil.NotNull(_context, nameof(_context)); + ArgUtil.NotNull(_newEvaluator, nameof(_newEvaluator)); + _trace.Info(methodName); + + // New evaluator + var newException = default(Exception); + var newResult = default(TNew); + try + { + newResult = newEvaluator(); + } + catch (Exception ex) + { + newException = ex; + } + + // Compare results or exceptions + if (legacyException != null || newException != null) + { + // Either one or both threw exceptions - compare them + if (!CompareExceptions(legacyException, newException)) + { + _trace.Info($"{methodName} exception mismatch"); + RecordMismatch($"{methodName}"); + } + } + else + { + // Both succeeded - compare results + if (!resultComparer(legacyResult, newResult)) + { + _trace.Info($"{methodName} mismatch"); + RecordMismatch($"{methodName}"); + } + } + } + catch (Exception ex) + { + _trace.Info($"Comparison failed: {ex.Message}"); + RecordComparisonError($"{methodName}: {ex.Message}"); + } + + // Re-throw legacy exception if any + if (legacyException != null) + { + throw legacyException; + } + + return legacyResult; + } + + private GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken ConvertToken( + GitHub.DistributedTask.ObjectTemplating.Tokens.TemplateToken token) + { + if (token == null) + { + return null; + } + + var json = StringUtil.ConvertToJson(token, Newtonsoft.Json.Formatting.None); + return StringUtil.ConvertFromJson(json); + } + + private GitHub.Actions.Expressions.Data.DictionaryExpressionData ConvertData( + GitHub.DistributedTask.Pipelines.ContextData.DictionaryContextData contextData) + { + if (contextData == null) + { + return null; + } + + var json = StringUtil.ConvertToJson(contextData, Newtonsoft.Json.Formatting.None); + return StringUtil.ConvertFromJson(json); + } + + private IList ConvertFunctions( + IList expressionFunctions) + { + if (expressionFunctions == null) + { + return null; + } + + var result = new List(); + foreach (var func in expressionFunctions) + { + GitHub.Actions.Expressions.IFunctionInfo newFunc = func.Name switch + { + "always" => new GitHub.Actions.Expressions.FunctionInfo(func.Name, func.MinParameters, func.MaxParameters), + "cancelled" => new GitHub.Actions.Expressions.FunctionInfo(func.Name, func.MinParameters, func.MaxParameters), + "failure" => new GitHub.Actions.Expressions.FunctionInfo(func.Name, func.MinParameters, func.MaxParameters), + "success" => new GitHub.Actions.Expressions.FunctionInfo(func.Name, func.MinParameters, func.MaxParameters), + "hashFiles" => new GitHub.Actions.Expressions.FunctionInfo(func.Name, func.MinParameters, func.MaxParameters), + _ => throw new NotSupportedException($"Expression function '{func.Name}' is not supported for conversion") + }; + result.Add(newFunc); + } + return result; + } + + private bool CompareStepEnvironment( + Dictionary legacyResult, + Dictionary newResult) + { + return CompareDictionaries(legacyResult, newResult, "StepEnvironment"); + } + + private bool CompareEnvironmentUrl( + TemplateToken legacyResult, + GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens.TemplateToken newResult) + { + var legacyJson = legacyResult != null ? Newtonsoft.Json.JsonConvert.SerializeObject(legacyResult, Newtonsoft.Json.Formatting.None) : null; + var newJson = newResult != null ? Newtonsoft.Json.JsonConvert.SerializeObject(newResult, Newtonsoft.Json.Formatting.None) : null; + return legacyJson == newJson; + } + + private bool CompareJobContainer( + GitHub.DistributedTask.Pipelines.JobContainer legacyResult, + GitHub.Actions.WorkflowParser.JobContainer newResult) + { + if (legacyResult == null && newResult == null) + { + return true; + } + + if (legacyResult == null || newResult == null) + { + _trace.Info($"CompareJobContainer mismatch - one result is null (legacy={legacyResult == null}, new={newResult == null})"); + return false; + } + + if (!string.Equals(legacyResult.Image, newResult.Image, StringComparison.Ordinal)) + { + _trace.Info($"CompareJobContainer mismatch - Image differs (legacy='{legacyResult.Image}', new='{newResult.Image}')"); + return false; + } + + if (!string.Equals(legacyResult.Options, newResult.Options, StringComparison.Ordinal)) + { + _trace.Info($"CompareJobContainer mismatch - Options differs (legacy='{legacyResult.Options}', new='{newResult.Options}')"); + return false; + } + + if (!CompareDictionaries(legacyResult.Environment, newResult.Environment, "Environment")) + { + return false; + } + + if (!CompareLists(legacyResult.Volumes, newResult.Volumes, "Volumes")) + { + return false; + } + + if (!CompareLists(legacyResult.Ports, newResult.Ports, "Ports")) + { + return false; + } + + if (!CompareCredentials(legacyResult.Credentials, newResult.Credentials)) + { + return false; + } + + return true; + } + + private bool CompareCredentials( + GitHub.DistributedTask.Pipelines.ContainerRegistryCredentials legacyCreds, + GitHub.Actions.WorkflowParser.ContainerRegistryCredentials newCreds) + { + if (legacyCreds == null && newCreds == null) + { + return true; + } + + if (legacyCreds == null || newCreds == null) + { + _trace.Info($"CompareCredentials mismatch - one is null (legacy={legacyCreds == null}, new={newCreds == null})"); + return false; + } + + if (!string.Equals(legacyCreds.Username, newCreds.Username, StringComparison.Ordinal)) + { + _trace.Info($"CompareCredentials mismatch - Credentials.Username differs (legacy='{legacyCreds.Username}', new='{newCreds.Username}')"); + return false; + } + + if (!string.Equals(legacyCreds.Password, newCreds.Password, StringComparison.Ordinal)) + { + _trace.Info($"CompareCredentials mismatch - Credentials.Password differs"); + return false; + } + + return true; + } + + private bool CompareLists(IList legacyList, IList newList, string fieldName) + { + if (legacyList == null && newList == null) + { + return true; + } + + if (legacyList == null || newList == null) + { + _trace.Info($"CompareLists mismatch - {fieldName} - one is null (legacy={legacyList == null}, new={newList == null})"); + return false; + } + + if (legacyList.Count != newList.Count) + { + _trace.Info($"CompareLists mismatch - {fieldName}.Count differs (legacy={legacyList.Count}, new={newList.Count})"); + return false; + } + + for (int i = 0; i < legacyList.Count; i++) + { + if (!string.Equals(legacyList[i], newList[i], StringComparison.Ordinal)) + { + _trace.Info($"CompareLists mismatch - {fieldName}[{i}] differs (legacy='{legacyList[i]}', new='{newList[i]}')"); + return false; + } + } + + return true; + } + + private bool CompareDictionaries(IDictionary legacyDict, IDictionary newDict, string fieldName) + { + if (legacyDict == null && newDict == null) + { + return true; + } + + if (legacyDict == null || newDict == null) + { + _trace.Info($"CompareDictionaries mismatch - {fieldName} - one is null (legacy={legacyDict == null}, new={newDict == null})"); + return false; + } + + if (legacyDict is Dictionary legacyTypedDict && newDict is Dictionary newTypedDict) + { + if (!object.Equals(legacyTypedDict.Comparer, newTypedDict.Comparer)) + { + _trace.Info($"CompareDictionaries mismatch - {fieldName} - different comparers (legacy={legacyTypedDict.Comparer.GetType().Name}, new={newTypedDict.Comparer.GetType().Name})"); + return false; + } + } + + if (legacyDict.Count != newDict.Count) + { + _trace.Info($"CompareDictionaries mismatch - {fieldName}.Count differs (legacy={legacyDict.Count}, new={newDict.Count})"); + return false; + } + + foreach (var kvp in legacyDict) + { + if (!newDict.TryGetValue(kvp.Key, out var newValue)) + { + _trace.Info($"CompareDictionaries mismatch - {fieldName} - key '{kvp.Key}' missing in new result"); + return false; + } + + if (!string.Equals(kvp.Value, newValue, StringComparison.Ordinal)) + { + _trace.Info($"CompareDictionaries mismatch - {fieldName}['{kvp.Key}'] differs (legacy='{kvp.Value}', new='{newValue}')"); + return false; + } + } + + return true; + } + + private bool CompareJobServiceContainers( + IList> legacyResult, + IList> newResult) + { + if (legacyResult == null && newResult == null) + { + return true; + } + + if (legacyResult == null || newResult == null) + { + _trace.Info($"CompareJobServiceContainers mismatch - one result is null (legacy={legacyResult == null}, new={newResult == null})"); + return false; + } + + if (legacyResult.Count != newResult.Count) + { + _trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers.Count differs (legacy={legacyResult.Count}, new={newResult.Count})"); + return false; + } + + for (int i = 0; i < legacyResult.Count; i++) + { + var legacyKvp = legacyResult[i]; + var newKvp = newResult[i]; + + if (!string.Equals(legacyKvp.Key, newKvp.Key, StringComparison.Ordinal)) + { + _trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers[{i}].Key differs (legacy='{legacyKvp.Key}', new='{newKvp.Key}')"); + return false; + } + + if (!CompareJobContainer(legacyKvp.Value, newKvp.Value)) + { + _trace.Info($"CompareJobServiceContainers mismatch - ServiceContainers['{legacyKvp.Key}']"); + return false; + } + } + + return true; + } + + private bool CompareSnapshot( + GitHub.DistributedTask.Pipelines.Snapshot legacyResult, + GitHub.Actions.WorkflowParser.Snapshot newResult) + { + if (legacyResult == null && newResult == null) + { + return true; + } + + if (legacyResult == null || newResult == null) + { + _trace.Info($"CompareSnapshot mismatch - one is null (legacy={legacyResult == null}, new={newResult == null})"); + return false; + } + + if (!string.Equals(legacyResult.ImageName, newResult.ImageName, StringComparison.Ordinal)) + { + _trace.Info($"CompareSnapshot mismatch - Snapshot.ImageName differs (legacy='{legacyResult.ImageName}', new='{newResult.ImageName}')"); + return false; + } + + if (!string.Equals(legacyResult.Version, newResult.Version, StringComparison.Ordinal)) + { + _trace.Info($"CompareSnapshot mismatch - Snapshot.Version differs (legacy='{legacyResult.Version}', new='{newResult.Version}')"); + return false; + } + + // Compare Condition (legacy) vs If (new) + // Legacy has Condition as string, new has If as BasicExpressionToken + // For comparison, we'll serialize the If token and compare with Condition + var newIfValue = newResult.If != null ? Newtonsoft.Json.JsonConvert.SerializeObject(newResult.If, Newtonsoft.Json.Formatting.None) : null; + + // Legacy Condition is a string expression like "success()" + // New If is a BasicExpressionToken that needs to be serialized + // We'll do a basic comparison - if both are null/empty or both exist + var legacyHasCondition = !string.IsNullOrEmpty(legacyResult.Condition); + var newHasIf = newResult.If != null; + + if (legacyHasCondition != newHasIf) + { + _trace.Info($"CompareSnapshot mismatch - condition/if presence differs (legacy has condition={legacyHasCondition}, new has if={newHasIf})"); + return false; + } + + return true; + } + + private bool CompareExceptions(Exception legacyException, Exception newException) + { + if (legacyException == null && newException == null) + { + return true; + } + + if (legacyException == null || newException == null) + { + _trace.Info($"CompareExceptions mismatch - one exception is null (legacy={legacyException == null}, new={newException == null})"); + return false; + } + + // Compare exception messages recursively (including inner exceptions) + var legacyMessages = GetExceptionMessages(legacyException); + var newMessages = GetExceptionMessages(newException); + + if (legacyMessages.Count != newMessages.Count) + { + _trace.Info($"CompareExceptions mismatch - different number of exception messages (legacy={legacyMessages.Count}, new={newMessages.Count})"); + return false; + } + + for (int i = 0; i < legacyMessages.Count; i++) + { + if (!string.Equals(legacyMessages[i], newMessages[i], StringComparison.Ordinal)) + { + _trace.Info($"CompareExceptions mismatch - exception messages differ at level {i} (legacy='{legacyMessages[i]}', new='{newMessages[i]}')"); + return false; + } + } + + return true; + } + + private IList GetExceptionMessages(Exception ex) + { + var messages = new List(); + var toProcess = new Queue(); + toProcess.Enqueue(ex); + int count = 0; + + while (toProcess.Count > 0 && count < 50) + { + var current = toProcess.Dequeue(); + if (current == null) continue; + + messages.Add(current.Message); + count++; + + // Special handling for AggregateException - enqueue all inner exceptions + if (current is AggregateException aggregateEx) + { + foreach (var innerEx in aggregateEx.InnerExceptions) + { + if (innerEx != null && count < 50) + { + toProcess.Enqueue(innerEx); + } + } + } + else if (current.InnerException != null) + { + toProcess.Enqueue(current.InnerException); + } + + // Failsafe: if we have too many exceptions, stop and return what we have + if (count >= 50) + { + _trace.Info("CompareExceptions failsafe triggered - too many exceptions (50+)"); + break; + } + } + + return messages; + } + } +} diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/IPipelineTemplateEvaluator.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/IPipelineTemplateEvaluator.cs new file mode 100644 index 000000000..6b52ce4fe --- /dev/null +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/IPipelineTemplateEvaluator.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using GitHub.DistributedTask.Expressions2; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines.ContextData; + +namespace GitHub.DistributedTask.Pipelines.ObjectTemplating +{ + /// + /// Evaluates parts of the workflow DOM. For example, a job strategy or step inputs. + /// + public interface IPipelineTemplateEvaluator + { + Boolean EvaluateStepContinueOnError( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + String EvaluateStepDisplayName( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + Dictionary EvaluateStepEnvironment( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions, + StringComparer keyComparer); + + Boolean EvaluateStepIf( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions, + IEnumerable> expressionState); + + Dictionary EvaluateStepInputs( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + Int32 EvaluateStepTimeout( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + JobContainer EvaluateJobContainer( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + Dictionary EvaluateJobOutput( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + TemplateToken EvaluateEnvironmentUrl( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + Dictionary EvaluateJobDefaultsRun( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + IList> EvaluateJobServiceContainers( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + + Snapshot EvaluateJobSnapshotRequest( + TemplateToken token, + DictionaryContextData contextData, + IList expressionFunctions); + } +} diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs index e5fbd5d28..345058997 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateEvaluator.cs @@ -18,7 +18,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating /// Evaluates parts of the workflow DOM. For example, a job strategy or step inputs. /// [EditorBrowsable(EditorBrowsableState.Never)] - public class PipelineTemplateEvaluator + public class PipelineTemplateEvaluator : IPipelineTemplateEvaluator { public PipelineTemplateEvaluator( ITraceWriter trace, diff --git a/src/Sdk/Expressions/Data/ArrayExpressionData.cs b/src/Sdk/Expressions/Data/ArrayExpressionData.cs new file mode 100644 index 000000000..fa45390f2 --- /dev/null +++ b/src/Sdk/Expressions/Data/ArrayExpressionData.cs @@ -0,0 +1,111 @@ +#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; +using System.Collections.Generic; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + [DataContract] + [JsonObject] + public sealed class ArrayExpressionData : ExpressionData, IEnumerable, IReadOnlyArray + { + public ArrayExpressionData() + : base(ExpressionDataType.Array) + { + } + + [IgnoreDataMember] + public Int32 Count => m_items?.Count ?? 0; + + public ExpressionData this[Int32 index] => m_items[index]; + + Object IReadOnlyArray.this[Int32 index] => m_items[index]; + + public void Add(ExpressionData item) + { + if (m_items == null) + { + m_items = new List(); + } + + m_items.Add(item); + } + + public override ExpressionData Clone() + { + var result = new ArrayExpressionData(); + if (m_items?.Count > 0) + { + result.m_items = new List(m_items.Count); + foreach (var item in m_items) + { + result.m_items.Add(item); + } + } + return result; + } + + public override JToken ToJToken() + { + var result = new JArray(); + if (m_items?.Count > 0) + { + foreach (var item in m_items) + { + result.Add(item?.ToJToken() ?? JValue.CreateNull()); + } + } + return result; + } + + public IEnumerator GetEnumerator() + { + if (m_items?.Count > 0) + { + foreach (var item in m_items) + { + yield return item; + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + if (m_items?.Count > 0) + { + foreach (var item in m_items) + { + yield return item; + } + } + } + + IEnumerator IReadOnlyArray.GetEnumerator() + { + if (m_items?.Count > 0) + { + foreach (var item in m_items) + { + yield return item; + } + } + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_items?.Count == 0) + { + m_items = null; + } + } + + [DataMember(Name = "a", EmitDefaultValue = false)] + private List m_items; + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/Data/BooleanExpressionData.cs b/src/Sdk/Expressions/Data/BooleanExpressionData.cs new file mode 100644 index 000000000..1fbd28070 --- /dev/null +++ b/src/Sdk/Expressions/Data/BooleanExpressionData.cs @@ -0,0 +1,58 @@ +using System; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + [DataContract] + public sealed class BooleanExpressionData : ExpressionData, IBoolean + { + public BooleanExpressionData(Boolean value) + : base(ExpressionDataType.Boolean) + { + m_value = value; + } + + public Boolean Value + { + get + { + return m_value; + } + } + + public override ExpressionData Clone() + { + return new BooleanExpressionData(m_value); + } + + public override JToken ToJToken() + { + return (JToken)m_value; + } + + public override String ToString() + { + return m_value ? "true" : "false"; + } + + Boolean IBoolean.GetBoolean() + { + return Value; + } + + public static implicit operator Boolean(BooleanExpressionData data) + { + return data.Value; + } + + public static implicit operator BooleanExpressionData(Boolean data) + { + return new BooleanExpressionData(data); + } + + [DataMember(Name = "b", EmitDefaultValue = false)] + private Boolean m_value; + } +} diff --git a/src/Sdk/Expressions/Data/CaseSensitiveDictionaryExpressionData.cs b/src/Sdk/Expressions/Data/CaseSensitiveDictionaryExpressionData.cs new file mode 100644 index 000000000..a9a9e089f --- /dev/null +++ b/src/Sdk/Expressions/Data/CaseSensitiveDictionaryExpressionData.cs @@ -0,0 +1,289 @@ +#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; +using System.Collections.Generic; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + [DataContract] + [JsonObject] + public class CaseSensitiveDictionaryExpressionData : ExpressionData, IEnumerable>, IReadOnlyObject + { + public CaseSensitiveDictionaryExpressionData() + : base(ExpressionDataType.CaseSensitiveDictionary) + { + } + + [IgnoreDataMember] + public Int32 Count => m_list?.Count ?? 0; + + [IgnoreDataMember] + public IEnumerable Keys + { + get + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return pair.Key; + } + } + } + } + + [IgnoreDataMember] + public IEnumerable Values + { + get + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return pair.Value; + } + } + } + } + + IEnumerable IReadOnlyObject.Values + { + get + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return pair.Value; + } + } + } + } + + private Dictionary IndexLookup + { + get + { + if (m_indexLookup == null) + { + m_indexLookup = new Dictionary(StringComparer.Ordinal); + if (m_list?.Count > 0) + { + for (var i = 0; i < m_list.Count; i++) + { + var pair = m_list[i]; + m_indexLookup.Add(pair.Key, i); + } + } + } + + return m_indexLookup; + } + } + + private List List + { + get + { + if (m_list == null) + { + m_list = new List(); + } + + return m_list; + } + } + + public ExpressionData this[String key] + { + get + { + var index = IndexLookup[key]; + return m_list[index].Value; + } + + set + { + // Existing + if (IndexLookup.TryGetValue(key, out var index)) + { + key = m_list[index].Key; // preserve casing + m_list[index] = new DictionaryExpressionDataPair(key, value); + } + // New + else + { + Add(key, value); + } + } + } + + Object IReadOnlyObject.this[String key] + { + get + { + var index = IndexLookup[key]; + return m_list[index].Value; + } + } + + internal KeyValuePair this[Int32 index] + { + get + { + var pair = m_list[index]; + return new KeyValuePair(pair.Key, pair.Value); + } + } + + public void Add(IEnumerable> pairs) + { + foreach (var pair in pairs) + { + Add(pair.Key, pair.Value); + } + } + + public void Add( + String key, + ExpressionData value) + { + IndexLookup.Add(key, m_list?.Count ?? 0); + List.Add(new DictionaryExpressionDataPair(key, value)); + } + + public override ExpressionData Clone() + { + var result = new CaseSensitiveDictionaryExpressionData(); + + if (m_list?.Count > 0) + { + result.m_list = new List(m_list.Count); + foreach (var item in m_list) + { + result.m_list.Add(new DictionaryExpressionDataPair(item.Key, item.Value?.Clone())); + } + } + + return result; + } + + public override JToken ToJToken() + { + var json = new JObject(); + if (m_list?.Count > 0) + { + foreach (var item in m_list) + { + json.Add(item.Key, item.Value?.ToJToken() ?? JValue.CreateNull()); + } + } + return json; + } + + public Boolean ContainsKey(String key) + { + return TryGetValue(key, out _); + } + + public IEnumerator> GetEnumerator() + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return new KeyValuePair(pair.Key, pair.Value); + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return new KeyValuePair(pair.Key, pair.Value); + } + } + } + + IEnumerator IReadOnlyObject.GetEnumerator() + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return new KeyValuePair(pair.Key, pair.Value); + } + } + } + + public Boolean TryGetValue( + String key, + out ExpressionData value) + { + if (m_list?.Count > 0 && + IndexLookup.TryGetValue(key, out var index)) + { + value = m_list[index].Value; + return true; + } + + value = null; + return false; + } + + Boolean IReadOnlyObject.TryGetValue( + String key, + out Object value) + { + if (TryGetValue(key, out ExpressionData data)) + { + value = data; + return true; + } + + value = null; + return false; + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_list?.Count == 0) + { + m_list = null; + } + } + + [DataContract] + private sealed class DictionaryExpressionDataPair + { + public DictionaryExpressionDataPair( + String key, + ExpressionData value) + { + Key = key; + Value = value; + } + + [DataMember(Name = "k")] + public readonly String Key; + + [DataMember(Name = "v")] + public readonly ExpressionData Value; + } + + private Dictionary m_indexLookup; + + [DataMember(Name = "d", EmitDefaultValue = false)] + private List m_list; + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/Data/DictionaryExpressionData.cs b/src/Sdk/Expressions/Data/DictionaryExpressionData.cs new file mode 100644 index 000000000..c5948c756 --- /dev/null +++ b/src/Sdk/Expressions/Data/DictionaryExpressionData.cs @@ -0,0 +1,289 @@ +#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; +using System.Collections.Generic; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + [DataContract] + [JsonObject] + public class DictionaryExpressionData : ExpressionData, IEnumerable>, IReadOnlyObject + { + public DictionaryExpressionData() + : base(ExpressionDataType.Dictionary) + { + } + + [IgnoreDataMember] + public Int32 Count => m_list?.Count ?? 0; + + [IgnoreDataMember] + public IEnumerable Keys + { + get + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return pair.Key; + } + } + } + } + + [IgnoreDataMember] + public IEnumerable Values + { + get + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return pair.Value; + } + } + } + } + + IEnumerable IReadOnlyObject.Values + { + get + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return pair.Value; + } + } + } + } + + private Dictionary IndexLookup + { + get + { + if (m_indexLookup == null) + { + m_indexLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (m_list?.Count > 0) + { + for (var i = 0; i < m_list.Count; i++) + { + var pair = m_list[i]; + m_indexLookup.Add(pair.Key, i); + } + } + } + + return m_indexLookup; + } + } + + private List List + { + get + { + if (m_list == null) + { + m_list = new List(); + } + + return m_list; + } + } + + public ExpressionData this[String key] + { + get + { + var index = IndexLookup[key]; + return m_list[index].Value; + } + + set + { + // Existing + if (IndexLookup.TryGetValue(key, out var index)) + { + key = m_list[index].Key; // preserve casing + m_list[index] = new DictionaryExpressionDataPair(key, value); + } + // New + else + { + Add(key, value); + } + } + } + + Object IReadOnlyObject.this[String key] + { + get + { + var index = IndexLookup[key]; + return m_list[index].Value; + } + } + + internal KeyValuePair this[Int32 index] + { + get + { + var pair = m_list[index]; + return new KeyValuePair(pair.Key, pair.Value); + } + } + + public void Add(IEnumerable> pairs) + { + foreach (var pair in pairs) + { + Add(pair.Key, pair.Value); + } + } + + public void Add( + String key, + ExpressionData value) + { + IndexLookup.Add(key, m_list?.Count ?? 0); + List.Add(new DictionaryExpressionDataPair(key, value)); + } + + public override ExpressionData Clone() + { + var result = new DictionaryExpressionData(); + + if (m_list?.Count > 0) + { + result.m_list = new List(m_list.Count); + foreach (var item in m_list) + { + result.m_list.Add(new DictionaryExpressionDataPair(item.Key, item.Value?.Clone())); + } + } + + return result; + } + + public override JToken ToJToken() + { + var json = new JObject(); + if (m_list?.Count > 0) + { + foreach (var item in m_list) + { + json.Add(item.Key, item.Value?.ToJToken() ?? JValue.CreateNull()); + } + } + return json; + } + + public Boolean ContainsKey(String key) + { + return TryGetValue(key, out _); + } + + public IEnumerator> GetEnumerator() + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return new KeyValuePair(pair.Key, pair.Value); + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return new KeyValuePair(pair.Key, pair.Value); + } + } + } + + IEnumerator IReadOnlyObject.GetEnumerator() + { + if (m_list?.Count > 0) + { + foreach (var pair in m_list) + { + yield return new KeyValuePair(pair.Key, pair.Value); + } + } + } + + public Boolean TryGetValue( + String key, + out ExpressionData value) + { + if (m_list?.Count > 0 && + IndexLookup.TryGetValue(key, out var index)) + { + value = m_list[index].Value; + return true; + } + + value = null; + return false; + } + + Boolean IReadOnlyObject.TryGetValue( + String key, + out Object value) + { + if (TryGetValue(key, out ExpressionData data)) + { + value = data; + return true; + } + + value = null; + return false; + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_list?.Count == 0) + { + m_list = null; + } + } + + [DataContract] + private sealed class DictionaryExpressionDataPair + { + public DictionaryExpressionDataPair( + String key, + ExpressionData value) + { + Key = key; + Value = value; + } + + [DataMember(Name = "k")] + public readonly String Key; + + [DataMember(Name = "v")] + public readonly ExpressionData Value; + } + + private Dictionary m_indexLookup; + + [DataMember(Name = "d", EmitDefaultValue = false)] + private List m_list; + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/Data/ExpressionData.cs b/src/Sdk/Expressions/Data/ExpressionData.cs new file mode 100644 index 000000000..949987c66 --- /dev/null +++ b/src/Sdk/Expressions/Data/ExpressionData.cs @@ -0,0 +1,27 @@ +using System; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + /// + /// Base class for all template tokens + /// + [DataContract] + [JsonConverter(typeof(ExpressionDataJsonConverter))] + public abstract class ExpressionData + { + protected ExpressionData(Int32 type) + { + Type = type; + } + + [DataMember(Name = "t", EmitDefaultValue = false)] + internal Int32 Type { get; } + + public abstract ExpressionData Clone(); + + public abstract JToken ToJToken(); + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/Data/ExpressionDataExtensions.cs b/src/Sdk/Expressions/Data/ExpressionDataExtensions.cs new file mode 100644 index 000000000..3c944a4a1 --- /dev/null +++ b/src/Sdk/Expressions/Data/ExpressionDataExtensions.cs @@ -0,0 +1,156 @@ +#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; + +namespace GitHub.Actions.Expressions.Data +{ + public static class ExpressionDataExtensions + { + public static ArrayExpressionData AssertArray( + this ExpressionData value, + String objectDescription) + { + if (value is ArrayExpressionData array) + { + return array; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(ArrayExpressionData)}' was expected."); + } + + public static DictionaryExpressionData AssertDictionary( + this ExpressionData value, + String objectDescription) + { + if (value is DictionaryExpressionData dictionary) + { + return dictionary; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(DictionaryExpressionData)}' was expected."); + } + + public static StringExpressionData AssertString( + this ExpressionData value, + String objectDescription) + { + if (value is StringExpressionData str) + { + return str; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(StringExpressionData)}' was expected."); + } + + /// + /// Returns all context data objects (depth first) + /// + public static IEnumerable Traverse(this ExpressionData value) + { + return Traverse(value, omitKeys: false); + } + + /// + /// Returns all context data objects (depth first) + /// + /// If true, dictionary keys are omitted + public static IEnumerable Traverse( + this ExpressionData value, + Boolean omitKeys) + { + yield return value; + + if (value is ArrayExpressionData || value is DictionaryExpressionData) + { + var state = new TraversalState(null, value); + while (state != null) + { + if (state.MoveNext(omitKeys)) + { + value = state.Current; + yield return value; + + if (value is ArrayExpressionData || value is DictionaryExpressionData) + { + state = new TraversalState(state, value); + } + } + else + { + state = state.Parent; + } + } + } + } + + private sealed class TraversalState + { + public TraversalState( + TraversalState parent, + ExpressionData data) + { + Parent = parent; + m_data = data; + } + + public Boolean MoveNext(Boolean omitKeys) + { + switch (m_data.Type) + { + case ExpressionDataType.Array: + var array = m_data.AssertArray("array"); + if (++m_index < array.Count) + { + Current = array[m_index]; + return true; + } + else + { + Current = null; + return false; + } + + case ExpressionDataType.Dictionary: + var dictionary = m_data.AssertDictionary("dictionary"); + + // Return the value + if (m_isKey) + { + m_isKey = false; + Current = dictionary[m_index].Value; + return true; + } + + if (++m_index < dictionary.Count) + { + // Skip the key, return the value + if (omitKeys) + { + m_isKey = false; + Current = dictionary[m_index].Value; + return true; + } + + // Return the key + m_isKey = true; + Current = new StringExpressionData(dictionary[m_index].Key); + return true; + } + + Current = null; + return false; + + default: + throw new NotSupportedException($"Unexpected {nameof(ExpressionData)} type '{m_data.Type}'"); + } + } + + private ExpressionData m_data; + private Int32 m_index = -1; + private Boolean m_isKey; + public ExpressionData Current; + public TraversalState Parent; + } + } +} diff --git a/src/Sdk/Expressions/Data/ExpressionDataJsonConverter.cs b/src/Sdk/Expressions/Data/ExpressionDataJsonConverter.cs new file mode 100644 index 000000000..44a341eef --- /dev/null +++ b/src/Sdk/Expressions/Data/ExpressionDataJsonConverter.cs @@ -0,0 +1,199 @@ +#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.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + /// + /// JSON serializer for ExpressionData objects + /// + internal sealed class ExpressionDataJsonConverter : JsonConverter + { + public override Boolean CanWrite + { + get + { + return true; + } + } + + public override Boolean CanConvert(Type objectType) + { + return typeof(ExpressionData).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()); + } + + public override Object ReadJson( + JsonReader reader, + Type objectType, + Object existingValue, + JsonSerializer serializer) + { + switch (reader.TokenType) + { + case JsonToken.String: + return new StringExpressionData(reader.Value.ToString()); + + case JsonToken.Boolean: + return new BooleanExpressionData((Boolean)reader.Value); + + case JsonToken.Float: + return new NumberExpressionData((Double)reader.Value); + + case JsonToken.Integer: + return new NumberExpressionData((Double)(Int64)reader.Value); + + case JsonToken.StartObject: + break; + + default: + return null; + } + + Int32? type = null; + JObject value = JObject.Load(reader); + if (!value.TryGetValue("t", StringComparison.OrdinalIgnoreCase, out JToken typeValue)) + { + type = ExpressionDataType.String; + } + else if (typeValue.Type == JTokenType.Integer) + { + type = (Int32)typeValue; + } + else + { + return existingValue; + } + + Object newValue = null; + switch (type) + { + case ExpressionDataType.String: + newValue = new StringExpressionData(null); + break; + + case ExpressionDataType.Array: + newValue = new ArrayExpressionData(); + break; + + case ExpressionDataType.Dictionary: + newValue = new DictionaryExpressionData(); + break; + + case ExpressionDataType.Boolean: + newValue = new BooleanExpressionData(false); + break; + + case ExpressionDataType.Number: + newValue = new NumberExpressionData(0); + break; + + case ExpressionDataType.CaseSensitiveDictionary: + newValue = new CaseSensitiveDictionaryExpressionData(); + break; + + default: + throw new NotSupportedException($"Unexpected {nameof(ExpressionDataType)} '{type}'"); + } + + if (value != null) + { + using JsonReader objectReader = value.CreateReader(); + serializer.Populate(objectReader, newValue); + } + + return newValue; + } + + public override void WriteJson( + JsonWriter writer, + Object value, + JsonSerializer serializer) + { + if (Object.ReferenceEquals(value, null)) + { + writer.WriteNull(); + } + else if (value is StringExpressionData stringData) + { + writer.WriteValue(stringData.Value); + } + else if (value is BooleanExpressionData boolData) + { + writer.WriteValue(boolData.Value); + } + else if (value is NumberExpressionData numberData) + { + writer.WriteValue(numberData.Value); + } + else if (value is ArrayExpressionData arrayData) + { + writer.WriteStartObject(); + writer.WritePropertyName("t"); + writer.WriteValue(ExpressionDataType.Array); + if (arrayData.Count > 0) + { + writer.WritePropertyName("a"); + writer.WriteStartArray(); + foreach (var item in arrayData) + { + serializer.Serialize(writer, item); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } + else if (value is DictionaryExpressionData dictionaryData) + { + writer.WriteStartObject(); + writer.WritePropertyName("t"); + writer.WriteValue(ExpressionDataType.Dictionary); + if (dictionaryData.Count > 0) + { + writer.WritePropertyName("d"); + writer.WriteStartArray(); + foreach (var pair in dictionaryData) + { + writer.WriteStartObject(); + writer.WritePropertyName("k"); + writer.WriteValue(pair.Key); + writer.WritePropertyName("v"); + serializer.Serialize(writer, pair.Value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } + else if (value is CaseSensitiveDictionaryExpressionData caseSensitiveDictionaryData) + { + writer.WriteStartObject(); + writer.WritePropertyName("t"); + writer.WriteValue(ExpressionDataType.CaseSensitiveDictionary); + if (caseSensitiveDictionaryData.Count > 0) + { + writer.WritePropertyName("d"); + writer.WriteStartArray(); + foreach (var pair in caseSensitiveDictionaryData) + { + writer.WriteStartObject(); + writer.WritePropertyName("k"); + writer.WriteValue(pair.Key); + writer.WritePropertyName("v"); + serializer.Serialize(writer, pair.Value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } + else + { + throw new NotSupportedException($"Unexpected type '{value.GetType().Name}'"); + } + } + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/Data/ExpressionDataType.cs b/src/Sdk/Expressions/Data/ExpressionDataType.cs new file mode 100644 index 000000000..4060418df --- /dev/null +++ b/src/Sdk/Expressions/Data/ExpressionDataType.cs @@ -0,0 +1,19 @@ +using System; + +namespace GitHub.Actions.Expressions.Data +{ + internal static class ExpressionDataType + { + internal const Int32 String = 0; + + internal const Int32 Array = 1; + + internal const Int32 Dictionary = 2; + + internal const Int32 Boolean = 3; + + internal const Int32 Number = 4; + + internal const Int32 CaseSensitiveDictionary = 5; + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/Data/JTokenExtensions.cs b/src/Sdk/Expressions/Data/JTokenExtensions.cs new file mode 100644 index 000000000..8a7ef42be --- /dev/null +++ b/src/Sdk/Expressions/Data/JTokenExtensions.cs @@ -0,0 +1,64 @@ +#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 Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + public static class JTokenExtensions + { + public static ExpressionData ToExpressionData(this JToken value) + { + return value.ToExpressionData(1, 100); + } + + public static ExpressionData ToExpressionData( + this JToken value, + Int32 depth, + Int32 maxDepth) + { + if (depth < maxDepth) + { + if (value.Type == JTokenType.String) + { + return new StringExpressionData((String)value); + } + else if (value.Type == JTokenType.Boolean) + { + return new BooleanExpressionData((Boolean)value); + } + else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) + { + return new NumberExpressionData((Double)value); + } + else if (value.Type == JTokenType.Object) + { + var subContext = new DictionaryExpressionData(); + var obj = (JObject)value; + foreach (var property in obj.Properties()) + { + subContext[property.Name] = ToExpressionData(property.Value, depth + 1, maxDepth); + } + return subContext; + } + else if (value.Type == JTokenType.Array) + { + var arrayContext = new ArrayExpressionData(); + var arr = (JArray)value; + foreach (var element in arr) + { + arrayContext.Add(ToExpressionData(element, depth + 1, maxDepth)); + } + return arrayContext; + } + else if (value.Type == JTokenType.Null) + { + return null; + } + } + + // We don't understand the type or have reached our max, return as string + return new StringExpressionData(value.ToString()); + } + } +} diff --git a/src/Sdk/Expressions/Data/NumberExpressionData.cs b/src/Sdk/Expressions/Data/NumberExpressionData.cs new file mode 100644 index 000000000..8392a0690 --- /dev/null +++ b/src/Sdk/Expressions/Data/NumberExpressionData.cs @@ -0,0 +1,78 @@ +using System; +using System.Globalization; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + [DataContract] + public sealed class NumberExpressionData : ExpressionData, INumber + { + public NumberExpressionData(Double value) + : base(ExpressionDataType.Number) + { + m_value = value; + } + + public Double Value + { + get + { + return m_value; + } + } + + public override ExpressionData Clone() + { + return new NumberExpressionData(m_value); + } + + public override JToken ToJToken() + { + if (Double.IsNaN(m_value) || m_value == Double.PositiveInfinity || m_value == Double.NegativeInfinity) + { + return (JToken)m_value; + } + + var floored = Math.Floor(m_value); + if (m_value == floored && m_value <= (Double)Int32.MaxValue && m_value >= (Double)Int32.MinValue) + { + var flooredInt = (Int32)floored; + return (JToken)flooredInt; + } + else if (m_value == floored && m_value <= (Double)Int64.MaxValue && m_value >= (Double)Int64.MinValue) + { + var flooredInt = (Int64)floored; + return (JToken)flooredInt; + } + else + { + return (JToken)m_value; + } + } + + public override String ToString() + { + return m_value.ToString("G15", CultureInfo.InvariantCulture); + } + + Double INumber.GetNumber() + { + return Value; + } + + public static implicit operator Double(NumberExpressionData data) + { + return data.Value; + } + + public static implicit operator NumberExpressionData(Double data) + { + return new NumberExpressionData(data); + } + + [DataMember(Name = "n", EmitDefaultValue = false)] + private Double m_value; + } +} diff --git a/src/Sdk/Expressions/Data/StringExpressionData.cs b/src/Sdk/Expressions/Data/StringExpressionData.cs new file mode 100644 index 000000000..ad6f86d93 --- /dev/null +++ b/src/Sdk/Expressions/Data/StringExpressionData.cs @@ -0,0 +1,74 @@ +#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.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Data +{ + [DataContract] + public sealed class StringExpressionData : ExpressionData, IString + { + public StringExpressionData(String value) + : base(ExpressionDataType.String) + { + m_value = value; + } + + public String Value + { + get + { + if (m_value == null) + { + m_value = String.Empty; + } + + return m_value; + } + } + + public override ExpressionData Clone() + { + return new StringExpressionData(m_value); + } + + public override JToken ToJToken() + { + return (JToken)m_value; + } + + String IString.GetString() + { + return Value; + } + + public override String ToString() + { + return Value; + } + + public static implicit operator String(StringExpressionData data) + { + return data.Value; + } + + public static implicit operator StringExpressionData(String data) + { + return new StringExpressionData(data); + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_value?.Length == 0) + { + m_value = null; + } + } + + [DataMember(Name = "s", EmitDefaultValue = false)] + private String m_value; + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/EvaluationOptions.cs b/src/Sdk/Expressions/EvaluationOptions.cs new file mode 100644 index 000000000..67fee945f --- /dev/null +++ b/src/Sdk/Expressions/EvaluationOptions.cs @@ -0,0 +1,50 @@ +using System; + +namespace GitHub.Actions.Expressions +{ + public sealed class EvaluationOptions + { + public EvaluationOptions() + { + } + + public EvaluationOptions(EvaluationOptions copy) + { + if (copy != null) + { + MaxMemory = copy.MaxMemory; + MaxCacheMemory = copy.MaxCacheMemory; + StrictJsonParsing = copy.StrictJsonParsing; + AlwaysTraceExpanded = copy.AlwaysTraceExpanded; + } + } + + /// + /// Maximum memory (in bytes) allowed during expression evaluation. + /// Memory is tracked across the entire expression tree evaluation to protect against DOS attacks. + /// Default is 1 MB (1048576 bytes) if not specified. + /// + public Int32 MaxMemory { get; set; } + + /// + /// Maximum memory (in bytes) allowed for caching expanded expression results during tracing. + /// When exceeded, the cache is cleared and expressions may not be fully expanded in trace output. + /// Default is 1 MB (1048576 bytes) if not specified. + /// + public Int32 MaxCacheMemory { get; set; } + + /// + /// Whether to enforce strict JSON parsing in the fromJson function. + /// When true, rejects JSON with comments, trailing commas, single quotes, and other non-standard features. + /// Default is false if not specified. + /// + public Boolean StrictJsonParsing { get; set; } + + /// + /// Whether to always include the expanded expression in trace output. + /// When true, the expanded expression is always traced even if it matches the original expression or result. + /// Default is false if not specified. + /// + public Boolean AlwaysTraceExpanded { get; set; } + } +} diff --git a/src/Sdk/Expressions/EvaluationResult.cs b/src/Sdk/Expressions/EvaluationResult.cs new file mode 100644 index 000000000..9c17f2c6c --- /dev/null +++ b/src/Sdk/Expressions/EvaluationResult.cs @@ -0,0 +1,459 @@ +#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.Globalization; +using System.Linq; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.Expressions +{ + public sealed class EvaluationResult + { + internal EvaluationResult( + EvaluationContext context, + Int32 level, + Object val, + ValueKind kind, + Object raw) + : this(context, level, val, kind, raw, false) + { + } + + internal EvaluationResult( + EvaluationContext context, + Int32 level, + Object val, + ValueKind kind, + Object raw, + Boolean omitTracing) + { + m_level = level; + Value = val; + Kind = kind; + Raw = raw; + m_omitTracing = omitTracing; + + if (!omitTracing) + { + TraceValue(context); + } + } + + public ValueKind Kind { get; } + + /// + /// When an interface converter is applied to the node result, raw contains the original value + /// + public Object Raw { get; } + + public Object Value { get; } + + public Boolean IsFalsy + { + get + { + switch (Kind) + { + case ValueKind.Null: + return true; + case ValueKind.Boolean: + var boolean = (Boolean)Value; + return !boolean; + case ValueKind.Number: + var number = (Double)Value; + return number == 0d || Double.IsNaN(number); + case ValueKind.String: + var str = (String)Value; + return String.Equals(str, String.Empty, StringComparison.Ordinal); + default: + return false; + } + } + } + + public Boolean IsPrimitive => ExpressionUtility.IsPrimitive(Kind); + + public Boolean IsTruthy => !IsFalsy; + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + public Boolean AbstractEqual(EvaluationResult right) + { + return AbstractEqual(Value, right.Value); + } + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + public Boolean AbstractGreaterThan(EvaluationResult right) + { + return AbstractGreaterThan(Value, right.Value); + } + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + public Boolean AbstractGreaterThanOrEqual(EvaluationResult right) + { + return AbstractEqual(Value, right.Value) || AbstractGreaterThan(Value, right.Value); + } + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + public Boolean AbstractLessThan(EvaluationResult right) + { + return AbstractLessThan(Value, right.Value); + } + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + public Boolean AbstractLessThanOrEqual(EvaluationResult right) + { + return AbstractEqual(Value, right.Value) || AbstractLessThan(Value, right.Value); + } + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + public Boolean AbstractNotEqual(EvaluationResult right) + { + return !AbstractEqual(Value, right.Value); + } + + public Double ConvertToNumber() + { + return ConvertToNumber(Value); + } + + public String ConvertToString() + { + switch (Kind) + { + case ValueKind.Null: + return String.Empty; + + case ValueKind.Boolean: + return ((Boolean)Value) ? ExpressionConstants.True : ExpressionConstants.False; + + case ValueKind.Number: + if ((Double)Value == -0) + { + // .NET Core 3.0 now prints negative zero as -0, so we need this to keep out behavior consistent + return ((Double)0).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture); + } + return ((Double)Value).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture); + + case ValueKind.String: + return Value as String; + + default: + return Kind.ToString(); + } + } + + public Boolean TryGetCollectionInterface(out Object collection) + { + if ((Kind == ValueKind.Object || Kind == ValueKind.Array)) + { + var obj = Value; + if (obj is IReadOnlyObject) + { + collection = obj; + return true; + } + else if (obj is IReadOnlyArray) + { + collection = obj; + return true; + } + } + + collection = null; + return false; + } + + /// + /// Useful for working with values that are not the direct evaluation result of a parameter. + /// This allows ExpressionNode authors to leverage the coercion and comparison functions + /// for any values. + /// + /// Also note, the value will be canonicalized (for example numeric types converted to double) and any + /// matching interfaces applied. + /// + public static EvaluationResult CreateIntermediateResult( + EvaluationContext context, + Object obj) + { + var val = ExpressionUtility.ConvertToCanonicalValue(obj, out ValueKind kind, out Object raw); + return new EvaluationResult(context, 0, val, kind, raw, omitTracing: true); + } + + private void TraceValue(EvaluationContext context) + { + if (!m_omitTracing) + { + TraceValue(context, Value, Kind); + } + } + + private void TraceValue( + EvaluationContext context, + Object val, + ValueKind kind) + { + if (!m_omitTracing) + { + TraceVerbose(context, String.Concat("=> ", ExpressionUtility.FormatValue(context?.SecretMasker, val, kind))); + } + } + + private void TraceVerbose( + EvaluationContext context, + String message) + { + if (!m_omitTracing) + { + context?.Trace.Verbose(String.Empty.PadLeft(m_level * 2, '.') + (message ?? String.Empty)); + } + } + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + private static Boolean AbstractEqual( + Object canonicalLeftValue, + Object canonicalRightValue) + { + CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind); + + // Same kind + if (leftKind == rightKind) + { + switch (leftKind) + { + // Null, Null + case ValueKind.Null: + return true; + + // Number, Number + case ValueKind.Number: + var leftDouble = (Double)canonicalLeftValue; + var rightDouble = (Double)canonicalRightValue; + if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble)) + { + return false; + } + return leftDouble == rightDouble; + + // String, String + case ValueKind.String: + var leftString = (String)canonicalLeftValue; + var rightString = (String)canonicalRightValue; + return String.Equals(leftString, rightString, StringComparison.OrdinalIgnoreCase); + + // Boolean, Boolean + case ValueKind.Boolean: + var leftBoolean = (Boolean)canonicalLeftValue; + var rightBoolean = (Boolean)canonicalRightValue; + return leftBoolean == rightBoolean; + + // Object, Object + case ValueKind.Object: + case ValueKind.Array: + return Object.ReferenceEquals(canonicalLeftValue, canonicalRightValue); + } + } + + return false; + } + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + private static Boolean AbstractGreaterThan( + Object canonicalLeftValue, + Object canonicalRightValue) + { + CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind); + + // Same kind + if (leftKind == rightKind) + { + switch (leftKind) + { + // Number, Number + case ValueKind.Number: + var leftDouble = (Double)canonicalLeftValue; + var rightDouble = (Double)canonicalRightValue; + if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble)) + { + return false; + } + return leftDouble > rightDouble; + + // String, String + case ValueKind.String: + var leftString = (String)canonicalLeftValue; + var rightString = (String)canonicalRightValue; + return String.Compare(leftString, rightString, StringComparison.OrdinalIgnoreCase) > 0; + + // Boolean, Boolean + case ValueKind.Boolean: + var leftBoolean = (Boolean)canonicalLeftValue; + var rightBoolean = (Boolean)canonicalRightValue; + return leftBoolean && !rightBoolean; + } + } + + return false; + } + + /// + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. + /// + private static Boolean AbstractLessThan( + Object canonicalLeftValue, + Object canonicalRightValue) + { + CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out var leftKind, out var rightKind); + + // Same kind + if (leftKind == rightKind) + { + switch (leftKind) + { + // Number, Number + case ValueKind.Number: + var leftDouble = (Double)canonicalLeftValue; + var rightDouble = (Double)canonicalRightValue; + if (Double.IsNaN(leftDouble) || Double.IsNaN(rightDouble)) + { + return false; + } + return leftDouble < rightDouble; + + // String, String + case ValueKind.String: + var leftString = (String)canonicalLeftValue; + var rightString = (String)canonicalRightValue; + return String.Compare(leftString, rightString, StringComparison.OrdinalIgnoreCase) < 0; + + // Boolean, Boolean + case ValueKind.Boolean: + var leftBoolean = (Boolean)canonicalLeftValue; + var rightBoolean = (Boolean)canonicalRightValue; + return !leftBoolean && rightBoolean; + } + } + + return false; + } + + /// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. + /// Except objects are not coerced to primitives. + private static void CoerceTypes( + ref Object canonicalLeftValue, + ref Object canonicalRightValue, + out ValueKind leftKind, + out ValueKind rightKind) + { + leftKind = GetKind(canonicalLeftValue); + rightKind = GetKind(canonicalRightValue); + + // Same kind + if (leftKind == rightKind) + { + } + // Number, String + else if (leftKind == ValueKind.Number && rightKind == ValueKind.String) + { + canonicalRightValue = ConvertToNumber(canonicalRightValue); + rightKind = ValueKind.Number; + } + // String, Number + else if (leftKind == ValueKind.String && rightKind == ValueKind.Number) + { + canonicalLeftValue = ConvertToNumber(canonicalLeftValue); + leftKind = ValueKind.Number; + } + // Boolean|Null, Any + else if (leftKind == ValueKind.Boolean || leftKind == ValueKind.Null) + { + canonicalLeftValue = ConvertToNumber(canonicalLeftValue); + CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out leftKind, out rightKind); + } + // Any, Boolean|Null + else if (rightKind == ValueKind.Boolean || rightKind == ValueKind.Null) + { + canonicalRightValue = ConvertToNumber(canonicalRightValue); + CoerceTypes(ref canonicalLeftValue, ref canonicalRightValue, out leftKind, out rightKind); + } + } + + /// + /// For primitives, follows the Javascript rules (the Number function in Javascript). Otherwise NaN. + /// + private static Double ConvertToNumber(Object canonicalValue) + { + var kind = GetKind(canonicalValue); + switch (kind) + { + case ValueKind.Null: + return 0d; + case ValueKind.Boolean: + return (Boolean)canonicalValue ? 1d : 0d; + case ValueKind.Number: + return (Double)canonicalValue; + case ValueKind.String: + return ExpressionUtility.ParseNumber(canonicalValue as String); + } + + return Double.NaN; + } + + private static ValueKind GetKind(Object canonicalValue) + { + if (Object.ReferenceEquals(canonicalValue, null)) + { + return ValueKind.Null; + } + else if (canonicalValue is Boolean) + { + return ValueKind.Boolean; + } + else if (canonicalValue is Double) + { + return ValueKind.Number; + } + else if (canonicalValue is String) + { + return ValueKind.String; + } + else if (canonicalValue is IReadOnlyObject) + { + return ValueKind.Object; + } + else if (canonicalValue is IReadOnlyArray) + { + return ValueKind.Array; + } + + return ValueKind.Object; + } + + private readonly Int32 m_level; + private readonly Boolean m_omitTracing; + } +} diff --git a/src/Sdk/Expressions/ExpressionConstants.cs b/src/Sdk/Expressions/ExpressionConstants.cs new file mode 100644 index 000000000..df98e3869 --- /dev/null +++ b/src/Sdk/Expressions/ExpressionConstants.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using GitHub.Actions.Expressions.Sdk; +using GitHub.Actions.Expressions.Sdk.Functions; + +namespace GitHub.Actions.Expressions +{ + public static class ExpressionConstants + { + static ExpressionConstants() + { + AddFunction("contains", 2, 2); + AddFunction("endsWith", 2, 2); + AddFunction("format", 1, Byte.MaxValue); + AddFunction("join", 1, 2); + AddFunction("startsWith", 2, 2); + AddFunction("toJson", 1, 1); + AddFunction("fromJson", 1, 1); + } + + private static void AddFunction(String name, Int32 minParameters, Int32 maxParameters) + where T : Function, new() + { + s_wellKnownFunctions.Add(name, new FunctionInfo(name, minParameters, maxParameters)); + } + + internal static readonly String False = "false"; + internal static readonly String Infinity = "Infinity"; + internal static readonly Int32 MaxDepth = 50; + internal static readonly Int32 MaxLength = 21000; // Under 85,000 large object heap threshold, even if .NET switches to UTF-32 + internal static readonly String NaN = "NaN"; + internal static readonly String NegativeInfinity = "-Infinity"; + public static readonly String Null = "null"; + internal static readonly String NumberFormat = "G15"; + internal static readonly String True = "true"; + private static readonly Dictionary s_wellKnownFunctions = new Dictionary(StringComparer.OrdinalIgnoreCase); + public static readonly IReadOnlyDictionary WellKnownFunctions = new ReadOnlyDictionary(s_wellKnownFunctions); + + // Punctuation + internal const Char StartGroup = '('; // logical grouping + internal const Char StartIndex = '['; + public static readonly Char StartParameter = '('; // function call + internal const Char EndGroup = ')'; // logical grouping + internal const Char EndIndex = ']'; + public static readonly Char EndParameter = ')'; // function calll + internal const Char Separator = ','; + internal const Char Dereference = '.'; + internal const Char Wildcard = '*'; + + // Operators + internal const String Not = "!"; + internal const String NotEqual = "!="; + internal const String GreaterThan = ">"; + internal const String GreaterThanOrEqual = ">="; + internal const String LessThan = "<"; + internal const String LessThanOrEqual = "<="; + internal const String Equal = "=="; + internal const String And = "&&"; + internal const String Or = "||"; + } +} diff --git a/src/Sdk/Expressions/ExpressionException.cs b/src/Sdk/Expressions/ExpressionException.cs new file mode 100644 index 000000000..4d1210acd --- /dev/null +++ b/src/Sdk/Expressions/ExpressionException.cs @@ -0,0 +1,21 @@ +using System; + +namespace GitHub.Actions.Expressions +{ + public class ExpressionException : Exception + { + internal ExpressionException(ISecretMasker secretMasker, String message) + { + if (secretMasker != null) + { + message = secretMasker.MaskSecrets(message); + } + + m_message = message; + } + + public override String Message => m_message; + + private readonly String m_message; + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/ExpressionParser.cs b/src/Sdk/Expressions/ExpressionParser.cs new file mode 100644 index 000000000..32b1fd740 --- /dev/null +++ b/src/Sdk/Expressions/ExpressionParser.cs @@ -0,0 +1,471 @@ +#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.Operators; +using GitHub.Actions.Expressions.Tokens; + +namespace GitHub.Actions.Expressions +{ + using GitHub.Actions.Expressions.Sdk; + using GitHub.Actions.Expressions.Sdk.Functions; + + public sealed class ExpressionParser + { + public IExpressionNode CreateTree( + String expression, + ITraceWriter trace, + IEnumerable namedValues, + IEnumerable functions) + { + var context = new ParseContext(expression, trace, namedValues, functions); + context.Trace.Info($"Parsing expression: <{expression}>"); + return CreateTree(context); + } + + public IExpressionNode ValidateSyntax( + String expression, + ITraceWriter trace) + { + var context = new ParseContext(expression, trace, namedValues: null, functions: null, allowUnknownKeywords: true); + context.Trace.Info($"Validating expression syntax: <{expression}>"); + return CreateTree(context); + } + + private static IExpressionNode CreateTree(ParseContext context) + { + // Push the tokens + while (context.LexicalAnalyzer.TryGetNextToken(ref context.Token)) + { + // Unexpected + if (context.Token.Kind == TokenKind.Unexpected) + { + throw new ParseException(ParseExceptionKind.UnexpectedSymbol, context.Token, context.Expression); + } + // Operator + else if (context.Token.IsOperator) + { + PushOperator(context); + } + // Operand + else + { + PushOperand(context); + } + + context.LastToken = context.Token; + } + + // No tokens + if (context.LastToken == null) + { + return null; + } + + // Check unexpected end of expression + if (context.Operators.Count > 0) + { + var unexpectedLastToken = false; + switch (context.LastToken.Kind) + { + case TokenKind.EndGroup: // ")" logical grouping + case TokenKind.EndIndex: // "]" + case TokenKind.EndParameters: // ")" function call + // Legal + break; + case TokenKind.Function: + // Illegal + unexpectedLastToken = true; + break; + default: + unexpectedLastToken = context.LastToken.IsOperator; + break; + } + + if (unexpectedLastToken || context.LexicalAnalyzer.UnclosedTokens.Any()) + { + throw new ParseException(ParseExceptionKind.UnexpectedEndOfExpression, context.LastToken, context.Expression); + } + } + + // Flush operators + while (context.Operators.Count > 0) + { + FlushTopOperator(context); + } + + // Check max depth + var result = context.Operands.Single(); + CheckMaxDepth(context, result); + + return result; + } + + private static void PushOperand(ParseContext context) + { + // Create the node + var node = default(ExpressionNode); + switch (context.Token.Kind) + { + // Function + case TokenKind.Function: + var function = context.Token.RawValue; + if (TryGetFunctionInfo(context, function, out var functionInfo)) + { + node = functionInfo.CreateNode(); + node.Name = function; + } + else if (context.AllowUnknownKeywords) + { + node = new NoOperation(); + node.Name = function; + } + else + { + throw new ParseException(ParseExceptionKind.UnrecognizedFunction, context.Token, context.Expression); + } + break; + + // Named-value + case TokenKind.NamedValue: + var name = context.Token.RawValue; + if (context.ExtensionNamedValues.TryGetValue(name, out var namedValueInfo)) + { + node = namedValueInfo.CreateNode(); + node.Name = name; + + } + else if (context.AllowUnknownKeywords) + { + node = new NoOperationNamedValue(); + node.Name = name; + } + else + { + throw new ParseException(ParseExceptionKind.UnrecognizedNamedValue, context.Token, context.Expression); + } + break; + + // Otherwise simple + default: + node = context.Token.ToNode(); + break; + } + + // Push the operand + context.Operands.Push(node); + } + + private static void PushOperator(ParseContext context) + { + // Flush higher or equal precedence + if (context.Token.Associativity == Associativity.LeftToRight) + { + var precedence = context.Token.Precedence; + while (context.Operators.Count > 0) + { + var topOperator = context.Operators.Peek(); + if (precedence <= topOperator.Precedence && + topOperator.Kind != TokenKind.StartGroup && // Unless top is "(" logical grouping + topOperator.Kind != TokenKind.StartIndex && // or unless top is "[" + topOperator.Kind != TokenKind.StartParameters &&// or unless top is "(" function call + topOperator.Kind != TokenKind.Separator) // or unless top is "," + { + FlushTopOperator(context); + continue; + } + + break; + } + } + + // Push the operator + context.Operators.Push(context.Token); + + // Process closing operators now, since context.LastToken is required + // to accurately process TokenKind.EndParameters + switch (context.Token.Kind) + { + case TokenKind.EndGroup: // ")" logical grouping + case TokenKind.EndIndex: // "]" + case TokenKind.EndParameters: // ")" function call + FlushTopOperator(context); + break; + } + } + + private static void FlushTopOperator(ParseContext context) + { + // Special handling for closing operators + switch (context.Operators.Peek().Kind) + { + case TokenKind.EndIndex: // "]" + FlushTopEndIndex(context); + return; + + case TokenKind.EndGroup: // ")" logical grouping + FlushTopEndGroup(context); + return; + + case TokenKind.EndParameters: // ")" function call + FlushTopEndParameters(context); + return; + } + + // Pop the operator + var @operator = context.Operators.Pop(); + + // Create the node + var node = (Container)@operator.ToNode(); + + // Pop the operands, add to the node + var operands = PopOperands(context, @operator.OperandCount); + foreach (var operand in operands) + { + // Flatten nested And + if (node is And) + { + if (operand is And nestedAnd) + { + foreach (var nestedParameter in nestedAnd.Parameters) + { + node.AddParameter(nestedParameter); + } + + continue; + } + } + // Flatten nested Or + else if (node is Or) + { + if (operand is Or nestedOr) + { + foreach (var nestedParameter in nestedOr.Parameters) + { + node.AddParameter(nestedParameter); + } + + continue; + } + } + + node.AddParameter(operand); + } + + // Push the node to the operand stack + context.Operands.Push(node); + } + + /// + /// Flushes the ")" logical grouping operator + /// + private static void FlushTopEndGroup(ParseContext context) + { + // Pop the operators + PopOperator(context, TokenKind.EndGroup); // ")" logical grouping + PopOperator(context, TokenKind.StartGroup); // "(" logical grouping + } + + /// + /// Flushes the "]" operator + /// + private static void FlushTopEndIndex(ParseContext context) + { + // Pop the operators + PopOperator(context, TokenKind.EndIndex); // "]" + var @operator = PopOperator(context, TokenKind.StartIndex); // "[" + + // Create the node + var node = (Container)@operator.ToNode(); + + // Pop the operands, add to the node + var operands = PopOperands(context, @operator.OperandCount); + foreach (var operand in operands) + { + node.AddParameter(operand); + } + + // Push the node to the operand stack + context.Operands.Push(node); + } + + // ")" function call + private static void FlushTopEndParameters(ParseContext context) + { + // Pop the operator + var @operator = PopOperator(context, TokenKind.EndParameters); // ")" function call + + // Sanity check top operator is the current token + if (!Object.ReferenceEquals(@operator, context.Token)) + { + throw new InvalidOperationException("Expected the operator to be the current token"); + } + + var function = default(Function); + + // No parameters + if (context.LastToken.Kind == TokenKind.StartParameters) + { + // Node already exists on the operand stack + function = (Function)context.Operands.Peek(); + } + // Has parameters + else + { + // Pop the operands + var parameterCount = 1; + while (context.Operators.Peek().Kind == TokenKind.Separator) + { + parameterCount++; + context.Operators.Pop(); + } + var functionOperands = PopOperands(context, parameterCount); + + // Node already exists on the operand stack + function = (Function)context.Operands.Peek(); + + // Add the operands to the node + foreach (var operand in functionOperands) + { + function.AddParameter(operand); + } + } + + // Pop the "(" operator too + @operator = PopOperator(context, TokenKind.StartParameters); + + // Check min/max parameter count + TryGetFunctionInfo(context, function.Name, out var functionInfo); + if (functionInfo == null && context.AllowUnknownKeywords) + { + // Don't check min/max + } + else if (function.Parameters.Count < functionInfo.MinParameters) + { + throw new ParseException(ParseExceptionKind.TooFewParameters, token: @operator, expression: context.Expression); + } + else if (function.Parameters.Count > functionInfo.MaxParameters) + { + throw new ParseException(ParseExceptionKind.TooManyParameters, token: @operator, expression: context.Expression); + } + } + + /// + /// Pops N operands from the operand stack. The operands are returned + /// in their natural listed order, i.e. not last-in-first-out. + /// + private static List PopOperands( + ParseContext context, + Int32 count) + { + var result = new List(); + while (count-- > 0) + { + result.Add(context.Operands.Pop()); + } + + result.Reverse(); + return result; + } + + /// + /// Pops an operator and asserts it is the expected kind. + /// + private static Token PopOperator( + ParseContext context, + TokenKind expected) + { + var token = context.Operators.Pop(); + if (token.Kind != expected) + { + throw new NotSupportedException($"Expected operator '{expected}' to be popped. Actual '{token.Kind}'."); + } + return token; + } + + /// + /// Checks the max depth of the expression tree + /// + private static void CheckMaxDepth( + ParseContext context, + ExpressionNode node, + Int32 depth = 1) + { + if (depth > ExpressionConstants.MaxDepth) + { + throw new ParseException(ParseExceptionKind.ExceededMaxDepth, token: null, expression: context.Expression); + } + + if (node is Container container) + { + foreach (var parameter in container.Parameters) + { + CheckMaxDepth(context, parameter, depth + 1); + } + } + } + + private static Boolean TryGetFunctionInfo( + ParseContext context, + String name, + out IFunctionInfo functionInfo) + { + return ExpressionConstants.WellKnownFunctions.TryGetValue(name, out functionInfo) || + context.ExtensionFunctions.TryGetValue(name, out functionInfo); + } + + private sealed class ParseContext + { + public Boolean AllowUnknownKeywords; + public readonly String Expression; + public readonly Dictionary ExtensionFunctions = new Dictionary(StringComparer.OrdinalIgnoreCase); + public readonly Dictionary ExtensionNamedValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + public readonly LexicalAnalyzer LexicalAnalyzer; + public readonly Stack Operands = new Stack(); + public readonly Stack Operators = new Stack(); + public readonly ITraceWriter Trace; + public Token Token; + public Token LastToken; + + public ParseContext( + String expression, + ITraceWriter trace, + IEnumerable namedValues, + IEnumerable functions, + Boolean allowUnknownKeywords = false) + { + Expression = expression ?? String.Empty; + if (Expression.Length > ExpressionConstants.MaxLength) + { + throw new ParseException(ParseExceptionKind.ExceededMaxLength, token: null, expression: Expression); + } + + Trace = trace ?? new NoOperationTraceWriter(); + foreach (var namedValueInfo in (namedValues ?? new INamedValueInfo[0])) + { + ExtensionNamedValues.Add(namedValueInfo.Name, namedValueInfo); + } + + foreach (var functionInfo in (functions ?? new IFunctionInfo[0])) + { + ExtensionFunctions.Add(functionInfo.Name, functionInfo); + } + + LexicalAnalyzer = new LexicalAnalyzer(Expression); + AllowUnknownKeywords = allowUnknownKeywords; + } + + private class NoOperationTraceWriter : ITraceWriter + { + public void Info(String message) + { + } + + public void Verbose(String message) + { + } + } + } + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/FunctionInfo.cs b/src/Sdk/Expressions/FunctionInfo.cs new file mode 100644 index 000000000..3ce17a417 --- /dev/null +++ b/src/Sdk/Expressions/FunctionInfo.cs @@ -0,0 +1,27 @@ +using System; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.Expressions +{ + public class FunctionInfo : IFunctionInfo + where T : Function, new() + { + public FunctionInfo(String name, Int32 minParameters, Int32 maxParameters) + { + Name = name; + MinParameters = minParameters; + MaxParameters = maxParameters; + } + + public String Name { get; } + + public Int32 MinParameters { get; } + + public Int32 MaxParameters { get; } + + public Function CreateNode() + { + return new T(); + } + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/IExpressionNode.cs b/src/Sdk/Expressions/IExpressionNode.cs new file mode 100644 index 000000000..15fb61fea --- /dev/null +++ b/src/Sdk/Expressions/IExpressionNode.cs @@ -0,0 +1,24 @@ +#nullable enable + +using System; + + +namespace GitHub.Actions.Expressions +{ + public interface IExpressionNode + { + /// + /// Evaluates the expression and returns the result, wrapped in a helper + /// for converting, comparing, and traversing objects. + /// + /// Optional trace writer + /// Optional secret masker + /// State object for custom evaluation function nodes and custom named-value nodes + /// Evaluation options + EvaluationResult Evaluate( + ITraceWriter trace, + ISecretMasker? secretMasker, + Object state, + EvaluationOptions options); + } +} diff --git a/src/Sdk/Expressions/IExpressionNodeExtensions.cs b/src/Sdk/Expressions/IExpressionNodeExtensions.cs new file mode 100644 index 000000000..6204b2a8d --- /dev/null +++ b/src/Sdk/Expressions/IExpressionNodeExtensions.cs @@ -0,0 +1,320 @@ +#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 + { + /// + /// Returns the node and all descendant nodes + /// + public static IEnumerable 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; + } + } + } + } + + /// + /// 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". + /// + 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[]); + + // Walk the expression tree + var stack = new Stack(); + 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[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[] patterns, + Boolean[] result, + out List 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(originalNodeSegments.Reverse()); // Push reverse to preserve order + nodeSegments.Pop(); + patternSegments = new Stack(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 GetMatchSegments( + IExpressionNode node, + out List needsFurtherAnalysis) + { + var result = new Stack(); + needsFurtherAnalysis = new List(); + + // 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; + } + } +} diff --git a/src/Sdk/Expressions/IFunctionInfo.cs b/src/Sdk/Expressions/IFunctionInfo.cs new file mode 100644 index 000000000..67fc2055e --- /dev/null +++ b/src/Sdk/Expressions/IFunctionInfo.cs @@ -0,0 +1,13 @@ +using System; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.Expressions +{ + public interface IFunctionInfo + { + String Name { get; } + Int32 MinParameters { get; } + Int32 MaxParameters { get; } + Function CreateNode(); + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/INamedValueInfo.cs b/src/Sdk/Expressions/INamedValueInfo.cs new file mode 100644 index 000000000..eea98bddb --- /dev/null +++ b/src/Sdk/Expressions/INamedValueInfo.cs @@ -0,0 +1,11 @@ +using System; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.Expressions +{ + public interface INamedValueInfo + { + String Name { get; } + NamedValue CreateNode(); + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/ISecretMasker.cs b/src/Sdk/Expressions/ISecretMasker.cs new file mode 100644 index 000000000..59093ce91 --- /dev/null +++ b/src/Sdk/Expressions/ISecretMasker.cs @@ -0,0 +1,12 @@ +using System; + +namespace GitHub.Actions.Expressions +{ + /// + /// Used to mask secrets from trace messages and exception messages + /// + public interface ISecretMasker + { + String MaskSecrets(String input); + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/ITraceWriter.cs b/src/Sdk/Expressions/ITraceWriter.cs new file mode 100644 index 000000000..a4358030a --- /dev/null +++ b/src/Sdk/Expressions/ITraceWriter.cs @@ -0,0 +1,10 @@ +using System; + +namespace GitHub.Actions.Expressions +{ + public interface ITraceWriter + { + void Info(String message); + void Verbose(String message); + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/NamedValueInfo.cs b/src/Sdk/Expressions/NamedValueInfo.cs new file mode 100644 index 000000000..f5b2eb151 --- /dev/null +++ b/src/Sdk/Expressions/NamedValueInfo.cs @@ -0,0 +1,21 @@ +using System; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.Expressions +{ + public class NamedValueInfo : INamedValueInfo + where T : NamedValue, new() + { + public NamedValueInfo(String name) + { + Name = name; + } + + public String Name { get; } + + public NamedValue CreateNode() + { + return new T(); + } + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/NoOpSecretMasker.cs b/src/Sdk/Expressions/NoOpSecretMasker.cs new file mode 100644 index 000000000..53df962b1 --- /dev/null +++ b/src/Sdk/Expressions/NoOpSecretMasker.cs @@ -0,0 +1,12 @@ +using System; + +namespace GitHub.Actions.Expressions +{ + internal sealed class NoOpSecretMasker : ISecretMasker + { + public String MaskSecrets(String input) + { + return input; + } + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/ParseException.cs b/src/Sdk/Expressions/ParseException.cs new file mode 100644 index 000000000..e5f7f3fa1 --- /dev/null +++ b/src/Sdk/Expressions/ParseException.cs @@ -0,0 +1,68 @@ +#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.Tokens; + +namespace GitHub.Actions.Expressions +{ + public sealed class ParseException : ExpressionException + { + internal ParseException(ParseExceptionKind kind, Token token, String expression) + : base(secretMasker: null, message: String.Empty) + { + Expression = expression; + Kind = kind; + RawToken = token?.RawValue; + TokenIndex = token?.Index ?? 0; + String description; + switch (kind) + { + case ParseExceptionKind.ExceededMaxDepth: + description = $"Exceeded max expression depth {ExpressionConstants.MaxDepth}"; + break; + case ParseExceptionKind.ExceededMaxLength: + description = $"Exceeded max expression length {ExpressionConstants.MaxLength}"; + break; + case ParseExceptionKind.TooFewParameters: + description = "Too few parameters supplied"; + break; + case ParseExceptionKind.TooManyParameters: + description = "Too many parameters supplied"; + break; + case ParseExceptionKind.UnexpectedEndOfExpression: + description = "Unexpected end of expression"; + break; + case ParseExceptionKind.UnexpectedSymbol: + description = "Unexpected symbol"; + break; + case ParseExceptionKind.UnrecognizedFunction: + description = "Unrecognized function"; + break; + case ParseExceptionKind.UnrecognizedNamedValue: + description = "Unrecognized named-value"; + break; + default: // Should never reach here. + throw new Exception($"Unexpected parse exception kind '{kind}'."); + } + + if (token == null) + { + Message = description; + } + else + { + Message = $"{description}: '{RawToken}'. Located at position {TokenIndex + 1} within expression: {Expression}"; + } + } + + internal String Expression { get; } + + internal ParseExceptionKind Kind { get; } + + internal String RawToken { get; } + + internal Int32 TokenIndex { get; } + + public sealed override String Message { get; } + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/ParseExceptionKind.cs b/src/Sdk/Expressions/ParseExceptionKind.cs new file mode 100644 index 000000000..297fe0e02 --- /dev/null +++ b/src/Sdk/Expressions/ParseExceptionKind.cs @@ -0,0 +1,14 @@ +namespace GitHub.Actions.Expressions +{ + internal enum ParseExceptionKind + { + ExceededMaxDepth, + ExceededMaxLength, + TooFewParameters, + TooManyParameters, + UnexpectedEndOfExpression, + UnexpectedSymbol, + UnrecognizedFunction, + UnrecognizedNamedValue, + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/Resources/ExpressionResources.cs b/src/Sdk/Expressions/Resources/ExpressionResources.cs new file mode 100644 index 000000000..b74b6362e --- /dev/null +++ b/src/Sdk/Expressions/Resources/ExpressionResources.cs @@ -0,0 +1,277 @@ +// +// *** AUTOMATICALLY GENERATED BY GenResourceClass -- DO NOT EDIT!!! *** +using System; +using System.Diagnostics; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using System.Resources; + +namespace GitHub.Actions.Expressions { + + +internal static class ExpressionResources +{ + + + //******************************************************************************************** + /// Creates the resource manager instance. + //******************************************************************************************** + static ExpressionResources() + { + s_resMgr = new ResourceManager("GitHub.Actions.Expressions.ExpressionResources", typeof(ExpressionResources).GetTypeInfo().Assembly); + } + + public static ResourceManager Manager + { + get + { + return s_resMgr; + } + } + + //******************************************************************************************** + /// Returns a localized string given a resource string name. + //******************************************************************************************** + public static String Get( + String resourceName) + { + return s_resMgr.GetString(resourceName, CultureInfo.CurrentUICulture); + } + + //******************************************************************************************** + /// Returns a localized integer given a resource string name. + //******************************************************************************************** + public static int GetInt( + String resourceName) + { + return (int)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture); + } + + //******************************************************************************************** + /// Returns a localized string given a resource string name. + //******************************************************************************************** + public static bool GetBool( + String resourceName) + { + return (bool)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture); + } + + + //******************************************************************************************** + /// A little helper function to alleviate some typing associated with loading resources and + /// formatting the strings. In DEBUG builds, it also asserts that the number of format + /// arguments and the length of args match. + //******************************************************************************************** + private static String Format( // The formatted resource string. + String resourceName, // The name of the resource. + params Object[] args) // Arguments to format. + { + String resource = Get(resourceName); + +#if DEBUG + // Check to make sure that the number of format string arguments matches the number of + // arguments passed in. + int formatArgCount = 0; + bool[] argSeen = new bool[100]; + for (int i = 0; i < resource.Length; i++) + { + if (resource[i] == '{') + { + if (i + 1 < resource.Length && + resource[i + 1] == '{') + { + i++; // Skip the escaped curly braces. + } + else + { + // Move past the curly brace and leading whitespace. + i++; + while (Char.IsWhiteSpace(resource[i])) + { + i++; + } + + // Get the argument number. + int length = 0; + while (i + length < resource.Length && Char.IsDigit(resource[i + length])) + { + length++; + } + + // Record it if it hasn't already been seen. + int argNumber = int.Parse(resource.Substring(i, length), CultureInfo.InvariantCulture); + if (!argSeen[argNumber]) + { + formatArgCount++; // Count it as a formatting argument. + argSeen[argNumber] = true; + } + } + } + } + + Debug.Assert(args != null || formatArgCount == 0, + String.Format(CultureInfo.InvariantCulture, "The number of format arguments is {0}, but the args parameter is null.", formatArgCount)); + Debug.Assert(args == null || formatArgCount == args.Length, + String.Format(CultureInfo.InvariantCulture, "Coding error using resource \"{0}\": The number of format arguments {1} != number of args {2}", + resourceName, formatArgCount, args != null ? args.Length : 0)); +#endif // DEBUG + + + if (args == null) + { + return resource; + } + + // If there are any DateTime structs in the arguments, we need to bracket them + // to make sure they are within the supported range of the current calendar. + for (int i = 0; i < args.Length; i++) + { + // DateTime is a struct, we cannot use the as operator and null check. + if (args[i] is DateTime) + { + DateTime dateTime = (DateTime)args[i]; + + // We need to fetch the calendar on each Format call since it may change. + // Since we don't have more than one DateTime for resource, do not + // bother to cache this for the duration of the for loop. + Calendar calendar = DateTimeFormatInfo.CurrentInfo.Calendar; + if (dateTime > calendar.MaxSupportedDateTime) + { + args[i] = calendar.MaxSupportedDateTime; + } + else if (dateTime < calendar.MinSupportedDateTime) + { + args[i] = calendar.MinSupportedDateTime; + } + } + } + + return String.Format(CultureInfo.CurrentCulture, resource, args); + } + + // According to the documentation for the ResourceManager class, it should be sufficient to + // create a single static instance. The following is an excerpt from the 1.1 documentation. + // Using the methods of ResourceManager, a caller can access the resources for a particular + // culture using the GetObject and GetString methods. By default, these methods return the + // resource for the culture determined by the current cultural settings of the thread that made + // the call. + private static ResourceManager s_resMgr; + + /// + /// The maximum allowed memory size was exceeded while evaluating the following expression: {0} + /// + public static String ExceededAllowedMemory(object arg0) { return Format("ExceededAllowedMemory", arg0); } + + /// + /// 0 is replaced with a number. + /// + /// Exceeded max expression depth {0} + /// + public static String ExceededMaxExpressionDepth(object arg0) { return Format("ExceededMaxExpressionDepth", arg0); } + + /// + /// 0 is replaced with a number. + /// + /// Exceeded max expression length {0} + /// + public static String ExceededMaxExpressionLength(object arg0) { return Format("ExceededMaxExpressionLength", arg0); } + + /// + /// Expected a property name to follow the dereference operator '.' + /// + public static String ExpectedPropertyName() { return Get("ExpectedPropertyName"); } + + /// + /// Expected '(' to follow a function + /// + public static String ExpectedStartParameter() { return Get("ExpectedStartParameter"); } + + /// + /// The following format string references more arguments than were supplied: {0} + /// + public static String InvalidFormatArgIndex(object arg0) { return Format("InvalidFormatArgIndex", arg0); } + + /// + /// The format specifiers '{0}' are not valid for objects of type '{1}' + /// + public static String InvalidFormatSpecifiers(object arg0, object arg1) { return Format("InvalidFormatSpecifiers", arg0, arg1); } + + /// + /// The following format string is invalid: {0} + /// + public static String InvalidFormatString(object arg0) { return Format("InvalidFormatString", arg0); } + + /// + /// Key not found '{0}' + /// + public static String KeyNotFound(object arg0) { return Format("KeyNotFound", arg0); } + + /// + /// 0 is replaced with the error message + /// + /// {0}. + /// + public static String ParseErrorWithFwlink(object arg0) { return Format("ParseErrorWithFwlink", arg0); } + + /// + /// 0 is replaced with the parse error message + /// 1 is replaced with the token + /// 2 is replaced with the character position within the string + /// 3 is replaced with the full statement + /// + /// {0}: '{1}'. Located at position {2} within expression: {3}. + /// + public static String ParseErrorWithTokenInfo(object arg0, object arg1, object arg2, object arg3) { return Format("ParseErrorWithTokenInfo", arg0, arg1, arg2, arg3); } + + /// + /// 0 is replaced with the from-type. + /// 1 is replaced with the to-type. + /// 2 is replaced with the value. + /// + /// Unable to convert from {0} to {1}. Value: {2} + /// + public static String TypeCastError(object arg0, object arg1, object arg2) { return Format("TypeCastError", arg0, arg1, arg2); } + + /// + /// 0 is replaced with the from-type. + /// 1 is replaced with the to-type. + /// + /// Unable to convert from {0} to {1}. + /// + public static String TypeCastErrorNoValue(object arg0, object arg1) { return Format("TypeCastErrorNoValue", arg0, arg1); } + + /// + /// 0 is replaced with the from-type. + /// 1 is replaced with the to-type. + /// 2 is replaced with the value. + /// 3 is replaced with the error message. + /// + /// Unable to convert from {0} to {1}. Value: {2}. Error: {3} + /// + public static String TypeCastErrorWithError(object arg0, object arg1, object arg2, object arg3) { return Format("TypeCastErrorWithError", arg0, arg1, arg2, arg3); } + + /// + /// Unclosed function + /// + public static String UnclosedFunction() { return Get("UnclosedFunction"); } + + /// + /// Unclosed indexer + /// + public static String UnclosedIndexer() { return Get("UnclosedIndexer"); } + + /// + /// Unexpected symbol + /// + public static String UnexpectedSymbol() { return Get("UnexpectedSymbol"); } + + /// + /// Unrecognized value + /// + public static String UnrecognizedValue() { return Get("UnrecognizedValue"); } + + +} + +} // namespace diff --git a/src/Sdk/Expressions/Resources/ExpressionResources.resx b/src/Sdk/Expressions/Resources/ExpressionResources.resx new file mode 100644 index 000000000..279361aa6 --- /dev/null +++ b/src/Sdk/Expressions/Resources/ExpressionResources.resx @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The maximum allowed memory size was exceeded while evaluating the following expression: {0} + + + Exceeded max expression depth {0} + 0 is replaced with a number. + + + Exceeded max expression length {0} + 0 is replaced with a number. + + + Expected a property name to follow the dereference operator '.' + + + Expected '(' to follow a function + + + The following format string references more arguments than were supplied: {0} + + + The format specifiers '{0}' are not valid for objects of type '{1}' + + + The following format string is invalid: {0} + + + Key not found '{0}' + + + {0}. + 0 is replaced with the error message + + + {0}: '{1}'. Located at position {2} within expression: {3}. + 0 is replaced with the parse error message +1 is replaced with the token +2 is replaced with the character position within the string +3 is replaced with the full statement + + + Unable to convert from {0} to {1}. Value: {2} + 0 is replaced with the from-type. +1 is replaced with the to-type. +2 is replaced with the value. + + + Unable to convert from {0} to {1}. + 0 is replaced with the from-type. +1 is replaced with the to-type. + + + Unable to convert from {0} to {1}. Value: {2}. Error: {3} + 0 is replaced with the from-type. +1 is replaced with the to-type. +2 is replaced with the value. +3 is replaced with the error message. + + + Unclosed function + + + Unclosed indexer + + + Unexpected symbol + + + Unrecognized value + + \ No newline at end of file diff --git a/src/Sdk/Expressions/Sdk/Container.cs b/src/Sdk/Expressions/Sdk/Container.cs new file mode 100644 index 000000000..631004718 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Container.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace GitHub.Actions.Expressions.Sdk +{ + public abstract class Container : ExpressionNode + { + public IReadOnlyList Parameters => m_parameters.AsReadOnly(); + + public void AddParameter(ExpressionNode node) + { + m_parameters.Add(node); + node.Container = this; + } + + private readonly List m_parameters = new List(); + } +} diff --git a/src/Sdk/Expressions/Sdk/EvaluationContext.cs b/src/Sdk/Expressions/Sdk/EvaluationContext.cs new file mode 100644 index 000000000..d417b8f28 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/EvaluationContext.cs @@ -0,0 +1,79 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk +{ + public sealed class EvaluationContext + { + internal EvaluationContext( + ITraceWriter trace, + ISecretMasker secretMasker, + Object state, + EvaluationOptions options, + ExpressionNode node) + { + Trace = trace ?? throw new ArgumentNullException(nameof(trace)); + SecretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker)); + State = state; + + // Copy the options + options = new EvaluationOptions(copy: options); + if (options.MaxMemory == 0) + { + // Set a reasonable default max memory + options.MaxMemory = 1048576; // 1 mb + } + if (options.MaxCacheMemory <= 0) + { + // Set a reasonable default max cache bytes + options.MaxCacheMemory = 1048576; // 1 mb + } + Options = options; + Memory = new EvaluationMemory(options.MaxMemory, node); + + m_traceResults = new Dictionary(); + m_traceMemory = new MemoryCounter(null, options.MaxCacheMemory); + } + + public ITraceWriter Trace { get; } + + public ISecretMasker SecretMasker { get; } + + public Object State { get; } + + internal EvaluationMemory Memory { get; } + + internal EvaluationOptions Options { get; } + + internal void SetTraceResult( + ExpressionNode node, + EvaluationResult result) + { + // Remove if previously added. This typically should not happen. This could happen + // due to a badly authored function. So we'll handle it and track memory correctly. + if (m_traceResults.TryGetValue(node, out String oldValue)) + { + m_traceMemory.Remove(oldValue); + m_traceResults.Remove(node); + } + + // Check max memory + String value = ExpressionUtility.FormatValue(SecretMasker, result); + if (m_traceMemory.TryAdd(value)) + { + // Store the result + m_traceResults[node] = value; + } + } + + internal Boolean TryGetTraceResult(ExpressionNode node, out String value) + { + return m_traceResults.TryGetValue(node, out value); + } + + private readonly Dictionary m_traceResults = new Dictionary(); + private readonly MemoryCounter m_traceMemory; + } +} diff --git a/src/Sdk/Expressions/Sdk/EvaluationMemory.cs b/src/Sdk/Expressions/Sdk/EvaluationMemory.cs new file mode 100644 index 000000000..934a4799e --- /dev/null +++ b/src/Sdk/Expressions/Sdk/EvaluationMemory.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Actions.Expressions.Sdk +{ + /// + /// This is an internal class only. + /// + /// This class is used to track current memory consumption + /// across the entire expression evaluation. + /// + internal sealed class EvaluationMemory + { + internal EvaluationMemory( + Int32 maxBytes, + ExpressionNode node) + { + m_maxAmount = maxBytes; + m_node = node; + } + + internal void AddAmount( + Int32 depth, + Int32 bytes, + Boolean trimDepth = false) + { + // Trim deeper depths + if (trimDepth) + { + while (m_maxActiveDepth > depth) + { + var amount = m_depths[m_maxActiveDepth]; + + if (amount > 0) + { + // Sanity check + if (amount > m_totalAmount) + { + throw new InvalidOperationException("Bytes to subtract exceeds total bytes"); + } + + // Subtract from the total + checked + { + m_totalAmount -= amount; + } + + // Reset the amount + m_depths[m_maxActiveDepth] = 0; + } + + m_maxActiveDepth--; + } + } + + // Grow the depths + if (depth > m_maxActiveDepth) + { + // Grow the list + while (m_depths.Count <= depth) + { + m_depths.Add(0); + } + + // Adjust the max active depth + m_maxActiveDepth = depth; + } + + checked + { + // Add to the depth + m_depths[depth] += bytes; + + // Add to the total + m_totalAmount += bytes; + } + + // Check max + if (m_totalAmount > m_maxAmount) + { + throw new InvalidOperationException(ExpressionResources.ExceededAllowedMemory(m_node?.ConvertToExpression())); + } + } + + internal static Int32 CalculateBytes(Object obj) + { + if (obj is String str) + { + // This measurement doesn't have to be perfect + // https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/ + + checked + { + return c_stringBaseOverhead + ((str?.Length ?? 0) * sizeof(Char)); + } + } + else + { + return c_minObjectSize; + } + } + + private const Int32 c_minObjectSize = 24; + private const Int32 c_stringBaseOverhead = 26; + private readonly List m_depths = new List(); + private readonly Int32 m_maxAmount; + private readonly ExpressionNode m_node; + private Int32 m_maxActiveDepth = -1; + private Int32 m_totalAmount; + } +} diff --git a/src/Sdk/Expressions/Sdk/EvaluationTraceWriter.cs b/src/Sdk/Expressions/Sdk/EvaluationTraceWriter.cs new file mode 100644 index 000000000..cf560cb22 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/EvaluationTraceWriter.cs @@ -0,0 +1,34 @@ +using System; + +namespace GitHub.Actions.Expressions.Sdk +{ + internal sealed class EvaluationTraceWriter : ITraceWriter + { + public EvaluationTraceWriter(ITraceWriter trace, ISecretMasker secretMasker) + { + m_trace = trace; + m_secretMasker = secretMasker ?? throw new ArgumentNullException(nameof(secretMasker)); + } + + public void Info(String message) + { + if (m_trace != null) + { + message = m_secretMasker.MaskSecrets(message); + m_trace.Info(message); + } + } + + public void Verbose(String message) + { + if (m_trace != null) + { + message = m_secretMasker.MaskSecrets(message); + m_trace.Verbose(message); + } + } + + private readonly ISecretMasker m_secretMasker; + private readonly ITraceWriter m_trace; + } +} \ No newline at end of file diff --git a/src/Sdk/Expressions/Sdk/ExpressionNode.cs b/src/Sdk/Expressions/Sdk/ExpressionNode.cs new file mode 100644 index 000000000..5acbad245 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/ExpressionNode.cs @@ -0,0 +1,187 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk +{ + public abstract class ExpressionNode : IExpressionNode + { + internal Container Container { get; set; } + + internal Int32 Level { get; private set; } + + /// + /// The name is used for tracing. Normally the parser will set the name. However if a node + /// is added manually, then the name may not be set and will fallback to the type name. + /// + public String Name + { + get + { + return !String.IsNullOrEmpty(m_name) ? m_name : this.GetType().Name; + } + + set + { + m_name = value; + } + } + + /// + /// Indicates whether the evalation result should be stored on the context and used + /// when the expanded result is traced. + /// + protected abstract Boolean TraceFullyExpanded { get; } + + /// + /// IExpressionNode entry point. + /// + EvaluationResult IExpressionNode.Evaluate( + ITraceWriter trace, + ISecretMasker secretMasker, + Object state, + EvaluationOptions options) + { + if (Container != null) + { + // Do not localize. This is an SDK consumer error. + throw new NotSupportedException($"Expected {nameof(IExpressionNode)}.{nameof(Evaluate)} to be called on root node only."); + } + + + var originalSecretMasker = secretMasker; + try + { + // Evaluate + secretMasker = secretMasker ?? new NoOpSecretMasker(); + trace = new EvaluationTraceWriter(trace, secretMasker); + var context = new EvaluationContext(trace, secretMasker, state, options, this); + var originalExpression = ConvertToExpression(); + trace.Info($"Evaluating: {originalExpression}"); + var result = Evaluate(context); + + // Trace the result + TraceTreeResult(context, originalExpression, result.Value, result.Kind); + + return result; + } + finally + { + if (secretMasker != null && secretMasker != originalSecretMasker) + { + (secretMasker as IDisposable)?.Dispose(); + secretMasker = null; + } + } + } + + /// + /// This function is intended only for ExpressionNode authors to call. The EvaluationContext + /// caches result-state specific to the evaluation instance. + /// + public EvaluationResult Evaluate(EvaluationContext context) + { + // Evaluate + Level = Container == null ? 0 : Container.Level + 1; + TraceVerbose(context, Level, $"Evaluating {Name}:"); + var coreResult = EvaluateCore(context, out ResultMemory coreMemory); + + if (coreMemory == null) + { + coreMemory = new ResultMemory(); + } + + // Convert to canonical value + var val = ExpressionUtility.ConvertToCanonicalValue(coreResult, out ValueKind kind, out Object raw); + + // The depth can be safely trimmed when the total size of the core result is known, + // or when the total size of the core result can easily be determined. + var trimDepth = coreMemory.IsTotal || (Object.ReferenceEquals(raw, null) && ExpressionUtility.IsPrimitive(kind)); + + // Account for the memory overhead of the core result + var coreBytes = coreMemory.Bytes ?? EvaluationMemory.CalculateBytes(raw ?? val); + context.Memory.AddAmount(Level, coreBytes, trimDepth); + + // Account for the memory overhead of the conversion result + if (!Object.ReferenceEquals(raw, null)) + { + var conversionBytes = EvaluationMemory.CalculateBytes(val); + context.Memory.AddAmount(Level, conversionBytes); + } + + var result = new EvaluationResult(context, Level, val, kind, raw); + + // Store the trace result + if (this.TraceFullyExpanded) + { + context.SetTraceResult(this, result); + } + + return result; + } + + public abstract String ConvertToExpression(); + + internal abstract String ConvertToExpandedExpression(EvaluationContext context); + + /// + /// Evaluates the node + /// + /// The current expression context + /// + /// Helps determine how much memory is being consumed across the evaluation of the expression. + /// + protected abstract Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory); + + protected MemoryCounter CreateMemoryCounter(EvaluationContext context) + { + return new MemoryCounter(this, context.Options.MaxMemory); + } + + private void TraceTreeResult( + EvaluationContext context, + String originalExpression, + Object result, + ValueKind kind) + { + // Get the expanded expression + String expandedExpression = ConvertToExpandedExpression(context); + + // Format the result + String traceValue = ExpressionUtility.FormatValue(context.SecretMasker, result, kind); + + // Only trace the expanded expression if it is meaningfully different (or if always showing) + if (context.Options.AlwaysTraceExpanded || + (!String.Equals(expandedExpression, originalExpression, StringComparison.Ordinal) && + !String.Equals(expandedExpression, traceValue, StringComparison.Ordinal))) + { + if (!context.Options.AlwaysTraceExpanded && + kind == ValueKind.Number && + String.Equals(expandedExpression, $"'{traceValue}'", StringComparison.Ordinal)) + { + // Don't bother tracing the expanded expression when the result is a number and the + // expanded expresion is a precisely matching string. + } + else + { + context.Trace.Info($"Expanded: {expandedExpression}"); + } + } + + // Always trace the result + context.Trace.Info($"Result: {traceValue}"); + } + + private static void TraceVerbose( + EvaluationContext context, + Int32 level, + String message) + { + context.Trace.Verbose(String.Empty.PadLeft(level * 2, '.') + (message ?? String.Empty)); + } + + private String m_name; + } +} diff --git a/src/Sdk/Expressions/Sdk/ExpressionUtility.cs b/src/Sdk/Expressions/Sdk/ExpressionUtility.cs new file mode 100644 index 000000000..84faa9afb --- /dev/null +++ b/src/Sdk/Expressions/Sdk/ExpressionUtility.cs @@ -0,0 +1,295 @@ +#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.Globalization; +using System.Linq; +using System.Reflection; + +namespace GitHub.Actions.Expressions.Sdk +{ + public static class ExpressionUtility + { + internal static Object ConvertToCanonicalValue( + Object val, + out ValueKind kind, + out Object raw) + { + raw = null; + + if (Object.ReferenceEquals(val, null)) + { + kind = ValueKind.Null; + return null; + } + else if (val is Boolean) + { + kind = ValueKind.Boolean; + return val; + } + else if (val is Double) + { + kind = ValueKind.Number; + return val; + } + else if (val is String) + { + kind = ValueKind.String; + return val; + } + else if (val is INull n) + { + kind = ValueKind.Null; + raw = val; + return null; + } + else if (val is IBoolean boolean) + { + kind = ValueKind.Boolean; + raw = val; + return boolean.GetBoolean(); + } + else if (val is INumber number) + { + kind = ValueKind.Number; + raw = val; + return number.GetNumber(); + } + else if (val is IString str) + { + kind = ValueKind.String; + raw = val; + return str.GetString(); + } + else if (val is IReadOnlyObject) + { + kind = ValueKind.Object; + return val; + } + else if (val is IReadOnlyArray) + { + kind = ValueKind.Array; + return val; + } + else if (!val.GetType().GetTypeInfo().IsClass) + { + if (val is Decimal || val is Byte || val is SByte || val is Int16 || val is UInt16 || val is Int32 || val is UInt32 || val is Int64 || val is UInt64 || val is Single) + { + kind = ValueKind.Number; + return Convert.ToDouble(val); + } + else if (val is Enum) + { + var strVal = String.Format(CultureInfo.InvariantCulture, "{0:G}", val); + if (Double.TryParse(strVal, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out Double doubleValue)) + { + kind = ValueKind.Number; + return doubleValue; + } + + kind = ValueKind.String; + return strVal; + } + } + + kind = ValueKind.Object; + return val; + } + + /// + /// Converts a string into it's parse token representation. Useful when programmatically constructing an expression. + /// For example the string "hello world" returns 'hello world'. Note, null will return the null token; pass empty string + /// if you want the empty string token instead. + /// + public static String ConvertToParseToken(String str) + { + if (str == null) + { + return FormatValue(null, null, ValueKind.Null); + } + + return FormatValue(null, str, ValueKind.String); + } + + /// + /// Converts a string into it's parse token representation. Useful when programmatically constructing an expression. + /// + public static String ConvertToParseToken(Double d) + { + return FormatValue(null, d, ValueKind.Number); + } + + /// + /// Converts a string into it's parse token representation. Useful when programmatically constructing an expression. + /// + public static String ConvertToParseToken(Boolean b) + { + return FormatValue(null, b, ValueKind.Boolean); + } + + internal static String FormatValue( + ISecretMasker secretMasker, + EvaluationResult evaluationResult) + { + return FormatValue(secretMasker, evaluationResult.Value, evaluationResult.Kind); + } + + internal static String FormatValue( + ISecretMasker secretMasker, + Object value, + ValueKind kind) + { + switch (kind) + { + case ValueKind.Null: + return ExpressionConstants.Null; + + case ValueKind.Boolean: + return ((Boolean)value) ? ExpressionConstants.True : ExpressionConstants.False; + + case ValueKind.Number: + var strNumber = ((Double)value).ToString(ExpressionConstants.NumberFormat, CultureInfo.InvariantCulture); + return secretMasker != null ? secretMasker.MaskSecrets(strNumber) : strNumber; + + case ValueKind.String: + // Mask secrets before string-escaping. + var strValue = secretMasker != null ? secretMasker.MaskSecrets(value as String) : value as String; + return $"'{StringEscape(strValue)}'"; + + case ValueKind.Array: + case ValueKind.Object: + return kind.ToString(); + + default: // Should never reach here. + throw new NotSupportedException($"Unable to convert to expanded expression. Unexpected value kind: {kind}"); + } + } + + internal static bool IsLegalKeyword(String str) + { + if (String.IsNullOrEmpty(str)) + { + return false; + } + + var first = str[0]; + if ((first >= 'a' && first <= 'z') || + (first >= 'A' && first <= 'Z') || + first == '_') + { + for (var i = 1; i < str.Length; i++) + { + var c = str[i]; + if ((c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '_' || + c == '-') + { + // OK + } + else + { + return false; + } + } + + return true; + } + else + { + return false; + } + + } + + internal static Boolean IsPrimitive(ValueKind kind) + { + switch (kind) + { + case ValueKind.Null: + case ValueKind.Boolean: + case ValueKind.Number: + case ValueKind.String: + return true; + default: + return false; + } + } + + /// + /// The rules here attempt to follow Javascript rules for coercing a string into a number + /// for comparison. That is, the Number() function in Javascript. + /// + internal static Double ParseNumber(String str) + { + // Trim + str = str?.Trim() ?? String.Empty; + + // Empty + if (String.IsNullOrEmpty(str)) + { + return 0d; + } + // Try parse + else if (Double.TryParse(str, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var value)) + { + return value; + } + // Check for 0x[0-9a-fA-F]+ + else if (str[0] == '0' && + str.Length > 2 && + str[1] == 'x' && + str.Skip(2).All(x => (x >= '0' && x <= '9') || (x >= 'a' && x <= 'f') || (x >= 'A' && x <= 'F'))) + { + // Try parse + if (Int32.TryParse(str.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var integer)) + { + return (Double)integer; + } + + // Otherwise exceeds range + } + // Check for 0o[0-9]+ + else if (str[0] == '0' && + str.Length > 2 && + str[1] == 'o' && + str.Skip(2).All(x => x >= '0' && x <= '7')) + { + // Try parse + var integer = default(Int32?); + try + { + integer = Convert.ToInt32(str.Substring(2), 8); + } + // Otherwise exceeds range + catch (Exception) + { + } + + // Success + if (integer != null) + { + return (Double)integer.Value; + } + } + // Infinity + else if (String.Equals(str, ExpressionConstants.Infinity, StringComparison.Ordinal)) + { + return Double.PositiveInfinity; + } + // -Infinity + else if (String.Equals(str, ExpressionConstants.NegativeInfinity, StringComparison.Ordinal)) + { + return Double.NegativeInfinity; + } + + // Otherwise NaN + return Double.NaN; + } + + public static String StringEscape(String value) + { + return String.IsNullOrEmpty(value) ? String.Empty : value.Replace("'", "''"); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Function.cs b/src/Sdk/Expressions/Sdk/Function.cs new file mode 100644 index 000000000..1cbaa42af --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Function.cs @@ -0,0 +1,43 @@ +using System; +using System.Globalization; +using System.Linq; + +namespace GitHub.Actions.Expressions.Sdk +{ + public abstract class Function : Container + { + /// + /// Generally this should not be overridden. True indicates the result of the node is traced as part of the + /// "expanded" trace information. Otherwise the node expression is printed, and parameters to the node may or + /// may not be fully expanded - depending on each respective parameter's trace-fully-expanded setting. + /// + /// The purpose is so the end user can understand how their expression expanded at run time. For example, consider + /// the expression: eq(variables.publish, 'true'). The runtime-expanded expression may be: eq('true', 'true') + /// + protected override Boolean TraceFullyExpanded => true; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "{0}({1})", + Name, + String.Join(", ", Parameters.Select(x => x.ConvertToExpression()))); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "{0}({1})", + Name, + String.Join(", ", Parameters.Select(x => x.ConvertToExpandedExpression(context)))); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/Contains.cs b/src/Sdk/Expressions/Sdk/Functions/Contains.cs new file mode 100644 index 000000000..06905e183 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/Contains.cs @@ -0,0 +1,46 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + internal sealed class Contains : Function + { + protected sealed override Boolean TraceFullyExpanded => false; + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + if (left.IsPrimitive) + { + var leftString = left.ConvertToString(); + + var right = Parameters[1].Evaluate(context); + if (right.IsPrimitive) + { + var rightString = right.ConvertToString(); + return leftString.IndexOf(rightString, StringComparison.OrdinalIgnoreCase) >= 0; + } + } + else if (left.TryGetCollectionInterface(out var collection) && + collection is IReadOnlyArray array && + array.Count > 0) + { + var right = Parameters[1].Evaluate(context); + foreach (var item in array) + { + var itemResult = EvaluationResult.CreateIntermediateResult(context, item); + if (right.AbstractEqual(itemResult)) + { + return true; + } + } + } + + return false; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/EndsWith.cs b/src/Sdk/Expressions/Sdk/Functions/EndsWith.cs new file mode 100644 index 000000000..b08301002 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/EndsWith.cs @@ -0,0 +1,32 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + internal sealed class EndsWith : Function + { + protected sealed override Boolean TraceFullyExpanded => false; + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + if (left.IsPrimitive) + { + var leftString = left.ConvertToString(); + + var right = Parameters[1].Evaluate(context); + if (right.IsPrimitive) + { + var rightString = right.ConvertToString(); + return leftString.EndsWith(rightString, StringComparison.OrdinalIgnoreCase); + } + } + + return false; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/Format.cs b/src/Sdk/Expressions/Sdk/Functions/Format.cs new file mode 100644 index 000000000..eb5fd0c0c --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/Format.cs @@ -0,0 +1,299 @@ +#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.Globalization; +using System.Linq; +using System.Text; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + public sealed class Format : Function + { + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var format = Parameters[0].Evaluate(context).ConvertToString(); + var index = 0; + var result = new FormatResultBuilder(this, context, CreateMemoryCounter(context)); + while (index < format.Length) + { + var lbrace = format.IndexOf('{', index); + var rbrace = format.IndexOf('}', index); + + // Left brace + if (lbrace >= 0 && (rbrace < 0 || rbrace > lbrace)) + { + // Escaped left brace + if (SafeCharAt(format, lbrace + 1) == '{') + { + result.Append(format.Substring(index, lbrace - index + 1)); + index = lbrace + 2; + } + // Left brace, number, optional format specifiers, right brace + else if (rbrace > lbrace + 1 && + ReadArgIndex(format, lbrace + 1, out Byte argIndex, out Int32 endArgIndex) && + ReadFormatSpecifiers(format, endArgIndex + 1, out String formatSpecifiers, out rbrace)) + { + // Check parameter count + if (argIndex > Parameters.Count - 2) + { + throw new FormatException(ExpressionResources.InvalidFormatArgIndex(format)); + } + + // Append the portion before the left brace + if (lbrace > index) + { + result.Append(format.Substring(index, lbrace - index)); + } + + // Append the arg + result.Append(argIndex, formatSpecifiers); + index = rbrace + 1; + } + else + { + throw new FormatException(ExpressionResources.InvalidFormatString(format)); + } + } + // Right brace + else if (rbrace >= 0) + { + // Escaped right brace + if (SafeCharAt(format, rbrace + 1) == '}') + { + result.Append(format.Substring(index, rbrace - index + 1)); + index = rbrace + 2; + } + else + { + throw new FormatException(ExpressionResources.InvalidFormatString(format)); + } + } + // Last segment + else + { + result.Append(format.Substring(index)); + break; + } + } + + return result.ToString(); + } + + private Boolean ReadArgIndex( + String str, + Int32 startIndex, + out Byte result, + out Int32 endIndex) + { + // Count the number of digits + var length = 0; + while (Char.IsDigit(SafeCharAt(str, startIndex + length))) + { + length++; + } + + // Validate at least one digit + if (length < 1) + { + result = default; + endIndex = default; + return false; + } + + // Parse the number + endIndex = startIndex + length - 1; + return Byte.TryParse(str.Substring(startIndex, length), NumberStyles.None, CultureInfo.InvariantCulture, out result); + } + + private Boolean ReadFormatSpecifiers( + String str, + Int32 startIndex, + out String result, + out Int32 rbrace) + { + // No format specifiers + var c = SafeCharAt(str, startIndex); + if (c == '}') + { + result = String.Empty; + rbrace = startIndex; + return true; + } + + // Validate starts with ":" + if (c != ':') + { + result = default; + rbrace = default; + return false; + } + + // Read the specifiers + var specifiers = new StringBuilder(); + var index = startIndex + 1; + while (true) + { + // Validate not the end of the string + if (index >= str.Length) + { + result = default; + rbrace = default; + return false; + } + + c = str[index]; + + // Not right-brace + if (c != '}') + { + specifiers.Append(c); + index++; + } + // Escaped right-brace + else if (SafeCharAt(str, index + 1) == '}') + { + specifiers.Append('}'); + index += 2; + } + // Closing right-brace + else + { + result = specifiers.ToString(); + rbrace = index; + return true; + } + } + } + + private Char SafeCharAt( + String str, + Int32 index) + { + if (str.Length > index) + { + return str[index]; + } + + return '\0'; + } + + private sealed class FormatResultBuilder + { + internal FormatResultBuilder( + Format node, + EvaluationContext context, + MemoryCounter counter) + { + m_node = node; + m_context = context; + m_counter = counter; + m_cache = new ArgValue[node.Parameters.Count - 1]; + } + + // Build the final string. This is when lazy segments are evaluated. + public override String ToString() + { + return String.Join( + String.Empty, + m_segments.Select(obj => + { + if (obj is Lazy lazy) + { + return lazy.Value; + } + else + { + return obj as String; + } + })); + } + + // Append a static value + internal void Append(String value) + { + if (value?.Length > 0) + { + // Track memory + m_counter.Add(value); + + // Append the segment + m_segments.Add(value); + } + } + + // Append an argument + internal void Append( + Int32 argIndex, + String formatSpecifiers) + { + // Delay execution until the final ToString + m_segments.Add(new Lazy(() => + { + String result; + + // Get the arg from the cache + var argValue = m_cache[argIndex]; + + // Evaluate the arg and cache the result + if (argValue == null) + { + // The evaluation result is required when format specifiers are used. Otherwise the string + // result is required. Go ahead and store both values. Since ConvertToString produces tracing, + // we need to run that now so the tracing appears in order in the log. + var evaluationResult = m_node.Parameters[argIndex + 1].Evaluate(m_context); + var stringResult = evaluationResult.ConvertToString(); + argValue = new ArgValue(evaluationResult, stringResult); + m_cache[argIndex] = argValue; + } + + // No format specifiers + if (String.IsNullOrEmpty(formatSpecifiers)) + { + result = argValue.StringResult; + } + // Invalid + else + { + throw new FormatException(ExpressionResources.InvalidFormatSpecifiers(formatSpecifiers, argValue.EvaluationResult.Kind)); + } + + // Track memory + if (!String.IsNullOrEmpty(result)) + { + m_counter.Add(result); + } + + return result; + })); + } + + private readonly ArgValue[] m_cache; + private readonly EvaluationContext m_context; + private readonly MemoryCounter m_counter; + private readonly Format m_node; + private readonly List m_segments = new List(); + } + + /// + /// Stores an EvaluateResult and the value converted to a String. + /// + private sealed class ArgValue + { + public ArgValue( + EvaluationResult evaluationResult, + String stringResult) + { + EvaluationResult = evaluationResult; + StringResult = stringResult; + } + + public EvaluationResult EvaluationResult { get; } + + public String StringResult { get; } + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/FromJson.cs b/src/Sdk/Expressions/Sdk/Functions/FromJson.cs new file mode 100644 index 000000000..19a4a6b96 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/FromJson.cs @@ -0,0 +1,49 @@ +#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.IO; +using GitHub.Actions.Expressions.Data; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + internal sealed class FromJson : Function + { + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var json = Parameters[0].Evaluate(context).ConvertToString(); + + if (context.Options.StrictJsonParsing) + { + try + { + return JsonParser.Parse(json); + } + catch (System.Text.Json.JsonException ex) + { + throw new System.Text.Json.JsonException($"Error parsing fromJson: {ex.Message}", ex); + } + catch (Exception ex) + { + throw new System.Text.Json.JsonException($"Unexpected error parsing fromJson: {ex.Message}", ex); + } + } + + try + { + using var stringReader = new StringReader(json); + using var jsonReader = new JsonTextReader(stringReader) { DateParseHandling = DateParseHandling.None, FloatParseHandling = FloatParseHandling.Double }; + var token = JToken.ReadFrom(jsonReader); + return token.ToExpressionData(); + } + catch (JsonReaderException ex) + { + throw new JsonReaderException("Error parsing fromJson", ex); + } + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/Join.cs b/src/Sdk/Expressions/Sdk/Functions/Join.cs new file mode 100644 index 000000000..8adcdaa5b --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/Join.cs @@ -0,0 +1,76 @@ +#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.Text; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + internal sealed class Join : Function + { + protected sealed override Boolean TraceFullyExpanded => true; + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var items = Parameters[0].Evaluate(context); + + // Array + if (items.TryGetCollectionInterface(out var collection) && + collection is IReadOnlyArray array && + array.Count > 0) + { + var result = new StringBuilder(); + var memory = new MemoryCounter(this, context.Options.MaxMemory); + + // Append the first item + var item = array[0]; + var itemResult = EvaluationResult.CreateIntermediateResult(context, item); + var itemString = itemResult.ConvertToString(); + memory.Add(itemString); + result.Append(itemString); + + // More items? + if (array.Count > 1) + { + var separator = ","; + if (Parameters.Count > 1) + { + var separatorResult = Parameters[1].Evaluate(context); + if (separatorResult.IsPrimitive) + { + separator = separatorResult.ConvertToString(); + } + } + + for (var i = 1; i < array.Count; i++) + { + // Append the separator + memory.Add(separator); + result.Append(separator); + + // Append the next item + var nextItem = array[i]; + var nextItemResult = EvaluationResult.CreateIntermediateResult(context, nextItem); + var nextItemString = nextItemResult.ConvertToString(); + memory.Add(nextItemString); + result.Append(nextItemString); + } + } + + return result.ToString(); + } + // Primitive + else if (items.IsPrimitive) + { + return items.ConvertToString(); + } + // Otherwise return empty string + else + { + return String.Empty; + } + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/JsonParser.cs b/src/Sdk/Expressions/Sdk/Functions/JsonParser.cs new file mode 100644 index 000000000..d9eb0c0e4 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/JsonParser.cs @@ -0,0 +1,125 @@ +#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.Text; +using System.Text.Json; +using GitHub.Actions.Expressions.Data; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + internal sealed class JsonParser + { + public static ExpressionData Parse(string json) + { + var reader = new Utf8JsonReader( + Encoding.UTF8.GetBytes(json), + new JsonReaderOptions{ + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 100, + }); + + // EOF? + if (!reader.Read()) + { + throw new Exception("Expected at least one JSON token"); + } + + // Read + var result = ReadRecursive(ref reader); + + // Not EOF? + if (reader.Read()) + { + throw new Exception($"Expected end of JSON but encountered '{reader.TokenType}'"); + } + + return result; + } + + private static ExpressionData ReadRecursive(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.StartArray: + return ReadArray(ref reader); + case JsonTokenType.StartObject: + return ReadObject(ref reader); + case JsonTokenType.Null: + return null; + case JsonTokenType.False: + return new BooleanExpressionData(false); + case JsonTokenType.True: + return new BooleanExpressionData(true); + case JsonTokenType.Number: + return new NumberExpressionData(reader.GetDouble()); + case JsonTokenType.String: + return new StringExpressionData(reader.GetString()); + default: + throw new Exception($"Unexpected token type '{reader.TokenType}'"); + } + } + + private static ArrayExpressionData ReadArray(ref Utf8JsonReader reader) + { + var result = new ArrayExpressionData(); + while (reader.Read()) + { + // End array + if (reader.TokenType == JsonTokenType.EndArray) + { + return result; + } + + // Item + result.Add(ReadRecursive(ref reader)); + } + + // EOF + throw new Exception($"Unexpected end of JSON while reading array"); + } + + private static DictionaryExpressionData ReadObject(ref Utf8JsonReader reader) + { + var result = new DictionaryExpressionData(); + while (reader.Read()) + { + var key = null as string; + switch (reader.TokenType) + { + // End object + case JsonTokenType.EndObject: + return result; + + // Property name + case JsonTokenType.PropertyName: + key = reader.GetString(); + break; + + default: + throw new Exception($"Unexpected token type '{reader.TokenType}' while reading object"); + } + + // Value + var value = null as ExpressionData; + if (reader.Read()) + { + value = ReadRecursive(ref reader); + } + else + { + throw new Exception("Unexpected end of JSON when reading object-pair value"); + } + + // Add + if (!result.ContainsKey(key)) + { + result.Add(key, value); + } + } + + // EOF + throw new Exception($"Unexpected end of JSON while reading object"); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/NoOperation.cs b/src/Sdk/Expressions/Sdk/Functions/NoOperation.cs new file mode 100644 index 000000000..f200f015f --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/NoOperation.cs @@ -0,0 +1,20 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + /// + /// Useful when validating an expression + /// + public sealed class NoOperation : Function + { + protected override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + return null; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/StartsWith.cs b/src/Sdk/Expressions/Sdk/Functions/StartsWith.cs new file mode 100644 index 000000000..8f2566454 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/StartsWith.cs @@ -0,0 +1,32 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + internal sealed class StartsWith : Function + { + protected sealed override Boolean TraceFullyExpanded => false; + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + if (left.IsPrimitive) + { + var leftString = left.ConvertToString(); + + var right = Parameters[1].Evaluate(context); + if (right.IsPrimitive) + { + var rightString = right.ConvertToString(); + return leftString.StartsWith(rightString, StringComparison.OrdinalIgnoreCase); + } + } + + return false; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Functions/ToJson.cs b/src/Sdk/Expressions/Sdk/Functions/ToJson.cs new file mode 100644 index 000000000..db28313d2 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Functions/ToJson.cs @@ -0,0 +1,392 @@ +#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; +using System.Collections.Generic; +using System.Text; +using Newtonsoft.Json; + +namespace GitHub.Actions.Expressions.Sdk.Functions +{ + internal sealed class ToJson : Function + { + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var result = new StringBuilder(); + var memory = new MemoryCounter(this, context.Options.MaxMemory); + var current = Parameters[0].Evaluate(context); + var ancestors = new Stack(); + + do + { + // Descend as much as possible + while (true) + { + // Collection + if (current.TryGetCollectionInterface(out Object collection)) + { + // Array + if (collection is IReadOnlyArray array) + { + if (array.Count > 0) + { + // Write array start + WriteArrayStart(result, memory, ancestors); + + // Move to first item + var enumerator = new ArrayEnumerator(context, current, array); + enumerator.MoveNext(); + ancestors.Push(enumerator); + current = enumerator.Current; + } + else + { + // Write empty array + WriteEmptyArray(result, memory, ancestors); + break; + } + } + // Mapping + else if (collection is IReadOnlyObject obj) + { + if (obj.Count > 0) + { + // Write mapping start + WriteMappingStart(result, memory, ancestors); + + // Move to first pair + var enumerator = new ObjectEnumerator(context, current, obj); + enumerator.MoveNext(); + ancestors.Push(enumerator); + + // Write mapping key + WriteMappingKey(context, result, memory, enumerator.Current.Key, ancestors); + + // Move to mapping value + current = enumerator.Current.Value; + } + else + { + // Write empty mapping + WriteEmptyMapping(result, memory, ancestors); + break; + } + } + else + { + throw new NotSupportedException($"Unexpected type '{collection?.GetType().FullName}'"); + } + } + // Not a collection + else + { + // Write value + WriteValue(context, result, memory, current, ancestors); + break; + } + } + + // Next sibling or ancestor sibling + do + { + if (ancestors.Count > 0) + { + var parent = ancestors.Peek(); + + // Parent array + if (parent is ArrayEnumerator arrayEnumerator) + { + // Move to next item + if (arrayEnumerator.MoveNext()) + { + current = arrayEnumerator.Current; + + break; + } + // Move to parent + else + { + ancestors.Pop(); + current = arrayEnumerator.Array; + + // Write array end + WriteArrayEnd(result, memory, ancestors); + } + } + // Parent mapping + else if (parent is ObjectEnumerator objectEnumerator) + { + // Move to next pair + if (objectEnumerator.MoveNext()) + { + // Write mapping key + WriteMappingKey(context, result, memory, objectEnumerator.Current.Key, ancestors); + + // Move to mapping value + current = objectEnumerator.Current.Value; + + break; + } + // Move to parent + else + { + ancestors.Pop(); + current = objectEnumerator.Object; + + // Write mapping end + WriteMappingEnd(result, memory, ancestors); + } + } + else + { + throw new NotSupportedException($"Unexpected type '{parent?.GetType().FullName}'"); + } + } + else + { + current = null; + } + + } while (current != null); + + } while (current != null); + + return result.ToString(); + } + + private void WriteArrayStart( + StringBuilder writer, + MemoryCounter memory, + Stack ancestors) + { + var str = PrefixValue("[", ancestors); + memory.Add(str); + writer.Append(str); + } + + private void WriteMappingStart( + StringBuilder writer, + MemoryCounter memory, + Stack ancestors) + { + var str = PrefixValue("{", ancestors); + memory.Add(str); + writer.Append(str); + } + + private void WriteArrayEnd( + StringBuilder writer, + MemoryCounter memory, + Stack ancestors) + { + var str = $"\n{new String(' ', ancestors.Count * 2)}]"; + memory.Add(str); + writer.Append(str); + } + + private void WriteMappingEnd( + StringBuilder writer, + MemoryCounter memory, + Stack ancestors) + { + var str = $"\n{new String(' ', ancestors.Count * 2)}}}"; + memory.Add(str); + writer.Append(str); + } + + private void WriteEmptyArray( + StringBuilder writer, + MemoryCounter memory, + Stack ancestors) + { + var str = PrefixValue("[]", ancestors); + memory.Add(str); + writer.Append(str); + } + + private void WriteEmptyMapping( + StringBuilder writer, + MemoryCounter memory, + Stack ancestors) + { + var str = PrefixValue("{}", ancestors); + memory.Add(str); + writer.Append(str); + } + + private void WriteMappingKey( + EvaluationContext context, + StringBuilder writer, + MemoryCounter memory, + EvaluationResult key, + Stack ancestors) + { + var str = PrefixValue(JsonConvert.ToString(key.ConvertToString()), ancestors, isMappingKey: true); + memory.Add(str); + writer.Append(str); + } + + private void WriteValue( + EvaluationContext context, + StringBuilder writer, + MemoryCounter memory, + EvaluationResult value, + Stack ancestors) + { + String str; + switch (value.Kind) + { + case ValueKind.Null: + str = "null"; + break; + + case ValueKind.Boolean: + str = (Boolean)value.Value ? "true" : "false"; + break; + + case ValueKind.Number: + str = value.ConvertToString(); + break; + + case ValueKind.String: + str = JsonConvert.ToString(value.Value); + break; + + default: + str = "{}"; // The value is an object we don't know how to traverse + break; + } + + str = PrefixValue(str, ancestors); + memory.Add(str); + writer.Append(str); + } + + private String PrefixValue( + String value, + Stack ancestors, + Boolean isMappingKey = false) + { + var level = ancestors.Count; + var parent = level > 0 ? ancestors.Peek() : null; + + if (!isMappingKey && parent is ObjectEnumerator) + { + return $": {value}"; + } + else if (level > 0) + { + return $"{(parent.IsFirst ? String.Empty : ",")}\n{new String(' ', level * 2)}{value}"; + } + else + { + return value; + } + } + + private interface ICollectionEnumerator : IEnumerator + { + Boolean IsFirst { get; } + } + + private sealed class ArrayEnumerator : ICollectionEnumerator + { + public ArrayEnumerator( + EvaluationContext context, + EvaluationResult result, + IReadOnlyArray array) + { + m_context = context; + m_result = result; + m_enumerator = array.GetEnumerator(); + } + + public EvaluationResult Array => m_result; + + public EvaluationResult Current => m_current; + + Object IEnumerator.Current => m_current; + + public Boolean IsFirst => m_index == 0; + + public Boolean MoveNext() + { + if (m_enumerator.MoveNext()) + { + m_current = EvaluationResult.CreateIntermediateResult(m_context, m_enumerator.Current); + m_index++; + return true; + } + else + { + m_current = null; + return false; + } + } + + public void Reset() + { + throw new NotSupportedException(nameof(Reset)); + } + + private readonly EvaluationContext m_context; + private readonly IEnumerator m_enumerator; + private readonly EvaluationResult m_result; + private EvaluationResult m_current; + private Int32 m_index = -1; + } + + private sealed class ObjectEnumerator : ICollectionEnumerator + { + public ObjectEnumerator( + EvaluationContext context, + EvaluationResult result, + IReadOnlyObject obj) + { + m_context = context; + m_result = result; + m_enumerator = obj.GetEnumerator(); + } + + public KeyValuePair Current => m_current; + + Object IEnumerator.Current => m_current; + + public Boolean IsFirst => m_index == 0; + + public EvaluationResult Object => m_result; + + public Boolean MoveNext() + { + if (m_enumerator.MoveNext()) + { + var current = (KeyValuePair)m_enumerator.Current; + var key = EvaluationResult.CreateIntermediateResult(m_context, current.Key); + var value = EvaluationResult.CreateIntermediateResult(m_context, current.Value); + m_current = new KeyValuePair(key, value); + m_index++; + return true; + } + else + { + m_current = default(KeyValuePair); + return false; + } + } + + public void Reset() + { + throw new NotSupportedException(nameof(Reset)); + } + + private readonly EvaluationContext m_context; + private readonly IEnumerator m_enumerator; + private readonly EvaluationResult m_result; + private KeyValuePair m_current; + private Int32 m_index = -1; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/IBoolean.cs b/src/Sdk/Expressions/Sdk/IBoolean.cs new file mode 100644 index 000000000..28b3de516 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/IBoolean.cs @@ -0,0 +1,9 @@ +using System; + +namespace GitHub.Actions.Expressions.Sdk +{ + public interface IBoolean + { + Boolean GetBoolean(); + } +} diff --git a/src/Sdk/Expressions/Sdk/INull.cs b/src/Sdk/Expressions/Sdk/INull.cs new file mode 100644 index 000000000..ac9cc6f5e --- /dev/null +++ b/src/Sdk/Expressions/Sdk/INull.cs @@ -0,0 +1,7 @@ + +namespace GitHub.Actions.Expressions.Sdk +{ + public interface INull + { + } +} diff --git a/src/Sdk/Expressions/Sdk/INumber.cs b/src/Sdk/Expressions/Sdk/INumber.cs new file mode 100644 index 000000000..ca3a4962a --- /dev/null +++ b/src/Sdk/Expressions/Sdk/INumber.cs @@ -0,0 +1,9 @@ +using System; + +namespace GitHub.Actions.Expressions.Sdk +{ + public interface INumber + { + Double GetNumber(); + } +} diff --git a/src/Sdk/Expressions/Sdk/IReadOnlyArray.cs b/src/Sdk/Expressions/Sdk/IReadOnlyArray.cs new file mode 100644 index 000000000..b76b80589 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/IReadOnlyArray.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections; + +namespace GitHub.Actions.Expressions.Sdk +{ + public interface IReadOnlyArray + { + Int32 Count { get; } + + Object this[Int32 index] { get; } + + IEnumerator GetEnumerator(); + } +} diff --git a/src/Sdk/Expressions/Sdk/IReadOnlyObject.cs b/src/Sdk/Expressions/Sdk/IReadOnlyObject.cs new file mode 100644 index 000000000..e8c4fc3c3 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/IReadOnlyObject.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace GitHub.Actions.Expressions.Sdk +{ + public interface IReadOnlyObject + { + Int32 Count { get; } + + IEnumerable Keys { get; } + + IEnumerable Values { get; } + + Object this[String key] { get; } + + Boolean ContainsKey(String key); + + IEnumerator GetEnumerator(); + + Boolean TryGetValue( + String key, + out Object value); + } +} diff --git a/src/Sdk/Expressions/Sdk/IString.cs b/src/Sdk/Expressions/Sdk/IString.cs new file mode 100644 index 000000000..d9dd3f7a1 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/IString.cs @@ -0,0 +1,9 @@ +using System; + +namespace GitHub.Actions.Expressions.Sdk +{ + public interface IString + { + String GetString(); + } +} diff --git a/src/Sdk/Expressions/Sdk/Literal.cs b/src/Sdk/Expressions/Sdk/Literal.cs new file mode 100644 index 000000000..d01a33be2 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Literal.cs @@ -0,0 +1,43 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk +{ + public sealed class Literal : ExpressionNode + { + public Literal(Object val) + { + Value = ExpressionUtility.ConvertToCanonicalValue(val, out var kind, out _); + Kind = kind; + Name = kind.ToString(); + } + + public ValueKind Kind { get; } + + public Object Value { get; } + + // Prevent the value from being stored on the evaluation context. + // This avoids unneccessarily duplicating the value in memory. + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return ExpressionUtility.FormatValue(null, Value, Kind); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + return ExpressionUtility.FormatValue(null, Value, Kind); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + return Value; + } + } + +} diff --git a/src/Sdk/Expressions/Sdk/MemoryCounter.cs b/src/Sdk/Expressions/Sdk/MemoryCounter.cs new file mode 100644 index 000000000..af53e39f3 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/MemoryCounter.cs @@ -0,0 +1,92 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk +{ + /// + /// Helper class for ExpressionNode authors. This class helps calculate memory overhead for a result object. + /// + public sealed class MemoryCounter + { + internal MemoryCounter( + ExpressionNode node, + Int32? maxBytes) + { + m_node = node; + m_maxBytes = (maxBytes ?? 0) > 0 ? maxBytes.Value : Int32.MaxValue; + } + + public Int32 CurrentBytes => m_currentBytes; + + public void Add(Int32 amount) + { + if (!TryAdd(amount)) + { + throw new InvalidOperationException(ExpressionResources.ExceededAllowedMemory(m_node?.ConvertToExpression())); + } + } + + public void Add(String value) + { + Add(CalculateSize(value)); + } + + public void AddMinObjectSize() + { + Add(MinObjectSize); + } + + public void Remove(String value) + { + m_currentBytes -= CalculateSize(value); + } + + public static Int32 CalculateSize(String value) + { + // This measurement doesn't have to be perfect. + // https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/ + + Int32 bytes; + checked + { + bytes = StringBaseOverhead + ((value?.Length ?? 0) * 2); + } + return bytes; + } + + internal Boolean TryAdd(Int32 amount) + { + try + { + checked + { + amount += m_currentBytes; + } + + if (amount > m_maxBytes) + { + return false; + } + + m_currentBytes = amount; + return true; + } + catch (OverflowException) + { + return false; + } + } + + internal Boolean TryAdd(String value) + { + return TryAdd(CalculateSize(value)); + } + + internal const Int32 MinObjectSize = 24; + internal const Int32 StringBaseOverhead = 26; + private readonly Int32 m_maxBytes; + private readonly ExpressionNode m_node; + private Int32 m_currentBytes; + } +} diff --git a/src/Sdk/Expressions/Sdk/NamedValue.cs b/src/Sdk/Expressions/Sdk/NamedValue.cs new file mode 100644 index 000000000..8c9b15fd0 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/NamedValue.cs @@ -0,0 +1,22 @@ +using System; + +namespace GitHub.Actions.Expressions.Sdk +{ + public abstract class NamedValue : ExpressionNode + { + public sealed override string ConvertToExpression() => Name; + + protected sealed override Boolean TraceFullyExpanded => true; + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return Name; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/NoOperationNamedValue.cs b/src/Sdk/Expressions/Sdk/NoOperationNamedValue.cs new file mode 100644 index 000000000..05db7d0f8 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/NoOperationNamedValue.cs @@ -0,0 +1,20 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk +{ + /// + /// Useful when validating an expression + /// + public sealed class NoOperationNamedValue : NamedValue + { + protected override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + return null; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/And.cs b/src/Sdk/Expressions/Sdk/Operators/And.cs new file mode 100644 index 000000000..e77bf51c0 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/And.cs @@ -0,0 +1,53 @@ +#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.Globalization; +using System.Linq; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class And : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "({0})", + String.Join(" && ", Parameters.Select(x => x.ConvertToExpression()))); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "({0})", + String.Join(" && ", Parameters.Select(x => x.ConvertToExpandedExpression(context)))); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var result = default(EvaluationResult); + foreach (var parameter in Parameters) + { + result = parameter.Evaluate(context); + if (result.IsFalsy) + { + return result.Value; + } + } + + return result?.Value; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/Equal.cs b/src/Sdk/Expressions/Sdk/Operators/Equal.cs new file mode 100644 index 000000000..9f8f95eba --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/Equal.cs @@ -0,0 +1,46 @@ +#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.Globalization; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class Equal : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "({0} == {1})", + Parameters[0].ConvertToExpression(), + Parameters[1].ConvertToExpression()); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "({0} == {1})", + Parameters[0].ConvertToExpandedExpression(context), + Parameters[1].ConvertToExpandedExpression(context)); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + var right = Parameters[1].Evaluate(context); + return left.AbstractEqual(right); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/GreaterThan.cs b/src/Sdk/Expressions/Sdk/Operators/GreaterThan.cs new file mode 100644 index 000000000..23bad5eb6 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/GreaterThan.cs @@ -0,0 +1,46 @@ +#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.Globalization; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class GreaterThan : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "({0} > {1})", + Parameters[0].ConvertToExpression(), + Parameters[1].ConvertToExpression()); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "({0} > {1})", + Parameters[0].ConvertToExpandedExpression(context), + Parameters[1].ConvertToExpandedExpression(context)); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + var right = Parameters[1].Evaluate(context); + return left.AbstractGreaterThan(right); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/GreaterThanOrEqual.cs b/src/Sdk/Expressions/Sdk/Operators/GreaterThanOrEqual.cs new file mode 100644 index 000000000..6eaa9048d --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/GreaterThanOrEqual.cs @@ -0,0 +1,46 @@ +#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.Globalization; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class GreaterThanOrEqual : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "({0} >= {1})", + Parameters[0].ConvertToExpression(), + Parameters[1].ConvertToExpression()); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "({0} >= {1})", + Parameters[0].ConvertToExpandedExpression(context), + Parameters[1].ConvertToExpandedExpression(context)); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + var right = Parameters[1].Evaluate(context); + return left.AbstractGreaterThanOrEqual(right); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/Index.cs b/src/Sdk/Expressions/Sdk/Operators/Index.cs new file mode 100644 index 000000000..0c5ab9f0e --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/Index.cs @@ -0,0 +1,319 @@ +#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; +using System.Collections.Generic; +using System.Globalization; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class Index : Container + { + protected sealed override Boolean TraceFullyExpanded => true; + + public sealed override String ConvertToExpression() + { + // Wildcard - use .* notation + if (Parameters[1] is Wildcard) + { + return String.Format( + CultureInfo.InvariantCulture, + "{0}.*", + Parameters[0].ConvertToExpression()); + } + // Verify if we can simplify the expression, we would rather return + // github.sha then github['sha'] so we check if this is a simple case. + else if (Parameters[1] is Literal literal && + literal.Value is String literalString && + ExpressionUtility.IsLegalKeyword(literalString)) + { + return String.Format( + CultureInfo.InvariantCulture, + "{0}.{1}", + Parameters[0].ConvertToExpression(), + literalString); + } + else + { + return String.Format( + CultureInfo.InvariantCulture, + "{0}[{1}]", + Parameters[0].ConvertToExpression(), + Parameters[1].ConvertToExpression()); + } + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + // Wildcard - use .* notation + if (Parameters[1] is Wildcard) + { + return String.Format( + CultureInfo.InvariantCulture, + "{0}.*", + Parameters[0].ConvertToExpandedExpression(context)); + } + // Verify if we can simplify the expression, we would rather return + // github.sha then github['sha'] so we check if this is a simple case. + else if (Parameters[1] is Literal literal && + literal.Value is String literalString && + ExpressionUtility.IsLegalKeyword(literalString)) + { + return String.Format( + CultureInfo.InvariantCulture, + "{0}.{1}", + Parameters[0].ConvertToExpandedExpression(context), + literalString); + } + else + { + return String.Format( + CultureInfo.InvariantCulture, + "{0}[{1}]", + Parameters[0].ConvertToExpandedExpression(context), + Parameters[1].ConvertToExpandedExpression(context)); + } + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + var left = Parameters[0].Evaluate(context); + + // Not a collection + if (!left.TryGetCollectionInterface(out Object collection)) + { + resultMemory = null; + return Parameters[1] is Wildcard ? new FilteredArray() : null; + } + // Filtered array + else if (collection is FilteredArray filteredArray) + { + return HandleFilteredArray(context, filteredArray, out resultMemory); + } + // Object + else if (collection is IReadOnlyObject obj) + { + return HandleObject(context, obj, out resultMemory); + } + // Array + else if (collection is IReadOnlyArray array) + { + return HandleArray(context, array, out resultMemory); + } + + resultMemory = null; + return null; + } + + private Object HandleFilteredArray( + EvaluationContext context, + FilteredArray filteredArray, + out ResultMemory resultMemory) + { + var result = new FilteredArray(); + var counter = new MemoryCounter(this, context.Options.MaxMemory); + + var index = new IndexHelper(context, Parameters[1]); + + foreach (var item in filteredArray) + { + // Leverage the expression SDK to traverse the object + var itemResult = EvaluationResult.CreateIntermediateResult(context, item); + if (itemResult.TryGetCollectionInterface(out var nestedCollection)) + { + // Apply the index to each child object + if (nestedCollection is IReadOnlyObject nestedObject) + { + // Wildcard + if (index.IsWildcard) + { + foreach (var val in nestedObject.Values) + { + result.Add(val); + counter.Add(IntPtr.Size); + } + } + // String + else if (index.HasStringIndex) + { + if (nestedObject.TryGetValue(index.StringIndex, out var nestedObjectValue)) + { + result.Add(nestedObjectValue); + counter.Add(IntPtr.Size); + } + } + } + // Apply the index to each child array + else if (nestedCollection is IReadOnlyArray nestedArray) + { + // Wildcard + if (index.IsWildcard) + { + foreach (var val in nestedArray) + { + result.Add(val); + counter.Add(IntPtr.Size); + } + } + // String + else if (index.HasIntegerIndex && + index.IntegerIndex < nestedArray.Count) + { + result.Add(nestedArray[index.IntegerIndex]); + counter.Add(IntPtr.Size); + } + } + } + } + + resultMemory = new ResultMemory { Bytes = counter.CurrentBytes }; + return result; + } + + private Object HandleObject( + EvaluationContext context, + IReadOnlyObject obj, + out ResultMemory resultMemory) + { + var index = new IndexHelper(context, Parameters[1]); + + // Wildcard + if (index.IsWildcard) + { + var filteredArray = new FilteredArray(); + var counter = new MemoryCounter(this, context.Options.MaxMemory); + counter.AddMinObjectSize(); + + foreach (var val in obj.Values) + { + filteredArray.Add(val); + counter.Add(IntPtr.Size); + } + + resultMemory = new ResultMemory { Bytes = counter.CurrentBytes }; + return filteredArray; + } + // String + else if (index.HasStringIndex && + obj.TryGetValue(index.StringIndex, out var result)) + { + resultMemory = null; + return result; + } + + resultMemory = null; + return null; + } + + private Object HandleArray( + EvaluationContext context, + IReadOnlyArray array, + out ResultMemory resultMemory) + { + var index = new IndexHelper(context, Parameters[1]); + + // Wildcard + if (index.IsWildcard) + { + var filtered = new FilteredArray(); + var counter = new MemoryCounter(this, context.Options.MaxMemory); + counter.AddMinObjectSize(); + + foreach (var item in array) + { + filtered.Add(item); + counter.Add(IntPtr.Size); + } + + resultMemory = new ResultMemory { Bytes = counter.CurrentBytes }; + return filtered; + } + // Integer + else if (index.HasIntegerIndex && index.IntegerIndex < array.Count) + { + resultMemory = null; + return array[index.IntegerIndex]; + } + + resultMemory = null; + return null; + } + + private class FilteredArray : IReadOnlyArray + { + public FilteredArray() + { + m_list = new List(); + } + + public void Add(Object o) + { + m_list.Add(o); + } + + public Int32 Count => m_list.Count; + + public Object this[Int32 index] => m_list[index]; + + public IEnumerator GetEnumerator() => m_list.GetEnumerator(); + + private readonly IList m_list; + } + + private class IndexHelper + { + public IndexHelper( + EvaluationContext context, + ExpressionNode parameter) + { + m_parameter = parameter; + m_result = parameter.Evaluate(context); + + m_integerIndex = new Lazy(() => + { + var doubleIndex = m_result.ConvertToNumber(); + if (Double.IsNaN(doubleIndex) || doubleIndex < 0d) + { + return null; + } + + doubleIndex = Math.Floor(doubleIndex); + if (doubleIndex > (Double)Int32.MaxValue) + { + return null; + } + + return (Int32)doubleIndex; + }); + + m_stringIndex = new Lazy(() => + { + return m_result.IsPrimitive ? m_result.ConvertToString() : null; + }); + } + + public Boolean HasIntegerIndex => m_integerIndex.Value != null; + + public Boolean HasStringIndex => m_stringIndex.Value != null; + + public Boolean IsWildcard => m_parameter is Wildcard; + + public Int32 IntegerIndex => m_integerIndex.Value ?? default(Int32); + + public String StringIndex => m_stringIndex.Value; + + private readonly ExpressionNode m_parameter; + private readonly EvaluationResult m_result; + private readonly Lazy m_integerIndex; + private readonly Lazy m_stringIndex; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/LessThan.cs b/src/Sdk/Expressions/Sdk/Operators/LessThan.cs new file mode 100644 index 000000000..b49cc5863 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/LessThan.cs @@ -0,0 +1,46 @@ +#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.Globalization; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class LessThan : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "({0} < {1})", + Parameters[0].ConvertToExpression(), + Parameters[1].ConvertToExpression()); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "({0} < {1})", + Parameters[0].ConvertToExpandedExpression(context), + Parameters[1].ConvertToExpandedExpression(context)); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + var right = Parameters[1].Evaluate(context); + return left.AbstractLessThan(right); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/LessThanOrEqual.cs b/src/Sdk/Expressions/Sdk/Operators/LessThanOrEqual.cs new file mode 100644 index 000000000..a6e76b40d --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/LessThanOrEqual.cs @@ -0,0 +1,46 @@ +#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.Globalization; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class LessThanOrEqual : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "({0} <= {1})", + Parameters[0].ConvertToExpression(), + Parameters[1].ConvertToExpression()); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "({0} <= {1})", + Parameters[0].ConvertToExpandedExpression(context), + Parameters[1].ConvertToExpandedExpression(context)); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + var right = Parameters[1].Evaluate(context); + return left.AbstractLessThanOrEqual(right); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/Not.cs b/src/Sdk/Expressions/Sdk/Operators/Not.cs new file mode 100644 index 000000000..b693778cc --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/Not.cs @@ -0,0 +1,43 @@ +#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.Globalization; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class Not : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "!{0}", + Parameters[0].ConvertToExpression()); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "!{0}", + Parameters[0].ConvertToExpandedExpression(context)); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var result = Parameters[0].Evaluate(context); + return result.IsFalsy; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/NotEqual.cs b/src/Sdk/Expressions/Sdk/Operators/NotEqual.cs new file mode 100644 index 000000000..b1e296446 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/NotEqual.cs @@ -0,0 +1,46 @@ +#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.Globalization; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class NotEqual : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "({0} != {1})", + Parameters[0].ConvertToExpression(), + Parameters[1].ConvertToExpression()); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "({0} != {1})", + Parameters[0].ConvertToExpandedExpression(context), + Parameters[1].ConvertToExpandedExpression(context)); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var left = Parameters[0].Evaluate(context); + var right = Parameters[1].Evaluate(context); + return left.AbstractNotEqual(right); + } + } +} diff --git a/src/Sdk/Expressions/Sdk/Operators/Or.cs b/src/Sdk/Expressions/Sdk/Operators/Or.cs new file mode 100644 index 000000000..cfef09cbd --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Operators/Or.cs @@ -0,0 +1,53 @@ +#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.Globalization; +using System.Linq; + +namespace GitHub.Actions.Expressions.Sdk.Operators +{ + internal sealed class Or : Container + { + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return String.Format( + CultureInfo.InvariantCulture, + "({0})", + String.Join(" || ", Parameters.Select(x => x.ConvertToExpression()))); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + // Check if the result was stored + if (context.TryGetTraceResult(this, out String result)) + { + return result; + } + + return String.Format( + CultureInfo.InvariantCulture, + "({0})", + String.Join(" || ", Parameters.Select(x => x.ConvertToExpandedExpression(context)))); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + var result = default(EvaluationResult); + foreach (var parameter in Parameters) + { + result = parameter.Evaluate(context); + if (result.IsTruthy) + { + break; + } + } + + return result?.Value; + } + } +} diff --git a/src/Sdk/Expressions/Sdk/ResultMemory.cs b/src/Sdk/Expressions/Sdk/ResultMemory.cs new file mode 100644 index 000000000..165d59b54 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/ResultMemory.cs @@ -0,0 +1,56 @@ +using System; + +namespace GitHub.Actions.Expressions.Sdk +{ + public class ResultMemory + { + /// + /// Only set a non-null value when both of the following conditions are met: + /// 1) The result is a complex object. In other words, the result is + /// not a simple type: string, boolean, number, or null. + /// 2) The result is a newly created object. + /// + /// + /// For example, consider a function jsonParse() which takes a string parameter, + /// and returns a JToken object. The JToken object is newly created and a rough + /// measurement should be returned for the number of bytes it consumes in memory. + /// + /// + /// + /// For another example, consider a function which returns a sub-object from a + /// complex parameter value. From the perspective of an individual function, + /// the size of the complex parameter value is unknown. In this situation, set the + /// value to IntPtr.Size. + /// + /// + /// + /// When you are unsure, set the value to null. Null indicates the overhead of a + /// new pointer should be accounted for. + /// + /// + public Int32? Bytes { get; set; } + + /// + /// Indicates whether represents the total size of the result. + /// True indicates the accounting-overhead of downstream parameters can be discarded. + /// + /// For , this value is currently ignored. + /// + /// + /// For example, consider a funciton jsonParse() which takes a string paramter, + /// and returns a JToken object. The JToken object is newly created and a rough + /// measurement should be returned for the amount of bytes it consumes in memory. + /// Set the to true, since new object contains no references + /// to previously allocated memory. + /// + /// + /// + /// For another example, consider a function which wraps a complex parameter result. + /// should be set to the amount of newly allocated memory. + /// However since the object references previously allocated memory, set + /// to false. + /// + /// + public Boolean IsTotal { get; set; } + } +} diff --git a/src/Sdk/Expressions/Sdk/Wildcard.cs b/src/Sdk/Expressions/Sdk/Wildcard.cs new file mode 100644 index 000000000..ec408daa3 --- /dev/null +++ b/src/Sdk/Expressions/Sdk/Wildcard.cs @@ -0,0 +1,32 @@ +#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; + +namespace GitHub.Actions.Expressions.Sdk +{ + public sealed class Wildcard : ExpressionNode + { + // Prevent the value from being stored on the evaluation context. + // This avoids unneccessarily duplicating the value in memory. + protected sealed override Boolean TraceFullyExpanded => false; + + public sealed override String ConvertToExpression() + { + return ExpressionConstants.Wildcard.ToString(); + } + + internal sealed override String ConvertToExpandedExpression(EvaluationContext context) + { + return ExpressionConstants.Wildcard.ToString(); + } + + protected sealed override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + return ExpressionConstants.Wildcard.ToString(); + } + } + +} diff --git a/src/Sdk/Expressions/Tokens/Associativity.cs b/src/Sdk/Expressions/Tokens/Associativity.cs new file mode 100644 index 000000000..0fbf8dad7 --- /dev/null +++ b/src/Sdk/Expressions/Tokens/Associativity.cs @@ -0,0 +1,9 @@ +namespace GitHub.Actions.Expressions.Tokens +{ + internal enum Associativity + { + None, + LeftToRight, + RightToLeft, + } +} diff --git a/src/Sdk/Expressions/Tokens/LexicalAnalyzer.cs b/src/Sdk/Expressions/Tokens/LexicalAnalyzer.cs new file mode 100644 index 000000000..af852d953 --- /dev/null +++ b/src/Sdk/Expressions/Tokens/LexicalAnalyzer.cs @@ -0,0 +1,492 @@ +#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 System.Text; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.Expressions.Tokens +{ + internal sealed class LexicalAnalyzer + { + public LexicalAnalyzer(String expression) + { + m_expression = expression; + } + + public IEnumerable UnclosedTokens => m_unclosedTokens; + + public Boolean TryGetNextToken(ref Token token) + { + // Skip whitespace + while (m_index < m_expression.Length && Char.IsWhiteSpace(m_expression[m_index])) + { + m_index++; + } + + // Test end of string + if (m_index >= m_expression.Length) + { + token = null; + return false; + } + + // Read the first character to determine the type of token. + var c = m_expression[m_index]; + switch (c) + { + case ExpressionConstants.StartGroup: // "(" + // Function call + if (m_lastToken?.Kind == TokenKind.Function) + { + token = CreateToken(TokenKind.StartParameters, c, m_index++); + } + // Logical grouping + else + { + token = CreateToken(TokenKind.StartGroup, c, m_index++); + } + break; + case ExpressionConstants.StartIndex: // "[" + token = CreateToken(TokenKind.StartIndex, c, m_index++); + break; + case ExpressionConstants.EndGroup: // ")" + // Function call + if (m_unclosedTokens.FirstOrDefault()?.Kind == TokenKind.StartParameters) // "(" function call + { + token = CreateToken(TokenKind.EndParameters, c, m_index++); + } + // Logical grouping + else + { + token = CreateToken(TokenKind.EndGroup, c, m_index++); + } + break; + case ExpressionConstants.EndIndex: // "]" + token = CreateToken(TokenKind.EndIndex, c, m_index++); + break; + case ExpressionConstants.Separator: // "," + token = CreateToken(TokenKind.Separator, c, m_index++); + break; + case ExpressionConstants.Wildcard: // "*" + token = CreateToken(TokenKind.Wildcard, c, m_index++); + break; + case '\'': + token = ReadStringToken(); + break; + case '!': // "!" and "!=" + case '>': // ">" and ">=" + case '<': // "<" and "<=" + case '=': // "==" + case '&': // "&&" + case '|': // "||" + token = ReadOperator(); + break; + default: + if (c == '.') + { + // Number + if (m_lastToken == null || + m_lastToken.Kind == TokenKind.Separator || // "," + m_lastToken.Kind == TokenKind.StartGroup || // "(" logical grouping + m_lastToken.Kind == TokenKind.StartIndex || // "[" + m_lastToken.Kind == TokenKind.StartParameters || // "(" function call + m_lastToken.Kind == TokenKind.LogicalOperator) // "!", "==", etc + { + token = ReadNumberToken(); + } + // "." + else + { + token = CreateToken(TokenKind.Dereference, c, m_index++); + } + } + else if (c == '-' || c == '+' || (c >= '0' && c <= '9')) + { + token = ReadNumberToken(); + } + else + { + token = ReadKeywordToken(); + } + + break; + } + + m_lastToken = token; + return true; + } + + private Token ReadNumberToken() + { + var startIndex = m_index; + do + { + m_index++; + } + while (m_index < m_expression.Length && (!TestTokenBoundary(m_expression[m_index]) || m_expression[m_index] == '.')); + + var length = m_index - startIndex; + var str = m_expression.Substring(startIndex, length); + var d = ExpressionUtility.ParseNumber(str); + + if (Double.IsNaN(d)) + { + return CreateToken(TokenKind.Unexpected, str, startIndex); + } + + return CreateToken(TokenKind.Number, str, startIndex, d); + } + + private Token ReadKeywordToken() + { + // Read to the end of the keyword. + var startIndex = m_index; + m_index++; // Skip the first char. It is already known to be the start of the keyword. + while (m_index < m_expression.Length && !TestTokenBoundary(m_expression[m_index])) + { + m_index++; + } + + // Test if valid keyword character sequence. + var length = m_index - startIndex; + var str = m_expression.Substring(startIndex, length); + if (ExpressionUtility.IsLegalKeyword(str)) + { + // Test if follows property dereference operator. + if (m_lastToken != null && m_lastToken.Kind == TokenKind.Dereference) + { + return CreateToken(TokenKind.PropertyName, str, startIndex); + } + + // Null + if (str.Equals(ExpressionConstants.Null, StringComparison.Ordinal)) + { + return CreateToken(TokenKind.Null, str, startIndex); + } + // Boolean + else if (str.Equals(ExpressionConstants.True, StringComparison.Ordinal)) + { + return CreateToken(TokenKind.Boolean, str, startIndex, true); + } + else if (str.Equals(ExpressionConstants.False, StringComparison.Ordinal)) + { + return CreateToken(TokenKind.Boolean, str, startIndex, false); + } + // NaN + else if (str.Equals(ExpressionConstants.NaN, StringComparison.Ordinal)) + { + return CreateToken(TokenKind.Number, str, startIndex, Double.NaN); + } + // Infinity + else if (str.Equals(ExpressionConstants.Infinity, StringComparison.Ordinal)) + { + return CreateToken(TokenKind.Number, str, startIndex, Double.PositiveInfinity); + } + + // Lookahead + var tempIndex = m_index; + while (tempIndex < m_expression.Length && Char.IsWhiteSpace(m_expression[tempIndex])) + { + tempIndex++; + } + + // Function + if (tempIndex < m_expression.Length && m_expression[tempIndex] == ExpressionConstants.StartGroup) // "(" + { + return CreateToken(TokenKind.Function, str, startIndex); + } + // Named-value + else + { + return CreateToken(TokenKind.NamedValue, str, startIndex); + } + } + else + { + // Invalid keyword + return CreateToken(TokenKind.Unexpected, str, startIndex); + } + } + + private Token ReadStringToken() + { + var startIndex = m_index; + var c = default(Char); + var closed = false; + var str = new StringBuilder(); + m_index++; // Skip the leading single-quote. + while (m_index < m_expression.Length) + { + c = m_expression[m_index++]; + if (c == '\'') + { + // End of string. + if (m_index >= m_expression.Length || m_expression[m_index] != '\'') + { + closed = true; + break; + } + + // Escaped single quote. + m_index++; + } + + str.Append(c); + } + + var length = m_index - startIndex; + var rawValue = m_expression.Substring(startIndex, length); + if (closed) + { + return CreateToken(TokenKind.String, rawValue, startIndex, str.ToString()); + } + + return CreateToken(TokenKind.Unexpected, rawValue, startIndex); + } + + private Token ReadOperator() + { + var startIndex = m_index; + var raw = default(String); + m_index++; + + // Check for a two-character operator + if (m_index < m_expression.Length) + { + m_index++; + raw = m_expression.Substring(startIndex, 2); + switch (raw) + { + case ExpressionConstants.NotEqual: + case ExpressionConstants.GreaterThanOrEqual: + case ExpressionConstants.LessThanOrEqual: + case ExpressionConstants.Equal: + case ExpressionConstants.And: + case ExpressionConstants.Or: + return CreateToken(TokenKind.LogicalOperator, raw, startIndex); + } + + // Backup + m_index--; + } + + // Check for one-character operator + raw = m_expression.Substring(startIndex, 1); + switch (raw) + { + case ExpressionConstants.Not: + case ExpressionConstants.GreaterThan: + case ExpressionConstants.LessThan: + return CreateToken(TokenKind.LogicalOperator, raw, startIndex); + } + + // Unexpected + while (m_index < m_expression.Length && !TestTokenBoundary(m_expression[m_index])) + { + m_index++; + } + + var length = m_index - startIndex; + raw = m_expression.Substring(startIndex, length); + return CreateToken(TokenKind.Unexpected, raw, startIndex); + } + + private static Boolean TestTokenBoundary(Char c) + { + switch (c) + { + case ExpressionConstants.StartGroup: // "(" + case ExpressionConstants.StartIndex: // "[" + case ExpressionConstants.EndGroup: // ")" + case ExpressionConstants.EndIndex: // "]" + case ExpressionConstants.Separator: // "," + case ExpressionConstants.Dereference: // "." + case '!': // "!" and "!=" + case '>': // ">" and ">=" + case '<': // "<" and "<=" + case '=': // "==" + case '&': // "&&" + case '|': // "||" + return true; + default: + return char.IsWhiteSpace(c); + } + } + + private Token CreateToken( + TokenKind kind, + Char rawValue, + Int32 index, + Object parsedValue = null) + { + return CreateToken(kind, rawValue.ToString(), index, parsedValue); + } + + private Token CreateToken( + TokenKind kind, + String rawValue, + Int32 index, + Object parsedValue = null) + { + // Check whether the current token is legal based on the last token + var legal = false; + switch (kind) + { + case TokenKind.StartGroup: // "(" logical grouping + // Is first or follows "," or "(" or "[" or a logical operator + legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.StartIndex, TokenKind.LogicalOperator); + break; + case TokenKind.StartIndex: // "[" + // Follows ")", "]", "*", a property name, or a named-value + legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.PropertyName, TokenKind.NamedValue); + break; + case TokenKind.StartParameters: // "(" function call + // Follows a function + legal = CheckLastToken(TokenKind.Function); + break; + case TokenKind.EndGroup: // ")" logical grouping + // Follows ")", "]", "*", a literal, a property name, or a named-value + legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue); + break; + case TokenKind.EndIndex: // "]" + // Follows ")", "]", "*", a literal, a property name, or a named-value + legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue); + break; + case TokenKind.EndParameters: // ")" function call + // Follows "(" function call, ")", "]", "*", a literal, a property name, or a named-value + legal = CheckLastToken(TokenKind.StartParameters, TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue); + break; + case TokenKind.Separator: // "," + // Follows ")", "]", "*", a literal, a property name, or a named-value + legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue); + break; + case TokenKind.Dereference: // "." + // Follows ")", "]", "*", a property name, or a named-value + legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.PropertyName, TokenKind.NamedValue); + break; + case TokenKind.Wildcard: // "*" + // Follows "[" or "." + legal = CheckLastToken(TokenKind.StartIndex, TokenKind.Dereference); + break; + case TokenKind.LogicalOperator: // "!", "==", etc + switch (rawValue) + { + case ExpressionConstants.Not: + // Is first or follows "," or "(" or "[" or a logical operator + legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.StartIndex, TokenKind.LogicalOperator); + break; + default: + // Follows ")", "]", "*", a literal, a property name, or a named-value + legal = CheckLastToken(TokenKind.EndGroup, TokenKind.EndParameters, TokenKind.EndIndex, TokenKind.Wildcard, TokenKind.Null, TokenKind.Boolean, TokenKind.Number, TokenKind.String, TokenKind.PropertyName, TokenKind.NamedValue); + break; + } + break; + case TokenKind.Null: + case TokenKind.Boolean: + case TokenKind.Number: + case TokenKind.String: + // Is first or follows "," or "[" or "(" or a logical operator (e.g. "!" or "==" etc) + legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartIndex, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.LogicalOperator); + break; + case TokenKind.PropertyName: + // Follows "." + legal = CheckLastToken(TokenKind.Dereference); + break; + case TokenKind.Function: + // Is first or follows "," or "[" or "(" or a logical operator (e.g. "!" or "==" etc) + legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartIndex, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.LogicalOperator); + break; + case TokenKind.NamedValue: + // Is first or follows "," or "[" or "(" or a logical operator (e.g. "!" or "==" etc) + legal = CheckLastToken(null, TokenKind.Separator, TokenKind.StartIndex, TokenKind.StartGroup, TokenKind.StartParameters, TokenKind.LogicalOperator); + break; + } + + // Illegal + if (!legal) + { + return new Token(TokenKind.Unexpected, rawValue, index); + } + + // Legal so far + var token = new Token(kind, rawValue, index, parsedValue); + + switch (kind) + { + case TokenKind.StartGroup: // "(" logical grouping + case TokenKind.StartIndex: // "[" + case TokenKind.StartParameters: // "(" function call + // Track start token + m_unclosedTokens.Push(token); + break; + + case TokenKind.EndGroup: // ")" logical grouping + // Check inside logical grouping + if (m_unclosedTokens.FirstOrDefault()?.Kind != TokenKind.StartGroup) + { + return new Token(TokenKind.Unexpected, rawValue, index); + } + + // Pop start token + m_unclosedTokens.Pop(); + break; + + case TokenKind.EndIndex: // "]" + // Check inside indexer + if (m_unclosedTokens.FirstOrDefault()?.Kind != TokenKind.StartIndex) + { + return new Token(TokenKind.Unexpected, rawValue, index); + } + + // Pop start token + m_unclosedTokens.Pop(); + break; + + case TokenKind.EndParameters: // ")" function call + // Check inside function call + if (m_unclosedTokens.FirstOrDefault()?.Kind != TokenKind.StartParameters) + { + return new Token(TokenKind.Unexpected, rawValue, index); + } + + // Pop start token + m_unclosedTokens.Pop(); + break; + + case TokenKind.Separator: // "," + // Check inside function call + if (m_unclosedTokens.FirstOrDefault()?.Kind != TokenKind.StartParameters) + { + return new Token(TokenKind.Unexpected, rawValue, index); + } + break; + } + + return token; + } + + /// + /// Checks whether the last token kind is in the array of allowed kinds. + /// + private Boolean CheckLastToken(params TokenKind?[] allowed) + { + var lastKind = m_lastToken?.Kind; + foreach (var kind in allowed) + { + if (kind == lastKind) + { + return true; + } + } + + return false; + } + + private readonly String m_expression; // Raw expression string + private readonly Stack m_unclosedTokens = new Stack(); // Unclosed start tokens + private Int32 m_index; // Index of raw expression string + private Token m_lastToken; + } +} diff --git a/src/Sdk/Expressions/Tokens/Token.cs b/src/Sdk/Expressions/Tokens/Token.cs new file mode 100644 index 000000000..f6c3edadb --- /dev/null +++ b/src/Sdk/Expressions/Tokens/Token.cs @@ -0,0 +1,211 @@ +#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.Sdk; +using GitHub.Actions.Expressions.Sdk.Operators; + +namespace GitHub.Actions.Expressions.Tokens +{ + internal sealed class Token + { + public Token( + TokenKind kind, + String rawValue, + Int32 index, + Object parsedValue = null) + { + Kind = kind; + RawValue = rawValue; + Index = index; + ParsedValue = parsedValue; + } + + public TokenKind Kind { get; } + + public String RawValue { get; } + + public Int32 Index { get; } + + public Object ParsedValue { get; } + + public Associativity Associativity + { + get + { + switch (Kind) + { + case TokenKind.StartGroup: + return Associativity.None; + case TokenKind.LogicalOperator: + switch (RawValue) + { + case ExpressionConstants.Not: // "!" + return Associativity.RightToLeft; + } + break; + } + + return IsOperator ? Associativity.LeftToRight : Associativity.None; + } + } + + public Boolean IsOperator + { + get + { + switch (Kind) + { + case TokenKind.StartGroup: // "(" logical grouping + case TokenKind.StartIndex: // "[" + case TokenKind.StartParameters: // "(" function call + case TokenKind.EndGroup: // ")" logical grouping + case TokenKind.EndIndex: // "]" + case TokenKind.EndParameters: // ")" function call + case TokenKind.Separator: // "," + case TokenKind.Dereference: // "." + case TokenKind.LogicalOperator: // "!", "==", etc + return true; + default: + return false; + } + } + } + + /// + /// Operator precedence. The value is only meaningful for operator tokens. + /// + public Int32 Precedence + { + get + { + switch (Kind) + { + case TokenKind.StartGroup: // "(" logical grouping + return 20; + case TokenKind.StartIndex: // "[" + case TokenKind.StartParameters: // "(" function call + case TokenKind.Dereference: // "." + return 19; + case TokenKind.LogicalOperator: + switch (RawValue) + { + case ExpressionConstants.Not: // "!" + return 16; + case ExpressionConstants.GreaterThan: // ">" + case ExpressionConstants.GreaterThanOrEqual:// ">=" + case ExpressionConstants.LessThan: // "<" + case ExpressionConstants.LessThanOrEqual: // "<=" + return 11; + case ExpressionConstants.Equal: // "==" + case ExpressionConstants.NotEqual: // "!=" + return 10; + case ExpressionConstants.And: // "&&" + return 6; + case ExpressionConstants.Or: // "||" + return 5; + } + break; + case TokenKind.EndGroup: // ")" logical grouping + case TokenKind.EndIndex: // "]" + case TokenKind.EndParameters: // ")" function call + case TokenKind.Separator: // "," + return 1; + } + + return 0; + } + } + + /// + /// Expected number of operands. The value is only meaningful for standalone unary operators and binary operators. + /// + public Int32 OperandCount + { + get + { + switch (Kind) + { + case TokenKind.StartIndex: // "[" + case TokenKind.Dereference: // "." + return 2; + case TokenKind.LogicalOperator: + switch (RawValue) + { + case ExpressionConstants.Not: // "!" + return 1; + case ExpressionConstants.GreaterThan: // ">" + case ExpressionConstants.GreaterThanOrEqual:// ">=" + case ExpressionConstants.LessThan: // "<" + case ExpressionConstants.LessThanOrEqual: // "<=" + case ExpressionConstants.Equal: // "==" + case ExpressionConstants.NotEqual: // "!=" + case ExpressionConstants.And: // "&&" + case ExpressionConstants.Or: // "|" + return 2; + } + break; + } + + return 0; + } + } + + public ExpressionNode ToNode() + { + switch (Kind) + { + case TokenKind.StartIndex: // "[" + case TokenKind.Dereference: // "." + return new Sdk.Operators.Index(); + + case TokenKind.LogicalOperator: + switch (RawValue) + { + case ExpressionConstants.Not: // "!" + return new Not(); + + case ExpressionConstants.NotEqual: // "!=" + return new NotEqual(); + + case ExpressionConstants.GreaterThan: // ">" + return new GreaterThan(); + + case ExpressionConstants.GreaterThanOrEqual:// ">=" + return new GreaterThanOrEqual(); + + case ExpressionConstants.LessThan: // "<" + return new LessThan(); + + case ExpressionConstants.LessThanOrEqual: // "<=" + return new LessThanOrEqual(); + + case ExpressionConstants.Equal: // "==" + return new Equal(); + + case ExpressionConstants.And: // "&&" + return new And(); + + case ExpressionConstants.Or: // "||" + return new Or(); + + default: + throw new NotSupportedException($"Unexpected logical operator '{RawValue}' when creating node"); + } + + case TokenKind.Null: + case TokenKind.Boolean: + case TokenKind.Number: + case TokenKind.String: + return new Literal(ParsedValue); + + case TokenKind.PropertyName: + return new Literal(RawValue); + + case TokenKind.Wildcard: // "*" + return new Wildcard(); + } + + throw new NotSupportedException($"Unexpected kind '{Kind}' when creating node"); + } + } +} diff --git a/src/Sdk/Expressions/Tokens/TokenKind.cs b/src/Sdk/Expressions/Tokens/TokenKind.cs new file mode 100644 index 000000000..6dfc9b49e --- /dev/null +++ b/src/Sdk/Expressions/Tokens/TokenKind.cs @@ -0,0 +1,30 @@ +using System; + +namespace GitHub.Actions.Expressions.Tokens +{ + internal enum TokenKind + { + // Punctuation + StartGroup, // "(" logical grouping + StartIndex, // "[" + StartParameters, // "(" function call + EndGroup, // ")" logical grouping + EndIndex, // "]" + EndParameters, // ")" function call + Separator, // "," + Dereference, // "." + Wildcard, // "*" + LogicalOperator, // "!", "==", etc + + // Values + Null, + Boolean, + Number, + String, + PropertyName, + Function, + NamedValue, + + Unexpected, + } +} diff --git a/src/Sdk/Expressions/ValueKind.cs b/src/Sdk/Expressions/ValueKind.cs new file mode 100644 index 000000000..77eb1e167 --- /dev/null +++ b/src/Sdk/Expressions/ValueKind.cs @@ -0,0 +1,14 @@ +using System; + +namespace GitHub.Actions.Expressions +{ + public enum ValueKind + { + Array, + Boolean, + Null, + Number, + Object, + String, + } +} diff --git a/src/Sdk/Sdk.csproj b/src/Sdk/Sdk.csproj index 53ee32e8c..1cfcf2618 100644 --- a/src/Sdk/Sdk.csproj +++ b/src/Sdk/Sdk.csproj @@ -9,7 +9,7 @@ NU1701;NU1603;SYSLIB0050;SYSLIB0051 $(Version) TRACE - 8.0 + 11.0 true @@ -33,5 +33,8 @@ GitHub.DistributedTask.Pipelines.ObjectTemplating.workflow-v1.0.json + + GitHub.Actions.WorkflowParser.workflow-v1.0.json + diff --git a/src/Sdk/WorkflowParser/ActionStep.cs b/src/Sdk/WorkflowParser/ActionStep.cs new file mode 100644 index 000000000..5764c5108 --- /dev/null +++ b/src/Sdk/WorkflowParser/ActionStep.cs @@ -0,0 +1,85 @@ +#nullable enable + +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public sealed class ActionStep : IStep + { + [DataMember(Order = 0, Name = "id", EmitDefaultValue = false)] + public string? Id + { + get; + set; + } + + /// + /// Gets or sets the display name + /// + [DataMember(Order = 1, Name = "name", EmitDefaultValue = false)] + public ScalarToken? Name + { + get; + set; + } + + [DataMember(Order = 2, Name = "if", EmitDefaultValue = false)] + public BasicExpressionToken? If + { + get; + set; + } + + [DataMember(Order = 3, Name = "continue-on-error", EmitDefaultValue = false)] + public ScalarToken? ContinueOnError + { + get; + set; + } + + [DataMember(Order = 4, Name = "timeout-minutes", EmitDefaultValue = false)] + public ScalarToken? TimeoutMinutes + { + get; + set; + } + + [DataMember(Order = 5, Name = "env", EmitDefaultValue = false)] + public TemplateToken? Env + { + get; + set; + } + + [DataMember(Order = 6, Name = "uses", EmitDefaultValue = false)] + public StringToken? Uses + { + get; + set; + } + + [DataMember(Order = 7, Name = "with", EmitDefaultValue = false)] + public TemplateToken? With + { + get; + set; + } + + public IStep Clone(bool omitSource) + { + return new ActionStep + { + ContinueOnError = ContinueOnError?.Clone(omitSource) as ScalarToken, + Env = Env?.Clone(omitSource), + Id = Id, + If = If?.Clone(omitSource) as BasicExpressionToken, + Name = Name?.Clone(omitSource) as ScalarToken, + TimeoutMinutes = TimeoutMinutes?.Clone(omitSource) as ScalarToken, + Uses = Uses?.Clone(omitSource) as StringToken, + With = With?.Clone(omitSource), + }; + } + } +} diff --git a/src/Sdk/WorkflowParser/ActionsEnvironmentReference.cs b/src/Sdk/WorkflowParser/ActionsEnvironmentReference.cs new file mode 100644 index 000000000..9f2e8b862 --- /dev/null +++ b/src/Sdk/WorkflowParser/ActionsEnvironmentReference.cs @@ -0,0 +1,25 @@ +#nullable enable + +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + /// + /// Information about an environment parsed from YML with evaluated name, URL will be evaluated on runner + /// + [DataContract] + public sealed class ActionsEnvironmentReference + { + public ActionsEnvironmentReference(string name) + { + Name = name; + } + + [DataMember] + public string Name { get; set; } + + [DataMember] + public TemplateToken? Url { get; set; } + } +} diff --git a/src/Sdk/WorkflowParser/CollectionsExtensions.cs b/src/Sdk/WorkflowParser/CollectionsExtensions.cs new file mode 100644 index 000000000..187a5b898 --- /dev/null +++ b/src/Sdk/WorkflowParser/CollectionsExtensions.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace GitHub.Actions.WorkflowParser +{ + internal static class CollectionsExtensions + { + /// + /// Adds all of the given values to this collection. + /// Can be used with dictionaries, which implement and where T is . + /// + public static TCollection AddRange(this TCollection collection, IEnumerable values) + where TCollection : ICollection + { + foreach (var value in values) + { + collection.Add(value); + } + + return collection; + } + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/EmptyServerTraceWriter.cs b/src/Sdk/WorkflowParser/Conversion/EmptyServerTraceWriter.cs new file mode 100644 index 000000000..95d83dbd1 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/EmptyServerTraceWriter.cs @@ -0,0 +1,14 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal sealed class EmptyServerTraceWriter : IServerTraceWriter + { + public void TraceAlways( + Int32 tracepoint, + String format, + params Object[] arguments) + { + } + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/IdBuilder.cs b/src/Sdk/WorkflowParser/Conversion/IdBuilder.cs new file mode 100644 index 000000000..bdc82e83f --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/IdBuilder.cs @@ -0,0 +1,183 @@ +#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.Globalization; +using System.Text; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + /// + /// Builder for job and step IDs + /// + internal sealed class IdBuilder + { + internal void AppendSegment(String value) + { + if (String.IsNullOrEmpty(value)) + { + return; + } + + if (m_name.Length == 0) + { + var first = value[0]; + if ((first >= 'a' && first <= 'z') || + (first >= 'A' && first <= 'Z') || + first == '_') + { + // Legal first char + } + else if ((first >= '0' && first <= '9') || first == '-') + { + // Illegal first char, but legal char. + // Prepend "_". + m_name.Append("_"); + } + else + { + // Illegal char + } + } + else + { + // Separator + m_name.Append(c_separator); + } + + foreach (var c in value) + { + if ((c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '_' || + c == '-') + { + // Legal + m_name.Append(c); + } + else + { + // Illegal + m_name.Append("_"); + } + } + } + + /// + /// Builds the ID from the segments + /// + /// When true, generated IDs may begin with "__" depending upon the segments + /// and collisions with known IDs. When false, generated IDs will never begin with the reserved prefix "__". + /// The maximum length of the generated ID. + internal String Build( + Boolean allowReservedPrefix, + Int32 maxLength = WorkflowConstants.MaxNodeNameLength) + { + // Ensure reasonable max length + if (maxLength <= 5) // Must be long enough to accommodate at least one character + length of max suffix "_999" (refer suffix logic further below) + { + maxLength = WorkflowConstants.MaxNodeNameLength; + } + + var original = m_name.Length > 0 ? m_name.ToString() : "job"; + + // Avoid prefix "__" when not allowed + if (!allowReservedPrefix && original.StartsWith("__", StringComparison.Ordinal)) + { + original = $"_{original.TrimStart('_')}"; + } + + var attempt = 1; + var suffix = default(String); + while (true) + { + if (attempt == 1) + { + suffix = String.Empty; + } + else if (attempt < 1000) + { + // Special case to avoid prefix "__" when not allowed + if (!allowReservedPrefix && String.Equals(original, "_", StringComparison.Ordinal)) + { + suffix = String.Format(CultureInfo.InvariantCulture, "{0}", attempt); + } + else + { + suffix = String.Format(CultureInfo.InvariantCulture, "_{0}", attempt); + } + } + else + { + throw new InvalidOperationException("Unable to create a unique name"); + } + + var candidate = original.Substring(0, Math.Min(original.Length, maxLength - suffix.Length)) + suffix; + + if (m_distinctNames.Add(candidate)) + { + m_name.Clear(); + return candidate; + } + + attempt++; + } + } + + internal Boolean TryAddKnownId( + String value, + out String error) + { + if (String.IsNullOrEmpty(value) || + !IsValid(value) || + value.Length >= WorkflowConstants.MaxNodeNameLength) + { + error = $"The identifier '{value}' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and must be less than {WorkflowConstants.MaxNodeNameLength} characters."; + return false; + } + else if (value.StartsWith("__", StringComparison.Ordinal)) + { + error = $"The identifier '{value}' is invalid. IDs starting with '__' are reserved."; + return false; + } + else if (!m_distinctNames.Add(value)) + { + error = $"The identifier '{value}' may not be used more than once within the same scope."; + return false; + } + else + { + error = null; + return true; + } + } + + private static Boolean IsValid(String name) + { + var result = true; + for (Int32 i = 0; i < name.Length; i++) + { + if ((name[i] >= 'a' && name[i] <= 'z') || + (name[i] >= 'A' && name[i] <= 'Z') || + (name[i] >= '0' && name[i] <= '9' && i > 0) || + (name[i] == '_') || + (name[i] == '-' && i > 0)) + { + continue; + } + else + { + result = false; + break; + } + } + + return result; + } + + private const String c_separator = "_"; + private readonly HashSet m_distinctNames = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly StringBuilder m_name = new StringBuilder(); + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/JobCountValidator.cs b/src/Sdk/WorkflowParser/Conversion/JobCountValidator.cs new file mode 100644 index 000000000..7e4001294 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/JobCountValidator.cs @@ -0,0 +1,44 @@ +#nullable enable + +using System; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal sealed class JobCountValidator + { + public JobCountValidator( + TemplateContext context, + Int32 maxCount) + { + m_context = context ?? throw new ArgumentNullException(nameof(context)); + m_maxCount = maxCount; + } + + /// + /// Increments the job counter. + /// + /// Appends an error to the template context only when the max job count is initially exceeded. + /// Additional calls will not append more errors. + /// + /// The token to use for error reporting. + public void Increment(TemplateToken? token) + { + // Initial breach? + if (m_maxCount > 0 && + m_count + 1 > m_maxCount && + m_count <= m_maxCount) + { + m_context.Error(token, $"Workflows may not contain more than {m_maxCount} jobs across all referenced files"); + } + + // Increment + m_count++; + } + + private readonly TemplateContext m_context; + private readonly Int32 m_maxCount; + private Int32 m_count; + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/JobNameBuilder.cs b/src/Sdk/WorkflowParser/Conversion/JobNameBuilder.cs new file mode 100644 index 000000000..f3c9c181a --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/JobNameBuilder.cs @@ -0,0 +1,64 @@ +#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.Globalization; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + /// + /// Builder for job display names. Used when appending strategy configuration values to build a display name. + /// + internal sealed class JobNameBuilder + { + public JobNameBuilder(String jobName) + { + if (!String.IsNullOrEmpty(jobName)) + { + m_jobName = jobName; + m_segments = new List(); + } + } + + public void AppendSegment(String value) + { + if (String.IsNullOrEmpty(value) || m_segments == null) + { + return; + } + + m_segments.Add(value); + } + + public String Build() + { + if (String.IsNullOrEmpty(m_jobName)) + { + return null; + } + + var name = default(String); + if (m_segments.Count == 0) + { + name = m_jobName; + } + else + { + var joinedSegments = String.Join(", ", m_segments); + name = String.Format(CultureInfo.InvariantCulture, "{0} ({1})", m_jobName, joinedSegments); + } + + const Int32 maxNameLength = 100; + if (name.Length > maxNameLength) + { + name = name.Substring(0, maxNameLength - 3) + "..."; + } + + m_segments.Clear(); + return name; + } + + private readonly String m_jobName; + private readonly List m_segments; + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/JsonObjectReader.cs b/src/Sdk/WorkflowParser/Conversion/JsonObjectReader.cs new file mode 100644 index 000000000..b21734c05 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/JsonObjectReader.cs @@ -0,0 +1,236 @@ +#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 GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal sealed class JsonObjectReader : IObjectReader + { + internal JsonObjectReader( + Int32? fileId, + String input) + { + m_fileId = fileId; + var token = JToken.Parse(input); + m_enumerator = GetEvents(token, true).GetEnumerator(); + m_enumerator.MoveNext(); + } + + public Boolean AllowLiteral(out LiteralToken literal) + { + var current = m_enumerator.Current; + switch (current.Type) + { + case ParseEventType.Null: + literal = new NullToken(m_fileId, current.Line, current.Column); + m_enumerator.MoveNext(); + return true; + + case ParseEventType.Boolean: + literal = new BooleanToken(m_fileId, current.Line, current.Column, (Boolean)current.Value); + m_enumerator.MoveNext(); + return true; + + case ParseEventType.Number: + literal = new NumberToken(m_fileId, current.Line, current.Column, (Double)current.Value); + m_enumerator.MoveNext(); + return true; + + case ParseEventType.String: + literal = new StringToken(m_fileId, current.Line, current.Column, (String)current.Value); + m_enumerator.MoveNext(); + return true; + } + + literal = null; + return false; + } + + public Boolean AllowSequenceStart(out SequenceToken sequence) + { + var current = m_enumerator.Current; + if (current.Type == ParseEventType.SequenceStart) + { + sequence = new SequenceToken(m_fileId, current.Line, current.Column); + m_enumerator.MoveNext(); + return true; + } + + sequence = null; + return false; + } + + public Boolean AllowSequenceEnd() + { + if (m_enumerator.Current.Type == ParseEventType.SequenceEnd) + { + m_enumerator.MoveNext(); + return true; + } + + return false; + } + + public Boolean AllowMappingStart(out MappingToken mapping) + { + var current = m_enumerator.Current; + if (current.Type == ParseEventType.MappingStart) + { + mapping = new MappingToken(m_fileId, current.Line, current.Column); + m_enumerator.MoveNext(); + return true; + } + + mapping = null; + return false; + } + + public Boolean AllowMappingEnd() + { + if (m_enumerator.Current.Type == ParseEventType.MappingEnd) + { + m_enumerator.MoveNext(); + return true; + } + + return false; + } + + /// + /// Consumes the last parsing events, which are expected to be DocumentEnd and StreamEnd. + /// + public void ValidateEnd() + { + if (m_enumerator.Current.Type == ParseEventType.DocumentEnd) + { + m_enumerator.MoveNext(); + return; + } + + throw new InvalidOperationException("Expected end of reader"); + } + + /// + /// Consumes the first parsing events, which are expected to be StreamStart and DocumentStart. + /// + public void ValidateStart() + { + if (m_enumerator.Current.Type == ParseEventType.DocumentStart) + { + m_enumerator.MoveNext(); + return; + } + + throw new InvalidOperationException("Expected start of reader"); + } + + private IEnumerable GetEvents( + JToken token, + Boolean root = false) + { + if (root) + { + yield return new ParseEvent(0, 0, ParseEventType.DocumentStart); + } + + var lineInfo = token as Newtonsoft.Json.IJsonLineInfo; + var line = lineInfo.LineNumber; + var column = lineInfo.LinePosition; + + switch (token.Type) + { + case JTokenType.Null: + yield return new ParseEvent(line, column, ParseEventType.Null, null); + break; + + case JTokenType.Boolean: + yield return new ParseEvent(line, column, ParseEventType.Boolean, token.ToObject()); + break; + + case JTokenType.Float: + case JTokenType.Integer: + yield return new ParseEvent(line, column, ParseEventType.Number, token.ToObject()); + break; + + case JTokenType.String: + yield return new ParseEvent(line, column, ParseEventType.String, token.ToObject()); + break; + + case JTokenType.Array: + yield return new ParseEvent(line, column, ParseEventType.SequenceStart); + foreach (var item in (token as JArray)) + { + foreach (var e in GetEvents(item)) + { + yield return e; + } + } + yield return new ParseEvent(line, column, ParseEventType.SequenceEnd); + break; + + case JTokenType.Object: + yield return new ParseEvent(line, column, ParseEventType.MappingStart); + foreach (var pair in (token as JObject)) + { + yield return new ParseEvent(line, column, ParseEventType.String, pair.Key ?? String.Empty); + foreach (var e in GetEvents(pair.Value)) + { + yield return e; + } + } + yield return new ParseEvent(line, column, ParseEventType.MappingEnd); + break; + + default: + throw new NotSupportedException($"Unexpected JTokenType {token.Type}"); + } + + if (root) + { + yield return new ParseEvent(0, 0, ParseEventType.DocumentEnd); + } + } + + private struct ParseEvent + { + public ParseEvent( + Int32 line, + Int32 column, + ParseEventType type, + Object value = null) + { + Line = line; + Column = column; + Type = type; + Value = value; + } + + public readonly Int32 Line; + public readonly Int32 Column; + public readonly ParseEventType Type; + public readonly Object Value; + } + + private enum ParseEventType + { + None = 0, + Null, + Boolean, + Number, + String, + SequenceStart, + SequenceEnd, + MappingStart, + MappingEnd, + DocumentStart, + DocumentEnd, + } + + private IEnumerator m_enumerator; + private Int32? m_fileId; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/Conversion/MatrixBuilder.cs b/src/Sdk/WorkflowParser/Conversion/MatrixBuilder.cs new file mode 100644 index 000000000..285757cae --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/MatrixBuilder.cs @@ -0,0 +1,738 @@ +#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 GitHub.Actions.Expressions; +using GitHub.Actions.Expressions.Data; +using GitHub.Actions.Expressions.Sdk; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + /// + /// Used to build a matrix cross product and apply include/exclude filters. + /// + internal sealed class MatrixBuilder + { + internal MatrixBuilder( + TemplateContext context, + String jobName) + { + m_context = context; + m_jobName = jobName; + } + + /// + /// Adds an input vector. creates a cross product from all input vectors. + /// + /// For example, given the matrix: + /// arch: [x64, x86] + /// os: [linux, windows] + /// + /// This method should be called twice: + /// AddVector("arch", ...); + /// AddVector("os", ...) + /// + internal void AddVector( + String name, + SequenceToken vector) + { + m_vectors.Add(name, vector.ToExpressionData()); + } + + /// + /// Adds the sequence containg all exclude mappings. + /// + internal void Exclude(SequenceToken exclude) + { + m_excludeSequence = exclude; + } + + /// + /// Adds the sequence containg all include mappings. + /// + internal void Include(SequenceToken include) + { + m_includeSequence = include; + } + + /// + /// Builds the matrix. + /// + /// In addition to computing the cross product of all input vectors, this method also: + /// 1. Applies all exclude filters against each cross product vector + /// 2. Applies all include filters against each cross product vector, which may + /// add additional values into existing vectors + /// 3. Appends all unmatched include vectors, as additional result vectors + /// + /// Example 1, simple cross product: + /// arch: [x64, x86] + /// os: [linux, windows] + /// The result would contain the following vectors: + /// [arch: x64, os: linux] + /// [arch: x64, os: windows] + /// [arch: x86, os: linux] + /// [arch: x86, os: windows] + /// + /// Example 2, using exclude filter: + /// arch: [x64, x86] + /// os: [linux, windows] + /// exclude: + /// - arch: x86 + /// os: linux + /// The result would contain the following vectors: + /// [arch: x64, os: linux] + /// [arch: x64, os: windows] + /// [arch: x86, os: windows] + /// + /// Example 3, using include filter to add additional values: + /// arch: [x64, x86] + /// os: [linux, windows] + /// include: + /// - arch: x64 + /// os: linux + /// publish: true + /// The result would contain the following vectors: + /// [arch: x64, os: linux, publish: true] + /// [arch: x64, os: windows] + /// [arch: x86, os: linux] + /// [arch: x86, os: windows] + /// + /// Example 4, include additional vectors: + /// arch: [x64, x86] + /// os: [linux, windows] + /// include: + /// - arch: x64 + /// - os: macos + /// The result would contain the following vectors: + /// [arch: x64, os: linux] + /// [arch: x64, os: windows] + /// [arch: x86, os: linux] + /// [arch: x86, os: windows] + /// [arch: x64, os: macos] + /// + /// One strategy configuration per result vector + internal IEnumerable Build() + { + // Parse includes/excludes + var include = new MatrixInclude(m_context, m_vectors, m_includeSequence); + var exclude = new MatrixExclude(m_context, m_vectors, m_excludeSequence); + + // Calculate the cross product size + int productSize; + if (m_vectors.Count > 0) + { + productSize = 1; + foreach (var vectorPair in m_vectors) + { + checked + { + var vector = vectorPair.Value.AssertArray("vector"); + productSize *= vector.Count; + } + } + } + else + { + productSize = 0; + } + + var idBuilder = new IdBuilder(); + + // Cross product vectors + for (var productIndex = 0; productIndex < productSize; productIndex++) + { + // Matrix + var matrix = new DictionaryExpressionData(); + var blockSize = productSize; + foreach (var vectorPair in m_vectors) + { + var vectorName = vectorPair.Key; + var vector = vectorPair.Value.AssertArray("vector"); + blockSize = blockSize / vector.Count; + var vectorIndex = (productIndex / blockSize) % vector.Count; + matrix.Add(vectorName, vector[vectorIndex]); + } + + // Exclude + if (exclude.Match(matrix)) + { + continue; + } + + // Include extra values in the vector + include.Match(matrix, out var extra); + + // Create the configuration + yield return CreateConfiguration(idBuilder, matrix, extra); + } + + // Explicit vectors + foreach (var matrix in include.GetUnmatchedVectors()) + { + yield return CreateConfiguration(idBuilder, matrix, null); + } + } + + private StrategyConfiguration CreateConfiguration( + IdBuilder idBuilder, + DictionaryExpressionData matrix, + DictionaryExpressionData extra) + { + // New configuration + var configuration = new StrategyConfiguration(); + m_context.Memory.AddBytes(TemplateMemory.MinObjectSize); + + // Gather segments for ID and display name + var nameBuilder = new JobNameBuilder(m_jobName); + foreach (var matrixData in matrix.Traverse(omitKeys: true)) + { + var segment = default(String); + if (matrixData is BooleanExpressionData || matrixData is NumberExpressionData || matrixData is StringExpressionData) + { + segment = matrixData.ToString(); + } + + if (!String.IsNullOrEmpty(segment)) + { + // ID segment + idBuilder.AppendSegment(segment); + + // Display name segment + nameBuilder.AppendSegment(segment); + } + } + + // Id + configuration.Id = idBuilder.Build(allowReservedPrefix: false, maxLength: m_context.GetFeatures().ShortMatrixIds ? 25 : WorkflowConstants.MaxNodeNameLength); + m_context.Memory.AddBytes(configuration.Id); + + // Display name + configuration.Name = nameBuilder.Build(); + m_context.Memory.AddBytes(configuration.Name); + + // Extra values + if (extra?.Count > 0) + { + matrix.Add(extra); + } + + // Matrix context + configuration.ExpressionData.Add(WorkflowTemplateConstants.Matrix, matrix); + m_context.Memory.AddBytes(WorkflowTemplateConstants.Matrix); + m_context.Memory.AddBytes(matrix, traverse: true); + + return configuration; + } + + /// + /// Represents the sequence "strategy.matrix.include" + /// + private sealed class MatrixInclude + { + public MatrixInclude( + TemplateContext context, + DictionaryExpressionData vectors, + SequenceToken includeSequence) + { + // Convert to includes sets + if (includeSequence?.Count > 0) + { + foreach (var includeItem in includeSequence) + { + var includeMapping = includeItem.AssertMapping("matrix includes item"); + + // Distinguish filters versus extra + var filter = new MappingToken(null, null, null); + var extra = new DictionaryExpressionData(); + foreach (var includePair in includeMapping) + { + var includeKeyLiteral = includePair.Key.AssertString("matrix include item key"); + if (vectors.ContainsKey(includeKeyLiteral.Value)) + { + filter.Add(includeKeyLiteral, includePair.Value); + } + else + { + extra.Add(includeKeyLiteral.Value, includePair.Value.ToExpressionData()); + } + } + + // At least one filter or extra + if (filter.Count == 0 && extra.Count == 0) + { + context.Error(includeMapping, $"Matrix include mapping does not contain any values"); + continue; + } + + // Add filter + m_filters.Add(new MatrixIncludeFilter(filter, extra)); + } + } + + m_matches = new Boolean[m_filters.Count]; + } + + /// + /// Matches a vector from the cross product against each include filter. + /// + /// For example, given the matrix: + /// arch: [x64, x86] + /// config: [release, debug] + /// include: + /// - arch: x64 + /// config: release + /// publish: true + /// + /// This method would return the following: + /// Match( + /// matrix: {arch: x64, config: release}, + /// out extra: {publish: true}) + /// => true + /// + /// Match( + /// matrix: {arch: x64, config: debug}, + /// out extra: null) + /// => false + /// + /// Match( + /// matrix: {arch: x86, config: release}, + /// out extra: null) + /// => false + /// + /// Match( + /// matrix: {arch: x86, config: debug}, + /// out extra: null) + /// => false + /// + /// A vector of the cross product + /// Extra values to add to the vector + /// True if the vector matched at least one include filter + public Boolean Match( + DictionaryExpressionData matrix, + out DictionaryExpressionData extra) + { + extra = default(DictionaryExpressionData); + for (var i = 0; i < m_filters.Count; i++) + { + var filter = m_filters[i]; + if (filter.Match(matrix, out var items)) + { + m_matches[i] = true; + + if (extra == null) + { + extra = new DictionaryExpressionData(); + } + + foreach (var pair in items) + { + extra[pair.Key] = pair.Value; + } + } + } + + return extra != null; + } + + /// + /// Gets all additional vectors to add. These are additional configurations that were not produced + /// from the cross product. These are include vectors that did not match any cross product results. + /// + /// For example, given the matrix: + /// arch: [x64, x86] + /// config: [release, debug] + /// include: + /// - arch: arm64 + /// config: debug + /// + /// This method would return the following: + /// - {arch: arm64, config: debug} + /// + public IEnumerable GetUnmatchedVectors() + { + for (var i = 0; i < m_filters.Count; i++) + { + if (m_matches[i]) + { + continue; + } + + var filter = m_filters[i]; + var matrix = new DictionaryExpressionData(); + foreach (var pair in filter.Filter) + { + var keyLiteral = pair.Key.AssertString("matrix include item key"); + matrix.Add(keyLiteral.Value, pair.Value.ToExpressionData()); + } + + foreach (var includePair in filter.Extra) + { + matrix.Add(includePair.Key, includePair.Value); + } + + yield return matrix; + } + } + + private readonly List m_filters = new List(); + + // Tracks whether a filter has been matched + private readonly Boolean[] m_matches; + } + + /// + /// Represents an item within the sequence "strategy.matrix.include" + /// + private sealed class MatrixIncludeFilter : MatrixFilter + { + public MatrixIncludeFilter( + MappingToken filter, + DictionaryExpressionData extra) + : base(filter) + { + Filter = filter; + Extra = extra; + } + + public Boolean Match( + DictionaryExpressionData matrix, + out DictionaryExpressionData extra) + { + if (base.Match(matrix)) + { + extra = Extra; + return true; + } + + extra = null; + return false; + } + + public DictionaryExpressionData Extra { get; } + public MappingToken Filter { get; } + } + + /// + /// Represents the sequence "strategy.matrix.exclude" + /// + private sealed class MatrixExclude + { + public MatrixExclude( + TemplateContext context, + DictionaryExpressionData vectors, + SequenceToken excludeSequence) + { + // Convert to excludes sets + if (excludeSequence?.Count > 0) + { + foreach (var excludeItem in excludeSequence) + { + var excludeMapping = excludeItem.AssertMapping("matrix excludes item"); + + // Check empty + if (excludeMapping.Count == 0) + { + context.Error(excludeMapping, $"Matrix exclude filter must not be empty"); + continue; + } + + // Validate first-level keys + foreach (var excludePair in excludeMapping) + { + var excludeKey = excludePair.Key.AssertString("matrix excludes item key"); + if (!vectors.ContainsKey(excludeKey.Value)) + { + context.Error(excludeKey, $"Matrix exclude key '{excludeKey.Value}' does not match any key within the matrix"); + continue; + } + } + + // Add filter + m_filters.Add(new MatrixExcludeFilter(excludeMapping)); + } + } + } + + /// + /// Matches a vector from the cross product against each exclude filter. + /// + /// For example, given the matrix: + /// arch: [x64, x86] + /// config: [release, debug] + /// exclude: + /// - arch: x86 + /// config: release + /// + /// This method would return the following: + /// Match( {arch: x64, config: release} ) => false + /// Match( {arch: x64, config: debug} ) => false + /// Match( {arch: x86, config: release} ) => true + /// Match( {arch: x86, config: debug} ) => false + /// + /// A vector of the cross product + /// Extra values to add to the vector + /// True if the vector matched at least one exclude filter + public Boolean Match(DictionaryExpressionData matrix) + { + foreach (var filter in m_filters) + { + if (filter.Match(matrix)) + { + return true; + } + } + + return false; + } + + private readonly List m_filters = new List(); + } + + /// + /// Represents an item within the sequence "strategy.matrix.exclude" + /// + private sealed class MatrixExcludeFilter : MatrixFilter + { + public MatrixExcludeFilter(MappingToken filter) + : base(filter) + { + } + + public new Boolean Match(DictionaryExpressionData matrix) + { + return base.Match(matrix); + } + } + + /// + /// Base class for matrix include/exclude filters. That is, an item within the + /// sequence "strategy.matrix.include" or within the sequence "strategy.matrix.exclude". + /// + private abstract class MatrixFilter + { + protected MatrixFilter(MappingToken matrixFilter) + { + // Traverse the structure and add an expression to compare each leaf node. + // For example, given the filter: + // versions: + // node-version: 12 + // npm-version: 6 + // config: release + // The following filter expressions would be created: + // - matrix.versions.node-version == 12 + // - matrix.versions.npm-version == 6 + // - matrix.config == 'release' + var state = new MappingState(null, matrixFilter) as TokenState; + while (state != null) + { + if (state.MoveNext()) + { + // Leaf + if (state.Current is LiteralToken literal) + { + AddExpression(state.Path, literal); + } + // Mapping + else if (state.Current is MappingToken mapping) + { + state = new MappingState(state, mapping); + } + // Sequence + else if (state.Current is SequenceToken sequence) + { + state = new SequenceState(state, sequence); + } + else + { + throw new NotSupportedException($"Unexpected token type '{state.Current.Type}' when constructing matrix filter expressions"); + } + } + else + { + state = state.Parent; + } + } + } + + protected Boolean Match(DictionaryExpressionData matrix) + { + if (matrix.Count == 0) + { + throw new InvalidOperationException("Matrix filter cannot be empty"); + } + + foreach (var expression in m_expressions) + { + var result = expression.Evaluate(null, null, matrix, null); + if (result.IsFalsy) + { + return false; + } + } + + return true; + } + + private void AddExpression( + String path, + LiteralToken literal) + { + var expressionLiteral = default(String); + switch (literal.Type) + { + case TokenType.Null: + expressionLiteral = ExpressionConstants.Null; + break; + + case TokenType.Boolean: + var booleanToken = literal as BooleanToken; + expressionLiteral = ExpressionUtility.ConvertToParseToken(booleanToken.Value); + break; + + case TokenType.Number: + var numberToken = literal as NumberToken; + expressionLiteral = ExpressionUtility.ConvertToParseToken(numberToken.Value); + break; + + case TokenType.String: + var stringToken = literal as StringToken; + expressionLiteral = ExpressionUtility.ConvertToParseToken(stringToken.Value); + break; + + default: + throw new NotSupportedException($"Unexpected literal type '{literal.Type}'"); + } + + var parser = new ExpressionParser(); + var expressionString = $"{path} == {expressionLiteral}"; + var expression = parser.CreateTree(expressionString, null, s_matrixFilterNamedValues, null); + m_expressions.Add(expression); + } + + /// + /// Used to maintain state while traversing a mapping when building filter expressions. + /// See for more info. + /// + private sealed class MappingState : TokenState + { + public MappingState( + TokenState parent, + MappingToken mapping) + : base(parent) + { + m_mapping = mapping; + m_index = -1; + } + + public override Boolean MoveNext() + { + if (++m_index < m_mapping.Count) + { + var pair = m_mapping[m_index]; + var keyLiteral = pair.Key.AssertString("matrix filter key"); + Current = pair.Value; + var parentPath = Parent?.Path ?? WorkflowTemplateConstants.Matrix; + Path = $"{parentPath}[{ExpressionUtility.ConvertToParseToken(keyLiteral.Value)}]"; + return true; + } + else + { + Current = null; + Path = null; + return false; + } + } + + private MappingToken m_mapping; + private Int32 m_index; + } + + /// + /// Used to maintain state while traversing a sequence when building filter expressions. + /// See for more info. + /// + private sealed class SequenceState : TokenState + { + public SequenceState( + TokenState parent, + SequenceToken sequence) + : base(parent) + { + m_sequence = sequence; + m_index = -1; + } + + public override Boolean MoveNext() + { + if (++m_index < m_sequence.Count) + { + Current = m_sequence[m_index]; + var parentPath = Parent?.Path ?? WorkflowTemplateConstants.Matrix; + Path = $"{parentPath}[{ExpressionUtility.ConvertToParseToken((Double)m_index)}]"; + return true; + } + else + { + Current = null; + Path = null; + return false; + } + } + + private SequenceToken m_sequence; + private Int32 m_index; + } + + /// + /// Used to maintain state while traversing a mapping/sequence when building filter expressions. + /// See for more info. + /// + private abstract class TokenState + { + protected TokenState(TokenState parent) + { + Parent = parent; + } + + public TemplateToken Current { get; protected set; } + public TokenState Parent { get; } + + /// + /// The expression used to reference the current position within the structure. + /// For example: matrix.node-version + /// + public String Path { get; protected set; } + + public abstract Boolean MoveNext(); + } + + /// + /// Represents the "matrix" context within an include/exclude expression + /// + private sealed class MatrixNamedValue : NamedValue + { + protected override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + return context.State; + } + } + + private static readonly INamedValueInfo[] s_matrixFilterNamedValues = new INamedValueInfo[] + { + new NamedValueInfo(WorkflowTemplateConstants.Matrix), + }; + private readonly List m_expressions = new List(); + } + + private readonly TemplateContext m_context; + private readonly String m_jobName; + private readonly DictionaryExpressionData m_vectors = new DictionaryExpressionData(); + private SequenceToken m_excludeSequence; + private SequenceToken m_includeSequence; + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/PermissionLevelExtensions.cs b/src/Sdk/WorkflowParser/Conversion/PermissionLevelExtensions.cs new file mode 100644 index 000000000..4daa7431d --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/PermissionLevelExtensions.cs @@ -0,0 +1,44 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal static class PermissionLevelExtensions + { + public static bool IsLessThanOrEqualTo( + this PermissionLevel permissionLevel, + PermissionLevel other) + { + switch (permissionLevel, other) + { + case (PermissionLevel.NoAccess, PermissionLevel.NoAccess): + case (PermissionLevel.NoAccess, PermissionLevel.Read): + case (PermissionLevel.NoAccess, PermissionLevel.Write): + case (PermissionLevel.Read, PermissionLevel.Read): + case (PermissionLevel.Read, PermissionLevel.Write): + case (PermissionLevel.Write, PermissionLevel.Write): + return true; + case (PermissionLevel.Read, PermissionLevel.NoAccess): + case (PermissionLevel.Write, PermissionLevel.NoAccess): + case (PermissionLevel.Write, PermissionLevel.Read): + return false; + default: + throw new ArgumentException($"Invalid enum comparison: {permissionLevel} and {other}"); + } + } + + public static string ConvertToString(this PermissionLevel permissionLevel) + { + switch (permissionLevel) + { + case PermissionLevel.NoAccess: + return "none"; + case PermissionLevel.Read: + return "read"; + case PermissionLevel.Write: + return "write"; + default: + throw new NotSupportedException($"invalid permission level found. {permissionLevel}"); + } + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/Conversion/PermissionLevelViolation.cs b/src/Sdk/WorkflowParser/Conversion/PermissionLevelViolation.cs new file mode 100644 index 000000000..e8e58bc15 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/PermissionLevelViolation.cs @@ -0,0 +1,37 @@ +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal sealed class PermissionLevelViolation + { + + public PermissionLevelViolation(string permissionName, PermissionLevel requestedPermissions, PermissionLevel allowedPermissions) + { + PermissionName = permissionName; + RequestedPermissionLevel = requestedPermissions; + AllowedPermissionLevel = allowedPermissions; + } + + public string PermissionName + { + get; + } + + public PermissionLevel RequestedPermissionLevel + { + get; + } + public PermissionLevel AllowedPermissionLevel + { + get; + } + + public string RequestedPermissionLevelString() + { + return $"{PermissionName}: {RequestedPermissionLevel.ConvertToString()}"; + } + + public string AllowedPermissionLevelString() + { + return $"{PermissionName}: {AllowedPermissionLevel.ConvertToString()}"; + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/Conversion/PermissionsHelper.cs b/src/Sdk/WorkflowParser/Conversion/PermissionsHelper.cs new file mode 100644 index 000000000..b53408950 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/PermissionsHelper.cs @@ -0,0 +1,79 @@ +#nullable enable + +using System; +using System.Linq; +using GitHub.Actions.WorkflowParser.ObjectTemplating; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal static class PermissionsHelper + { + /// + /// Validates permissions requested in a reusable workflow do not exceed allowed permissions + /// + /// The template context + /// The reusable workflow job + /// (Optional) Used when formatting errors related to an embedded job within the reusable workflow + /// The permissions within the reusable workflow file. These may be defined either at the root of the file, or may be defined on a job within the file. + /// (Optional) The max permissions explicitly allowed by the caller + /// The default permissions policy + /// Indicates whether the reusable workflow exists within the same trust boundary (e.g. enterprise/organization) as a the root workflow + internal static void ValidateEmbeddedPermissions( + TemplateContext context, + ReusableWorkflowJob workflowJob, + IJob? embeddedJob, + Permissions requested, + Permissions? explicitMax, + string permissionsPolicy, + bool isTrusted) + { + if (requested == null) + { + return; + } + + var effectiveMax = explicitMax ?? CreatePermissionsFromPolicy(context, permissionsPolicy, includeIdToken: isTrusted, includeModels: context.GetFeatures().AllowModelsPermission); + + if (requested.ViolatesMaxPermissions(effectiveMax, out var permissionLevelViolations)) + { + var requestedStr = string.Join(", ", permissionLevelViolations.Select(x => x.RequestedPermissionLevelString())); + var allowedStr = string.Join(", ", permissionLevelViolations.Select(x => x.AllowedPermissionLevelString())); + if (embeddedJob != null) + { + context.Error(workflowJob.Id, $"Error calling workflow '{workflowJob.Ref}'. The nested job '{embeddedJob.Id!.Value}' is requesting '{requestedStr}', but is only allowed '{allowedStr}'."); + } + else + { + context.Error(workflowJob.Id, $"Error calling workflow '{workflowJob.Ref}'. The workflow is requesting '{requestedStr}', but is only allowed '{allowedStr}'."); + } + } + } + + /// + /// Creates permissions based on policy + /// + /// The template context + /// The permissions policy + /// Indicates whether the permissions should include an ID token + private static Permissions CreatePermissionsFromPolicy( + TemplateContext context, + string permissionsPolicy, + bool includeIdToken, + bool includeModels) + { + switch (permissionsPolicy) + { + case WorkflowConstants.PermissionsPolicy.LimitedRead: + return new Permissions(PermissionLevel.NoAccess, includeIdToken: false, includeAttestations: false, includeModels: false) + { + Contents = PermissionLevel.Read, + Packages = PermissionLevel.Read, + }; + case WorkflowConstants.PermissionsPolicy.Write: + return new Permissions(PermissionLevel.Write, includeIdToken: includeIdToken, includeAttestations: true, includeModels: includeModels); + default: + throw new ArgumentException($"Unexpected permission policy: '{permissionsPolicy}'"); + } + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/Conversion/ReusableWorkflowsLoader.cs b/src/Sdk/WorkflowParser/Conversion/ReusableWorkflowsLoader.cs new file mode 100644 index 000000000..b030cdc18 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/ReusableWorkflowsLoader.cs @@ -0,0 +1,272 @@ +#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; +using GitHub.Actions.Expressions.Sdk; +using GitHub.Actions.WorkflowParser.Conversion; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + using GitHub.Actions.WorkflowParser.ObjectTemplating; + + /// + /// Loads reusable workflows + /// + internal sealed class ReusableWorkflowsLoader + { + private ReusableWorkflowsLoader( + IServerTraceWriter serverTrace, + ITraceWriter trace, + ParseOptions options, + WorkflowUsage usage, + TemplateContext context, + WorkflowTemplate workflowTemplate, + YamlTemplateLoader loader, + String permissionPolicy, + IDictionary referencedWorkflows) + { + m_serverTrace = serverTrace ?? new EmptyServerTraceWriter(); + m_trace = trace ?? new EmptyTraceWriter(); + m_parseOptions = new ParseOptions(options ?? throw new ArgumentNullException(nameof(options))); + m_usage = usage ?? throw new ArgumentNullException(nameof(usage)); + m_context = context ?? throw new ArgumentNullException(nameof(context)); + m_workflowTemplate = workflowTemplate ?? throw new ArgumentNullException(nameof(workflowTemplate)); + m_loader = loader ?? throw new ArgumentNullException(nameof(loader)); + m_permissionPolicy = permissionPolicy ?? throw new ArgumentNullException(nameof(permissionPolicy)); + m_referencedWorkflows = referencedWorkflows ?? throw new ArgumentNullException(nameof(referencedWorkflows)); + } + + /// + /// Loads reusable workflows if not in an error state. + /// + /// Any new errors are recorded to both and . + /// + public static void Load( + IServerTraceWriter serverTrace, + ITraceWriter trace, + ParseOptions options, + WorkflowUsage usage, + TemplateContext context, + WorkflowTemplate workflowTemplate, + YamlTemplateLoader loader, + String permissionPolicy, + IDictionary referencedWorkflows) + { + new ReusableWorkflowsLoader(serverTrace, trace, options, usage, context, workflowTemplate, loader, permissionPolicy, referencedWorkflows) + .Load(); + } + + /// + /// Refer overload + /// + private void Load() + { + // Skip reusable workflows? + if (m_parseOptions.SkipReusableWorkflows) + { + return; + } + + // Check errors + if (m_context.Errors.Count > 0) + { + return; + } + + // Note, the "finally" block appends context.Errors to workflowTemplate + var hasReusableWorkflowJob = false; + try + { + foreach (var job in m_workflowTemplate.Jobs) + { + // Load reusable workflow + if (job is ReusableWorkflowJob workflowJob) + { + hasReusableWorkflowJob = true; + LoadRecursive(workflowJob); + + // Check errors + if (m_context.Errors.Count > 0) + { + return; + } + } + } + } + catch (ReferencedWorkflowNotFoundException) + { + // Long term, catch TemplateUserException and let others bubble + throw; + } + catch (Exception ex) + { + m_context.Errors.Add(ex); + } + finally + { + // Append context.Errors to workflowTemplate + if (m_context.Errors.Count > 0) + { + foreach (var error in m_context.Errors) + { + m_workflowTemplate.Errors.Add(new WorkflowValidationError(error.Code, error.Message)); + } + } + + // Update WorkflowTemplate.FileTable with referenced workflows + if (hasReusableWorkflowJob) + { + m_workflowTemplate.FileTable.Clear(); + m_workflowTemplate.FileTable.AddRange(m_context.GetFileTable()); + } + } + } + + /// + /// This loads referenced workflow by parsing the workflow file and converting to workflow template WorkflowJob. + /// + private void LoadRecursive( + ReusableWorkflowJob workflowJob, + int depth = 1) + { + // Check depth + if (depth > m_parseOptions.MaxNestedReusableWorkflowsDepth) + { + throw new Exception($"Nested reusable workflow depth exceeded {m_parseOptions.MaxNestedReusableWorkflowsDepth}."); + } + + TemplateToken tokens; + + // Load the reusable workflow + try + { + // Fully qualify workflow ref + workflowJob.Ref = FullyQualifyWorkflowRef(m_context, workflowJob.Ref, m_referencedWorkflows); + var isTrusted = IsReferencedWorkflowTrusted(workflowJob.Ref.Value); + + // Parse template tokens + tokens = m_loader.ParseWorkflow(m_context, workflowJob.Ref.Value); + + // Gather telemetry + m_usage.Gather(m_context, tokens); + + // Check errors + if (m_context.Errors.Count > 0) + { + // Short-circuit + return; + } + + // Convert to workflow types + WorkflowTemplateConverter.ConvertToReferencedWorkflow(m_context, tokens, workflowJob, m_permissionPolicy, isTrusted); + + // Check errors + if (m_context.Errors.Count > 0) + { + // Short-circuit + return; + } + } + finally + { + // Prefix errors with caller file/line/col + PrefixErrorsWithCallerInfo(workflowJob); + } + + // Load nested reusable workflows + foreach (var nestedJob in workflowJob.Jobs) + { + if (nestedJob is ReusableWorkflowJob nestedWorkflowJob) + { + // Recurse + LoadRecursive(nestedWorkflowJob, depth + 1); + + // Check errors + if (m_context.Errors.Count > 0) + { + return; + } + } + } + } + + /// + /// For the given token and referencedWorkflows, resolve the workflow reference (i.e. token value) + /// This ensures that the workflow reference is the fully qualified form (nwo+path+version) even when calling local workflows without nwo or version + /// + internal static StringToken FullyQualifyWorkflowRef( + TemplateContext context, + StringToken workflowJobRef, + IDictionary referencedWorkflows) + { + if (!workflowJobRef.Value.StartsWith(WorkflowTemplateConstants.LocalPrefix)) + { + return workflowJobRef; + } + + var callerPath = context.GetFileName(workflowJobRef.FileId.Value); + if (!referencedWorkflows.TryGetValue(callerPath, out ReferencedWorkflow callerWorkflow) || callerWorkflow == null) + { + throw new ReferencedWorkflowNotFoundException($"Cannot find the caller workflow from the referenced workflows: '{callerPath}'"); + } + + var filePath = workflowJobRef.Value.Substring(WorkflowTemplateConstants.LocalPrefix.Length); + var path = $"{callerWorkflow.Repository}/{filePath}@{callerWorkflow.ResolvedSha}"; + + return new StringToken(workflowJobRef.FileId, workflowJobRef.Line, workflowJobRef.Column, path); + } + + /// + /// Prefixes all error messages with the caller file/line/column. + /// + private void PrefixErrorsWithCallerInfo(ReusableWorkflowJob workflowJob) + { + if (m_context.Errors.Count == 0) + { + return; + } + + var callerFile = m_context.GetFileName(workflowJob.Ref.FileId.Value); + for (int i = 0; i < m_context.Errors.Count; i++) + { + var errorMessage = m_context.Errors.GetMessage(i); + if (String.IsNullOrEmpty(errorMessage) || !errorMessage.StartsWith(callerFile)) + { + // when there is no caller file in the error message, we add it for annotation + m_context.Errors.PrefixMessage( + i, + TemplateStrings.CalledWorkflowNotValidWithErrors( + callerFile, + TemplateStrings.LineColumn(workflowJob.Ref.Line, workflowJob.Ref.Column))); + } + } + } + + /// + /// Checks if the given workflowJobRefValue is trusted + /// + private bool IsReferencedWorkflowTrusted(String workflowJobRefValue) + { + if (m_referencedWorkflows.TryGetValue(workflowJobRefValue, out ReferencedWorkflow referencedWorkflow) && + referencedWorkflow != null) + { + return referencedWorkflow.IsTrusted(); + } + + return false; + } + + private readonly TemplateContext m_context; + private readonly YamlTemplateLoader m_loader; + private readonly ParseOptions m_parseOptions; + private readonly string m_permissionPolicy; + private readonly IDictionary m_referencedWorkflows; + private readonly IServerTraceWriter m_serverTrace; + private readonly ITraceWriter m_trace; + private readonly WorkflowUsage m_usage; + private readonly WorkflowTemplate m_workflowTemplate; + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/TemplateTokenExtensions.cs b/src/Sdk/WorkflowParser/Conversion/TemplateTokenExtensions.cs new file mode 100644 index 000000000..72f8b865e --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/TemplateTokenExtensions.cs @@ -0,0 +1,76 @@ +#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; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal static class TemplateTokenExtensions + { + public static ArrayExpressionData ToExpressionData(this SequenceToken sequence) + { + var token = sequence as TemplateToken; + var expressionData = token.ToExpressionData(); + return expressionData.AssertArray("converted sequence token"); + } + + public static DictionaryExpressionData ToExpressionData(this MappingToken mapping) + { + var token = mapping as TemplateToken; + var expressionData = token.ToExpressionData(); + return expressionData.AssertDictionary("converted mapping token"); + } + + public static ExpressionData ToExpressionData(this TemplateToken token) + { + switch (token.Type) + { + case TokenType.Mapping: + var mapping = token as MappingToken; + var dictionary = new DictionaryExpressionData(); + if (mapping.Count > 0) + { + foreach (var pair in mapping) + { + var keyLiteral = pair.Key.AssertString("dictionary context data key"); + var key = keyLiteral.Value; + var value = pair.Value.ToExpressionData(); + dictionary.Add(key, value); + } + } + return dictionary; + + case TokenType.Sequence: + var sequence = token as SequenceToken; + var array = new ArrayExpressionData(); + if (sequence.Count > 0) + { + foreach (var item in sequence) + { + array.Add(item.ToExpressionData()); + } + } + return array; + + case TokenType.Null: + return null; + + case TokenType.Boolean: + var boolean = token as BooleanToken; + return new BooleanExpressionData(boolean.Value); + + case TokenType.Number: + var number = token as NumberToken; + return new NumberExpressionData(number.Value); + + case TokenType.String: + var stringToken = token as StringToken; + return new StringExpressionData(stringToken.Value); + + default: + throw new NotSupportedException($"Unexpected {nameof(TemplateToken)} type '{token.Type}'"); + } + } + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowSchemaFactory.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowSchemaFactory.cs new file mode 100644 index 000000000..266bcb635 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowSchemaFactory.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Schema; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + /// + /// Loads the schema for workflows + /// + internal static class WorkflowSchemaFactory + { + /// + /// Loads the template schema for the specified features. + /// + internal static TemplateSchema GetSchema(WorkflowFeatures features) + { + if (features == null) + { + throw new System.ArgumentNullException(nameof(features)); + } + + // Find resource names corresponding to enabled features + var resourceNames = WorkflowFeatures.Names + .Where(x => features.GetFeature(x)) // Enabled features only + .Select(x => string.Concat(c_resourcePrefix, "-", x, c_resourceSuffix)) // To resource name + .Where(x => s_resourceNames.Contains(x)) // Resource must exist + .ToList(); + + // More than one resource found? + if (resourceNames.Count > 1) + { + throw new NotSupportedException("Failed to load workflow schema. Only one feature flag with schema changes can be enabled at a time."); + } + + var resourceName = resourceNames.FirstOrDefault() ?? c_defaultResourceName; + return s_schemas.GetOrAdd( + resourceName, + (resourceName) => + { + var assembly = Assembly.GetExecutingAssembly(); + var json = default(String); + using (var stream = assembly.GetManifestResourceStream(resourceName)!) + using (var streamReader = new StreamReader(stream)) + { + json = streamReader.ReadToEnd(); + } + + var objectReader = new JsonObjectReader(null, json); + return TemplateSchema.Load(objectReader); + }); + } + + private const string c_resourcePrefix = "GitHub.Actions.WorkflowParser.workflow-v1.0"; + private const string c_resourceSuffix = ".json"; + private const string c_defaultResourceName = c_resourcePrefix + c_resourceSuffix; + private static readonly HashSet s_resourceNames = Assembly.GetExecutingAssembly().GetManifestResourceNames().ToHashSet(StringComparer.Ordinal); + private static readonly ConcurrentDictionary s_schemas = new(StringComparer.Ordinal); + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs new file mode 100644 index 000000000..fb2065f41 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConstants.cs @@ -0,0 +1,121 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal static class WorkflowTemplateConstants + { + public const String Always = "always"; + public const String BooleanNeedsContext = "boolean-needs-context"; + public const String BooleanStepsContext = "boolean-steps-context"; + public const String BooleanStrategyContext = "boolean-strategy-context"; + public const String CancelInProgress = "cancel-in-progress"; + public const String CancelTimeoutMinutes = "cancel-timeout-minutes"; + public const String Cancelled = "cancelled"; + public const String Concurrency = "concurrency"; + public const String Container = "container"; + public const String ContinueOnError = "continue-on-error"; + public const String Credentials = "credentials"; + public const String Default = "default"; + public const String Defaults = "defaults"; + public const String Description = "description"; + public const String DockerUriPrefix = "docker://"; + public const String EmbeddedConcurrency = "embedded-concurrency"; + public const String Env = "env"; + public const String Ent = "ent"; + public const String Enterprise = "enterprise"; + public const String Environment = "environment"; + public const String Event = "event"; + public const String EventName = "event_name"; + public const String EventPattern = "github.event"; + public const String Exclude = "exclude"; + public const String FailFast = "fail-fast"; + public const String Failure = "failure"; + public const String GitHub = "github"; + public const String Group = "group"; + public const String HashFiles = "hashFiles"; + public const String Id = "id"; + public const String If = "if"; + public const String Image = "image"; + public const String ImageName = "image-name"; + public const String CustomImageVersion = "version"; + public const String Include = "include"; + public const String Inherit = "inherit"; + public const String Inputs = "inputs"; + public const String InputsPattern = "inputs.*"; + public const String Job = "job"; + public const String JobConcurrency = "job-concurrency"; + public const String JobDefaultsRun = "job-defaults-run"; + public const String JobEnvironment = "job-environment"; + public const String JobIfResult = "job-if-result"; + public const String JobOutputs = "job-outputs"; + public const String Jobs = "jobs"; + public const String JobsPattern = "jobs.*"; + public const String JobsOutputsPattern = "jobs.*.outputs"; + public const String Labels = "labels"; + public const String LocalPrefix = "./"; + public const String Matrix = "matrix"; + public const String MaxParallel = "max-parallel"; + public const String Name = "name"; + public const String Needs = "needs"; + public const String NumberNeedsContext = "number-needs-context"; + public const String NumberStepsContext = "number-steps-context"; + public const String NumberStrategyContext = "number-strategy-context"; + public const String On = "on"; + public const String Options = "options"; + public const String Org = "org"; + public const String Organization = "organization"; + public const String Outputs = "outputs"; + public const String OutputsPattern = "needs.*.outputs"; + public const String Password = "password"; + public const String Permissions = "permissions"; + public const String Pool = "pool"; + public const String Ports = "ports"; + public const String Required = "required"; + public const String Result = "result"; + public const String Run = "run"; + public const String RunName = "run-name"; + public const String Runner = "runner"; + public const String RunsOn = "runs-on"; + public const String Secret = "secret"; + public const String Secrets = "secrets"; + public const String Services = "services"; + public const String Shell = "shell"; + public const String Skipped = "skipped"; + public const String Slash = "/"; + public const String Snapshot = "snapshot"; + public const String StepEnv = "step-env"; + public const String StepIfResult = "step-if-result"; + public const String StepWith = "step-with"; + public const String Steps = "steps"; + public const String Strategy = "strategy"; + public const String StringNeedsContext = "string-needs-context"; + public const String StringRunnerContextNoSecrets = "string-runner-context-no-secrets"; + public const String StringStepsContext = "string-steps-context"; + public const String StringStrategyContext = "string-strategy-context"; + public const String Success = "success"; + public const String TimeoutMinutes = "timeout-minutes"; + public const String Type = "type"; + public const String TypeString = "string"; + public const String TypeBoolean = "boolean"; + public const String TypeNumber = "number"; + public const String Url = "url"; + public const String Username = "username"; + public const String Uses = "uses"; + public const String Vars = "vars"; + public const String VarsPattern = "vars.*"; + public const String VmImage = "vmImage"; + public const String Volumes = "volumes"; + public const String With = "with"; + public const String Workflow = "workflow"; + public const String Workflow_1_0 = "workflow-v1.0"; + public const String WorkflowCall = "workflow_call"; + public const String WorkflowCallInputs = "workflow-call-inputs"; + public const String WorkflowCallOutputs = "workflow-call-outputs"; + public const String WorkflowConcurrency = "workflow-concurrency"; + public const String WorkflowDispatch = "workflow_dispatch"; + public const String WorkflowJobSecrets = "workflow-job-secrets"; + public const String WorkflowJobWith = "workflow-job-with"; + public const String WorkflowRoot = "workflow-root"; + public const String WorkingDirectory = "working-directory"; + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs new file mode 100644 index 000000000..564adc03c --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs @@ -0,0 +1,2242 @@ +#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.Globalization; +using System.Linq; +using GitHub.Actions.Expressions; +using GitHub.Actions.Expressions.Data; +using GitHub.Actions.Expressions.Sdk; +using GitHub.Actions.Expressions.Sdk.Functions; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + internal static class WorkflowTemplateConverter + { + /// + /// Constructs the . Errors are stored to both and . + /// + internal static WorkflowTemplate ConvertToWorkflow( + TemplateContext context, + TemplateToken workflow) + { + var result = new WorkflowTemplate(); + result.FileTable.AddRange(context.GetFileTable()); + + // Note, the "finally" block appends context.Errors to result + try + { + if (workflow == null || context.Errors.Count > 0) + { + return result; + } + + var workflowMapping = workflow.AssertMapping("root"); + + foreach (var workflowPair in workflowMapping) + { + var workflowKey = workflowPair.Key.AssertString("root key"); + + switch (workflowKey.Value) + { + case WorkflowTemplateConstants.On: + var inputTypes = ConvertToOnWorkflowDispatchInputTypes(workflowPair.Value); + foreach(var item in inputTypes) + { + result.InputTypes.TryAdd(item.Key, item.Value); + } + break; + + case WorkflowTemplateConstants.Description: + case WorkflowTemplateConstants.Name: + case WorkflowTemplateConstants.RunName: + break; + + case WorkflowTemplateConstants.Defaults: + result.Defaults = workflowPair.Value; + break; + + case WorkflowTemplateConstants.Env: + result.Env = workflowPair.Value; + break; + + case WorkflowTemplateConstants.Concurrency: + ConvertToConcurrency(context, workflowPair.Value, isEarlyValidation: true); + result.Concurrency = workflowPair.Value; + break; + + case WorkflowTemplateConstants.Jobs: + result.Jobs.AddRange(ConvertToJobs(context, workflowPair.Value)); + break; + + case WorkflowTemplateConstants.Permissions: + result.Permissions = ConvertToPermissions(context, workflowPair.Value); + break; + + default: + workflowKey.AssertUnexpectedValue("root key"); // throws + break; + } + } + + // Propagate explicit permissions + if (result.Permissions != null) + { + foreach (var job in result.Jobs) + { + if (job.Permissions == null) + { + job.Permissions = result.Permissions; + } + } + } + } + catch (Exception ex) + { + context.Errors.Add(ex); + } + finally + { + if (context.Errors.Count > 0) + { + foreach (var error in context.Errors) + { + result.Errors.Add(new WorkflowValidationError(error.Code, error.Message)); + } + } + } + + return result; + } + + internal static void ConvertToReferencedWorkflow( + TemplateContext context, + TemplateToken referencedWorkflow, + ReusableWorkflowJob workflowJob, + String permissionsPolicy, + bool isTrusted) + { + // Explicit max permissions for the reusable workflow or reusable workflow chain. + // Present only when the caller (or higher ancestor) has defined the maximum allowed permissions in YAML. + var explicitMaxPermissions = workflowJob.Permissions; + + var workflowMapping = referencedWorkflow.AssertMapping("root"); + foreach (var workflowPair in workflowMapping) + { + var workflowKey = workflowPair.Key.AssertString("root key"); + switch (workflowKey.Value) + { + case WorkflowTemplateConstants.On: + ConvertToOnTrigger(context, workflowPair.Value, workflowJob); + break; + + case WorkflowTemplateConstants.Description: + case WorkflowTemplateConstants.Name: + case WorkflowTemplateConstants.RunName: + break; + + case WorkflowTemplateConstants.Defaults: + workflowJob.Defaults = workflowPair.Value; + break; + + case WorkflowTemplateConstants.Env: + workflowJob.Env = workflowPair.Value; + break; + + case WorkflowTemplateConstants.Concurrency: + ConvertToConcurrency(context, workflowPair.Value, true); + workflowJob.EmbeddedConcurrency = workflowPair.Value; + break; + case WorkflowTemplateConstants.Jobs: + workflowJob.Jobs.AddRange(ConvertToJobs(context, workflowPair.Value)); + break; + + case WorkflowTemplateConstants.Permissions: + var embeddedRootPermissions = ConvertToPermissions(context, workflowPair.Value); + PermissionsHelper.ValidateEmbeddedPermissions( + context, + workflowJob, + embeddedJob: null, + requested: embeddedRootPermissions, + explicitMax: explicitMaxPermissions, + permissionsPolicy, + isTrusted); + workflowJob.Permissions = embeddedRootPermissions; + break; + + default: + workflowKey.AssertUnexpectedValue("root key"); // throws + break; + } + } + + // Validate requested permissions or propagate explicit permissions + foreach (var embeddedJob in workflowJob.Jobs) + { + if (embeddedJob.Permissions != null) + { + // Validate requested permissions + PermissionsHelper.ValidateEmbeddedPermissions( + context, + workflowJob, + embeddedJob, + requested: embeddedJob.Permissions, + explicitMax: explicitMaxPermissions, + permissionsPolicy, + isTrusted); + } + else if (workflowJob.Permissions != null) + { + // Propagate explicit permissions + embeddedJob.Permissions = workflowJob.Permissions; + } + } + } + + internal static IDictionary ConvertToOnWorkflowDispatchInputTypes(TemplateToken onToken) + { + var result = new Dictionary(); + if (onToken.Type != TokenType.Mapping) + { + return result; + } + + var triggerMapping = onToken.AssertMapping($"workflow {WorkflowTemplateConstants.On} value"); + var dispatchTrigger = triggerMapping.FirstOrDefault(x => + string.Equals((x.Key as StringToken).Value, WorkflowTemplateConstants.WorkflowDispatch, StringComparison.Ordinal)).Value; + if (dispatchTrigger == null || dispatchTrigger is NullToken) + { + return result; + } + + var wfDispatchDefinitions = dispatchTrigger.AssertMapping($"workflow {WorkflowTemplateConstants.On} value {WorkflowTemplateConstants.WorkflowDispatch}"); + var inputDefinitionsToken = wfDispatchDefinitions.FirstOrDefault(x => + string.Equals((x.Key as StringToken).Value, WorkflowTemplateConstants.Inputs, StringComparison.Ordinal)).Value; + if (inputDefinitionsToken == null || inputDefinitionsToken is NullToken) + { + return result; + } + + var inputs = inputDefinitionsToken.AssertMapping($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowDispatch}-{WorkflowTemplateConstants.Inputs}"); + var inputDefinitions = inputs? + .ToDictionary( + x => x.Key.AssertString("inputs key").Value, + x => x.Value, + StringComparer.OrdinalIgnoreCase + ); + foreach (var definedItem in inputDefinitions) + { + string definedKey = definedItem.Key; + + if (definedItem.Value is NullToken) + { + result.Add(definedKey, WorkflowTemplateConstants.TypeString); + continue; + } + + var definedInputSpec = definedItem.Value.AssertMapping($"input {definedKey}").ToDictionary( + x => x.Key.AssertString($"input {definedKey} key").Value, + x => x.Value, + StringComparer.OrdinalIgnoreCase); + if (definedInputSpec.TryGetValue(WorkflowTemplateConstants.Type, out TemplateToken inputTypeToken) && + inputTypeToken is StringToken inputTypeStringToken) + { + result.Add(definedKey, inputTypeStringToken.Value); + } + else + { + result.Add(definedKey, WorkflowTemplateConstants.TypeString); + } + } + return result; + } + + internal static void ConvertToOnTrigger( + TemplateContext context, + TemplateToken onToken, + ReusableWorkflowJob parentWorkflowJob) + { + switch (onToken.Type) + { + // check for on: workflow_call + case TokenType.String: + var result = onToken.AssertString($"Reference workflow {WorkflowTemplateConstants.On} value"); + if (result.Value == WorkflowTemplateConstants.WorkflowCall) + { + ValidateWorkflowJobTrigger(context, parentWorkflowJob); + return; + } + break; + + // check for on: [push, workflow_call] + case TokenType.Sequence: + var triggers = onToken.AssertSequence($"Reference workflow {WorkflowTemplateConstants.On} value"); + + foreach (var triggerItem in triggers) + { + var triggerString = triggerItem.AssertString($"Reference workflow {WorkflowTemplateConstants.On} value {triggerItem}").Value; + if (triggerString == WorkflowTemplateConstants.WorkflowCall) + { + ValidateWorkflowJobTrigger(context, parentWorkflowJob); + return; + } + } + break; + + // check for on: workflow_call Mapping + case TokenType.Mapping: + var triggerMapping = onToken.AssertMapping($"Reference workflow {WorkflowTemplateConstants.On} value"); + + foreach (var triggerItem in triggerMapping) + { + var triggerString = triggerItem.Key.AssertString($"Reference workflow {WorkflowTemplateConstants.On} value {triggerItem.Key}").Value; + if (triggerString == WorkflowTemplateConstants.WorkflowCall) + { + if (triggerItem.Value is NullToken) + { + ValidateWorkflowJobTrigger(context, parentWorkflowJob); + return; + } + + var wfCallDefinitions = triggerItem.Value.AssertMapping($"Reference workflow {WorkflowTemplateConstants.On} value {triggerItem.Key}"); + + foreach (var wfCallDefinitionItem in wfCallDefinitions) + { + var wfCallDefinitionItemKey = wfCallDefinitionItem.Key.AssertString($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowCall}-{wfCallDefinitionItem.Key.ToString()}").Value; + if (wfCallDefinitionItemKey == WorkflowTemplateConstants.Inputs) + { + parentWorkflowJob.InputDefinitions = wfCallDefinitionItem.Value.AssertMapping($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowCall}-{wfCallDefinitionItem.Key.ToString()}"); + } + else if (wfCallDefinitionItemKey == WorkflowTemplateConstants.Secrets) + { + parentWorkflowJob.SecretDefinitions = wfCallDefinitionItem.Value.AssertMapping($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowCall}-{wfCallDefinitionItem.Key.ToString()}"); + } + else if (wfCallDefinitionItemKey == WorkflowTemplateConstants.Outputs) + { + parentWorkflowJob.Outputs = wfCallDefinitionItem.Value.AssertMapping($"{WorkflowTemplateConstants.On}-{WorkflowTemplateConstants.WorkflowCall}-{wfCallDefinitionItem.Key.ToString()}"); + } + } + + ValidateWorkflowJobTrigger(context, parentWorkflowJob); + return; + } + } + break; + default: + break; + } + + context.Error(onToken, $"{WorkflowTemplateConstants.WorkflowCall} key is not defined in the referenced workflow."); + return; + } + + internal static Boolean ConvertToIfResult( + TemplateContext context, + TemplateToken ifResult) + { + var expression = ifResult.Traverse().FirstOrDefault(x => x is ExpressionToken); + if (expression != null) + { + throw new ArgumentException($"Unexpected type '{expression.GetType().Name}' encountered while reading 'if'."); + } + + var evaluationResult = EvaluationResult.CreateIntermediateResult(null, ifResult); + return evaluationResult.IsTruthy; + } + + internal static String ConvertToJobName( + TemplateContext context, + TemplateToken name, + Boolean isEarlyValidation = false) + { + var result = default(String); + + // Expression + if (isEarlyValidation && name is ExpressionToken) + { + return result; + } + + // String + var nameString = name.AssertString($"job {WorkflowTemplateConstants.Name}"); + result = nameString.Value; + return result; + } + + internal static Snapshot ConvertToSnapshot( + TemplateContext context, + TemplateToken snapshotToken, + Boolean isEarlyValidation = false) + { + String imageName = null; + string defaultVersion = "1.*"; + String version = null; + var condition = new BasicExpressionToken(null, null, null, $"{WorkflowTemplateConstants.Success}()"); + + if (isEarlyValidation && snapshotToken is ExpressionToken) + { + return default; + } + + + // String + if (snapshotToken is StringToken snapshotStringToken) + { + imageName = snapshotStringToken.Value; + } + // Mapping + else if (snapshotToken is MappingToken snapshotMappingToken) + { + foreach (var snapshotProperty in snapshotMappingToken) + { + var propertyName = snapshotProperty.Key.AssertString($"job {WorkflowTemplateConstants.Snapshot} key"); + var propertyValue = snapshotProperty.Value; + + switch (propertyName.Value) + { + case WorkflowTemplateConstants.ImageName: + if (isEarlyValidation && propertyValue is ExpressionToken) + { + return default; + } + imageName = propertyValue.AssertString($"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName}").Value; + break; + case WorkflowTemplateConstants.If: + condition = ConvertToIfCondition(context, propertyValue, IfKind.Snapshot); + break; + case WorkflowTemplateConstants.CustomImageVersion: + if (isEarlyValidation && propertyValue is ExpressionToken) + { + return default; + } + var versionValue = propertyValue.AssertString($"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.CustomImageVersion}").Value; + if (versionValue != null && !IsSnapshotImageVersionValid(versionValue)) + { + context.Error(snapshotToken, "Expected format '{major-version}.*' Actual '" + versionValue + "'"); + return null; + } + version = versionValue; + break; + default: + propertyName.AssertUnexpectedValue($"job {WorkflowTemplateConstants.Snapshot} key"); + break; + } + } + } + + // ImageName is a required property (schema validation) + if (imageName == null) + { + context.Error(snapshotToken, $"job {WorkflowTemplateConstants.Snapshot} {WorkflowTemplateConstants.ImageName} is required."); + return null; + } + + return new Snapshot + { + ImageName = imageName, + If = condition, + Version = version ?? defaultVersion + }; + } + + private static bool IsSnapshotImageVersionValid(string versionString) + { + var versionSegments = versionString.Split("."); + + if (versionSegments.Length != 2 || + !versionSegments[1].Equals("*") || + !Int32.TryParse(versionSegments[0], NumberStyles.None, CultureInfo.InvariantCulture, result: out int parsedMajor) || + parsedMajor < 0) + { + return false; + } + + return true; + } + + internal static RunsOn ConvertToRunsOn( + TemplateContext context, + TemplateToken runsOn, + Boolean isEarlyValidation = false) + { + var result = new RunsOn(); + + ConvertToRunsOnLabels(context, runsOn, result, isEarlyValidation); + + // Mapping + if (runsOn is MappingToken runsOnMapping) + { + foreach (var runsOnToken in runsOnMapping) + { + var propertyName = runsOnToken.Key.AssertString($"job {WorkflowTemplateConstants.RunsOn} property name"); + + switch (propertyName.Value) + { + case WorkflowTemplateConstants.Group: + // Expression + if (isEarlyValidation && runsOnToken.Value is ExpressionToken) + { + continue; + } + + // String + var groupName = runsOnToken.Value.AssertString($"job {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Group} name").Value; + var names = groupName.Split(WorkflowTemplateConstants.Slash); + if (names.Length == 2) + { + if (string.IsNullOrEmpty(names[1])) + { + context.Error(runsOnToken.Value, $"Invalid {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Group} name '{groupName}'."); + } + else if (!string.Equals(names[0], WorkflowTemplateConstants.Org) && !string.Equals(names[0], WorkflowTemplateConstants.Organization) && + !string.Equals(names[0], WorkflowTemplateConstants.Ent) && !string.Equals(names[0], WorkflowTemplateConstants.Enterprise)) + { + + context.Error(runsOnToken.Value, $"Invalid {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Group} name '{groupName}'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group."); + } + else + { + result.RunnerGroup = groupName; + } + } + else if (names.Length > 2) + { + context.Error(runsOnToken.Value, $"Invalid {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Group} name '{groupName}'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group."); + } + else + { + result.RunnerGroup = groupName; + } + break; + case WorkflowTemplateConstants.Labels: + ConvertToRunsOnLabels(context, runsOnToken.Value, result, isEarlyValidation); + break; + } + } + } + + return result; + } + + internal static void ConvertToRunsOnLabels( + TemplateContext context, + TemplateToken runsOnLabelsToken, + RunsOn runsOn, + Boolean isEarlyValidation = false) + { + // Expression + if (isEarlyValidation && runsOnLabelsToken is ExpressionToken) + { + return; + } + + // String + if (runsOnLabelsToken is StringToken runsOnLabelsString) + { + runsOn.Labels.Add(runsOnLabelsString.Value); + } + // Sequence + else if (runsOnLabelsToken is SequenceToken runsOnLabelsSequence) + { + foreach (var runsOnLabelToken in runsOnLabelsSequence) + { + // Expression + if (isEarlyValidation && runsOnLabelToken is ExpressionToken) + { + continue; + } + + // String + var label = runsOnLabelToken.AssertString($"job {WorkflowTemplateConstants.RunsOn} {WorkflowTemplateConstants.Labels} sequence item"); + runsOn.Labels.Add(label.Value); + } + } + } + + internal static Int32? ConvertToJobTimeout( + TemplateContext context, + TemplateToken token, + Boolean isEarlyValidation = false) + { + if (isEarlyValidation && token is ExpressionToken) + { + return null; + } + + var numberToken = token.AssertNumber($"job {WorkflowTemplateConstants.TimeoutMinutes}"); + return (Int32)numberToken.Value; + } + + internal static Int32? ConvertToJobCancelTimeout( + TemplateContext context, + TemplateToken token, + Boolean isEarlyValidation = false) + { + if (isEarlyValidation && token is ExpressionToken) + { + return null; + } + + var numberToken = token.AssertNumber($"job {WorkflowTemplateConstants.CancelTimeoutMinutes}"); + return (Int32)numberToken.Value; + } + + internal static Boolean? ConvertToJobContinueOnError( + TemplateContext context, + TemplateToken token, + Boolean isEarlyValidation = false) + { + if (isEarlyValidation && token is ExpressionToken) + { + return null; + } + + var booleanToken = token.AssertBoolean($"job {WorkflowTemplateConstants.ContinueOnError}"); + return booleanToken.Value; + } + + internal static Boolean? ConvertToStepContinueOnError( + TemplateContext context, + TemplateToken token, + Boolean isEarlyValidation = false) + { + if (isEarlyValidation && token is ExpressionToken) + { + return null; + } + + var booleanToken = token.AssertBoolean($"step {WorkflowTemplateConstants.ContinueOnError}"); + return booleanToken.Value; + } + + internal static String ConvertToStepName( + TemplateContext context, + TemplateToken token, + Boolean isEarlyValidation = false) + { + if (isEarlyValidation && token is ExpressionToken) + { + return null; + } + + var stringToken = token.AssertString($"step {WorkflowTemplateConstants.Name}"); + return stringToken.Value; + } + + internal static GroupPermitSetting ConvertToConcurrency( + TemplateContext context, + TemplateToken concurrency, + Boolean isEarlyValidation = false) + { + var result = new GroupPermitSetting(""); + + // Expression + if (isEarlyValidation && concurrency is ExpressionToken) + { + return result; + } + + // String + if (concurrency is StringToken concurrencyString) + { + result.Group = concurrencyString.Value; + } + // Mapping + else + { + var concurrencyMapping = concurrency.AssertMapping($"{WorkflowTemplateConstants.Concurrency}"); + foreach (var concurrencyProperty in concurrencyMapping) + { + var propertyName = concurrencyProperty.Key.AssertString($"{WorkflowTemplateConstants.Concurrency} key"); + + // Expression + if (isEarlyValidation && (concurrencyProperty.Value is ExpressionToken || concurrencyProperty.Key is ExpressionToken)) + { + continue; + } + + switch (propertyName.Value) + { + case WorkflowTemplateConstants.Group: + // Literal + var group = concurrencyProperty.Value.AssertString($"{WorkflowTemplateConstants.Group} key"); + result.Group = group.Value; + break; + case WorkflowTemplateConstants.CancelInProgress: + // Literal + var cancelInProgress = concurrencyProperty.Value.AssertBoolean($"{WorkflowTemplateConstants.CancelInProgress} key"); + result.CancelInProgress = cancelInProgress.Value; + break; + default: + propertyName.AssertUnexpectedValue($"{WorkflowTemplateConstants.Concurrency} key"); // throws + break; + } + } + } + + if (!isEarlyValidation && String.IsNullOrEmpty(result.Group)) + { + context.Error(concurrency, "Concurrency group name cannot be empty"); + } + + if (result.Group?.Length > 400) + { + context.Error(concurrency, "Concurrency group name must be less than 400 characters"); + } + + return result; + } + + internal static ActionsEnvironmentReference ConvertToActionEnvironmentReference( + TemplateContext context, + TemplateToken environment, + bool isEarlyValidation = false) + { + // Expression + if (isEarlyValidation && environment is ExpressionToken) + { + return null; + } + // String + else if (environment is StringToken nameString) + { + return String.IsNullOrEmpty(nameString.Value) ? null : new ActionsEnvironmentReference(nameString.Value); + } + // Mapping + else + { + var environmentMapping = environment.AssertMapping($"job {WorkflowTemplateConstants.Environment}"); + + if (isEarlyValidation) + { + // Skip early validation if any expressions other than "url" (expanded by the runner) + var urlToken = environmentMapping + .Where(x => x.Key is StringToken key && string.Equals(key.Value, WorkflowTemplateConstants.Url, StringComparison.Ordinal)) + .Select(x => x.Value) + .SingleOrDefault(); + if (isEarlyValidation && environmentMapping.Traverse().Any(x => x is ExpressionToken && x != urlToken)) + { + return null; + } + } + + var name = default(String); + var url = default(TemplateToken); + foreach (var environmentProp in environmentMapping) + { + var propertyName = environmentProp.Key.AssertString($"job {WorkflowTemplateConstants.Environment} key"); + var propertyValue = environmentProp.Value; + + switch (propertyName.Value) + { + // Name is a required property (schema validation) + case WorkflowTemplateConstants.Name: + name = propertyValue.AssertString($"job {WorkflowTemplateConstants.Environment} {WorkflowTemplateConstants.Name}").Value; + break; + case WorkflowTemplateConstants.Url: + url = propertyValue; + break; + default: + propertyName.AssertUnexpectedValue($"job {WorkflowTemplateConstants.Environment} key"); // throws + break; + } + } + + if (!String.IsNullOrEmpty(name)) + { + return new ActionsEnvironmentReference(name) { Url = url }; + } + else + { + return null; + } + } + } + + internal static Dictionary ConvertToStepEnvironment( + TemplateContext context, + TemplateToken environment, + StringComparer keyComparer, + Boolean isEarlyValidation = false) + { + var result = new Dictionary(keyComparer); + + // Expression + if (isEarlyValidation && environment is ExpressionToken) + { + return result; + } + + // Mapping + var mapping = environment.AssertMapping("environment"); + + foreach (var pair in mapping) + { + // Expression key + if (isEarlyValidation && pair.Key is ExpressionToken) + { + continue; + } + + // String key + var key = pair.Key.AssertString("environment key"); + + // Expression value + if (isEarlyValidation && pair.Value is ExpressionToken) + { + continue; + } + + // String value + var value = pair.Value.AssertString("environment value"); + result[key.Value] = value.Value; + } + + return result; + } + + internal static Dictionary ConvertToStepInputs( + TemplateContext context, + TemplateToken inputs, + Boolean isEarlyValidation = false) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Expression + if (isEarlyValidation && inputs is ExpressionToken) + { + return result; + } + + // Mapping + var mapping = inputs.AssertMapping("inputs"); + + foreach (var pair in mapping) + { + // Expression key + if (isEarlyValidation && pair.Key is ExpressionToken) + { + continue; + } + + // Literal key + var key = pair.Key.AssertString("inputs key"); + + // Expression value + if (isEarlyValidation && pair.Value is ExpressionToken) + { + continue; + } + + // Literal value + var value = pair.Value.AssertString("inputs value"); + result[key.Value] = value.Value; + } + + return result; + } + + internal static Int32? ConvertToStepTimeout( + TemplateContext context, + TemplateToken token, + Boolean isEarlyValidation = false) + { + if (isEarlyValidation && token is ExpressionToken) + { + return null; + } + + var numberToken = token.AssertNumber($"step {WorkflowTemplateConstants.TimeoutMinutes}"); + return (Int32)numberToken.Value; + } + + internal static Strategy ConvertToStrategy( + TemplateContext context, + TemplateToken token, + String jobFactoryName, + Boolean isEarlyValidation = false) + { + var result = new Strategy(); + + // Expression + if (isEarlyValidation && token is ExpressionToken) + { + return result; + } + + var strategyMapping = token.AssertMapping(WorkflowTemplateConstants.Strategy); + var matrixBuilder = default(MatrixBuilder); + var hasExpressions = false; + + foreach (var strategyPair in strategyMapping) + { + // Expression key + if (isEarlyValidation && strategyPair.Key is ExpressionToken) + { + hasExpressions = true; + continue; + } + + // Literal key + var strategyKey = strategyPair.Key.AssertString("strategy key"); + + switch (strategyKey.Value) + { + // Fail-Fast + case WorkflowTemplateConstants.FailFast: + if (isEarlyValidation && strategyPair.Value is ExpressionToken) + { + hasExpressions = true; + continue; + } + + var failFastBooleanToken = strategyPair.Value.AssertBoolean($"strategy {WorkflowTemplateConstants.FailFast}"); + result.FailFast = failFastBooleanToken.Value; + break; + + // Max-Parallel + case WorkflowTemplateConstants.MaxParallel: + if (isEarlyValidation && strategyPair.Value is ExpressionToken) + { + hasExpressions = true; + continue; + } + + var maxParallelNumberToken = strategyPair.Value.AssertNumber($"strategy {WorkflowTemplateConstants.MaxParallel}"); + result.MaxParallel = (Int32)maxParallelNumberToken.Value; + break; + + // Matrix + case WorkflowTemplateConstants.Matrix: + + // Expression + if (isEarlyValidation && strategyPair.Value is ExpressionToken) + { + hasExpressions = true; + continue; + } + + var matrix = strategyPair.Value.AssertMapping("matrix"); + hasExpressions = hasExpressions || matrix.Traverse().Any(x => x is ExpressionToken); + matrixBuilder = new MatrixBuilder(context, jobFactoryName); + var hasCrossProductVector = false; + var hasIncludeVector = false; + + foreach (var matrixPair in matrix) + { + // Expression key + if (isEarlyValidation && matrixPair.Key is ExpressionToken) + { + hasCrossProductVector = true; // For early validation, treat as if a vector is defined + continue; + } + + var matrixKey = matrixPair.Key.AssertString("matrix key"); + switch (matrixKey.Value) + { + case WorkflowTemplateConstants.Include: + if (isEarlyValidation && matrixPair.Value.Traverse().Any(x => x is ExpressionToken)) + { + hasIncludeVector = true; // For early validation, treat as OK + continue; + } + + var includeSequence = matrixPair.Value.AssertSequence("matrix includes"); + hasIncludeVector = includeSequence.Count > 0; + matrixBuilder.Include(includeSequence); + break; + + case WorkflowTemplateConstants.Exclude: + if (isEarlyValidation && matrixPair.Value.Traverse().Any(x => x is ExpressionToken)) + { + continue; + } + + var excludeSequence = matrixPair.Value.AssertSequence("matrix excludes"); + matrixBuilder.Exclude(excludeSequence); + break; + + default: + hasCrossProductVector = true; + + if (isEarlyValidation && matrixPair.Value.Traverse().Any(x => x is ExpressionToken)) + { + continue; + } + + var vectorName = matrixKey.Value; + var vectorSequence = matrixPair.Value.AssertSequence("matrix vector value"); + if (vectorSequence.Count == 0) + { + context.Error(vectorSequence, $"Matrix vector '{vectorName}' does not contain any values"); + } + else + { + matrixBuilder.AddVector(vectorName, vectorSequence); + } + break; + } + } + + if (!hasCrossProductVector && !hasIncludeVector) + { + context.Error(matrix, $"Matrix must define at least one vector"); + } + + break; + + default: + strategyKey.AssertUnexpectedValue("strategy key"); // throws + break; + } + } + + if (hasExpressions) + { + return result; + } + + if (matrixBuilder != null) + { + result.Configurations.AddRange(matrixBuilder.Build()); + } + + for (var i = 0; i < result.Configurations.Count; i++) + { + var configuration = result.Configurations[i]; + + var strategy = new DictionaryExpressionData() + { + { + "fail-fast", + new BooleanExpressionData(result.FailFast) + }, + { + "job-index", + new NumberExpressionData(i) + }, + { + "job-total", + new NumberExpressionData(result.Configurations.Count) + } + }; + + if (result.MaxParallel > 0) + { + strategy.Add( + "max-parallel", + new NumberExpressionData(result.MaxParallel) + ); + } + else + { + strategy.Add( + "max-parallel", + new NumberExpressionData(result.Configurations.Count) + ); + } + + configuration.ExpressionData.Add(WorkflowTemplateConstants.Strategy, strategy); + context.Memory.AddBytes(WorkflowTemplateConstants.Strategy); + context.Memory.AddBytes(strategy, traverse: true); + + if (!configuration.ExpressionData.ContainsKey(WorkflowTemplateConstants.Matrix)) + { + configuration.ExpressionData.Add(WorkflowTemplateConstants.Matrix, null); + context.Memory.AddBytes(WorkflowTemplateConstants.Matrix); + } + } + + return result; + } + + internal static ContainerRegistryCredentials ConvertToContainerCredentials(TemplateToken token) + { + var credentials = token.AssertMapping(WorkflowTemplateConstants.Credentials); + var result = new ContainerRegistryCredentials(); + foreach (var credentialProperty in credentials) + { + var propertyName = credentialProperty.Key.AssertString($"{WorkflowTemplateConstants.Credentials} key"); + switch (propertyName.Value) + { + case WorkflowTemplateConstants.Username: + result.Username = credentialProperty.Value.AssertString(WorkflowTemplateConstants.Username).Value; + break; + case WorkflowTemplateConstants.Password: + result.Password = credentialProperty.Value.AssertString(WorkflowTemplateConstants.Password).Value; + break; + default: + propertyName.AssertUnexpectedValue($"{WorkflowTemplateConstants.Credentials} key {propertyName}"); + break; + } + } + + return result; + } + + internal static JobContainer ConvertToJobContainer( + TemplateContext context, + TemplateToken value, + bool isEarlyValidation = false) + { + var result = new JobContainer(); + if (isEarlyValidation && value.Traverse().Any(x => x is ExpressionToken)) + { + return result; + } + + if (value is StringToken containerLiteral) + { + if (String.IsNullOrEmpty(containerLiteral.Value)) + { + return null; + } + + result.Image = containerLiteral.Value; + } + else + { + var containerMapping = value.AssertMapping($"{WorkflowTemplateConstants.Container}"); + foreach (var containerPropertyPair in containerMapping) + { + var propertyName = containerPropertyPair.Key.AssertString($"{WorkflowTemplateConstants.Container} key"); + + switch (propertyName.Value) + { + case WorkflowTemplateConstants.Image: + result.Image = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value; + break; + case WorkflowTemplateConstants.Env: + var env = containerPropertyPair.Value.AssertMapping($"{WorkflowTemplateConstants.Container} {propertyName}"); + var envDict = new Dictionary(env.Count); + foreach (var envPair in env) + { + var envKey = envPair.Key.ToString(); + var envValue = envPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName} {envPair.Key.ToString()}").Value; + envDict.Add(envKey, envValue); + } + result.Environment = envDict; + break; + case WorkflowTemplateConstants.Options: + result.Options = containerPropertyPair.Value.AssertString($"{WorkflowTemplateConstants.Container} {propertyName}").Value; + break; + case WorkflowTemplateConstants.Ports: + var ports = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}"); + var portList = new List(ports.Count); + foreach (var portItem in ports) + { + var portString = portItem.AssertString($"{WorkflowTemplateConstants.Container} {propertyName} {portItem.ToString()}").Value; + portList.Add(portString); + } + result.Ports = portList; + break; + case WorkflowTemplateConstants.Volumes: + var volumes = containerPropertyPair.Value.AssertSequence($"{WorkflowTemplateConstants.Container} {propertyName}"); + var volumeList = new List(volumes.Count); + foreach (var volumeItem in volumes) + { + var volumeString = volumeItem.AssertString($"{WorkflowTemplateConstants.Container} {propertyName} {volumeItem.ToString()}").Value; + volumeList.Add(volumeString); + } + result.Volumes = volumeList; + break; + case WorkflowTemplateConstants.Credentials: + result.Credentials = ConvertToContainerCredentials(containerPropertyPair.Value); + break; + default: + propertyName.AssertUnexpectedValue($"{WorkflowTemplateConstants.Container} key"); + break; + } + } + } + + if (String.IsNullOrEmpty(result.Image)) + { + context.Error(value, "Container image cannot be empty"); + return null; + } + + if (result.Image.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal)) + { + result.Image = result.Image.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length); + } + + return result; + } + + internal static List> ConvertToJobServiceContainers( + TemplateContext context, + TemplateToken services, + bool isEarlyValidation = false) + { + var result = new List>(); + + if (isEarlyValidation && services.Traverse().Any(x => x is ExpressionToken)) + { + return result; + } + + var servicesMapping = services.AssertMapping("services"); + + foreach (var servicePair in servicesMapping) + { + var networkAlias = servicePair.Key.AssertString("services key").Value; + var container = ConvertToJobContainer(context, servicePair.Value); + result.Add(new KeyValuePair(networkAlias, container)); + } + + return result; + } + + private static IList ConvertToJobs( + TemplateContext context, + TemplateToken workflow) + { + var result = new List(); + var jobsMapping = workflow.AssertMapping(WorkflowTemplateConstants.Jobs); + var ready = new Queue(); + var allUnsatisfied = new List(); + var jobCountValidator = context.GetJobCountValidator(); + foreach (var jobsPair in jobsMapping) + { + var jobId = jobsPair.Key.AssertString($"{WorkflowTemplateConstants.Jobs} key"); + jobCountValidator.Increment(jobId); + var jobDefinition = jobsPair.Value.AssertMapping($"{WorkflowTemplateConstants.Jobs} value"); + var idBuilder = new IdBuilder(); + var job = ConvertToJob(context, jobId, jobDefinition, idBuilder); + result.Add(job); + var nodeInfo = new NodeInfo + { + Name = job.Id!.Value, + Needs = new List(job.Needs ?? new List()), + }; + if (nodeInfo.Needs.Count == 0) + { + ready.Enqueue(nodeInfo); + } + else + { + allUnsatisfied.Add(nodeInfo); + } + } + + if (context.Errors.Count != 0) + { + return result; + } + + if (ready.Count == 0) + { + context.Error(jobsMapping, "The workflow must contain at least one job with no dependencies."); + return result; + } + + while (ready.Count > 0) + { + var current = ready.Dequeue(); + + // Figure out which nodes would start after current completes + for (var i = allUnsatisfied.Count - 1; i >= 0; i--) + { + var unsatisfied = allUnsatisfied[i]; + for (var j = unsatisfied.Needs.Count - 1; j >= 0; j--) + { + if (String.Equals(unsatisfied.Needs[j].Value, current.Name, StringComparison.OrdinalIgnoreCase)) + { + unsatisfied.Needs.RemoveAt(j); + if (unsatisfied.Needs.Count == 0) + { + ready.Enqueue(unsatisfied); + allUnsatisfied.RemoveAt(i); + } + } + } + } + } + + // Check whether some jobs will never execute + if (allUnsatisfied.Count > 0) + { + var names = result.ToHashSet(x => x.Id!.Value, StringComparer.OrdinalIgnoreCase); + foreach (var unsatisfied in allUnsatisfied) + { + foreach (var need in unsatisfied.Needs) + { + if (names.Contains(need.Value)) + { + context.Error(need, $"Job '{unsatisfied.Name}' depends on job '{need.Value}' which creates a cycle in the dependency graph."); + } + else + { + context.Error(need, $"Job '{unsatisfied.Name}' depends on unknown job '{need.Value}'."); + } + } + } + } + + return result; + } + + private static IJob ConvertToJob( + TemplateContext context, + StringToken jobId, + MappingToken jobDefinition, + IdBuilder idBuilder) + { + if (!idBuilder.TryAddKnownId(jobId.Value, out var error)) + { + context.Error(jobId, error); + } + + var condition = new BasicExpressionToken(null, null, null, $"{WorkflowTemplateConstants.Success}()"); + var continueOnError = default(ScalarToken); + var env = default(TemplateToken); + var name = default(ScalarToken); + var jobTarget = default(TemplateToken); + var steps = new List(); + var strategy = default(TemplateToken); + var jobContainer = default(TemplateToken); + var jobServiceContainers = default(TemplateToken); + var concurrency = default(TemplateToken); + var actionsEnvironment = default(TemplateToken); + var defaults = default(TemplateToken); + var permissions = default(Permissions); + var outputs = default(TemplateToken); + var jobTimeout = default(ScalarToken); + var jobCancelTimeout = default(ScalarToken); + var snapshot = default(TemplateToken); + var needs = new List(); + + var workflowJobRef = default(StringToken); + var workflowJobInputs = default(MappingToken); + var workflowJobSecrets = default(MappingToken); + var workflowJobSecretsInherited = false; + + foreach (var jobProperty in jobDefinition) + { + var propertyName = jobProperty.Key.AssertString($"job property name"); + + switch (propertyName.Value) + { + case WorkflowTemplateConstants.ContinueOnError: + ConvertToJobContinueOnError(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + continueOnError = jobProperty.Value.AssertScalar($"job {WorkflowTemplateConstants.ContinueOnError}"); + break; + + case WorkflowTemplateConstants.If: + condition = ConvertToIfCondition(context, jobProperty.Value, IfKind.Job); + break; + + case WorkflowTemplateConstants.Name: + name = jobProperty.Value.AssertScalar($"job {WorkflowTemplateConstants.Name}"); + ConvertToJobName(context, name, isEarlyValidation: true); // Validate early if possible + break; + + case WorkflowTemplateConstants.Needs: + if (jobProperty.Value is StringToken needsLiteral) + { + needs.Add(needsLiteral); + } + else + { + var needsSeq = jobProperty.Value.AssertSequence($"job {WorkflowTemplateConstants.Needs}"); + foreach (var needsItem in needsSeq) + { + var need = needsItem.AssertString($"job {WorkflowTemplateConstants.Needs} item"); + needs.Add(need); + } + } + break; + + case WorkflowTemplateConstants.RunsOn: + ConvertToRunsOn(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + jobTarget = jobProperty.Value; + break; + + case WorkflowTemplateConstants.Snapshot: + if (!context.GetFeatures().Snapshot) + { + context.Error(jobProperty.Key, $"The key '{WorkflowTemplateConstants.Snapshot}' is not allowed"); + break; + } + + ConvertToSnapshot(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + snapshot = jobProperty.Value; + break; + + case WorkflowTemplateConstants.Steps: + steps.AddRange(ConvertToSteps(context, jobProperty.Value)); + break; + + case WorkflowTemplateConstants.Strategy: + ConvertToStrategy(context, jobProperty.Value, null, isEarlyValidation: true); // Validate early if possible + strategy = jobProperty.Value; + break; + + case WorkflowTemplateConstants.TimeoutMinutes: + ConvertToJobTimeout(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + jobTimeout = jobProperty.Value as ScalarToken; + break; + + case WorkflowTemplateConstants.CancelTimeoutMinutes: + ConvertToJobCancelTimeout(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + jobCancelTimeout = jobProperty.Value as ScalarToken; + break; + + case WorkflowTemplateConstants.Container: + ConvertToJobContainer(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + jobContainer = jobProperty.Value; + break; + + case WorkflowTemplateConstants.Services: + ConvertToJobServiceContainers(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + jobServiceContainers = jobProperty.Value; + break; + + case WorkflowTemplateConstants.Concurrency: + ConvertToConcurrency(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + concurrency = jobProperty.Value; + break; + case WorkflowTemplateConstants.Env: + env = jobProperty.Value; + break; + + case WorkflowTemplateConstants.Environment: + ConvertToActionEnvironmentReference(context, jobProperty.Value, isEarlyValidation: true); // Validate early if possible + actionsEnvironment = jobProperty.Value; + break; + + case WorkflowTemplateConstants.Outputs: + outputs = jobProperty.Value; + break; + + case WorkflowTemplateConstants.Defaults: + defaults = jobProperty.Value; + break; + + case WorkflowTemplateConstants.Permissions: + permissions = ConvertToPermissions(context, jobProperty.Value); + break; + + case WorkflowTemplateConstants.Uses: + workflowJobRef = jobProperty.Value.AssertString($"job {WorkflowTemplateConstants.Uses} value"); + break; + + case WorkflowTemplateConstants.With: + workflowJobInputs = jobProperty.Value.AssertMapping($"{WorkflowTemplateConstants.Uses}-{WorkflowTemplateConstants.With} value"); + break; + + case WorkflowTemplateConstants.Secrets: + // String in case inherit is used + if (jobProperty.Value is StringToken sToken + && sToken.Value == WorkflowTemplateConstants.Inherit) + { + workflowJobSecretsInherited = true; + } + else + { + workflowJobSecrets = jobProperty.Value.AssertMapping($"{WorkflowTemplateConstants.Uses}-{WorkflowTemplateConstants.Secrets} value"); + } + + break; + + default: + propertyName.AssertUnexpectedValue("job key"); // throws + break; + } + } + + if (workflowJobRef != null) + { + var workflowJob = new ReusableWorkflowJob + { + Id = jobId, + Name = name, + If = condition, + Ref = workflowJobRef, + InputValues = workflowJobInputs, + SecretValues = workflowJobSecrets, + InheritSecrets = workflowJobSecretsInherited, + Permissions = permissions, + Concurrency = concurrency, + Strategy = strategy, + }; + if (workflowJob.Name is null || workflowJob.Name is StringToken nameStr && String.IsNullOrEmpty(nameStr.Value)) + { + workflowJob.Name = workflowJob.Id; + } + workflowJob.Needs.AddRange(needs); + + return workflowJob; + } + else + { + var result = new Job + { + Id = jobId, + Name = name, + ContinueOnError = continueOnError, + If = condition, + RunsOn = jobTarget, + Strategy = strategy, + TimeoutMinutes = jobTimeout, + CancelTimeoutMinutes = jobCancelTimeout, + Container = jobContainer, + Services = jobServiceContainers, + Concurrency = concurrency, + Env = env, + Environment = actionsEnvironment, + Outputs = outputs, + Defaults = defaults, + Permissions = permissions, + Snapshot = snapshot, + }; + if (result.Name is null || result.Name is StringToken nameStr && String.IsNullOrEmpty(nameStr.Value)) + { + result.Name = result.Id; + } + + result.Needs.AddRange(needs); + result.Steps.AddRange(steps); + return result; + } + } + + internal static IDictionary ConvertToWorkflowJobSecrets( + TemplateContext context, + TemplateToken secrets) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var mapping = secrets.AssertMapping("workflow job secrets"); + foreach (var pair in mapping) + { + var key = pair.Key.AssertString("workflow job secret key").Value; + var value = pair.Value.AssertString("workflow job secret value").Value; + if (!String.IsNullOrEmpty(value)) + { + result[key] = value; + } + } + + return result; + } + + // Public because used by runner for composite actions + public static List ConvertToSteps( + TemplateContext context, + TemplateToken steps) + { + var stepsSequence = steps.AssertSequence($"job {WorkflowTemplateConstants.Steps}"); + + var idBuilder = new IdBuilder(); + var result = stepsSequence.Select(x => ConvertToStep(context, x, idBuilder)).ToList(); + + // Generate default IDs when empty + foreach (IStep step in result) + { + if (!string.IsNullOrEmpty(step.Id)) + { + continue; + } + + var id = default(string); + if (step is ActionStep action) + { + if (action.Uses!.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal)) + { + id = action.Uses!.Value.Substring(WorkflowTemplateConstants.DockerUriPrefix.Length); + } + else if (action.Uses!.Value.StartsWith("./") || action.Uses!.Value.StartsWith(".\\")) + { + id = WorkflowConstants.SelfAlias; + } + else + { + var usesSegments = action.Uses!.Value.Split('@'); + var pathSegments = usesSegments[0].Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + var gitRef = usesSegments.Length == 2 ? usesSegments[1] : String.Empty; + + if (usesSegments.Length == 2 && + pathSegments.Length >= 2 && + !string.IsNullOrEmpty(pathSegments[0]) && + !string.IsNullOrEmpty(pathSegments[1]) && + !string.IsNullOrEmpty(gitRef)) + { + id = $"{pathSegments[0]}/{pathSegments[1]}"; + } + } + } + + if (string.IsNullOrEmpty(id)) + { + id = "run"; + } + + idBuilder.AppendSegment($"__{id}"); + + // Allow reserved prefix "__" for default IDs + step.Id = idBuilder.Build(allowReservedPrefix: true); + } + + return result; + } + + private static IStep ConvertToStep( + TemplateContext context, + TemplateToken stepsItem, + IdBuilder idBuilder) + { + var step = stepsItem.AssertMapping($"{WorkflowTemplateConstants.Steps} item"); + var continueOnError = default(ScalarToken); + var env = default(TemplateToken); + var id = default(StringToken); + var ifCondition = default(BasicExpressionToken); + var ifToken = default(ScalarToken); + var name = default(ScalarToken); + var run = default(ScalarToken); + var timeoutMinutes = default(ScalarToken); + var uses = default(StringToken); + var with = default(TemplateToken); + var workingDir = default(ScalarToken); + var shell = default(ScalarToken); + + foreach (var stepProperty in step) + { + var propertyName = stepProperty.Key.AssertString($"{WorkflowTemplateConstants.Steps} item key"); + + switch (propertyName.Value) + { + case WorkflowTemplateConstants.ContinueOnError: + ConvertToStepContinueOnError(context, stepProperty.Value, isEarlyValidation: true); // Validate early if possible + continueOnError = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} {WorkflowTemplateConstants.ContinueOnError}"); + break; + + case WorkflowTemplateConstants.Env: + ConvertToStepEnvironment(context, stepProperty.Value, StringComparer.Ordinal, isEarlyValidation: true); // Validate early if possible + env = stepProperty.Value; + break; + + case WorkflowTemplateConstants.Id: + id = stepProperty.Value.AssertString($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Id}"); + if (!String.IsNullOrEmpty(id.Value) && + !idBuilder.TryAddKnownId(id.Value, out var error)) + { + context.Error(id, error); + } + break; + + case WorkflowTemplateConstants.If: + ifToken = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.If}"); + break; + + case WorkflowTemplateConstants.Name: + name = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Name}"); + break; + + case WorkflowTemplateConstants.Run: + run = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Run}"); + break; + + case WorkflowTemplateConstants.Shell: + shell = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Shell}"); + break; + + case WorkflowTemplateConstants.TimeoutMinutes: + ConvertToStepTimeout(context, stepProperty.Value, isEarlyValidation: true); // Validate early if possible + timeoutMinutes = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.TimeoutMinutes}"); + break; + + case WorkflowTemplateConstants.Uses: + uses = stepProperty.Value.AssertString($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Uses}"); + break; + + case WorkflowTemplateConstants.With: + ConvertToStepInputs(context, stepProperty.Value, isEarlyValidation: true); // Validate early if possible + with = stepProperty.Value; + break; + + case WorkflowTemplateConstants.WorkingDirectory: + workingDir = stepProperty.Value.AssertScalar($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.WorkingDirectory}"); + break; + + default: + propertyName.AssertUnexpectedValue($"{WorkflowTemplateConstants.Steps} item key"); // throws + break; + } + } + + // Fixup the if-condition + ifCondition = ConvertToIfCondition(context, ifToken, IfKind.Step); + + if (run != null) + { + return new RunStep + { + Id = id?.Value, + ContinueOnError = continueOnError, + Name = name, + If = ifCondition, + TimeoutMinutes = timeoutMinutes, + Env = env, + WorkingDirectory = workingDir, + Shell = shell, + Run = run, + }; + } + else + { + uses.AssertString($"{WorkflowTemplateConstants.Steps} item {WorkflowTemplateConstants.Uses}"); + var result = new ActionStep + { + Id = id?.Value, + ContinueOnError = continueOnError, + Name = name, + If = ifCondition, + TimeoutMinutes = timeoutMinutes, + Env = env, + Uses = uses, + With = with, + }; + + if (!uses.Value.StartsWith(WorkflowTemplateConstants.DockerUriPrefix, StringComparison.Ordinal) && + !uses.Value.StartsWith("./") && + !uses.Value.StartsWith(".\\")) + { + var usesSegments = uses.Value.Split('@'); + var pathSegments = usesSegments[0].Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + var gitRef = usesSegments.Length == 2 ? usesSegments[1] : String.Empty; + + if (usesSegments.Length != 2 || + pathSegments.Length < 2 || + String.IsNullOrEmpty(pathSegments[0]) || + String.IsNullOrEmpty(pathSegments[1]) || + String.IsNullOrEmpty(gitRef)) + { + context.Error(uses, $"Expected format {{org}}/{{repo}}[/path]@ref. Actual '{uses.Value}'"); + } + } + + return result; + } + } + + /// + /// When empty, default to "success()". + /// When a status function is not referenced, format as "success() && <CONDITION>". + /// + private static BasicExpressionToken ConvertToIfCondition( + TemplateContext context, + TemplateToken token, + IfKind ifKind) + { + String condition; + if (token is null) + { + condition = null; + } + else if (token is BasicExpressionToken expressionToken) + { + condition = expressionToken.Expression; + } + else + { + var stringToken = token.AssertString($"{ifKind} {WorkflowTemplateConstants.If}"); + condition = stringToken.Value; + } + + if (String.IsNullOrWhiteSpace(condition)) + { + return new BasicExpressionToken(token?.FileId, token?.Line, token?.Column, $"{WorkflowTemplateConstants.Success}()"); + } + + var expressionParser = new ExpressionParser(); + var functions = default(IFunctionInfo[]); + var namedValues = default(INamedValueInfo[]); + switch (ifKind) + { + case IfKind.Job: + namedValues = s_jobIfNamedValues; + functions = s_jobConditionFunctions; + break; + case IfKind.Step: + namedValues = s_stepNamedValues; + functions = s_stepConditionFunctions; + break; + case IfKind.Snapshot: + namedValues = s_snapshotIfNamedValues; + functions = s_snapshotConditionFunctions; + break; + default: + throw new ArgumentException($"Unexpected IfKind Enum value '{ifKind}' encountered while translating the token '{token}' to an IfCondition."); + } + + var node = default(ExpressionNode); + try + { + node = expressionParser.CreateTree(condition, null, namedValues, functions) as ExpressionNode; + } + catch (Exception ex) + { + context.Error(token, ex); + return null; + } + + if (node == null) + { + return new BasicExpressionToken(token?.FileId, token?.Line, token?.Column, $"{WorkflowTemplateConstants.Success}()"); + } + + var hasStatusFunction = node.Traverse().Any(x => + { + if (x is Function function) + { + return String.Equals(function.Name, WorkflowTemplateConstants.Always, StringComparison.OrdinalIgnoreCase) || + String.Equals(function.Name, WorkflowTemplateConstants.Cancelled, StringComparison.OrdinalIgnoreCase) || + String.Equals(function.Name, WorkflowTemplateConstants.Failure, StringComparison.OrdinalIgnoreCase) || + String.Equals(function.Name, WorkflowTemplateConstants.Success, StringComparison.OrdinalIgnoreCase); + } + + return false; + }); + + var finalCondition = hasStatusFunction ? condition : $"{WorkflowTemplateConstants.Success}() && ({condition})"; + return new BasicExpressionToken(token?.FileId, token?.Line, token?.Column, finalCondition); + } + + + private static Permissions ConvertToPermissions(TemplateContext context, TemplateToken token) + { + if (token is StringToken) + { + var permissionLevel = PermissionLevel.NoAccess; + var permissionsStr = token.AssertString("permissions"); + switch (permissionsStr.Value) + { + case "read-all": + permissionLevel = PermissionLevel.Read; + break; + case "write-all": + permissionLevel = PermissionLevel.Write; + break; + default: + permissionsStr.AssertUnexpectedValue(permissionsStr.Value); + break; + } + return new Permissions(permissionLevel, includeIdToken: true, includeAttestations: true, includeModels: context.GetFeatures().AllowModelsPermission); + } + + var mapping = token.AssertMapping("permissions"); + var permissions = new Permissions(); + foreach (var pair in mapping) + { + var key = pair.Key.AssertString("permissions.key"); + var permissionLevel = ConvertToPermissionLevel(context, pair.Value); + switch (key.Value) + { + case "actions": + permissions.Actions = permissionLevel; + break; + case "artifact-metadata": + permissions.ArtifactMetadata = permissionLevel; + break; + case "attestations": + permissions.Attestations = permissionLevel; + break; + case "checks": + permissions.Checks = permissionLevel; + break; + case "contents": + permissions.Contents = permissionLevel; + break; + case "deployments": + permissions.Deployments = permissionLevel; + break; + case "issues": + permissions.Issues = permissionLevel; + break; + case "discussions": + permissions.Discussions = permissionLevel; + break; + case "packages": + permissions.Packages = permissionLevel; + break; + case "pages": + permissions.Pages = permissionLevel; + break; + case "pull-requests": + permissions.PullRequests = permissionLevel; + break; + case "repository-projects": + permissions.RepositoryProjects = permissionLevel; + break; + case "statuses": + permissions.Statuses = permissionLevel; + break; + case "security-events": + permissions.SecurityEvents = permissionLevel; + break; + case "id-token": + if (context.GetFeatures().IdToken) + { + permissions.IdToken = permissionLevel; + } + else + { + context.Error(key, $"The key 'id-token' is not allowed"); + } + break; + case "models": + if (context.GetFeatures().AllowModelsPermission) + { + if (permissionLevel == PermissionLevel.Write) + { + permissions.Models = PermissionLevel.Read; + } + else + { + permissions.Models = permissionLevel; + } + } + else + { + context.Error(key, $"The permission 'models' is not allowed"); + } + break; + default: + break; + } + } + + return permissions; + } + + private static PermissionLevel ConvertToPermissionLevel( + TemplateContext context, + TemplateToken token) + { + var level = token.AssertString("permissions.value"); + switch (level.Value) + { + case "none": + return PermissionLevel.NoAccess; + case "read": + return PermissionLevel.Read; + case "write": + return PermissionLevel.Write; + default: + level.AssertUnexpectedValue(level.Value); + return PermissionLevel.NoAccess; + } + } + + private static void ValidateWorkflowJobTrigger( + TemplateContext context, + ReusableWorkflowJob workflowJob) + { + ConvertToWorkflowJobInputs(context, workflowJob.InputDefinitions, workflowJob.InputValues, workflowJob, isEarlyValidation: true); + ValidateWorkflowJobSecrets(context, workflowJob.SecretDefinitions, workflowJob.SecretValues, workflowJob); + } + + private static ExpressionData ConvertToInputValueDefinedType( + TemplateContext context, + string key, + StringToken definedType, + TemplateToken token, + Boolean isEarlyValidation = false) + { + var inputType = default(string); + + switch (definedType.Value) + { + case WorkflowTemplateConstants.TypeBoolean: + inputType = WorkflowTemplateConstants.BooleanNeedsContext; + + break; + case WorkflowTemplateConstants.TypeNumber: + inputType = WorkflowTemplateConstants.NumberNeedsContext; + + break; + case WorkflowTemplateConstants.TypeString: + inputType = WorkflowTemplateConstants.StringNeedsContext; + + break; + default: + // The schema for worflow_call.inputs only allows boolean, string, or number. + // We should have failed earlier if we receive any other type. + throw new ArgumentException($"Unexpected defined type '{definedType.Value}' when converting input value for '{key}'"); + } + + // Leverage the templating library to coerce or error + // + // During early validation, we're not actually evaluating any expressions with this call. + // Any allowed contexts (i.e. "github"/"inputs") have not been added yet, so the TemplateEvaluator + // will not unravel any expressions. + // + // During runtime, the expressions have already been expanded. + var result = TemplateEvaluator.Evaluate( + context, + inputType, + token, + context.Memory.CalculateBytes(token), // Remove the size of the template token that is being replaced + token.FileId + ); + + if (isEarlyValidation && token.Traverse().Any(x => x is ExpressionToken)) + { + return null; + } + + return result.ToExpressionData(); + } + + internal static IDictionary ConvertToWorkflowJobOutputs(TemplateToken workflowJobOutputDefinitions) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var outputs = workflowJobOutputDefinitions + .AssertMapping("workflow job output definitions") + .ToDictionary( + x => x.Key.AssertString("outputs key").Value, + x => x.Value, + StringComparer.OrdinalIgnoreCase + ); + + foreach (var definition in outputs) + { + var spec = definition.Value.AssertMapping("workfow job output spec").ToDictionary( + x => x.Key.AssertString("outputs spec key").Value, + x => x.Value, + StringComparer.OrdinalIgnoreCase + ); + + var value = spec["value"].AssertString("workfow job output value").Value; + + result.Add(definition.Key, value); + } + + return result; + } + + internal static DictionaryExpressionData ConvertToWorkflowJobInputs( + TemplateContext context, + TemplateToken workflowJobInputDefinitions, + TemplateToken workflowJobInputValues, + ReusableWorkflowJob workflowJob, + Boolean isEarlyValidation = false) + { + var result = default(DictionaryExpressionData); + var inputDefinitions = workflowJobInputDefinitions? + .AssertMapping("workflow job input definitions") + .ToDictionary( + x => x.Key.AssertString("inputs key").Value, + x => x.Value, + StringComparer.OrdinalIgnoreCase + ); + + var inputValues = workflowJobInputValues? + .AssertMapping("workflow job input values") + .ToDictionary( + x => x.Key.AssertString("with key").Value, + x => x.Value, + StringComparer.OrdinalIgnoreCase + ); + + if (inputDefinitions != null) + { + result = new DictionaryExpressionData(); + foreach (var definedItem in inputDefinitions) + { + string definedKey = definedItem.Key; + var definedInputSpec = definedItem.Value.AssertMapping($"input {definedKey}").ToDictionary( + x => x.Key.AssertString($"input {definedKey} key").Value, + x => x.Value, + StringComparer.OrdinalIgnoreCase); + + var inputSpecType = definedInputSpec[WorkflowTemplateConstants.Type].AssertString($"inputs {definedKey} type"); // must exist, per schema + + // if default provided, check with the defined type + if (definedInputSpec.TryGetValue(WorkflowTemplateConstants.Default, out TemplateToken defaultValue)) + { + var value = ConvertToInputValueDefinedType(context, definedKey, inputSpecType, defaultValue, isEarlyValidation); + if (!isEarlyValidation) + { + result.Add(definedKey, value); + } + } + else if (!isEarlyValidation) + { + result.Add(definedKey, GetDefaultValueByType(inputSpecType).ToExpressionData()); + } + + // if input provided, check with defined type and continue + if (inputValues != null && inputValues.TryGetValue(definedKey, out TemplateToken inputValue)) + { + var value = ConvertToInputValueDefinedType(context, definedKey, inputSpecType, inputValue, isEarlyValidation); + if (!isEarlyValidation) + { + result[definedKey] = value; + } + continue; + } + + // if input required but not provided, error out + if (isEarlyValidation + && definedInputSpec.TryGetValue(WorkflowTemplateConstants.Required, out TemplateToken requiredToken) + && requiredToken.AssertBoolean(WorkflowTemplateConstants.Required).Value) + { + context.Error(workflowJob.Ref, $"Input {definedKey} is required, but not provided while calling."); + continue; + } + } + } + + // Validating if any undefined inputs are provided + ValidateUndefinedParameters(context, inputDefinitions, inputValues, "input"); + + return result; + } + + private static void ValidateWorkflowJobSecrets( + TemplateContext context, + MappingToken workflowJobSecretDefinitions, + MappingToken workflowJobSecretValues, + ReusableWorkflowJob workflowJob) + { + // if the secrets are inherited from the caller, we do not have any workflowJob.SecretValues (i.e. explicit mapping) + // Inherited org/repo/env secrets will be stored in context variables and will be validated there + if (workflowJob.InheritSecrets) + { + return; + } + + var secretDefinitions = workflowJobSecretDefinitions?.ToDictionary( + x => x.Key.AssertString("secrets key").Value, + x => x.Value, StringComparer.OrdinalIgnoreCase); + + var secretValues = workflowJobSecretValues?.ToDictionary( + x => x.Key.AssertString("secrets key").Value, + x => x.Value, StringComparer.OrdinalIgnoreCase); + + if (secretDefinitions != null) + { + foreach (var definedItem in secretDefinitions) + { + if (definedItem.Value is NullToken nullToken) + { + continue; + } + + var definedKey = definedItem.Key.ToString(); + var definedSecretSpec = definedItem.Value.AssertMapping($"secret {definedKey}").ToDictionary( + x => x.Key.AssertString($"secret {definedKey} key").Value, + x => x.Value, StringComparer.OrdinalIgnoreCase); + + // if secret provided, continue + if (secretValues != null && secretValues.TryGetValue(definedKey, out TemplateToken secretValue)) + { + continue; + } + + // if secret required but not provided, error out + if (definedSecretSpec.TryGetValue(WorkflowTemplateConstants.Required, out TemplateToken requiredToken) + && requiredToken.AssertBoolean(WorkflowTemplateConstants.Required).Value) + { + context.Error(workflowJob.Ref, $"Secret {definedKey} is required, but not provided while calling."); + } + } + } + + // Validating if any undefined secrets are provided + ValidateUndefinedParameters(context, secretDefinitions, secretValues, WorkflowTemplateConstants.Secret); + } + + private static void ValidateUndefinedParameters( + TemplateContext context, + Dictionary definitions, + Dictionary providedValues, + string parameterType) + { + if (providedValues != null) + { + foreach (var providedValue in providedValues) + { + var providedKey = providedValue.Key; + if (definitions == null || !definitions.TryGetValue(providedKey, out TemplateToken value)) + { + context.Error(providedValue.Value, $"Invalid {parameterType}, {providedKey} is not defined in the referenced workflow."); + } + } + } + } + + private static TemplateToken GetDefaultValueByType(StringToken type) + { + return type.Value switch + { + WorkflowTemplateConstants.TypeString => new StringToken(type.FileId, type.Line, type.Column, string.Empty), + WorkflowTemplateConstants.TypeBoolean => new BooleanToken(type.FileId, type.Line, type.Column, false), + WorkflowTemplateConstants.TypeNumber => new NumberToken(type.FileId, type.Line, type.Column, 0.0), + _ => null, + }; + } + + private sealed class NodeInfo + { + public String Name { get; set; } + public List Needs { get; set; } + } + + private enum IfKind + { + Job = 0, + Step = 1, + Snapshot = 2 + } + private static readonly INamedValueInfo[] s_jobIfNamedValues = new INamedValueInfo[] + { + new NamedValueInfo(WorkflowTemplateConstants.GitHub), + new NamedValueInfo(WorkflowTemplateConstants.Vars), + new NamedValueInfo(WorkflowTemplateConstants.Inputs), + new NamedValueInfo(WorkflowTemplateConstants.Needs), + }; + private static readonly INamedValueInfo[] s_stepNamedValues = new INamedValueInfo[] + { + new NamedValueInfo(WorkflowTemplateConstants.GitHub), + new NamedValueInfo(WorkflowTemplateConstants.Vars), + new NamedValueInfo(WorkflowTemplateConstants.Inputs), + new NamedValueInfo(WorkflowTemplateConstants.Needs), + new NamedValueInfo(WorkflowTemplateConstants.Strategy), + new NamedValueInfo(WorkflowTemplateConstants.Matrix), + new NamedValueInfo(WorkflowTemplateConstants.Steps), + new NamedValueInfo(WorkflowTemplateConstants.Job), + new NamedValueInfo(WorkflowTemplateConstants.Runner), + new NamedValueInfo(WorkflowTemplateConstants.Env), + }; + private static readonly INamedValueInfo[] s_snapshotIfNamedValues = new INamedValueInfo[] + { + new NamedValueInfo(WorkflowTemplateConstants.GitHub), + new NamedValueInfo(WorkflowTemplateConstants.Vars), + new NamedValueInfo(WorkflowTemplateConstants.Inputs), + new NamedValueInfo(WorkflowTemplateConstants.Needs), + new NamedValueInfo(WorkflowTemplateConstants.Strategy), + new NamedValueInfo(WorkflowTemplateConstants.Matrix), + }; + private static readonly IFunctionInfo[] s_jobConditionFunctions = new IFunctionInfo[] + { + new FunctionInfo(WorkflowTemplateConstants.Always, 0, 0), + new FunctionInfo(WorkflowTemplateConstants.Failure, 0, Int32.MaxValue), + new FunctionInfo(WorkflowTemplateConstants.Cancelled, 0, 0), + new FunctionInfo(WorkflowTemplateConstants.Success, 0, Int32.MaxValue), + }; + private static readonly IFunctionInfo[] s_stepConditionFunctions = new IFunctionInfo[] + { + new FunctionInfo(WorkflowTemplateConstants.Always, 0, 0), + new FunctionInfo(WorkflowTemplateConstants.Cancelled, 0, 0), + new FunctionInfo(WorkflowTemplateConstants.Failure, 0, 0), + new FunctionInfo(WorkflowTemplateConstants.Success, 0, 0), + new FunctionInfo(WorkflowTemplateConstants.HashFiles, 1, Byte.MaxValue), + }; + private static readonly IFunctionInfo[] s_snapshotConditionFunctions = null; + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/YamlObjectReader.cs b/src/Sdk/WorkflowParser/Conversion/YamlObjectReader.cs new file mode 100644 index 000000000..970514e20 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/YamlObjectReader.cs @@ -0,0 +1,805 @@ +#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.Globalization; +using System.IO; +using System.Linq; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + /// + /// Converts a YAML file into a TemplateToken + /// + internal sealed class YamlObjectReader : IObjectReader + { + internal YamlObjectReader( + Int32? fileId, + TextReader input, + Boolean allowAnchors = false, + Telemetry telemetry = null) + { + m_fileId = fileId; + m_parser = new Parser(input); + m_allowAnchors = allowAnchors; + m_telemetry = telemetry ?? new Telemetry(); + m_events = new List(); + m_anchors = new Dictionary(); + m_replay = new Stack(); + } + + public Boolean AllowLiteral(out LiteralToken value) + { + if (EvaluateCurrent() is Scalar scalar) + { + // Tag specified + if (!String.IsNullOrEmpty(scalar.Tag)) + { + // String tag + if (String.Equals(scalar.Tag, c_stringTag, StringComparison.Ordinal)) + { + value = new StringToken(m_fileId, scalar.Start.Line, scalar.Start.Column, scalar.Value); + MoveNext(); + return true; + } + + // Not plain style + if (scalar.Style != ScalarStyle.Plain) + { + throw new NotSupportedException($"The scalar style '{scalar.Style}' on line {scalar.Start.Line} and column {scalar.Start.Column} is not valid with the tag '{scalar.Tag}'"); + } + + // Boolean, Float, Integer, or Null + switch (scalar.Tag) + { + case c_booleanTag: + value = ParseBoolean(scalar); + break; + case c_floatTag: + value = ParseFloat(scalar); + break; + case c_integerTag: + value = ParseInteger(scalar); + break; + case c_nullTag: + value = ParseNull(scalar); + break; + default: + throw new NotSupportedException($"Unexpected tag '{scalar.Tag}'"); + } + + MoveNext(); + return true; + } + + // Plain style, determine type using YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923 + if (scalar.Style == ScalarStyle.Plain) + { + if (MatchNull(scalar, out var nullToken)) + { + value = nullToken; + } + else if (MatchBoolean(scalar, out var booleanToken)) + { + value = booleanToken; + } + else if (MatchInteger(scalar, out var numberToken) || + MatchFloat(scalar, out numberToken)) + { + value = numberToken; + } + else + { + value = new StringToken(m_fileId, scalar.Start.Line, scalar.Start.Column, scalar.Value); + } + + MoveNext(); + return true; + } + + // Otherwise assume string + value = new StringToken(m_fileId, scalar.Start.Line, scalar.Start.Column, scalar.Value); + MoveNext(); + return true; + } + + value = default; + return false; + } + + public Boolean AllowSequenceStart(out SequenceToken value) + { + if (EvaluateCurrent() is SequenceStart sequenceStart) + { + value = new SequenceToken(m_fileId, sequenceStart.Start.Line, sequenceStart.Start.Column); + MoveNext(); + return true; + } + + value = default; + return false; + } + + public Boolean AllowSequenceEnd() + { + if (EvaluateCurrent() is SequenceEnd) + { + MoveNext(); + return true; + } + + return false; + } + + public Boolean AllowMappingStart(out MappingToken value) + { + if (EvaluateCurrent() is MappingStart mappingStart) + { + value = new MappingToken(m_fileId, mappingStart.Start.Line, mappingStart.Start.Column); + MoveNext(); + return true; + } + + value = default; + return false; + } + + public Boolean AllowMappingEnd() + { + if (EvaluateCurrent() is MappingEnd) + { + MoveNext(); + return true; + } + + return false; + } + + /// + /// Consumes the last parsing events, which are expected to be DocumentEnd and StreamEnd. + /// + public void ValidateEnd() + { + if (EvaluateCurrent() is DocumentEnd) + { + MoveNext(); + } + else + { + throw new InvalidOperationException("Expected document end parse event"); + } + + if (EvaluateCurrent() is StreamEnd) + { + MoveNext(); + } + else + { + throw new InvalidOperationException("Expected stream end parse event"); + } + + if (MoveNext()) + { + throw new InvalidOperationException("Expected end of parse events"); + } + } + + /// + /// Consumes the first parsing events, which are expected to be StreamStart and DocumentStart. + /// + public void ValidateStart() + { + if (EvaluateCurrent() != null) + { + throw new InvalidOperationException("Unexpected parser state"); + } + + if (!MoveNext()) + { + throw new InvalidOperationException("Expected a parse event"); + } + + if (EvaluateCurrent() is StreamStart) + { + MoveNext(); + } + else + { + throw new InvalidOperationException("Expected stream start parse event"); + } + + if (EvaluateCurrent() is DocumentStart) + { + MoveNext(); + } + else + { + throw new InvalidOperationException("Expected document start parse event"); + } + } + + private ParsingEvent EvaluateCurrent_Legacy() + { + if (m_current == null) + { + m_current = m_parser.Current; + if (m_current != null) + { + if (m_current is Scalar scalar) + { + // Verify not using achors + if (scalar.Anchor != null) + { + throw new InvalidOperationException($"Anchors are not currently supported. Remove the anchor '{scalar.Anchor}'"); + } + } + else if (m_current is MappingStart mappingStart) + { + // Verify not using achors + if (mappingStart.Anchor != null) + { + throw new InvalidOperationException($"Anchors are not currently supported. Remove the anchor '{mappingStart.Anchor}'"); + } + } + else if (m_current is SequenceStart sequenceStart) + { + // Verify not using achors + if (sequenceStart.Anchor != null) + { + throw new InvalidOperationException($"Anchors are not currently supported. Remove the anchor '{sequenceStart.Anchor}'"); + } + } + else if (!(m_current is MappingEnd) && + !(m_current is SequenceEnd) && + !(m_current is DocumentStart) && + !(m_current is DocumentEnd) && + !(m_current is StreamStart) && + !(m_current is StreamEnd)) + { + throw new InvalidOperationException($"Unexpected parsing event type: {m_current.GetType().Name}"); + } + } + } + + return m_current; + } + + private ParsingEvent EvaluateCurrent() + { + if (!m_allowAnchors) + { + return EvaluateCurrent_Legacy(); + } + + return m_current; + } + + private Boolean MoveNext_Legacy() + { + m_current = null; + return m_parser.MoveNext(); + } + + private Boolean MoveNext() + { + if (!m_allowAnchors) + { + return MoveNext_Legacy(); + } + + // Replaying an anchor? + // Adjust depth. + // Pop if done. + if (m_replay.Count > 0) + { + var replay = m_replay.Peek(); + + if (m_current is Scalar) + { + // Done? + if (replay.Depth == 0) + { + // Pop + m_replay.Pop(); + } + } + else if (m_current is SequenceStart || m_current is MappingStart) + { + // Increment depth + replay.Depth++; + } + else if (m_current is SequenceEnd || m_current is MappingEnd) + { + // Decrement depth + replay.Depth--; + + // Done? + if (replay.Depth == 0) + { + // Pop + m_replay.Pop(); + } + } + } + + // Still replaying? + if (m_replay.Count > 0) + { + var replay = m_replay.Peek(); + + // Move next + replay.Index++; + + // Store current + m_current = m_events[replay.Index]; + } + // Not replaying + else + { + // Move next + if (!m_parser.MoveNext()) + { + // Clear current + m_current = null; + + // Short-circuit + return false; + } + + // Store current + m_current = m_parser.Current; + + // Store event + m_events.Add(m_current); + + // Anchor? + var anchor = (m_current as NodeEvent)?.Anchor; + if (anchor != null) + { + // Not allowed? + if (!m_allowAnchors) + { + throw new InvalidOperationException($"Anchors are not currently supported. Remove the anchor '{anchor}'"); + } + + // Validate node type + if (m_current is not Scalar && m_current is not MappingStart && m_current is not SequenceStart) + { + throw new InvalidOperationException($"Unexpected node type with anchor '{anchor}': {m_current.GetType().Name}"); + } + + // Store anchor index + m_anchors[anchor] = m_events.Count - 1; + + // Count anchors + m_telemetry.YamlAnchors++; + } + + // Count aliases + if (m_current is AnchorAlias) + { + m_telemetry.YamlAliases++; + } + + // Validate node type + if (m_current is not Scalar && + m_current is not MappingStart && + m_current is not MappingEnd && + m_current is not SequenceStart && + m_current is not SequenceEnd && + m_current is not DocumentStart && + m_current is not DocumentEnd && + m_current is not StreamStart && + m_current is not StreamEnd && + m_current is not AnchorAlias) + { + throw new InvalidOperationException($"Unexpected parsing event type: {m_current.GetType().Name}"); + } + } + + // Alias? + if (m_current is AnchorAlias alias) + { + // Anchor index + if (!m_anchors.TryGetValue(alias.Value, out var anchorIndex)) + { + throw new InvalidOperationException($"Unknown anchor '{alias.Value}'"); + } + + // Move to anchor + m_current = m_events[anchorIndex]; + + // Push replay state + m_replay.Push(new YamlReplayState { Index = anchorIndex, Depth = 0 }); + } + + // Max nodes traversed? + m_numNodes++; + if (m_numNodes > c_maxYamlNodes) + { + throw new InvalidOperationException("Maximum YAML nodes exceeded"); + } + + return true; + } + + private BooleanToken ParseBoolean(Scalar scalar) + { + if (MatchBoolean(scalar, out var token)) + { + return token; + } + + ThrowInvalidValue(scalar, c_booleanTag); // throws + return default; + } + + private NumberToken ParseFloat(Scalar scalar) + { + if (MatchFloat(scalar, out var token)) + { + return token; + } + + ThrowInvalidValue(scalar, c_floatTag); // throws + return default; + } + + private NumberToken ParseInteger(Scalar scalar) + { + if (MatchInteger(scalar, out var token)) + { + return token; + } + + ThrowInvalidValue(scalar, c_integerTag); // throws + return default; + } + + private NullToken ParseNull(Scalar scalar) + { + if (MatchNull(scalar, out var token)) + { + return token; + } + + ThrowInvalidValue(scalar, c_nullTag); // throws + return default; + } + + private Boolean MatchBoolean( + Scalar scalar, + out BooleanToken value) + { + // YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923 + switch (scalar.Value ?? String.Empty) + { + case "true": + case "True": + case "TRUE": + value = new BooleanToken(m_fileId, scalar.Start.Line, scalar.Start.Column, true); + return true; + case "false": + case "False": + case "FALSE": + value = new BooleanToken(m_fileId, scalar.Start.Line, scalar.Start.Column, false); + return true; + } + + value = default; + return false; + } + + private Boolean MatchFloat( + Scalar scalar, + out NumberToken value) + { + // YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923 + var str = scalar.Value; + if (!String.IsNullOrEmpty(str)) + { + // Check for [-+]?(\.inf|\.Inf|\.INF)|\.nan|\.NaN|\.NAN + switch (str) + { + case ".inf": + case ".Inf": + case ".INF": + case "+.inf": + case "+.Inf": + case "+.INF": + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, Double.PositiveInfinity); + return true; + case "-.inf": + case "-.Inf": + case "-.INF": + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, Double.NegativeInfinity); + return true; + case ".nan": + case ".NaN": + case ".NAN": + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, Double.NaN); + return true; + } + + // Otherwise check [-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)? + + // Skip leading sign + var index = str[0] == '-' || str[0] == '+' ? 1 : 0; + + // Check for integer portion + var length = str.Length; + var hasInteger = false; + while (index < length && str[index] >= '0' && str[index] <= '9') + { + hasInteger = true; + index++; + } + + // Check for decimal point + var hasDot = false; + if (index < length && str[index] == '.') + { + hasDot = true; + index++; + } + + // Check for decimal portion + var hasDecimal = false; + while (index < length && str[index] >= '0' && str[index] <= '9') + { + hasDecimal = true; + index++; + } + + // Check [-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?) + if ((hasDot && hasDecimal) || hasInteger) + { + // Check for end + if (index == length) + { + // Try parse + if (Double.TryParse(str, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var doubleValue)) + { + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, doubleValue); + return true; + } + // Otherwise exceeds range + else + { + ThrowInvalidValue(scalar, c_floatTag); // throws + } + } + // Check [eE][-+]?[0-9] + else if (index < length && (str[index] == 'e' || str[index] == 'E')) + { + index++; + + // Skip sign + if (index < length && (str[index] == '-' || str[index] == '+')) + { + index++; + } + + // Check for exponent + var hasExponent = false; + while (index < length && str[index] >= '0' && str[index] <= '9') + { + hasExponent = true; + index++; + } + + // Check for end + if (hasExponent && index == length) + { + // Try parse + if (Double.TryParse(str, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var doubleValue)) + { + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, (Double)doubleValue); + return true; + } + // Otherwise exceeds range + else + { + ThrowInvalidValue(scalar, c_floatTag); // throws + } + } + } + } + } + + value = default; + return false; + } + + private Boolean MatchInteger( + Scalar scalar, + out NumberToken value) + { + // YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923 + var str = scalar.Value; + if (!String.IsNullOrEmpty(str)) + { + // Check for [0-9]+ + var firstChar = str[0]; + if (firstChar >= '0' && firstChar <= '9' && + str.Skip(1).All(x => x >= '0' && x <= '9')) + { + // Try parse + if (Double.TryParse(str, NumberStyles.None, CultureInfo.InvariantCulture, out var doubleValue)) + { + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, doubleValue); + return true; + } + + // Otherwise exceeds range + ThrowInvalidValue(scalar, c_integerTag); // throws + } + // Check for (-|+)[0-9]+ + else if ((firstChar == '-' || firstChar == '+') && + str.Length > 1 && + str.Skip(1).All(x => x >= '0' && x <= '9')) + { + // Try parse + if (Double.TryParse(str, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var doubleValue)) + { + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, doubleValue); + return true; + } + + // Otherwise exceeds range + ThrowInvalidValue(scalar, c_integerTag); // throws + } + // Check for 0x[0-9a-fA-F]+ + else if (firstChar == '0' && + str.Length > 2 && + str[1] == 'x' && + str.Skip(2).All(x => (x >= '0' && x <= '9') || (x >= 'a' && x <= 'f') || (x >= 'A' && x <= 'F'))) + { + // Try parse + if (Int32.TryParse(str.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var integerValue)) + { + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, integerValue); + return true; + } + + // Otherwise exceeds range + ThrowInvalidValue(scalar, c_integerTag); // throws + } + // Check for 0o[0-9]+ + else if (firstChar == '0' && + str.Length > 2 && + str[1] == 'o' && + str.Skip(2).All(x => x >= '0' && x <= '7')) + { + // Try parse + var integerValue = default(Int32); + try + { + integerValue = Convert.ToInt32(str.Substring(2), 8); + } + // Otherwise exceeds range + catch (Exception) + { + ThrowInvalidValue(scalar, c_integerTag); // throws + } + + value = new NumberToken(m_fileId, scalar.Start.Line, scalar.Start.Column, integerValue); + return true; + } + } + + value = default; + return false; + } + + private Boolean MatchNull( + Scalar scalar, + out NullToken value) + { + // YAML 1.2 "core" schema https://yaml.org/spec/1.2/spec.html#id2804923 + switch (scalar.Value ?? String.Empty) + { + case "": + case "null": + case "Null": + case "NULL": + case "~": + value = new NullToken(m_fileId, scalar.Start.Line, scalar.Start.Column); + return true; + } + + value = default; + return false; + } + + private void ThrowInvalidValue( + Scalar scalar, + String tag) + { + throw new NotSupportedException($"The value '{scalar.Value}' on line {scalar.Start.Line} and column {scalar.Start.Column} is invalid for the type '{tag}'"); + } + + /// + /// The maximum number of YAML nodes allowed when parsing a file. A single YAML node may be + /// encountered multiple times due to YAML anchors. + /// + /// Note, depth and maximum accumulated bytes are tracked in an outer layer. The goal of this + /// layer is to prevent YAML anchors from causing excessive node traversal. + /// + private const int c_maxYamlNodes = 50000; + + /// + /// Boolean YAML tag + /// + private const String c_booleanTag = "tag:yaml.org,2002:bool"; + + /// + /// Float YAML tag + /// + private const String c_floatTag = "tag:yaml.org,2002:float"; + + /// + /// Integer YAML tag + /// + private const String c_integerTag = "tag:yaml.org,2002:int"; + + /// + /// Null YAML tag + /// + private const String c_nullTag = "tag:yaml.org,2002:null"; + + /// + /// String YAML tag + /// + private const String c_stringTag = "tag:yaml.org,2002:str"; + + /// + /// File ID + /// + private readonly Int32? m_fileId; + + /// + /// Parser instance + /// + private readonly Parser m_parser; + + /// + /// Current parsing event + /// + private ParsingEvent m_current; + + /// + /// Indicates whether YAML anchors are allowed + /// + private readonly Boolean m_allowAnchors; + + /// + /// Telemetry data + /// + private readonly Telemetry m_telemetry; + + /// + /// Number of YAML nodes traversed + /// + private Int32 m_numNodes; + + /// + /// All encountered parsing events + /// + private readonly List m_events; + + /// + /// Anchor event index map + /// + private readonly Dictionary m_anchors; + + /// + /// Stack of anchor replay states + /// + private readonly Stack m_replay; + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/YamlObjectWriter.cs b/src/Sdk/WorkflowParser/Conversion/YamlObjectWriter.cs new file mode 100644 index 000000000..754b1e437 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/YamlObjectWriter.cs @@ -0,0 +1,73 @@ +using System; +using System.Globalization; +using System.IO; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using YamlDotNet.Core.Events; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + /// + /// Converts a TemplateToken into YAML + /// + internal sealed class YamlObjectWriter : IObjectWriter + { + internal YamlObjectWriter(StringWriter writer) + { + m_emitter = new YamlDotNet.Core.Emitter(writer); + } + + public void WriteString(String value) + { + m_emitter.Emit(new Scalar(value ?? String.Empty)); + } + + public void WriteBoolean(Boolean value) + { + m_emitter.Emit(new Scalar(value ? "true" : "false")); + } + + public void WriteNumber(Double value) + { + m_emitter.Emit(new Scalar(value.ToString("G15", CultureInfo.InvariantCulture))); + } + + public void WriteNull() + { + m_emitter.Emit(new Scalar("null")); + } + + public void WriteSequenceStart() + { + m_emitter.Emit(new SequenceStart(null, null, true, SequenceStyle.Block)); + } + + public void WriteSequenceEnd() + { + m_emitter.Emit(new SequenceEnd()); + } + + public void WriteMappingStart() + { + m_emitter.Emit(new MappingStart()); + } + + public void WriteMappingEnd() + { + m_emitter.Emit(new MappingEnd()); + } + + public void WriteStart() + { + m_emitter.Emit(new StreamStart()); + m_emitter.Emit(new DocumentStart()); + } + + public void WriteEnd() + { + m_emitter.Emit(new DocumentEnd(isImplicit: true)); + m_emitter.Emit(new StreamEnd()); + } + + private readonly YamlDotNet.Core.IEmitter m_emitter; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/Conversion/YamlReplayState.cs b/src/Sdk/WorkflowParser/Conversion/YamlReplayState.cs new file mode 100644 index 000000000..efaf6f930 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/YamlReplayState.cs @@ -0,0 +1,23 @@ +#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; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + /// + /// Index and depth while replaying a YAML anchor + /// + sealed class YamlReplayState + { + /// + /// Gets or sets the current node event index that is being replayed. + /// + public Int32 Index { get; set; } + + /// + /// Gets or sets the depth within the current anchor that is being replayed. + /// When the depth reaches zero, the anchor replay is complete. + /// + public Int32 Depth { get; set; } + } +} diff --git a/src/Sdk/WorkflowParser/Conversion/YamlTemplateLoader.cs b/src/Sdk/WorkflowParser/Conversion/YamlTemplateLoader.cs new file mode 100644 index 000000000..6cf624f27 --- /dev/null +++ b/src/Sdk/WorkflowParser/Conversion/YamlTemplateLoader.cs @@ -0,0 +1,124 @@ +#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.IO; +using System.Threading; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.Conversion +{ + /// + /// Loads a YAML file, and returns the parsed TemplateToken + /// + internal sealed class YamlTemplateLoader + { + public YamlTemplateLoader( + ParseOptions parseOptions, + IFileProvider fileProvider) + { + m_parseOptions = new ParseOptions(parseOptions); + m_fileProvider = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider)); + } + + /// + /// Parses a workflow template. + /// + /// Check for errors. + /// + public TemplateToken ParseWorkflow( + TemplateContext context, + String path) + { + var result = default(TemplateToken); + try + { + result = LoadFile(context, path, WorkflowTemplateConstants.WorkflowRoot); + } + catch (Exception ex) + { + context.Errors.Add(ex); + } + + return result; + } + + private TemplateToken LoadFile( + TemplateContext context, + String path, + String templateType) + { + if (context.Errors.Count > 0) + { + throw new InvalidOperationException("Expected error count to be 0 when attempting to load a new file"); + } + + // Is entry file? + var isEntryFile = m_referencedFiles.Count == 0; + + // Root the path + path = m_fileProvider.ResolvePath(null, path); + + // Validate max files + m_referencedFiles.Add(path); + if (m_parseOptions.MaxFiles > 0 && m_referencedFiles.Count > m_parseOptions.MaxFiles) + { + throw new InvalidOperationException($"The maximum file count of {m_parseOptions.MaxFiles} has been exceeded"); + } + + // Get the file ID + var fileId = context.GetFileId(path); + + // Check the cache + if (!m_cache.TryGetValue(path, out String fileContent)) + { + // Fetch the file + context.CancellationToken.ThrowIfCancellationRequested(); + fileContent = m_fileProvider.GetFileContent(path); + + // Validate max file size + if (fileContent.Length > m_parseOptions.MaxFileSize) + { + throw new InvalidOperationException($"{path}: The maximum file size of {m_parseOptions.MaxFileSize} characters has been exceeded"); + } + + // Cache + m_cache[path] = fileContent; + } + + // Deserialize + var token = default(TemplateToken); + using (var stringReader = new StringReader(fileContent)) + { + var yamlObjectReader = new YamlObjectReader(fileId, stringReader, m_parseOptions.AllowAnchors, context.Telemetry); + token = TemplateReader.Read(context, templateType, yamlObjectReader, fileId, out _); + } + + // Trace + if (!isEntryFile) + { + context.TraceWriter.Info(String.Empty); + } + context.TraceWriter.Info("# "); + context.TraceWriter.Info("# {0}", path); + context.TraceWriter.Info("# "); + + return token; + } + + /// + /// Cache of file content + /// + private readonly Dictionary m_cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + private readonly IFileProvider m_fileProvider; + + private readonly ParseOptions m_parseOptions; + + /// + /// Tracks unique file references + /// + private readonly HashSet m_referencedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Sdk/WorkflowParser/EnumerableExtensions.cs b/src/Sdk/WorkflowParser/EnumerableExtensions.cs new file mode 100644 index 000000000..641adb609 --- /dev/null +++ b/src/Sdk/WorkflowParser/EnumerableExtensions.cs @@ -0,0 +1,23 @@ +#nullable disable // Temporary: should be removed and issues fixed manually + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GitHub.Actions.WorkflowParser +{ + internal static class EnumerableExtensions + { + /// + /// Creates a HashSet with equality comparer based on the elements + /// in , using transformation function . + /// + public static HashSet ToHashSet( + this IEnumerable source, + Func selector, + IEqualityComparer comparer) + { + return new HashSet(source.Select(selector), comparer); + } + } +} diff --git a/src/Sdk/WorkflowParser/FileInfo.cs b/src/Sdk/WorkflowParser/FileInfo.cs new file mode 100644 index 000000000..367033379 --- /dev/null +++ b/src/Sdk/WorkflowParser/FileInfo.cs @@ -0,0 +1,52 @@ +#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.ComponentModel; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace GitHub.Actions.WorkflowParser +{ + // Actions service should not use this class at all. + [DataContract] + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class FileInfo + { + [JsonConstructor] + public FileInfo() + { + } + + private FileInfo(FileInfo infoToClone) + { + this.Path = infoToClone.Path; + this.NWO = infoToClone.NWO; + this.ResolvedRef = infoToClone.ResolvedRef; + this.ResolvedSha = infoToClone.ResolvedSha; + this.IsTrusted = infoToClone.IsTrusted; + this.IsRequired = infoToClone.IsRequired; + } + + [DataMember(Name = "path", EmitDefaultValue = false)] + public string Path { get; set; } + + [DataMember(Name = "nwo", EmitDefaultValue = false)] + public string NWO { get; set; } + + [DataMember(Name = "resolved_ref", EmitDefaultValue = false)] + public string ResolvedRef { get; set; } + + [DataMember(Name = "resolved_sha", EmitDefaultValue = false)] + public string ResolvedSha { get; set; } + + [DataMember(Name = "is_trusted", EmitDefaultValue = false)] + public bool IsTrusted { get; set; } + + [DataMember(Name = "is_required", EmitDefaultValue = false)] + public bool IsRequired { get; set; } + + public FileInfo Clone() + { + return new FileInfo(this); + } + } +} diff --git a/src/Sdk/WorkflowParser/GroupPermitSetting.cs b/src/Sdk/WorkflowParser/GroupPermitSetting.cs new file mode 100644 index 000000000..377a36a4c --- /dev/null +++ b/src/Sdk/WorkflowParser/GroupPermitSetting.cs @@ -0,0 +1,21 @@ +using System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser +{ + /// + /// Information about concurrency setting parsed from YML + /// + [DataContract] + public sealed class GroupPermitSetting + { + public GroupPermitSetting(string group) { + Group = group; + } + + [DataMember] + public string Group { get; set; } + + [DataMember] + public bool CancelInProgress { get; set; } + } +} diff --git a/src/Sdk/WorkflowParser/IFileProvider.cs b/src/Sdk/WorkflowParser/IFileProvider.cs new file mode 100644 index 000000000..ecd5eb6f9 --- /dev/null +++ b/src/Sdk/WorkflowParser/IFileProvider.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Actions.WorkflowParser +{ + public interface IFileProvider + { + String GetFileContent(String path); + + String ResolvePath(String defaultRoot, String path); + } +} diff --git a/src/Sdk/WorkflowParser/IJob.cs b/src/Sdk/WorkflowParser/IJob.cs new file mode 100644 index 000000000..ef66c2012 --- /dev/null +++ b/src/Sdk/WorkflowParser/IJob.cs @@ -0,0 +1,37 @@ +#nullable enable + +using System.Collections.Generic; +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; +using Newtonsoft.Json; + +namespace GitHub.Actions.WorkflowParser +{ + [JsonConverter(typeof(IJobJsonConverter))] + public interface IJob + { + JobType Type + { + get; + } + + StringToken? Id + { + get; + set; + } + + IList Needs + { + get; + } + + public Permissions? Permissions + { + get; + set; + } + + IJob Clone(bool omitSource); + } +} diff --git a/src/Sdk/WorkflowParser/IJobJsonConverter.cs b/src/Sdk/WorkflowParser/IJobJsonConverter.cs new file mode 100644 index 000000000..57da53273 --- /dev/null +++ b/src/Sdk/WorkflowParser/IJobJsonConverter.cs @@ -0,0 +1,94 @@ +#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.Reflection; +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.WorkflowParser +{ + internal sealed class IJobJsonConverter : JsonConverter + { + public override Boolean CanWrite + { + get + { + return false; + } + } + + public override Boolean CanConvert(Type objectType) + { + return typeof(IJob).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()); + } + + public override Object ReadJson( + JsonReader reader, + Type objectType, + Object existingValue, + JsonSerializer serializer) + { + if (reader.TokenType != JsonToken.StartObject) + { + return null; + } + + JobType? jobType = null; + JObject value = JObject.Load(reader); + if (!value.TryGetValue("Type", StringComparison.OrdinalIgnoreCase, out JToken typeValue)) + { + return existingValue; + } + else + { + if (typeValue.Type == JTokenType.Integer) + { + jobType = (JobType)(int)typeValue; + } + else if (typeValue.Type == JTokenType.String) + { + JobType parsedType; + if (Enum.TryParse((String)typeValue, ignoreCase: true, result: out parsedType)) + { + jobType = parsedType; + } + } + } + + if (jobType == null) + { + return existingValue; + } + + Object newValue = null; + switch (jobType) + { + case JobType.Job: + newValue = new Job(); + break; + + case JobType.ReusableWorkflowJob: + newValue = new ReusableWorkflowJob(); + break; + } + + if (value != null) + { + using JsonReader objectReader = value.CreateReader(); + serializer.Populate(objectReader, newValue); + } + + return newValue; + } + + public override void WriteJson( + JsonWriter writer, + Object value, + JsonSerializer serializer) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Sdk/WorkflowParser/IServerTraceWriter.cs b/src/Sdk/WorkflowParser/IServerTraceWriter.cs new file mode 100644 index 000000000..2f5aa1865 --- /dev/null +++ b/src/Sdk/WorkflowParser/IServerTraceWriter.cs @@ -0,0 +1,12 @@ +using System; + +namespace GitHub.Actions.WorkflowParser +{ + public interface IServerTraceWriter + { + void TraceAlways( + Int32 tracepoint, + String format, + params Object[] arguments); + } +} diff --git a/src/Sdk/WorkflowParser/IStep.cs b/src/Sdk/WorkflowParser/IStep.cs new file mode 100644 index 000000000..9bbd24d35 --- /dev/null +++ b/src/Sdk/WorkflowParser/IStep.cs @@ -0,0 +1,18 @@ +#nullable enable + +using Newtonsoft.Json; + +namespace GitHub.Actions.WorkflowParser +{ + [JsonConverter(typeof(IStepJsonConverter))] + public interface IStep + { + string? Id + { + get; + set; + } + + IStep Clone(bool omitSource); + } +} diff --git a/src/Sdk/WorkflowParser/IStepJsonConverter.cs b/src/Sdk/WorkflowParser/IStepJsonConverter.cs new file mode 100644 index 000000000..0458f5f33 --- /dev/null +++ b/src/Sdk/WorkflowParser/IStepJsonConverter.cs @@ -0,0 +1,70 @@ +#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.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.WorkflowParser +{ + internal sealed class IStepJsonConverter : JsonConverter + { + public override Boolean CanWrite + { + get + { + return false; + } + } + + public override Boolean CanConvert(Type objectType) + { + return typeof(IStep).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()); + } + + public override Object ReadJson( + JsonReader reader, + Type objectType, + Object existingValue, + JsonSerializer serializer) + { + if (reader.TokenType != JsonToken.StartObject) + { + return null; + } + + JObject value = JObject.Load(reader); + Object newValue = null; + + if (value.TryGetValue("Uses", StringComparison.OrdinalIgnoreCase, out _)) + { + newValue = new ActionStep(); + } + else if (value.TryGetValue("Run", StringComparison.OrdinalIgnoreCase, out _)) + { + newValue = new RunStep(); + } + else + { + return existingValue; + } + + + if (value != null) + { + using JsonReader objectReader = value.CreateReader(); + serializer.Populate(objectReader, newValue); + } + + return newValue; + } + + public override void WriteJson( + JsonWriter writer, + Object value, + JsonSerializer serializer) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Sdk/WorkflowParser/Job.cs b/src/Sdk/WorkflowParser/Job.cs new file mode 100644 index 000000000..bdab5e003 --- /dev/null +++ b/src/Sdk/WorkflowParser/Job.cs @@ -0,0 +1,213 @@ +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public sealed class Job : IJob + { + [DataMember(Order = 0, Name = "type", EmitDefaultValue = true)] + public JobType Type + { + get + { + return JobType.Job; + } + } + + [DataMember(Order = 1, Name = "id", EmitDefaultValue = false)] + public StringToken? Id + { + get; + set; + } + + /// + /// Gets or sets the display name + /// + [DataMember(Order = 2, Name = "name", EmitDefaultValue = false)] + public ScalarToken? Name + { + get; + set; + } + + public IList Needs + { + get + { + if (m_needs == null) + { + m_needs = new List(); + } + return m_needs; + } + } + [DataMember(Order = 3, Name = "needs", EmitDefaultValue = false)] + private List? m_needs; + + [DataMember(Order = 4, Name = "if", EmitDefaultValue = false)] + public BasicExpressionToken? If + { + get; + set; + } + + [DataMember(Order = 5, Name = "strategy", EmitDefaultValue = false)] + public TemplateToken? Strategy + { + get; + set; + } + + [DataMember(Order = 6, Name = "continue-on-error", EmitDefaultValue = false)] + public ScalarToken? ContinueOnError + { + get; + set; + } + + [DataMember(Order = 7, Name = "timeout-minutes", EmitDefaultValue = false)] + public ScalarToken? TimeoutMinutes + { + get; + set; + } + + [DataMember(Order = 8, Name = "cancel-timeout-minutes", EmitDefaultValue = false)] + public ScalarToken? CancelTimeoutMinutes + { + get; + set; + } + + [DataMember(Order = 9, Name = "concurrency", EmitDefaultValue = false)] + public TemplateToken? Concurrency + { + get; + set; + } + + [DataMember(Order = 10, Name = "permissions", EmitDefaultValue = false)] + public Permissions? Permissions + { + get; + set; + } + + [DataMember(Order = 11, Name = "env", EmitDefaultValue = false)] + public TemplateToken? Env + { + get; + set; + } + + [DataMember(Order = 12, Name = "environment", EmitDefaultValue = false)] + public TemplateToken? Environment + { + get; + set; + } + + [DataMember(Order = 13, Name = "defaults", EmitDefaultValue = false)] + public TemplateToken? Defaults + { + get; + set; + } + + [DataMember(Order = 14, Name = "runs-on", EmitDefaultValue = false)] + public TemplateToken? RunsOn + { + get; + set; + } + + [DataMember(Order = 15, Name = "container", EmitDefaultValue = false)] + public TemplateToken? Container + { + get; + set; + } + + [DataMember(Order = 16, Name = "services", EmitDefaultValue = false)] + public TemplateToken? Services + { + get; + set; + } + + [DataMember(Order = 17, Name = "outputs", EmitDefaultValue = false)] + public TemplateToken? Outputs + { + get; + set; + } + + public IList Steps + { + get + { + if (m_steps == null) + { + m_steps = new List(); + } + return m_steps; + } + } + [DataMember(Order = 18, Name = "steps", EmitDefaultValue = false)] + private List? m_steps; + + [DataMember(Order = 19, Name = "snapshot", EmitDefaultValue = false)] + public TemplateToken? Snapshot + { + get; + set; + } + + public IJob Clone(bool omitSource) + { + var result = new Job + { + CancelTimeoutMinutes = CancelTimeoutMinutes?.Clone(omitSource) as ScalarToken, + Concurrency = Concurrency?.Clone(omitSource), + Container = Container?.Clone(omitSource), + ContinueOnError = ContinueOnError?.Clone(omitSource) as ScalarToken, + Defaults = Defaults?.Clone(omitSource), + Env = Env?.Clone(omitSource), + Environment = Environment?.Clone(omitSource), + Id = Id?.Clone(omitSource) as StringToken, + If = If?.Clone(omitSource) as BasicExpressionToken, + Name = Name?.Clone(omitSource) as ScalarToken, + Outputs = Outputs?.Clone(omitSource), + Permissions = Permissions?.Clone(), + RunsOn = RunsOn?.Clone(omitSource), + Services = Services?.Clone(omitSource), + Strategy = Strategy?.Clone(omitSource), + TimeoutMinutes = TimeoutMinutes?.Clone(omitSource) as ScalarToken, + Snapshot = Snapshot?.Clone(omitSource), + }; + result.Needs.AddRange(Needs.Select(x => (x.Clone(omitSource) as StringToken)!)); + result.Steps.AddRange(Steps.Select(x => x.Clone(omitSource))); + return result; + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_needs?.Count == 0) + { + m_needs = null; + } + + if (m_steps?.Count == 0) + { + m_steps = null; + } + } + } +} diff --git a/src/Sdk/WorkflowParser/JobContainer.cs b/src/Sdk/WorkflowParser/JobContainer.cs new file mode 100644 index 000000000..dfa173c10 --- /dev/null +++ b/src/Sdk/WorkflowParser/JobContainer.cs @@ -0,0 +1,86 @@ +#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; + +namespace GitHub.Actions.WorkflowParser +{ + public sealed class JobContainer + { + + /// + /// Gets or sets the environment which is provided to the container. + /// + public IDictionary Environment + { + get; + set; + } + + /// + /// Gets or sets the container image name. + /// + public String Image + { + get; + set; + } + + /// + /// Gets or sets the options used for the container instance. + /// + public String Options + { + get; + set; + } + + /// + /// Gets or sets the volumes which are mounted into the container. + /// + public IList Volumes + { + get; + set; + } + + /// + /// Gets or sets the ports which are exposed on the container. + /// + public IList Ports + { + get; + set; + } + + /// + /// Gets or sets the credentials used for pulling the container iamge. + /// + public ContainerRegistryCredentials Credentials + { + get; + set; + } + } + + public sealed class ContainerRegistryCredentials + { + /// + /// Gets or sets the user to authenticate to a registry with + /// + public String Username + { + get; + set; + } + + /// + /// Gets or sets the password to authenticate to a registry with + /// + public String Password + { + get; + set; + } + } +} diff --git a/src/Sdk/WorkflowParser/JobType.cs b/src/Sdk/WorkflowParser/JobType.cs new file mode 100644 index 000000000..a50deeba7 --- /dev/null +++ b/src/Sdk/WorkflowParser/JobType.cs @@ -0,0 +1,7 @@ +namespace GitHub.Actions.WorkflowParser; + +public enum JobType +{ + Job, + ReusableWorkflowJob, +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/ContextValueNode.cs b/src/Sdk/WorkflowParser/ObjectTemplating/ContextValueNode.cs new file mode 100644 index 000000000..dec6458f8 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/ContextValueNode.cs @@ -0,0 +1,21 @@ +#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.Sdk; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// This expression node retrieves a user-defined named-value. This is used during expression evaluation. + /// + internal sealed class ContextValueNode : NamedValue + { + protected override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + return (context.State as TemplateContext).ExpressionValues[Name]; + } + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/EmptyTraceWriter.cs b/src/Sdk/WorkflowParser/ObjectTemplating/EmptyTraceWriter.cs new file mode 100644 index 000000000..67d30ee27 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/EmptyTraceWriter.cs @@ -0,0 +1,25 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + internal class EmptyTraceWriter : ITraceWriter + { + public void Error( + String format, + params Object[] args) + { + } + + public void Info( + String format, + params Object[] args) + { + } + + public void Verbose( + String format, + params Object[] args) + { + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/ExpressionTraceWriter.cs b/src/Sdk/WorkflowParser/ObjectTemplating/ExpressionTraceWriter.cs new file mode 100644 index 000000000..90ed64785 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/ExpressionTraceWriter.cs @@ -0,0 +1,27 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Wraps an ITraceWriter so it can be passed for expression evaluation. + /// + internal sealed class ExpressionTraceWriter : GitHub.Actions.Expressions.ITraceWriter + { + public ExpressionTraceWriter(ITraceWriter trace) + { + m_trace = trace; + } + + public void Info(String message) + { + m_trace.Info("{0}", message); + } + + public void Verbose(String message) + { + m_trace.Verbose("{0}", message); + } + + private readonly ITraceWriter m_trace; + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/IObjectReader.cs b/src/Sdk/WorkflowParser/ObjectTemplating/IObjectReader.cs new file mode 100644 index 000000000..e1d13161f --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/IObjectReader.cs @@ -0,0 +1,26 @@ +using System; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Interface for reading a source object (or file). + /// This interface is used by TemplateReader to build a TemplateToken DOM. + /// + internal interface IObjectReader + { + Boolean AllowLiteral(out LiteralToken token); + + Boolean AllowSequenceStart(out SequenceToken token); + + Boolean AllowSequenceEnd(); + + Boolean AllowMappingStart(out MappingToken token); + + Boolean AllowMappingEnd(); + + void ValidateStart(); + + void ValidateEnd(); + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/IObjectWriter.cs b/src/Sdk/WorkflowParser/ObjectTemplating/IObjectWriter.cs new file mode 100644 index 000000000..f7154594c --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/IObjectWriter.cs @@ -0,0 +1,31 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Interface for building an object. This interface is used by + /// TemplateWriter to convert a TemplateToken DOM to another format. + /// + internal interface IObjectWriter + { + void WriteNull(); + + void WriteBoolean(Boolean value); + + void WriteNumber(Double value); + + void WriteString(String value); + + void WriteSequenceStart(); + + void WriteSequenceEnd(); + + void WriteMappingStart(); + + void WriteMappingEnd(); + + void WriteStart(); + + void WriteEnd(); + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/ITraceWriter.cs b/src/Sdk/WorkflowParser/ObjectTemplating/ITraceWriter.cs new file mode 100644 index 000000000..199451267 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/ITraceWriter.cs @@ -0,0 +1,19 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + public interface ITraceWriter + { + void Error( + String format, + params Object[] args); + + void Info( + String format, + params Object[] args); + + void Verbose( + String format, + params Object[] args); + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/ITraceWriterExtensions.cs b/src/Sdk/WorkflowParser/ObjectTemplating/ITraceWriterExtensions.cs new file mode 100644 index 000000000..cdc9ad0ed --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/ITraceWriterExtensions.cs @@ -0,0 +1,10 @@ +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + internal static class ITraceWriterExtensions + { + internal static GitHub.Actions.Expressions.ITraceWriter ToExpressionTraceWriter(this ITraceWriter trace) + { + return new ExpressionTraceWriter(trace); + } + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/BooleanDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/BooleanDefinition.cs new file mode 100644 index 000000000..9ae526613 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/BooleanDefinition.cs @@ -0,0 +1,57 @@ +using System; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal sealed class BooleanDefinition : ScalarDefinition + { + internal BooleanDefinition() + { + } + + internal BooleanDefinition(MappingToken definition) + : base(definition) + { + foreach (var definitionPair in definition) + { + var definitionKey = definitionPair.Key.AssertString($"{TemplateConstants.Definition} key"); + switch (definitionKey.Value) + { + case TemplateConstants.Boolean: + var mapping = definitionPair.Value.AssertMapping($"{TemplateConstants.Definition} {TemplateConstants.Boolean}"); + foreach (var mappingPair in mapping) + { + var mappingKey = mappingPair.Key.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Boolean} key"); + switch (mappingKey.Value) + { + default: + mappingKey.AssertUnexpectedValue($"{TemplateConstants.Definition} {TemplateConstants.Boolean} key"); + break; + } + } + break; + + case TemplateConstants.CoerceRaw: + continue; + + default: + definitionKey.AssertUnexpectedValue($"{TemplateConstants.Definition} key"); + break; + } + } + } + + internal override DefinitionType DefinitionType => DefinitionType.Boolean; + + internal override Boolean IsMatch(LiteralToken literal) + { + return literal is BooleanToken; + } + + internal override void Validate( + TemplateSchema schema, + String name) + { + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/Definition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/Definition.cs new file mode 100644 index 000000000..8a6325df7 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/Definition.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + /// + /// Defines the allowable schema for a user defined type + /// + internal abstract class Definition + { + protected Definition() + { + } + + protected Definition(MappingToken definition) + { + for (var i = 0; i < definition.Count; ) + { + var definitionKey = definition[i].Key.AssertString($"{TemplateConstants.Definition} key"); + if (String.Equals(definitionKey.Value, TemplateConstants.Context, StringComparison.Ordinal)) + { + var context = definition[i].Value.AssertSequence($"{TemplateConstants.Context}"); + definition.RemoveAt(i); + var readerContext = new HashSet(StringComparer.OrdinalIgnoreCase); + var evaluatorContext = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (TemplateToken item in context) + { + var itemStr = item.AssertString($"{TemplateConstants.Context} item").Value; + readerContext.Add(itemStr); + + // Remove min/max parameter info + var paramIndex = itemStr.IndexOf('('); + if (paramIndex > 0) + { + evaluatorContext.Add(String.Concat(itemStr.Substring(0, paramIndex + 1), ")")); + } + else + { + evaluatorContext.Add(itemStr); + } + } + + ReaderContext = readerContext.ToArray(); + EvaluatorContext = evaluatorContext.ToArray(); + } + else if (String.Equals(definitionKey.Value, TemplateConstants.Description, StringComparison.Ordinal)) + { + definition.RemoveAt(i); + } + else + { + i++; + } + } + } + + internal abstract DefinitionType DefinitionType { get; } + + /// + /// Used by the template reader to determine allowed expression values and functions. + /// Also used by the template reader to validate function min/max parameters. + /// + internal String[] ReaderContext { get; private set; } = new String[0]; + + /// + /// Used by the template evaluator to determine allowed expression values and functions. + /// The min/max parameter info is omitted. + /// + internal String[] EvaluatorContext { get; private set; } = new String[0]; + + internal abstract void Validate( + TemplateSchema schema, + String name); + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/DefinitionType.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/DefinitionType.cs new file mode 100644 index 000000000..93237f2fa --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/DefinitionType.cs @@ -0,0 +1,16 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal enum DefinitionType + { + Null, + Boolean, + Number, + String, + Sequence, + Mapping, + OneOf, + AllowedValues, + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/MappingDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/MappingDefinition.cs new file mode 100644 index 000000000..fcf8ba358 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/MappingDefinition.cs @@ -0,0 +1,111 @@ +#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 GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal sealed class MappingDefinition : Definition + { + internal MappingDefinition() + { + } + + internal MappingDefinition(MappingToken definition) + : base(definition) + { + foreach (var definitionPair in definition) + { + var definitionKey = definitionPair.Key.AssertString($"{TemplateConstants.Definition} key"); + switch (definitionKey.Value) + { + case TemplateConstants.Mapping: + var mapping = definitionPair.Value.AssertMapping($"{TemplateConstants.Definition} {TemplateConstants.Mapping}"); + foreach (var mappingPair in mapping) + { + var mappingKey = mappingPair.Key.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Mapping} key"); + switch (mappingKey.Value) + { + case TemplateConstants.Properties: + var properties = mappingPair.Value.AssertMapping($"{TemplateConstants.Definition} {TemplateConstants.Mapping} {TemplateConstants.Properties}"); + foreach (var propertiesPair in properties) + { + var propertyName = propertiesPair.Key.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Mapping} {TemplateConstants.Properties} key"); + Properties.Add(propertyName.Value, new PropertyDefinition(propertiesPair.Value)); + } + break; + + case TemplateConstants.LooseKeyType: + var looseKeyType = mappingPair.Value.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Mapping} {TemplateConstants.LooseKeyType}"); + LooseKeyType = looseKeyType.Value; + break; + + case TemplateConstants.LooseValueType: + var looseValueType = mappingPair.Value.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Mapping} {TemplateConstants.LooseValueType}"); + LooseValueType = looseValueType.Value; + break; + + default: + definitionKey.AssertUnexpectedValue($"{TemplateConstants.Definition} key"); + break; + } + } + break; + + case TemplateConstants.CoerceRaw: + continue; + + default: + definitionKey.AssertUnexpectedValue($"{TemplateConstants.Definition} key"); + break; + } + } + } + + internal override DefinitionType DefinitionType => DefinitionType.Mapping; + + internal String LooseKeyType { get; set; } + + internal String LooseValueType { get; set; } + + internal Dictionary Properties { get; } = new Dictionary(StringComparer.Ordinal); + + internal override void Validate( + TemplateSchema schema, + String name) + { + // Lookup loose key type + if (!String.IsNullOrEmpty(LooseKeyType)) + { + schema.GetDefinition(LooseKeyType); + + // Lookup loose value type + if (!String.IsNullOrEmpty(LooseValueType)) + { + schema.GetDefinition(LooseValueType); + } + else + { + throw new ArgumentException($"Property '{TemplateConstants.LooseKeyType}' is defined but '{TemplateConstants.LooseValueType}' is not defined on '{name}'"); + } + } + // Otherwise validate loose value type not be defined + else if (!String.IsNullOrEmpty(LooseValueType)) + { + throw new ArgumentException($"Property '{TemplateConstants.LooseValueType}' is defined but '{TemplateConstants.LooseKeyType}' is not defined"); + } + + // Lookup each property + foreach (var property in Properties) + { + if (String.IsNullOrEmpty(property.Value.Type)) + { + throw new ArgumentException($"Type not specified for the '{property.Key}' property on the '{name}' type"); + } + + schema.GetDefinition(property.Value.Type); + } + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/NullDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/NullDefinition.cs new file mode 100644 index 000000000..0c564768a --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/NullDefinition.cs @@ -0,0 +1,58 @@ +using System; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal sealed class NullDefinition : ScalarDefinition + { + internal NullDefinition() + { + } + + internal NullDefinition(MappingToken definition) + : base(definition) + { + foreach (var definitionPair in definition) + { + var definitionKey = definitionPair.Key.AssertString($"{TemplateConstants.Definition} key"); + switch (definitionKey.Value) + { + case TemplateConstants.Null: + var mapping = definitionPair.Value.AssertMapping($"{TemplateConstants.Definition} {TemplateConstants.Null}"); + foreach (var mappingPair in mapping) + { + var mappingKey = mappingPair.Key.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Null} key"); + switch (mappingKey.Value) + { + default: + mappingKey.AssertUnexpectedValue($"{TemplateConstants.Definition} {TemplateConstants.Null} key"); + break; + } + } + break; + + case TemplateConstants.AllowedValues: + case TemplateConstants.CoerceRaw: + continue; + + default: + definitionKey.AssertUnexpectedValue($"{TemplateConstants.Definition} key"); + break; + } + } + } + + internal override DefinitionType DefinitionType => DefinitionType.Null; + + internal override Boolean IsMatch(LiteralToken literal) + { + return literal is NullToken; + } + + internal override void Validate( + TemplateSchema schema, + String name) + { + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/NumberDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/NumberDefinition.cs new file mode 100644 index 000000000..816b1564a --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/NumberDefinition.cs @@ -0,0 +1,57 @@ +using System; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal sealed class NumberDefinition : ScalarDefinition + { + internal NumberDefinition() + { + } + + internal NumberDefinition(MappingToken definition) + : base(definition) + { + foreach (var definitionPair in definition) + { + var definitionKey = definitionPair.Key.AssertString($"{TemplateConstants.Definition} key"); + switch (definitionKey.Value) + { + case TemplateConstants.Number: + var mapping = definitionPair.Value.AssertMapping($"{TemplateConstants.Definition} {TemplateConstants.Number}"); + foreach (var mappingPair in mapping) + { + var mappingKey = mappingPair.Key.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Number} key"); + switch (mappingKey.Value) + { + default: + mappingKey.AssertUnexpectedValue($"{TemplateConstants.Definition} {TemplateConstants.Number} key"); + break; + } + } + break; + + case TemplateConstants.CoerceRaw: + continue; + + default: + definitionKey.AssertUnexpectedValue($"{TemplateConstants.Definition} key"); + break; + } + } + } + + internal override DefinitionType DefinitionType => DefinitionType.Number; + + internal override Boolean IsMatch(LiteralToken literal) + { + return literal is NumberToken; + } + + internal override void Validate( + TemplateSchema schema, + String name) + { + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/OneOfDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/OneOfDefinition.cs new file mode 100644 index 000000000..86179e021 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/OneOfDefinition.cs @@ -0,0 +1,191 @@ +#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.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + /// + /// Must resolve to exactly one of the referenced definitions + /// + internal sealed class OneOfDefinition : Definition + { + internal OneOfDefinition() + { + } + + internal OneOfDefinition(MappingToken definition) + : base(definition) + { + foreach (var definitionPair in definition) + { + var definitionKey = definitionPair.Key.AssertString($"{TemplateConstants.Definition} key"); + switch (definitionKey.Value) + { + case TemplateConstants.OneOf: + var oneOf = definitionPair.Value.AssertSequence(TemplateConstants.OneOf); + foreach (var oneOfItem in oneOf) + { + var reference = oneOfItem.AssertString(TemplateConstants.OneOf); + OneOf.Add(reference.Value); + } + break; + + case TemplateConstants.CoerceRaw: + continue; + + default: + definitionKey.AssertUnexpectedValue($"{TemplateConstants.Definition} key"); + break; + } + } + } + + internal override DefinitionType DefinitionType => DefinitionType.OneOf; + + internal List OneOf { get; } = new List(); + + internal override void Validate( + TemplateSchema schema, + String name) + { + if (OneOf.Count == 0) + { + throw new ArgumentException($"'{name}' does not contain any references"); + } + + var foundLooseKeyType = false; + var mappingDefinitions = default(List); + var sequenceDefinition = default(SequenceDefinition); + var nullDefinition = default(NullDefinition); + var booleanDefinition = default(BooleanDefinition); + var numberDefinition = default(NumberDefinition); + var stringDefinitions = default(List); + + foreach (var nestedType in OneOf) + { + var nestedDefinition = schema.GetDefinition(nestedType); + + if (nestedDefinition.ReaderContext.Length > 0) + { + throw new ArgumentException($"'{name}' is a one-of definition and references another definition that defines context. This is currently not supported."); + } + + if (nestedDefinition is MappingDefinition mappingDefinition) + { + if (mappingDefinitions == null) + { + mappingDefinitions = new List(); + } + + mappingDefinitions.Add(mappingDefinition); + + if (!String.IsNullOrEmpty(mappingDefinition.LooseKeyType)) + { + foundLooseKeyType = true; + } + } + else if (nestedDefinition is SequenceDefinition s) + { + // Multiple sequence definitions not allowed + if (sequenceDefinition != null) + { + throw new ArgumentException($"'{name}' refers to more than one '{TemplateConstants.Sequence}'"); + } + + sequenceDefinition = s; + } + else if (nestedDefinition is NullDefinition n) + { + // Multiple sequence definitions not allowed + if (nullDefinition != null) + { + throw new ArgumentException($"'{name}' refers to more than one '{TemplateConstants.Null}'"); + } + + nullDefinition = n; + } + else if (nestedDefinition is BooleanDefinition b) + { + // Multiple boolean definitions not allowed + if (booleanDefinition != null) + { + throw new ArgumentException($"'{name}' refers to more than one '{TemplateConstants.Boolean}'"); + } + + booleanDefinition = b; + } + else if (nestedDefinition is NumberDefinition num) + { + // Multiple number definitions not allowed + if (numberDefinition != null) + { + throw new ArgumentException($"'{name}' refers to more than one '{TemplateConstants.Number}'"); + } + + numberDefinition = num; + } + else if (nestedDefinition is StringDefinition stringDefinition) + { + // First string definition + if (stringDefinitions == null) + { + stringDefinitions = new List(); + } + // Multiple string definitions, all must be 'Constant' + else if ((stringDefinitions.Count == 1 && String.IsNullOrEmpty(stringDefinitions[0].Constant)) + || String.IsNullOrEmpty(stringDefinition.Constant)) + { + throw new ArgumentException($"'{name}' refers to more than one '{TemplateConstants.Scalar}', but some do not set '{TemplateConstants.Constant}'"); + } + + stringDefinitions.Add(stringDefinition); + } + else if (nestedDefinition is OneOfDefinition oneOfDefinition) + { + // Allow one-of to reference another one-of + } + else + { + throw new ArgumentException($"'{name}' refers to a '{nestedDefinition.DefinitionType}' definition"); + } + } + + if (mappingDefinitions?.Count > 1) + { + if (foundLooseKeyType) + { + throw new ArgumentException($"'{name}' refers to two mappings that both set '{TemplateConstants.LooseKeyType}'"); + } + + var seenProperties = new Dictionary(StringComparer.Ordinal); + + foreach (var mappingDefinition in mappingDefinitions) + { + foreach (var newProperty in mappingDefinition.Properties) + { + // Already seen + if (seenProperties.TryGetValue(newProperty.Key, out PropertyDefinition existingProperty)) + { + // Types match + if (String.Equals(existingProperty.Type, newProperty.Value.Type, StringComparison.Ordinal)) + { + continue; + } + + // Collision + throw new ArgumentException($"'{name}' contains two mappings with the same property, but each refers to a different type. All matching properties must refer to the same type."); + } + // New + else + { + seenProperties.Add(newProperty.Key, newProperty.Value); + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/PropertyDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/PropertyDefinition.cs new file mode 100644 index 000000000..5f805d9df --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/PropertyDefinition.cs @@ -0,0 +1,47 @@ +#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.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal sealed class PropertyDefinition + { + internal PropertyDefinition(TemplateToken token) + { + if (token is StringToken stringToken) + { + Type = stringToken.Value; + } + else + { + var mapping = token.AssertMapping($"{TemplateConstants.MappingPropertyValue}"); + foreach (var mappingPair in mapping) + { + var mappingKey = mappingPair.Key.AssertString($"{TemplateConstants.MappingPropertyValue} key"); + switch (mappingKey.Value) + { + case TemplateConstants.Type: + Type = mappingPair.Value.AssertString($"{TemplateConstants.MappingPropertyValue} {TemplateConstants.Type}").Value; + break; + case TemplateConstants.Required: + Required = mappingPair.Value.AssertBoolean($"{TemplateConstants.MappingPropertyValue} {TemplateConstants.Required}").Value; + break; + case TemplateConstants.Description: + Description = mappingPair.Value.AssertString($"{TemplateConstants.MappingPropertyValue} {TemplateConstants.Description}").Value; + break; + default: + mappingKey.AssertUnexpectedValue($"{TemplateConstants.MappingPropertyValue} key"); + break; + } + } + } + } + + internal String Type { get; set; } + + internal Boolean Required { get; set; } + + internal String Description { get; set; } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/ScalarDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/ScalarDefinition.cs new file mode 100644 index 000000000..5c0143614 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/ScalarDefinition.cs @@ -0,0 +1,19 @@ +using System; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal abstract class ScalarDefinition : Definition + { + internal ScalarDefinition() + { + } + + internal ScalarDefinition(MappingToken definition) + : base(definition) + { + } + + internal abstract Boolean IsMatch(LiteralToken literal); + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/SequenceDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/SequenceDefinition.cs new file mode 100644 index 000000000..b9096d15c --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/SequenceDefinition.cs @@ -0,0 +1,69 @@ +#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.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal sealed class SequenceDefinition : Definition + { + internal SequenceDefinition() + { + } + + internal SequenceDefinition(MappingToken definition) + : base(definition) + { + foreach (var definitionPair in definition) + { + var definitionKey = definitionPair.Key.AssertString($"{TemplateConstants.Definition} key"); + + switch (definitionKey.Value) + { + case TemplateConstants.Sequence: + var mapping = definitionPair.Value.AssertMapping($"{TemplateConstants.Definition} {TemplateConstants.Sequence}"); + foreach (var mappingPair in mapping) + { + var mappingKey = mappingPair.Key.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Sequence} key"); + switch (mappingKey.Value) + { + case TemplateConstants.ItemType: + var itemType = mappingPair.Value.AssertString($"{TemplateConstants.Definition} {TemplateConstants.Sequence} {TemplateConstants.ItemType}"); + ItemType = itemType.Value; + break; + + default: + mappingKey.AssertUnexpectedValue($"{TemplateConstants.Definition} {TemplateConstants.Sequence} key"); + break; + } + } + break; + + case TemplateConstants.CoerceRaw: + continue; + + default: + definitionKey.AssertUnexpectedValue($"{TemplateConstants.Definition} key"); + break; + } + } + } + + internal override DefinitionType DefinitionType => DefinitionType.Sequence; + + internal String ItemType { get; set; } + + internal override void Validate( + TemplateSchema schema, + String name) + { + if (String.IsNullOrEmpty(ItemType)) + { + throw new ArgumentException($"'{name}' does not define '{TemplateConstants.ItemType}'"); + } + + // Lookup item type + schema.GetDefinition(ItemType); + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/StringDefinition.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/StringDefinition.cs new file mode 100644 index 000000000..a0b14a580 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/StringDefinition.cs @@ -0,0 +1,116 @@ +#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.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + internal sealed class StringDefinition : ScalarDefinition + { + internal StringDefinition() + { + } + + internal StringDefinition(MappingToken definition) + : base(definition) + { + foreach (var definitionPair in definition) + { + var definitionKey = definitionPair.Key.AssertString($"{TemplateConstants.Definition} key"); + switch (definitionKey.Value) + { + case TemplateConstants.String: + var mapping = definitionPair.Value.AssertMapping($"{TemplateConstants.Definition} {TemplateConstants.String}"); + foreach (var mappingPair in mapping) + { + var mappingKey = mappingPair.Key.AssertString($"{TemplateConstants.Definition} {TemplateConstants.String} key"); + switch (mappingKey.Value) + { + case TemplateConstants.Constant: + var constantStringToken = mappingPair.Value.AssertString($"{TemplateConstants.Definition} {TemplateConstants.String} {TemplateConstants.Constant}"); + Constant = constantStringToken.Value; + break; + + case TemplateConstants.IgnoreCase: + var ignoreCaseBooleanToken = mappingPair.Value.AssertBoolean($"{TemplateConstants.Definition} {TemplateConstants.String} {TemplateConstants.IgnoreCase}"); + IgnoreCase = ignoreCaseBooleanToken.Value; + break; + + case TemplateConstants.RequireNonEmpty: + var requireNonEmptyBooleanToken = mappingPair.Value.AssertBoolean($"{TemplateConstants.Definition} {TemplateConstants.String} {TemplateConstants.RequireNonEmpty}"); + RequireNonEmpty = requireNonEmptyBooleanToken.Value; + break; + + case TemplateConstants.IsExpression: + var isExpressionBooleanToken = mappingPair.Value.AssertBoolean($"{TemplateConstants.Definition} {TemplateConstants.String} {TemplateConstants.IsExpression}"); + IsExpression = isExpressionBooleanToken.Value; + break; + + default: + mappingKey.AssertUnexpectedValue($"{TemplateConstants.Definition} {TemplateConstants.String} key"); + break; + } + } + break; + + case TemplateConstants.CoerceRaw: + continue; + + default: + definitionKey.AssertUnexpectedValue($"{TemplateConstants.Definition} key"); + break; + } + } + } + + internal override DefinitionType DefinitionType => DefinitionType.String; + + internal String Constant { get; set; } + + internal Boolean IgnoreCase { get; set; } + + internal Boolean RequireNonEmpty { get; set; } + + internal Boolean IsExpression { get; set; } + + internal override Boolean IsMatch(LiteralToken literal) + { + if (literal is StringToken str) + { + var value = str.Value; + if (!String.IsNullOrEmpty(Constant)) + { + var comparison = IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + if (String.Equals(Constant, value, comparison)) + { + return true; + } + } + else if (RequireNonEmpty) + { + if (!String.IsNullOrEmpty(value)) + { + return true; + } + } + else + { + return true; + } + } + + return false; + } + + internal override void Validate( + TemplateSchema schema, + String name) + { + if (!String.IsNullOrEmpty(Constant) && RequireNonEmpty) + { + throw new ArgumentException($"Properties '{TemplateConstants.Constant}' and '{TemplateConstants.RequireNonEmpty}' cannot both be set"); + } + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Schema/TemplateSchema.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/TemplateSchema.cs new file mode 100644 index 000000000..43ba69b8d --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Schema/TemplateSchema.cs @@ -0,0 +1,484 @@ +#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 System.Text.RegularExpressions; +using System.Threading; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Schema +{ + /// + /// This models the root schema object and contains definitions + /// + public sealed class TemplateSchema + { + internal TemplateSchema() + : this(null) + { + } + + private TemplateSchema(MappingToken mapping) + { + // Add built-in type: null + var nullDefinition = new NullDefinition(); + Definitions.Add(TemplateConstants.Null, nullDefinition); + + // Add built-in type: boolean + var booleanDefinition = new BooleanDefinition(); + Definitions.Add(TemplateConstants.Boolean, booleanDefinition); + + // Add built-in type: number + var numberDefinition = new NumberDefinition(); + Definitions.Add(TemplateConstants.Number, numberDefinition); + + // Add built-in type: string + var stringDefinition = new StringDefinition(); + Definitions.Add(TemplateConstants.String, stringDefinition); + + // Add built-in type: sequence + var sequenceDefinition = new SequenceDefinition { ItemType = TemplateConstants.Any }; + Definitions.Add(TemplateConstants.Sequence, sequenceDefinition); + + // Add built-in type: mapping + var mappingDefinition = new MappingDefinition { LooseKeyType = TemplateConstants.String, LooseValueType = TemplateConstants.Any }; + Definitions.Add(TemplateConstants.Mapping, mappingDefinition); + + // Add built-in type: any + var anyDefinition = new OneOfDefinition(); + anyDefinition.OneOf.Add(TemplateConstants.Null); + anyDefinition.OneOf.Add(TemplateConstants.Boolean); + anyDefinition.OneOf.Add(TemplateConstants.Number); + anyDefinition.OneOf.Add(TemplateConstants.String); + anyDefinition.OneOf.Add(TemplateConstants.Sequence); + anyDefinition.OneOf.Add(TemplateConstants.Mapping); + Definitions.Add(TemplateConstants.Any, anyDefinition); + + if (mapping != null) + { + foreach (var pair in mapping) + { + var key = pair.Key.AssertString($"{TemplateConstants.TemplateSchema} key"); + switch (key.Value) + { + case TemplateConstants.Version: + var version = pair.Value.AssertString(TemplateConstants.Version); + Version = version.Value; + break; + + case TemplateConstants.Definitions: + var definitions = pair.Value.AssertMapping(TemplateConstants.Definitions); + foreach (var definitionsPair in definitions) + { + var definitionsKey = definitionsPair.Key.AssertString($"{TemplateConstants.Definitions} key"); + var definitionsValue = definitionsPair.Value.AssertMapping(TemplateConstants.Definition); + var definition = default(Definition); + foreach (var definitionPair in definitionsValue) + { + var definitionKey = definitionPair.Key.AssertString($"{TemplateConstants.Definition} key"); + switch (definitionKey.Value) + { + case TemplateConstants.Null: + definition = new NullDefinition(definitionsValue); + break; + + case TemplateConstants.Boolean: + definition = new BooleanDefinition(definitionsValue); + break; + + case TemplateConstants.Number: + definition = new NumberDefinition(definitionsValue); + break; + + case TemplateConstants.String: + definition = new StringDefinition(definitionsValue); + break; + + case TemplateConstants.Sequence: + definition = new SequenceDefinition(definitionsValue); + break; + + case TemplateConstants.Mapping: + definition = new MappingDefinition(definitionsValue); + break; + + case TemplateConstants.OneOf: + definition = new OneOfDefinition(definitionsValue); + break; + + case TemplateConstants.AllowedValues: + // Ignore allowed-values in CSharp parser, we don't need to support events here + definition = new NullDefinition(definitionsValue); + break; + + case TemplateConstants.CoerceRaw: + case TemplateConstants.Context: + case TemplateConstants.Description: + continue; + + default: + definitionKey.AssertUnexpectedValue("definition mapping key"); // throws + break; + } + + break; + } + + if (definition == null) + { + throw new ArgumentException($"Unable to determine definition details. Specify the '{TemplateConstants.Structure}' property"); + } + + Definitions.Add(definitionsKey.Value, definition); + } + break; + + default: + key.AssertUnexpectedValue($"{TemplateConstants.TemplateSchema} key"); // throws + break; + } + } + } + } + + internal Dictionary Definitions { get; } = new Dictionary(StringComparer.Ordinal); + + internal String Version { get; } + + /// + /// Loads a user's schema file + /// + internal static TemplateSchema Load(IObjectReader objectReader) + { + var context = new TemplateContext + { + CancellationToken = CancellationToken.None, + Errors = new TemplateValidationErrors(maxErrors: 10, maxMessageLength: 500), + Memory = new TemplateMemory( + maxDepth: 50, + maxEvents: 1000000, // 1 million + maxBytes: 1024 * 1024), // 1 mb + TraceWriter = new EmptyTraceWriter(), + }; + + var value = TemplateReader.Read(context, TemplateConstants.TemplateSchema, objectReader, null, Schema, out _); + + if (context.Errors.Count > 0) + { + throw new TemplateValidationException(context.Errors); + } + + var mapping = value.AssertMapping(TemplateConstants.TemplateSchema); + var schema = new TemplateSchema(mapping); + schema.Validate(); + return schema; + } + + internal IEnumerable Get(Definition definition) + where T : Definition + { + if (definition is T match) + { + yield return match; + } + else if (definition is OneOfDefinition oneOf) + { + foreach (var reference in oneOf.OneOf) + { + var nestedDefinition = GetDefinition(reference); + if (nestedDefinition is T match2) + { + yield return match2; + } + } + } + } + + internal Definition GetDefinition(String type) + { + if (Definitions.TryGetValue(type, out Definition value)) + { + return value; + } + + throw new ArgumentException($"Schema definition '{type}' not found"); + } + + internal Boolean HasProperties(MappingDefinition definition) + { + return definition.Properties.Count > 0; + } + + internal Boolean TryGetProperty( + MappingDefinition definition, + String name, + out String type) + { + if (definition.Properties.TryGetValue(name, out PropertyDefinition property)) + { + type = property.Type; + return true; + } + + type = null; + return false; + } + + internal Boolean TryMatchKey( + List definitions, + String key, + out String valueType) + { + valueType = null; + + // Check for a matching well known property + var notFoundInSome = false; + for (var i = 0; i < definitions.Count; i++) + { + var definition = definitions[i]; + + if (TryGetProperty(definition, key, out String t)) + { + if (valueType == null) + { + valueType = t; + } + } + else + { + notFoundInSome = true; + } + } + + // Check if found + if (valueType != null) + { + // Filter the matched definitions if needed + if (notFoundInSome) + { + for (var i = 0; i < definitions.Count;) + { + if (TryGetProperty(definitions[i], key, out _)) + { + i++; + } + else + { + definitions.RemoveAt(i); + } + } + } + + return true; + } + + return false; + } + + /// + /// The built-in schema for reading schema files + /// + private static TemplateSchema Schema + { + get + { + if (s_schema == null) + { + var schema = new TemplateSchema(); + + StringDefinition stringDefinition; + SequenceDefinition sequenceDefinition; + MappingDefinition mappingDefinition; + OneOfDefinition oneOfDefinition; + + // template-schema + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Version, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.NonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.Definitions, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Definitions))); + schema.Definitions.Add(TemplateConstants.TemplateSchema, mappingDefinition); + + // definitions + mappingDefinition = new MappingDefinition(); + mappingDefinition.LooseKeyType = TemplateConstants.NonEmptyString; + mappingDefinition.LooseValueType = TemplateConstants.Definition; + schema.Definitions.Add(TemplateConstants.Definitions, mappingDefinition); + + // definition + oneOfDefinition = new OneOfDefinition(); + oneOfDefinition.OneOf.Add(TemplateConstants.NullDefinition); + oneOfDefinition.OneOf.Add(TemplateConstants.BooleanDefinition); + oneOfDefinition.OneOf.Add(TemplateConstants.NumberDefinition); + oneOfDefinition.OneOf.Add(TemplateConstants.StringDefinition); + oneOfDefinition.OneOf.Add(TemplateConstants.SequenceDefinition); + oneOfDefinition.OneOf.Add(TemplateConstants.MappingDefinition); + oneOfDefinition.OneOf.Add(TemplateConstants.OneOfDefinition); + schema.Definitions.Add(TemplateConstants.Definition, oneOfDefinition); + + // null-definition + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Description, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.String))); + mappingDefinition.Properties.Add(TemplateConstants.CoerceRaw, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.Context, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.Null, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.NullDefinitionProperties))); + schema.Definitions.Add(TemplateConstants.NullDefinition, mappingDefinition); + + // null-definition-properties + mappingDefinition = new MappingDefinition(); + schema.Definitions.Add(TemplateConstants.NullDefinitionProperties, mappingDefinition); + + // boolean-definition + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Description, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.String))); + mappingDefinition.Properties.Add(TemplateConstants.CoerceRaw, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.Context, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.Boolean, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.BooleanDefinitionProperties))); + schema.Definitions.Add(TemplateConstants.BooleanDefinition, mappingDefinition); + + // boolean-definition-properties + mappingDefinition = new MappingDefinition(); + schema.Definitions.Add(TemplateConstants.BooleanDefinitionProperties, mappingDefinition); + + // number-definition + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Description, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.String))); + mappingDefinition.Properties.Add(TemplateConstants.CoerceRaw, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.Context, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.Number, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.NumberDefinitionProperties))); + schema.Definitions.Add(TemplateConstants.NumberDefinition, mappingDefinition); + + // number-definition-properties + mappingDefinition = new MappingDefinition(); + schema.Definitions.Add(TemplateConstants.NumberDefinitionProperties, mappingDefinition); + + // string-definition + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Description, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.String))); + mappingDefinition.Properties.Add(TemplateConstants.CoerceRaw, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.Context, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.String, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.StringDefinitionProperties))); + schema.Definitions.Add(TemplateConstants.StringDefinition, mappingDefinition); + + // string-definition-properties + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Constant, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.NonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.IgnoreCase, new PropertyDefinition(new StringToken(null, null, null,TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.RequireNonEmpty, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.IsExpression, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + schema.Definitions.Add(TemplateConstants.StringDefinitionProperties, mappingDefinition); + + // sequence-definition + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Description, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.String))); + mappingDefinition.Properties.Add(TemplateConstants.CoerceRaw, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.Context, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.Sequence, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceDefinitionProperties))); + schema.Definitions.Add(TemplateConstants.SequenceDefinition, mappingDefinition); + + // sequence-definition-properties + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.ItemType, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.NonEmptyString))); + schema.Definitions.Add(TemplateConstants.SequenceDefinitionProperties, mappingDefinition); + + // mapping-definition + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Description, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.String))); + mappingDefinition.Properties.Add(TemplateConstants.CoerceRaw, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.Context, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.Mapping, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.MappingDefinitionProperties))); + schema.Definitions.Add(TemplateConstants.MappingDefinition, mappingDefinition); + + // mapping-definition-properties + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Properties, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Properties))); + mappingDefinition.Properties.Add(TemplateConstants.LooseKeyType, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.NonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.LooseValueType, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.NonEmptyString))); + schema.Definitions.Add(TemplateConstants.MappingDefinitionProperties, mappingDefinition); + + // properties + mappingDefinition = new MappingDefinition(); + mappingDefinition.LooseKeyType = TemplateConstants.NonEmptyString; + mappingDefinition.LooseValueType = TemplateConstants.PropertyValue; + schema.Definitions.Add(TemplateConstants.Properties, mappingDefinition); + + // property-value + oneOfDefinition = new OneOfDefinition(); + oneOfDefinition.OneOf.Add(TemplateConstants.NonEmptyString); + oneOfDefinition.OneOf.Add(TemplateConstants.MappingPropertyValue); + schema.Definitions.Add(TemplateConstants.PropertyValue, oneOfDefinition); + + // mapping-property-value + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Type, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.NonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.Required, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.Description, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.String))); + schema.Definitions.Add(TemplateConstants.MappingPropertyValue, mappingDefinition); + + // one-of-definition + mappingDefinition = new MappingDefinition(); + mappingDefinition.Properties.Add(TemplateConstants.Description, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.String))); + mappingDefinition.Properties.Add(TemplateConstants.CoerceRaw, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.Boolean))); + mappingDefinition.Properties.Add(TemplateConstants.Context, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.OneOf, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + mappingDefinition.Properties.Add(TemplateConstants.AllowedValues, new PropertyDefinition(new StringToken(null, null, null, TemplateConstants.SequenceOfNonEmptyString))); + schema.Definitions.Add(TemplateConstants.OneOfDefinition, mappingDefinition); + + // non-empty-string + stringDefinition = new StringDefinition(); + stringDefinition.RequireNonEmpty = true; + schema.Definitions.Add(TemplateConstants.NonEmptyString, stringDefinition); + + // sequence-of-non-empty-string + sequenceDefinition = new SequenceDefinition(); + sequenceDefinition.ItemType = TemplateConstants.NonEmptyString; + schema.Definitions.Add(TemplateConstants.SequenceOfNonEmptyString, sequenceDefinition); + + schema.Validate(); + + Interlocked.CompareExchange(ref s_schema, schema, null); + } + + return s_schema; + } + } + + private void Validate() + { + var oneOfPairs = new List>(); + + foreach (var pair in Definitions) + { + var name = pair.Key; + + if (!s_definitionNameRegex.IsMatch(name ?? String.Empty)) + { + throw new ArgumentException($"Invalid definition name '{name}'"); + } + + var definition = pair.Value; + + // Delay validation for 'one-of' definitions + if (definition is OneOfDefinition oneOf) + { + oneOfPairs.Add(new KeyValuePair(name, oneOf)); + } + // Otherwise validate now + else + { + definition.Validate(this, name); + } + } + + // Validate 'one-of' definitions + foreach (var pair in oneOfPairs) + { + var name = pair.Key; + var oneOf = pair.Value; + oneOf.Validate(this, name); + } + } + + private static readonly Regex s_definitionNameRegex = new Regex("^[a-zA-Z_][a-zA-Z0-9_-]*$", RegexOptions.Compiled); + private static TemplateSchema s_schema; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Telemetry.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Telemetry.cs new file mode 100644 index 000000000..691a9ce89 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Telemetry.cs @@ -0,0 +1,20 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Tracks telemetry data during workflow parsing. + /// + public sealed class Telemetry + { + /// + /// Gets or sets the count of YAML anchors encountered during parsing. + /// + public Int32 YamlAnchors { get; set; } + + /// + /// Gets or sets the count of YAML aliases encountered during parsing. + /// + public Int32 YamlAliases { get; set; } + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateConstants.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateConstants.cs new file mode 100644 index 000000000..8931af52f --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateConstants.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + internal static class TemplateConstants + { + internal const String AllowedValues = "allowed-values"; + internal const String Any = "any"; + internal const String Boolean = "boolean"; + internal const String BooleanDefinition = "boolean-definition"; + internal const String BooleanDefinitionProperties = "boolean-definition-properties"; + internal const String CloseExpression = "}}"; + internal const String CoerceRaw = "coerce-raw"; + internal const String Constant = "constant"; + internal const String Context = "context"; + internal const String Definition = "definition"; + internal const String Definitions = "definitions"; + internal const String Description = "description"; + internal const String IgnoreCase = "ignore-case"; + internal const String InsertDirective = "insert"; + internal const String IsExpression = "is-expression"; + internal const String ItemType = "item-type"; + internal const String LooseKeyType = "loose-key-type"; + internal const String LooseValueType = "loose-value-type"; + internal const String MaxConstant = "MAX"; + internal const String Mapping = "mapping"; + internal const String MappingDefinition = "mapping-definition"; + internal const String MappingDefinitionProperties = "mapping-definition-properties"; + internal const String MappingPropertyValue = "mapping-property-value"; + internal const String NonEmptyString = "non-empty-string"; + internal const String Null = "null"; + internal const String NullDefinition = "null-definition"; + internal const String NullDefinitionProperties = "null-definition-properties"; + internal const String Number = "number"; + internal const String NumberDefinition = "number-definition"; + internal const String NumberDefinitionProperties = "number-definition-properties"; + internal const String OneOf = "one-of"; + internal const String OneOfDefinition = "one-of-definition"; + internal const String OpenExpression = "${{"; + internal const String PropertyValue = "property-value"; + internal const String Properties = "properties"; + internal const String Required = "required"; + internal const String RequireNonEmpty = "require-non-empty"; + internal const String Scalar = "scalar"; + internal const String Sequence = "sequence"; + internal const String SequenceDefinition = "sequence-definition"; + internal const String SequenceDefinitionProperties = "sequence-definition-properties"; + internal const String Type = "type"; + internal const String SequenceOfNonEmptyString = "sequence-of-non-empty-string"; + internal const String String = "string"; + internal const String StringDefinition = "string-definition"; + internal const String StringDefinitionProperties = "string-definition-properties"; + internal const String Structure = "structure"; + internal const String TemplateSchema = "template-schema"; + internal const String Version = "version"; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs new file mode 100644 index 000000000..8118b6b26 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateContext.cs @@ -0,0 +1,261 @@ +#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 System.Threading; +using GitHub.Actions.Expressions; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Schema; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Context object that is flowed through while loading and evaluating object templates + /// + public sealed class TemplateContext + { + internal CancellationToken CancellationToken { get; set; } + + internal TemplateValidationErrors Errors + { + get + { + if (m_errors == null) + { + m_errors = new TemplateValidationErrors(); + } + + return m_errors; + } + + set + { + m_errors = value; + } + } + + /// + /// Available functions within expression contexts + /// + internal IList ExpressionFunctions + { + get + { + if (m_expressionFunctions == null) + { + m_expressionFunctions = new List(); + } + + return m_expressionFunctions; + } + } + + /// + /// Available values within expression contexts + /// + internal IDictionary ExpressionValues + { + get + { + if (m_expressionValues == null) + { + m_expressionValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return m_expressionValues; + } + } + + internal TemplateMemory Memory { get; set; } + + internal TemplateSchema Schema { get; set; } + + /// + /// Gets or sets the telemetry data collected during parsing. + /// + public Telemetry Telemetry + { + get + { + if (m_telemetry == null) + { + m_telemetry = new Telemetry(); + } + + return m_telemetry; + } + set + { + m_telemetry = value; + } + } + + /// + /// State data for the current evaluation + /// + public IDictionary State + { + get + { + if (m_state == null) + { + m_state = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return m_state; + } + } + + /// + /// Gets or sets a value indicating whether the expression function fromJson performs strict JSON parsing. + /// Used during evaluation only. + /// + internal Boolean StrictJsonParsing { get; set; } + + internal ITraceWriter TraceWriter { get; set; } + + private IDictionary FileIds + { + get + { + if (m_fileIds == null) + { + m_fileIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return m_fileIds; + } + set + { + m_fileIds = value; + } + } + + private List FileNames + { + get + { + if (m_fileNames == null) + { + m_fileNames = new List(); + } + + return m_fileNames; + } + set + { + m_fileNames = value; + } + } + + internal void Error( + TemplateToken value, + Exception ex) + { + Error(value?.FileId, value?.Line, value?.Column, ex); + } + + internal void Error( + Int32? fileId, + Int32? line, + Int32? column, + Exception ex) + { + var prefix = GetErrorPrefix(fileId, line, column); + Errors.Add(prefix, ex); + TraceWriter.Error(prefix, ex); + } + + internal void Error( + TemplateToken value, + String message) + { + Error(value?.FileId, value?.Line, value?.Column, message); + } + + internal void Error( + Int32? fileId, + Int32? line, + Int32? column, + String message) + { + var prefix = GetErrorPrefix(fileId, line, column); + if (!String.IsNullOrEmpty(prefix)) + { + message = $"{prefix} {message}"; + } + + Errors.Add(message); + TraceWriter.Error(message); + } + + internal INamedValueInfo[] GetExpressionNamedValues() + { + if (m_expressionValues?.Count > 0) + { + return m_expressionValues.Keys.Select(x => new NamedValueInfo(x)).ToArray(); + } + + return null; + } + + internal Int32 GetFileId(String file) + { + if (!FileIds.TryGetValue(file, out Int32 id)) + { + id = FileIds.Count + 1; + FileIds.Add(file, id); + FileNames.Add(file); + Memory.AddBytes(file); + } + + return id; + } + + internal String GetFileName(Int32 fileId) + { + return FileNames.Count >= fileId ? FileNames[fileId - 1] : null; + } + + internal IReadOnlyList GetFileTable() + { + return FileNames.AsReadOnly(); + } + + private String GetErrorPrefix( + Int32? fileId, + Int32? line, + Int32? column) + { + var fileName = fileId.HasValue ? GetFileName(fileId.Value) : null; + if (!String.IsNullOrEmpty(fileName)) + { + if (line != null && column != null) + { + return $"{fileName} {TemplateStrings.LineColumn(line, column)}:"; + } + else + { + return $"{fileName}:"; + } + } + else if (line != null && column != null) + { + return $"{TemplateStrings.LineColumn(line, column)}:"; + } + else + { + return String.Empty; + } + } + + private TemplateValidationErrors m_errors; + private IList m_expressionFunctions; + private IDictionary m_expressionValues; + private IDictionary m_fileIds; + private List m_fileNames; + private IDictionary m_state; + private Telemetry m_telemetry; + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateEvaluator.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateEvaluator.cs new file mode 100644 index 000000000..7d5eeee4c --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateEvaluator.cs @@ -0,0 +1,428 @@ +#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.WorkflowParser.ObjectTemplating.Schema; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Expands expression tokens where the allowed context is available now. The allowed context is defined + /// within the schema. The available context is based on the ExpressionValues registered in the TemplateContext. + /// + internal partial class TemplateEvaluator + { + private TemplateEvaluator( + TemplateContext context, + TemplateToken template, + Int32 removeBytes) + { + m_context = context; + m_schema = context.Schema; + m_unraveler = new TemplateUnraveler(context, template, removeBytes); + } + + internal static TemplateToken Evaluate( + TemplateContext context, + String type, + TemplateToken template, + Int32 removeBytes, + Int32? fileId) + { + TemplateToken result; + + var evaluator = new TemplateEvaluator(context, template, removeBytes); + try + { + var availableContext = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var key in context.ExpressionValues.Keys) + { + availableContext.Add(key); + } + foreach (var function in context.ExpressionFunctions) + { + availableContext.Add($"{function.Name}()"); + } + + var definitionInfo = new DefinitionInfo(context.Schema, type, availableContext); + result = evaluator.Evaluate(definitionInfo); + + if (result != null) + { + evaluator.m_unraveler.ReadEnd(); + } + } + catch (Exception ex) + { + context.Error(fileId, null, null, ex); + result = null; + } + + return result; + } + + private TemplateToken Evaluate(DefinitionInfo definition) + { + // Scalar + if (m_unraveler.AllowScalar(definition.Expand, out ScalarToken scalar)) + { + if (scalar is LiteralToken literal) + { + Validate(ref literal, definition); + return literal; + } + else + { + return scalar; + } + } + + // Sequence start + if (m_unraveler.AllowSequenceStart(definition.Expand, out SequenceToken sequence)) + { + var sequenceDefinition = definition.Get().FirstOrDefault(); + + // Legal + if (sequenceDefinition != null) + { + var itemDefinition = new DefinitionInfo(definition, sequenceDefinition.ItemType); + + // Add each item + while (!m_unraveler.AllowSequenceEnd(definition.Expand)) + { + var item = Evaluate(itemDefinition); + sequence.Add(item); + } + } + // Illegal + else + { + // Error + m_context.Error(sequence, TemplateStrings.UnexpectedSequenceStart()); + + // Skip each item + while (!m_unraveler.AllowSequenceEnd(expand: false)) + { + m_unraveler.SkipSequenceItem(); + } + } + + return sequence; + } + + // Mapping + if (m_unraveler.AllowMappingStart(definition.Expand, out MappingToken mapping)) + { + var mappingDefinitions = definition.Get().ToList(); + + // Legal + if (mappingDefinitions.Count > 0) + { + if (mappingDefinitions.Count > 1 || + m_schema.HasProperties(mappingDefinitions[0]) || + String.IsNullOrEmpty(mappingDefinitions[0].LooseKeyType)) + { + HandleMappingWithWellKnownProperties(definition, mappingDefinitions, mapping); + } + else + { + var keyDefinition = new DefinitionInfo(definition, mappingDefinitions[0].LooseKeyType); + var valueDefinition = new DefinitionInfo(definition, mappingDefinitions[0].LooseValueType); + HandleMappingWithAllLooseProperties(definition, keyDefinition, valueDefinition, mapping); + } + } + // Illegal + else + { + m_context.Error(mapping, TemplateStrings.UnexpectedMappingStart()); + + while (!m_unraveler.AllowMappingEnd(expand: false)) + { + m_unraveler.SkipMappingKey(); + m_unraveler.SkipMappingValue(); + } + } + + return mapping; + } + + throw new ArgumentException(TemplateStrings.ExpectedScalarSequenceOrMapping()); + } + + private void HandleMappingWithWellKnownProperties( + DefinitionInfo definition, + List mappingDefinitions, + MappingToken mapping) + { + // Check if loose properties are allowed + String looseKeyType = null; + String looseValueType = null; + DefinitionInfo? looseKeyDefinition = null; + DefinitionInfo? looseValueDefinition = null; + if (!String.IsNullOrEmpty(mappingDefinitions[0].LooseKeyType)) + { + looseKeyType = mappingDefinitions[0].LooseKeyType; + looseValueType = mappingDefinitions[0].LooseValueType; + } + + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + var hasExpressionKey = false; + + while (m_unraveler.AllowScalar(definition.Expand, out ScalarToken nextKeyScalar)) + { + // Expression + if (nextKeyScalar is ExpressionToken) + { + hasExpressionKey = true; + var anyDefinition = new DefinitionInfo(definition, TemplateConstants.Any); + mapping.Add(nextKeyScalar, Evaluate(anyDefinition)); + continue; + } + + // Not a string, convert + if (!(nextKeyScalar is StringToken nextKey)) + { + nextKey = new StringToken(nextKeyScalar.FileId, nextKeyScalar.Line, nextKeyScalar.Column, nextKeyScalar.ToString()); + } + + // Duplicate + if (!keys.Add(nextKey.Value)) + { + m_context.Error(nextKey, TemplateStrings.ValueAlreadyDefined(nextKey.Value)); + m_unraveler.SkipMappingValue(); + continue; + } + + // Well known + if (m_schema.TryMatchKey(mappingDefinitions, nextKey.Value, out String nextValueType)) + { + var nextValueDefinition = new DefinitionInfo(definition, nextValueType); + var nextValue = Evaluate(nextValueDefinition); + mapping.Add(nextKey, nextValue); + continue; + } + + // Loose + if (looseKeyType != null) + { + if (looseKeyDefinition == null) + { + looseKeyDefinition = new DefinitionInfo(definition, looseKeyType); + looseValueDefinition = new DefinitionInfo(definition, looseValueType); + } + + Validate(nextKey, looseKeyDefinition.Value); + var nextValue = Evaluate(looseValueDefinition.Value); + mapping.Add(nextKey, nextValue); + continue; + } + + // Error + m_context.Error(nextKey, TemplateStrings.UnexpectedValue(nextKey.Value)); + m_unraveler.SkipMappingValue(); + } + + // Only one + if (mappingDefinitions.Count > 1) + { + var hitCount = new Dictionary(); + foreach (MappingDefinition mapdef in mappingDefinitions) + { + foreach (String key in mapdef.Properties.Keys) + { + if (!hitCount.TryGetValue(key, out Int32 value)) + { + hitCount.Add(key, 1); + } + else + { + hitCount[key] = value + 1; + } + } + } + + List nonDuplicates = new List(); + foreach (String key in hitCount.Keys) + { + if (hitCount[key] == 1) + { + nonDuplicates.Add(key); + } + } + nonDuplicates.Sort(); + + String listToDeDuplicate = String.Join(", ", nonDuplicates); + m_context.Error(mapping, TemplateStrings.UnableToDetermineOneOf(listToDeDuplicate)); + } + else if (mappingDefinitions.Count == 1 && !hasExpressionKey) + { + foreach (var property in mappingDefinitions[0].Properties) + { + if (property.Value.Required) + { + if (!keys.Contains(property.Key)) + { + m_context.Error(mapping, $"Required property is missing: {property.Key}"); + } + } + } + } + + m_unraveler.ReadMappingEnd(); + } + + private void HandleMappingWithAllLooseProperties( + DefinitionInfo mappingDefinition, + DefinitionInfo keyDefinition, + DefinitionInfo valueDefinition, + MappingToken mapping) + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + + while (m_unraveler.AllowScalar(mappingDefinition.Expand, out ScalarToken nextKeyScalar)) + { + // Expression + if (nextKeyScalar is ExpressionToken) + { + if (nextKeyScalar is BasicExpressionToken) + { + mapping.Add(nextKeyScalar, Evaluate(valueDefinition)); + } + else + { + var anyDefinition = new DefinitionInfo(mappingDefinition, TemplateConstants.Any); + mapping.Add(nextKeyScalar, Evaluate(anyDefinition)); + } + + continue; + } + + // Not a string + if (!(nextKeyScalar is StringToken nextKey)) + { + nextKey = new StringToken(nextKeyScalar.FileId, nextKeyScalar.Line, nextKeyScalar.Column, nextKeyScalar.ToString()); + } + + // Duplicate + if (!keys.Add(nextKey.Value)) + { + m_context.Error(nextKey, TemplateStrings.ValueAlreadyDefined(nextKey.Value)); + m_unraveler.SkipMappingValue(); + continue; + } + + // Validate + Validate(nextKey, keyDefinition); + + // Add the pair + var nextValue = Evaluate(valueDefinition); + mapping.Add(nextKey, nextValue); + } + + m_unraveler.ReadMappingEnd(); + } + + private void Validate( + StringToken stringToken, + DefinitionInfo definition) + { + var literal = stringToken as LiteralToken; + Validate(ref literal, definition); + } + + private void Validate( + ref LiteralToken literal, + DefinitionInfo definition) + { + // Legal + var literal2 = literal; + if (definition.Get().Any(x => x.IsMatch(literal2))) + { + return; + } + + // Not a string, convert + if (literal.Type != TokenType.String) + { + var stringToken = new StringToken(literal.FileId, literal.Line, literal.Column, literal.ToString()); + + // Legal + if (definition.Get().Any(x => x.IsMatch(stringToken))) + { + literal = stringToken; + return; + } + } + + // Illegal + m_context.Error(literal, TemplateStrings.UnexpectedValue(literal)); + } + + private struct DefinitionInfo + { + public DefinitionInfo( + TemplateSchema schema, + String name, + HashSet availableContext) + { + m_schema = schema; + m_availableContext = availableContext; + + // Lookup the definition + Definition = m_schema.GetDefinition(name); + + // Determine whether to expand + m_allowedContext = Definition.EvaluatorContext; + if (Definition.EvaluatorContext.Length > 0) + { + Expand = m_availableContext.IsSupersetOf(m_allowedContext); + } + else + { + Expand = false; + } + } + + public DefinitionInfo( + DefinitionInfo parent, + String name) + { + m_schema = parent.m_schema; + m_availableContext = parent.m_availableContext; + + // Lookup the definition + Definition = m_schema.GetDefinition(name); + + // Determine whether to expand + if (Definition.EvaluatorContext.Length > 0) + { + m_allowedContext = new HashSet(parent.m_allowedContext.Concat(Definition.EvaluatorContext), StringComparer.OrdinalIgnoreCase).ToArray(); + Expand = m_availableContext.IsSupersetOf(m_allowedContext); + } + else + { + m_allowedContext = parent.m_allowedContext; + Expand = parent.Expand; + } + } + + public IEnumerable Get() + where T : Definition + { + return m_schema.Get(Definition); + } + + private HashSet m_availableContext; + private String[] m_allowedContext; + private TemplateSchema m_schema; + public Definition Definition; + public Boolean Expand; + } + + private readonly TemplateContext m_context; + private readonly TemplateSchema m_schema; + private readonly TemplateUnraveler m_unraveler; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateMemory.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateMemory.cs new file mode 100644 index 000000000..2011888eb --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateMemory.cs @@ -0,0 +1,385 @@ +#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.Data; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Tracks characteristics about the current memory usage (CPU, stack, size) + /// + public sealed class TemplateMemory + { + /// + /// Creates a new instance + /// + /// The maximum allowed bytes + public TemplateMemory(Int32 maxBytes) + : this(0, 0, maxBytes: maxBytes, null) + { + } + + /// + /// Creates a new instance + /// + /// The maximum allowed depth + /// The maximum allowed events + /// The maximum allowed bytes + internal TemplateMemory( + Int32 maxDepth, + Int32 maxEvents, + Int32 maxBytes) + : this(maxDepth, maxEvents, maxBytes, null) + { + } + + /// + /// Creates a new instance + /// + /// The maximum allowed depth + /// The maximum allowed events + /// The maximum allowed bytes + /// Optional parent instance, for byte tracking only. Any bytes added/subtracted to the current instance, will be also added/subtracted to the parent instance. + internal TemplateMemory( + Int32 maxDepth, + Int32 maxEvents, + Int32 maxBytes, + TemplateMemory parent) + { + m_maxDepth = maxDepth; + m_maxEvents = maxEvents; + m_maxBytes = maxBytes; + m_parent = parent; + } + + public Int32 CurrentBytes => m_currentBytes; + + public Int32 MaxBytes => m_maxBytes; + + public void AddBytes(Int32 bytes) + { + checked + { + m_currentBytes += bytes; + } + + if (m_currentBytes > m_maxBytes) + { + throw new InvalidOperationException(TemplateStrings.MaxObjectSizeExceeded()); + } + + m_parent?.AddBytes(bytes); + } + + public void AddBytes(String value) + { + var bytes = CalculateBytes(value); + AddBytes(bytes); + } + + internal void AddBytes( + ExpressionData value, + Boolean traverse) + { + var bytes = CalculateBytes(value, traverse); + AddBytes(bytes); + } + + internal void AddBytes( + JToken value, + Boolean traverse) + { + var bytes = CalculateBytes(value, traverse); + AddBytes(bytes); + } + + internal void AddBytes( + TemplateToken value, + Boolean traverse = false) + { + var bytes = CalculateBytes(value, traverse); + AddBytes(bytes); + } + + internal void AddBytes(LiteralToken literal) + { + var bytes = CalculateBytes(literal); + AddBytes(bytes); + } + + internal void AddBytes(SequenceToken sequence) + { + var bytes = CalculateBytes(sequence); + AddBytes(bytes); + } + + internal void AddBytes(MappingToken mapping) + { + var bytes = CalculateBytes(mapping); + AddBytes(bytes); + } + + internal void AddBytes(BasicExpressionToken basicExpression) + { + var bytes = CalculateBytes(basicExpression); + AddBytes(bytes); + } + + internal void AddBytes(InsertExpressionToken insertExpression) + { + var bytes = CalculateBytes(insertExpression); + AddBytes(bytes); + } + + internal Int32 CalculateBytes(String value) + { + // This measurement doesn't have to be perfect + // https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/ + + checked + { + return StringBaseOverhead + ((value?.Length ?? 0) * sizeof(Char)); + } + } + + internal static Int32 CalculateBytes( + ExpressionData value, + Boolean traverse) + { + var enumerable = traverse ? value.Traverse() : new[] { value } as IEnumerable; + var result = 0; + foreach (var item in enumerable) + { + // This measurement doesn't have to be perfect + // https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/ + if (item is StringExpressionData str) + { + checked + { + result += TemplateMemory.MinObjectSize + TemplateMemory.StringBaseOverhead + (str.Value.Length * sizeof(Char)); + } + } + else if (item is ArrayExpressionData || item is DictionaryExpressionData || item is BooleanExpressionData || item is NumberExpressionData) + { + // Min object size is good enough. Allows for base + a few fields. + checked + { + result += TemplateMemory.MinObjectSize; + } + } + else if (item is null) + { + checked + { + result += IntPtr.Size; + } + } + else + { + throw new NotSupportedException($"Unexpected workflow context data type '{item.GetType().Name}'"); + } + } + + return result; + } + + internal Int32 CalculateBytes( + JToken value, + Boolean traverse) + { + // This measurement doesn't have to be perfect + // https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/ + + if (value is null) + { + return MinObjectSize; + } + + if (!traverse) + { + switch (value.Type) + { + case JTokenType.String: + checked + { + return StringBaseOverhead + (value.ToObject().Length * sizeof(Char)); + } + + case JTokenType.Property: + var property = value as JProperty; + checked + { + return StringBaseOverhead + ((property.Name?.Length ?? 0) * sizeof(Char)); + } + + case JTokenType.Array: + case JTokenType.Boolean: + case JTokenType.Float: + case JTokenType.Integer: + case JTokenType.Null: + case JTokenType.Object: + return MinObjectSize; + + default: + throw new NotSupportedException($"Unexpected JToken type '{value.Type}' when traversing object"); + } + } + + var result = 0; + do + { + // Descend as much as possible + while (true) + { + // Add bytes + var bytes = CalculateBytes(value, false); + checked + { + result += bytes; + } + + // Descend + if (value.HasValues) + { + value = value.First; + } + // No more descendants + else + { + break; + } + } + + // Next sibling or ancestor sibling + do + { + var sibling = value.Next; + + // Sibling found + if (sibling != null) + { + value = sibling; + break; + } + + // Ascend + value = value.Parent; + + } while (value != null); + + } while (value != null); + + return result; + } + + internal Int32 CalculateBytes( + TemplateToken value, + Boolean traverse = false) + { + var enumerable = traverse ? value.Traverse() : new[] { value }; + var result = 0; + foreach (var item in enumerable) + { + // This measurement doesn't have to be perfect + // https://codeblog.jonskeet.uk/2011/04/05/of-memory-and-strings/ + switch (item.Type) + { + case TokenType.Null: + case TokenType.Boolean: + case TokenType.Number: + checked + { + result += MinObjectSize; + } + break; + + case TokenType.String: + var stringToken = item as StringToken; + checked + { + result += MinObjectSize + StringBaseOverhead + ((stringToken.Value?.Length ?? 0) * sizeof(Char)); + } + break; + + case TokenType.Sequence: + case TokenType.Mapping: + case TokenType.InsertExpression: + // Min object size is good enough. Allows for base + a few fields. + checked + { + result += MinObjectSize; + } + break; + + case TokenType.BasicExpression: + var basicExpression = item as BasicExpressionToken; + checked + { + result += MinObjectSize + StringBaseOverhead + ((basicExpression.Expression?.Length ?? 0) * sizeof(Char)); + } + break; + + default: + throw new NotSupportedException($"Unexpected template type '{item.Type}'"); + } + } + + return result; + } + + internal void SubtractBytes(Int32 bytes) + { + if (bytes > m_currentBytes) + { + throw new InvalidOperationException("Bytes to subtract exceeds total bytes"); + } + + m_currentBytes -= bytes; + + m_parent?.SubtractBytes(bytes); + } + + internal void SubtractBytes( + TemplateToken value, + Boolean traverse = false) + { + var bytes = CalculateBytes(value, traverse); + SubtractBytes(bytes); + } + + internal void IncrementDepth() + { + if (m_depth++ >= m_maxDepth) + { + throw new InvalidOperationException(TemplateStrings.MaxObjectDepthExceeded()); + } + } + + internal void DecrementDepth() + { + m_depth--; + } + + internal void IncrementEvents() + { + if (m_events++ >= m_maxEvents) + { + throw new InvalidOperationException(TemplateStrings.MaxTemplateEventsExceeded()); + } + } + + internal const Int32 MinObjectSize = 24; + internal const Int32 StringBaseOverhead = 26; + private readonly Int32 m_maxDepth; + private readonly Int32 m_maxEvents; + private readonly Int32 m_maxBytes; + private Int32 m_depth; + private Int32 m_events; + private Int32 m_currentBytes; + private TemplateMemory m_parent; + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateReader.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateReader.cs new file mode 100644 index 000000000..6cd4ee804 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateReader.cs @@ -0,0 +1,832 @@ +#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.Globalization; +using System.Linq; +using System.Text; +using GitHub.Actions.Expressions.Sdk; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Schema; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Converts a source object format into a TemplateToken + /// + internal sealed class TemplateReader + { + private TemplateReader( + TemplateContext context, + TemplateSchema schema, + IObjectReader objectReader, + Int32? fileId) + { + m_context = context; + m_schema = schema; + m_memory = context.Memory; + m_objectReader = objectReader; + m_fileId = fileId; + } + + internal static TemplateToken Read( + TemplateContext context, + String type, + IObjectReader objectReader, + Int32? fileId, + out Int32 bytes) + { + return Read(context, type, objectReader, fileId, context.Schema, out bytes); + } + + internal static TemplateToken Read( + TemplateContext context, + String type, + IObjectReader objectReader, + Int32? fileId, + TemplateSchema schema, + out Int32 bytes) + { + TemplateToken result = null; + + var reader = new TemplateReader(context, schema, objectReader, fileId); + var originalBytes = context.Memory.CurrentBytes; + try + { + objectReader.ValidateStart(); + var definition = new DefinitionInfo(schema, type); + result = reader.ReadValue(definition); + objectReader.ValidateEnd(); + } + catch (Exception ex) + { + context.Error(fileId, null, null, ex); + } + finally + { + bytes = context.Memory.CurrentBytes - originalBytes; + } + + return result; + } + + private TemplateToken ReadValue(DefinitionInfo definition) + { + m_memory.IncrementEvents(); + + // Scalar + if (m_objectReader.AllowLiteral(out LiteralToken literal)) + { + var scalar = ParseScalar(literal, definition.AllowedContext); + Validate(ref scalar, definition); + m_memory.AddBytes(scalar); + return scalar; + } + + // Sequence + if (m_objectReader.AllowSequenceStart(out SequenceToken sequence)) + { + m_memory.IncrementDepth(); + m_memory.AddBytes(sequence); + + var sequenceDefinition = definition.Get().FirstOrDefault(); + + // Legal + if (sequenceDefinition != null) + { + var itemDefinition = new DefinitionInfo(definition, sequenceDefinition.ItemType); + + // Add each item + while (!m_objectReader.AllowSequenceEnd()) + { + var item = ReadValue(itemDefinition); + sequence.Add(item); + } + } + // Illegal + else + { + // Error + m_context.Error(sequence, TemplateStrings.UnexpectedSequenceStart()); + + // Skip each item + while (!m_objectReader.AllowSequenceEnd()) + { + SkipValue(); + } + } + + m_memory.DecrementDepth(); + return sequence; + } + + // Mapping + if (m_objectReader.AllowMappingStart(out MappingToken mapping)) + { + m_memory.IncrementDepth(); + m_memory.AddBytes(mapping); + + var mappingDefinitions = definition.Get().ToList(); + + // Legal + if (mappingDefinitions.Count > 0) + { + if (mappingDefinitions.Count > 1 || + m_schema.HasProperties(mappingDefinitions[0]) || + String.IsNullOrEmpty(mappingDefinitions[0].LooseKeyType)) + { + HandleMappingWithWellKnownProperties(definition, mappingDefinitions, mapping); + } + else + { + var keyDefinition = new DefinitionInfo(definition, mappingDefinitions[0].LooseKeyType); + var valueDefinition = new DefinitionInfo(definition, mappingDefinitions[0].LooseValueType); + HandleMappingWithAllLooseProperties(definition, keyDefinition, valueDefinition, mapping); + } + } + // Illegal + else + { + m_context.Error(mapping, TemplateStrings.UnexpectedMappingStart()); + + while (!m_objectReader.AllowMappingEnd()) + { + SkipValue(); + SkipValue(); + } + } + + m_memory.DecrementDepth(); + return mapping; + } + + throw new InvalidOperationException(TemplateStrings.ExpectedScalarSequenceOrMapping()); + } + + private void HandleMappingWithWellKnownProperties( + DefinitionInfo definition, + List mappingDefinitions, + MappingToken mapping) + { + // Check if loose properties are allowed + String looseKeyType = null; + String looseValueType = null; + DefinitionInfo? looseKeyDefinition = null; + DefinitionInfo? looseValueDefinition = null; + if (!String.IsNullOrEmpty(mappingDefinitions[0].LooseKeyType)) + { + looseKeyType = mappingDefinitions[0].LooseKeyType; + looseValueType = mappingDefinitions[0].LooseValueType; + } + + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + var hasExpressionKey = false; + + while (m_objectReader.AllowLiteral(out LiteralToken rawLiteral)) + { + var nextKeyScalar = ParseScalar(rawLiteral, definition.AllowedContext); + // Expression + if (nextKeyScalar is ExpressionToken) + { + hasExpressionKey = true; + // Legal + if (definition.AllowedContext.Length > 0) + { + m_memory.AddBytes(nextKeyScalar); + var anyDefinition = new DefinitionInfo(definition, TemplateConstants.Any); + mapping.Add(nextKeyScalar, ReadValue(anyDefinition)); + } + // Illegal + else + { + m_context.Error(nextKeyScalar, TemplateStrings.ExpressionNotAllowed()); + SkipValue(); + } + + continue; + } + + // Not a string, convert + if (!(nextKeyScalar is StringToken nextKey)) + { + nextKey = new StringToken(nextKeyScalar.FileId, nextKeyScalar.Line, nextKeyScalar.Column, nextKeyScalar.ToString()); + } + + // Duplicate + if (!keys.Add(nextKey.Value)) + { + m_context.Error(nextKey, TemplateStrings.ValueAlreadyDefined(nextKey.Value)); + SkipValue(); + continue; + } + + // Well known + if (m_schema.TryMatchKey(mappingDefinitions, nextKey.Value, out String nextValueType)) + { + m_memory.AddBytes(nextKey); + var nextValueDefinition = new DefinitionInfo(definition, nextValueType); + var nextValue = ReadValue(nextValueDefinition); + mapping.Add(nextKey, nextValue); + continue; + } + + // Loose + if (looseKeyType != null) + { + if (looseKeyDefinition == null) + { + looseKeyDefinition = new DefinitionInfo(definition, looseKeyType); + looseValueDefinition = new DefinitionInfo(definition, looseValueType); + } + + Validate(nextKey, looseKeyDefinition.Value); + m_memory.AddBytes(nextKey); + var nextValue = ReadValue(looseValueDefinition.Value); + mapping.Add(nextKey, nextValue); + continue; + } + + // Error + m_context.Error(nextKey, TemplateStrings.UnexpectedValue(nextKey.Value)); + SkipValue(); + } + + // Only one + if (mappingDefinitions.Count > 1) + { + var hitCount = new Dictionary(); + foreach (MappingDefinition mapdef in mappingDefinitions) + { + foreach (String key in mapdef.Properties.Keys) + { + if (!hitCount.TryGetValue(key, out Int32 value)) + { + hitCount.Add(key, 1); + } + else + { + hitCount[key] = value + 1; + } + } + } + + List nonDuplicates = new List(); + foreach (String key in hitCount.Keys) + { + if(hitCount[key] == 1) + { + nonDuplicates.Add(key); + } + } + nonDuplicates.Sort(); + + String listToDeDuplicate = String.Join(", ", nonDuplicates); + m_context.Error(mapping, TemplateStrings.UnableToDetermineOneOf(listToDeDuplicate)); + } + else if (mappingDefinitions.Count == 1 && !hasExpressionKey) + { + foreach (var property in mappingDefinitions[0].Properties) + { + if (property.Value.Required) + { + if (!keys.Contains(property.Key)) + { + m_context.Error(mapping, $"Required property is missing: {property.Key}"); + } + } + } + } + ExpectMappingEnd(); + } + + private void HandleMappingWithAllLooseProperties( + DefinitionInfo mappingDefinition, + DefinitionInfo keyDefinition, + DefinitionInfo valueDefinition, + MappingToken mapping) + { + TemplateToken nextValue; + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + + while (m_objectReader.AllowLiteral(out LiteralToken rawLiteral)) + { + var nextKeyScalar = ParseScalar(rawLiteral, mappingDefinition.AllowedContext); + + // Expression + if (nextKeyScalar is ExpressionToken) + { + // Legal + if (mappingDefinition.AllowedContext.Length > 0) + { + m_memory.AddBytes(nextKeyScalar); + nextValue = ReadValue(valueDefinition); + mapping.Add(nextKeyScalar, nextValue); + } + // Illegal + else + { + m_context.Error(nextKeyScalar, TemplateStrings.ExpressionNotAllowed()); + SkipValue(); + } + + continue; + } + + // Not a string, convert + if (!(nextKeyScalar is StringToken nextKey)) + { + nextKey = new StringToken(nextKeyScalar.FileId, nextKeyScalar.Line, nextKeyScalar.Column, nextKeyScalar.ToString()); + } + + // Duplicate + if (!keys.Add(nextKey.Value)) + { + m_context.Error(nextKey, TemplateStrings.ValueAlreadyDefined(nextKey.Value)); + SkipValue(); + continue; + } + + // Validate + Validate(nextKey, keyDefinition); + m_memory.AddBytes(nextKey); + + // Add the pair + nextValue = ReadValue(valueDefinition); + mapping.Add(nextKey, nextValue); + } + + ExpectMappingEnd(); + } + + private void ExpectMappingEnd() + { + if (!m_objectReader.AllowMappingEnd()) + { + throw new Exception("Expected mapping end"); // Should never happen + } + } + + private void SkipValue(Boolean error = false) + { + m_memory.IncrementEvents(); + + // Scalar + if (m_objectReader.AllowLiteral(out LiteralToken literal)) + { + if (error) + { + m_context.Error(literal, TemplateStrings.UnexpectedValue(literal)); + } + + return; + } + + // Sequence + if (m_objectReader.AllowSequenceStart(out SequenceToken sequence)) + { + m_memory.IncrementDepth(); + + if (error) + { + m_context.Error(sequence, TemplateStrings.UnexpectedSequenceStart()); + } + + while (!m_objectReader.AllowSequenceEnd()) + { + SkipValue(); + } + + m_memory.DecrementDepth(); + return; + } + + // Mapping + if (m_objectReader.AllowMappingStart(out MappingToken mapping)) + { + m_memory.IncrementDepth(); + + if (error) + { + m_context.Error(mapping, TemplateStrings.UnexpectedMappingStart()); + } + + while (!m_objectReader.AllowMappingEnd()) + { + SkipValue(); + SkipValue(); + } + + m_memory.DecrementDepth(); + return; + } + + // Unexpected + throw new InvalidOperationException(TemplateStrings.ExpectedScalarSequenceOrMapping()); + } + + private void Validate( + StringToken stringToken, + DefinitionInfo definition) + { + var scalar = stringToken as ScalarToken; + Validate(ref scalar, definition); + } + + private void Validate( + ref ScalarToken scalar, + DefinitionInfo definition) + { + switch (scalar.Type) + { + case TokenType.Null: + case TokenType.Boolean: + case TokenType.Number: + case TokenType.String: + var literal = scalar as LiteralToken; + + // Legal + if (definition.Get().Any(x => x.IsMatch(literal))) + { + return; + } + + // Not a string, convert + if (literal.Type != TokenType.String) + { + literal = new StringToken(literal.FileId, literal.Line, literal.Column, literal.ToString()); + + // Legal + if (definition.Get().Any(x => x.IsMatch(literal))) + { + scalar = literal; + return; + } + } + + // Illegal + m_context.Error(literal, TemplateStrings.UnexpectedValue(literal)); + break; + + case TokenType.BasicExpression: + + // Illegal + if (definition.AllowedContext.Length == 0) + { + m_context.Error(scalar, TemplateStrings.ExpressionNotAllowed()); + } + + break; + + default: + m_context.Error(scalar, TemplateStrings.UnexpectedValue(scalar)); + break; + } + } + + private ScalarToken ParseScalar( + LiteralToken token, + String[] allowedContext) + { + // Not a string + if (token.Type != TokenType.String) + { + return token; + } + + // Check if the value is definitely a literal + var raw = token.ToString(); + Int32 startExpression; + if (String.IsNullOrEmpty(raw) || + (startExpression = raw.IndexOf(TemplateConstants.OpenExpression)) < 0) // Doesn't contain ${{ + { + return token; + } + + // Break the value into segments of LiteralToken and ExpressionToken + var segments = new List(); + var i = 0; + while (i < raw.Length) + { + // An expression starts here: + if (i == startExpression) + { + // Find the end of the expression - i.e. }} + startExpression = i; + var endExpression = -1; + var inString = false; + for (i += TemplateConstants.OpenExpression.Length; i < raw.Length; i++) + { + if (raw[i] == '\'') + { + inString = !inString; // Note, this handles escaped single quotes gracefully. Ex. 'foo''bar' + } + else if (!inString && raw[i] == '}' && raw[i - 1] == '}') + { + endExpression = i; + i++; + break; + } + } + + // Check if not closed + if (endExpression < startExpression) + { + m_context.Error(token, TemplateStrings.ExpressionNotClosed()); + return token; + } + + // Parse the expression + var rawExpression = raw.Substring( + startExpression + TemplateConstants.OpenExpression.Length, + endExpression - startExpression + 1 - TemplateConstants.OpenExpression.Length - TemplateConstants.CloseExpression.Length); + var expression = ParseExpression(token.Line, token.Column, rawExpression, allowedContext, out Exception ex); + + // Check for error + if (ex != null) + { + m_context.Error(token, ex); + return token; + } + + // Check if a directive was used when not allowed + if (!String.IsNullOrEmpty(expression.Directive) && + ((startExpression != 0) || (i < raw.Length))) + { + m_context.Error(token, TemplateStrings.DirectiveNotAllowedInline(expression.Directive)); + return token; + } + + // Add the segment + segments.Add(expression); + + // Look for the next expression + startExpression = raw.IndexOf(TemplateConstants.OpenExpression, i); + } + // The next expression is further ahead: + else if (i < startExpression) + { + // Append the segment + AddString(segments, token.Line, token.Column, raw.Substring(i, startExpression - i)); + + // Adjust the position + i = startExpression; + } + // No remaining expressions: + else + { + AddString(segments, token.Line, token.Column, raw.Substring(i)); + break; + } + } + + // Check if can convert to a literal + // For example, the escaped expression: ${{ '{{ this is a literal }}' }} + if (segments.Count == 1 && + segments[0] is BasicExpressionToken basicExpression && + IsExpressionString(basicExpression.Expression, out String str)) + { + return new StringToken(m_fileId, token.Line, token.Column, str); + } + + // Check if only ony segment + if (segments.Count == 1) + { + return segments[0]; + } + + // Build the new expression, using the format function + var format = new StringBuilder(); + var args = new StringBuilder(); + var argIndex = 0; + foreach (var segment in segments) + { + if (segment is StringToken literal) + { + var text = ExpressionUtility.StringEscape(literal.Value) // Escape quotes + .Replace("{", "{{") // Escape braces + .Replace("}", "}}"); + format.Append(text); + } + else + { + format.Append("{" + argIndex.ToString(CultureInfo.InvariantCulture) + "}"); // Append formatter + argIndex++; + + var expression = segment as BasicExpressionToken; + args.Append(", "); + args.Append(expression.Expression); + } + } + + var finalExpression = $"format('{format}'{args})"; + if (!ExpressionToken.IsValidExpression(finalExpression, allowedContext, out Exception ex2)) + { + m_context.Error(token, ex2); + return token; + } + return new BasicExpressionToken(m_fileId, token.Line, token.Column, finalExpression); + } + + private ExpressionToken ParseExpression( + Int32? line, + Int32? column, + String value, + String[] allowedContext, + out Exception ex) + { + var trimmed = value.Trim(); + + // Check if the value is empty + if (String.IsNullOrEmpty(trimmed)) + { + ex = new ArgumentException(TemplateStrings.ExpectedExpression()); + return null; + } + + // Try to find a matching directive + List parameters; + if (MatchesDirective(trimmed, TemplateConstants.InsertDirective, 0, out parameters, out ex)) + { + return new InsertExpressionToken(m_fileId, line, column); + } + else if (ex != null) + { + return null; + } + + // Check if the value is an expression + if (!ExpressionToken.IsValidExpression(trimmed, allowedContext, out ex)) + { + return null; + } + + // Return the expression + return new BasicExpressionToken(m_fileId, line, column, trimmed); + } + + private void AddString( + List segments, + Int32? line, + Int32? column, + String value) + { + // If the last segment was a LiteralToken, then append to the last segment + if (segments.Count > 0 && segments[segments.Count - 1] is StringToken lastSegment) + { + segments[segments.Count - 1] = new StringToken(m_fileId, line, column, lastSegment.Value + value); + } + // Otherwise add a new LiteralToken + else + { + segments.Add(new StringToken(m_fileId, line, column, value)); + } + } + + private static Boolean MatchesDirective( + String trimmed, + String directive, + Int32 expectedParameters, + out List parameters, + out Exception ex) + { + if (trimmed.StartsWith(directive, StringComparison.Ordinal) && + (trimmed.Length == directive.Length || Char.IsWhiteSpace(trimmed[directive.Length]))) + { + parameters = new List(); + var startIndex = directive.Length; + var inString = false; + var parens = 0; + for (var i = startIndex; i < trimmed.Length; i++) + { + var c = trimmed[i]; + if (Char.IsWhiteSpace(c) && !inString && parens == 0) + { + if (startIndex < i) + { + parameters.Add(trimmed.Substring(startIndex, i - startIndex)); + } + + startIndex = i + 1; + } + else if (c == '\'') + { + inString = !inString; + } + else if (c == '(' && !inString) + { + parens++; + } + else if (c == ')' && !inString) + { + parens--; + } + } + + if (startIndex < trimmed.Length) + { + parameters.Add(trimmed.Substring(startIndex)); + } + + if (expectedParameters != parameters.Count) + { + ex = new ArgumentException(TemplateStrings.ExpectedNParametersFollowingDirective(expectedParameters, directive, parameters.Count)); + parameters = null; + return false; + } + + ex = null; + return true; + } + + ex = null; + parameters = null; + return false; + } + + private static Boolean IsExpressionString( + String trimmed, + out String str) + { + var builder = new StringBuilder(); + + var inString = false; + for (var i = 0; i < trimmed.Length; i++) + { + var c = trimmed[i]; + if (c == '\'') + { + inString = !inString; + + if (inString && i != 0) + { + builder.Append(c); + } + } + else if (!inString) + { + str = default; + return false; + } + else + { + builder.Append(c); + } + } + + str = builder.ToString(); + return true; + } + + private struct DefinitionInfo + { + public DefinitionInfo( + TemplateSchema schema, + String name) + { + m_schema = schema; + + // Lookup the definition + Definition = m_schema.GetDefinition(name); + + // Record allowed context + AllowedContext = Definition.ReaderContext; + } + + public DefinitionInfo( + DefinitionInfo parent, + String name) + { + m_schema = parent.m_schema; + + // Lookup the definition + Definition = m_schema.GetDefinition(name); + + // Record allowed context + if (Definition.ReaderContext.Length > 0) + { + AllowedContext = new HashSet(parent.AllowedContext.Concat(Definition.ReaderContext), StringComparer.OrdinalIgnoreCase).ToArray(); + } + else + { + AllowedContext = parent.AllowedContext; + } + } + + public IEnumerable Get() + where T : Definition + { + return m_schema.Get(Definition); + } + + private TemplateSchema m_schema; + public Definition Definition; + public String[] AllowedContext; + } + + private readonly TemplateContext m_context; + private readonly Int32? m_fileId; + private readonly TemplateMemory m_memory; + private readonly IObjectReader m_objectReader; + private readonly TemplateSchema m_schema; + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateStrings.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateStrings.cs new file mode 100644 index 000000000..848ef2903 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateStrings.cs @@ -0,0 +1,281 @@ +// +// *** AUTOMATICALLY GENERATED BY GenResourceClass -- DO NOT EDIT!!! *** +using System; +using System.Diagnostics; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using System.Resources; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating { + + +internal static class TemplateStrings +{ + + + //******************************************************************************************** + /// Creates the resource manager instance. + //******************************************************************************************** + static TemplateStrings() + { + s_resMgr = new ResourceManager("GitHub.Actions.WorkflowParser.ObjectTemplating.TemplateStrings", typeof(TemplateStrings).GetTypeInfo().Assembly); + } + + public static ResourceManager Manager + { + get + { + return s_resMgr; + } + } + + //******************************************************************************************** + /// Returns a localized string given a resource string name. + //******************************************************************************************** + public static String Get( + String resourceName) + { + return s_resMgr.GetString(resourceName, CultureInfo.CurrentUICulture); + } + + //******************************************************************************************** + /// Returns a localized integer given a resource string name. + //******************************************************************************************** + public static int GetInt( + String resourceName) + { + return (int)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture); + } + + //******************************************************************************************** + /// Returns a localized string given a resource string name. + //******************************************************************************************** + public static bool GetBool( + String resourceName) + { + return (bool)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture); + } + + + //******************************************************************************************** + /// A little helper function to alleviate some typing associated with loading resources and + /// formatting the strings. In DEBUG builds, it also asserts that the number of format + /// arguments and the length of args match. + //******************************************************************************************** + private static String Format( // The formatted resource string. + String resourceName, // The name of the resource. + params Object[] args) // Arguments to format. + { + String resource = Get(resourceName); + +#if DEBUG + // Check to make sure that the number of format string arguments matches the number of + // arguments passed in. + int formatArgCount = 0; + bool[] argSeen = new bool[100]; + for (int i = 0; i < resource.Length; i++) + { + if (resource[i] == '{') + { + if (i + 1 < resource.Length && + resource[i + 1] == '{') + { + i++; // Skip the escaped curly braces. + } + else + { + // Move past the curly brace and leading whitespace. + i++; + while (Char.IsWhiteSpace(resource[i])) + { + i++; + } + + // Get the argument number. + int length = 0; + while (i + length < resource.Length && Char.IsDigit(resource[i + length])) + { + length++; + } + + // Record it if it hasn't already been seen. + int argNumber = int.Parse(resource.Substring(i, length), CultureInfo.InvariantCulture); + if (!argSeen[argNumber]) + { + formatArgCount++; // Count it as a formatting argument. + argSeen[argNumber] = true; + } + } + } + } + + Debug.Assert(args != null || formatArgCount == 0, + String.Format(CultureInfo.InvariantCulture, "The number of format arguments is {0}, but the args parameter is null.", formatArgCount)); + Debug.Assert(args == null || formatArgCount == args.Length, + String.Format(CultureInfo.InvariantCulture, "Coding error using resource \"{0}\": The number of format arguments {1} != number of args {2}", + resourceName, formatArgCount, args != null ? args.Length : 0)); +#endif // DEBUG + + + if (args == null) + { + return resource; + } + + // If there are any DateTime structs in the arguments, we need to bracket them + // to make sure they are within the supported range of the current calendar. + for (int i = 0; i < args.Length; i++) + { + // DateTime is a struct, we cannot use the as operator and null check. + if (args[i] is DateTime) + { + DateTime dateTime = (DateTime)args[i]; + + // We need to fetch the calendar on each Format call since it may change. + // Since we don't have more than one DateTime for resource, do not + // bother to cache this for the duration of the for loop. + Calendar calendar = DateTimeFormatInfo.CurrentInfo.Calendar; + if (dateTime > calendar.MaxSupportedDateTime) + { + args[i] = calendar.MaxSupportedDateTime; + } + else if (dateTime < calendar.MinSupportedDateTime) + { + args[i] = calendar.MinSupportedDateTime; + } + } + } + + return String.Format(CultureInfo.CurrentCulture, resource, args); + } + + // According to the documentation for the ResourceManager class, it should be sufficient to + // create a single static instance. The following is an excerpt from the 1.1 documentation. + // Using the methods of ResourceManager, a caller can access the resources for a particular + // culture using the GetObject and GetString methods. By default, these methods return the + // resource for the culture determined by the current cultural settings of the thread that made + // the call. + private static ResourceManager s_resMgr; + + /// + /// The expression directive '{0}' is not supported in this context + /// + public static String DirectiveNotAllowed(object arg0) { return Format("DirectiveNotAllowed", arg0); } + + /// + /// The directive '{0}' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression. + /// + public static String DirectiveNotAllowedInline(object arg0) { return Format("DirectiveNotAllowedInline", arg0); } + + /// + /// An expression was expected + /// + public static String ExpectedExpression() { return Get("ExpectedExpression"); } + + /// + /// Expected a mapping + /// + public static String ExpectedMapping() { return Get("ExpectedMapping"); } + + /// + /// Exactly {0} parameter(s) were expected following the directive '{1}'. Actual parameter count: {2} + /// + public static String ExpectedNParametersFollowingDirective(object arg0, object arg1, object arg2) { return Format("ExpectedNParametersFollowingDirective", arg0, arg1, arg2); } + + /// + /// Expected a scalar value + /// + public static String ExpectedScalar() { return Get("ExpectedScalar"); } + + /// + /// Expected a scalar value, a sequence, or a mapping + /// + public static String ExpectedScalarSequenceOrMapping() { return Get("ExpectedScalarSequenceOrMapping"); } + + /// + /// Expected a sequence + /// + public static String ExpectedSequence() { return Get("ExpectedSequence"); } + + /// + /// A template expression is not allowed in this context + /// + public static String ExpressionNotAllowed() { return Get("ExpressionNotAllowed"); } + + /// + /// The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found. + /// + public static String ExpressionNotClosed() { return Get("ExpressionNotClosed"); } + + /// + /// Error message prefix that indicates the line and column where the error occurred + /// + /// (Line: {0}, Col: {1}) + /// + public static String LineColumn(object arg0, object arg1) { return Format("LineColumn", arg0, arg1); } + + /// + /// Maximum object depth exceeded + /// + public static String MaxObjectDepthExceeded() { return Get("MaxObjectDepthExceeded"); } + + /// + /// Maximum object size exceeded + /// + public static String MaxObjectSizeExceeded() { return Get("MaxObjectSizeExceeded"); } + + /// + /// Maximum events exceeded while evaluating the template. This may indicate an infinite loop or too many nested loops. + /// + public static String MaxTemplateEventsExceeded() { return Get("MaxTemplateEventsExceeded"); } + + /// + /// The template is not valid. + /// + public static String TemplateNotValid() { return Get("TemplateNotValid"); } + + /// + /// The template is not valid. {0} + /// + public static String TemplateNotValidWithErrors(object arg0) { return Format("TemplateNotValidWithErrors", arg0); } + + /// + /// In {0} {1}: Error from called workflow + /// + public static String CalledWorkflowNotValidWithErrors(object arg0, object arg1) { return Format("CalledWorkflowNotValidWithErrors", arg0, arg1); } + + /// + /// Unable to convert the object to a template token. Actual type '{0}' + /// + public static String UnableToConvertToTemplateToken(object arg0) { return Format("UnableToConvertToTemplateToken", arg0); } + + /// + /// There's not enough info to determine what you meant. Add one of these properties: {0} + /// + public static String UnableToDetermineOneOf(object arg0) { return Format("UnableToDetermineOneOf", arg0); } + + /// + /// A mapping was not expected + /// + public static String UnexpectedMappingStart() { return Get("UnexpectedMappingStart"); } + + /// + /// A sequence was not expected + /// + public static String UnexpectedSequenceStart() { return Get("UnexpectedSequenceStart"); } + + /// + /// Unexpected value '{0}' + /// + public static String UnexpectedValue(object arg0) { return Format("UnexpectedValue", arg0); } + + /// + /// '{0}' is already defined + /// + public static String ValueAlreadyDefined(object arg0) { return Format("ValueAlreadyDefined", arg0); } + + +} + +} // namespace diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateStrings.resx b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateStrings.resx new file mode 100644 index 000000000..07940dbbf --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateStrings.resx @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The expression directive '{0}' is not supported in this context + + + The directive '{0}' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression. + + + An expression was expected + + + Expected a mapping + + + Exactly {0} parameter(s) were expected following the directive '{1}'. Actual parameter count: {2} + + + Expected a scalar value + + + Expected a scalar value, a sequence, or a mapping + + + Expected a sequence + + + A template expression is not allowed in this context + + + The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found. + + + (Line: {0}, Col: {1}) + Error message prefix that indicates the line and column where the error occurred + + + Maximum object depth exceeded + + + Maximum object size exceeded + + + Maximum events exceeded while evaluating the template. This may indicate an infinite loop or too many nested loops. + + + The template is not valid. + + + The template is not valid. {0} + + + In {0} {1}: Error from called workflow + + + Unable to convert the object to a template token. Actual type '{0}' + + + There's not enough info to determine what you meant. Add one of these properties: {0} + + + A mapping was not expected + + + A sequence was not expected + + + Unexpected value '{0}' + + + '{0}' is already defined + + \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateUnraveler.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateUnraveler.cs new file mode 100644 index 000000000..e05e37f60 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateUnraveler.cs @@ -0,0 +1,1198 @@ +#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.Text; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// This class allows callers to easily traverse a template object. + /// This class hides the details of expression expansion, depth tracking, + /// and memory tracking. + /// + internal sealed class TemplateUnraveler + { + internal TemplateUnraveler( + TemplateContext context, + TemplateToken template, + Int32 removeBytes) + { + m_context = context; + m_memory = context.Memory; + + // Initialize the reader state + MoveFirst(template, removeBytes); + } + + internal Boolean AllowScalar( + Boolean expand, + out ScalarToken scalar) + { + m_memory.IncrementEvents(); + + if (expand) + { + Unravel(expand: true); + } + + if (m_current?.Value is ScalarToken scalarToken) + { + scalar = scalarToken; + + // Add bytes before they are emitted to the caller (so the caller doesn't have to track bytes) + m_memory.AddBytes(scalar); + + MoveNext(); + return true; + } + + scalar = null; + return false; + } + + internal Boolean AllowSequenceStart( + Boolean expand, + out SequenceToken sequence) + { + m_memory.IncrementEvents(); + + if (expand) + { + Unravel(expand: true); + } + + if (m_current is SequenceState sequenceState && sequenceState.IsStart) + { + sequence = new SequenceToken(sequenceState.Value.FileId, sequenceState.Value.Line, sequenceState.Value.Column); + + // Add bytes before they are emitted to the caller (so the caller doesn't have to track bytes) + m_memory.AddBytes(sequence); + + MoveNext(); + return true; + } + + sequence = null; + return false; + } + + internal Boolean AllowSequenceEnd(Boolean expand) + { + m_memory.IncrementEvents(); + + if (expand) + { + Unravel(expand: true); + } + + if (m_current is SequenceState sequenceState && sequenceState.IsEnd) + { + MoveNext(); + return true; + } + + return false; + } + + internal Boolean AllowMappingStart( + Boolean expand, + out MappingToken mapping) + { + m_memory.IncrementEvents(); + + if (expand) + { + Unravel(expand: true); + } + + if (m_current is MappingState mappingState && mappingState.IsStart) + { + mapping = new MappingToken(mappingState.Value.FileId, mappingState.Value.Line, mappingState.Value.Column); + + // Add bytes before they are emitted to the caller (so the caller doesn't have to track bytes) + m_memory.AddBytes(mapping); + + MoveNext(); + return true; + } + + mapping = null; + return false; + } + + internal Boolean AllowMappingEnd(Boolean expand) + { + m_memory.IncrementEvents(); + + if (expand) + { + Unravel(expand: true); + } + + if (m_current is MappingState mappingState && mappingState.IsEnd) + { + MoveNext(); + return true; + } + + return false; + } + + internal void ReadEnd() + { + m_memory.IncrementEvents(); + + if (m_current != null) + { + throw new InvalidOperationException("Expected end of template object. " + DumpState()); + } + } + + internal void ReadMappingEnd() + { + if (!AllowMappingEnd(expand: false)) + { + throw new InvalidOperationException("Unexpected state while attempting to read the mapping end. " + DumpState()); + } + } + + internal void SkipSequenceItem() + { + m_memory.IncrementEvents(); + + if (!(m_current?.Parent is SequenceState ancestor)) + { + throw new InvalidOperationException("Unexpected state while attempting to skip the current sequence item. " + DumpState()); + } + + MoveNext(skipNestedEvents: true); + } + + internal void SkipMappingKey() + { + m_memory.IncrementEvents(); + + if (!(m_current?.Parent is MappingState ancestor) || !ancestor.IsKey) + { + throw new InvalidOperationException("Unexpected state while attempting to skip the current mapping key. " + DumpState()); + } + + MoveNext(skipNestedEvents: true); + } + + internal void SkipMappingValue() + { + m_memory.IncrementEvents(); + + if (!(m_current?.Parent is MappingState ancestor) || ancestor.IsKey) + { + throw new InvalidOperationException("Unexpected state while attempting to skip the current mapping value. " + DumpState()); + } + + MoveNext(skipNestedEvents: true); + } + + private String DumpState() + { + var result = new StringBuilder(); + + if (m_current == null) + { + result.AppendLine("State: (null)"); + } + else + { + result.AppendLine("State:"); + result.AppendLine(); + + // Push state hierarchy + var stack = new Stack(); + var curr = m_current; + while (curr != null) + { + result.AppendLine(curr.ToString()); + curr = curr.Parent; + } + } + + return result.ToString(); + } + + private void MoveFirst( + TemplateToken value, + Int32 removeBytes) + { + if (!(value is LiteralToken) && !(value is SequenceToken) && !(value is MappingToken) && !(value is BasicExpressionToken)) + { + throw new NotSupportedException($"Unexpected type '{value?.GetType().Name}' when initializing object reader state"); + } + + m_memory.IncrementEvents(); + m_current = ReaderState.CreateState(null, value, m_context, removeBytes); + } + + private void MoveNext(Boolean skipNestedEvents = false) + { + m_memory.IncrementEvents(); + + if (m_current == null) + { + return; + } + + // Sequence start + if (m_current is SequenceState sequenceState && + sequenceState.IsStart && + !skipNestedEvents) + { + // Move to the first item or sequence end + m_current = sequenceState.Next(); + } + // Mapping start + else if (m_current is MappingState mappingState && + mappingState.IsStart && + !skipNestedEvents) + { + // Move to the first item key or mapping end + m_current = mappingState.Next(); + } + // Parent is a sequence + else if (m_current.Parent is SequenceState parentSequenceState) + { + // Move to the next item or sequence end + m_current.Remove(); + m_current = parentSequenceState.Next(); + } + // Parent is a mapping + else if (m_current.Parent is MappingState parentMappingState) + { + // Move to the next item value, item key, or mapping end + m_current.Remove(); + m_current = parentMappingState.Next(); + } + // Parent is an expression end + else if (m_current.Parent != null) + { + m_current.Remove(); + m_current = m_current.Parent; + } + // Parent is null + else + { + m_current.Remove(); + m_current = null; + } + + m_expanded = false; + Unravel(expand: false); + } + + private void Unravel(Boolean expand) + { + if (m_expanded) + { + return; + } + + do + { + if (m_current == null) + { + break; + } + // Literal + else if (m_current is LiteralState literalState) + { + break; + } + else if (m_current is BasicExpressionState basicExpressionState) + { + // Sequence item is a basic expression start + // For example: + // steps: + // - script: credScan + // - ${{ parameters.preBuild }} + // - script: build + if (basicExpressionState.IsStart && + m_current.Parent is SequenceState) + { + if (expand) + { + SequenceItemBasicExpression(); + } + else + { + break; + } + } + // Mapping key is a basic expression start + // For example: + // steps: + // - ${{ parameters.scriptHost }}: echo hi + else if (basicExpressionState.IsStart && + m_current.Parent is MappingState parentMappingState && + parentMappingState.IsKey) + { + if (expand) + { + MappingKeyBasicExpression(); + } + else + { + break; + } + } + // Mapping value is a basic expression start + // For example: + // steps: + // - script: credScan + // - script: ${{ parameters.tool }} + else if (basicExpressionState.IsStart && + m_current.Parent is MappingState parentMappingState2 && + !parentMappingState2.IsKey) + { + if (expand) + { + MappingValueBasicExpression(); + } + else + { + break; + } + } + else if (basicExpressionState.IsStart && + m_current.Parent is null) + { + if (expand) + { + RootBasicExpression(); + } + else + { + break; + } + } + // Basic expression end + else if (basicExpressionState.IsEnd) + { + EndExpression(); + } + else + { + UnexpectedState(); + } + } + else if (m_current is MappingState mappingState) + { + // Mapping end, closing an "insert" mapping insertion + if (mappingState.IsEnd && + m_current.Parent is InsertExpressionState) + { + m_current.Remove(); + m_current = m_current.Parent; // Skip to the expression end + } + // Normal mapping start + else if (mappingState.IsStart) + { + break; + } + // Normal mapping end + else if (mappingState.IsEnd) + { + break; + } + else + { + UnexpectedState(); + } + } + else if (m_current is SequenceState sequenceState) + { + // Sequence end, closing a sequence insertion + if (sequenceState.IsEnd && + m_current.Parent is BasicExpressionState && + m_current.Parent.Parent is SequenceState) + { + m_current.Remove(); + m_current = m_current.Parent; // Skip to the expression end + } + // Normal sequence start + else if (sequenceState.IsStart) + { + break; + } + // Normal sequence end + else if (sequenceState.IsEnd) + { + break; + } + else + { + UnexpectedState(); + } + } + else if (m_current is InsertExpressionState insertExpressionState) + { + // Mapping key, beginning an "insert" mapping insertion + // For example: + // - job: a + // variables: + // ${{ insert }}: ${{ parameters.jobVariables }} + if (insertExpressionState.IsStart && + m_current.Parent is MappingState parentMappingState && + parentMappingState.IsKey) + { + if (expand) + { + StartMappingInsertion(); + } + else + { + break; + } + } + // Expression end + else if (insertExpressionState.IsEnd) + { + EndExpression(); + } + // Not allowed + else if (insertExpressionState.IsStart) + { + m_context.Error(insertExpressionState.Value, TemplateStrings.DirectiveNotAllowed(insertExpressionState.Value.Directive)); + m_current.Remove(); + m_current = insertExpressionState.ToStringToken(); + } + else + { + UnexpectedState(); + } + } + else + { + UnexpectedState(); + } + + m_memory.IncrementEvents(); + } while (true); + + m_expanded = expand; + } + + private void SequenceItemBasicExpression() + { + // The template looks like: + // + // steps: + // - ${{ parameters.preSteps }} + // - script: build + // + // The current state looks like: + // + // MappingState // The document starts with a mapping + // + // SequenceState // The "steps" sequence + // + // BasicExpressionState // m_current + + var expressionState = m_current as BasicExpressionState; + var expression = expressionState.Value; + TemplateToken value; + var removeBytes = 0; + try + { + value = expression.EvaluateTemplateToken(expressionState.Context, out removeBytes); + } + catch (Exception ex) + { + m_context.Error(expression, ex); + value = null; + } + + // Move to the nested sequence, skip the sequence start + if (value is SequenceToken nestedSequence) + { + m_current = expressionState.Next(nestedSequence, isSequenceInsertion: true, removeBytes: removeBytes); + } + // Move to the new value + else if (value != null) + { + m_current = expressionState.Next(value, removeBytes); + } + // Move to the expression end + else if (value == null) + { + expressionState.End(); + } + } + + private void MappingKeyBasicExpression() + { + // The template looks like: + // + // steps: + // - ${{ parameters.scriptHost }}: echo hi + // + // The current state looks like: + // + // MappingState // The document starts with a mapping + // + // SequenceState // The "steps" sequence + // + // MappingState // The step mapping + // + // BasicExpressionState // m_current + + // The expression should evaluate to a string + var expressionState = m_current as BasicExpressionState; + var expression = expressionState.Value as BasicExpressionToken; + StringToken stringToken; + var removeBytes = 0; + try + { + stringToken = expression.EvaluateStringToken(expressionState.Context, out removeBytes); + } + catch (Exception ex) + { + m_context.Error(expression, ex); + stringToken = null; + } + + // Move to the stringToken + if (stringToken != null) + { + m_current = expressionState.Next(stringToken, removeBytes); + } + // Move to the next key or mapping end + else + { + m_current.Remove(); + var parentMappingState = m_current.Parent as MappingState; + parentMappingState.Next().Remove(); // Skip the value + m_current = parentMappingState.Next(); // Next key or mapping end + } + } + + private void MappingValueBasicExpression() + { + // The template looks like: + // + // steps: + // - script: credScan + // - script: ${{ parameters.tool }} + // + // The current state looks like: + // + // MappingState // The document starts with a mapping + // + // SequenceState // The "steps" sequence + // + // MappingState // The step mapping + // + // BasicExpressionState // m_current + + var expressionState = m_current as BasicExpressionState; + var expression = expressionState.Value; + TemplateToken value; + var removeBytes = 0; + try + { + value = expression.EvaluateTemplateToken(expressionState.Context, out removeBytes); + } + catch (Exception ex) + { + m_context.Error(expression, ex); + value = new StringToken(expression.FileId, expression.Line, expression.Column, String.Empty); + } + + // Move to the new value + m_current = expressionState.Next(value, removeBytes); + } + + private void RootBasicExpression() + { + // The template looks like: + // + // ${{ parameters.tool }} + // + // The current state looks like: + // + // BasicExpressionState // m_current + + var expressionState = m_current as BasicExpressionState; + var expression = expressionState.Value; + TemplateToken value; + var removeBytes = 0; + try + { + value = expression.EvaluateTemplateToken(expressionState.Context, out removeBytes); + } + catch (Exception ex) + { + m_context.Error(expression, ex); + value = new StringToken(expression.FileId, expression.Line, expression.Column, String.Empty); + } + + // Move to the new value + m_current = expressionState.Next(value, removeBytes); + } + + private void StartMappingInsertion() + { + // The template looks like: + // + // jobs: + // - job: a + // variables: + // ${{ insert }}: ${{ parameters.jobVariables }} + // + // The current state looks like: + // + // MappingState // The document starts with a mapping + // + // SequenceState // The "jobs" sequence + // + // MappingState // The "job" mapping + // + // MappingState // The "variables" mapping + // + // InsertExpressionState // m_current + + var expressionState = m_current as InsertExpressionState; + var parentMappingState = expressionState.Parent as MappingState; + var nestedValue = parentMappingState.Value[parentMappingState.Index].Value; + var nestedMapping = nestedValue as MappingToken; + var removeBytes = 0; + if (nestedMapping != null) + { + // Intentionally empty + } + else if (nestedValue is BasicExpressionToken basicExpression) + { + // The expression should evaluate to a mapping + try + { + nestedMapping = basicExpression.EvaluateMappingToken(expressionState.Context, out removeBytes); + } + catch (Exception ex) + { + m_context.Error(basicExpression, ex); + nestedMapping = null; + } + } + else + { + m_context.Error(nestedValue, TemplateStrings.ExpectedMapping()); + nestedMapping = null; + } + + // Move to the nested first key + if (nestedMapping?.Count > 0) + { + m_current = expressionState.Next(nestedMapping, removeBytes); + } + // Move to the expression end + else + { + if (removeBytes > 0) + { + m_memory.SubtractBytes(removeBytes); + } + + expressionState.End(); + } + } + + private void EndExpression() + { + // End of document + if (m_current.Parent == null) + { + m_current.Remove(); + m_current = null; + } + // End basic expression + else if (m_current is BasicExpressionState) + { + // Move to the next item or sequence end + if (m_current.Parent is SequenceState parentSequenceState) + { + m_current.Remove(); + m_current = parentSequenceState.Next(); + } + // Move to the next key, next value, or mapping end + else + { + m_current.Remove(); + var parentMappingState = m_current.Parent as MappingState; + m_current = parentMappingState.Next(); + } + } + // End "insert" mapping insertion + else + { + // Move to the next key or mapping end + m_current.Remove(); + var parentMappingState = m_current.Parent as MappingState; + parentMappingState.Next().Remove(); // Skip the value + m_current = parentMappingState.Next(); + } + } + + private void UnexpectedState() + { + throw new InvalidOperationException("Expected state while unraveling expressions. " + DumpState()); + } + + private abstract class ReaderState + { + public ReaderState( + ReaderState parent, + TemplateToken value, + TemplateContext context) + { + Parent = parent; + Value = value; + Context = context; + } + + public static ReaderState CreateState( + ReaderState parent, + TemplateToken value, + TemplateContext context, + Int32 removeBytes = 0) + { + switch (value.Type) + { + case TokenType.Null: + case TokenType.Boolean: + case TokenType.Number: + case TokenType.String: + return new LiteralState(parent, value as LiteralToken, context, removeBytes); + + case TokenType.Sequence: + return new SequenceState(parent, value as SequenceToken, context, removeBytes); + + case TokenType.Mapping: + return new MappingState(parent, value as MappingToken, context, removeBytes); + + case TokenType.BasicExpression: + return new BasicExpressionState(parent, value as BasicExpressionToken, context, removeBytes); + + case TokenType.InsertExpression: + if (removeBytes > 0) + { + throw new InvalidOperationException($"Unexpected {nameof(removeBytes)}"); + } + + return new InsertExpressionState(parent, value as InsertExpressionToken, context); + + default: + throw new NotSupportedException($"Unexpected {nameof(ReaderState)} type: {value?.GetType().Name}"); + } + } + + public ReaderState Parent { get; } + public TemplateContext Context { get; protected set; } + public TemplateToken Value { get; } + + public abstract void Remove(); + } + + private abstract class ReaderState : ReaderState + where T : class + { + public ReaderState( + ReaderState parent, + TemplateToken value, + TemplateContext context) + : base(parent, value, context) + { + } + + public new T Value + { + get + { + if (!Object.ReferenceEquals(base.Value, m_value)) + { + m_value = base.Value as T; + } + + return m_value; + } + } + + private T m_value; + } + + private sealed class LiteralState : ReaderState + { + public LiteralState( + ReaderState parent, + LiteralToken literal, + TemplateContext context, + Int32 removeBytes) + : base(parent, literal, context) + { + context.Memory.AddBytes(literal); + context.Memory.IncrementDepth(); + m_removeBytes = removeBytes; + } + + public override void Remove() + { + Context.Memory.SubtractBytes(Value); + Context.Memory.DecrementDepth(); + + // Subtract the memory overhead of the template token. + // We are now done traversing it and pointers to it no longer need to exist. + if (m_removeBytes > 0) + { + Context.Memory.SubtractBytes(m_removeBytes); + } + } + + public override String ToString() + { + var result = new StringBuilder(); + result.AppendLine($"{GetType().Name}"); + return result.ToString(); + } + + private Int32 m_removeBytes; + } + + private sealed class SequenceState : ReaderState + { + public SequenceState( + ReaderState parent, + SequenceToken sequence, + TemplateContext context, + Int32 removeBytes) + : base(parent, sequence, context) + { + context.Memory.AddBytes(sequence); + context.Memory.IncrementDepth(); + m_removeBytes = removeBytes; + } + + /// + /// Indicates whether the state represents the sequence-start event + /// + public Boolean IsStart { get; private set; } = true; + + /// + /// The current index within the sequence + /// + public Int32 Index { get; private set; } + + /// + /// Indicates whether the state represents the sequence-end event + /// + public Boolean IsEnd => !IsStart && Index >= Value.Count; + + public ReaderState Next() + { + // Adjust the state + if (IsStart) + { + IsStart = false; + } + else + { + Index++; + } + + // Return the next event + if (!IsEnd) + { + return CreateState(this, Value[Index], Context); + } + else + { + return this; + } + } + + public override void Remove() + { + Context.Memory.SubtractBytes(Value); + Context.Memory.DecrementDepth(); + + // Subtract the memory overhead of the template token. + // We are now done traversing it and pointers to it no longer need to exist. + if (m_removeBytes > 0) + { + Context.Memory.SubtractBytes(m_removeBytes); + } + } + + public override String ToString() + { + var result = new StringBuilder(); + result.AppendLine($"{GetType().Name}:"); + result.AppendLine($" IsStart: {IsStart}"); + result.AppendLine($" Index: {Index}"); + result.AppendLine($" IsEnd: {IsEnd}"); + return result.ToString(); + } + + private Int32 m_removeBytes; + } + + private sealed class MappingState : ReaderState + { + public MappingState( + ReaderState parent, + MappingToken mapping, + TemplateContext context, + Int32 removeBytes) + : base(parent, mapping, context) + { + context.Memory.AddBytes(mapping); + context.Memory.IncrementDepth(); + m_removeBytes = removeBytes; + } + + /// + /// Indicates whether the state represents the mapping-start event + /// + public Boolean IsStart { get; private set; } = true; + + /// + /// The current index within the mapping + /// + public Int32 Index { get; private set; } + + /// + /// Indicates whether the state represents a mapping-key position + /// + public Boolean IsKey { get; private set; } + + /// + /// Indicates whether the state represents the mapping-end event + /// + public Boolean IsEnd => !IsStart && Index >= Value.Count; + + public ReaderState Next() + { + // Adjust the state + if (IsStart) + { + IsStart = false; + IsKey = true; + } + else if (IsKey) + { + IsKey = false; + } + else + { + Index++; + IsKey = true; + } + + // Return the next event + if (!IsEnd) + { + if (IsKey) + { + return CreateState(this, Value[Index].Key, Context); + } + else + { + return CreateState(this, Value[Index].Value, Context); + } + } + else + { + return this; + } + } + + public override void Remove() + { + Context.Memory.SubtractBytes(Value); + Context.Memory.DecrementDepth(); + + // Subtract the memory overhead of the template token. + // We are now done traversing it and pointers to it no longer need to exist. + if (m_removeBytes > 0) + { + Context.Memory.SubtractBytes(m_removeBytes); + } + } + + public override String ToString() + { + var result = new StringBuilder(); + result.AppendLine($"{GetType().Name}:"); + result.AppendLine($" IsStart: {IsStart}"); + result.AppendLine($" Index: {Index}"); + result.AppendLine($" IsKey: {IsKey}"); + result.AppendLine($" IsEnd: {IsEnd}"); + return result.ToString(); + } + + private Int32 m_removeBytes; + } + + private sealed class BasicExpressionState : ReaderState + { + public BasicExpressionState( + ReaderState parent, + BasicExpressionToken expression, + TemplateContext context, + Int32 removeBytes) + : base(parent, expression, context) + { + context.Memory.AddBytes(expression); + context.Memory.IncrementDepth(); + m_removeBytes = removeBytes; + } + + /// + /// Indicates whether entering the expression + /// + public Boolean IsStart { get; private set; } = true; + + /// + /// Indicates whether leaving the expression + /// + public Boolean IsEnd => !IsStart; + + public ReaderState Next( + TemplateToken value, + Int32 removeBytes = 0) + { + // Adjust the state + IsStart = false; + + // Return the nested state + return CreateState(this, value, Context, removeBytes); + } + + public ReaderState Next( + SequenceToken value, + Boolean isSequenceInsertion = false, + Int32 removeBytes = 0) + { + // Adjust the state + IsStart = false; + + // Create the nested state + var nestedState = CreateState(this, value, Context, removeBytes); + if (isSequenceInsertion) + { + var nestedSequenceState = nestedState as SequenceState; + return nestedSequenceState.Next(); // Skip the sequence start + } + else + { + return nestedState; + } + } + + public ReaderState End() + { + IsStart = false; + return this; + } + + public override void Remove() + { + Context.Memory.SubtractBytes(Value); + Context.Memory.DecrementDepth(); + + // Subtract the memory overhead of the template token. + // We are now done traversing it and pointers to it no longer need to exist. + if (m_removeBytes > 0) + { + Context.Memory.SubtractBytes(m_removeBytes); + } + } + + public override String ToString() + { + var result = new StringBuilder(); + result.AppendLine($"{GetType().Name}:"); + result.AppendLine($" IsStart: {IsStart}"); + return result.ToString(); + } + + private Int32 m_removeBytes; + } + + private sealed class InsertExpressionState : ReaderState + { + public InsertExpressionState( + ReaderState parent, + InsertExpressionToken expression, + TemplateContext context) + : base(parent, expression, context) + { + Context.Memory.AddBytes(expression); + Context.Memory.IncrementDepth(); + } + + /// + /// Indicates whether entering or leaving the expression + /// + public Boolean IsStart { get; private set; } = true; + + /// + /// Indicates whether leaving the expression + /// + public Boolean IsEnd => !IsStart; + + public ReaderState Next( + MappingToken value, + Int32 removeBytes = 0) + { + // Adjust the state + IsStart = false; + + // Create the nested state + var nestedState = CreateState(this, value, Context, removeBytes) as MappingState; + return nestedState.Next(); // Skip the mapping start + } + + public ReaderState End() + { + IsStart = false; + return this; + } + + /// + /// This happens when the expression is not allowed + /// + public ReaderState ToStringToken() + { + var literal = new StringToken(Value.FileId, Value.Line, Value.Column, $"{TemplateConstants.OpenExpression} {Value.Directive} {TemplateConstants.CloseExpression}"); + return CreateState(Parent, literal, Context); + } + + public override void Remove() + { + Context.Memory.SubtractBytes(Value); + Context.Memory.DecrementDepth(); + } + + public override String ToString() + { + var result = new StringBuilder(); + result.AppendLine($"{GetType().Name}:"); + result.AppendLine($" IsStart: {IsStart}"); + return result.ToString(); + } + } + + private readonly TemplateContext m_context; + private readonly TemplateMemory m_memory; + private ReaderState m_current; + private Boolean m_expanded; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationError.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationError.cs new file mode 100644 index 000000000..9bf95ee7b --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationError.cs @@ -0,0 +1,60 @@ +#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.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Provides information about an error which occurred during validation. + /// + [DataContract] + public class TemplateValidationError + { + public TemplateValidationError() + { + } + + public TemplateValidationError(String message) + : this(null, message) + { + } + + public TemplateValidationError( + String code, + String message) + { + Code = code; + Message = message; + } + + [DataMember(Name = "code", EmitDefaultValue = false)] + public String Code + { + get; + set; + } + + [DataMember(Name = "Message", EmitDefaultValue = false)] + public String Message + { + get; + set; + } + + public static IEnumerable Create(Exception exception) + { + for (int i = 0; i < 50; i++) + { + yield return new TemplateValidationError(exception.Message); + if (exception.InnerException == null) + { + break; + } + + exception = exception.InnerException; + } + } + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationErrors.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationErrors.cs new file mode 100644 index 000000000..e4a10490d --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationErrors.cs @@ -0,0 +1,157 @@ +#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; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Provides information about an error which occurred during validation. + /// + [DataContract] + public sealed class TemplateValidationErrors : IEnumerable + { + public TemplateValidationErrors() + { + } + + public TemplateValidationErrors( + Int32 maxErrors, + Int32 maxMessageLength) + { + m_maxErrors = maxErrors; + m_maxMessageLength = maxMessageLength; + } + + public Int32 Count => m_errors.Count; + + public void Add(String message) + { + Add(new TemplateValidationError(message)); + } + + public void Add(Exception ex) + { + Add(null, ex); + } + + public void Add(String messagePrefix, Exception ex) + { + for (int i = 0; i < 50; i++) + { + String message = !String.IsNullOrEmpty(messagePrefix) ? $"{messagePrefix} {ex.Message}" : ex.Message; + Add(new TemplateValidationError(message)); + if (ex.InnerException == null) + { + break; + } + + ex = ex.InnerException; + } + } + + public void Add(IEnumerable errors) + { + foreach (var error in errors) + { + Add(error); + } + } + + public void Add(TemplateValidationError error) + { + // Check max errors + if (m_maxErrors <= 0 || + m_errors.Count < m_maxErrors) + { + // Check max message length + if (m_maxMessageLength > 0 && + error.Message?.Length > m_maxMessageLength) + { + error = new TemplateValidationError(error.Code, error.Message.Substring(0, m_maxMessageLength) + "[...]"); + } + + m_errors.Add(error); + } + } + + /// + /// Add a prefix in the error message of the given index. + /// + public void PrefixMessage(int index, String prefix) { + if (index < 0 || index >= m_errors.Count) { + return; + } + + var message = GetMessage(index); + if (!String.IsNullOrEmpty(message)) + { + m_errors[index].Message = $"{prefix} {message}"; + } + else + { + m_errors[index].Message = $"{prefix}"; + } + } + + public string GetMessage(int index) + { + if (index < 0 || index >= m_errors.Count) + { + return null; + } + + return m_errors[index].Message; + } + + /// + /// Throws if any errors. + /// + public void Check() + { + if (m_errors.Count > 0) + { + throw new TemplateValidationException(m_errors); + } + } + + /// + /// Throws if any errors. + /// The error message prefix + /// + public void Check(String prefix) + { + if (String.IsNullOrEmpty(prefix)) + { + this.Check(); + } + else if (m_errors.Count > 0) + { + var message = $"{prefix.Trim()} {String.Join(",", m_errors.Select(e => e.Message))}"; + throw new TemplateValidationException(message, m_errors); + } + } + + public void Clear() + { + m_errors.Clear(); + } + + public IEnumerator GetEnumerator() + { + return (m_errors as IEnumerable).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return (m_errors as IEnumerable).GetEnumerator(); + } + + private readonly List m_errors = new List(); + private readonly Int32 m_maxErrors; + private readonly Int32 m_maxMessageLength; + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationException.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationException.cs new file mode 100644 index 000000000..fac1d1c65 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateValidationException.cs @@ -0,0 +1,57 @@ +#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 System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + public class TemplateValidationException : Exception + { + public TemplateValidationException() + : this(TemplateStrings.TemplateNotValid()) + { + } + + public TemplateValidationException(IEnumerable errors) + : this(TemplateStrings.TemplateNotValidWithErrors(string.Join(",", (errors ?? Enumerable.Empty()).Select(e => e.Message)))) + { + m_errors = new List(errors ?? Enumerable.Empty()); + } + + public TemplateValidationException( + String message, + IEnumerable errors) + : this(message) + { + m_errors = new List(errors ?? Enumerable.Empty()); + } + + public TemplateValidationException(String message) + : base(message) + { + } + + public TemplateValidationException( + String message, + Exception innerException) + : base(message, innerException) + { + } + + public IList Errors + { + get + { + if (m_errors == null) + { + m_errors = new List(); + } + return m_errors; + } + } + + private List m_errors; + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/TemplateWriter.cs b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateWriter.cs new file mode 100644 index 000000000..075ad908b --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/TemplateWriter.cs @@ -0,0 +1,74 @@ +#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.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating +{ + /// + /// Converts from a TemplateToken into another object format + /// + internal sealed class TemplateWriter + { + internal static void Write( + IObjectWriter objectWriter, + TemplateToken value) + { + objectWriter.WriteStart(); + WriteValue(objectWriter, value); + objectWriter.WriteEnd(); + } + + private static void WriteValue( + IObjectWriter objectWriter, + TemplateToken value) + { + switch (value?.Type ?? TokenType.Null) + { + case TokenType.Null: + objectWriter.WriteNull(); + break; + + case TokenType.Boolean: + var booleanToken = value as BooleanToken; + objectWriter.WriteBoolean(booleanToken.Value); + break; + + case TokenType.Number: + var numberToken = value as NumberToken; + objectWriter.WriteNumber(numberToken.Value); + break; + + case TokenType.String: + case TokenType.BasicExpression: + case TokenType.InsertExpression: + objectWriter.WriteString(value.ToString()); + break; + + case TokenType.Mapping: + var mappingToken = value as MappingToken; + objectWriter.WriteMappingStart(); + foreach (var pair in mappingToken) + { + WriteValue(objectWriter, pair.Key); + WriteValue(objectWriter, pair.Value); + } + objectWriter.WriteMappingEnd(); + break; + + case TokenType.Sequence: + var sequenceToken = value as SequenceToken; + objectWriter.WriteSequenceStart(); + foreach (var item in sequenceToken) + { + WriteValue(objectWriter, item); + } + objectWriter.WriteSequenceEnd(); + break; + + default: + throw new NotSupportedException($"Unexpected type '{value.GetType()}'"); + } + } + } +} diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/BasicExpressionToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/BasicExpressionToken.cs new file mode 100644 index 000000000..f47379eed --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/BasicExpressionToken.cs @@ -0,0 +1,142 @@ +#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.Globalization; +using System.Linq; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions; +using GitHub.Actions.Expressions.Sdk; +using GitHub.Actions.Expressions.Sdk.Functions; +using Container = GitHub.Actions.Expressions.Sdk.Container; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + public sealed class BasicExpressionToken : ExpressionToken + { + public BasicExpressionToken( + Int32? fileId, + Int32? line, + Int32? column, + String expression) + : base(TokenType.BasicExpression, fileId, line, column, null) + { + m_expression = expression; + } + + internal String Expression + { + get + { + if (m_expression == null) + { + m_expression = String.Empty; + } + + return m_expression; + } + } + + public override TemplateToken Clone(Boolean omitSource) + { + return omitSource ? new BasicExpressionToken(null, null, null, m_expression) : new BasicExpressionToken(FileId, Line, Column, m_expression); + } + + public override String ToString() + { + return $"{TemplateConstants.OpenExpression} {m_expression} {TemplateConstants.CloseExpression}"; + } + + public override String ToDisplayString() + { + var expressionParser = new ExpressionParser(); + var expressionNode = expressionParser.ValidateSyntax(Expression, null); + if (expressionNode is Format formatNode) + { + // Make sure our first item is indeed a literal string so we can format it. + if (formatNode.Parameters.Count > 1 && + formatNode.Parameters.First() is Literal literalValueNode && + literalValueNode.Kind == ValueKind.String) + { + // Get all other Parameters san the formatted string to pass into the formatter + var formatParameters = formatNode.Parameters.Skip(1).Select(x => this.ConvertFormatParameterToExpression(x)).ToArray(); + if (formatParameters.Length > 0) + { + String formattedString = String.Empty; + try + { + formattedString = String.Format(CultureInfo.InvariantCulture, (formatNode.Parameters[0] as Literal).Value as String, formatParameters); + } + catch (FormatException) { } + catch (ArgumentNullException) { } // If this operation fails, revert to default display name + if (!String.IsNullOrEmpty(formattedString)) + { + return TrimDisplayString(formattedString); + } + } + } + } + return base.ToDisplayString(); + } + + internal StringToken EvaluateStringToken( + TemplateContext context, + out Int32 bytes) + { + return EvaluateStringToken(context, Expression, out bytes); + } + + internal MappingToken EvaluateMappingToken( + TemplateContext context, + out Int32 bytes) + { + return EvaluateMappingToken(context, Expression, out bytes); + } + + internal SequenceToken EvaluateSequenceToken( + TemplateContext context, + out Int32 bytes) + { + return EvaluateSequenceToken(context, Expression, out bytes); + } + + internal TemplateToken EvaluateTemplateToken( + TemplateContext context, + out Int32 bytes) + { + return EvaluateTemplateToken(context, Expression, out bytes); + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_expression?.Length == 0) + { + m_expression = null; + } + } + + private String ConvertFormatParameterToExpression(ExpressionNode node) + { + var nodeString = node.ConvertToExpression(); + + // If the node is a container, see if it starts with '(' and ends with ')' so we can simplify the string + // Should only simplify if only one '(' or ')' exists in the string + // We are trying to simplify the case (a || b) to a || b + // But we should avoid simplifying ( a && b + if (node is Container && + nodeString.Length > 2 && + nodeString[0] == ExpressionConstants.StartParameter && + nodeString[nodeString.Length - 1] == ExpressionConstants.EndParameter && + nodeString.Count(character => character == ExpressionConstants.StartParameter) == 1 && + nodeString.Count(character => character == ExpressionConstants.EndParameter) == 1) + { + nodeString = nodeString = nodeString.Substring(1, nodeString.Length - 2); + } + return String.Concat(TemplateConstants.OpenExpression, " ", nodeString, " ", TemplateConstants.CloseExpression); + } + + [DataMember(Name = "expr", EmitDefaultValue = false)] + private String m_expression; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/BooleanToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/BooleanToken.cs new file mode 100644 index 000000000..c5dea64f8 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/BooleanToken.cs @@ -0,0 +1,40 @@ +using System; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + public sealed class BooleanToken : LiteralToken, IBoolean + { + public BooleanToken( + Int32? fileId, + Int32? line, + Int32? column, + Boolean value) + : base(TokenType.Boolean, fileId, line, column) + { + m_value = value; + } + + public Boolean Value => m_value; + + public override TemplateToken Clone(Boolean omitSource) + { + return omitSource ? new BooleanToken(null, null, null, m_value) : new BooleanToken(FileId, Line, Column, m_value); + } + + public override String ToString() + { + return m_value ? "true" : "false"; + } + + Boolean IBoolean.GetBoolean() + { + return Value; + } + + [DataMember(Name = "bool", EmitDefaultValue = false)] + private Boolean m_value; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/ExpressionToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/ExpressionToken.cs new file mode 100644 index 000000000..4abcb371b --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/ExpressionToken.cs @@ -0,0 +1,95 @@ +#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.Globalization; +using System.Runtime.Serialization; +using System.Text.RegularExpressions; +using GitHub.Actions.Expressions; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + /// + /// Base class for all template expression tokens + /// + [DataContract] + public abstract class ExpressionToken : ScalarToken + { + internal ExpressionToken( + Int32 templateType, + Int32? fileId, + Int32? line, + Int32? column, + String directive) + : base(templateType, fileId, line, column) + { + Directive = directive; + } + + [DataMember(Name = "directive", EmitDefaultValue = false)] + internal String Directive { get; } + + internal static Boolean IsValidExpression( + String expression, + String[] allowedContext, + out Exception ex) + { + // Create dummy named values and functions + var namedValues = new List(); + var functions = new List(); + if (allowedContext?.Length > 0) + { + foreach (var contextItem in allowedContext) + { + var match = s_function.Match(contextItem); + if (match.Success) + { + var functionName = match.Groups[1].Value; + var minParameters = Int32.Parse(match.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture); + var maxParametersRaw = match.Groups[3].Value; + var maxParameters = String.Equals(maxParametersRaw, TemplateConstants.MaxConstant, StringComparison.Ordinal) + ? Int32.MaxValue + : Int32.Parse(maxParametersRaw, NumberStyles.None, CultureInfo.InvariantCulture); + functions.Add(new FunctionInfo(functionName, minParameters, maxParameters)); + } + else + { + namedValues.Add(new NamedValueInfo(contextItem)); + } + } + } + + // Parse + Boolean result; + ExpressionNode root = null; + try + { + root = new ExpressionParser().CreateTree(expression, null, namedValues, functions) as ExpressionNode; + + result = true; + ex = null; + } + catch (Exception exception) + { + result = false; + ex = exception; + } + + return result; + } + + private sealed class DummyFunction : Function + { + protected override Object EvaluateCore( + EvaluationContext context, + out ResultMemory resultMemory) + { + resultMemory = null; + return null; + } + } + + private static readonly Regex s_function = new Regex(@"^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$", RegexOptions.Compiled); + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/InsertExpressionToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/InsertExpressionToken.cs new file mode 100644 index 000000000..e469b173b --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/InsertExpressionToken.cs @@ -0,0 +1,27 @@ +using System; +using System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + public sealed class InsertExpressionToken : ExpressionToken + { + public InsertExpressionToken( + Int32? fileId, + Int32? line, + Int32? column) + : base(TokenType.InsertExpression, fileId, line, column, TemplateConstants.InsertDirective) + { + } + + public override TemplateToken Clone(Boolean omitSource) + { + return omitSource ? new InsertExpressionToken(null, null, null) : new InsertExpressionToken(FileId, Line, Column); + } + + public override String ToString() + { + return $"{TemplateConstants.OpenExpression} insert {TemplateConstants.CloseExpression}"; + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/LiteralToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/LiteralToken.cs new file mode 100644 index 000000000..75a35dcf6 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/LiteralToken.cs @@ -0,0 +1,18 @@ +using System; +using System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + public abstract class LiteralToken : ScalarToken + { + public LiteralToken( + Int32 tokenType, + Int32? fileId, + Int32? line, + Int32? column) + : base(tokenType, fileId, line, column) + { + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/MappingToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/MappingToken.cs new file mode 100644 index 000000000..eaa5006ac --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/MappingToken.cs @@ -0,0 +1,245 @@ +#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; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Runtime.Serialization; +using System.Threading; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + [JsonObject] + public sealed class MappingToken : TemplateToken, IEnumerable>, IReadOnlyObject + { + public MappingToken( + Int32? fileId, + Int32? line, + Int32? column) + : base(TokenType.Mapping, fileId, line, column) + { + } + + internal Int32 Count => m_items?.Count ?? 0; + + // IReadOnlyObject (for expressions) + Int32 IReadOnlyObject.Count + { + get + { + InitializeDictionary(); + return m_dictionary.Count; + } + } + + // IReadOnlyObject (for expressions) + IEnumerable IReadOnlyObject.Keys + { + get + { + InitializeDictionary(); + foreach (var key in m_dictionary.Keys) + { + yield return key as String; + } + } + } + + // IReadOnlyObject (for expressions) + IEnumerable IReadOnlyObject.Values + { + get + { + InitializeDictionary(); + foreach (var value in m_dictionary.Values) + { + yield return value; + } + } + } + + public KeyValuePair this[Int32 index] + { + get + { + return m_items[index]; + } + + set + { + m_items[index] = value; + m_dictionary = null; + } + } + + // IReadOnlyObject (for expressions) + Object IReadOnlyObject.this[String key] + { + get + { + InitializeDictionary(); + return m_dictionary[key]; + } + } + + public void Add(IEnumerable> items) + { + foreach (var item in items) + { + Add(item); + } + } + + public void Add(KeyValuePair item) + { + if (m_items == null) + { + m_items = new List>(); + } + + m_items.Add(item); + m_dictionary = null; + } + + public void Add( + ScalarToken key, + TemplateToken value) + { + Add(new KeyValuePair(key, value)); + } + + public override TemplateToken Clone(Boolean omitSource) + { + var result = omitSource ? new MappingToken(null, null, null) : new MappingToken(FileId, Line, Column); + if (m_items?.Count > 0) + { + foreach (var pair in m_items) + { + result.Add(pair.Key?.Clone(omitSource) as ScalarToken, pair.Value?.Clone(omitSource)); + } + } + return result; + } + + public IEnumerator> GetEnumerator() + { + if (m_items?.Count > 0) + { + return m_items.GetEnumerator(); + } + else + { + return (new List>(0)).GetEnumerator(); + } + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + if (m_items?.Count > 0) + { + return m_items.GetEnumerator(); + } + else + { + return (new KeyValuePair[0]).GetEnumerator(); + } + } + + public void Insert( + Int32 index, + KeyValuePair item) + { + if (m_items == null) + { + m_items = new List>(); + } + + m_items.Insert(index, item); + m_dictionary = null; + } + + public void Insert( + Int32 index, + ScalarToken key, + TemplateToken value) + { + Insert(index, new KeyValuePair(key, value)); + } + + public void RemoveAt(Int32 index) + { + m_items.RemoveAt(index); + m_dictionary = null; + } + + // IReadOnlyObject (for expressions) + Boolean IReadOnlyObject.ContainsKey(String key) + { + InitializeDictionary(); + return m_dictionary.Contains(key); + } + + // IReadOnlyObject (for expressions) + IEnumerator IReadOnlyObject.GetEnumerator() + { + InitializeDictionary(); + return m_dictionary.GetEnumerator(); + } + + // IReadOnlyObject (for expressions) + Boolean IReadOnlyObject.TryGetValue( + String key, + out Object value) + { + InitializeDictionary(); + if (!m_dictionary.Contains(key)) + { + value = null; + return false; + } + + value = m_dictionary[key]; + return true; + } + + /// + /// Initializes the dictionary used for the expressions IReadOnlyObject interface + /// + private void InitializeDictionary() + { + if (m_dictionary == null) + { + var dictionary = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); + if (m_items?.Count > 0) + { + foreach (var pair in m_items) + { + if (pair.Key is StringToken stringToken && + !dictionary.Contains(stringToken.Value)) + { + dictionary.Add(stringToken.Value, pair.Value); + } + } + } + Interlocked.CompareExchange(ref m_dictionary, dictionary, null); + } + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_items?.Count == 0) + { + m_items = null; + } + } + + [DataMember(Name = "map", EmitDefaultValue = false)] + private List> m_items; + + private IDictionary m_dictionary; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/NullToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/NullToken.cs new file mode 100644 index 000000000..456b2e7bf --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/NullToken.cs @@ -0,0 +1,28 @@ +using System; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + public sealed class NullToken : LiteralToken, INull + { + public NullToken( + Int32? fileId, + Int32? line, + Int32? column) + : base(TokenType.Null, fileId, line, column) + { + } + + public override TemplateToken Clone(Boolean omitSource) + { + return omitSource ? new NullToken(null, null, null) : new NullToken(FileId, Line, Column); + } + + public override String ToString() + { + return String.Empty; + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/NumberToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/NumberToken.cs new file mode 100644 index 000000000..33bcb73c4 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/NumberToken.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + public sealed class NumberToken : LiteralToken, INumber + { + public NumberToken( + Int32? fileId, + Int32? line, + Int32? column, + Double value) + : base(TokenType.Number, fileId, line, column) + { + m_value = value; + } + + public Double Value => m_value; + + public override TemplateToken Clone(Boolean omitSource) + { + return omitSource ? new NumberToken(null, null, null, m_value) : new NumberToken(FileId, Line, Column, m_value); + } + + public override String ToString() + { + return m_value.ToString("G15", CultureInfo.InvariantCulture); + } + + Double INumber.GetNumber() + { + return Value; + } + + [DataMember(Name = "num", EmitDefaultValue = false)] + private Double m_value; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/ScalarToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/ScalarToken.cs new file mode 100644 index 000000000..d0518f828 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/ScalarToken.cs @@ -0,0 +1,34 @@ +#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; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + public abstract class ScalarToken : TemplateToken + { + protected ScalarToken( + Int32 type, + Int32? fileId, + Int32? line, + Int32? column) + : base(type, fileId, line, column) + { + } + + public virtual String ToDisplayString() + { + return TrimDisplayString(ToString()); + } + + protected String TrimDisplayString(String displayString) + { + var firstLine = displayString.TrimStart(' ', '\t', '\r', '\n'); + var firstNewLine = firstLine.IndexOfAny(new[] { '\r', '\n' }); + if (firstNewLine >= 0) + { + firstLine = firstLine.Substring(0, firstNewLine); + } + return firstLine; + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/SequenceToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/SequenceToken.cs new file mode 100644 index 000000000..d120b6adc --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/SequenceToken.cs @@ -0,0 +1,149 @@ +#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; +using System.Collections.Generic; +using System.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + [JsonObject] + public sealed class SequenceToken : TemplateToken, IEnumerable, IReadOnlyArray + { + public SequenceToken( + Int32? fileId, + Int32? line, + Int32? column) + : base(TokenType.Sequence, fileId, line, column) + { + } + + public Int32 Count => m_items?.Count ?? 0; + + public TemplateToken this[Int32 index] + { + get + { + return m_items[index]; + } + + set + { + m_items[index] = value; + } + } + + // IReadOnlyArray (for expressions) + Object IReadOnlyArray.this[Int32 index] + { + get + { + return m_items[index]; + } + } + + public void Add(TemplateToken value) + { + if (m_items == null) + { + m_items = new List(); + } + + m_items.Add(value); + } + + public override TemplateToken Clone(Boolean omitSource) + { + var result = omitSource ? new SequenceToken(null, null, null) : new SequenceToken(FileId, Line, Column); + if (m_items?.Count > 0) + { + foreach (var item in m_items) + { + result.Add(item?.Clone(omitSource)); + } + } + return result; + } + + public IEnumerator GetEnumerator() + { + if (m_items?.Count > 0) + { + return m_items.GetEnumerator(); + } + else + { + return (new TemplateToken[0] as IEnumerable).GetEnumerator(); + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + if (m_items?.Count > 0) + { + return m_items.GetEnumerator(); + } + else + { + return (new TemplateToken[0] as IEnumerable).GetEnumerator(); + } + } + + // IReadOnlyArray (for expressions) + IEnumerator IReadOnlyArray.GetEnumerator() + { + if (m_items?.Count > 0) + { + return m_items.GetEnumerator(); + } + else + { + return (new TemplateToken[0] as IEnumerable).GetEnumerator(); + } + } + + public void Insert( + Int32 index, + TemplateToken item) + { + if (m_items == null) + { + m_items = new List(); + } + + m_items.Insert(index, item); + } + + public void InsertRange( + Int32 index, + IEnumerable items) + { + if (m_items == null) + { + m_items = new List(); + } + + m_items.InsertRange(index, items); + } + + public void RemoveAt(Int32 index) + { + m_items.RemoveAt(index); + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_items?.Count == 0) + { + m_items = null; + } + } + + [DataMember(Name = "seq", EmitDefaultValue = false)] + private List m_items; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/StringToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/StringToken.cs new file mode 100644 index 000000000..531e30ba9 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/StringToken.cs @@ -0,0 +1,62 @@ +#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.Runtime.Serialization; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + [DataContract] + public sealed class StringToken : LiteralToken, IString + { + public StringToken( + Int32? fileId, + Int32? line, + Int32? column, + String value) + : base(TokenType.String, fileId, line, column) + { + m_value = value; + } + + public String Value + { + get + { + if (m_value == null) + { + m_value = String.Empty; + } + + return m_value; + } + } + + public override TemplateToken Clone(Boolean omitSource) + { + return omitSource ? new StringToken(null, null, null, m_value) : new StringToken(FileId, Line, Column, m_value); + } + + public override String ToString() + { + return m_value ?? String.Empty; + } + + String IString.GetString() + { + return Value; + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_value?.Length == 0) + { + m_value = null; + } + } + + [DataMember(Name = "lit", EmitDefaultValue = false)] + private String m_value; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs new file mode 100644 index 000000000..193bcba2f --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateToken.cs @@ -0,0 +1,292 @@ +#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.Runtime.Serialization; +using GitHub.Actions.Expressions; +using GitHub.Actions.Expressions.Sdk; +using Newtonsoft.Json; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + /// + /// Base class for all template tokens + /// + [DataContract] + [JsonConverter(typeof(TemplateTokenJsonConverter))] + public abstract class TemplateToken + { + protected TemplateToken( + Int32 type, + Int32? fileId, + Int32? line, + Int32? column) + { + Type = type; + FileId = fileId; + Line = line; + Column = column; + } + + [DataMember(Name = "file", EmitDefaultValue = false)] + internal Int32? FileId { get; private set; } + + [DataMember(Name = "line", EmitDefaultValue = false)] + internal Int32? Line { get; private set; } + + [DataMember(Name = "col", EmitDefaultValue = false)] + internal Int32? Column { get; private set; } + + [DataMember(Name = "type", EmitDefaultValue = false)] + internal Int32 Type { get; } + + public TemplateToken Clone() + { + return Clone(false); + } + + public abstract TemplateToken Clone(Boolean omitSource); + + protected StringToken EvaluateStringToken( + TemplateContext context, + String expression, + out Int32 bytes) + { + var originalBytes = context.Memory.CurrentBytes; + try + { + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var options = new EvaluationOptions + { + MaxMemory = context.Memory.MaxBytes, + StrictJsonParsing = context.StrictJsonParsing, + }; + var result = tree.Evaluate(context.TraceWriter.ToExpressionTraceWriter(), null, context, options); + + if (result.Raw is LiteralToken literalToken) + { + var stringToken = new StringToken(FileId, Line, Column, literalToken.ToString()); + context.Memory.AddBytes(stringToken); + return stringToken; + } + + if (!result.IsPrimitive) + { + context.Error(this, "Expected a string"); + return CreateStringToken(context, expression); + } + + var stringValue = result.Kind == ValueKind.Null ? String.Empty : result.ConvertToString(); + return CreateStringToken(context, stringValue); + } + finally + { + bytes = context.Memory.CurrentBytes - originalBytes; + } + } + + protected SequenceToken EvaluateSequenceToken( + TemplateContext context, + String expression, + out Int32 bytes) + { + var originalBytes = context.Memory.CurrentBytes; + try + { + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var options = new EvaluationOptions + { + MaxMemory = context.Memory.MaxBytes, + StrictJsonParsing = context.StrictJsonParsing, + }; + var result = tree.Evaluate(context.TraceWriter.ToExpressionTraceWriter(), null, context, options); + var templateToken = ConvertToTemplateToken(context, result); + if (templateToken is SequenceToken sequence) + { + return sequence; + } + + context.Error(this, TemplateStrings.ExpectedSequence()); + return CreateSequenceToken(context); + } + finally + { + bytes = context.Memory.CurrentBytes - originalBytes; + } + } + + protected MappingToken EvaluateMappingToken( + TemplateContext context, + String expression, + out Int32 bytes) + { + var originalBytes = context.Memory.CurrentBytes; + try + { + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var options = new EvaluationOptions + { + MaxMemory = context.Memory.MaxBytes, + StrictJsonParsing = context.StrictJsonParsing, + }; + var result = tree.Evaluate(context.TraceWriter.ToExpressionTraceWriter(), null, context, options); + var templateToken = ConvertToTemplateToken(context, result); + if (templateToken is MappingToken mapping) + { + return mapping; + } + + context.Error(this, TemplateStrings.ExpectedMapping()); + return CreateMappingToken(context); + } + finally + { + bytes = context.Memory.CurrentBytes - originalBytes; + } + } + + protected TemplateToken EvaluateTemplateToken( + TemplateContext context, + String expression, + out Int32 bytes) + { + var originalBytes = context.Memory.CurrentBytes; + try + { + var tree = new ExpressionParser().CreateTree(expression, null, context.GetExpressionNamedValues(), context.ExpressionFunctions); + var options = new EvaluationOptions + { + MaxMemory = context.Memory.MaxBytes, + StrictJsonParsing = context.StrictJsonParsing, + }; + var result = tree.Evaluate(context.TraceWriter.ToExpressionTraceWriter(), null, context, options); + return ConvertToTemplateToken(context, result); + } + finally + { + bytes = context.Memory.CurrentBytes - originalBytes; + } + } + + private TemplateToken ConvertToTemplateToken( + TemplateContext context, + EvaluationResult result) + { + // Literal + if (TryConvertToLiteralToken(context, result, out LiteralToken literal)) + { + return literal; + } + // Known raw types + else if (!Object.ReferenceEquals(result.Raw, null)) + { + if (result.Raw is SequenceToken sequence) + { + context.Memory.AddBytes(sequence, true); + return sequence; + } + else if (result.Raw is MappingToken mapping) + { + context.Memory.AddBytes(mapping, true); + return mapping; + } + } + + // Leverage the expression SDK to traverse the object + if (result.TryGetCollectionInterface(out Object collection)) + { + if (collection is IReadOnlyObject dictionary) + { + var mapping = CreateMappingToken(context); + + foreach (KeyValuePair pair in dictionary) + { + var keyToken = CreateStringToken(context, pair.Key); + var valueResult = EvaluationResult.CreateIntermediateResult(null, pair.Value); + var valueToken = ConvertToTemplateToken(context, valueResult); + mapping.Add(keyToken, valueToken); + } + + return mapping; + } + else if (collection is IReadOnlyArray list) + { + var sequence = CreateSequenceToken(context); + + foreach (var item in list) + { + var itemResult = EvaluationResult.CreateIntermediateResult(null, item); + var itemToken = ConvertToTemplateToken(context, itemResult); + sequence.Add(itemToken); + } + + return sequence; + } + } + + throw new ArgumentException(TemplateStrings.UnableToConvertToTemplateToken(result.Value?.GetType().FullName)); + } + + private Boolean TryConvertToLiteralToken( + TemplateContext context, + EvaluationResult result, + out LiteralToken literal) + { + if (result.Raw is LiteralToken literal2) + { + context.Memory.AddBytes(literal2); + literal = literal2; + return true; + } + + switch (result.Kind) + { + case ValueKind.Null: + literal = new NullToken(FileId, Line, Column); + break; + + case ValueKind.Boolean: + literal = new BooleanToken(FileId, Line, Column, (Boolean)result.Value); + break; + + case ValueKind.Number: + literal = new NumberToken(FileId, Line, Column, (Double)result.Value); + break; + + case ValueKind.String: + literal = new StringToken(FileId, Line, Column, (String)result.Value); + break; + + default: + literal = null; + return false; + } + + context.Memory.AddBytes(literal); + return true; + } + + private StringToken CreateStringToken( + TemplateContext context, + String value) + { + var result = new StringToken(FileId, Line, Column, value); + context.Memory.AddBytes(result); + return result; + } + + private SequenceToken CreateSequenceToken(TemplateContext context) + { + var result = new SequenceToken(FileId, Line, Column); + context.Memory.AddBytes(result); + return result; + } + + private MappingToken CreateMappingToken(TemplateContext context) + { + var result = new MappingToken(FileId, Line, Column); + context.Memory.AddBytes(result); + return result; + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateTokenExtensions.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateTokenExtensions.cs new file mode 100644 index 000000000..328587f8d --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateTokenExtensions.cs @@ -0,0 +1,291 @@ +#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; +using GitHub.Actions.Expressions.Sdk; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + internal static class TemplateTokenExtensions + { + internal static BooleanToken AssertBoolean( + this TemplateToken value, + string objectDescription) + { + if (value is BooleanToken booleanToken) + { + return booleanToken; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(BooleanToken)}' was expected."); + } + + internal static NullToken AssertNull( + this TemplateToken value, + string objectDescription) + { + if (value is NullToken nullToken) + { + return nullToken; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(NullToken)}' was expected."); + } + + internal static NumberToken AssertNumber( + this TemplateToken value, + string objectDescription) + { + if (value is NumberToken numberToken) + { + return numberToken; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(NumberToken)}' was expected."); + } + + internal static StringToken AssertString( + this TemplateToken value, + string objectDescription) + { + if (value is StringToken stringToken) + { + return stringToken; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(StringToken)}' was expected."); + } + + internal static MappingToken AssertMapping( + this TemplateToken value, + string objectDescription) + { + if (value is MappingToken mapping) + { + return mapping; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(MappingToken)}' was expected."); + } + + internal static ScalarToken AssertScalar( + this TemplateToken value, + string objectDescription) + { + if (value is ScalarToken scalar) + { + return scalar; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(ScalarToken)}' was expected."); + } + + internal static SequenceToken AssertSequence( + this TemplateToken value, + string objectDescription) + { + if (value is SequenceToken sequence) + { + return sequence; + } + + throw new ArgumentException($"Unexpected type '{value?.GetType().Name}' encountered while reading '{objectDescription}'. The type '{nameof(SequenceToken)}' was expected."); + } + + internal static void AssertUnexpectedValue( + this LiteralToken literal, + string objectDescription) + { + throw new ArgumentException($"Error while reading '{objectDescription}'. Unexpected value '{literal.ToString()}'"); + } + + /// + /// Traverses the token and checks whether all required expression values + /// and functions are provided. + /// + public static bool CheckHasRequiredContext( + this TemplateToken token, + IReadOnlyObject expressionValues, + IList expressionFunctions) + { + var expressionTokens = token.Traverse() + .OfType() + .ToArray(); + var parser = new ExpressionParser(); + foreach (var expressionToken in expressionTokens) + { + var tree = parser.ValidateSyntax(expressionToken.Expression, null); + foreach (var node in tree.Traverse()) + { + if (node is NamedValue namedValue) + { + if (expressionValues?.Keys.Any(x => string.Equals(x, namedValue.Name, StringComparison.OrdinalIgnoreCase)) != true) + { + return false; + } + } + else if (node is Function function && + !ExpressionConstants.WellKnownFunctions.ContainsKey(function.Name) && + expressionFunctions?.Any(x => string.Equals(x.Name, function.Name, StringComparison.OrdinalIgnoreCase)) != true) + { + return false; + } + } + } + + return true; + } + + /// + /// Traverses each token that is provided (including descendants) and + /// 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, it is assumed that it may. + /// + /// Wildcards are supported in the pattern, and are treated as matching any literal. + /// For example, the expression "needs.my-job.outputs.my-output" matches the pattern "needs.*.outputs". + /// + public static bool[] CheckReferencesContext( + this IList tokens, + params string[] patterns) + { + var result = new bool[patterns.Length]; + + var expressionTokens = tokens + .SelectMany(x => x.Traverse()) + .OfType() + .ToArray(); + var parser = new ExpressionParser(); + foreach (var expressionToken in expressionTokens) + { + var tree = parser.ValidateSyntax(expressionToken.Expression, null); + var isReferenced = tree.CheckReferencesContext(patterns); + for (var i = 0; i < patterns.Length; i++) + { + if (isReferenced[i]) + { + result[i] = true; + } + } + } + + return result; + } + + /// + /// Returns all tokens (depth first) + /// + public static IEnumerable Traverse(this TemplateToken token) + { + return Traverse(token, omitKeys: false); + } + + /// + /// Returns all tokens (depth first) + /// + public static IEnumerable Traverse( + this TemplateToken token, + bool omitKeys) + { + if (token != null) + { + yield return token; + + if (token is SequenceToken || token is MappingToken) + { + var state = new TraversalState(null, token); + while (state != null) + { + if (state.MoveNext(omitKeys)) + { + token = state.Current; + yield return token; + + if (token is SequenceToken || token is MappingToken) + { + state = new TraversalState(state, token); + } + } + else + { + state = state.Parent; + } + } + } + } + } + + private sealed class TraversalState + { + public TraversalState( + TraversalState parent, + TemplateToken token) + { + Parent = parent; + m_token = token; + } + + public bool MoveNext(bool omitKeys) + { + switch (m_token.Type) + { + case TokenType.Sequence: + var sequence = m_token as SequenceToken; + if (++m_index < sequence.Count) + { + Current = sequence[m_index]; + return true; + } + else + { + Current = null; + return false; + } + + case TokenType.Mapping: + var mapping = m_token as MappingToken; + + // Return the value + if (m_isKey) + { + m_isKey = false; + Current = mapping[m_index].Value; + return true; + } + + if (++m_index < mapping.Count) + { + // Skip the key, return the value + if (omitKeys) + { + m_isKey = false; + Current = mapping[m_index].Value; + return true; + } + + // Return the key + m_isKey = true; + Current = mapping[m_index].Key; + return true; + } + + Current = null; + return false; + + default: + throw new NotSupportedException($"Unexpected token type '{m_token.Type}'"); + } + } + + private TemplateToken m_token; + private int m_index = -1; + private bool m_isKey; + public TemplateToken Current; + public TraversalState Parent; + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateTokenJsonConverter.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateTokenJsonConverter.cs new file mode 100644 index 000000000..50b4b77f3 --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TemplateTokenJsonConverter.cs @@ -0,0 +1,370 @@ +#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.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + /// + /// JSON serializer for TemplateToken objects + /// + internal sealed class TemplateTokenJsonConverter : JsonConverter + { + public override Boolean CanWrite + { + get + { + return true; + } + } + + public override Boolean CanConvert(Type objectType) + { + return typeof(TemplateToken).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()); + } + + public override Object ReadJson( + JsonReader reader, + Type objectType, + Object existingValue, + JsonSerializer serializer) + { + switch (reader.TokenType) + { + case JsonToken.String: + return new StringToken(null, null, null, reader.Value.ToString()); + case JsonToken.Boolean: + return new BooleanToken(null, null, null, (Boolean)reader.Value); + case JsonToken.Float: + return new NumberToken(null, null, null, (Double)reader.Value); + case JsonToken.Integer: + return new NumberToken(null, null, null, (Double)(Int64)reader.Value); + case JsonToken.Null: + return new NullToken(null, null, null); + case JsonToken.StartObject: + break; + default: + return null; + } + + Int32? type = null; + JObject value = JObject.Load(reader); + if (!value.TryGetValue("type", StringComparison.OrdinalIgnoreCase, out JToken typeValue)) + { + type = TokenType.String; + } + else if (typeValue.Type == JTokenType.Integer) + { + type = (Int32)typeValue; + } + else + { + return existingValue; + } + + Object newValue = null; + switch (type) + { + case TokenType.Null: + newValue = new NullToken(null, null, null); + break; + + case TokenType.Boolean: + newValue = new BooleanToken(null, null, null, default(Boolean)); + break; + + case TokenType.Number: + newValue = new NumberToken(null, null, null, default(Double)); + break; + + case TokenType.String: + newValue = new StringToken(null, null, null, null); + break; + + case TokenType.BasicExpression: + newValue = new BasicExpressionToken(null, null, null, null); + break; + + case TokenType.InsertExpression: + newValue = new InsertExpressionToken(null, null, null); + break; + + case TokenType.Sequence: + newValue = new SequenceToken(null, null, null); + break; + + case TokenType.Mapping: + newValue = new MappingToken(null, null, null); + break; + } + + if (value != null) + { + using JsonReader objectReader = value.CreateReader(); + serializer.Populate(objectReader, newValue); + } + + return newValue; + } + + public override void WriteJson( + JsonWriter writer, + Object value, + JsonSerializer serializer) + { + if (value is TemplateToken token) + { + switch (token.Type) + { + case TokenType.Null: + if (token.FileId == null && token.Line == null && token.Column == null) + { + writer.WriteNull(); + } + else + { + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(token.Type); + if (token.FileId != null) + { + writer.WritePropertyName("file"); + writer.WriteValue(token.FileId); + } + if (token.Line != null) + { + writer.WritePropertyName("line"); + writer.WriteValue(token.Line); + } + if (token.Column != null) + { + writer.WritePropertyName("col"); + writer.WriteValue(token.Column); + } + writer.WriteEndObject(); + } + return; + + case TokenType.Boolean: + var booleanToken = token as BooleanToken; + if (token.FileId == null && token.Line == null && token.Column == null) + { + writer.WriteValue(booleanToken.Value); + } + else + { + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(token.Type); + if (token.FileId != null) + { + writer.WritePropertyName("file"); + writer.WriteValue(token.FileId); + } + if (token.Line != null) + { + writer.WritePropertyName("line"); + writer.WriteValue(token.Line); + } + if (token.Column != null) + { + writer.WritePropertyName("col"); + writer.WriteValue(token.Column); + } + writer.WritePropertyName("bool"); + writer.WriteValue(booleanToken.Value); + writer.WriteEndObject(); + } + return; + + case TokenType.Number: + var numberToken = token as NumberToken; + if (token.FileId == null && token.Line == null && token.Column == null) + { + writer.WriteValue(numberToken.Value); + } + else + { + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(token.Type); + if (token.FileId != null) + { + writer.WritePropertyName("file"); + writer.WriteValue(token.FileId); + } + if (token.Line != null) + { + writer.WritePropertyName("line"); + writer.WriteValue(token.Line); + } + if (token.Column != null) + { + writer.WritePropertyName("col"); + writer.WriteValue(token.Column); + } + writer.WritePropertyName("num"); + writer.WriteValue(numberToken.Value); + writer.WriteEndObject(); + } + return; + + case TokenType.String: + var stringToken = token as StringToken; + if (token.FileId == null && token.Line == null && token.Column == null) + { + writer.WriteValue(stringToken.Value); + } + else + { + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(token.Type); + if (token.FileId != null) + { + writer.WritePropertyName("file"); + writer.WriteValue(token.FileId); + } + if (token.Line != null) + { + writer.WritePropertyName("line"); + writer.WriteValue(token.Line); + } + if (token.Column != null) + { + writer.WritePropertyName("col"); + writer.WriteValue(token.Column); + } + writer.WritePropertyName("lit"); + writer.WriteValue(stringToken.Value); + writer.WriteEndObject(); + } + return; + + case TokenType.BasicExpression: + var basicExpressionToken = token as BasicExpressionToken; + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(token.Type); + if (token.FileId != null) + { + writer.WritePropertyName("file"); + writer.WriteValue(token.FileId); + } + if (token.Line != null) + { + writer.WritePropertyName("line"); + writer.WriteValue(token.Line); + } + if (token.Column != null) + { + writer.WritePropertyName("col"); + writer.WriteValue(token.Column); + } + if (!String.IsNullOrEmpty(basicExpressionToken.Expression)) + { + writer.WritePropertyName("expr"); + writer.WriteValue(basicExpressionToken.Expression); + } + writer.WriteEndObject(); + return; + + case TokenType.InsertExpression: + var insertExpressionToken = token as InsertExpressionToken; + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(token.Type); + if (token.FileId != null) + { + writer.WritePropertyName("file"); + writer.WriteValue(token.FileId); + } + if (token.Line != null) + { + writer.WritePropertyName("line"); + writer.WriteValue(token.Line); + } + if (token.Column != null) + { + writer.WritePropertyName("col"); + writer.WriteValue(token.Column); + } + writer.WritePropertyName("directive"); + writer.WriteValue(insertExpressionToken.Directive); + writer.WriteEndObject(); + return; + + case TokenType.Sequence: + var sequenceToken = token as SequenceToken; + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(token.Type); + if (token.FileId != null) + { + writer.WritePropertyName("file"); + writer.WriteValue(token.FileId); + } + if (token.Line != null) + { + writer.WritePropertyName("line"); + writer.WriteValue(token.Line); + } + if (token.Column != null) + { + writer.WritePropertyName("col"); + writer.WriteValue(token.Column); + } + if (sequenceToken.Count > 0) + { + writer.WritePropertyName("seq"); + writer.WriteStartArray(); + foreach (var item in sequenceToken) + { + serializer.Serialize(writer, item); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + return; + + case TokenType.Mapping: + var mappingToken = token as MappingToken; + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(token.Type); + if (token.FileId != null) + { + writer.WritePropertyName("file"); + writer.WriteValue(token.FileId); + } + if (token.Line != null) + { + writer.WritePropertyName("line"); + writer.WriteValue(token.Line); + } + if (token.Column != null) + { + writer.WritePropertyName("col"); + writer.WriteValue(token.Column); + } + if (mappingToken.Count > 0) + { + writer.WritePropertyName("map"); + writer.WriteStartArray(); + foreach (var item in mappingToken) + { + serializer.Serialize(writer, item); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + return; + } + } + + throw new NotSupportedException($"Unexpected type '{value?.GetType().FullName}' when serializing template token"); + } + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TokenType.cs b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TokenType.cs new file mode 100644 index 000000000..aa224774c --- /dev/null +++ b/src/Sdk/WorkflowParser/ObjectTemplating/Tokens/TokenType.cs @@ -0,0 +1,23 @@ +using System; + +namespace GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens +{ + internal static class TokenType + { + internal const Int32 String = 0; + + internal const Int32 Sequence = 1; + + internal const Int32 Mapping = 2; + + internal const Int32 BasicExpression = 3; + + internal const Int32 InsertExpression = 4; + + internal const Int32 Boolean = 5; + + internal const Int32 Number = 6; + + internal const Int32 Null = 7; + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/ParseOptions.cs b/src/Sdk/WorkflowParser/ParseOptions.cs new file mode 100644 index 000000000..fe8f670be --- /dev/null +++ b/src/Sdk/WorkflowParser/ParseOptions.cs @@ -0,0 +1,81 @@ +namespace GitHub.Actions.WorkflowParser +{ + public sealed class ParseOptions + { + public ParseOptions() + { + } + + internal ParseOptions(ParseOptions copy) + { + AllowAnchors = copy.AllowAnchors; + MaxDepth = copy.MaxDepth; + MaxFiles = copy.MaxFiles; + MaxFileSize = copy.MaxFileSize; + MaxJobLimit = copy.MaxJobLimit; + MaxNestedReusableWorkflowsDepth = copy.MaxNestedReusableWorkflowsDepth; + MaxResultSize = copy.MaxResultSize; + SkipReusableWorkflows = copy.SkipReusableWorkflows; + } + + /// + /// Gets or sets a value indicating whether YAML anchors are allowed. + /// + public bool AllowAnchors { get; set; } + + /// + /// Gets or sets the maximum element depth when parsing a workflow. + /// + public int MaxDepth { get; set; } = 50; + + /// + /// Gets the maximum error message length before the message will be truncated. + /// + public int MaxErrorMessageLength => 500; + + /// + /// Gets the maximum number of errors that can be recorded when parsing a workflow. + /// + public int MaxErrors => 10; + + /// + /// Gets or sets the maximum number of files that can be loaded when parsing a workflow. Zero or less is treated as infinite. + /// + public int MaxFiles { get; set; } = 51; // 1 initial caller + max 50 reusable workflow references + + /// + /// Gets or set the maximum number of characters a file can contain when parsing a workflow. + /// + public int MaxFileSize { get; set; } = 1024 * 1024; + + /// + /// Gets or sets the maximum number of internal parsing events. This concept was initially + /// introduced to prevent infinite loops from user-controlled looping constructs. However, + /// we no longer have looping constructs. + /// + /// This concept can be removed. + /// + public int MaxParseEvents => 1000000; // 1 million + + /// + /// Gets or sets the maximum number of jobs that can be defined in a workflow (includes nested workflows). + /// Zero or less is treated as infinite. + /// + public int MaxJobLimit { get; set; } + + /// + /// Gets or sets the maximum workflow nest depth. Zero indicates reusable workflows are not allowed. + /// + public int MaxNestedReusableWorkflowsDepth { get; set; } + + /// + /// Gets or sets the maximum size of the result in bytes. + /// + public int MaxResultSize { get; set; } = 10 * 1024 * 1024; // 10 mb + + /// + /// Gets or sets a value indicating whether to skip loading reusable workflows. + /// + public bool SkipReusableWorkflows { get; set; } + } +} diff --git a/src/Sdk/WorkflowParser/PermissionLevel.cs b/src/Sdk/WorkflowParser/PermissionLevel.cs new file mode 100644 index 000000000..64ea313db --- /dev/null +++ b/src/Sdk/WorkflowParser/PermissionLevel.cs @@ -0,0 +1,9 @@ +namespace GitHub.Actions.WorkflowParser +{ + public enum PermissionLevel + { + NoAccess = 0, // Default value + Read, + Write, + } +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/Permissions.cs b/src/Sdk/WorkflowParser/Permissions.cs new file mode 100644 index 000000000..0a211e5b0 --- /dev/null +++ b/src/Sdk/WorkflowParser/Permissions.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.Conversion; +using Newtonsoft.Json; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public class Permissions + { + [JsonConstructor] + public Permissions() + { + } + + public Permissions(Permissions copy) + { + Actions = copy.Actions; + ArtifactMetadata = copy.ArtifactMetadata; + Attestations = copy.Attestations; + Checks = copy.Checks; + Contents = copy.Contents; + Deployments = copy.Deployments; + Issues = copy.Issues; + Discussions = copy.Discussions; + Packages = copy.Packages; + Pages = copy.Pages; + PullRequests = copy.PullRequests; + RepositoryProjects = copy.RepositoryProjects; + Statuses = copy.Statuses; + SecurityEvents = copy.SecurityEvents; + IdToken = copy.IdToken; + Models = copy.Models; + } + + public Permissions( + PermissionLevel permissionLevel, + bool includeIdToken, + bool includeAttestations, + bool includeModels) + { + Actions = permissionLevel; + ArtifactMetadata = permissionLevel; + Attestations = includeAttestations ? permissionLevel : PermissionLevel.NoAccess; + Checks = permissionLevel; + Contents = permissionLevel; + Deployments = permissionLevel; + Issues = permissionLevel; + Discussions = permissionLevel; + Packages = permissionLevel; + Pages = permissionLevel; + PullRequests = permissionLevel; + RepositoryProjects = permissionLevel; + Statuses = permissionLevel; + SecurityEvents = permissionLevel; + IdToken = includeIdToken ? permissionLevel : PermissionLevel.NoAccess; + // Models must not have higher permissions than Read + Models = includeModels + ? (permissionLevel == PermissionLevel.Write ? PermissionLevel.Read : permissionLevel) + : PermissionLevel.NoAccess; + } + + private static KeyValuePair[] ComparisonKeyMapping(Permissions left, Permissions right) + { + return new[] + { + new KeyValuePair("actions", (left.Actions, right.Actions)), + new KeyValuePair("artifact-metadata", (left.ArtifactMetadata, right.ArtifactMetadata)), + new KeyValuePair("attestations", (left.Attestations, right.Attestations)), + new KeyValuePair("checks", (left.Checks, right.Checks)), + new KeyValuePair("contents", (left.Contents, right.Contents)), + new KeyValuePair("deployments", (left.Deployments, right.Deployments)), + new KeyValuePair("discussions", (left.Discussions, right.Discussions)), + new KeyValuePair("issues", (left.Issues, right.Issues)), + new KeyValuePair("packages", (left.Packages, right.Packages)), + new KeyValuePair("pages", (left.Pages, right.Pages)), + new KeyValuePair("pull-requests", (left.PullRequests, right.PullRequests)), + new KeyValuePair("repository-projects", (left.RepositoryProjects, right.RepositoryProjects)), + new KeyValuePair("statuses", (left.Statuses, right.Statuses)), + new KeyValuePair("security-events", (left.SecurityEvents, right.SecurityEvents)), + new KeyValuePair("id-token", (left.IdToken, right.IdToken)), + new KeyValuePair("models", (left.Models, right.Models)), + }; + } + + [DataMember(Name = "actions", EmitDefaultValue = false)] + public PermissionLevel Actions + { + get; + set; + } + + [DataMember(Name = "artifact-metadata", EmitDefaultValue = false)] + public PermissionLevel ArtifactMetadata + { + get; + set; + } + + [DataMember(Name = "attestations", EmitDefaultValue = false)] + public PermissionLevel Attestations + { + get; + set; + } + + [DataMember(Name = "checks", EmitDefaultValue = false)] + public PermissionLevel Checks + { + get; + set; + } + + [DataMember(Name = "contents", EmitDefaultValue = false)] + public PermissionLevel Contents + { + get; + set; + } + + [DataMember(Name = "deployments", EmitDefaultValue = false)] + public PermissionLevel Deployments + { + get; + set; + } + + [DataMember(Name = "discussions", EmitDefaultValue = false)] + public PermissionLevel Discussions + { + get; + set; + } + + [DataMember(Name = "id-token", EmitDefaultValue = false)] + public PermissionLevel IdToken + { + get; + set; + } + + [DataMember(Name = "issues", EmitDefaultValue = false)] + public PermissionLevel Issues + { + get; + set; + } + + [DataMember(Name = "models", EmitDefaultValue = false)] + public PermissionLevel Models + { + get; + set; + } + + [DataMember(Name = "packages", EmitDefaultValue = false)] + public PermissionLevel Packages + { + get; + set; + } + + [DataMember(Name = "pages", EmitDefaultValue = false)] + public PermissionLevel Pages + { + get; + set; + } + + [DataMember(Name = "pull-requests", EmitDefaultValue = false)] + public PermissionLevel PullRequests + { + get; + set; + } + + [DataMember(Name = "repository-projects", EmitDefaultValue = false)] + public PermissionLevel RepositoryProjects + { + get; + set; + } + + [DataMember(Name = "security-events", EmitDefaultValue = false)] + public PermissionLevel SecurityEvents + { + get; + set; + } + + [DataMember(Name = "statuses", EmitDefaultValue = false)] + public PermissionLevel Statuses + { + get; + set; + } + + public Permissions Clone() + { + return new Permissions(this); + } + + internal bool ViolatesMaxPermissions(Permissions maxPermissions, out List permissionsViolations) + { + var mapping = Permissions.ComparisonKeyMapping(this, maxPermissions); + permissionsViolations = new List(); + + foreach (var (key, (permissionLevel, maxPermissionLevel)) in mapping) + { + if (!permissionLevel.IsLessThanOrEqualTo(maxPermissionLevel)) + { + permissionsViolations.Add(new PermissionLevelViolation(key, permissionLevel, maxPermissionLevel)); + } + } + + return permissionsViolations.Count > 0; + } + } +} diff --git a/src/Sdk/WorkflowParser/ReferencedWorkflow.cs b/src/Sdk/WorkflowParser/ReferencedWorkflow.cs new file mode 100644 index 000000000..cf62ff751 --- /dev/null +++ b/src/Sdk/WorkflowParser/ReferencedWorkflow.cs @@ -0,0 +1,167 @@ +#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.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public sealed class ReferencedWorkflow + { + [JsonConstructor] + public ReferencedWorkflow() + { + } + + private ReferencedWorkflow(ReferencedWorkflow infoToClone) + { + this.CallingWorkflowRef = infoToClone.CallingWorkflowRef; + this.CallingWorkflowSha = infoToClone.CallingWorkflowSha; + this.Repository = infoToClone.Repository; + this.RepositoryId = infoToClone.RepositoryId; + this.TenantId = infoToClone.TenantId; + this.ResolvedRef = infoToClone.ResolvedRef; + this.ResolvedSha = infoToClone.ResolvedSha; + this.WorkflowRef = infoToClone.WorkflowRef; + this.WorkflowFileFullPath = infoToClone.WorkflowFileFullPath; + this.m_data = new Dictionary(infoToClone.Data, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets or sets the repository's NWO, ex: / + /// + [DataMember] + public string Repository { get; set; } + + /// + /// Gets or sets the repository's GitHub global relay id + /// + [DataMember] + public string RepositoryId { get; set; } + + /// + /// Gets or sets the branch/tag ref that was resolved to the calling workflow file + /// refs/tags/ or refs/heads/ + /// This could be empty if the calling workflow file was referenced directly via commit SHA, or if there is no calling workflow + /// + [DataMember] + public string CallingWorkflowRef { get; set; } + + /// + /// Gets or sets the commit SHA for the calling workflow file + /// This is empty if there is no calling workflow + /// + [DataMember] + public string CallingWorkflowSha { get; set; } + + /// + /// Gets or sets the repository's Actions tenant HostId + /// + [DataMember] + public Guid TenantId { get; set; } + + /// + /// Gets or sets the branch/tag ref that was resolved to the workflow file + /// refs/tags/ or refs/heads/ + /// This could be empty if the workflow file was referenced directly via commit SHA + /// + [DataMember] + public string ResolvedRef { get; set; } + + /// + /// Gets or sets the commit SHA for the workflow file + /// + [DataMember] + public string ResolvedSha { get; set; } + + /// + /// Gets or sets the full path to the workflow file + /// owner/repo/path/to/workflow.yml + /// + [DataMember] + public string WorkflowFileFullPath { get; set; } + + /// + /// Gets or sets the workflow ref. + // for a callable workflow: + /// owner/repo/path/to/workflow.yml@ref + /// for main workflow file: + /// path/to/workflow.yml + /// + [DataMember] + public string WorkflowRef { get; set; } + + [IgnoreDataMember] + public string CanonicalWorkflowRef + { + get + { + // When ResolvedRef is not empty, the workflow ref was like "uses: my-org/my-repo/.github/workflows/foo.yml@main". + // Otherwise the workflow ref was like "uses: my-org/my-repo/.github/workflows/foo.yml@664bf207624be1e27b36b04c058d01893570f45c" + return string.Concat( + this.WorkflowFileFullPath, + "@", + !string.IsNullOrEmpty(this.ResolvedRef) ? this.ResolvedRef : this.ResolvedSha); + } + } + + public Dictionary Data + { + get + { + if (m_data == null) + { + m_data = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return m_data; + } + } + + public ReferencedWorkflow Clone() + { + return new ReferencedWorkflow(this); + } + + public bool IsTrusted() + { + if (Data.TryGetValue("IsTrusted", out var isTrusted)) + { + return string.Equals(isTrusted, bool.TrueString, StringComparison.OrdinalIgnoreCase); + } + return false; + } + + public bool IsRequiredWorkflow() + { + if (Data.TryGetValue("IsRequiredWorkflow", out var isRequiredWorkflow)) + { + return string.Equals(isRequiredWorkflow, bool.TrueString, StringComparison.OrdinalIgnoreCase); + } + return false; + } + + public string GetPlanOwnerId() + { + if (Data.TryGetValue("PlanOwnerId", out var planOwnerId)) + { + return planOwnerId; + } + return null; + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_data?.Count == 0) + { + m_data = null; + } + } + + [DataMember(Name = "Data", EmitDefaultValue = false)] + private Dictionary m_data; + } +} diff --git a/src/Sdk/WorkflowParser/ReferencedWorkflowNotFoundException.cs b/src/Sdk/WorkflowParser/ReferencedWorkflowNotFoundException.cs new file mode 100644 index 000000000..fb4cbbf2c --- /dev/null +++ b/src/Sdk/WorkflowParser/ReferencedWorkflowNotFoundException.cs @@ -0,0 +1,12 @@ +using System; + +namespace GitHub.Actions.WorkflowParser +{ + public class ReferencedWorkflowNotFoundException : Exception + { + public ReferencedWorkflowNotFoundException(String message) + : base(message) + { + } + } +} diff --git a/src/Sdk/WorkflowParser/ReusableWorkflowJob.cs b/src/Sdk/WorkflowParser/ReusableWorkflowJob.cs new file mode 100644 index 000000000..a2f16339c --- /dev/null +++ b/src/Sdk/WorkflowParser/ReusableWorkflowJob.cs @@ -0,0 +1,192 @@ +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public sealed class ReusableWorkflowJob : IJob + { + [DataMember(Order = 0, Name = "type", EmitDefaultValue = false)] + public JobType Type + { + get + { + return JobType.ReusableWorkflowJob; + } + } + + [DataMember(Order = 1, Name = "id", EmitDefaultValue = false)] + public StringToken? Id + { + get; + set; + } + + [DataMember(Order = 2, Name = "name", EmitDefaultValue = false)] + public ScalarToken? Name + { + get; + set; + } + + [IgnoreDataMember] + public IList Needs + { + get + { + if (m_needs == null) + { + m_needs = new List(); + } + return m_needs; + } + } + + [DataMember(Order = 4, Name = "if", EmitDefaultValue = false)] + public BasicExpressionToken? If + { + get; + set; + } + + [DataMember(Order = 5, Name = "ref", EmitDefaultValue = false)] + public StringToken? Ref + { + get; + set; + } + + [DataMember(Order = 6, Name = "permissions", EmitDefaultValue = false)] + public Permissions? Permissions + { + get; + set; + } + + [DataMember(Order = 7, Name = "input-definitions", EmitDefaultValue = false)] + public MappingToken? InputDefinitions + { + get; + set; + } + + [DataMember(Order = 8, Name = "input-values", EmitDefaultValue = false)] + public MappingToken? InputValues + { + get; + set; + } + + [DataMember(Order = 9, Name = "secret-definitions", EmitDefaultValue = false)] + public MappingToken? SecretDefinitions + { + get; + set; + } + + [DataMember(Order = 10, Name = "secret-values", EmitDefaultValue = false)] + public MappingToken? SecretValues + { + get; + set; + } + + [DataMember(Order = 11, Name = "inherit-secrets", EmitDefaultValue = false)] + public bool InheritSecrets + { + get; + set; + } + + [DataMember(Order = 12, Name = "outputs", EmitDefaultValue = false)] + public MappingToken? Outputs + { + get; + set; + } + + [DataMember(Order = 13, Name = "defaults", EmitDefaultValue = false)] + public TemplateToken? Defaults + { + get; + set; + } + + [DataMember(Order = 14, Name = "env", EmitDefaultValue = false)] + public TemplateToken? Env + { + get; + set; + } + + [DataMember(Order = 15, Name = "concurrency", EmitDefaultValue = false)] + public TemplateToken? Concurrency + { + get; + set; + } + + [DataMember(Order = 16, Name = "embedded-concurrency", EmitDefaultValue = false)] + public TemplateToken? EmbeddedConcurrency + { + get; + set; + } + + [DataMember(Order = 17, Name = "strategy", EmitDefaultValue = false)] + public TemplateToken? Strategy + { + get; + set; + } + + [IgnoreDataMember] + public IList Jobs + { + get + { + if (m_jobs == null) + { + m_jobs = new List(); + } + return m_jobs; + } + } + + public IJob Clone(bool omitSource) + { + var result = new ReusableWorkflowJob + { + Concurrency = Concurrency?.Clone(omitSource), + Defaults = Defaults?.Clone(omitSource), + Name = Name?.Clone(omitSource) as ScalarToken, + EmbeddedConcurrency = EmbeddedConcurrency?.Clone(omitSource), + Env = Env?.Clone(omitSource), + Id = Id?.Clone(omitSource) as StringToken, + If = If?.Clone(omitSource) as BasicExpressionToken, + InheritSecrets = InheritSecrets, + InputDefinitions = InputDefinitions?.Clone(omitSource) as MappingToken, + InputValues = InputValues?.Clone(omitSource) as MappingToken, + Outputs = Outputs?.Clone(omitSource) as MappingToken, + Permissions = Permissions?.Clone(), + Ref = Ref?.Clone(omitSource) as StringToken, + SecretDefinitions = SecretDefinitions?.Clone(omitSource) as MappingToken, + SecretValues = SecretValues?.Clone(omitSource) as MappingToken, + Strategy = Strategy?.Clone(omitSource), + }; + result.Jobs.AddRange(Jobs.Select(x => x.Clone(omitSource))); + result.Needs.AddRange(Needs.Select(x => (x.Clone(omitSource) as StringToken)!)); + return result; + } + + [DataMember(Order = 3, Name = "needs", EmitDefaultValue = false)] + private List? m_needs; + + [DataMember(Order = 18, Name = "jobs", EmitDefaultValue = false)] + private List? m_jobs; + } +} diff --git a/src/Sdk/WorkflowParser/RunStep.cs b/src/Sdk/WorkflowParser/RunStep.cs new file mode 100644 index 000000000..a8445f957 --- /dev/null +++ b/src/Sdk/WorkflowParser/RunStep.cs @@ -0,0 +1,93 @@ +#nullable enable + +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public sealed class RunStep : IStep + { + [DataMember(Order = 0, Name = "id", EmitDefaultValue = false)] + public string? Id + { + get; + set; + } + + /// + /// Gets or sets the display name + /// + [DataMember(Order = 1, Name = "name", EmitDefaultValue = false)] + public ScalarToken? Name + { + get; + set; + } + + [DataMember(Order = 2, Name = "if", EmitDefaultValue = false)] + public BasicExpressionToken? If + { + get; + set; + } + + [DataMember(Order = 3, Name = "continue-on-error", EmitDefaultValue = false)] + public ScalarToken? ContinueOnError + { + get; + set; + } + + [DataMember(Order = 4, Name = "timeout-minutes", EmitDefaultValue = false)] + public ScalarToken? TimeoutMinutes + { + get; + set; + } + + [DataMember(Order = 5, Name = "env", EmitDefaultValue = false)] + public TemplateToken? Env + { + get; + set; + } + + [DataMember(Order = 6, Name = "working-directory", EmitDefaultValue = false)] + public ScalarToken? WorkingDirectory + { + get; + set; + } + + [DataMember(Order = 7, Name = "shell", EmitDefaultValue = false)] + public ScalarToken? Shell + { + get; + set; + } + + [DataMember(Order = 8, Name = "run", EmitDefaultValue = false)] + public ScalarToken? Run + { + get; + set; + } + + public IStep Clone(bool omitSource) + { + return new RunStep + { + ContinueOnError = ContinueOnError?.Clone(omitSource) as ScalarToken, + Env = Env?.Clone(omitSource), + Id = Id, + If = If?.Clone(omitSource) as BasicExpressionToken, + Name = Name?.Clone(omitSource) as ScalarToken, + Run = Run?.Clone(omitSource) as ScalarToken, + Shell = Shell?.Clone(omitSource) as ScalarToken, + TimeoutMinutes = TimeoutMinutes?.Clone(omitSource) as ScalarToken, + WorkingDirectory = WorkingDirectory?.Clone(omitSource) as ScalarToken, + }; + } + } +} diff --git a/src/Sdk/WorkflowParser/RunsOn.cs b/src/Sdk/WorkflowParser/RunsOn.cs new file mode 100644 index 000000000..ac8be2064 --- /dev/null +++ b/src/Sdk/WorkflowParser/RunsOn.cs @@ -0,0 +1,39 @@ +#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.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public class RunsOn + { + public HashSet Labels + { + get + { + if (m_labels == null) + { + m_labels = new HashSet(); + } + return m_labels; + } + } + + [DataMember(EmitDefaultValue = false)] + public String RunnerGroup { get; set; } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_labels?.Count == 0) + { + m_labels = null; + } + } + + [DataMember(Name = "Labels", EmitDefaultValue = false)] + private HashSet m_labels; + } +} diff --git a/src/Sdk/WorkflowParser/Snapshot.cs b/src/Sdk/WorkflowParser/Snapshot.cs new file mode 100644 index 000000000..7b62556c7 --- /dev/null +++ b/src/Sdk/WorkflowParser/Snapshot.cs @@ -0,0 +1,24 @@ +#nullable enable + +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public class Snapshot + { + [DataMember(EmitDefaultValue = false)] + public required string ImageName { get; set; } + + [DataMember(EmitDefaultValue = false)] + public BasicExpressionToken? If + { + get; + set; + } + + [DataMember(EmitDefaultValue = false)] + public required string Version { get; set; } + } +} diff --git a/src/Sdk/WorkflowParser/StepType.cs b/src/Sdk/WorkflowParser/StepType.cs new file mode 100644 index 000000000..4a9519e3d --- /dev/null +++ b/src/Sdk/WorkflowParser/StepType.cs @@ -0,0 +1,7 @@ +namespace GitHub.Actions.WorkflowParser; + +public enum StepType +{ + ActionStep, + RunStep, +} \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/Strategy.cs b/src/Sdk/WorkflowParser/Strategy.cs new file mode 100644 index 000000000..87447f279 --- /dev/null +++ b/src/Sdk/WorkflowParser/Strategy.cs @@ -0,0 +1,39 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public sealed class Strategy + { + public Strategy() + { + FailFast = true; + } + + [DataMember(Name = "failFast", EmitDefaultValue = true)] + public Boolean FailFast { get; set; } + + [DataMember(Name = "maxParallel", EmitDefaultValue = false)] + public int MaxParallel { get; set; } + + [IgnoreDataMember] + public List Configurations + { + get + { + if (m_configuration is null) + { + m_configuration = new List(); + } + return m_configuration; + } + } + + [DataMember(Name = "configuration", EmitDefaultValue = false)] + private List? m_configuration; + } +} diff --git a/src/Sdk/WorkflowParser/StrategyConfiguration.cs b/src/Sdk/WorkflowParser/StrategyConfiguration.cs new file mode 100644 index 000000000..139d5f3f4 --- /dev/null +++ b/src/Sdk/WorkflowParser/StrategyConfiguration.cs @@ -0,0 +1,38 @@ +#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.Runtime.Serialization; +using GitHub.Actions.Expressions.Data; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public sealed class StrategyConfiguration + { + /// + /// Gets or sets the display name + /// + [DataMember(Name = "name", EmitDefaultValue = false)] + public String Name { get; set; } + + [DataMember(Name = "id", EmitDefaultValue = false)] + public String Id { get; set; } + + [IgnoreDataMember] + public Dictionary ExpressionData + { + get + { + if (m_expressionData is null) + { + m_expressionData = new Dictionary(StringComparer.Ordinal); + } + return m_expressionData; + } + } + + [DataMember(Name = "expressionData", EmitDefaultValue = false)] + private Dictionary m_expressionData; + } +} diff --git a/src/Sdk/WorkflowParser/TemplateContextExtensions.cs b/src/Sdk/WorkflowParser/TemplateContextExtensions.cs new file mode 100644 index 000000000..f47fe25e8 --- /dev/null +++ b/src/Sdk/WorkflowParser/TemplateContextExtensions.cs @@ -0,0 +1,70 @@ +using System; +using GitHub.Actions.WorkflowParser.Conversion; +using GitHub.Actions.WorkflowParser.ObjectTemplating; + +namespace GitHub.Actions.WorkflowParser +{ + /// + /// Extension methods for + /// + internal static class TemplateContextExtensions + { + /// + /// Stores the in the state. + /// + public static void SetFeatures( + this TemplateContext context, + WorkflowFeatures features) + { + context.State[s_featuresKey] = features; + } + + /// + /// Gets the from the state. + /// + public static WorkflowFeatures GetFeatures(this TemplateContext context) + { + if (context.State.TryGetValue(s_featuresKey, out var value) && + value is WorkflowFeatures features) + { + return features; + } + + throw new ArgumentNullException(nameof(WorkflowFeatures)); + } + + /// + /// Stores the in the state. + /// + public static void SetJobCountValidator( + this TemplateContext context, + JobCountValidator validator) + { + context.State[s_jobCountValidatorKey] = validator; + } + + /// + /// Gets the from the state. + /// + public static JobCountValidator GetJobCountValidator(this TemplateContext context) + { + if (context.State.TryGetValue(s_jobCountValidatorKey, out var value) && + value is JobCountValidator validator) + { + return validator; + } + + throw new ArgumentNullException(nameof(JobCountValidator)); + } + + /// + /// Lookup key for the object within the state dictionary. + /// + private static readonly string s_featuresKey = typeof(WorkflowFeatures).FullName!; + + /// + /// Lookup key for the object within the state dictionary. + /// + private static readonly string s_jobCountValidatorKey = typeof(JobCountValidator).FullName!; + } +} diff --git a/src/Sdk/WorkflowParser/WorkflowConstants.cs b/src/Sdk/WorkflowParser/WorkflowConstants.cs new file mode 100644 index 000000000..e35c244e6 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowConstants.cs @@ -0,0 +1,39 @@ +using System; + +namespace GitHub.Actions.WorkflowParser +{ + public static class WorkflowConstants + { + /// + /// The default job cancel timeout in minutes. + /// + internal const Int32 DefaultJobCancelTimeoutInMinutes = 5; + + /// + /// The default job name. This job name is used when a job does not leverage multipliers + /// or slicing and only has one implicit job. + /// + internal const String DefaultJobName = "__default"; + + /// + /// The default job timeout in minutes. + /// + internal const Int32 DefaultJobTimeoutInMinutes = 360; + + /// + /// The max length for a node within a workflow - e.g. a job ID or a matrix configuration ID. + /// + internal const Int32 MaxNodeNameLength = 100; + + /// + /// Alias for the self repository. + /// + internal const String SelfAlias = "self"; + + public static class PermissionsPolicy + { + public const string LimitedRead = "LimitedRead"; + public const string Write = "Write"; + } + } +} diff --git a/src/Sdk/WorkflowParser/WorkflowFeatures.cs b/src/Sdk/WorkflowParser/WorkflowFeatures.cs new file mode 100644 index 000000000..8b36a5fa3 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowFeatures.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser +{ + /// + /// Features flags (mostly short-lived) + /// + [DataContract] + public class WorkflowFeatures + { + /// + /// Gets or sets a value indicating whether users may specify permission "id-token". + /// Used during parsing only. + /// + [DataMember(EmitDefaultValue = false)] + public bool IdToken { get; set; } // Remove with DistributedTask.AllowGenerateIdToken + + /// + /// Gets or sets a value indicating whether users may specify permission "short-matrix-ids". + /// Used during parsing and evaluation. + /// + [DataMember(EmitDefaultValue = false)] + public bool ShortMatrixIds { get; set; } // Remove with DistributedTask.GenerateShortMatrixIds + + /// + /// Gets or sets a value indicating whether users may use the "snapshot" keyword. + /// Used during parsing only. + /// More information: https://github.com/github/hosted-runners/issues/186 + /// + [DataMember(EmitDefaultValue = false)] + public bool Snapshot { get; set; } + + /// + /// Gets or sets a value indicating whether users may use the "models" permission. + /// Used during parsing only. + /// + [DataMember(EmitDefaultValue = false)] + public bool AllowModelsPermission { get; set; } + + /// + /// Gets or sets a value indicating whether the expression function fromJson performs strict JSON parsing. + /// Used during evaluation only. + /// + [DataMember(EmitDefaultValue = false)] + public bool StrictJsonParsing { get; set; } + + /// + /// Gets the default workflow features. + /// + public static WorkflowFeatures GetDefaults() + { + return new WorkflowFeatures + { + IdToken = true, // Default to true since this is a long-lived feature flag + ShortMatrixIds = true, // Default to true since this is a long-lived feature flag + Snapshot = false, // Default to false since this feature is still in an experimental phase + StrictJsonParsing = false, // Default to false since this is temporary for telemetry purposes only + AllowModelsPermission = false, // Default to false since we want this to be disabled for all non-production environments + }; + } + + /// + /// Gets the value of the feature flag + /// + public bool GetFeature(string name) + { + return (bool)s_properties[name].GetValue(this)!; + } + + /// + /// Sets the value of the feature flag + /// + public void SetFeature(string name, bool value) + { + s_properties[name].SetValue(this, value); + } + + /// + /// Reflection info for accessing the feature flags + /// + private static readonly Dictionary s_properties = + typeof(WorkflowFeatures).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(x => x.PropertyType == typeof(bool)) // Boolean properties only + .ToDictionary(x => x.Name, StringComparer.Ordinal); + + /// + /// Names of all feature flags + /// + public static readonly IReadOnlyList Names = s_properties.Keys.Order().ToList().AsReadOnly(); + } +} diff --git a/src/Sdk/WorkflowParser/WorkflowStrings.cs b/src/Sdk/WorkflowParser/WorkflowStrings.cs new file mode 100644 index 000000000..ba6091d26 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowStrings.cs @@ -0,0 +1,172 @@ +// +// *** AUTOMATICALLY GENERATED BY GenResourceClass -- DO NOT EDIT!!! *** +using System; +using System.Diagnostics; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using System.Resources; + +namespace GitHub.Actions.WorkflowParser { + + +internal static class WorkflowStrings +{ + + + //******************************************************************************************** + /// Creates the resource manager instance. + //******************************************************************************************** + static WorkflowStrings() + { + s_resMgr = new ResourceManager("GitHub.Actions.WorkflowParser.WorkflowStrings", typeof(WorkflowStrings).GetTypeInfo().Assembly); + } + + public static ResourceManager Manager + { + get + { + return s_resMgr; + } + } + + //******************************************************************************************** + /// Returns a localized string given a resource string name. + //******************************************************************************************** + public static String Get( + String resourceName) + { + return s_resMgr.GetString(resourceName, CultureInfo.CurrentUICulture); + } + + //******************************************************************************************** + /// Returns a localized integer given a resource string name. + //******************************************************************************************** + public static int GetInt( + String resourceName) + { + return (int)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture); + } + + //******************************************************************************************** + /// Returns a localized string given a resource string name. + //******************************************************************************************** + public static bool GetBool( + String resourceName) + { + return (bool)s_resMgr.GetObject(resourceName, CultureInfo.CurrentUICulture); + } + + + //******************************************************************************************** + /// A little helper function to alleviate some typing associated with loading resources and + /// formatting the strings. In DEBUG builds, it also asserts that the number of format + /// arguments and the length of args match. + //******************************************************************************************** + private static String Format( // The formatted resource string. + String resourceName, // The name of the resource. + params Object[] args) // Arguments to format. + { + String resource = Get(resourceName); + +#if DEBUG + // Check to make sure that the number of format string arguments matches the number of + // arguments passed in. + int formatArgCount = 0; + bool[] argSeen = new bool[100]; + for (int i = 0; i < resource.Length; i++) + { + if (resource[i] == '{') + { + if (i + 1 < resource.Length && + resource[i + 1] == '{') + { + i++; // Skip the escaped curly braces. + } + else + { + // Move past the curly brace and leading whitespace. + i++; + while (Char.IsWhiteSpace(resource[i])) + { + i++; + } + + // Get the argument number. + int length = 0; + while (i + length < resource.Length && Char.IsDigit(resource[i + length])) + { + length++; + } + + // Record it if it hasn't already been seen. + int argNumber = int.Parse(resource.Substring(i, length), CultureInfo.InvariantCulture); + if (!argSeen[argNumber]) + { + formatArgCount++; // Count it as a formatting argument. + argSeen[argNumber] = true; + } + } + } + } + + Debug.Assert(args != null || formatArgCount == 0, + String.Format(CultureInfo.InvariantCulture, "The number of format arguments is {0}, but the args parameter is null.", formatArgCount)); + Debug.Assert(args == null || formatArgCount == args.Length, + String.Format(CultureInfo.InvariantCulture, "Coding error using resource \"{0}\": The number of format arguments {1} != number of args {2}", + resourceName, formatArgCount, args != null ? args.Length : 0)); +#endif // DEBUG + + + if (args == null) + { + return resource; + } + + // If there are any DateTime structs in the arguments, we need to bracket them + // to make sure they are within the supported range of the current calendar. + for (int i = 0; i < args.Length; i++) + { + // DateTime is a struct, we cannot use the as operator and null check. + if (args[i] is DateTime) + { + DateTime dateTime = (DateTime)args[i]; + + // We need to fetch the calendar on each Format call since it may change. + // Since we don't have more than one DateTime for resource, do not + // bother to cache this for the duration of the for loop. + Calendar calendar = DateTimeFormatInfo.CurrentInfo.Calendar; + if (dateTime > calendar.MaxSupportedDateTime) + { + args[i] = calendar.MaxSupportedDateTime; + } + else if (dateTime < calendar.MinSupportedDateTime) + { + args[i] = calendar.MinSupportedDateTime; + } + } + } + + return String.Format(CultureInfo.CurrentCulture, resource, args); + } + + // According to the documentation for the ResourceManager class, it should be sufficient to + // create a single static instance. The following is an excerpt from the 1.1 documentation. + // Using the methods of ResourceManager, a caller can access the resources for a particular + // culture using the GetObject and GetString methods. By default, these methods return the + // resource for the culture determined by the current cultural settings of the thread that made + // the call. + private static ResourceManager s_resMgr; + + /// + /// The workflow is not valid. + /// + public static String WorkflowNotValid() { return Get("WorkflowNotValid"); } + + /// + /// The workflow is not valid. {0} + /// + public static String WorkflowNotValidWithErrors(object arg0) { return Format("WorkflowNotValidWithErrors", arg0); } +} + +} // namespace diff --git a/src/Sdk/WorkflowParser/WorkflowStrings.resx b/src/Sdk/WorkflowParser/WorkflowStrings.resx new file mode 100644 index 000000000..0f8d149aa --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowStrings.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The workflow is not valid. + + + The workflow is not valid. {0} + + \ No newline at end of file diff --git a/src/Sdk/WorkflowParser/WorkflowTemplate.cs b/src/Sdk/WorkflowParser/WorkflowTemplate.cs new file mode 100644 index 000000000..f1842a4c4 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowTemplate.cs @@ -0,0 +1,188 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.Serialization; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + [DataContract] + public class WorkflowTemplate + { + public IDictionary InputTypes + { + get + { + if (m_inputTypes == null) + { + m_inputTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + return m_inputTypes; + } + } + [DataMember(Order = 0, Name = "input-types", EmitDefaultValue = false)] + private Dictionary? m_inputTypes; + + [DataMember(Order = 1, Name = "env", EmitDefaultValue = false)] + public TemplateToken? Env + { + get; + set; + } + + [DataMember(Order = 2, Name = "permissions", EmitDefaultValue = false)] + public Permissions? Permissions + { + get; + set; + } + + [DataMember(Order = 3, Name = "defaults", EmitDefaultValue = false)] + public TemplateToken? Defaults + { + get; + set; + } + + [DataMember(Order = 4, Name = "concurrency", EmitDefaultValue = false)] + public TemplateToken? Concurrency + { + get; + set; + } + + public IList Jobs + { + get + { + if (m_jobs == null) + { + m_jobs = new List(); + } + return m_jobs; + } + } + [DataMember(Order = 5, Name = "jobs", EmitDefaultValue = false)] + private List? m_jobs; + + public List FileTable + { + get + { + if (m_fileTable == null) + { + m_fileTable = new List(); + } + return m_fileTable; + } + } + [DataMember(Order = 6, Name = "file-table", EmitDefaultValue = false)] + private List? m_fileTable; + + public IList Errors + { + get + { + if (m_errors == null) + { + m_errors = new List(); + } + return m_errors; + } + } + [DataMember(Order = 7, Name = "errors", EmitDefaultValue = false)] + private List? m_errors; + + [EditorBrowsable(EditorBrowsableState.Never)] + public List FileInfo + { + get + { + if (m_fileInfo == null) + { + m_fileInfo = new List(); + } + return m_fileInfo; + } + } + + [DataMember(Order = 8, Name = "file-info", EmitDefaultValue = false)] + private List? m_fileInfo; + + [IgnoreDataMember] + public String? InitializationLog + { + get; + set; + } + + [IgnoreDataMember] + public Telemetry? Telemetry + { + get; + set; + } + + public void CheckErrors() + { + if (m_errors?.Count > 0) + { + throw new WorkflowValidationException(m_errors); + } + } + + internal WorkflowTemplate Clone(bool omitSource) + { + var result = new WorkflowTemplate + { + Concurrency = Concurrency?.Clone(omitSource), + Defaults = Defaults?.Clone(omitSource), + Env = Env?.Clone(omitSource), + Permissions = Permissions?.Clone(), + }; + result.Errors.AddRange(Errors.Select(x => x.Clone())); + result.InitializationLog = InitializationLog; + result.InputTypes.AddRange(InputTypes); + result.Jobs.AddRange(Jobs.Select(x => x.Clone(omitSource))); + if (!omitSource) + { + result.FileTable.AddRange(FileTable); + result.FileInfo.AddRange(FileInfo.Select(x => x.Clone())); + } + return result; + } + + [OnSerializing] + private void OnSerializing(StreamingContext context) + { + if (m_inputTypes?.Count == 0) + { + m_inputTypes = null; + } + + if (m_jobs?.Count == 0) + { + m_jobs = null; + } + + if (m_errors?.Count == 0) + { + m_errors = null; + } + + if (m_fileTable?.Count == 0) + { + m_fileTable = null; + } + + if (m_fileInfo?.Count == 0) + { + m_fileInfo = null; + } + } + } +} diff --git a/src/Sdk/WorkflowParser/WorkflowTemplateEvaluator.cs b/src/Sdk/WorkflowParser/WorkflowTemplateEvaluator.cs new file mode 100644 index 000000000..09a6c8471 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowTemplateEvaluator.cs @@ -0,0 +1,986 @@ +#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.Threading; +using GitHub.Actions.Expressions; +using GitHub.Actions.Expressions.Data; +using GitHub.Actions.Expressions.Sdk.Functions; +using GitHub.Actions.WorkflowParser.Conversion; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Schema; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; +using ITraceWriter = GitHub.Actions.WorkflowParser.ObjectTemplating.ITraceWriter; + +namespace GitHub.Actions.WorkflowParser +{ + /// + /// Evaluates parts of the workflow DOM. For example, a job strategy or step inputs. + /// + public class WorkflowTemplateEvaluator + { + /// + /// Creates a new instance for evaluating tokens within a workflow template. + /// + /// Optional trace writer for telemetry + /// Optional file table from the workflow template, for better error messages + /// Optional workflow features + public WorkflowTemplateEvaluator( + ITraceWriter trace, + IList fileTable, + WorkflowFeatures features) + { + m_trace = trace ?? new EmptyTraceWriter(); + m_fileTable = fileTable; + m_features = features ?? WorkflowFeatures.GetDefaults(); + m_schema = WorkflowSchemaFactory.GetSchema(m_features); + } + + /// + /// Creates a new instance for evaluating tokens within a workflow template. + /// + /// Optional trace writer for telemetry + /// Optional file table from the workflow template, for better error messages + /// Optional workflow features + /// Optional parent memory counter, for byte tracking across evaluation calls. + public WorkflowTemplateEvaluator( + ITraceWriter trace, + IList fileTable, + WorkflowFeatures features, + TemplateMemory parentMemory) + { + m_trace = trace ?? new EmptyTraceWriter(); + m_fileTable = fileTable; + m_features = features ?? WorkflowFeatures.GetDefaults(); + m_schema = WorkflowSchemaFactory.GetSchema(m_features); + m_parentMemory = parentMemory; + } + + public Int32 MaxDepth => 50; + + /// + /// Gets the maximum error message length before the message will be truncated. + /// + public Int32 MaxErrorMessageLength { get; set; } = 500; + + /// + /// Gets the maximum number of errors that can be recorded when parsing a workflow. + /// + public Int32 MaxErrors => 10; + + public Int32 MaxEvents => 1000000; // 1 million + + public Int32 MaxResultSize { get; set; } = 10 * 1024 * 1024; // 10 mb + + public Boolean EvaluateStageIf( + String stageId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions, + IEnumerable> expressionState) + { + var result = default(Boolean?); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.If}' for stage '{stageId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions, expressionState); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.JobIfResult, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToIfResult(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result ?? throw new InvalidOperationException("Stage if cannot be null"); + } + + public Boolean EvaluateJobIf( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions, + IEnumerable> expressionState) + { + var result = default(Boolean?); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.If}' for job '{jobId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions, expressionState); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.JobIfResult, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToIfResult(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result ?? throw new InvalidOperationException("Job if cannot be null"); + } + + /// + /// Evaluates a job strategy token + /// + /// The default job display name (any display name expression is evaluated after strategy) + public Strategy EvaluateStrategy( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions, + String jobName) + { + var result = new Strategy(); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.Strategy}' for job '{jobId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.Strategy, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToStrategy(context, token, jobName); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + if (result.Configurations.Count == 0) + { + var configuration = new StrategyConfiguration + { + Id = WorkflowConstants.DefaultJobName, + Name = new JobNameBuilder(jobName).Build(), + }; + configuration.ExpressionData.Add(WorkflowTemplateConstants.Matrix, null); + configuration.ExpressionData.Add( + WorkflowTemplateConstants.Strategy, + new DictionaryExpressionData + { + { + "fail-fast", + new BooleanExpressionData(result.FailFast) + }, + { + "job-index", + new NumberExpressionData(0) + }, + { + "job-total", + new NumberExpressionData(1) + }, + { + "max-parallel", + new NumberExpressionData(1) + } + }); + result.Configurations.Add(configuration); + } + + return result; + } + + public String EvaluateJobName( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions, + String defaultName) + { + var result = default(String); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.Name}' for job '{jobId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.StringStrategyContext, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToJobName(context, token); + if (string.IsNullOrEmpty(result)) + { + result = defaultName; + context.Memory.AddBytes(defaultName); + } + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result; + } + + public DictionaryExpressionData EvaluateWorkflowJobInputs( + ReusableWorkflowJob workflowJob, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var inputDefinitions = workflowJob.InputDefinitions; + var inputValues = workflowJob.InputValues; + var result = default(DictionaryExpressionData); + + if (inputDefinitions != null && inputDefinitions.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + var inputDefinitionsToken = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.WorkflowCallInputs, inputDefinitions, 0, null); + context.Errors.Check(); + var inputValuesToken = default(TemplateToken); + if (inputValues != null && inputValues.Type != TokenType.Null) + { + inputValuesToken = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.WorkflowJobWith, inputValues, 0, null); + context.Errors.Check(); + } + result = WorkflowTemplateConverter.ConvertToWorkflowJobInputs(context, inputDefinitionsToken, inputValuesToken, workflowJob); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result ?? new DictionaryExpressionData(); + } + + public IDictionary EvaluateWorkflowJobOutputs( + MappingToken outputDefinitions, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(IDictionary); + + if (outputDefinitions != null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + var outputs = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.WorkflowCallOutputs, outputDefinitions, 0, null); + + result = WorkflowTemplateConverter.ConvertToWorkflowJobOutputs(outputs); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public ActionsEnvironmentReference EvaluateJobEnvironment( + string jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(ActionsEnvironmentReference); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.Environment}' for job '{jobId}'."; + if (token != null && token.Type != TokenType.Null) + { + // Set "addMissingContexts:false" because the environment contains some properties + // that are intended to be evaluated on the server, and others on the runner. + // + // For example: + // environment: + // name: ${{ this evaluates on the server }} + // url: ${{ this evaluates on the runner }} + var context = CreateContext(expressionData, expressionFunctions, addMissingContexts: false); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.JobEnvironment, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToActionEnvironmentReference(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result; + } + + public TemplateToken EvaluateJobEnvironmentUrl( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(TemplateToken); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.StringRunnerContextNoSecrets, token, 0, null); + context.Errors.Check(); + result = token.AssertString("environment.url"); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result; + } + + public GroupPermitSetting EvaluateConcurrency( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(GroupPermitSetting); + string type; + string errorPrefix; + if (String.IsNullOrEmpty(jobId)) + { + type = WorkflowTemplateConstants.WorkflowConcurrency; + errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.Concurrency}'."; + } + else + { + type = WorkflowTemplateConstants.JobConcurrency; + errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.Concurrency}' for job '{jobId}'."; + } + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, type, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToConcurrency(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result; + } + + public RunsOn EvaluateRunsOn( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(RunsOn); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.RunsOn}' for job '{jobId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.RunsOn, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToRunsOn(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result ?? throw new InvalidOperationException("Job target cannot be null"); + } + + public Snapshot EvaluateSnapshot( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Snapshot); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.Snapshot}' for job '{jobId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.Snapshot, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToSnapshot(context, token); + } + catch (Exception ex) when (ex is not TemplateValidationException) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result; + } + + public Int32 EvaluateJobTimeout( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Int32?); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.TimeoutMinutes}' for job '{jobId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.NumberStrategyContext, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToJobTimeout(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result ?? WorkflowConstants.DefaultJobTimeoutInMinutes; + } + + public Int32 EvaluateJobCancelTimeout( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Int32?); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.CancelTimeoutMinutes}' for job '{jobId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.NumberStrategyContext, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToJobCancelTimeout(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result ?? WorkflowConstants.DefaultJobCancelTimeoutInMinutes; + } + + public Boolean EvaluateJobContinueOnError( + String jobId, + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Boolean?); + var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.ContinueOnError}' for job '{jobId}'."; + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.BooleanStrategyContext, token, 0, null); + context.Errors.Check(errorPrefix); + result = WorkflowTemplateConverter.ConvertToJobContinueOnError(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(errorPrefix); + } + + return result ?? false; + } + + public Boolean EvaluateStepContinueOnError( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Boolean?); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.BooleanStepsContext, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToStepContinueOnError(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result ?? false; + } + + public String EvaluateStepName( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(String); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.StringStepsContext, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToStepName(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result; + } + + public Boolean EvaluateStepIf( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions, + IEnumerable> expressionState) + { + var result = default(Boolean?); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions, expressionState); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.StepIfResult, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToIfResult(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result ?? throw new InvalidOperationException("Step if cannot be null"); + } + + public Dictionary EvaluateStepEnvironment( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions, + StringComparer keyComparer) + { + var result = default(Dictionary); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.StepEnv, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToStepEnvironment(context, token, keyComparer); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result ?? new Dictionary(keyComparer); + } + + public Dictionary EvaluateStepInputs( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Dictionary); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.StepWith, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToStepInputs(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public Int32 EvaluateStepTimeout( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Int32?); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.NumberStepsContext, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToStepTimeout(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result ?? 0; + } + + public JobContainer EvaluateJobContainer( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(JobContainer); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.Container, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToJobContainer(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result; + } + + public IList> EvaluateJobServiceContainers( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(List>); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.Services, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToJobServiceContainers(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result; + } + + public Dictionary EvaluateJobDefaultsRun( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Dictionary); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.JobDefaultsRun, token, 0, null); + context.Errors.Check(); + result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var mapping = token.AssertMapping("defaults run"); + foreach (var pair in mapping) + { + // Literal key + var key = pair.Key.AssertString("defaults run key"); + + // Literal value + var value = pair.Value.AssertString("defaults run value"); + result[key.Value] = value.Value; + } + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result; + } + + public Dictionary EvaluateJobOutputs( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(Dictionary); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.JobOutputs, token, 0, null); + context.Errors.Check(); + result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var mapping = token.AssertMapping("outputs"); + foreach (var pair in mapping) + { + // Literal key + var key = pair.Key.AssertString("output key"); + + // Literal value + var value = pair.Value.AssertString("output value"); + result[key.Value] = value.Value; + } + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result; + } + + public IDictionary EvaluateWorkflowJobSecrets( + TemplateToken token, + DictionaryExpressionData expressionData, + IList expressionFunctions) + { + var result = default(IDictionary); + + if (token != null && token.Type != TokenType.Null) + { + var context = CreateContext(expressionData, expressionFunctions); + try + { + token = TemplateEvaluator.Evaluate(context, WorkflowTemplateConstants.WorkflowJobSecrets, token, 0, null); + context.Errors.Check(); + result = WorkflowTemplateConverter.ConvertToWorkflowJobSecrets(context, token); + } + catch (Exception ex) when (!(ex is TemplateValidationException)) + { + context.Errors.Add(ex); + } + + context.Errors.Check(); + } + + return result ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private TemplateContext CreateContext( + DictionaryExpressionData expressionData, + IList expressionFunctions, + IEnumerable> expressionState = null, + Boolean addMissingContexts = true) + { + var result = new TemplateContext + { + CancellationToken = CancellationToken.None, + Errors = new TemplateValidationErrors(MaxErrors, MaxErrorMessageLength), + Memory = new TemplateMemory( + maxDepth: MaxDepth, + maxEvents: MaxEvents, + maxBytes: MaxResultSize, + parent: m_parentMemory), + Schema = m_schema, + StrictJsonParsing = m_features.StrictJsonParsing, + TraceWriter = m_trace, + }; + result.SetFeatures(m_features); + + // Add the file table + if (m_fileTable?.Count > 0) + { + foreach (var file in m_fileTable) + { + result.GetFileId(file); + } + } + + // Add named values + if (expressionData != null) + { + foreach (var pair in expressionData) + { + result.ExpressionValues[pair.Key] = pair.Value; + } + } + + // Add functions + var functionNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (expressionFunctions?.Count > 0) + { + foreach (var function in expressionFunctions) + { + result.ExpressionFunctions.Add(function); + functionNames.Add(function.Name); + } + } + + // Add missing expression values and expression functions. + // This solves the following problems: + // - Compat for new agent against old server (new contexts not sent down in job message) + // - Evaluating early when all referenced contexts are available, even though all allowed + // contexts may not yet be available. For example, evaluating step name can often + // be performed early. + if (addMissingContexts) + { + foreach (var name in s_expressionValueNames) + { + if (!result.ExpressionValues.ContainsKey(name)) + { + result.ExpressionValues[name] = null; + } + } + foreach (var name in s_expressionFunctionNames) + { + if (!functionNames.Contains(name)) + { + result.ExpressionFunctions.Add(new FunctionInfo(name, 0, Int32.MaxValue)); + } + } + } + + // Add vars context even if addMissingContexts is false to avoid + // JobEnvironment Evaluation errors + if(!result.ExpressionValues.ContainsKey(WorkflowTemplateConstants.Vars)) + { + result.ExpressionValues[WorkflowTemplateConstants.Vars] = null; + } + + // Add state + if (expressionState != null) + { + foreach (var pair in expressionState) + { + result.State[pair.Key] = pair.Value; + } + } + + return result; + } + + private readonly ITraceWriter m_trace; + private readonly TemplateSchema m_schema; + private readonly IList m_fileTable; + private readonly WorkflowFeatures m_features; + private readonly TemplateMemory m_parentMemory; + private readonly String[] s_expressionValueNames = new[] + { + WorkflowTemplateConstants.GitHub, + WorkflowTemplateConstants.Needs, + WorkflowTemplateConstants.Strategy, + WorkflowTemplateConstants.Matrix, + WorkflowTemplateConstants.Secrets, + WorkflowTemplateConstants.Vars, + WorkflowTemplateConstants.Steps, + WorkflowTemplateConstants.Inputs, + WorkflowTemplateConstants.Jobs, + WorkflowTemplateConstants.Job, + WorkflowTemplateConstants.Runner, + WorkflowTemplateConstants.Env, + }; + private readonly String[] s_expressionFunctionNames = new[] + { + WorkflowTemplateConstants.Always, + WorkflowTemplateConstants.Cancelled, + WorkflowTemplateConstants.Failure, + WorkflowTemplateConstants.HashFiles, + WorkflowTemplateConstants.Success, + }; + } +} diff --git a/src/Sdk/WorkflowParser/WorkflowTemplateParser.cs b/src/Sdk/WorkflowParser/WorkflowTemplateParser.cs new file mode 100644 index 000000000..10a345525 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowTemplateParser.cs @@ -0,0 +1,134 @@ +#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.Threading; +using GitHub.Actions.WorkflowParser.Conversion; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + using GitHub.Actions.WorkflowParser.ObjectTemplating; + + /// + /// Parses a workflow YAML file. + /// + public sealed class WorkflowTemplateParser + { + public WorkflowTemplateParser( + IServerTraceWriter serverTrace, + ITraceWriter trace, + ParseOptions options, + WorkflowFeatures features) + { + m_serverTrace = serverTrace ?? new EmptyServerTraceWriter(); + m_trace = trace ?? new EmptyTraceWriter(); + m_parseOptions = new ParseOptions(options ?? throw new ArgumentNullException(nameof(options))); + m_features = features ?? WorkflowFeatures.GetDefaults(); + } + + /// + /// Loads the YAML workflow template + /// + public WorkflowTemplate LoadWorkflow( + IFileProvider fileProvider, + String path, + String permissionPolicy, + IDictionary referencedWorkflows, + CancellationToken cancellationToken) + { + (var result, _) = LoadWorkflowInternal(fileProvider, path, permissionPolicy, referencedWorkflows, cancellationToken); + return result; + } + + /// + /// Loads the YAML workflow template and the estimated number of bytes consumed in memory (for x-lang unit tests) + /// + /// The workflow template, and the estimated number of bytes consumed in memory + internal (WorkflowTemplate, int) LoadWorkflowInternal( + IFileProvider fileProvider, + String path, + String permissionPolicy, + IDictionary referencedWorkflows, + CancellationToken cancellationToken) + + { + fileProvider = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider)); + TemplateContext context; + YamlTemplateLoader loader; + TemplateToken tokens; + + // Parse template tokens + (context, loader, tokens) = ParseTemplate(fileProvider, path, cancellationToken); + + var usage = new WorkflowUsage(m_serverTrace); + try + { + // Gather telemetry + usage.Gather(context, tokens); + + // Convert to workflow types + var workflowTemplate = WorkflowTemplateConverter.ConvertToWorkflow(context, tokens); + + // Set telemetry + workflowTemplate.Telemetry = context.Telemetry; + + // Load reusable workflows + ReusableWorkflowsLoader.Load(m_serverTrace, m_trace, m_parseOptions, usage, context, workflowTemplate, loader, permissionPolicy, referencedWorkflows); + + // Error state? Throw away the model + if (workflowTemplate.Errors.Count > 0) + { + var errorTemplate = new WorkflowTemplate(); + errorTemplate.Errors.AddRange(workflowTemplate.Errors); + errorTemplate.FileTable.AddRange(workflowTemplate.FileTable); + errorTemplate.Telemetry = context.Telemetry; + workflowTemplate = errorTemplate; + } + + return (workflowTemplate, context.Memory.CurrentBytes); + } + finally + { + usage.Trace(); + } + } + + /// + /// Parses a workflow template file. + /// + private (TemplateContext, YamlTemplateLoader, TemplateToken) ParseTemplate( + IFileProvider fileProvider, + String path, + CancellationToken cancellationToken) + { + // Setup the template context + var context = new TemplateContext + { + CancellationToken = cancellationToken, + Errors = new TemplateValidationErrors(m_parseOptions.MaxErrors, m_parseOptions.MaxErrorMessageLength), + Memory = new TemplateMemory( + maxDepth: m_parseOptions.MaxDepth, + maxEvents: m_parseOptions.MaxParseEvents, + maxBytes: m_parseOptions.MaxResultSize), + Schema = WorkflowSchemaFactory.GetSchema(m_features), + TraceWriter = m_trace, + }; + context.SetFeatures(m_features); + context.SetJobCountValidator(new JobCountValidator(context, m_parseOptions.MaxJobLimit)); + + // Setup the template loader + var loader = new YamlTemplateLoader(new ParseOptions(m_parseOptions), fileProvider); + + // Parse the template tokens + var tokens = loader.ParseWorkflow(context, path); + + return (context, loader, tokens); + } + + private readonly WorkflowFeatures m_features; + private readonly ParseOptions m_parseOptions; + private readonly IServerTraceWriter m_serverTrace; + private readonly ITraceWriter m_trace; + } +} diff --git a/src/Sdk/WorkflowParser/WorkflowUsage.cs b/src/Sdk/WorkflowParser/WorkflowUsage.cs new file mode 100644 index 000000000..c0afbcc91 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowUsage.cs @@ -0,0 +1,94 @@ +#nullable enable + +using System; +using GitHub.Actions.Expressions; +using GitHub.Actions.Expressions.Sdk; +using GitHub.Actions.WorkflowParser.ObjectTemplating; +using GitHub.Actions.WorkflowParser.ObjectTemplating.Tokens; + +namespace GitHub.Actions.WorkflowParser +{ + /// + /// Feature usage data + /// + internal class WorkflowUsage + { + public WorkflowUsage(IServerTraceWriter? serverTrace) + { + m_serverTrace = serverTrace; + } + + /// + /// Gathers feature usage from template tokens. Call after gathering all data. + /// + public void Gather( + TemplateContext context, + TemplateToken token) + { + try + { + if (context.Errors.Count > 0 || context.CancellationToken.IsCancellationRequested) + { + return; + } + + foreach (var t in token.Traverse()) + { + // ${{ insert }} + if (t is InsertExpressionToken) + { + m_data.ContainsInsertExpression = true; + } + else if (t is BasicExpressionToken expressionToken) + { + // Parse the expression + var parser = new ExpressionParser(); + var tree = parser.ValidateSyntax(expressionToken.Expression, null); + foreach (var node in IExpressionNodeExtensions.Traverse(tree)) + { + // success(arg[, arg]) or failure(arg[, arg]) + if (node is Function functionNode && + (string.Equals(functionNode.Name, "success", StringComparison.OrdinalIgnoreCase) || string.Equals(functionNode.Name, "failure", StringComparison.OrdinalIgnoreCase)) && + functionNode.Parameters.Count > 0) + { + m_data.CallsSuccessOrFailureWithArgs = true; + } + } + } + } + } + catch (Exception ex) + { + m_serverTrace?.TraceAlways(c_yamlFeaturesTracepoint, "Unexpected exception when gathering telemetry from YAML: {0}", ex); + } + } + + /// + /// Traces feature usage. Call this method once after gathering all data. + /// + public void Trace() + { + if (m_data.CallsSuccessOrFailureWithArgs) + { + m_serverTrace?.TraceAlways(c_yamlFeaturesTracepoint, "CallsSuccessOrFailureWithArgs=true"); + } + + if (m_data.ContainsInsertExpression) + { + m_serverTrace?.TraceAlways(c_yamlFeaturesTracepoint, "ContainsInsertExpression=true"); + } + + m_data = new UsageData(); + } + + private sealed class UsageData + { + public bool CallsSuccessOrFailureWithArgs { get; set; } + public bool ContainsInsertExpression { get; set; } + } + + private const int c_yamlFeaturesTracepoint = 10016155; // Copied from /Actions/Runtime/Sdk/Server/TraceConstants.cs + private readonly IServerTraceWriter? m_serverTrace; + private UsageData m_data = new UsageData(); + } +} diff --git a/src/Sdk/WorkflowParser/WorkflowValidationError.cs b/src/Sdk/WorkflowParser/WorkflowValidationError.cs new file mode 100644 index 000000000..7421863f4 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowValidationError.cs @@ -0,0 +1,65 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser +{ + /// + /// Provides information about an error which occurred during workflow validation. + /// + [DataContract] + public class WorkflowValidationError + { + public WorkflowValidationError() + { + } + + public WorkflowValidationError(String? message) + : this(null, message) + { + } + + public WorkflowValidationError( + String? code, + String? message) + { + Code = code; + Message = message; + } + + [DataMember(EmitDefaultValue = false)] + public String? Code + { + get; + set; + } + + [DataMember(EmitDefaultValue = false)] + public String? Message + { + get; + set; + } + + internal WorkflowValidationError Clone() + { + return new WorkflowValidationError(Code, Message); + } + + public static IEnumerable Create(Exception exception) + { + for (int i = 0; i < 50; i++) + { + yield return new WorkflowValidationError(exception.Message); + if (exception.InnerException == null) + { + break; + } + + exception = exception.InnerException; + } + } + } +} diff --git a/src/Sdk/WorkflowParser/WorkflowValidationException.cs b/src/Sdk/WorkflowParser/WorkflowValidationException.cs new file mode 100644 index 000000000..8b7179354 --- /dev/null +++ b/src/Sdk/WorkflowParser/WorkflowValidationException.cs @@ -0,0 +1,45 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; + +namespace GitHub.Actions.WorkflowParser +{ + public class WorkflowValidationException : Exception + { + public WorkflowValidationException() + : this(WorkflowStrings.WorkflowNotValid()) + { + } + + public WorkflowValidationException(IEnumerable errors) + : this(WorkflowStrings.WorkflowNotValidWithErrors(string.Join(" ", (errors ?? Enumerable.Empty()).Take(ErrorCount).Select(e => e.Message)))) + { + m_errors = new List(errors ?? Enumerable.Empty()); + } + + public WorkflowValidationException(String message) + : base(message) + { + } + + public WorkflowValidationException( + String message, + Exception innerException) + : base(message, innerException) + { + } + + internal IReadOnlyList Errors => (m_errors ?? new List()).AsReadOnly(); + + private List? m_errors; + + /// + /// Previously set to 2 when there were UI limitations. + /// Setting this to 10 to increase the number of errors returned from parser. + /// + private const int ErrorCount = 10; + } +} diff --git a/src/Sdk/WorkflowParser/workflow-v1.0.json b/src/Sdk/WorkflowParser/workflow-v1.0.json new file mode 100644 index 000000000..a2d9ed7c9 --- /dev/null +++ b/src/Sdk/WorkflowParser/workflow-v1.0.json @@ -0,0 +1,2872 @@ +{ + "version": "workflow-v1.0", + "definitions": { + "workflow-root": { + "description": "A workflow file.", + "mapping": { + "properties": { + "on": "on", + "name": "workflow-name", + "description": "workflow-description", + "run-name": "run-name", + "defaults": "workflow-defaults", + "env": "workflow-env", + "permissions": "permissions", + "concurrency": "workflow-concurrency", + "jobs": { + "type": "jobs", + "required": true + } + } + } + }, + "workflow-root-new": { + "description": "A workflow file.", + "mapping": { + "properties": { + "on": "on-new", + "name": "workflow-name", + "description": "workflow-description", + "run-name": "run-name", + "defaults": "workflow-defaults", + "env": "workflow-env", + "permissions": "permissions", + "concurrency": "workflow-concurrency", + "jobs": { + "type": "jobs", + "required": true + } + } + } + }, + "workflow-root-strict": { + "description": "Workflow file with strict validation", + "mapping": { + "properties": { + "on": { + "type": "on-strict", + "required": true + }, + "name": "workflow-name", + "description": "workflow-description", + "run-name": "run-name", + "defaults": "workflow-defaults", + "env": "workflow-env", + "permissions": "permissions", + "concurrency": "workflow-concurrency", + "jobs": { + "type": "jobs", + "required": true + } + } + } + }, + "workflow-name": { + "description": "The name of the workflow that GitHub displays on your repository's 'Actions' tab.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#name)", + "coerce-raw": true, + "string": {} + }, + "workflow-description": { + "description": "A description for your workflow or reusable workflow", + "string": {} + }, + "run-name": { + "context": [ + "github", + "inputs", + "vars" + ], + "string": {}, + "description": "The name for workflow runs generated from the workflow. GitHub displays the workflow run name in the list of workflow runs on your repository's 'Actions' tab.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#run-name)" + }, + "on": { + "description": "The GitHub event that triggers the workflow. Events can be a single string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. View a full list of [events that trigger workflows](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows).\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#on)", + "one-of": [ + "string", + "sequence", + "on-mapping" + ] + }, + "on-new": { + "description": "The GitHub event that triggers the workflow. Events can be a single string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. View a full list of [events that trigger workflows](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows).\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#on)", + "coerce-raw": true, + "one-of": [ + "branch-protection-rule-string", + "check-run-string", + "check-suite-string", + "create-string", + "delete-string", + "deployment-string", + "deployment-status-string", + "discussion-string", + "discussion-comment-string", + "fork-string", + "gollum-string", + "image-version-string", + "interactive-component-string", + "issue-comment-string", + "issues-string", + "label-string", + "member-string", + "merge-group-string", + "milestone-string", + "registry-package-string", + "page-build-string", + "project-card-string", + "project-column-string", + "project-string", + "public-string", + "pull-request-review-comment-string", + "pull-request-review-string", + "pull-request-string", + "pull-request-target-string", + "push-string", + "release-string", + "repository-dispatch-string", + "status-string", + "watch-string", + "workflow-run-string", + "workflow-dispatch-string", + "schedule-string", + "dynamic-string", + "workflow-call-string", + "sequence-of-on-string", + "on-mapping-new" + ] + }, + "on-string": { + "one-of": [ + "branch-protection-rule-string", + "check-run-string", + "check-suite-string", + "create-string", + "delete-string", + "deployment-string", + "deployment-status-string", + "discussion-string", + "discussion-comment-string", + "fork-string", + "gollum-string", + "image-version-string", + "interactive-component-string", + "issue-comment-string", + "issues-string", + "label-string", + "member-string", + "merge-group-string", + "milestone-string", + "registry-package-string", + "page-build-string", + "project-card-string", + "project-column-string", + "project-string", + "public-string", + "pull-request-review-comment-string", + "pull-request-review-string", + "pull-request-string", + "pull-request-target-string", + "push-string", + "release-string", + "repository-dispatch-string", + "status-string", + "watch-string", + "workflow-run-string", + "workflow-dispatch-string", + "schedule-string", + "dynamic-string", + "workflow-call-string" + ] + }, + "sequence-of-on-string": { + "sequence": { + "item-type": "on-string" + } + }, + "on-mapping": { + "mapping": { + "properties": { + "workflow_call": "workflow-call" + }, + "loose-key-type": "non-empty-string", + "loose-value-type": "any" + } + }, + "on-mapping-new": { + "mapping": { + "properties": { + "schedule": "schedule-relaxed", + "workflow_call": "workflow-call", + "workflow_dispatch": "workflow-dispatch-relaxed" + }, + "loose-key-type": "on-string", + "loose-value-type": "on-event-config-relaxed" + } + }, + "schedule-relaxed": { + "sequence": { + "item-type": "schedule-item-relaxed" + } + }, + "schedule-item-relaxed": { + "mapping": { + "properties": { + "cron": { + "type": "non-empty-string", + "required": true + } + }, + "loose-key-type": "string", + "loose-value-type": "any" + } + }, + "workflow-dispatch-relaxed": { + "one-of": [ + "null", + "workflow-dispatch-mapping-relaxed" + ] + }, + "workflow-dispatch-mapping-relaxed": { + "mapping": { + "properties": { + "inputs": "workflow-dispatch-inputs-relaxed" + }, + "loose-key-type": "string", + "loose-value-type": "any" + } + }, + "workflow-dispatch-inputs-relaxed": { + "one-of": [ + "null", + "workflow-dispatch-inputs-mapping-relaxed" + ] + }, + "workflow-dispatch-inputs-mapping-relaxed": { + "mapping": { + "loose-key-type": "workflow-dispatch-input-name", + "loose-value-type": "workflow-dispatch-input-relaxed" + } + }, + "workflow-dispatch-input-relaxed": { + "one-of": [ + "null", + "workflow-dispatch-input-mapping-relaxed" + ] + }, + "workflow-dispatch-input-mapping-relaxed": { + "mapping": { + "properties": { + "description": "string", + "type": "string", + "required": "boolean", + "default": "string" + }, + "loose-key-type": "string", + "loose-value-type": "any" + } + }, + "on-event-config-relaxed": { + "one-of": [ + "null", + "on-event-config-mapping-relaxed" + ] + }, + "on-event-config-mapping-relaxed": { + "mapping": { + "properties": { + "branches": "event-property-strings-relaxed", + "branches-ignore": "event-property-strings-relaxed", + "tags": "event-property-strings-relaxed", + "tags-ignore": "event-property-strings-relaxed", + "paths": "event-property-strings-relaxed", + "paths-ignore": "event-property-strings-relaxed", + "workflows": "event-property-strings-relaxed", + "types": "event-property-strings-relaxed", + "names": "event-property-strings-relaxed", + "versions": "event-property-strings-relaxed", + "reuse-previous-outcome": "boolean" + }, + "loose-key-type": "string", + "loose-value-type": "any" + } + }, + "event-property-strings-relaxed": { + "one-of": [ + "null", + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "on-strict": { + "description": "The GitHub event that triggers the workflow. Events can be a single string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. View a full list of [events that trigger workflows](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows).\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#on)", + "one-of": [ + "on-string-strict", + "on-sequence-strict", + "on-mapping-strict" + ] + }, + "on-mapping-strict": { + "description": "The GitHub event that triggers the workflow. Events can be a single string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. View a full list of [events that trigger workflows](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows).\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#on)", + "mapping": { + "properties": { + "branch_protection_rule": "branch-protection-rule", + "check_run": "check-run", + "check_suite": "check-suite", + "create": "create", + "delete": "delete", + "deployment": "deployment", + "deployment_status": "deployment-status", + "discussion": "discussion", + "discussion_comment": "discussion-comment", + "fork": "fork", + "gollum": "gollum", + "image_version": "image-version", + "issue_comment": "issue-comment", + "issues": "issues", + "label": "label", + "merge_group": "merge-group", + "milestone": "milestone", + "page_build": "page-build", + "project": "project", + "project_card": "project-card", + "project_column": "project-column", + "public": "public", + "pull_request": "pull-request", + "pull_request_comment": "pull-request-comment", + "pull_request_review": "pull-request-review", + "pull_request_review_comment": "pull-request-review-comment", + "pull_request_target": "pull-request-target", + "push": "push", + "registry_package": "registry-package", + "release": "release", + "repository_dispatch": "repository-dispatch", + "schedule": "schedule", + "status": "status", + "watch": "watch", + "workflow_call": "workflow-call", + "workflow_dispatch": "workflow-dispatch", + "workflow_run": "workflow-run" + } + } + }, + "on-string-strict": { + "one-of": [ + "branch-protection-rule-string", + "check-run-string", + "check-suite-string", + "create-string", + "delete-string", + "deployment-string", + "deployment-status-string", + "discussion-string", + "discussion-comment-string", + "fork-string", + "gollum-string", + "image-version-string", + "issue-comment-string", + "issues-string", + "label-string", + "merge-group-string", + "milestone-string", + "page-build-string", + "project-string", + "project-card-string", + "project-column-string", + "public-string", + "pull-request-string", + "pull-request-comment-string", + "pull-request-review-string", + "pull-request-review-comment-string", + "pull-request-target-string", + "push-string", + "registry-package-string", + "release-string", + "repository-dispatch-string", + "schedule-string", + "status-string", + "watch-string", + "workflow-call-string", + "workflow-dispatch-string", + "workflow-run-string" + ] + }, + "on-sequence-strict": { + "sequence": { + "item-type": "on-string-strict" + } + }, + "branch-protection-rule-string": { + "description": "Runs your workflow when branch protection rules in the workflow repository are changed.", + "string": { + "constant": "branch_protection_rule" + } + }, + "branch-protection-rule": { + "description": "Runs your workflow when branch protection rules in the workflow repository are changed.", + "one-of": [ + "null", + "branch-protection-rule-mapping" + ] + }, + "branch-protection-rule-mapping": { + "mapping": { + "properties": { + "types": "branch-protection-rule-activity" + } + } + }, + "branch-protection-rule-activity": { + "description": "The types of branch protection rule activity that trigger the workflow. Supported activity types: `created`, `edited`, `deleted`.", + "one-of": [ + "branch-protection-rule-activity-type", + "branch-protection-rule-activity-types" + ] + }, + "branch-protection-rule-activity-types": { + "sequence": { + "item-type": "branch-protection-rule-activity-type" + } + }, + "branch-protection-rule-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "check-run-string": { + "description": "Runs your workflow when activity related to a check run occurs. A check run is an individual test that is part of a check suite.", + "string": { + "constant": "check_run" + } + }, + "check-run": { + "description": "Runs your workflow when activity related to a check run occurs. A check run is an individual test that is part of a check suite.", + "one-of": [ + "null", + "check-run-mapping" + ] + }, + "check-run-mapping": { + "mapping": { + "properties": { + "types": "check-run-activity" + } + } + }, + "check-run-activity": { + "description": "The types of check run activity that trigger the workflow. Supported activity types: `created`, `rerequested`, `completed`, `requested_action`.", + "one-of": [ + "check-run-activity-type", + "check-run-activity-types" + ] + }, + "check-run-activity-types": { + "sequence": { + "item-type": "check-run-activity-type" + } + }, + "check-run-activity-type": { + "allowed-values": [ + "completed", + "created", + "rerequested", + "requested_action" + ] + }, + "check-suite-string": { + "description": "Runs your workflow when check suite activity occurs. A check suite is a collection of the check runs created for a specific commit. Check suites summarize the status and conclusion of the check runs that are in the suite.", + "string": { + "constant": "check_suite" + } + }, + "check-suite": { + "description": "Runs your workflow when check suite activity occurs. A check suite is a collection of the check runs created for a specific commit. Check suites summarize the status and conclusion of the check runs that are in the suite.", + "one-of": [ + "null", + "check-suite-mapping" + ] + }, + "check-suite-mapping": { + "mapping": { + "properties": { + "types": "check-suite-activity" + } + } + }, + "check-suite-activity": { + "description": "The types of check suite activity that trigger the workflow. Supported activity types: `completed`.", + "one-of": [ + "check-suite-activity-type", + "check-suite-activity-types" + ] + }, + "check-suite-activity-types": { + "sequence": { + "item-type": "check-suite-activity-type" + } + }, + "check-suite-activity-type": { + "allowed-values": [ + "completed" + ] + }, + "create-string": { + "description": "Runs your workflow when someone creates a Git reference (Git branch or tag) in the workflow's repository.", + "string": { + "constant": "create" + } + }, + "create": { + "description": "Runs your workflow when someone creates a Git reference (Git branch or tag) in the workflow's repository.", + "null": {} + }, + "delete-string": { + "description": "Runs your workflow when someone deletes a Git reference (Git branch or tag) in the workflow's repository.", + "string": { + "constant": "delete" + } + }, + "delete": { + "description": "Runs your workflow when someone deletes a Git reference (Git branch or tag) in the workflow's repository.", + "null": {} + }, + "deployment-string": { + "description": "Runs your workflow when someone creates a deployment in the workflow's repository. Deployments created with a commit SHA may not have a Git ref.", + "string": { + "constant": "deployment" + } + }, + "deployment": { + "description": "Runs your workflow when someone creates a deployment in the workflow's repository. Deployments created with a commit SHA may not have a Git ref.", + "null": {} + }, + "deployment-status-string": { + "description": "Runs your workflow when a third party provides a deployment status. Deployments created with a commit SHA may not have a Git ref.", + "string": { + "constant": "deployment_status" + } + }, + "deployment-status": { + "description": "Runs your workflow when a third party provides a deployment status. Deployments created with a commit SHA may not have a Git ref.", + "null": {} + }, + "discussion-string": { + "description": "Runs your workflow when a discussion in the workflow's repository is created or modified. For activity related to comments on a discussion, use the `discussion_comment` event.", + "string": { + "constant": "discussion" + } + }, + "discussion": { + "description": "Runs your workflow when a discussion in the workflow's repository is created or modified. For activity related to comments on a discussion, use the `discussion_comment` event.", + "one-of": [ + "null", + "discussion-mapping" + ] + }, + "discussion-mapping": { + "mapping": { + "properties": { + "types": "discussion-activity" + } + } + }, + "discussion-activity": { + "description": "The types of discussion activity that trigger the workflow. Supported activity types: `created`, `edited`, `deleted`, `transferred`, `pinned`, `unpinned`, `labeled`, `unlabeled`, `locked`, `unlocked`, `category_changed`, `answered`, `unanswered`.", + "one-of": [ + "discussion-activity-type", + "discussion-activity-types" + ] + }, + "discussion-activity-types": { + "sequence": { + "item-type": "discussion-activity-type" + } + }, + "discussion-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted", + "transferred", + "pinned", + "unpinned", + "labeled", + "unlabeled", + "locked", + "unlocked", + "category_changed", + "answered", + "unanswered" + ] + }, + "discussion-comment-string": { + "description": "Runs your workflow when a comment on a discussion in the workflow's repository is created or modified. For activity related to a discussion as opposed to comments on the discussion, use the `discussion` event.", + "string": { + "constant": "discussion_comment" + } + }, + "discussion-comment": { + "description": "Runs your workflow when a comment on a discussion in the workflow's repository is created or modified. For activity related to a discussion as opposed to comments on the discussion, use the `discussion` event.", + "one-of": [ + "null", + "discussion-comment-mapping" + ] + }, + "discussion-comment-mapping": { + "mapping": { + "properties": { + "types": "discussion-comment-activity" + } + } + }, + "discussion-comment-activity": { + "description": "The types of discussion comment activity that trigger the workflow. Supported activity types: `created`, `edited`, `deleted`.", + "one-of": [ + "discussion-comment-activity-type", + "discussion-comment-activity-types" + ] + }, + "discussion-comment-activity-types": { + "sequence": { + "item-type": "discussion-comment-activity-type" + } + }, + "discussion-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "dynamic-string": { + "string": { + "constant": "dynamic" + } + }, + "fork-string": { + "description": "Runs your workflow when someone forks a repository.", + "string": { + "constant": "fork" + } + }, + "fork": { + "description": "Runs your workflow when someone forks a repository.", + "null": {} + }, + "gollum-string": { + "description": "Runs your workflow when someone creates or updates a Wiki page.", + "string": { + "constant": "gollum" + } + }, + "gollum": { + "description": "Runs your workflow when someone creates or updates a Wiki page.", + "null": {} + }, + "image-version-string": { + "description": "Runs your workflow when an image version is created or changes state.", + "string": { + "constant": "image_version" + } + }, + "image-version": { + "description": "Runs your workflow when an image version is created or changes state.", + "one-of": [ + "null", + "image-version-mapping" + ] + }, + "image-version-mapping": { + "mapping": { + "properties": { + "types": "image-version-activity", + "names": "event-names", + "versions": "event-versions" + } + } + }, + "image-version-activity": { + "description": "The types of image version activity that trigger the workflow. Supported activity types: `created`, `ready`, `deleted`.", + "one-of": [ + "image-version-activity-type", + "image-version-activity-types" + ] + }, + "image-version-activity-types": { + "sequence": { + "item-type": "image-version-activity-type" + } + }, + "image-version-activity-type": { + "allowed-values": [ + "created", + "ready", + "deleted" + ] + }, + "interactive-component-string": { + "string": { + "constant": "interactive_component" + } + }, + "issue-comment-string": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "string": { + "constant": "issue_comment" + } + }, + "issue-comment": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "one-of": [ + "null", + "issue-comment-mapping" + ] + }, + "issue-comment-mapping": { + "mapping": { + "properties": { + "types": "issue-comment-activity" + } + } + }, + "issue-comment-activity": { + "description": "The types of issue comment activity that trigger the workflow. Supported activity types: `created`, `edited`, `deleted`.", + "one-of": [ + "issue-comment-activity-type", + "issue-comment-activity-types" + ] + }, + "issue-comment-activity-types": { + "sequence": { + "item-type": "issue-comment-activity-type" + } + }, + "issue-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "issues-string": { + "description": "Runs your workflow when an issue in the workflow's repository is created or modified. For activity related to comments in an issue, use the `issue_comment` event.", + "string": { + "constant": "issues" + } + }, + "issues": { + "description": "Runs your workflow when an issue in the workflow's repository is created or modified. For activity related to comments in an issue, use the `issue_comment` event.", + "one-of": [ + "null", + "issues-mapping" + ] + }, + "issues-mapping": { + "mapping": { + "properties": { + "types": "issues-activity" + } + } + }, + "issues-activity": { + "description": "The types of issue activity that trigger the workflow. Supported activity types: `opened`, `edited`, `deleted`, `transferred`, `pinned`, `unpinned`, `closed`, `reopened`, `assigned`, `unassigned`, `labeled`, `unlabeled`, `locked`, `unlocked`, `milestoned`, `demilestoned`.", + "one-of": [ + "issues-activity-type", + "issues-activity-types" + ] + }, + "issues-activity-types": { + "sequence": { + "item-type": "issues-activity-type" + } + }, + "issues-activity-type": { + "allowed-values": [ + "opened", + "edited", + "deleted", + "transferred", + "pinned", + "unpinned", + "closed", + "reopened", + "assigned", + "unassigned", + "labeled", + "unlabeled", + "locked", + "unlocked", + "milestoned", + "demilestoned" + ] + }, + "label-string": { + "description": "Runs your workflow when a label in your workflow's repository is created or modified.", + "string": { + "constant": "label" + } + }, + "label": { + "description": "Runs your workflow when a label in your workflow's repository is created or modified.", + "one-of": [ + "null", + "label-mapping" + ] + }, + "label-mapping": { + "mapping": { + "properties": { + "types": "label-activity" + } + } + }, + "label-activity": { + "description": "The types of label activity that trigger the workflow. Supported activity types: `created`, `edited`, `deleted`.", + "one-of": [ + "label-activity-type", + "label-activity-types" + ] + }, + "label-activity-types": { + "sequence": { + "item-type": "label-activity-type" + } + }, + "label-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "member-string": { + "string": { + "constant": "member" + } + }, + "merge-group-string": { + "description": "Runs your workflow when a pull request is added to a merge queue, which adds the pull request to a merge group.", + "string": { + "constant": "merge_group" + } + }, + "merge-group": { + "description": "Runs your workflow when a pull request is added to a merge queue, which adds the pull request to a merge group.", + "one-of": [ + "null", + "merge-group-mapping" + ] + }, + "merge-group-mapping": { + "mapping": { + "properties": { + "types": "merge-group-activity" + } + } + }, + "merge-group-activity": { + "description": "The types of merge group activity that trigger the workflow. Supported activity types: `checks_requested`.", + "one-of": [ + "merge-group-activity-type", + "merge-group-activity-types" + ] + }, + "merge-group-activity-types": { + "sequence": { + "item-type": "merge-group-activity-type" + } + }, + "merge-group-activity-type": { + "allowed-values": [ + "checks_requested" + ] + }, + "milestone-string": { + "description": "Runs your workflow when a milestone in the workflow's repository is created or modified.", + "string": { + "constant": "milestone" + } + }, + "milestone": { + "description": "Runs your workflow when a milestone in the workflow's repository is created or modified.", + "one-of": [ + "null", + "milestone-mapping" + ] + }, + "milestone-mapping": { + "mapping": { + "properties": { + "types": "milestone-activity" + } + } + }, + "milestone-activity": { + "description": "The types of milestone activity that trigger the workflow. Supported activity types: `created`, `closed`, `opened`, `edited`, `deleted`.", + "one-of": [ + "milestone-activity-type", + "milestone-activity-types" + ] + }, + "milestone-activity-types": { + "sequence": { + "item-type": "milestone-activity-type" + } + }, + "milestone-activity-type": { + "allowed-values": [ + "created", + "closed", + "opened", + "edited", + "deleted" + ] + }, + "page-build-string": { + "description": "Runs your workflow when someone pushes to a branch that is the publishing source for GitHub Pages, if GitHub Pages is enabled for the repository.", + "string": { + "constant": "page_build" + } + }, + "page-build": { + "description": "Runs your workflow when someone pushes to a branch that is the publishing source for GitHub Pages, if GitHub Pages is enabled for the repository.", + "null": {} + }, + "project-string": { + "description": "Runs your workflow when a project board is created or modified. For activity related to cards or columns in a project board, use the `project_card` or `project_column` events instead.", + "string": { + "constant": "project" + } + }, + "project": { + "description": "Runs your workflow when a project board is created or modified. For activity related to cards or columns in a project board, use the `project_card` or `project_column` events instead.", + "one-of": [ + "null", + "project-mapping" + ] + }, + "project-mapping": { + "mapping": { + "properties": { + "types": "project-activity" + } + } + }, + "project-activity": { + "description": "The types of project activity that trigger the workflow. Supported activity types: `created`, `closed`, `reopened`, `edited`, `deleted`.", + "one-of": [ + "project-activity-type", + "project-activity-types" + ] + }, + "project-activity-types": { + "sequence": { + "item-type": "project-activity-type" + } + }, + "project-activity-type": { + "allowed-values": [ + "created", + "closed", + "reopened", + "edited", + "deleted" + ] + }, + "project-card-string": { + "description": "Runs your workflow when a card on a project board is created or modified. For activity related to project boards or columns in a project board, use the `project` or `project_column` event instead.", + "string": { + "constant": "project_card" + } + }, + "project-card": { + "description": "Runs your workflow when a card on a project board is created or modified. For activity related to project boards or columns in a project board, use the `project` or `project_column` event instead.", + "one-of": [ + "null", + "project-card-mapping" + ] + }, + "project-card-mapping": { + "mapping": { + "properties": { + "types": "project-card-activity" + } + } + }, + "project-card-activity": { + "description": "The types of project card activity that trigger the workflow. Supported activity types: `created`, `moved`, `converted`, `edited`, `deleted`.", + "one-of": [ + "project-card-activity-type", + "project-card-activity-types" + ] + }, + "project-card-activity-types": { + "sequence": { + "item-type": "project-card-activity-type" + } + }, + "project-card-activity-type": { + "allowed-values": [ + "created", + "moved", + "converted", + "edited", + "deleted" + ] + }, + "project-column-string": { + "description": "Runs your workflow when a column on a project board is created or modified. For activity related to project boards or cards in a project board, use the `project` or `project_card` event instead.", + "string": { + "constant": "project_column" + } + }, + "project-column": { + "description": "Runs your workflow when a column on a project board is created or modified. For activity related to project boards or cards in a project board, use the `project` or `project_card` event instead.", + "one-of": [ + "null", + "project-column-mapping" + ] + }, + "project-column-mapping": { + "mapping": { + "properties": { + "types": "project-column-activity" + } + } + }, + "project-column-activity": { + "description": "The types of project column activity that trigger the workflow. Supported activity types: `created`, `updated`, `moved`, `deleted`.", + "one-of": [ + "project-column-activity-type", + "project-column-activity-types" + ] + }, + "project-column-activity-types": { + "sequence": { + "item-type": "project-column-activity-type" + } + }, + "project-column-activity-type": { + "allowed-values": [ + "created", + "updated", + "moved", + "deleted" + ] + }, + "public-string": { + "description": "Runs your workflow when your workflow's repository changes from private to public.", + "string": { + "constant": "public" + } + }, + "public": { + "description": "Runs your workflow when your workflow's repository changes from private to public.", + "null": {} + }, + "pull-request-string": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. If no activity types are specified, the workflow runs when a pull request is opened, reopened, or when the head branch of the pull request is updated.", + "string": { + "constant": "pull_request" + } + }, + "pull-request": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. If no activity types are specified, the workflow runs when a pull request is opened, reopened, or when the head branch of the pull request is updated.", + "one-of": [ + "null", + "pull-request-mapping" + ] + }, + "pull-request-mapping": { + "mapping": { + "properties": { + "types": "pull-request-activity", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "pull-request-activity": { + "description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.", + "one-of": [ + "pull-request-activity-type", + "pull-request-activity-types" + ] + }, + "pull-request-activity-types": { + "sequence": { + "item-type": "pull-request-activity-type" + } + }, + "pull-request-activity-type": { + "allowed-values": [ + "assigned", + "unassigned", + "labeled", + "unlabeled", + "opened", + "edited", + "closed", + "reopened", + "synchronize", + "converted_to_draft", + "ready_for_review", + "locked", + "unlocked", + "review_requested", + "review_request_removed", + "auto_merge_enabled", + "auto_merge_disabled" + ] + }, + "pull-request-comment-string": { + "description": "Please use the `issue_comment` event instead.", + "string": { + "constant": "pull_request_comment" + } + }, + "pull-request-comment": { + "description": "Please use the `issue_comment` event instead.", + "one-of": [ + "null", + "issue-comment-mapping" + ] + }, + "pull-request-review-string": { + "description": "Runs your workflow when a pull request review is submitted, edited, or dismissed. A pull request review is a group of pull request review comments in addition to a body comment and a state. For activity related to pull request review comments or pull request comments, use the `pull_request_review_comment` or `issue_comment` events instead.", + "string": { + "constant": "pull_request_review" + } + }, + "pull-request-review": { + "description": "Runs your workflow when a pull request review is submitted, edited, or dismissed. A pull request review is a group of pull request review comments in addition to a body comment and a state. For activity related to pull request review comments or pull request comments, use the `pull_request_review_comment` or `issue_comment` events instead.", + "one-of": [ + "null", + "pull-request-review-mapping" + ] + }, + "pull-request-review-mapping": { + "mapping": { + "properties": { + "types": "pull-request-review-activity" + } + } + }, + "pull-request-review-activity": { + "description": "The types of pull request review activity that trigger the workflow. Supported activity types: `submitted`, `edited`, `dismissed`.", + "one-of": [ + "pull-request-review-activity-type", + "pull-request-review-activity-types" + ] + }, + "pull-request-review-activity-types": { + "sequence": { + "item-type": "pull-request-review-activity-type" + } + }, + "pull-request-review-activity-type": { + "allowed-values": [ + "submitted", + "edited", + "dismissed" + ] + }, + "pull-request-review-comment-string": { + "description": "", + "string": { + "constant": "pull_request_review_comment" + } + }, + "pull-request-review-comment": { + "description": "", + "one-of": [ + "null", + "pull-request-review-comment-mapping" + ] + }, + "pull-request-review-comment-mapping": { + "mapping": { + "properties": { + "types": "pull-request-review-comment-activity" + } + } + }, + "pull-request-review-comment-activity": { + "description": "The types of pull request review comment activity that trigger the workflow. Supported activity types: `created`, `edited`, `deleted`.", + "one-of": [ + "pull-request-review-comment-activity-type", + "pull-request-review-comment-activity-types" + ] + }, + "pull-request-review-comment-activity-types": { + "sequence": { + "item-type": "pull-request-review-comment-activity-type" + } + }, + "pull-request-review-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "pull-request-target-string": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. If no activity types are specified, the workflow runs when a pull request is opened, reopened, or when the head branch of the pull request is updated.\n\nThis event runs in the context of the base of the pull request, rather than in the context of the merge commit, as the `pull_request` event does. This prevents execution of unsafe code from the head of the pull request that could alter your repository or steal any secrets you use in your workflow. This event allows your workflow to do things like label or comment on pull requests from forks. Avoid using this event if you need to build or run code from the pull request.", + "string": { + "constant": "pull_request_target" + } + }, + "pull-request-target": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. If no activity types are specified, the workflow runs when a pull request is opened, reopened, or when the head branch of the pull request is updated.\n\nThis event runs in the context of the base of the pull request, rather than in the context of the merge commit, as the `pull_request` event does. This prevents execution of unsafe code from the head of the pull request that could alter your repository or steal any secrets you use in your workflow. This event allows your workflow to do things like label or comment on pull requests from forks. Avoid using this event if you need to build or run code from the pull request.", + "one-of": [ + "null", + "pull-request-target-mapping" + ] + }, + "pull-request-target-mapping": { + "mapping": { + "properties": { + "types": "pull-request-target-activity", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "pull-request-target-activity": { + "description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.", + "one-of": [ + "pull-request-target-activity-type", + "pull-request-target-activity-types" + ] + }, + "pull-request-target-activity-types": { + "sequence": { + "item-type": "pull-request-target-activity-type" + } + }, + "pull-request-target-activity-type": { + "allowed-values": [ + "assigned", + "unassigned", + "labeled", + "unlabeled", + "opened", + "edited", + "closed", + "reopened", + "synchronize", + "converted_to_draft", + "ready_for_review", + "locked", + "unlocked", + "review_requested", + "review_request_removed", + "auto_merge_enabled", + "auto_merge_disabled" + ] + }, + "push-string": { + "description": "Runs your workflow when you push a commit or tag.", + "string": { + "constant": "push" + } + }, + "push": { + "description": "Runs your workflow when you push a commit or tag.", + "one-of": [ + "null", + "push-mapping" + ] + }, + "push-mapping": { + "mapping": { + "properties": { + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "tags": "event-tags", + "tags-ignore": "event-tags-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "registry-package-string": { + "description": "Runs your workflow when activity related to GitHub Packages occurs in your repository.", + "string": { + "constant": "registry_package" + } + }, + "registry-package": { + "description": "Runs your workflow when activity related to GitHub Packages occurs in your repository.", + "one-of": [ + "null", + "registry-package-mapping" + ] + }, + "registry-package-mapping": { + "mapping": { + "properties": { + "types": "registry-package-activity" + } + } + }, + "registry-package-activity": { + "description": "The types of registry package activity that trigger the workflow. Supported activity types: `published`, `updated`.", + "one-of": [ + "registry-package-activity-type", + "registry-package-activity-types" + ] + }, + "registry-package-activity-types": { + "sequence": { + "item-type": "registry-package-activity-type" + } + }, + "registry-package-activity-type": { + "allowed-values": [ + "published", + "updated" + ] + }, + "release-string": { + "description": "Runs your workflow when release activity in your repository occurs.", + "string": { + "constant": "release" + } + }, + "release": { + "description": "Runs your workflow when release activity in your repository occurs.", + "one-of": [ + "null", + "release-mapping" + ] + }, + "release-mapping": { + "mapping": { + "properties": { + "types": "release-activity" + } + } + }, + "release-activity": { + "description": "The types of release activity that trigger the workflow. Supported activity types: `published`, `unpublished`, `created`, `edited`, `deleted`, `prereleased`, `released`.", + "one-of": [ + "release-activity-type", + "release-activity-types" + ] + }, + "release-activity-types": { + "sequence": { + "item-type": "release-activity-type" + } + }, + "release-activity-type": { + "allowed-values": [ + "published", + "unpublished", + "created", + "edited", + "deleted", + "prereleased", + "released" + ] + }, + "schedule-string": { + "description": "The `schedule` event allows you to trigger a workflow at a scheduled time.\n\nYou can schedule a workflow to run at specific UTC times using POSIX cron syntax. Scheduled workflows run on the latest commit on the default or base branch. The shortest interval you can run scheduled workflows is once every 5 minutes. GitHub Actions does not support the non-standard syntax `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`, and `@reboot`.", + "string": { + "constant": "schedule" + } + }, + "schedule": { + "description": "The `schedule` event allows you to trigger a workflow at a scheduled time.\n\nYou can schedule a workflow to run at specific UTC times using POSIX cron syntax. Scheduled workflows run on the latest commit on the default or base branch. The shortest interval you can run scheduled workflows is once every 5 minutes. GitHub Actions does not support the non-standard syntax `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`, and `@reboot`.", + "sequence": { + "item-type": "cron-mapping" + } + }, + "status-string": { + "description": "Runs your workflow when the status of a Git commit changes. For example, commits can be marked as `error`, `failure`, `pending`, or `success`. If you want to provide more details about the status change, you may want to use the `check_run` event.", + "string": { + "constant": "status" + } + }, + "status": { + "description": "Runs your workflow when the status of a Git commit changes. For example, commits can be marked as `error`, `failure`, `pending`, or `success`. If you want to provide more details about the status change, you may want to use the `check_run` event.", + "null": {} + }, + "watch-string": { + "description": "Runs your workflow when the workflow's repository is starred.", + "string": { + "constant": "watch" + } + }, + "watch": { + "description": "Runs your workflow when the workflow's repository is starred.", + "one-of": [ + "null", + "watch-mapping" + ] + }, + "watch-mapping": { + "mapping": { + "properties": { + "types": "watch-activity" + } + } + }, + "watch-activity": { + "description": "The types of watch activity that trigger the workflow. Supported activity types: `started`.", + "one-of": [ + "watch-activity-type", + "watch-activity-types" + ] + }, + "watch-activity-types": { + "sequence": { + "item-type": "watch-activity-type" + } + }, + "watch-activity-type": { + "allowed-values": [ + "started" + ] + }, + "workflow-run-string": { + "description": "This event occurs when a workflow run is requested or completed. It allows you to execute a workflow based on execution or completion of another workflow. The workflow started by the `workflow_run` event is able to access secrets and write tokens, even if the previous workflow was not. This is useful in cases where the previous workflow is intentionally not privileged, but you need to take a privileged action in a later workflow.", + "string": { + "constant": "workflow_run" + } + }, + "workflow-run": { + "description": "This event occurs when a workflow run is requested or completed. It allows you to execute a workflow based on execution or completion of another workflow. The workflow started by the `workflow_run` event is able to access secrets and write tokens, even if the previous workflow was not. This is useful in cases where the previous workflow is intentionally not privileged, but you need to take a privileged action in a later workflow.", + "one-of": [ + "null", + "workflow-run-mapping" + ] + }, + "workflow-run-mapping": { + "mapping": { + "properties": { + "types": "workflow-run-activity", + "workflows": "workflow-run-workflows", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore" + } + } + }, + "workflow-run-workflows": { + "description": "The name of the workflow that triggers the `workflow_run` event. The workflow must be in the same repository as the workflow that uses the `workflow_run` event.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "workflow-run-activity": { + "description": "The types of workflow run activity that trigger the workflow. Suupported activity types: `completed`, `requested`, `in_progress`.", + "one-of": [ + "workflow-run-activity-type", + "workflow-run-activity-types" + ] + }, + "workflow-run-activity-types": { + "sequence": { + "item-type": "workflow-run-activity-type" + } + }, + "workflow-run-activity-type": { + "allowed-values": [ + "requested", + "completed", + "in_progress" + ] + }, + "event-branches": { + "description": "Use the `branches` filter when you want to include branch name patterns or when you want to both include and exclude branch name patterns. You cannot use both the `branches` and `branches-ignore` filters for the same event in a workflow.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-branches-ignore": { + "description": "Use the `branches-ignore` filter when you only want to exclude branch name patterns. You cannot use both the `branches` and `branches-ignore` filters for the same event in a workflow.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-names": { + "description": "Use the `names` filter when you want to include names via patterns or when you want to both include and exclude names using patterns. ", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-tags": { + "description": "Use the `tags` filter when you want to include tag name patterns or when you want to both include and exclude tag names patterns. You cannot use both the `tags` and `tags-ignore` filters for the same event in a workflow.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-tags-ignore": { + "description": "Use the `tags-ignore` filter when you only want to exclude tag name patterns. You cannot use both the `tags` and `tags-ignore` filters for the same event in a workflow.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-paths": { + "description": "Use the `paths` filter when you want to include file path patterns or when you want to both include and exclude file path patterns. You cannot use both the `paths` and `paths-ignore` filters for the same event in a workflow.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-paths-ignore": { + "description": "Use the `paths-ignore` filter when you only want to exclude file path patterns. You cannot use both the `paths` and `paths-ignore` filters for the same event in a workflow.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-versions": { + "description": "Use the `versions` filter when you want to include versions via patterns or when you want to both include and exclude versions using patterns. ", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "repository-dispatch-string": { + "description": "You can use the GitHub API to trigger a webhook event called `repository_dispatch` when you want to trigger a workflow for activity that happens outside of GitHub.", + "string": { + "constant": "repository_dispatch" + } + }, + "repository-dispatch": { + "description": "You can use the GitHub API to trigger a webhook event called `repository_dispatch` when you want to trigger a workflow for activity that happens outside of GitHub.", + "one-of": [ + "null", + "repository-dispatch-mapping" + ] + }, + "repository-dispatch-mapping": { + "mapping": { + "properties": { + "types": "sequence-of-non-empty-string" + } + } + }, + "workflow-call-string": { + "description": "The `workflow_call` event is used to indicate that a workflow can be called by another workflow. When a workflow is triggered with the `workflow_call` event, the event payload in the called workflow is the same event payload from the calling workflow.", + "string": { + "constant": "workflow_call" + } + }, + "workflow-call": { + "description": "The `workflow_call` event is used to indicate that a workflow can be called by another workflow. When a workflow is triggered with the `workflow_call` event, the event payload in the called workflow is the same event payload from the calling workflow.", + "one-of": [ + "null", + "workflow-call-mapping" + ] + }, + "workflow-call-mapping": { + "mapping": { + "properties": { + "inputs": "workflow-call-inputs", + "secrets": "workflow-call-secrets", + "outputs": "workflow-call-outputs" + } + } + }, + "workflow-call-inputs": { + "description": "Inputs that are passed to the called workflow from the caller workflow.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "workflow-call-input-definition" + } + }, + "workflow-call-input-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the input parameter." + }, + "type": { + "type": "workflow-call-input-type", + "required": true + }, + "required": { + "type": "boolean", + "description": "A boolean to indicate whether the action requires the input parameter. Set to `true` when the parameter is required." + }, + "default": "workflow-call-input-default" + } + } + }, + "workflow-call-input-type": { + "description": "Required if input is defined for the `on.workflow_call` keyword. The value of this parameter is a string specifying the data type of the input. This must be one of: `boolean`, `number`, or `string`.", + "one-of": [ + "input-type-string", + "input-type-boolean", + "input-type-number" + ] + }, + "input-type-string": { + "string": { + "constant": "string" + } + }, + "input-type-boolean": { + "string": { + "constant": "boolean" + } + }, + "input-type-number": { + "string": { + "constant": "number" + } + }, + "input-type-choice": { + "string": { + "constant": "choice" + } + }, + "input-type-environment": { + "string": { + "constant": "environment" + } + }, + "workflow-call-input-default": { + "description": "If a `default` parameter is not set, the default value of the input is `false` for boolean, `0` for a number, and `\"\"` for a string.", + "context": [ + "github", + "inputs", + "vars" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "workflow-call-secrets": { + "description": "A map of the secrets that can be used in the called workflow. Within the called workflow, you can use the `secrets` context to refer to a secret.", + "mapping": { + "loose-key-type": "workflow-call-secret-name", + "loose-value-type": "workflow-call-secret-definition" + } + }, + "workflow-call-secret-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the secret." + }, + "workflow-call-secret-definition": { + "one-of": [ + "null", + "workflow-call-secret-mapping-definition" + ] + }, + "workflow-call-secret-mapping-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the secret parameter." + }, + "required": { + "type": "boolean", + "description": "A boolean specifying whether the secret must be supplied." + } + } + } + }, + "workflow-call-outputs": { + "description": "A reusable workflow may generate data that you want to use in the caller workflow. To use these outputs, you must specify them as the outputs of the reusable workflow.", + "mapping": { + "loose-key-type": "workflow-call-output-name", + "loose-value-type": "workflow-call-output-definition" + } + }, + "workflow-call-output-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the output. The value of `` is a map of the input's metadata. The `` must be a unique identifier within the outputs object and must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "workflow-call-output-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the output parameter." + }, + "value": { + "type": "workflow-output-context", + "required": true + } + } + } + }, + "workflow-output-context": { + "description": "The value to assign to the output parameter.", + "context": [ + "github", + "inputs", + "vars", + "jobs" + ], + "string": {} + }, + "workflow-dispatch-string": { + "description": "The `workflow_dispatch` event allows you to manually trigger a workflow run. A workflow can be manually triggered using the GitHub API, GitHub CLI, or GitHub browser interface.", + "string": { + "constant": "workflow_dispatch" + } + }, + "workflow-dispatch": { + "description": "The `workflow_dispatch` event allows you to manually trigger a workflow run. A workflow can be manually triggered using the GitHub API, GitHub CLI, or GitHub browser interface.", + "one-of": [ + "null", + "workflow-dispatch-mapping" + ] + }, + "workflow-dispatch-mapping": { + "mapping": { + "properties": { + "inputs": "workflow-dispatch-inputs" + } + } + }, + "workflow-dispatch-inputs": { + "description": "You can configure custom-defined input properties, default input values, and required inputs for the event directly in your workflow. When you trigger the event, you can provide the `ref` and any `inputs`. When the workflow runs, you can access the input values in the `inputs` context.", + "mapping": { + "loose-key-type": "workflow-dispatch-input-name", + "loose-value-type": "workflow-dispatch-input" + } + }, + "workflow-dispatch-input-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the input. The value of is a map of the input's metadata. The must be a unique identifier within the inputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "workflow-dispatch-input": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the input parameter." + }, + "type": { + "type": "workflow-dispatch-input-type" + }, + "required": { + "type": "boolean", + "description": "A boolean to indicate whether the workflow requires the input parameter. Set to true when the parameter is required." + }, + "default": "workflow-dispatch-input-default", + "options": { + "type": "sequence-of-non-empty-string", + "description": "The options of the dropdown list, if the type is a choice." + } + } + } + }, + "workflow-dispatch-input-type": { + "description": "A string representing the type of the input. This must be one of: `boolean`, `number`, `string`, `choice`, or `environment`.", + "one-of": [ + "input-type-string", + "input-type-boolean", + "input-type-number", + "input-type-environment", + "input-type-choice" + ] + }, + "workflow-dispatch-input-default": { + "description": "The default value is used when an input parameter isn't specified in a workflow file.", + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "permissions": { + "description": "You can use `permissions` to modify the default permissions granted to the `GITHUB_TOKEN`, adding or removing access as required, so that you only allow the minimum required access.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#permissions)", + "one-of": [ + "permissions-mapping", + "permission-level-shorthand-read-all", + "permission-level-shorthand-write-all" + ] + }, + "permissions-mapping": { + "mapping": { + "properties": { + "actions": { + "type": "permission-level-any", + "description": "Actions workflows, workflow runs, and artifacts." + }, + "artifact-metadata": { + "type": "permission-level-any", + "description": "Artifact Metadata." + }, + "attestations": { + "type": "permission-level-any", + "description": "Artifact attestations." + }, + "checks": { + "type": "permission-level-any", + "description": "Check runs and check suites." + }, + "contents": { + "type": "permission-level-any", + "description": "Repository contents, commits, branches, downloads, releases, and merges." + }, + "deployments": { + "type": "permission-level-any", + "description": "Deployments and deployment statuses." + }, + "discussions": { + "type": "permission-level-any", + "description": "Discussions and related comments and labels." + }, + "id-token": { + "type": "permission-level-write-or-no-access", + "description": "Token to request an OpenID Connect token." + }, + "issues": { + "type": "permission-level-any", + "description": "Issues and related comments, assignees, labels, and milestones." + }, + "models": { + "type": "permission-level-read-or-no-access", + "description": "Call AI models with GitHub Models" + }, + "packages": { + "type": "permission-level-any", + "description": "Packages published to the GitHub Package Platform." + }, + "pages": { + "type": "permission-level-any", + "description": "Retrieve Pages statuses, configuration, and builds, as well as create new builds." + }, + "pull-requests": { + "type": "permission-level-any", + "description": "Pull requests and related comments, assignees, labels, milestones, and merges." + }, + "repository-projects": { + "type": "permission-level-any", + "description": "Classic projects within a repository." + }, + "security-events": { + "type": "permission-level-any", + "description": "Code scanning and Dependabot alerts." + }, + "statuses": { + "type": "permission-level-any", + "description": "Commit statuses." + } + } + } + }, + "permission-level-any": { + "description": "The permission level for the `GITHUB_TOKEN`.", + "one-of": [ + "permission-level-read", + "permission-level-write", + "permission-level-no-access" + ] + }, + "permission-level-read-or-no-access": { + "one-of": [ + "permission-level-read", + "permission-level-no-access" + ] + }, + "permission-level-write-or-no-access": { + "one-of": [ + "permission-level-write", + "permission-level-no-access" + ] + }, + "permission-level-read": { + "description": "The permission level for the `GITHUB_TOKEN`. Grants `read` permission for the specified scope.", + "string": { + "constant": "read" + } + }, + "permission-level-write": { + "description": "The permission level for the `GITHUB_TOKEN`. Grants `write` permission for the specified scope.", + "string": { + "constant": "write" + } + }, + "permission-level-no-access": { + "description": "The permission level for the `GITHUB_TOKEN`. Restricts all access for the specified scope.", + "string": { + "constant": "none" + } + }, + "permission-level-shorthand-read-all": { + "description": "The permission level for the `GITHUB_TOKEN`. Grants `read` access for all scopes.", + "string": { + "constant": "read-all" + } + }, + "permission-level-shorthand-write-all": { + "description": "The permission level for the `GITHUB_TOKEN`. Grants `write` access for all scopes.", + "string": { + "constant": "write-all" + } + }, + "workflow-defaults": { + "description": "Use `defaults` to create a map of default settings that will apply to all jobs in the workflow. You can also set default settings that are only available to a job.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#defaults)", + "mapping": { + "properties": { + "run": "workflow-defaults-run" + } + } + }, + "workflow-defaults-run": { + "mapping": { + "properties": { + "shell": "shell", + "working-directory": "working-directory" + } + } + }, + "workflow-env": { + "description": "A map of environment variables that are available to the steps of all jobs in the workflow. You can also set environment variables that are only available to the steps of a single job or to a single step.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#env)", + "context": [ + "github", + "inputs", + "vars", + "secrets" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "jobs": { + "description": "A workflow run is made up of one or more `jobs`, which run in parallel by default. To run jobs sequentially, you can define dependencies on other jobs using the `jobs..needs` keyword. Each job runs in a runner environment specified by `runs-on`.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobs)", + "mapping": { + "loose-key-type": "job-id", + "loose-value-type": "job" + } + }, + "job-id": { + "string": { + "require-non-empty": true + }, + "description": "A unique identifier for the job. The identifier must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "job": { + "description": "Each job must have an id to associate with the job. The key `job_id` is a string and its value is a map of the job's configuration data. You must replace `` with a string that is unique to the jobs object. The `` must start with a letter or _ and contain only alphanumeric characters, -, or _.", + "one-of": [ + "job-factory", + "workflow-job" + ] + }, + "job-factory": { + "mapping": { + "properties": { + "needs": "needs", + "if": "job-if", + "strategy": "strategy", + "name": { + "type": "string-strategy-context", + "description": "The name of the job displayed on GitHub." + }, + "runs-on": { + "type": "runs-on", + "required": true + }, + "timeout-minutes": { + "type": "number-strategy-context", + "description": "The maximum number of minutes to let a workflow run before GitHub automatically cancels it. Default: 360" + }, + "cancel-timeout-minutes": "number-strategy-context", + "continue-on-error": { + "type": "boolean-strategy-context", + "description": "Prevents a workflow run from failing when a job fails. Set to true to allow a workflow run to pass when this job fails." + }, + "container": "container", + "services": "services", + "env": "job-env", + "environment": "job-environment", + "permissions": "permissions", + "concurrency": "job-concurrency", + "outputs": "job-outputs", + "defaults": "job-defaults", + "steps": "steps", + "snapshot": "snapshot" + } + } + }, + "workflow-job": { + "mapping": { + "properties": { + "name": { + "type": "string-strategy-context", + "description": "The name of the job displayed on GitHub." + }, + "uses": { + "description": "The location and version of a reusable workflow file to run as a job. Use one of the following formats:\n\n* `{owner}/{repo}/.github/workflows/{filename}@{ref}` for reusable workflows in public and private repositories.\n* `./.github/workflows/{filename}` for reusable workflows in the same repository.\n\n{ref} can be a SHA, a release tag, or a branch name. Using the commit SHA is the safest for stability and security.", + "type": "non-empty-string", + "required": true + }, + "with": "workflow-job-with", + "secrets": "workflow-job-secrets", + "needs": "needs", + "if": "job-if", + "permissions": "permissions", + "concurrency": "job-concurrency", + "strategy": "strategy" + } + } + }, + "workflow-job-with": { + "description": "When a job is used to call a reusable workflow, you can use `with` to provide a map of inputs that are passed to the called workflow.\n\nAny inputs that you pass must match the input specifications defined in the called workflow.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "scalar-needs-context" + } + }, + "workflow-job-secrets": { + "description": "When a job is used to call a reusable workflow, you can use `secrets` to provide a map of secrets that are passed to the called workflow.\n\nAny secrets that you pass must match the names defined in the called workflow.", + "one-of": [ + "workflow-job-secrets-mapping", + "workflow-job-secrets-inherit" + ] + }, + "workflow-job-secrets-mapping": { + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "scalar-needs-context-with-secrets" + } + }, + "workflow-job-secrets-inherit": { + "string": { + "constant": "inherit" + } + }, + "needs": { + "description": "Use `needs` to identify any jobs that must complete successfully before this job will run. It can be a string or array of strings. If a job fails, all jobs that need it are skipped unless the jobs use a conditional expression that causes the job to continue. If a run contains a series of jobs that need each other, a failure applies to all jobs in the dependency chain from the point of failure onwards.", + "one-of": [ + "sequence-of-non-empty-string", + "non-empty-string" + ] + }, + "job-if": { + "description": "You can use the `if` conditional to prevent a job from running unless a condition is met. You can use any supported context and expression to create a conditional.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "always(0,0)", + "failure(0,MAX)", + "cancelled(0,0)", + "success(0,MAX)" + ], + "string": { + "is-expression": true + } + }, + "job-if-result": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "always(0,0)", + "failure(0,MAX)", + "cancelled(0,0)", + "success(0,MAX)" + ], + "one-of": [ + "null", + "boolean", + "number", + "string", + "sequence", + "mapping" + ] + }, + "strategy": { + "description": "Use `strategy` to use a matrix strategy for your jobs. A matrix strategy lets you use variables in a single job definition to automatically create multiple job runs that are based on the combinations of the variables. ", + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "mapping": { + "properties": { + "fail-fast": { + "type": "boolean", + "description": "Setting `fail-fast` to `false` prevents GitHub from canceling all in-progress jobs if any matrix job fails. Default: `true`" + }, + "max-parallel": { + "type": "number", + "description": "The maximum number of jobs that can run simultaneously when using a matrix job strategy. By default, GitHub will maximize the number of jobs run in parallel depending on runner availability." + }, + "matrix": "matrix" + } + } + }, + "matrix": { + "description": "Use `matrix` to define a matrix of different job configurations. Within your matrix, define one or more variables followed by an array of values.", + "mapping": { + "properties": { + "include": { + "type": "matrix-filter", + "description": "Use `include` to expand existing matrix configurations or to add new configurations. The value of `include` is a list of objects.\n\nFor each object in the `include` list, the key:value pairs in the object will be added to each of the matrix combinations if none of the key:value pairs overwrite any of the original matrix values. If the object cannot be added to any of the matrix combinations, a new matrix combination will be created instead. Note that the original matrix values will not be overwritten, but added matrix values can be overwritten." + }, + "exclude": { + "type": "matrix-filter", + "description": "To remove specific configurations defined in the matrix, use `exclude`. An excluded configuration only has to be a partial match for it to be excluded." + } + }, + "loose-key-type": "non-empty-string", + "loose-value-type": "sequence" + } + }, + "matrix-filter": { + "sequence": { + "item-type": "matrix-filter-item" + } + }, + "matrix-filter-item": { + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "any" + } + }, + "snapshot": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "description": "Use `snapshot` to define a custom image you want to create or update after your job succeeds by taking a snapshot of your runner.", + "one-of": [ + "snapshot-image-name", + "snapshot-mapping" + ] + }, + "snapshot-mapping": { + "mapping": { + "properties": { + "image-name": { + "type": "snapshot-image-name", + "required": true + }, + "if": "snapshot-if", + "version": { + "description": "The desired major version updates upon a new custom image version creation.", + "type": "non-empty-string" + } + } + } + }, + "snapshot-image-name": { + "description": "The desired name of the custom image you want to create or update.", + "string": { + "require-non-empty": true + } + }, + "snapshot-if": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "description": "Use the if conditional to prevent a snapshot from being taken unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.", + "string": { + "is-expression": true + } + }, + "runs-on": { + "description": "Use `runs-on` to define the type of machine to run the job on.\n* The destination machine can be either a GitHub-hosted runner, larger runner, or a self-hosted runner.\n* You can target runners based on the labels assigned to them, or their group membership, or a combination of these.\n* You can provide `runs-on` as a single string or as an array of strings.\n* If you specify an array of strings, your workflow will execute on any runner that matches all of the specified `runs-on` values.\n* If you would like to run your workflow on multiple machines, use `jobs..strategy`.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string", + "runs-on-mapping" + ] + }, + "runs-on-mapping": { + "mapping": { + "properties": { + "group": { + "description": "The group from which to select a runner.", + "type": "non-empty-string" + }, + "labels": "runs-on-labels" + } + } + }, + "runs-on-labels": { + "description": "The label by which to filter for available runners.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "job-env": { + "description": "A map of variables that are available to all steps in the job.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "workflow-concurrency": { + "description": "Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression.\n\nYou can also specify `concurrency` at the job level.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#concurrency)", + "context": [ + "github", + "inputs", + "vars" + ], + "one-of": [ + "string", + "concurrency-mapping" + ] + }, + "job-concurrency": { + "description": "Concurrency ensures that only a single job using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the `secrets` context.\n\nYou can also specify `concurrency` at the workflow level.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "concurrency-mapping" + ] + }, + "concurrency-mapping": { + "description": "Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression.\n\nYou can also specify `concurrency` at the job level.\n\n[Documentation](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#concurrency)", + "mapping": { + "properties": { + "group": { + "type": "non-empty-string", + "required": true, + "description": "When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be `pending`. Any previously pending job or workflow in the concurrency group will be canceled. To also cancel any currently running job or workflow in the same concurrency group, specify `cancel-in-progress: true`." + }, + "cancel-in-progress": { + "type": "boolean", + "description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true." + } + } + } + }, + "job-environment": { + "description": "The environment that the job references. All environment protection rules must pass before a job referencing the environment is sent to a runner.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "job-environment-mapping" + ] + }, + "job-environment-mapping": { + "mapping": { + "properties": { + "name": { + "type": "job-environment-name", + "required": true + }, + "url": { + "type": "string-runner-context-no-secrets", + "description": "The environment URL, which maps to `environment_url` in the deployments API." + } + } + } + }, + "job-environment-name": { + "description": "The name of the environment used by the job.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "string": {} + }, + "job-defaults": { + "description": "A map of default settings that will apply to all steps in the job. You can also set default settings for the entire workflow.", + "mapping": { + "properties": { + "run": "job-defaults-run" + } + } + }, + "job-defaults-run": { + "context": [ + "github", + "inputs", + "vars", + "strategy", + "matrix", + "needs", + "env" + ], + "mapping": { + "properties": { + "shell": "shell", + "working-directory": "working-directory" + } + } + }, + "job-outputs": { + "description": "A map of outputs for a called workflow. Called workflow outputs are available to all downstream jobs in the caller workflow. Each output has an identifier, an optional `description,` and a `value`. The `value` must be set to the value of an output from a job within the called workflow.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string-runner-context" + } + }, + "steps": { + "description": "A job contains a sequence of tasks called `steps`. Steps can run commands, run setup tasks, or run an action in your repository, a public repository, or an action published in a Docker registry. Not all steps run actions, but all actions run as a step. Each step runs in its own process in the runner environment and has access to the workspace and filesystem. Because steps run in their own process, changes to environment variables are not preserved between steps. GitHub provides built-in steps to set up and complete a job. Must contain either `uses` or `run`.", + "sequence": { + "item-type": "steps-item" + } + }, + "steps-item": { + "one-of": [ + "run-step", + "regular-step" + ] + }, + "run-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "if": "step-if", + "timeout-minutes": "step-timeout-minutes", + "run": { + "type": "string-steps-context", + "description": "Runs command-line programs using the operating system's shell. If you do not provide a `name`, the step name will default to the text specified in the `run` command. Commands run using non-login shells by default. You can choose a different shell and customize the shell used to run commands. Each `run` keyword represents a new process and shell in the virtual environment. When you provide multi-line commands, each line runs in the same shell.", + "required": true + }, + "continue-on-error": "step-continue-on-error", + "env": "step-env", + "working-directory": "string-steps-context", + "shell": "shell" + } + } + }, + "regular-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "if": "step-if", + "continue-on-error": "step-continue-on-error", + "timeout-minutes": "step-timeout-minutes", + "uses": { + "type": "step-uses", + "required": true + }, + "with": "step-with", + "env": "step-env" + } + } + }, + "step-uses": { + "description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image.", + "string": { + "require-non-empty": true + } + }, + "step-continue-on-error": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "boolean": {}, + "description": "Prevents a job from failing when a step fails. Set to `true` to allow a job to pass when this step fails." + }, + "step-id": { + "string": { + "require-non-empty": true + }, + "description": "A unique identifier for the step. You can use the `id` to reference the step in contexts." + }, + "step-if": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env", + "always(0,0)", + "failure(0,0)", + "cancelled(0,0)", + "success(0,0)", + "hashFiles(1,255)" + ], + "description": "Use the `if` conditional to prevent a step from running unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.", + "string": { + "is-expression": true + } + }, + "step-if-result": { + "context": [ + "github", + "inputs", + "vars", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env", + "always(0,0)", + "failure(0,0)", + "cancelled(0,0)", + "success(0,0)", + "hashFiles(1,255)" + ], + "one-of": [ + "null", + "boolean", + "number", + "string", + "sequence", + "mapping" + ] + }, + "step-env": { + "description": "Sets variables for steps to use in the runner environment. You can also set variables for the entire workflow or a job.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "step-name": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "string": {}, + "description": "A name for your step to display on GitHub." + }, + "step-timeout-minutes": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "number": {}, + "description": "The maximum number of minutes to run the step before killing the process." + }, + "step-with": { + "description": "A map of the input parameters defined by the action. Each input parameter is a key/value pair. Input parameters are set as variables. When you specify an input in a workflow file or use a default input value, GitHub creates a variable for the input with the name `INPUT_`. The variable created converts input names to uppercase letters and replaces spaces with `_`.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "container": { + "description": "A container to run any steps in a job that don't already specify a container. If you have steps that use both script and container actions, the container actions will run as sibling containers on the same network with the same volume mounts.\n\nIf you do not set a container, all steps will run directly on the host specified by runs-on unless a step refers to an action configured to run in a container.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "container-mapping" + ] + }, + "container-mapping": { + "mapping": { + "properties": { + "image": { + "type": "non-empty-string", + "description": "Use `jobs..container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name." + }, + "options": { + "type": "non-empty-string", + "description": "Use `jobs..container.options` to configure additional Docker container resource options." + }, + "env": "container-env", + "ports": { + "type": "sequence-of-non-empty-string", + "description": "Use `jobs..container.ports` to set an array of ports to expose on the container." + }, + "volumes": { + "type": "sequence-of-non-empty-string", + "description": "Use `jobs..container.volumes` to set an array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host." + }, + "credentials": "container-registry-credentials" + } + } + }, + "services": { + "description": "Additional containers to host services for a job in a workflow. These are useful for creating databases or cache services like redis. The runner on the virtual machine will automatically create a network and manage the life cycle of the service containers. When you use a service container for a job or your step uses container actions, you don't need to set port information to access the service. Docker automatically exposes all ports between containers on the same network. When both the job and the action run in a container, you can directly reference the container by its hostname. The hostname is automatically mapped to the service name. When a step does not use a container action, you must access the service using localhost and bind the ports.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "services-container" + } + }, + "services-container": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "container-mapping" + ] + }, + "container-registry-credentials": { + "description": "If the image's container registry requires authentication to pull the image, you can use `jobs..container.credentials` to set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.", + "context": [ + "github", + "inputs", + "vars", + "secrets", + "env" + ], + "mapping": { + "properties": { + "username": "non-empty-string", + "password": "non-empty-string" + } + } + }, + "container-env": { + "description": "Use `jobs..container.env` to set a map of variables in the container.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string-runner-context" + } + }, + "non-empty-string": { + "string": { + "require-non-empty": true + } + }, + "sequence-of-non-empty-string": { + "sequence": { + "item-type": "non-empty-string" + } + }, + "boolean-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "boolean": {} + }, + "number-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "number": {} + }, + "string-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "string": {} + }, + "scalar-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "scalar-needs-context-with-secrets": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "secrets", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "boolean-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "boolean": {} + }, + "number-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "number": {} + }, + "string-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "string": {} + }, + "boolean-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "boolean": {} + }, + "number-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "number": {} + }, + "string-runner-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env" + ], + "string": {} + }, + "string-runner-context-no-secrets": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env" + ], + "string": {} + }, + "string-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "string": {} + }, + "shell": { + "string": { + "require-non-empty": true + }, + "description": "Use `shell` to override the default shell settings in the runner's operating system. You can use built-in shell keywords, or you can define a custom set of shell options. The shell command that is run internally executes a temporary file that contains the commands specified in `run`." + }, + "working-directory": { + "string": { + "require-non-empty": true + }, + "description": "The `working-directory` keyword specifies the working directory where the command is run." + }, + "cron-mapping": { + "mapping": { + "properties": { + "cron": "cron-pattern" + } + } + }, + "cron-pattern": { + "string": { + "require-non-empty": true + } + } + } +}