diff --git a/.ps-rule/OpenSource.Rule.ps1 b/.ps-rule/OpenSource.Rule.ps1 index 44d21380d..7c072da28 100644 --- a/.ps-rule/OpenSource.Rule.ps1 +++ b/.ps-rule/OpenSource.Rule.ps1 @@ -3,52 +3,20 @@ # Synopsis: Check for recommended community files Rule 'OpenSource.Community' -Type 'System.IO.DirectoryInfo', 'PSRule.Data.RepositoryInfo' { - $requiredFiles = @( - 'CHANGELOG.md' - 'LICENSE.txt' - 'CODE_OF_CONDUCT.md' - 'CONTRIBUTING.md' - 'SECURITY.md' - 'README.md' - '.github/CODEOWNERS' - '.github/PULL_REQUEST_TEMPLATE.md' - ) - Test-Path -Path $TargetObject.FullName; - for ($i = 0; $i -lt $requiredFiles.Length; $i++) { - $filePath = Join-Path -Path $TargetObject.FullName -ChildPath $requiredFiles[$i]; - $Assert.Create((Test-Path -Path $filePath -PathType Leaf), "$($requiredFiles[$i]) does not exist"); - } + $Assert.FilePath($TargetObject, 'FullName', @('CHANGELOG.md')); + $Assert.FilePath($TargetObject, 'FullName', @('LICENSE', 'LICENSE.txt')); + $Assert.FilePath($TargetObject, 'FullName', @('CODE_OF_CONDUCT.md')); + $Assert.FilePath($TargetObject, 'FullName', @('CONTRIBUTING.md')); + $Assert.FilePath($TargetObject, 'FullName', @('SECURITY.md')); + $Assert.FilePath($TargetObject, 'FullName', @('README.md')); + $Assert.FilePath($TargetObject, 'FullName', @('.github/CODEOWNERS')); + $Assert.FilePath($TargetObject, 'FullName', @('.github/PULL_REQUEST_TEMPLATE.md')); } # Synopsis: Check for license in code files -Rule 'OpenSource.License' -Type 'System.IO.FileInfo', 'PSRule.Data.InputFileInfo', '.cs', '.ps1', '.psd1', '.psm1' -If { $TargetObject.Extension -in '.cs', '.ps1', '.psd1', '.psm1' } { - $commentPrefix = "`# "; - if ($TargetObject.Extension -eq '.cs') { - $commentPrefix = '// ' - } - $header = GetLicenseHeader -CommentPrefix $commentPrefix; - $content = Get-Content -Path $TargetObject.FullName -Raw; - $content.StartsWith($header); -} - -function global:GetLicenseHeader { - [CmdletBinding()] - [OutputType([String])] - param ( - [Parameter(Mandatory = $True)] - [String]$CommentPrefix - ) - process { - $text = @( - 'Copyright (c) Microsoft Corporation.' - 'Licensed under the MIT License.' - ) - $builder = [System.Text.StringBuilder]::new(); - foreach ($line in $text) { - $Null = $builder.Append($CommentPrefix); - $Null = $builder.Append($line); - $Null = $builder.Append([System.Environment]::NewLine); - } - return $builder.ToString(); - } +Rule 'OpenSource.License' -Type '.cs', '.ps1', '.psd1', '.psm1' { + $Assert.FileHeader($TargetObject, 'FullName', @( + 'Copyright (c) Microsoft Corporation.' + 'Licensed under the MIT License.' + )); } diff --git a/CHANGELOG.md b/CHANGELOG.md index ba65a7ff9..f5a1b0819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## Unreleased +- Engine features: + - Added file assertion helpers `FileHeader`, and `FilePath`. [#534](https://github.com/microsoft/PSRule/issues/534) + - `FileHeader` checks for a comment header in the file. + - `FilePath` checks that a file path, optionally with suffixes exist. - Bug fixes: - Fixed out of bounds exception when empty markdown documentation is used. [#516](https://github.com/microsoft/PSRule/issues/516) diff --git a/README.md b/README.md index cf17f6e0d..3f54a5b10 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,8 @@ The following conceptual topics exist in the `PSRule` module: - [Assert](docs/concepts/PSRule/en-US/about_PSRule_Assert.md) - [Contains](docs/concepts/PSRule/en-US/about_PSRule_Assert.md#contains) - [EndsWith](docs/concepts/PSRule/en-US/about_PSRule_Assert.md#endswith) + - [FileHeader](docs/concepts/PSRule/en-US/about_PSRule_Assert.md#fileheader) + - [FilePath](docs/concepts/PSRule/en-US/about_PSRule_Assert.md#filepath) - [Greater](docs/concepts/PSRule/en-US/about_PSRule_Assert.md#greater) - [GreaterOrEqual](docs/concepts/PSRule/en-US/about_PSRule_Assert.md#greaterorequal) - [HasDefaultValue](docs/concepts/PSRule/en-US/about_PSRule_Assert.md#hasdefaultvalue) diff --git a/docs/concepts/PSRule/en-US/about_PSRule_Assert.md b/docs/concepts/PSRule/en-US/about_PSRule_Assert.md index 9bc29e6ef..100f2e3aa 100644 --- a/docs/concepts/PSRule/en-US/about_PSRule_Assert.md +++ b/docs/concepts/PSRule/en-US/about_PSRule_Assert.md @@ -17,6 +17,8 @@ The following built-in assertion methods are provided: - [Contains](#contains) - The field value must contain at least one of the strings. - [EndsWith](#endswith) - The field value must match at least one suffix. +- [FileHeader](#fileheader) - The file must contain a comment header. +- [FilePath](#filepath) - The file path must exist. - [Greater](#greater) - The field value must be greater. - [GreaterOrEqual](#greaterorequal) - The field value must be greater or equal to. - [HasDefaultValue](#hasdefaultvalue) - The object should not have the field or the field value is set to the default value. @@ -129,6 +131,8 @@ The following parameters are accepted: - `suffix` - One or more suffixes to compare the field value with. Only one suffix must match. - `caseSensitive` (optional) - Use a case sensitive compare of the field value. Case is ignored by default. +Reasons include: + - _The parameter 'inputObject' is null._ - _The parameter 'field' is null or empty._ - _The field '{0}' does not exist._ @@ -144,6 +148,83 @@ Rule 'EndsWith' { } ``` +### FileHeader + +The `FileHeader` assertion method checks a file for a comment header. +When comparing the file header, the format of line comments are automatically detected by file extension. +Single line comments are supported. Multi-line comments are not supported. + +The following parameters are accepted: + +- `inputObject` - The object being checked for the specified field. +- `field` - The name of the field containing a valid file path. +- `header` - One or more lines of a header to compare with file contents. +- `prefix` (optional) - An optional comment prefix for each line. +By default a comment prefix will automatically detected based on file extension. +When set, detection by file extension is skipped. + +Prefix detection for line comments is supported with the following file extensions: + +- `.cs`, `.ts`, `.js`, `.fs`, `.go`, `.php`, `.cpp`, `.h` - Use a prefix of (`// `). +- `.ps1`, `.psd1`, `.psm1`, `.yaml`, `.yml`, `.r`, `.py`, `.sh`, `.tf`, `.tfvars`, `.gitignore`, `.pl` - Use a prefix of (`# `). +- `.sql`, `.lau` - Use a prefix of (`-- `). + +Reasons include: + +- _The parameter 'inputObject' is null._ +- _The parameter 'field' is null or empty._ +- _The field '{0}' does not exist._ +- _The field value '{0}' is not a string._ +- _The file '{0}' does not exist._ +- _The header was not set._ + +Examples: + +```powershell +Rule 'FileHeader' { + $Assert.FileHeader($TargetObject, 'FullName', @( + 'Copyright (c) Microsoft Corporation.' + 'Licensed under the MIT License.' + )); +} +``` + +### FilePath + +The `FilePath` assertion method checks the file exists. +Checks use OS case-sensitivity rules. + +The following parameters are accepted: + +- `inputObject` - The object being checked for the specified field. +- `field` - The name of the field containing a file path. +- `suffix` (optional) - Additional file path suffixes to append. +When specified each suffix is combined with the file path. +Only one full file path must be a valid file for the assertion method to pass. + +Reasons include: + +- _The parameter 'inputObject' is null._ +- _The parameter 'field' is null or empty._ +- _The field '{0}' does not exist._ +- _The field value '{0}' is not a string._ +- _The file '{0}' does not exist._ + +Examples: + +```powershell +Rule 'FilePath' { + $Assert.FilePath($TargetObject, 'FullName', @('CHANGELOG.md')); + $Assert.FilePath($TargetObject, 'FullName', @('LICENSE', 'LICENSE.txt')); + $Assert.FilePath($TargetObject, 'FullName', @('CODE_OF_CONDUCT.md')); + $Assert.FilePath($TargetObject, 'FullName', @('CONTRIBUTING.md')); + $Assert.FilePath($TargetObject, 'FullName', @('SECURITY.md')); + $Assert.FilePath($TargetObject, 'FullName', @('README.md')); + $Assert.FilePath($TargetObject, 'FullName', @('.github/CODEOWNERS')); + $Assert.FilePath($TargetObject, 'FullName', @('.github/PULL_REQUEST_TEMPLATE.md')); +} +``` + ### Greater The `Greater` assertion method checks the field value is greater than the specified value. diff --git a/src/PSRule/Resources/ReasonStrings.Designer.cs b/src/PSRule/Resources/ReasonStrings.Designer.cs index 11ae45925..676ef29b8 100644 --- a/src/PSRule/Resources/ReasonStrings.Designer.cs +++ b/src/PSRule/Resources/ReasonStrings.Designer.cs @@ -105,6 +105,24 @@ namespace PSRule.Resources { } } + /// + /// Looks up a localized string similar to The header was not set.. + /// + internal static string FileHeader { + get { + return ResourceManager.GetString("FileHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file '{0}' does not exist.. + /// + internal static string FilePath { + get { + return ResourceManager.GetString("FilePath", resourceCulture); + } + } + /// /// Looks up a localized string similar to The value '{0}' was not > '{1}'.. /// diff --git a/src/PSRule/Resources/ReasonStrings.resx b/src/PSRule/Resources/ReasonStrings.resx index cee0fa9b7..3e547d551 100644 --- a/src/PSRule/Resources/ReasonStrings.resx +++ b/src/PSRule/Resources/ReasonStrings.resx @@ -134,6 +134,12 @@ The field(s) existed: {0} Included when any of the fields exist and the -Not switch is used. + + The header was not set. + + + The file '{0}' does not exist. + The value '{0}' was not > '{1}'. diff --git a/src/PSRule/Runtime/Assert.cs b/src/PSRule/Runtime/Assert.cs index d41dbe4fd..df12d0beb 100644 --- a/src/PSRule/Runtime/Assert.cs +++ b/src/PSRule/Runtime/Assert.cs @@ -421,6 +421,73 @@ namespace PSRule.Runtime return Fail(ReasonStrings.NotMatchPattern, value, pattern); } + public AssertResult FilePath(PSObject inputObject, string field, string[] suffix = null) + { + // Guard parameters + if (GuardNullParam(inputObject, nameof(inputObject), out AssertResult result) || + GuardNullOrEmptyParam(field, nameof(field), out result) || + GuardField(inputObject, field, false, out object fieldValue, out result) || + GuardString(fieldValue, out string value, out result)) + return result; + + if (suffix == null || suffix.Length == 0) + { + if (!TryFilePath(value, out _)) + return Fail(ReasonStrings.FilePath, value); + + return Pass(); + } + + var reason = Fail(); + for (var i = 0; i < suffix.Length; i++) + { + if (!TryFilePath(Path.Combine(value, suffix[i]), out _)) + reason.AddReason(ReasonStrings.FilePath, suffix[i]); + else + return Pass(); + } + return reason; + } + + public AssertResult FileHeader(PSObject inputObject, string field, string[] header, string prefix = null) + { + // Guard parameters + if (GuardNullParam(inputObject, nameof(inputObject), out AssertResult result) || + GuardNullOrEmptyParam(field, nameof(field), out result) || + GuardField(inputObject, field, false, out object fieldValue, out result) || + GuardString(fieldValue, out string value, out result)) + return result; + + // File does not exist + if (!TryFilePath(value, out _)) + return Fail(ReasonStrings.FilePath, value); + + // No header + if (header == null || header.Length == 0) + return Pass(); + + if (string.IsNullOrEmpty(prefix)) + prefix = DetectLinePrefix(Path.GetExtension(value)); + + var lineNo = 0; + foreach (var content in File.ReadLines(value)) + { + if (lineNo >= header.Length) + break; + + if (content != string.Concat(prefix, header[lineNo])) + return Fail(ReasonStrings.FileHeader); + + lineNo++; + } + + // Catch file has less lines than header + if (lineNo < header.Length) + return Fail(ReasonStrings.FileHeader); + + return Pass(); + } + #region Helper methods private static bool IsEmpty(object fieldValue) @@ -472,7 +539,7 @@ namespace PSRule.Runtime private static bool TryString(object obj, out string value) { value = null; - if (obj is string svalue) + if (GetBaseObject(obj) is string svalue) { value = svalue; return true; @@ -656,10 +723,10 @@ namespace PSRule.Runtime return false; } - private static bool TryFilePath(string uri, out string path) + private static bool TryFilePath(string path, out string rootedPath) { - path = PSRuleOption.GetRootedPath(uri); - return File.Exists(path); + rootedPath = PSRuleOption.GetRootedPath(path); + return File.Exists(rootedPath); } private static string FormatArray(string[] values) @@ -697,6 +764,46 @@ namespace PSRule.Runtime PipelineContext.CurrentThread.ExpressionCache[string.Concat(prefix, key)] = value; } + /// + /// Determine line comment prefix by file extension + /// + private static string DetectLinePrefix(string extension) + { + switch (extension) + { + case ".cs": + case ".ts": + case ".js": + case ".fs": + case ".go": + case ".php": + case ".cpp": + case ".h": + return "// "; + + case ".ps1": + case ".psd1": + case ".psm1": + case ".yaml": + case ".yml": + case ".r": + case ".py": + case ".sh": + case ".tf": + case ".tfvars": + case ".gitignore": + case ".pl": + return "# "; + + case ".sql": + case ".lua": + return "-- "; + + default: + return string.Empty; + } + } + #endregion Helper methods } } diff --git a/tests/PSRule.Tests/AssertTests.cs b/tests/PSRule.Tests/AssertTests.cs index b7849a746..6e854278d 100644 --- a/tests/PSRule.Tests/AssertTests.cs +++ b/tests/PSRule.Tests/AssertTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using PSRule.Pipeline; +using System; +using System.IO; using System.Management.Automation; using Xunit; @@ -493,6 +495,30 @@ namespace PSRule Assert.True(assert.NotMatch(value, "notValue", "\\w*2").Result); } + [Fact] + public void FileHeader() + { + SetContext(); + var assert = GetAssertionHelper(); + + var value = GetObject((name: "FullName", value: GetSourcePath("FromFile.Rule.ps1"))); + Assert.True(assert.FileHeader(value, "FullName", new string[] { "Copyright (c) Microsoft Corporation.", "Licensed under the MIT License." }).Result); + value = GetObject((name: "FullName", value: GetSourcePath("Baseline.Rule.yaml"))); + Assert.False(assert.FileHeader(value, "FullName", new string[] { "Copyright (c) Microsoft Corporation.", "Licensed under the MIT License." }).Result); + } + + [Fact] + public void FilePath() + { + SetContext(); + var assert = GetAssertionHelper(); + + var value = GetObject((name: "FullName", value: GetSourcePath("Baseline.Rule.yaml"))); + Assert.True(assert.FilePath(value, "FullName").Result); + value = GetObject((name: "FullName", value: GetSourcePath("README.zz"))); + Assert.False(assert.FilePath(value, "FullName").Result); + } + #region Helper methods private static void SetContext() @@ -516,6 +542,11 @@ namespace PSRule return new Runtime.Assert(); } + private string GetSourcePath(string fileName) + { + return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileName); + } + #endregion Helper methods } } diff --git a/tests/PSRule.Tests/FromFileAssert.Rule.ps1 b/tests/PSRule.Tests/FromFileAssert.Rule.ps1 index d6f635367..1bea9f864 100644 --- a/tests/PSRule.Tests/FromFileAssert.Rule.ps1 +++ b/tests/PSRule.Tests/FromFileAssert.Rule.ps1 @@ -52,6 +52,20 @@ Rule 'Assert.EndsWith' { $Assert.EndsWith($TargetObject, 'Name', '1') } +# Synopsis: Test for $Assert.FileHeader +Rule 'Assert.FileHeader' { + $Assert.FileHeader($TargetObject, 'Path', @( + 'Copyright (c) Microsoft Corporation.' + 'Licensed under the MIT License.' + )) +} + +# Synopsis: Test for $Assert.FilePath +Rule 'Assert.FilePath' { + $Assert.FilePath($TargetObject, 'Path') + $Assert.FilePath($TargetObject, 'ParentPath', @('PSRule.Assert.Tests.ps1')) +} + # Synopsis: Test for $Assert.Greater Rule 'Assert.Greater' { $Assert.Greater($TargetObject, 'CompareNumeric', 2) diff --git a/tests/PSRule.Tests/PSRule.Assert.Tests.ps1 b/tests/PSRule.Tests/PSRule.Assert.Tests.ps1 index f2f2a942c..467cd862f 100644 --- a/tests/PSRule.Tests/PSRule.Assert.Tests.ps1 +++ b/tests/PSRule.Tests/PSRule.Assert.Tests.ps1 @@ -49,6 +49,8 @@ Describe 'PSRule assertions' -Tag 'Assert' { 'Item3' 'Item4' ) + Path = $PSCommandPath + ParentPath = $here } [PSCustomObject]@{ '$schema' = "http://json-schema.org/draft-07/schema`#" @@ -75,6 +77,7 @@ Describe 'PSRule assertions' -Tag 'Assert' { 'item2' 'item3' ) + ParentPath = (Join-Path -Path $here -ChildPath 'notapath') } ) @@ -162,6 +165,39 @@ Describe 'PSRule assertions' -Tag 'Assert' { $result[1].Reason | Should -BeLike "The field '*' does not end with '*'."; } + It 'FileHeader' { + $result = @($testObject | Invoke-PSRule -Path $ruleFilePath -Name 'Assert.FileHeader'); + $result | Should -Not -BeNullOrEmpty; + $result.Length | Should -Be 2; + + # Positive case + $result[0].IsSuccess() | Should -Be $True; + $result[0].TargetName | Should -Be 'TestObject1'; + + # Negative case + $result[1].IsSuccess() | Should -Be $False; + $result[1].TargetName | Should -Be 'TestObject2'; + $result[1].Reason.Length | Should -Be 1; + $result[1].Reason | Should -Be "The field 'Path' does not exist."; + } + + It 'FilePath' { + $result = @($testObject | Invoke-PSRule -Path $ruleFilePath -Name 'Assert.FilePath'); + $result | Should -Not -BeNullOrEmpty; + $result.Length | Should -Be 2; + + # Positive case + $result[0].IsSuccess() | Should -Be $True; + $result[0].TargetName | Should -Be 'TestObject1'; + + # Negative case + $result[1].IsSuccess() | Should -Be $False; + $result[1].TargetName | Should -Be 'TestObject2'; + $result[1].Reason.Length | Should -Be 2; + $result[1].Reason[0] | Should -Be "The field 'Path' does not exist."; + $result[1].Reason[1] | Should -BeLike "The file '*' does not exist."; + } + It 'Greater' { $result = @($testObject | Invoke-PSRule -Path $ruleFilePath -Name 'Assert.Greater'); $result | Should -Not -BeNullOrEmpty;