From e05ff57c29a5d460ec6e130c738c625004a613be Mon Sep 17 00:00:00 2001 From: Bernie White Date: Wed, 4 Sep 2024 09:18:53 +1000 Subject: [PATCH] Context refactor 2 (#2525) --- src/PSRule/Configuration/BindingOption.cs | 5 +- src/PSRule/Configuration/IBindingOption.cs | 61 +++ .../Pipeline/GetTargetPipelineBuilder.cs | 38 +- src/PSRule/Pipeline/IPipelineBuilder.cs | 29 ++ src/PSRule/Pipeline/IPipelineReader.cs | 2 + .../Pipeline/InvokePipelineBuilderBase.cs | 38 +- src/PSRule/Pipeline/PipelineBuilder.cs | 458 ------------------ src/PSRule/Pipeline/PipelineBuilderBase.cs | 440 +++++++++++++++++ src/PSRule/Pipeline/PipelineInputStream.cs | 22 +- .../Pipeline/PipelineWriterExtensions.cs | 8 + src/PSRule/Runtime/RunspaceContext.cs | 6 +- tests/PSRule.Tests/PipelineTests.cs | 4 +- 12 files changed, 558 insertions(+), 553 deletions(-) create mode 100644 src/PSRule/Pipeline/IPipelineBuilder.cs create mode 100644 src/PSRule/Pipeline/PipelineBuilderBase.cs diff --git a/src/PSRule/Configuration/BindingOption.cs b/src/PSRule/Configuration/BindingOption.cs index 84fb7a72c6..fb8e0fad8c 100644 --- a/src/PSRule/Configuration/BindingOption.cs +++ b/src/PSRule/Configuration/BindingOption.cs @@ -7,8 +7,11 @@ namespace PSRule.Configuration; /// -/// Options that affect property binding of TargetName and TargetType. +/// Options that configure property binding. /// +/// +/// See . +/// public sealed class BindingOption : IEquatable, IBindingOption { private const bool DEFAULT_IGNORECASE = true; diff --git a/src/PSRule/Configuration/IBindingOption.cs b/src/PSRule/Configuration/IBindingOption.cs index 4591b27ae1..5c7c58ab3c 100644 --- a/src/PSRule/Configuration/IBindingOption.cs +++ b/src/PSRule/Configuration/IBindingOption.cs @@ -5,6 +5,67 @@ namespace PSRule.Configuration; +/// +/// Options that configure property binding. +/// +/// +/// See . +/// public interface IBindingOption : IOption { + /// + /// One or more custom fields to bind. + /// + /// + /// See . + /// + FieldMap Field { get; } + + /// + /// Determines if custom binding uses ignores case when matching properties. + /// + /// + /// See . + /// + bool? IgnoreCase { get; } + + /// + /// Configures the separator to use for building a qualified name. + /// + /// + /// See . + /// + string NameSeparator { get; } + + /// + /// Determines if binding prefers target info provided by the object over custom configuration. + /// + /// + /// See . + /// + bool? PreferTargetInfo { get; } + + /// + /// Property names to use to bind TargetName. + /// + /// + /// See . + /// + string[] TargetName { get; } + + /// + /// Property names to use to bind TargetType. + /// + /// + /// See . + /// + string[] TargetType { get; } + + /// + /// Determines if a qualified TargetName is used. + /// + /// + /// See . + /// + bool? UseQualifiedName { get; } } diff --git a/src/PSRule/Pipeline/GetTargetPipelineBuilder.cs b/src/PSRule/Pipeline/GetTargetPipelineBuilder.cs index 9b2d93bbd6..382f1df9c5 100644 --- a/src/PSRule/Pipeline/GetTargetPipelineBuilder.cs +++ b/src/PSRule/Pipeline/GetTargetPipelineBuilder.cs @@ -63,42 +63,6 @@ public override IPipeline Build(IPipelineWriter writer = null) /// protected override PipelineInputStream PrepareReader() { - if (!string.IsNullOrEmpty(Option.Input.ObjectPath)) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ReadObjectPath(sourceObject, next, Option.Input.ObjectPath, true); - }); - } - - if (Option.Input.Format == InputFormat.Yaml) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ConvertFromYaml(sourceObject, next); - }); - } - else if (Option.Input.Format == InputFormat.Json) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ConvertFromJson(sourceObject, next); - }); - } - else if (Option.Input.Format == InputFormat.Markdown) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ConvertFromMarkdown(sourceObject, next); - }); - } - else if (Option.Input.Format == InputFormat.PowerShellData) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ConvertFromPowerShellData(sourceObject, next); - }); - } - return new PipelineInputStream(VisitTargetObject, _InputPath, GetInputObjectSourceFilter(), Option); + return new PipelineInputStream(_InputPath, GetInputObjectSourceFilter(), Option); } } diff --git a/src/PSRule/Pipeline/IPipelineBuilder.cs b/src/PSRule/Pipeline/IPipelineBuilder.cs new file mode 100644 index 0000000000..4c9612bd8b --- /dev/null +++ b/src/PSRule/Pipeline/IPipelineBuilder.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using PSRule.Configuration; + +namespace PSRule.Pipeline; + +/// +/// A helper to build a PSRule pipeline. +/// +public interface IPipelineBuilder +{ + /// + /// Configure the pipeline with options. + /// + IPipelineBuilder Configure(PSRuleOption option); + + /// + /// Configure the pipeline to use a specific baseline. + /// + /// A baseline option or the name of a baseline. + void Baseline(BaselineOption baseline); + + /// + /// Build the pipeline. + /// + /// Optionally specify a custom writer which will handle output processing. + IPipeline Build(IPipelineWriter writer = null); +} diff --git a/src/PSRule/Pipeline/IPipelineReader.cs b/src/PSRule/Pipeline/IPipelineReader.cs index 6f367c1ab3..3d6cb63fd1 100644 --- a/src/PSRule/Pipeline/IPipelineReader.cs +++ b/src/PSRule/Pipeline/IPipelineReader.cs @@ -5,6 +5,8 @@ namespace PSRule.Pipeline; +#nullable enable + internal interface IPipelineReader { int Count { get; } diff --git a/src/PSRule/Pipeline/InvokePipelineBuilderBase.cs b/src/PSRule/Pipeline/InvokePipelineBuilderBase.cs index fc35064dcb..9d72d60609 100644 --- a/src/PSRule/Pipeline/InvokePipelineBuilderBase.cs +++ b/src/PSRule/Pipeline/InvokePipelineBuilderBase.cs @@ -131,42 +131,6 @@ private static bool IsBlocked(string path) protected override PipelineInputStream PrepareReader() { - if (!string.IsNullOrEmpty(Option.Input.ObjectPath)) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ReadObjectPath(sourceObject, next, Option.Input.ObjectPath, true); - }); - } - - if (Option.Input.Format == InputFormat.Yaml) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ConvertFromYaml(sourceObject, next); - }); - } - else if (Option.Input.Format == InputFormat.Json) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ConvertFromJson(sourceObject, next); - }); - } - else if (Option.Input.Format == InputFormat.Markdown) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ConvertFromMarkdown(sourceObject, next); - }); - } - else if (Option.Input.Format == InputFormat.PowerShellData) - { - AddVisitTargetObjectAction((sourceObject, next) => - { - return PipelineReceiverActions.ConvertFromPowerShellData(sourceObject, next); - }); - } - return new PipelineInputStream(VisitTargetObject, _InputPath, GetInputObjectSourceFilter(), Option); + return new PipelineInputStream(_InputPath, GetInputObjectSourceFilter(), Option); } } diff --git a/src/PSRule/Pipeline/PipelineBuilder.cs b/src/PSRule/Pipeline/PipelineBuilder.cs index 73d31c5aa9..9de9fae046 100644 --- a/src/PSRule/Pipeline/PipelineBuilder.cs +++ b/src/PSRule/Pipeline/PipelineBuilder.cs @@ -1,15 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections; -using System.Globalization; using PSRule.Configuration; -using PSRule.Data; -using PSRule.Definitions; -using PSRule.Definitions.Baselines; -using PSRule.Options; -using PSRule.Pipeline.Output; -using PSRule.Resources; namespace PSRule.Pipeline; @@ -164,453 +156,3 @@ public static IGetTargetPipelineBuilder GetTarget(PSRuleOption option, IHostCont return pipeline; } } - -/// -/// A helper to build a PSRule pipeline. -/// -public interface IPipelineBuilder -{ - /// - /// Configure the pipeline with options. - /// - IPipelineBuilder Configure(PSRuleOption option); - - /// - /// Configure the pipeline to use a specific baseline. - /// - /// A baseline option or the name of a baseline. - void Baseline(Configuration.BaselineOption baseline); - - /// - /// Build the pipeline. - /// - /// Optionally specify a custom writer which will handle output processing. - IPipeline Build(IPipelineWriter writer = null); -} - -internal abstract class PipelineBuilderBase : IPipelineBuilder -{ - private const string ENGINE_MODULE_NAME = "PSRule"; - - protected readonly PSRuleOption Option; - protected readonly Source[] Source; - protected readonly IHostContext HostContext; - protected BindTargetMethod BindTargetNameHook; - protected BindTargetMethod BindTargetTypeHook; - protected BindTargetMethod BindFieldHook; - protected VisitTargetObject VisitTargetObject; - - private string[] _Include; - private Hashtable _Tag; - private Configuration.BaselineOption _Baseline; - private string[] _Convention; - private PathFilter _InputFilter; - private PipelineWriter _Writer; - - private readonly HostPipelineWriter _Output; - - private const int MIN_JSON_INDENT = 0; - private const int MAX_JSON_INDENT = 4; - - protected PipelineBuilderBase(Source[] source, IHostContext hostContext) - { - Option = new PSRuleOption(); - Source = source; - _Output = new HostPipelineWriter(hostContext, Option, ShouldProcess); - HostContext = hostContext; - BindTargetNameHook = PipelineHookActions.BindTargetName; - BindTargetTypeHook = PipelineHookActions.BindTargetType; - BindFieldHook = PipelineHookActions.BindField; - VisitTargetObject = PipelineReceiverActions.PassThru; - } - - /// - /// Determines if the pipeline is executing in a remote PowerShell session. - /// - public bool InSession => HostContext != null && HostContext.InSession; - - /// - public void Name(string[] name) - { - if (name == null || name.Length == 0) - return; - - _Include = name; - } - - /// - public void Tag(Hashtable tag) - { - if (tag == null || tag.Count == 0) - return; - - _Tag = tag; - } - - /// - public void Convention(string[] convention) - { - if (convention == null || convention.Length == 0) - return; - - _Convention = convention; - } - - /// - public virtual IPipelineBuilder Configure(PSRuleOption option) - { - if (option == null) - return this; - - Option.Baseline = new Options.BaselineOption(option.Baseline); - Option.Binding = new BindingOption(option.Binding); - Option.Convention = new ConventionOption(option.Convention); - Option.Execution = GetExecutionOption(option.Execution); - Option.Input = new InputOption(option.Input); - Option.Input.Format ??= InputOption.Default.Format; - Option.Output = new OutputOption(option.Output); - Option.Output.Outcome ??= OutputOption.Default.Outcome; - Option.Output.Banner ??= OutputOption.Default.Banner; - Option.Output.Style = GetStyle(option.Output.Style ?? OutputOption.Default.Style.Value); - Option.Repository = GetRepository(option.Repository); - return this; - } - - /// - public abstract IPipeline Build(IPipelineWriter writer = null); - - /// - public void Baseline(Configuration.BaselineOption baseline) - { - if (baseline == null) - return; - - _Baseline = baseline; - } - - /// - /// Require correct module versions for pipeline execution. - /// - protected bool RequireModules() - { - var result = true; - if (Option.Requires.TryGetValue(ENGINE_MODULE_NAME, out var requiredVersion)) - { - var engineVersion = Engine.GetVersion(); - if (GuardModuleVersion(ENGINE_MODULE_NAME, engineVersion, requiredVersion)) - result = false; - } - for (var i = 0; Source != null && i < Source.Length; i++) - { - if (Source[i].Module != null && Option.Requires.TryGetValue(Source[i].Module.Name, out requiredVersion)) - { - if (GuardModuleVersion(Source[i].Module.Name, Source[i].Module.Version, requiredVersion)) - result = false; - } - } - return result; - } - - /// - /// Require sources for pipeline execution. - /// - protected bool RequireSources() - { - if (Source == null || Source.Length == 0) - { - PrepareWriter().WarnRulePathNotFound(); - return false; - } - return true; - } - - private bool GuardModuleVersion(string moduleName, string moduleVersion, string requiredVersion) - { - if (!TryModuleVersion(moduleVersion, requiredVersion)) - { - var writer = PrepareWriter(); - writer.ErrorRequiredVersionMismatch(moduleName, moduleVersion, requiredVersion); - writer.End(new DefaultPipelineResult(null, BreakLevel.None) { HadErrors = true }); - return true; - } - return false; - } - - private static bool TryModuleVersion(string moduleVersion, string requiredVersion) - { - return SemanticVersion.TryParseVersion(moduleVersion, out var version) && - SemanticVersion.TryParseConstraint(requiredVersion, out var constraint) && - constraint.Equals(version); - } - - protected PipelineContext PrepareContext(BindTargetMethod bindTargetName, BindTargetMethod bindTargetType, BindTargetMethod bindField) - { - var unresolved = new List(); - if (_Baseline is Configuration.BaselineOption.BaselineRef baselineRef) - unresolved.Add(new BaselineRef(ResolveBaselineGroup(baselineRef.Name), ScopeType.Explicit)); - - return PipelineContext.New( - option: Option, - hostContext: HostContext, - reader: PrepareReader(), - bindTargetName: bindTargetName, - bindTargetType: bindTargetType, - bindField: bindField, - optionBuilder: GetOptionBuilder(), - unresolved: unresolved - ); - } - - protected string[] ResolveBaselineGroup(string[] name) - { - for (var i = 0; name != null && i < name.Length; i++) - name[i] = ResolveBaselineGroup(name[i]); - - return name; - } - - protected string ResolveBaselineGroup(string name) - { - if (name == null || name.Length < 2 || !name.StartsWith("@") || - Option == null || Option.Baseline == null || Option.Baseline.Group == null || - Option.Baseline.Group.Count == 0) - return name; - - var key = name.Substring(1); - if (!Option.Baseline.Group.TryGetValue(key, out var baselines) || baselines.Length == 0) - throw new PipelineConfigurationException("Baseline.Group", string.Format(Thread.CurrentThread.CurrentCulture, PSRuleResources.PSR0003, key)); - - var writer = PrepareWriter(); - writer.WriteVerbose($"Using baseline group '{key}': {baselines[0]}"); - return baselines[0]; - } - - protected virtual PipelineInputStream PrepareReader() - { - return new PipelineInputStream(null, null, GetInputObjectSourceFilter(), Option); - } - - protected virtual PipelineWriter PrepareWriter() - { - if (_Writer != null) - return _Writer; - - var output = GetOutput(); - _Writer = Option.Output.Format switch - { - OutputFormat.Csv => new CsvOutputWriter(output, Option, ShouldProcess), - OutputFormat.Json => new JsonOutputWriter(output, Option, ShouldProcess), - OutputFormat.NUnit3 => new NUnit3OutputWriter(output, Option, ShouldProcess), - OutputFormat.Yaml => new YamlOutputWriter(output, Option, ShouldProcess), - OutputFormat.Markdown => new MarkdownOutputWriter(output, Option, ShouldProcess), - OutputFormat.Wide => new WideOutputWriter(output, Option, ShouldProcess), - OutputFormat.Sarif => new SarifOutputWriter(Source, output, Option, ShouldProcess), - _ => output, - }; - return _Writer; - } - - protected virtual PipelineWriter GetOutput(bool writeHost = false) - { - // Redirect to file instead - return !string.IsNullOrEmpty(Option.Output.Path) - ? new FileOutputWriter( - inner: _Output, - option: Option, - encoding: Option.Output.GetEncoding(), - path: Option.Output.Path, - shouldProcess: HostContext.ShouldProcess, - writeHost: writeHost - ) - : _Output; - } - - protected static string[] GetCulture(string[] culture) - { - var result = new List(); - var parent = new List(); - var set = new HashSet(); - for (var i = 0; culture != null && i < culture.Length; i++) - { - var c = CultureInfo.CreateSpecificCulture(culture[i]); - if (!set.Contains(c.Name)) - { - result.Add(c.Name); - set.Add(c.Name); - } - for (var p = c.Parent; !string.IsNullOrEmpty(p.Name); p = p.Parent) - { - if (!set.Contains(p.Name)) - { - parent.Add(p.Name); - set.Add(p.Name); - } - } - } - if (parent.Count > 0) - result.AddRange(parent); - - return result.Count == 0 ? null : result.ToArray(); - } - - protected static RepositoryOption GetRepository(RepositoryOption option) - { - var result = new RepositoryOption(option); - if (string.IsNullOrEmpty(result.Url) && GitHelper.TryRepository(out var url)) - result.Url = url; - - if (string.IsNullOrEmpty(result.BaseRef) && GitHelper.TryBaseRef(out var baseRef)) - result.BaseRef = baseRef; - - return result; - } - - /// - /// Coalesce execution options with defaults. - /// - protected static ExecutionOption GetExecutionOption(ExecutionOption option) - { - var result = ExecutionOption.Combine(option, ExecutionOption.Default); - - // Handle when preference is set to none. The default should be used. - result.AliasReference = result.AliasReference == ExecutionActionPreference.None ? ExecutionOption.Default.AliasReference.Value : result.AliasReference; - result.DuplicateResourceId = result.DuplicateResourceId == ExecutionActionPreference.None ? ExecutionOption.Default.DuplicateResourceId.Value : result.DuplicateResourceId; - result.InvariantCulture = result.InvariantCulture == ExecutionActionPreference.None ? ExecutionOption.Default.InvariantCulture.Value : result.InvariantCulture; - result.RuleExcluded = result.RuleExcluded == ExecutionActionPreference.None ? ExecutionOption.Default.RuleExcluded.Value : result.RuleExcluded; - result.RuleInconclusive = result.RuleInconclusive == ExecutionActionPreference.None ? ExecutionOption.Default.RuleInconclusive.Value : result.RuleInconclusive; - result.RuleSuppressed = result.RuleSuppressed == ExecutionActionPreference.None ? ExecutionOption.Default.RuleSuppressed.Value : result.RuleSuppressed; - result.SuppressionGroupExpired = result.SuppressionGroupExpired == ExecutionActionPreference.None ? ExecutionOption.Default.SuppressionGroupExpired.Value : result.SuppressionGroupExpired; - result.UnprocessedObject = result.UnprocessedObject == ExecutionActionPreference.None ? ExecutionOption.Default.UnprocessedObject.Value : result.UnprocessedObject; - return result; - } - - protected PathFilter GetInputObjectSourceFilter() - { - return Option.Input.IgnoreObjectSource.GetValueOrDefault(InputOption.Default.IgnoreObjectSource.Value) ? GetInputFilter() : null; - } - - protected PathFilter GetInputFilter() - { - if (_InputFilter == null) - { - var basePath = Environment.GetWorkingPath(); - var ignoreGitPath = Option.Input.IgnoreGitPath ?? InputOption.Default.IgnoreGitPath.Value; - var ignoreRepositoryCommon = Option.Input.IgnoreRepositoryCommon ?? InputOption.Default.IgnoreRepositoryCommon.Value; - var builder = PathFilterBuilder.Create(basePath, Option.Input.PathIgnore, ignoreGitPath, ignoreRepositoryCommon); - builder.UseGitIgnore(); - - _InputFilter = builder.Build(); - } - return _InputFilter; - } - - private OptionContextBuilder GetOptionBuilder() - { - return new OptionContextBuilder(Option, _Include, _Tag, _Convention); - } - - protected void ConfigureBinding(PSRuleOption option) - { - if (option.Pipeline.BindTargetName != null && option.Pipeline.BindTargetName.Count > 0) - { - // Do not allow custom binding functions to be used with constrained language mode - if (Option.Execution.LanguageMode == LanguageMode.ConstrainedLanguage) - throw new PipelineConfigurationException(optionName: "BindTargetName", message: PSRuleResources.ConstrainedTargetBinding); - - foreach (var action in option.Pipeline.BindTargetName) - BindTargetNameHook = AddBindTargetAction(action, BindTargetNameHook); - } - - if (option.Pipeline.BindTargetType != null && option.Pipeline.BindTargetType.Count > 0) - { - // Do not allow custom binding functions to be used with constrained language mode - if (Option.Execution.LanguageMode == LanguageMode.ConstrainedLanguage) - throw new PipelineConfigurationException(optionName: "BindTargetType", message: PSRuleResources.ConstrainedTargetBinding); - - foreach (var action in option.Pipeline.BindTargetType) - BindTargetTypeHook = AddBindTargetAction(action, BindTargetTypeHook); - } - } - - private static BindTargetMethod AddBindTargetAction(BindTargetFunc action, BindTargetMethod previous) - { - // Nest the previous write action in the new supplied action - // Execution chain will be: action -> previous -> previous..n - return (string[] propertyNames, bool caseSensitive, bool preferTargetInfo, object targetObject, out string path) => - { - return action(propertyNames, caseSensitive, preferTargetInfo, targetObject, previous, out path); - }; - } - - private static BindTargetMethod AddBindTargetAction(BindTargetName action, BindTargetMethod previous) - { - return AddBindTargetAction((string[] propertyNames, bool caseSensitive, bool preferTargetInfo, object targetObject, BindTargetMethod next, out string path) => - { - path = null; - var targetType = action(targetObject); - return string.IsNullOrEmpty(targetType) ? next(propertyNames, caseSensitive, preferTargetInfo, targetObject, out path) : targetType; - }, previous); - } - - protected void AddVisitTargetObjectAction(VisitTargetObjectAction action) - { - // Nest the previous write action in the new supplied action - // Execution chain will be: action -> previous -> previous..n - var previous = VisitTargetObject; - VisitTargetObject = (targetObject) => action(targetObject, previous); - } - - /// - /// Normalizes JSON indent range between minimum 0 and maximum 4. - /// - /// - /// The number of characters to indent. - protected static int NormalizeJsonIndentRange(int? jsonIndent) - { - if (jsonIndent.HasValue) - { - if (jsonIndent < MIN_JSON_INDENT) - return MIN_JSON_INDENT; - - else if (jsonIndent > MAX_JSON_INDENT) - return MAX_JSON_INDENT; - - return jsonIndent.Value; - } - return MIN_JSON_INDENT; - } - - protected bool TryChangedFiles(out string[] files) - { - files = null; - if (!Option.Input.IgnoreUnchangedPath.GetValueOrDefault(InputOption.Default.IgnoreUnchangedPath.Value) || - !GitHelper.TryGetChangedFiles(Option.Repository.BaseRef, "d", null, out files)) - return false; - - for (var i = 0; i < files.Length; i++) - HostContext.Verbose(string.Format(Thread.CurrentThread.CurrentCulture, PSRuleResources.UsingChangedFile, files[i])); - - return true; - } - - protected bool ShouldProcess(string target, string action) - { - return HostContext == null || HostContext.ShouldProcess(target, action); - } - - protected static OutputStyle GetStyle(OutputStyle style) - { - if (style != OutputStyle.Detect) - return style; - - if (Environment.IsAzurePipelines()) - return OutputStyle.AzurePipelines; - - if (Environment.IsGitHubActions()) - return OutputStyle.GitHubActions; - - return Environment.IsVisualStudioCode() ? - OutputStyle.VisualStudioCode : - OutputStyle.Client; - } -} diff --git a/src/PSRule/Pipeline/PipelineBuilderBase.cs b/src/PSRule/Pipeline/PipelineBuilderBase.cs new file mode 100644 index 0000000000..03a7fab8a9 --- /dev/null +++ b/src/PSRule/Pipeline/PipelineBuilderBase.cs @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.Globalization; +using PSRule.Configuration; +using PSRule.Data; +using PSRule.Definitions; +using PSRule.Definitions.Baselines; +using PSRule.Options; +using PSRule.Pipeline.Output; +using PSRule.Resources; + +namespace PSRule.Pipeline; + +internal abstract class PipelineBuilderBase : IPipelineBuilder +{ + private const string ENGINE_MODULE_NAME = "PSRule"; + + protected readonly PSRuleOption Option; + protected readonly Source[] Source; + protected readonly IHostContext HostContext; + protected BindTargetMethod BindTargetNameHook; + protected BindTargetMethod BindTargetTypeHook; + protected BindTargetMethod BindFieldHook; + protected VisitTargetObject VisitTargetObject; + + private string[] _Include; + private Hashtable _Tag; + private Configuration.BaselineOption _Baseline; + private string[] _Convention; + private PathFilter _InputFilter; + private PipelineWriter _Writer; + + private readonly HostPipelineWriter _Output; + + private const int MIN_JSON_INDENT = 0; + private const int MAX_JSON_INDENT = 4; + + protected PipelineBuilderBase(Source[] source, IHostContext hostContext) + { + Option = new PSRuleOption(); + Source = source; + _Output = new HostPipelineWriter(hostContext, Option, ShouldProcess); + HostContext = hostContext; + BindTargetNameHook = PipelineHookActions.BindTargetName; + BindTargetTypeHook = PipelineHookActions.BindTargetType; + BindFieldHook = PipelineHookActions.BindField; + } + + /// + /// Determines if the pipeline is executing in a remote PowerShell session. + /// + public bool InSession => HostContext != null && HostContext.InSession; + + /// + public void Name(string[] name) + { + if (name == null || name.Length == 0) + return; + + _Include = name; + } + + /// + public void Tag(Hashtable tag) + { + if (tag == null || tag.Count == 0) + return; + + _Tag = tag; + } + + /// + public void Convention(string[] convention) + { + if (convention == null || convention.Length == 0) + return; + + _Convention = convention; + } + + /// + public virtual IPipelineBuilder Configure(PSRuleOption option) + { + if (option == null) + return this; + + Option.Baseline = new Options.BaselineOption(option.Baseline); + Option.Binding = new BindingOption(option.Binding); + Option.Convention = new ConventionOption(option.Convention); + Option.Execution = GetExecutionOption(option.Execution); + Option.Input = new InputOption(option.Input); + Option.Input.Format ??= InputOption.Default.Format; + Option.Output = new OutputOption(option.Output); + Option.Output.Outcome ??= OutputOption.Default.Outcome; + Option.Output.Banner ??= OutputOption.Default.Banner; + Option.Output.Style = GetStyle(option.Output.Style ?? OutputOption.Default.Style.Value); + Option.Repository = GetRepository(option.Repository); + return this; + } + + /// + public abstract IPipeline Build(IPipelineWriter writer = null); + + /// + public void Baseline(Configuration.BaselineOption baseline) + { + if (baseline == null) + return; + + _Baseline = baseline; + } + + /// + /// Require correct module versions for pipeline execution. + /// + protected bool RequireModules() + { + var result = true; + if (Option.Requires.TryGetValue(ENGINE_MODULE_NAME, out var requiredVersion)) + { + var engineVersion = Engine.GetVersion(); + if (GuardModuleVersion(ENGINE_MODULE_NAME, engineVersion, requiredVersion)) + result = false; + } + for (var i = 0; Source != null && i < Source.Length; i++) + { + if (Source[i].Module != null && Option.Requires.TryGetValue(Source[i].Module.Name, out requiredVersion)) + { + if (GuardModuleVersion(Source[i].Module.Name, Source[i].Module.Version, requiredVersion)) + result = false; + } + } + return result; + } + + /// + /// Require sources for pipeline execution. + /// + protected bool RequireSources() + { + if (Source == null || Source.Length == 0) + { + PrepareWriter().WarnRulePathNotFound(); + return false; + } + return true; + } + + private bool GuardModuleVersion(string moduleName, string moduleVersion, string requiredVersion) + { + if (!TryModuleVersion(moduleVersion, requiredVersion)) + { + var writer = PrepareWriter(); + writer.ErrorRequiredVersionMismatch(moduleName, moduleVersion, requiredVersion); + writer.End(new DefaultPipelineResult(null, BreakLevel.None) { HadErrors = true }); + return true; + } + return false; + } + + private static bool TryModuleVersion(string moduleVersion, string requiredVersion) + { + return SemanticVersion.TryParseVersion(moduleVersion, out var version) && + SemanticVersion.TryParseConstraint(requiredVersion, out var constraint) && + constraint.Equals(version); + } + + protected PipelineContext PrepareContext(BindTargetMethod bindTargetName, BindTargetMethod bindTargetType, BindTargetMethod bindField) + { + var unresolved = new List(); + if (_Baseline is Configuration.BaselineOption.BaselineRef baselineRef) + unresolved.Add(new BaselineRef(ResolveBaselineGroup(baselineRef.Name), ScopeType.Explicit)); + + return PipelineContext.New( + option: Option, + hostContext: HostContext, + reader: PrepareReader(), + bindTargetName: bindTargetName, + bindTargetType: bindTargetType, + bindField: bindField, + optionBuilder: GetOptionBuilder(), + unresolved: unresolved + ); + } + + protected string[] ResolveBaselineGroup(string[] name) + { + for (var i = 0; name != null && i < name.Length; i++) + name[i] = ResolveBaselineGroup(name[i]); + + return name; + } + + protected string ResolveBaselineGroup(string name) + { + if (name == null || name.Length < 2 || !name.StartsWith("@") || + Option == null || Option.Baseline == null || Option.Baseline.Group == null || + Option.Baseline.Group.Count == 0) + return name; + + var key = name.Substring(1); + if (!Option.Baseline.Group.TryGetValue(key, out var baselines) || baselines.Length == 0) + throw new PipelineConfigurationException("Baseline.Group", string.Format(Thread.CurrentThread.CurrentCulture, PSRuleResources.PSR0003, key)); + + var writer = PrepareWriter(); + writer.WriteVerbose($"Using baseline group '{key}': {baselines[0]}"); + return baselines[0]; + } + + protected virtual PipelineInputStream PrepareReader() + { + return new PipelineInputStream(null, GetInputObjectSourceFilter(), Option); + } + + protected virtual PipelineWriter PrepareWriter() + { + if (_Writer != null) + return _Writer; + + var output = GetOutput(); + _Writer = Option.Output.Format switch + { + OutputFormat.Csv => new CsvOutputWriter(output, Option, ShouldProcess), + OutputFormat.Json => new JsonOutputWriter(output, Option, ShouldProcess), + OutputFormat.NUnit3 => new NUnit3OutputWriter(output, Option, ShouldProcess), + OutputFormat.Yaml => new YamlOutputWriter(output, Option, ShouldProcess), + OutputFormat.Markdown => new MarkdownOutputWriter(output, Option, ShouldProcess), + OutputFormat.Wide => new WideOutputWriter(output, Option, ShouldProcess), + OutputFormat.Sarif => new SarifOutputWriter(Source, output, Option, ShouldProcess), + _ => output, + }; + return _Writer; + } + + protected virtual PipelineWriter GetOutput(bool writeHost = false) + { + // Redirect to file instead + return !string.IsNullOrEmpty(Option.Output.Path) + ? new FileOutputWriter( + inner: _Output, + option: Option, + encoding: Option.Output.GetEncoding(), + path: Option.Output.Path, + shouldProcess: HostContext.ShouldProcess, + writeHost: writeHost + ) + : _Output; + } + + protected static string[] GetCulture(string[] culture) + { + var result = new List(); + var parent = new List(); + var set = new HashSet(); + for (var i = 0; culture != null && i < culture.Length; i++) + { + var c = CultureInfo.CreateSpecificCulture(culture[i]); + if (!set.Contains(c.Name)) + { + result.Add(c.Name); + set.Add(c.Name); + } + for (var p = c.Parent; !string.IsNullOrEmpty(p.Name); p = p.Parent) + { + if (!set.Contains(p.Name)) + { + parent.Add(p.Name); + set.Add(p.Name); + } + } + } + if (parent.Count > 0) + result.AddRange(parent); + + return result.Count == 0 ? null : result.ToArray(); + } + + protected static RepositoryOption GetRepository(RepositoryOption option) + { + var result = new RepositoryOption(option); + if (string.IsNullOrEmpty(result.Url) && GitHelper.TryRepository(out var url)) + result.Url = url; + + if (string.IsNullOrEmpty(result.BaseRef) && GitHelper.TryBaseRef(out var baseRef)) + result.BaseRef = baseRef; + + return result; + } + + /// + /// Coalesce execution options with defaults. + /// + protected static ExecutionOption GetExecutionOption(ExecutionOption option) + { + var result = ExecutionOption.Combine(option, ExecutionOption.Default); + + // Handle when preference is set to none. The default should be used. + result.AliasReference = result.AliasReference == ExecutionActionPreference.None ? ExecutionOption.Default.AliasReference.Value : result.AliasReference; + result.DuplicateResourceId = result.DuplicateResourceId == ExecutionActionPreference.None ? ExecutionOption.Default.DuplicateResourceId.Value : result.DuplicateResourceId; + result.InvariantCulture = result.InvariantCulture == ExecutionActionPreference.None ? ExecutionOption.Default.InvariantCulture.Value : result.InvariantCulture; + result.RuleExcluded = result.RuleExcluded == ExecutionActionPreference.None ? ExecutionOption.Default.RuleExcluded.Value : result.RuleExcluded; + result.RuleInconclusive = result.RuleInconclusive == ExecutionActionPreference.None ? ExecutionOption.Default.RuleInconclusive.Value : result.RuleInconclusive; + result.RuleSuppressed = result.RuleSuppressed == ExecutionActionPreference.None ? ExecutionOption.Default.RuleSuppressed.Value : result.RuleSuppressed; + result.SuppressionGroupExpired = result.SuppressionGroupExpired == ExecutionActionPreference.None ? ExecutionOption.Default.SuppressionGroupExpired.Value : result.SuppressionGroupExpired; + result.UnprocessedObject = result.UnprocessedObject == ExecutionActionPreference.None ? ExecutionOption.Default.UnprocessedObject.Value : result.UnprocessedObject; + return result; + } + + protected PathFilter GetInputObjectSourceFilter() + { + return Option.Input.IgnoreObjectSource.GetValueOrDefault(InputOption.Default.IgnoreObjectSource.Value) ? GetInputFilter() : null; + } + + protected PathFilter GetInputFilter() + { + if (_InputFilter == null) + { + var basePath = Environment.GetWorkingPath(); + var ignoreGitPath = Option.Input.IgnoreGitPath ?? InputOption.Default.IgnoreGitPath.Value; + var ignoreRepositoryCommon = Option.Input.IgnoreRepositoryCommon ?? InputOption.Default.IgnoreRepositoryCommon.Value; + var builder = PathFilterBuilder.Create(basePath, Option.Input.PathIgnore, ignoreGitPath, ignoreRepositoryCommon); + builder.UseGitIgnore(); + + _InputFilter = builder.Build(); + } + return _InputFilter; + } + + private OptionContextBuilder GetOptionBuilder() + { + return new OptionContextBuilder(Option, _Include, _Tag, _Convention); + } + + protected void ConfigureBinding(PSRuleOption option) + { + if (option.Pipeline.BindTargetName != null && option.Pipeline.BindTargetName.Count > 0) + { + // Do not allow custom binding functions to be used with constrained language mode + if (Option.Execution.LanguageMode == LanguageMode.ConstrainedLanguage) + throw new PipelineConfigurationException(optionName: "BindTargetName", message: PSRuleResources.ConstrainedTargetBinding); + + foreach (var action in option.Pipeline.BindTargetName) + BindTargetNameHook = AddBindTargetAction(action, BindTargetNameHook); + } + + if (option.Pipeline.BindTargetType != null && option.Pipeline.BindTargetType.Count > 0) + { + // Do not allow custom binding functions to be used with constrained language mode + if (Option.Execution.LanguageMode == LanguageMode.ConstrainedLanguage) + throw new PipelineConfigurationException(optionName: "BindTargetType", message: PSRuleResources.ConstrainedTargetBinding); + + foreach (var action in option.Pipeline.BindTargetType) + BindTargetTypeHook = AddBindTargetAction(action, BindTargetTypeHook); + } + } + + private static BindTargetMethod AddBindTargetAction(BindTargetFunc action, BindTargetMethod previous) + { + // Nest the previous write action in the new supplied action + // Execution chain will be: action -> previous -> previous..n + return (string[] propertyNames, bool caseSensitive, bool preferTargetInfo, object targetObject, out string path) => + { + return action(propertyNames, caseSensitive, preferTargetInfo, targetObject, previous, out path); + }; + } + + private static BindTargetMethod AddBindTargetAction(BindTargetName action, BindTargetMethod previous) + { + return AddBindTargetAction((string[] propertyNames, bool caseSensitive, bool preferTargetInfo, object targetObject, BindTargetMethod next, out string path) => + { + path = null; + var targetType = action(targetObject); + return string.IsNullOrEmpty(targetType) ? next(propertyNames, caseSensitive, preferTargetInfo, targetObject, out path) : targetType; + }, previous); + } + + protected void AddVisitTargetObjectAction(VisitTargetObjectAction action) + { + // Nest the previous write action in the new supplied action + // Execution chain will be: action -> previous -> previous..n + var previous = VisitTargetObject; + VisitTargetObject = (targetObject) => action(targetObject, previous); + } + + /// + /// Normalizes JSON indent range between minimum 0 and maximum 4. + /// + /// + /// The number of characters to indent. + protected static int NormalizeJsonIndentRange(int? jsonIndent) + { + if (jsonIndent.HasValue) + { + if (jsonIndent < MIN_JSON_INDENT) + return MIN_JSON_INDENT; + + else if (jsonIndent > MAX_JSON_INDENT) + return MAX_JSON_INDENT; + + return jsonIndent.Value; + } + return MIN_JSON_INDENT; + } + + protected bool TryChangedFiles(out string[] files) + { + files = null; + if (!Option.Input.IgnoreUnchangedPath.GetValueOrDefault(InputOption.Default.IgnoreUnchangedPath.Value) || + !GitHelper.TryGetChangedFiles(Option.Repository.BaseRef, "d", null, out files)) + return false; + + for (var i = 0; i < files.Length; i++) + HostContext.Verbose(string.Format(Thread.CurrentThread.CurrentCulture, PSRuleResources.UsingChangedFile, files[i])); + + return true; + } + + protected bool ShouldProcess(string target, string action) + { + return HostContext == null || HostContext.ShouldProcess(target, action); + } + + protected static OutputStyle GetStyle(OutputStyle style) + { + if (style != OutputStyle.Detect) + return style; + + if (Environment.IsAzurePipelines()) + return OutputStyle.AzurePipelines; + + if (Environment.IsGitHubActions()) + return OutputStyle.GitHubActions; + + return Environment.IsVisualStudioCode() ? + OutputStyle.VisualStudioCode : + OutputStyle.Client; + } +} diff --git a/src/PSRule/Pipeline/PipelineInputStream.cs b/src/PSRule/Pipeline/PipelineInputStream.cs index 9d72cd8244..abac0452d3 100644 --- a/src/PSRule/Pipeline/PipelineInputStream.cs +++ b/src/PSRule/Pipeline/PipelineInputStream.cs @@ -14,17 +14,15 @@ namespace PSRule.Pipeline; /// /// A stream of input objects that will be evaluated. /// -internal sealed class PipelineInputStream +internal sealed class PipelineInputStream : IPipelineReader { - private readonly VisitTargetObject _Input; private readonly InputPathBuilder _InputPath; private readonly PathFilter _InputFilter; private readonly ConcurrentQueue _Queue; private readonly EmitterCollection _EmitterCollection; - public PipelineInputStream(VisitTargetObject input, InputPathBuilder inputPath, PathFilter inputFilter, PSRuleOption option) + public PipelineInputStream(InputPathBuilder inputPath, PathFilter inputFilter, PSRuleOption option) { - _Input = input; _InputPath = inputPath; _InputFilter = inputFilter; _Queue = new ConcurrentQueue(); @@ -35,12 +33,7 @@ public PipelineInputStream(VisitTargetObject input, InputPathBuilder inputPath, public bool IsEmpty => _Queue.IsEmpty; - /// - /// Add a new object into the stream. - /// - /// An object to process. - /// A pre-bound type. - /// Determines if expansion is skipped. + /// public void Enqueue(object sourceObject, string? targetType = null, bool skipExpansion = false) { if (sourceObject == null) @@ -55,11 +48,13 @@ public void Enqueue(object sourceObject, string? targetType = null, bool skipExp _EmitterCollection.Visit(sourceObject); } + /// public bool TryDequeue(out ITargetObject sourceObject) { return _Queue.TryDequeue(out sourceObject); } + /// public void Open() { if (_InputPath == null || _InputPath.Count == 0) @@ -97,11 +92,8 @@ private bool ShouldQueue(TargetObject targetObject) return true; } - /// - /// Add a path to the list of inputs. - /// - /// The path of files to add. - internal void Add(string path) + /// + public void Add(string path) { _InputPath.Add(path); } diff --git a/src/PSRule/Pipeline/PipelineWriterExtensions.cs b/src/PSRule/Pipeline/PipelineWriterExtensions.cs index 0790d862ac..052f05d15d 100644 --- a/src/PSRule/Pipeline/PipelineWriterExtensions.cs +++ b/src/PSRule/Pipeline/PipelineWriterExtensions.cs @@ -109,6 +109,14 @@ internal static void WriteDebug(this IPipelineWriter writer, string message, par )); } + internal static void VerboseRuleDiscovery(this IPipelineWriter writer, string path) + { + if (writer == null || !writer.ShouldWriteVerbose() || string.IsNullOrEmpty(path)) + return; + + writer.WriteVerbose($"[PSRule][D] -- Discovering rules in: {path}"); + } + private static string Format(string message, params object[] args) { return args == null || args.Length == 0 ? message : string.Format(Thread.CurrentThread.CurrentCulture, message, args); diff --git a/src/PSRule/Runtime/RunspaceContext.cs b/src/PSRule/Runtime/RunspaceContext.cs index 5db8f4641b..0f061d8d14 100644 --- a/src/PSRule/Runtime/RunspaceContext.cs +++ b/src/PSRule/Runtime/RunspaceContext.cs @@ -673,16 +673,16 @@ internal void Import(IConvention resource) internal void AddService(string id, object service) { ResourceHelper.ParseIdString(LanguageScope.Name, id, out var scopeName, out var name); - if (!StringComparer.OrdinalIgnoreCase.Equals(LanguageScope.Name, scopeName)) + if (!StringComparer.OrdinalIgnoreCase.Equals(LanguageScope.Name, scopeName) || string.IsNullOrEmpty(name)) return; - LanguageScope.AddService(name, service); + LanguageScope.AddService(name!, service); } internal object? GetService(string id) { ResourceHelper.ParseIdString(LanguageScope.Name, id, out var scopeName, out var name); - return !_LanguageScopes.TryScope(scopeName, out var scope) ? null : scope.GetService(name); + return !_LanguageScopes.TryScope(scopeName, out var scope) || string.IsNullOrEmpty(name) ? null : scope.GetService(name!); } private void RunConventionInitialize() diff --git a/tests/PSRule.Tests/PipelineTests.cs b/tests/PSRule.Tests/PipelineTests.cs index ffa0e34b2f..1fcf5c6c5f 100644 --- a/tests/PSRule.Tests/PipelineTests.cs +++ b/tests/PSRule.Tests/PipelineTests.cs @@ -180,7 +180,7 @@ public void PipelineWithInvariantCulture() Environment.UseCurrentCulture(CultureInfo.InvariantCulture); var context = PipelineContext.New(GetOption(), null, null, null, null, null, new OptionContextBuilder(), null); var writer = new TestWriter(GetOption()); - var pipeline = new GetRulePipeline(context, GetSource(), new PipelineInputStream(null, null, null, null), writer, false); + var pipeline = new GetRulePipeline(context, GetSource(), new PipelineInputStream(null, null, null), writer, false); try { pipeline.Begin(); @@ -202,7 +202,7 @@ public void PipelineWithInvariantCultureDisabled() option.Execution.InvariantCulture = ExecutionActionPreference.Ignore; var context = PipelineContext.New(option, null, null, null, null, null, new OptionContextBuilder(), null); var writer = new TestWriter(option); - var pipeline = new GetRulePipeline(context, GetSource(), new PipelineInputStream(null, null, null, null), writer, false); + var pipeline = new GetRulePipeline(context, GetSource(), new PipelineInputStream(null, null, null), writer, false); try { pipeline.Begin();