diff --git a/.vscode/settings.json b/.vscode/settings.json index 017ae0cc20..9f0f157542 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -40,7 +40,8 @@ }, { "fileMatch": [ - "/**/ps-rule.lock.json" + "/**/ps-rule.lock.json", + "/**/test.lock.json" ], "url": "./schemas/PSRule-lock.schema.json" } @@ -135,5 +136,6 @@ "release/*" ], "dotnet.completion.showCompletionItemsFromUnimportedNamespaces": true, - "PSRule.options.path": "ps-rule-ci.yaml" + "PSRule.options.path": "ps-rule-ci.yaml", + "PSRule.lock.restore": true } diff --git a/docs/CHANGELOG-v3.md b/docs/CHANGELOG-v3.md index 2a652b9924..402db55eee 100644 --- a/docs/CHANGELOG-v3.md +++ b/docs/CHANGELOG-v3.md @@ -27,6 +27,13 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers ## Unreleased +What's changed since pre-release v3.0.0-B0342: + +- General improvements: + - Added an integrity hash to lock file by @BernieWhite. + [#2664](https://github.com/microsoft/PSRule/issues/2664) + - The lock file now includes an integrity hash to ensures the restored module matches originally added module. + ## v3.0.0-B0342 (pre-release) What's changed since pre-release v3.0.0-B0340: diff --git a/docs/updates/v3.0.md b/docs/updates/v3.0.md index 7db91a0305..3eab61890e 100644 --- a/docs/updates/v3.0.md +++ b/docs/updates/v3.0.md @@ -1,11 +1,11 @@ --- -date: 2024-11-30 +date: 2024-01-30 version: 3.0 --- -# nnn nnn (v3.0) +# January 2025 (v3.0) -Welcome to the nnn nnn release of PSRule. +Welcome to the January 2025 release of PSRule. There are many updates in this version that we hope you'll like, some of the key highlights include: - [Official CLI support](#official-cli-support) — A new CLI experience for PSRule. @@ -19,7 +19,7 @@ See the detailed change log [here](../CHANGELOG-v3.md). While many of you have been using PSRule through PowerShell for some time, we've been working on a new experience for those who prefer a CLI. -Additionally we wanted to improve the bootstrapping experience for PSRule in CI/CD pipelines. +Additionally, we wanted to improve the bootstrapping experience for PSRule in during development and CI/CD pipelines. The new CLI runs on Windows, macOS, and Linux and is available as a standalone executable or can be installed as a .NET tool. @@ -27,9 +27,21 @@ The new CLI runs on Windows, macOS, and Linux and is available as a standalone e We've introduced a new feature to help you manage the versions of modules used by PSRule. The module lock file is a JSON-based file named `ps-rule.lock.json` that lists the modules and versions used by PSRule. +Initialize and commit the lock file to your repository to pin each module to a specific version. + +If the lock file is present, PSRule will use the versions listed in the lock file instead of the latest available version. +When no lock file is present, PSRule will use the latest version of each module that meets any configured constraints. This makes it easier to share and reproduce the versions of modules used during development and CI/CD pipelines. +This is a change to the previous behavior in CI/CD pipelines where PSRule: + +- Would install the latest version of each module but only if no version was not already installed. +- Would not check if the installed version met constraints defined in the options file. + +The lock file is supported by the CLI, GitHub Actions, Azure Pipelines, and Visual Studio Code extension. +When using PSRule from PowerShell, the lock file is ignored to prevent conflicts with PowerShell's built-in update mechanism. + ## Visual Studio Code ### New home and identity @@ -38,6 +50,8 @@ The Visual Studio Code (VSCode) extension for PSRule now lives side-by-side with As part of this change we are now publishing the extension as a **verified** Microsoft extension with the ID `ps-rule.vscode-ps-rule`. +The new extension supports pre-release and stable releases managed through Visual Studio Code's extension marketplace. + We hope this will not only help the community to log issues and get help on the correct repository, but also streamline how we deliver updates in the future. @@ -49,10 +63,13 @@ Bringing together the code base is the first step in building an all improved ri Previously to use PSRule within VSCode, a prerequisite step was to install PowerShell on non-Windows OSs and then install PSRule through PowerShell. +Additionally, any required rules modules would also need to be installed through PowerShell. We've done away with this approach entirely for the authoring experience in VSCode by providing native support in the extension. -This means you can now use PSRule in VSCode without needing to install PowerShell or PSRule on your machine. +This means you can now use PSRule in VSCode without needing to separately install PowerShell or PSRule on your machine. +The extension includes the necessary components to run PSRule and will install and cache required rule modules. + We've improved the experience by adding the ability to: - Restore modules from the VSCode command palette. diff --git a/ps-rule.lock.json b/ps-rule.lock.json index 558020c8ea..d8c6761a87 100644 --- a/ps-rule.lock.json +++ b/ps-rule.lock.json @@ -2,7 +2,8 @@ "version": 1, "modules": { "PSRule.Rules.MSFT.OSS": { - "version": "1.1.0" + "version": "1.1.0", + "integrity": "sha512-4oEbkAT3VIQQlrDUOpB9qKkbNU5BMktvkDCriws4LgCMUiyUoYMcN0XovljAIW4FO0cmP7mP6A8Z7MPNGlgK7Q==" } } -} +} \ No newline at end of file diff --git a/schemas/PSRule-lock.schema.json b/schemas/PSRule-lock.schema.json index edee473fb1..5c2d38bd27 100644 --- a/schemas/PSRule-lock.schema.json +++ b/schemas/PSRule-lock.schema.json @@ -24,6 +24,11 @@ "title": "Module version", "description": "The version of the module to use." }, + "integrity": { + "type": "string", + "title": "Module integrity", + "description": "The integrity hash of the module to use." + }, "includePrerelease": { "type": "boolean", "title": "Include prerelease", diff --git a/src/PSRule.CommandLine/ClientContext.cs b/src/PSRule.CommandLine/ClientContext.cs index 0d68525a0d..60dcf8db3d 100644 --- a/src/PSRule.CommandLine/ClientContext.cs +++ b/src/PSRule.CommandLine/ClientContext.cs @@ -3,6 +3,8 @@ using System.CommandLine.Invocation; using PSRule.Configuration; +using PSRule.Options; +using PSRule.Pipeline.Dependencies; namespace PSRule.CommandLine; @@ -28,6 +30,7 @@ public ClientContext(InvocationContext invocation, string? option, bool verbose, Host = new ClientHost(this, verbose, debug); Option = GetOption(Host, option); CachePath = Path; + IntegrityAlgorithm = Option.Execution.HashAlgorithm.GetValueOrDefault(ExecutionOption.Default.HashAlgorithm!.Value).ToIntegrityAlgorithm(); } /// @@ -66,6 +69,11 @@ public ClientContext(InvocationContext invocation, string? option, bool verbose, /// public string CachePath { get; } + /// + /// The default integrity algorithm to use. + /// + public IntegrityAlgorithm IntegrityAlgorithm { get; } + private static PSRuleOption GetOption(ClientHost host, string? path) { PSRuleOption.UseHostContext(host); diff --git a/src/PSRule.CommandLine/Commands/ModuleCommand.cs b/src/PSRule.CommandLine/Commands/ModuleCommand.cs index 8f745e4a40..cfb0bc7676 100644 --- a/src/PSRule.CommandLine/Commands/ModuleCommand.cs +++ b/src/PSRule.CommandLine/Commands/ModuleCommand.cs @@ -72,7 +72,7 @@ public static async Task ModuleRestoreAsync(RestoreOptions operationOptions var idealVersion = await FindVersionAsync(module, null, targetVersion, null, cancellationToken); if (idealVersion != null) { - installedVersion = await InstallVersionAsync(clientContext, module, idealVersion, cancellationToken); + installedVersion = await InstallVersionAsync(clientContext, module, idealVersion, kv.Value.Integrity, cancellationToken); } if (pwsh.HadErrors || idealVersion == null || installedVersion == null) @@ -112,7 +112,7 @@ public static async Task ModuleRestoreAsync(RestoreOptions operationOptions var idealVersion = await FindVersionAsync(includeModule, moduleConstraint, null, null, cancellationToken); if (idealVersion != null) { - await InstallVersionAsync(clientContext, includeModule, idealVersion, cancellationToken); + await InstallVersionAsync(clientContext, includeModule, idealVersion, null, cancellationToken); } else if (idealVersion == null) { @@ -153,13 +153,18 @@ public static async Task ModuleInitAsync(ModuleOptions operationOptions, Cl var file = !operationOptions.Force ? LockFile.Read(null) : new LockFile(); using var pwsh = CreatePowerShell(); + if (operationOptions.Force) + { + clientContext.LogVerbose(Messages.UsingForce); + } + // Add for any included modules. if (clientContext.Option?.Include?.Module != null && clientContext.Option.Include.Module.Length > 0) { foreach (var includeModule in clientContext.Option.Include.Module) { // Skip modules already in the lock unless force is used. - if (file.Modules.TryGetValue(includeModule, out var lockEntry)) + if (file.Modules.TryGetValue(includeModule, out var lockEntry) && !operationOptions.Force) continue; // Get a constraint if set from options. @@ -173,14 +178,18 @@ public static async Task ModuleInitAsync(ModuleOptions operationOptions, Cl return ERROR_MODULE_FAILED_TO_FIND; } - if (lockEntry?.Version == idealVersion) + if (lockEntry?.Version == idealVersion && !operationOptions.Force) continue; - // invocation.Log(Messages.UsingModule, includeModule, idealVersion.ToString()); + if (!IsInstalled(pwsh, includeModule, idealVersion, out _)) + { + await InstallVersionAsync(clientContext, includeModule, idealVersion, null, cancellationToken); + } - file.Modules[includeModule] = new LockEntry + clientContext.LogVerbose(Messages.UsingModule, includeModule, idealVersion.ToString()); + file.Modules[includeModule] = new LockEntry(idealVersion) { - Version = idealVersion + Integrity = IntegrityBuilder.Build(clientContext.IntegrityAlgorithm, GetModulePath(clientContext, includeModule, idealVersion)), }; } } @@ -224,6 +233,11 @@ public static async Task ModuleAddAsync(ModuleOptions operationOptions, Cli var requires = clientContext.Option.Requires.ToDictionary(); var file = LockFile.Read(null); + if (operationOptions.Force) + { + clientContext.LogVerbose(Messages.UsingForce); + } + using var pwsh = CreatePowerShell(); foreach (var module in operationOptions.Module) { @@ -253,11 +267,16 @@ public static async Task ModuleAddAsync(ModuleOptions operationOptions, Cli return ERROR_MODULE_FAILED_TO_FIND; } + if (!IsInstalled(pwsh, module, idealVersion, out _)) + { + await InstallVersionAsync(clientContext, module, idealVersion, null, cancellationToken); + } + clientContext.LogVerbose(Messages.UsingModule, module, idealVersion.ToString()); - item = new LockEntry + item = new LockEntry(idealVersion) { - Version = idealVersion, IncludePrerelease = operationOptions.Prerelease && !idealVersion.Stable ? true : null, + Integrity = IntegrityBuilder.Build(clientContext.IntegrityAlgorithm, GetModulePath(clientContext, module, idealVersion)), }; file.Modules[module] = item; } @@ -337,9 +356,15 @@ public static async Task ModuleUpgradeAsync(ModuleOptions operationOptions, if (idealVersion == kv.Value.Version) continue; + if (!IsInstalled(pwsh, kv.Key, idealVersion, out _)) + { + await InstallVersionAsync(clientContext, kv.Key, idealVersion, null, cancellationToken); + } + clientContext.LogVerbose(Messages.UsingModule, kv.Key, idealVersion.ToString()); kv.Value.Version = idealVersion; + kv.Value.Integrity = IntegrityBuilder.Build(clientContext.IntegrityAlgorithm, GetModulePath(clientContext, kv.Key, idealVersion)); kv.Value.IncludePrerelease = (kv.Value.IncludePrerelease.GetValueOrDefault(false) || operationOptions.Prerelease) && !idealVersion.Stable ? true : null; file.Modules[kv.Key] = kv.Value; } @@ -470,7 +495,7 @@ private static bool TryPrivateData(PSModuleInfo info, string propertyName, out H return result; } - private static async Task InstallVersionAsync([DisallowNull] ClientContext context, [DisallowNull] string name, [DisallowNull] SemanticVersion.Version version, CancellationToken cancellationToken) + private static async Task InstallVersionAsync([DisallowNull] ClientContext context, [DisallowNull] string name, [DisallowNull] SemanticVersion.Version version, LockEntryIntegrity? integrity, CancellationToken cancellationToken) { context.LogVerbose(Messages.RestoringModule, name, version); @@ -495,14 +520,23 @@ private static bool TryPrivateData(PSModuleInfo info, string propertyName, out H var nuspecReader = await packageReader.GetNuspecReaderAsync(cancellationToken); var modulePath = GetModulePath(context, name, version); + var tempPath = GetModuleTempPath(context, name, version); // Remove existing module. if (Directory.Exists(modulePath)) + { Directory.Delete(modulePath, true); + } + + // Remove existing temp module. + if (Directory.Exists(tempPath)) + { + Directory.Delete(tempPath, true); + } var count = 0; var files = packageReader.GetFiles(); - packageReader.CopyFiles(modulePath, files, (name, targetPath, s) => + packageReader.CopyFiles(tempPath, files, (name, targetPath, s) => { if (ShouldIgnorePackageFile(name)) return null; @@ -515,6 +549,36 @@ private static bool TryPrivateData(PSModuleInfo info, string propertyName, out H }, logger, cancellationToken); // Check module path exists. + if (!Directory.Exists(tempPath)) + return null; + + if (integrity != null) + { + context.LogVerbose("Checking module integrity: {0} -- {1}", name, integrity.Hash); + + var actualIntegrity = IntegrityBuilder.Build(integrity.Algorithm, tempPath); + if (!string.Equals(actualIntegrity.Hash, integrity.Hash)) + { + context.LogVerbose("Module integrity check failed: {0} -- {1}", name, actualIntegrity.Hash); + + // Clean up the temp path. + if (Directory.Exists(tempPath)) + { + Directory.Delete(tempPath, true); + } + + context.LogError(Messages.Error_504, name, version); + return null; + } + } + + var parentDirectory = Directory.GetParent(modulePath)?.FullName; + if (!Directory.Exists(parentDirectory) && parentDirectory != null) + Directory.CreateDirectory(parentDirectory); + + // Move the module to the final path. + Directory.Move(tempPath, modulePath); + if (!Directory.Exists(modulePath)) return null; @@ -534,10 +598,17 @@ private static string GetModulePath(ClientContext context, string name, [Disallo return Path.Combine(context.CachePath, MODULES_PATH, name, version.ToShortString()); } + private static string GetModuleTempPath(ClientContext context, string name, [DisallowNull] SemanticVersion.Version version) + { + return Path.Combine(context.CachePath, MODULES_PATH, string.Concat("temp-", name, "-", version.ToShortString())); + } + private static bool ShouldIgnorePackageFile(string name) { return string.Equals(name, "[Content_Types].xml", StringComparison.OrdinalIgnoreCase) || - string.Equals(name, "_rels/.rels", StringComparison.OrdinalIgnoreCase); + string.Equals(name, "_rels/.rels", StringComparison.OrdinalIgnoreCase) || + Path.GetExtension(name) == ".nuspec" || + name.StartsWith("package/services/metadata/core-properties/", StringComparison.OrdinalIgnoreCase); } private static PowerShell CreatePowerShell() diff --git a/src/PSRule.CommandLine/Resources/Messages.Designer.cs b/src/PSRule.CommandLine/Resources/Messages.Designer.cs index b515f4cfd5..805f99f232 100644 --- a/src/PSRule.CommandLine/Resources/Messages.Designer.cs +++ b/src/PSRule.CommandLine/Resources/Messages.Designer.cs @@ -87,6 +87,15 @@ internal static string Error_503 { } } + /// + /// Looks up a localized string similar to Failed to verify module integrity: {0} -- v{1}. + /// + internal static string Error_504 { + get { + return ResourceManager.GetString("Error_504", resourceCulture); + } + } + /// /// Looks up a localized string similar to Restoring module: {0} -- v{1}. /// @@ -96,6 +105,15 @@ internal static string RestoringModule { } } + /// + /// Looks up a localized string similar to Using force command option.. + /// + internal static string UsingForce { + get { + return ResourceManager.GetString("UsingForce", resourceCulture); + } + } + /// /// Looks up a localized string similar to Using module: {0} -- v{1}. /// diff --git a/src/PSRule.CommandLine/Resources/Messages.resx b/src/PSRule.CommandLine/Resources/Messages.resx index 0be3d66e98..ebe06b28a6 100644 --- a/src/PSRule.CommandLine/Resources/Messages.resx +++ b/src/PSRule.CommandLine/Resources/Messages.resx @@ -126,10 +126,16 @@ The specified verison v{0} does not meet the required constraint. + + Using force command option. + Restoring module: {0} -- v{1} Using module: {0} -- v{1} + + Failed to verify module integrity: {0} -- v{1} + \ No newline at end of file diff --git a/src/PSRule.Tool/ClientBuilder.cs b/src/PSRule.Tool/ClientBuilder.cs index a585185bcb..a4cf74c101 100644 --- a/src/PSRule.Tool/ClientBuilder.cs +++ b/src/PSRule.Tool/ClientBuilder.cs @@ -197,8 +197,7 @@ private void AddModule() var option = new ModuleOptions { Path = invocation.ParseResult.GetValueForOption(_Global_Path), - Version = invocation.ParseResult.GetValueForOption(_Module_Add_Version), - Force = invocation.ParseResult.GetValueForOption(_Module_Add_Force), + Force = invocation.ParseResult.GetValueForOption(_Module_Init_Force), SkipVerification = invocation.ParseResult.GetValueForOption(_Module_Add_SkipVerification), }; diff --git a/src/PSRule.Types/Options/ExecutionOption.cs b/src/PSRule.Types/Options/ExecutionOption.cs index 7c437af809..a0d5b0e5ec 100644 --- a/src/PSRule.Types/Options/ExecutionOption.cs +++ b/src/PSRule.Types/Options/ExecutionOption.cs @@ -27,7 +27,10 @@ public sealed class ExecutionOption : IEquatable, IExecutionOpt private const ExecutionActionPreference DEFAULT_UNPROCESSEDOBJECT = ExecutionActionPreference.Warn; private const HashAlgorithm DEFAULT_HASHALGORITHM = Options.HashAlgorithm.SHA512; - internal static readonly ExecutionOption Default = new() + /// + /// The default execution option. + /// + public static readonly ExecutionOption Default = new() { Break = DEFAULT_BREAK, DuplicateResourceId = DEFAULT_DUPLICATERESOURCEID, diff --git a/src/PSRule/Common/JsonConverters.cs b/src/PSRule/Common/JsonConverters.cs index 6b51012fe1..bb3776a5d6 100644 --- a/src/PSRule/Common/JsonConverters.cs +++ b/src/PSRule/Common/JsonConverters.cs @@ -1024,22 +1024,6 @@ private static void ReadMap(EnumMap map, JsonReader reader) } } -/// -/// A converter for converting to/ from JSON. -/// -internal sealed class SemanticVersionConverter : JsonConverter -{ - public override SemanticVersion.Version ReadJson(JsonReader reader, Type objectType, SemanticVersion.Version existingValue, bool hasExistingValue, JsonSerializer serializer) - { - return reader.TokenType == JsonToken.String && SemanticVersion.TryParseVersion(reader.Value as string, out var version) ? version : default; - } - - public override void WriteJson(JsonWriter writer, SemanticVersion.Version value, JsonSerializer serializer) - { - writer.WriteValue(value?.ToString()); - } -} - internal sealed class CaseInsensitiveDictionaryConverter : JsonConverter { public override bool CanConvert(Type objectType) diff --git a/src/PSRule/Converters/Json/LockEntryIntegrityJsonConverter.cs b/src/PSRule/Converters/Json/LockEntryIntegrityJsonConverter.cs new file mode 100644 index 0000000000..0b2086c474 --- /dev/null +++ b/src/PSRule/Converters/Json/LockEntryIntegrityJsonConverter.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; +using PSRule.Pipeline.Dependencies; + +namespace PSRule.Converters.Json; + +#nullable enable + +/// +/// A converter for converting to/ from JSON. +/// +internal sealed class LockEntryIntegrityJsonConverter : JsonConverter +{ + public override LockEntryIntegrity? ReadJson(JsonReader reader, Type objectType, LockEntryIntegrity? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType != JsonToken.String || reader.Value is not string s || string.IsNullOrEmpty(s) || s.IndexOf('-') == -1) + return null; + + var parts = s.Split('-'); + if (!Enum.TryParse(parts[0], ignoreCase: true, result: out var algorithm)) + return null; + + return new LockEntryIntegrity + { + Algorithm = algorithm, + Hash = parts[1] + }; + } + + public override void WriteJson(JsonWriter writer, LockEntryIntegrity? value, JsonSerializer serializer) + { + if (value == null || value.Algorithm == null || value.Algorithm == IntegrityAlgorithm.Unknown || string.IsNullOrEmpty(value.Hash)) + return; + + var algorithm = Enum.GetName(typeof(IntegrityAlgorithm), value.Algorithm).ToLower(); + + writer.WriteValue($"{algorithm}-{value.Hash}"); + } +} + +#nullable restore diff --git a/src/PSRule/Converters/Json/SemanticVersionJsonConverter.cs b/src/PSRule/Converters/Json/SemanticVersionJsonConverter.cs new file mode 100644 index 0000000000..1cbd7932ae --- /dev/null +++ b/src/PSRule/Converters/Json/SemanticVersionJsonConverter.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Newtonsoft.Json; +using PSRule.Data; + +namespace PSRule.Converters.Json; + +/// +/// A converter for converting to/ from JSON. +/// +internal sealed class SemanticVersionJsonConverter : JsonConverter +{ + public override SemanticVersion.Version ReadJson(JsonReader reader, Type objectType, SemanticVersion.Version existingValue, bool hasExistingValue, JsonSerializer serializer) + { + return reader.TokenType == JsonToken.String && SemanticVersion.TryParseVersion(reader.Value as string, out var version) ? version : default; + } + + public override void WriteJson(JsonWriter writer, SemanticVersion.Version value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } +} diff --git a/src/PSRule/Pipeline/Dependencies/HashAlgorithmExtensions.cs b/src/PSRule/Pipeline/Dependencies/HashAlgorithmExtensions.cs new file mode 100644 index 0000000000..f55abff98b --- /dev/null +++ b/src/PSRule/Pipeline/Dependencies/HashAlgorithmExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using PSRule.Options; + +namespace PSRule.Pipeline.Dependencies; + +/// +/// Extension for . +/// +public static class HashAlgorithmExtensions +{ + /// + /// Convert a to . + /// + public static IntegrityAlgorithm ToIntegrityAlgorithm(this HashAlgorithm hashAlgorithm) + { + return hashAlgorithm switch + { + HashAlgorithm.SHA256 => IntegrityAlgorithm.SHA256, + HashAlgorithm.SHA384 => IntegrityAlgorithm.SHA384, + HashAlgorithm.SHA512 => IntegrityAlgorithm.SHA512, + _ => IntegrityAlgorithm.Unknown + }; + } +} diff --git a/src/PSRule/Pipeline/Dependencies/IntegrityAlgorithm.cs b/src/PSRule/Pipeline/Dependencies/IntegrityAlgorithm.cs new file mode 100644 index 0000000000..eae5bc5f93 --- /dev/null +++ b/src/PSRule/Pipeline/Dependencies/IntegrityAlgorithm.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; + +namespace PSRule.Pipeline.Dependencies; + +/// +/// The algorithm used to generate the integrity hash. +/// +public enum IntegrityAlgorithm +{ + /// + /// Unknown algorithm. + /// + [Description("unknown")] + Unknown = 0, + + /// + /// SHA-256 algorithm. + /// + [Description("sha256")] + SHA256 = 1, + + /// + /// SHA-384 algorithm. + /// + [Description("sha384")] + SHA384 = 2, + + /// + /// SHA-512 algorithm. + /// + [Description("sha512")] + SHA512 = 3, +} + +#nullable restore diff --git a/src/PSRule/Pipeline/Dependencies/IntegrityBuilder.cs b/src/PSRule/Pipeline/Dependencies/IntegrityBuilder.cs new file mode 100644 index 0000000000..59729bcfe4 --- /dev/null +++ b/src/PSRule/Pipeline/Dependencies/IntegrityBuilder.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; + +namespace PSRule.Pipeline.Dependencies; + +#nullable enable + +/// +/// Calculates the integrity of dependency. +/// +/// +/// The integrity is calculated by deterministically getting the hash of each file. +/// +public static class IntegrityBuilder +{ + private sealed class FileIntegrity(string path, string hash) + { + [JsonProperty("path", Order = 0)] + public string Path { get; set; } = path; + + [JsonProperty("hash", Order = 1)] + public string Hash { get; set; } = hash; + } + + /// + /// Build an integrity hash for a dependency. + /// + /// The algorithm to use. + /// The directory path to the dependency. + public static LockEntryIntegrity Build(IntegrityAlgorithm alg, string path) + { + if (!Directory.Exists(path)) + throw new InvalidOperationException($"The path '{path}' does not exist."); + + var ignoredFiles = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "PSGetModuleInfo.xml", + }; + + var files = GetFiles(alg, path, ignoredFiles); + var content = JsonConvert.SerializeObject(files, new JsonSerializerSettings + { + Formatting = Formatting.None, + }); + + return new LockEntryIntegrity + { + Algorithm = IntegrityAlgorithm.SHA512, + Hash = CalculateHashFromContent(alg, content) + }; + } + + private static string CalculateHashFromPath(IntegrityAlgorithm alg, string path) + { + using var stream = File.OpenRead(path); + using var hashingAlgorithm = GetHashAlgorithm(alg); + var hash = hashingAlgorithm.ComputeHash(stream); + + return Convert.ToBase64String(hash); + } + + private static string CalculateHashFromContent(IntegrityAlgorithm alg, string content) + { + using var hashingAlgorithm = GetHashAlgorithm(alg); + var hash = hashingAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(content)); + + return Convert.ToBase64String(hash); + } + + private static FileIntegrity[] GetFiles(IntegrityAlgorithm alg, string path, HashSet ignoredFiles) + { + var files = new List(); + + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + var relativePath = ExpressionHelpers.NormalizePath(path, file, caseSensitive: true); + if (ignoredFiles.Contains(relativePath)) + continue; + + files.Add(new FileIntegrity( + path: relativePath, + hash: CalculateHashFromPath(alg, file) + )); + } + + // Sort files by path to ensure a deterministic hash. + files.Sort((x, y) => string.Compare(x.Path, y.Path, StringComparison.Ordinal)); + return [.. files]; + } + + private static HashAlgorithm GetHashAlgorithm(IntegrityAlgorithm alg) + { + return alg switch + { + IntegrityAlgorithm.SHA512 => SHA512.Create(), + _ => throw new InvalidOperationException($"The integrity algorithm '{alg}' is not supported.") + }; + } +} + +#nullable restore diff --git a/src/PSRule/Pipeline/Dependencies/LockEntry.cs b/src/PSRule/Pipeline/Dependencies/LockEntry.cs index e0afdf7d42..5be47b381c 100644 --- a/src/PSRule/Pipeline/Dependencies/LockEntry.cs +++ b/src/PSRule/Pipeline/Dependencies/LockEntry.cs @@ -2,21 +2,34 @@ // Licensed under the MIT License. using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using Newtonsoft.Json; +using PSRule.Converters.Json; using PSRule.Data; namespace PSRule.Pipeline.Dependencies; +#nullable enable + /// /// An entry within the lock file. /// -public sealed class LockEntry +public sealed class LockEntry(SemanticVersion.Version version) { /// /// The version to use. /// - [JsonProperty("version", NullValueHandling = NullValueHandling.Include)] - public SemanticVersion.Version Version { get; set; } + [Required] + [JsonProperty("version", NullValueHandling = NullValueHandling.Include, Order = 0)] + public SemanticVersion.Version Version { get; set; } = version; + + /// + /// The integrity hash for the module. + /// + // [Required] + [JsonProperty("integrity", NullValueHandling = NullValueHandling.Ignore, Order = 1)] + [JsonConverter(typeof(LockEntryIntegrityJsonConverter))] + public LockEntryIntegrity? Integrity { get; set; } /// /// Accept pre-release versions in addition to stable module versions. @@ -24,3 +37,5 @@ public sealed class LockEntry [DefaultValue(null), JsonProperty("includePrerelease", NullValueHandling = NullValueHandling.Ignore)] public bool? IncludePrerelease { get; set; } } + +#nullable restore diff --git a/src/PSRule/Pipeline/Dependencies/LockEntryIntegrity.cs b/src/PSRule/Pipeline/Dependencies/LockEntryIntegrity.cs new file mode 100644 index 0000000000..3aef46a5e6 --- /dev/null +++ b/src/PSRule/Pipeline/Dependencies/LockEntryIntegrity.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel.DataAnnotations; + +namespace PSRule.Pipeline.Dependencies; + +#nullable enable + +/// +/// Split out an integrity hash string into the algorithm and hash value. +/// +public sealed class LockEntryIntegrity +{ + /// + /// The algorithm used to generate the hash. + /// + [Required] + public IntegrityAlgorithm Algorithm { get; set; } + + /// + /// The base64 encoded hash value. + /// + [Required] + public string Hash { get; set; } +} + +#nullable restore diff --git a/src/PSRule/Pipeline/Dependencies/LockFile.cs b/src/PSRule/Pipeline/Dependencies/LockFile.cs index 892028bdc1..f0539f4727 100644 --- a/src/PSRule/Pipeline/Dependencies/LockFile.cs +++ b/src/PSRule/Pipeline/Dependencies/LockFile.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.ComponentModel.DataAnnotations; using System.Text; using Newtonsoft.Json; +using PSRule.Converters.Json; namespace PSRule.Pipeline.Dependencies; @@ -19,6 +21,7 @@ public sealed class LockFile /// /// The version of the lock file schema. /// + [Required] [JsonProperty("version")] public int Version { get; set; } @@ -45,7 +48,8 @@ public static LockFile Read(string? path) { Converters = [ - new SemanticVersionConverter() + new SemanticVersionJsonConverter(), + new LockEntryIntegrityJsonConverter(), ], }); return result ?? new LockFile(); @@ -69,7 +73,8 @@ public void Write(string? path) { Converters = [ - new SemanticVersionConverter() + new SemanticVersionJsonConverter(), + new LockEntryIntegrityJsonConverter(), ] }); File.WriteAllText(path, json, Encoding.UTF8); diff --git a/tests/PSRule.Tests/LockFileTests.cs b/tests/PSRule.Tests/LockFileTests.cs deleted file mode 100644 index 32e3ef694a..0000000000 --- a/tests/PSRule.Tests/LockFileTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using PSRule.Pipeline.Dependencies; - -namespace PSRule; - -public sealed class LockFileTests : BaseTests -{ - [Fact] - public void ReadFile() - { - var lockFile = LockFile.Read(GetSourcePath("test.lock.json")); - Assert.True(lockFile.Modules.TryGetValue("PSRule.Rules.MSFT.OSS", out var item)); - Assert.Equal("1.1.0", item.Version.ToString()); - - Assert.True(lockFile.Modules.TryGetValue("psrule.rules.msft.oss", out item)); - Assert.Equal("1.1.0", item.Version.ToString()); - } -} diff --git a/tests/PSRule.Tests/Pipeline/Dependencies/LockFileTests.cs b/tests/PSRule.Tests/Pipeline/Dependencies/LockFileTests.cs new file mode 100644 index 0000000000..39ef6f8eab --- /dev/null +++ b/tests/PSRule.Tests/Pipeline/Dependencies/LockFileTests.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.Pipeline.Dependencies; + +/// +/// Tests for the lock file. +/// +public sealed class LockFileTests : BaseTests +{ + [Fact] + public void Read_WhenValidLockFile_ShouldReturnInstance() + { + var lockFile = LockFile.Read(GetSourcePath("test.lock.json")); + Assert.NotNull(lockFile); + + Assert.True(lockFile.Modules.TryGetValue("PSRule.Rules.MSFT.OSS", out var item)); + Assert.Equal("1.1.0", item.Version.ToString()); + Assert.Equal(IntegrityAlgorithm.SHA512, item.Integrity.Algorithm); + Assert.Equal("4oEbkAT3VIQQlrDUOpB9qKkbNU5BMktvkDCriws4LgCMUiyUoYMcN0XovljAIW4FO0cmP7mP6A8Z7MPNGlgK7Q==", item.Integrity.Hash); + + Assert.True(lockFile.Modules.TryGetValue("psrule.rules.msft.oss", out item)); + Assert.Equal("1.1.0", item.Version.ToString()); + Assert.Equal(IntegrityAlgorithm.SHA512, item.Integrity.Algorithm); + Assert.Equal("4oEbkAT3VIQQlrDUOpB9qKkbNU5BMktvkDCriws4LgCMUiyUoYMcN0XovljAIW4FO0cmP7mP6A8Z7MPNGlgK7Q==", item.Integrity.Hash); + } +} diff --git a/tests/PSRule.Tests/test.lock.json b/tests/PSRule.Tests/test.lock.json index 9afd5428d1..201aa6cc83 100644 --- a/tests/PSRule.Tests/test.lock.json +++ b/tests/PSRule.Tests/test.lock.json @@ -1,7 +1,9 @@ { + "version": 1, "modules": { "PSRule.Rules.MSFT.OSS": { - "version": "1.1.0" + "version": "1.1.0", + "integrity": "sha512-4oEbkAT3VIQQlrDUOpB9qKkbNU5BMktvkDCriws4LgCMUiyUoYMcN0XovljAIW4FO0cmP7mP6A8Z7MPNGlgK7Q==" } } }