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;