From 6c4e71c71b0b7fc874b689423e7ec66bf4d0770c Mon Sep 17 00:00:00 2001 From: Rain Sallow Date: Fri, 9 May 2025 10:58:49 -0400 Subject: [PATCH 1/2] (#3477, #3635) Add Expand-ChocolateyArchive helper cmdlet --- .../Chocolatey.PowerShell.csproj | 6 + .../ExpandChocolateyArchiveCommand.cs | 94 +++++ .../Helpers/ArchitectureWidth.cs | 31 ++ .../Helpers/ExtractArchiveHelper.cs | 399 ++++++++++++++++++ src/Chocolatey.PowerShell/Helpers/PSHelper.cs | 140 +++++- .../Shared/ChocolateyCmdlet.cs | 80 +++- .../Shared/ProcessHandler.cs | 274 ++++++++++++ .../Shared/SevenZipException.cs | 43 ++ .../helpers/chocolateyInstaller.psm1 | 1 + src/chocolatey/StringResources.cs | 15 + .../builders/ConfigurationBuilder.cs | 3 + .../configuration/ChocolateyConfiguration.cs | 1 + .../configuration/EnvironmentSettings.cs | 5 + 13 files changed, 1088 insertions(+), 4 deletions(-) create mode 100644 src/Chocolatey.PowerShell/Commands/ExpandChocolateyArchiveCommand.cs create mode 100644 src/Chocolatey.PowerShell/Helpers/ArchitectureWidth.cs create mode 100644 src/Chocolatey.PowerShell/Helpers/ExtractArchiveHelper.cs create mode 100644 src/Chocolatey.PowerShell/Shared/ProcessHandler.cs create mode 100644 src/Chocolatey.PowerShell/Shared/SevenZipException.cs diff --git a/src/Chocolatey.PowerShell/Chocolatey.PowerShell.csproj b/src/Chocolatey.PowerShell/Chocolatey.PowerShell.csproj index 3534013dc..d5a2dc0d2 100644 --- a/src/Chocolatey.PowerShell/Chocolatey.PowerShell.csproj +++ b/src/Chocolatey.PowerShell/Chocolatey.PowerShell.csproj @@ -46,6 +46,7 @@ + @@ -65,6 +66,9 @@ + + + @@ -75,6 +79,7 @@ + @@ -90,6 +95,7 @@ + diff --git a/src/Chocolatey.PowerShell/Commands/ExpandChocolateyArchiveCommand.cs b/src/Chocolatey.PowerShell/Commands/ExpandChocolateyArchiveCommand.cs new file mode 100644 index 000000000..5cd2fb2c5 --- /dev/null +++ b/src/Chocolatey.PowerShell/Commands/ExpandChocolateyArchiveCommand.cs @@ -0,0 +1,94 @@ +using Chocolatey.PowerShell.Helpers; +using Chocolatey.PowerShell.Shared; +using System; +using System.IO; +using System.Management.Automation; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using static chocolatey.StringResources.EnvironmentVariables; + +namespace Chocolatey.PowerShell.Commands +{ + [Cmdlet(VerbsData.Expand, "ChocolateyArchive", DefaultParameterSetName = "Path", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)] + [OutputType(typeof(string))] + public class ExpandChocolateyArchiveCommand : ChocolateyCmdlet + { + [Alias("File", "FileFullPath")] + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Path")] + [Parameter(Mandatory = true, ParameterSetName = "BothPaths")] + public string Path { get; set; } = string.Empty; + + [Alias("UnzipLocation")] + [Parameter(Mandatory = true, Position = 1)] + public string Destination { get; set; } = string.Empty; + + [Parameter(Position = 2)] + [Alias("SpecificFolder")] + public string FilesToExtract { get; set; } + + [Parameter(Position = 3)] + public string PackageName { get; set; } + + [Alias("File64", "FileFullPath64")] + [Parameter(Mandatory = true, ParameterSetName = "Path64")] + [Parameter(Mandatory = true, ParameterSetName = "BothPaths")] + public string Path64 { get; set; } + + [Parameter] + public SwitchParameter DisableLogging { get; set; } + + [Parameter] + public SwitchParameter UseBuiltinCompression { get; set; } = EnvironmentHelper.GetVariable(Package.ChocolateyUseBuiltinCompression) == "true"; + + protected override void End() + { + var helper = new ExtractArchiveHelper(this, PipelineStopToken); + try + { + helper.ExtractFiles(Path, Path64, PackageName, Destination, FilesToExtract, UseBuiltinCompression, DisableLogging); + + WriteObject(Destination); + } + catch (FileNotFoundException error) + { + ThrowTerminatingError(new ErrorRecord( + error, + $"{ErrorId}.FileNotFound", + ErrorCategory.ObjectNotFound, + string.IsNullOrEmpty(Path) ? Path64 : Path)); + } + catch (InvalidOperationException error) + { + ThrowTerminatingError(new ErrorRecord( + error, + $"{ErrorId}.ApplicationMissing", + ErrorCategory.InvalidOperation, + targetObject: null)); + } + catch (NotSupportedException error) + { + ThrowTerminatingError(new ErrorRecord( + error, + $"{ErrorId}.UnsupportedArchitecture", + ErrorCategory.NotImplemented, + string.IsNullOrEmpty(Path) ? Path64 : Path)); + } + catch (SevenZipException error) + { + ThrowTerminatingError(new ErrorRecord( + error, + $"{ErrorId}.ExtractionFailed", + ErrorCategory.InvalidResult, + string.IsNullOrEmpty(Path) ? Path64 : Path)); + } + catch (Exception error) + { + ThrowTerminatingError(new ErrorRecord( + error, + $"{ErrorId}.Unknown", + ErrorCategory.NotSpecified, + string.IsNullOrEmpty(Path) ? Path64 : Path)); + } + } + } +} diff --git a/src/Chocolatey.PowerShell/Helpers/ArchitectureWidth.cs b/src/Chocolatey.PowerShell/Helpers/ArchitectureWidth.cs new file mode 100644 index 000000000..f6fa6c15e --- /dev/null +++ b/src/Chocolatey.PowerShell/Helpers/ArchitectureWidth.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Chocolatey.PowerShell.Helpers +{ + /// + /// Provides information on the current running architecture width of the process. + /// + internal static class ArchitectureWidth + { + /// + /// Returns either 64 or 32, depending on whether the current process environment is 64-bit. + /// + /// The current architecture width as an integer. + internal static int Get() + { + return Environment.Is64BitProcess ? 64 : 32; + } + + /// + /// Compares the current architecture to the expected value. + /// + /// The architecture width to compare to. + /// True if the provided value matches the current architecture width, otherwise false. + internal static bool Matches(int compareTo) + { + return Get() == compareTo; + } + } +} diff --git a/src/Chocolatey.PowerShell/Helpers/ExtractArchiveHelper.cs b/src/Chocolatey.PowerShell/Helpers/ExtractArchiveHelper.cs new file mode 100644 index 000000000..188b141e9 --- /dev/null +++ b/src/Chocolatey.PowerShell/Helpers/ExtractArchiveHelper.cs @@ -0,0 +1,399 @@ +// Copyright © 2017 - 2025 Chocolatey Software, Inc +// Copyright © 2011 - 2017 RealDimensions Software, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Chocolatey.PowerShell.Shared; +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Management.Automation; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using PackageVariables = chocolatey.StringResources.EnvironmentVariables.Package; +using SystemVariables = chocolatey.StringResources.EnvironmentVariables.System; + +namespace Chocolatey.PowerShell.Helpers +{ + public class ExtractArchiveHelper + { + private const int BuiltinBufferSize = 1024 * 80; // 80kb (default) + + private readonly CancellationToken _cancellationToken; + private readonly PSCmdlet _cmdlet; + private readonly StringBuilder _extractedFilesList = new StringBuilder(); + + public ExtractArchiveHelper(PSCmdlet cmdlet, CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + _cmdlet = cmdlet; + } + + /// + /// Extract files from an archive to a directory. + /// + /// The path to the default or 32-bit archive to extract. + /// The path to a 64-bit archive to extract. + /// The package that is being currently installed. Will default to ChocolateyPackageName environment variable if not provided. + /// The destination directory to extract files to. + /// A path or glob pattern to filter the files extracted from the archive. + /// If true, uses a builtin .NET extraction method rather than 7zip. Note that this method only supports zip files. + /// Whether to write a log of the files extracted from the archive. + /// Thrown if incorrect combinations of arguments were given. + /// Thrown if a file in the archive has an invalid relative path. + /// Thrown if the 7-Zip executable cannot be found and is false. + /// Thrown if there is no 32-bit archive path available for the given package and we are forced to use 32-bit. + /// Thrown if the 7-Zip executable returns a non-zero error code and is false.. + /// Thrown if the 7-Zip executable cannot be opened and is false.. + public void ExtractFiles( + string path, + string path64, + string packageName, + string destination, + string filesToExtract, + bool useBuiltinCompression, + bool disableLogging) + { + if (string.IsNullOrEmpty(path) && string.IsNullOrEmpty(path64)) + { + throw new ArgumentException("At least one of the path or path64 values must be specified."); + } + + var bitnessMessage = string.Empty; + var zipFilePath = path; + packageName = string.IsNullOrEmpty(packageName) + ? Environment.GetEnvironmentVariable(PackageVariables.ChocolateyPackageName) + : packageName; + var logPath = string.Empty; + + var forceX86 = PSHelper.IsEqual(Environment.GetEnvironmentVariable(PackageVariables.ChocolateyForceX86), "true"); + if (ArchitectureWidth.Matches(32) || forceX86) + { + if (string.IsNullOrEmpty(path)) + { + throw new NotSupportedException($"32-bit archive is not supported for {packageName}"); + } + + if (!string.IsNullOrEmpty(path64)) + { + bitnessMessage = "32-bit "; + } + } + else if (!string.IsNullOrEmpty(path64)) + { + zipFilePath = path64; + bitnessMessage = "64 bit "; + } + + if (!PSHelper.FileExists(_cmdlet, zipFilePath)) + { + throw new FileNotFoundException("The target archive could not be found at the specified path.", zipFilePath); + } + + if (!string.IsNullOrEmpty(packageName)) + { + var libPath = Environment.GetEnvironmentVariable(PackageVariables.ChocolateyPackageFolder); + if (!string.IsNullOrEmpty(libPath)) + { + if (!PSHelper.ContainerExists(_cmdlet, libPath)) + { + PSHelper.NewDirectory(_cmdlet, libPath); + } + + if (!disableLogging) + { + logPath = PSHelper.CombinePaths(_cmdlet, libPath, $"{PSHelper.GetFileName(zipFilePath)}.txt"); + } + } + } + + var envChocolateyPackageName = Environment.GetEnvironmentVariable(PackageVariables.ChocolateyPackageName); + var envChocolateyInstallDirectoryPackage = Environment.GetEnvironmentVariable(PackageVariables.ChocolateyInstallDirectoryPackage); + + if (!string.IsNullOrEmpty(envChocolateyPackageName) && PSHelper.IsEqual(envChocolateyPackageName, envChocolateyInstallDirectoryPackage)) + { + _cmdlet.WriteWarning("Install Directory override not available for zip packages at this time. If this package also runs a native installer using Chocolatey functions, the directory will be honored."); + } + + PSHelper.WriteHost(_cmdlet, $"Extracting {bitnessMessage}{zipFilePath} to {destination}..."); + + PSHelper.EnsureDirectoryExists(_cmdlet, destination); + + var filesToExtractMessage = string.IsNullOrEmpty(filesToExtract) ? string.Empty : $" matching pattern {filesToExtract}"; + try + { + if (useBuiltinCompression) + { + if (_cmdlet.ShouldProcess(zipFilePath, $"Extract zip file contents{filesToExtractMessage} to '{destination}' with built-in decompression")) + { + ExtractWithBuiltin(zipFilePath, destination, filesToExtract, disableLogging); + } + } + else if (_cmdlet.ShouldProcess(zipFilePath, $"Extract zip file contents{filesToExtractMessage} to '{destination}' with 7-Zip")) + { + var helper = new SevenZipExtractionHelper(_cmdlet, _cancellationToken, _extractedFilesList); + helper.ExtractFiles(zipFilePath, destination, filesToExtract, disableLogging); + } + } + finally + { + if (!string.IsNullOrEmpty(logPath)) + { + try + { + PSHelper.SetContent(_cmdlet, logPath, _extractedFilesList.ToString(), Encoding.UTF8); + } + catch (IOException error) + { + // Non-terminating error, because this doesn't mean the operation actually failed, + // it just means we couldn't write the log file. + _cmdlet.WriteError(new RuntimeException($"There was an error recording the zip file extraction log: {error.Message}", error).ErrorRecord); + } + } + } + + EnvironmentHelper.SetVariable(PackageVariables.ChocolateyPackageInstallLocation, destination); + } + + /// + /// Use builtin zip archive methods to extract files. + /// + /// The path to the archive to extract. + /// The destination directory to extract files to. + /// A path or glob pattern to filter the files extracted from the archive. + /// Whether to write a log of the files extracted from the archive. + /// The destination path where the files were extracted to. + /// Thrown if a file in the archive has an invalid relative path. + private void ExtractWithBuiltin(string path, string destination, string filesToExtract, bool disableLogging) + { + var fullDestination = PSHelper.GetUnresolvedPath(_cmdlet, destination); + using (var file = File.OpenRead(path)) + using (var zipArchive = new ZipArchive(file, ZipArchiveMode.Read)) + { + BuiltinExtractToDirectory(zipArchive, fullDestination, filesToExtract, disableLogging); + } + } + + private void BuiltinExtractToDirectory(ZipArchive source, string resolvedDirectoryName, string filesToExtract, bool disableLogging) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (resolvedDirectoryName == null) + { + throw new ArgumentNullException(nameof(resolvedDirectoryName)); + } + + // Wildcard patterns we support are just * and ?, so convert those and escape everything else as literal. + // We anchor to the start of the path, leaving the end un-anchored so that providing a directory name + // will extract the directory along with its contents. + var filter = string.IsNullOrEmpty(filesToExtract) + ? null + : new Regex("^" + Regex.Escape(filesToExtract).Replace(@"\\*", ".*").Replace(@"\\?", "."), RegexOptions.IgnoreCase); + + DirectoryInfo directoryInfo = Directory.CreateDirectory(resolvedDirectoryName); + var destination = directoryInfo.FullName; + + var length = destination.Length; + if (length != 0 && destination[length - 1] != Path.DirectorySeparatorChar) + { + destination += Path.DirectorySeparatorChar; + } + + foreach (ZipArchiveEntry entry in source.Entries) + { + if (!(filter is null)) + { + if (!filter.IsMatch(entry.FullName)) + { + _cmdlet.WriteDebug($"Skipping zipfile entry {entry.FullName} as it does not match the pattern {filesToExtract}"); + continue; + } + } + + var fullPath = Path.GetFullPath(Path.Combine(destination, entry.FullName)); + if (!fullPath.StartsWith(destination, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException($"Invalid data encountered in the archive; entry's relative path '{entry.FullName}' would cause it to be extracted outside the destination directory."); + } + + if (Path.GetFileName(fullPath).Length == 0) + { + if (entry.Length != 0) + { + throw new IOException($"Invalid data encountered for entry {entry.FullName}; a directory entry containing file data cannot be extracted."); + } + + Directory.CreateDirectory(fullPath); + } + else + { + ExtractZipArchiveFile(fullPath, entry, disableLogging, _cancellationToken).GetAwaiter().GetResult(); + } + } + } + + // NOTE: async method used here to make use of the cancellation token + // SAFETY: do not call Cmdlet or PSHelper methods from async methods when possible; + // some calls, most notable the Cmdlet.Write*() methods will throw exceptions. + private async Task ExtractZipArchiveFile(string fullPath, ZipArchiveEntry entry, bool disableLogging, CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); + + // Create causes overwrite if the file already exists, this is intentional. + using (var target = File.Create(fullPath)) + using (var entryStream = entry.Open()) + { + await entryStream.CopyToAsync(target, BuiltinBufferSize, cancellationToken); + + await target.FlushAsync(cancellationToken); + if (!disableLogging) + { + _extractedFilesList.AppendLine(fullPath); + } + } + } + + /// + /// Helper class for handling decompression of archives with 7-Zip. + /// This helper relies on 7z.exe being present on the system. + /// + private sealed class SevenZipExtractionHelper : ProcessHandler + { + private const string ErrorMessageAddendum = "This is most likely an issue with the '{0}' package and not with Chocolatey itself. Please follow up with the package maintainer(s) directly."; + + private string _destinationFolder = string.Empty; + + /// + /// Instantiates a new . + /// + /// The calling cmdlet. + /// The calling cmdlet's pipeline stop token. This is used to ensure we correctly dispose of resources when a user cancels an operation. + /// A stringbuilder to append a log of extracted files to. + internal SevenZipExtractionHelper(PSCmdlet cmdlet, CancellationToken pipelineStopToken, StringBuilder extractedFilesList) + : base(cmdlet, pipelineStopToken) + { + ProcessOutputReceived += (sender, output) => + { + if (output.Message.StartsWith("- ")) + { + extractedFilesList.AppendLine(_destinationFolder + '\\' + output.Message.Substring(2)); + } + }; + } + + /// + /// Run 7-Zip to extract files to the directory. + /// + /// The path to the archive to extract. + /// The destination directory to extract files to. + /// A path or glob pattern to filter the files extracted from the archive. + /// Whether to write a log of the files extracted from the archive. + /// The destination path where the files were extracted to. + /// Thrown if the 7-Zip executable cannot be found. + /// Thrown if the 7-Zip executable returns a non-zero error code. + /// Thrown if the 7-Zip executable cannot be opened. + internal void ExtractFiles(string zipFilePath, string destination, string filesToExtract, bool disableLogging) + { + var exePath = PSHelper.CombinePaths(Cmdlet, PSHelper.GetInstallLocation(Cmdlet), "tools", "7z.exe"); + + if (!PSHelper.ItemExists(Cmdlet, exePath)) + { + EnvironmentHelper.UpdateSession(Cmdlet); + exePath = PSHelper.CombinePaths(Cmdlet, EnvironmentHelper.GetVariable(SystemVariables.ChocolateyInstall), "tools", "7zip.exe"); + } + + exePath = PSHelper.GetUnresolvedPath(Cmdlet, exePath); + + if (!PSHelper.ItemExists(Cmdlet, exePath)) + { + throw new InvalidOperationException("Could not locate the 7z.exe or 7zip.exe executables."); + } + + Cmdlet.WriteDebug($"7zip found at '{exePath}'"); + + // 32-bit 7z would not find C:\Windows\System32\config\systemprofile\AppData\Local\Temp, + // because it gets translated to C:\Windows\SysWOW64\... by the WOW redirection layer. + // Replace System32 with sysnative, which does not get redirected. + // 32-bit 7z is required so it can see both architectures + if (ArchitectureWidth.Matches(64)) + { + var systemPath = Environment.GetFolderPath(Environment.SpecialFolder.System); + var sysNativePath = PSHelper.CombinePaths(Cmdlet, EnvironmentHelper.GetVariable("SystemRoot"), "SysNative"); + zipFilePath = PSHelper.Replace(zipFilePath, Regex.Escape(systemPath), sysNativePath); + destination = PSHelper.Replace(destination, Regex.Escape(systemPath), sysNativePath); + } + + var workingDirectory = PSHelper.GetCurrentDirectory(Cmdlet); + if (string.IsNullOrEmpty(workingDirectory)) + { + Cmdlet.WriteDebug("Unable to use current location for Working Directory. Using Cache Location instead."); + workingDirectory = EnvironmentHelper.GetVariable("TEMP"); + } + + var loggingOption = disableLogging ? "-bb0" : "-bb1"; + + var options = $"x -aoa -bd {loggingOption} -o\"{destination}\" -y \"{zipFilePath}\""; + if (!string.IsNullOrEmpty(filesToExtract)) + { + options += $" \"{filesToExtract}\""; + } + + Cmdlet.WriteDebug($"Executing command ['{exePath}' {options}]"); + + _destinationFolder = destination; + + var exitCode = Run(exePath, workingDirectory, options, sensitiveStatements: null, elevated: false, ProcessWindowStyle.Hidden, noNewWindow: true); + + PSHelper.SetExitCode(Cmdlet, exitCode); + + Cmdlet.WriteDebug($"7z exit code: {exitCode}"); + + if (exitCode == 0) + { + return; + } + + var error = GetExitCodeException(exitCode); + var disclaimer = string.Format(ErrorMessageAddendum, EnvironmentHelper.GetVariable(PackageVariables.ChocolateyPackageName)); + + throw new SevenZipException($"{error.Message} {disclaimer}", error); + } + + private Exception GetExitCodeException(int exitCode) + { + switch (exitCode) + { + case 1: + return new ApplicationFailedException($"Some files could not be extracted. (Code {exitCode})"); + case 2: + return new ApplicationException($"7-Zip encountered a fatal error while extracting the files. (Code {exitCode})"); + case 7: + return new ArgumentException($"7-Zip command line error. (Code {exitCode})"); + case 8: + return new OutOfMemoryException($"7-Zip exited with an out of memory error. (Code {exitCode})"); + case 255: + return new OperationCanceledException($"7-Zip extraction was cancelled by the user. (Code {exitCode})"); + default: + return new Exception($"7-Zip exited with an unknown error. (Code {exitCode})"); + }; + } + } + } +} diff --git a/src/Chocolatey.PowerShell/Helpers/PSHelper.cs b/src/Chocolatey.PowerShell/Helpers/PSHelper.cs index 5779d75e1..8b8097256 100644 --- a/src/Chocolatey.PowerShell/Helpers/PSHelper.cs +++ b/src/Chocolatey.PowerShell/Helpers/PSHelper.cs @@ -20,8 +20,13 @@ using System; using System.Management.Automation; using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; using static chocolatey.StringResources; +using chocolatey; +using System.Threading; +using System.Threading.Tasks; namespace Chocolatey.PowerShell.Helpers { @@ -131,6 +136,30 @@ public static void EnsureDirectoryExists(PSCmdlet cmdlet, string directory) } } + /// + /// Determines if the given is a child of the given . + /// + /// + /// This assumes the following: + /// + /// The file system is case insensitive or the casing is irrelevant; if the casing of the parent path differs, + /// it will return false to be safe. + /// Both paths are already expanded; ensure you have called + /// before passing paths into this method. + /// + /// + /// The full, expanded path of the parent directory. + /// The full, expanded path of the child item to be checked. + /// True if the child path is a child path of the parent. + public static bool IsChildPath(string parentPath, string childPath) + { + // Determining if a file system is case sensitive is non-trivial. + // For our use cases, we make the safer assumption that people aren't + // messing around with mixed casing and will assume false if the casing + // of the parent path differs. + return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase); + } + /// /// Test the equality of two values, based on PowerShell's equality checks, case insensitive for string values. /// Equivalent to -eq in PowerShell. @@ -192,6 +221,16 @@ public static bool ContainerExists(PSCmdlet cmdlet, string path) return cmdlet.InvokeProvider.Item.IsContainer(path); } + /// + /// Gets the current filesystem directory location in the session. + /// + /// The cmdlet calling the method. + /// The path to the current directory. + public static string GetCurrentDirectory(PSCmdlet cmdlet) + { + return cmdlet.SessionState.Path.CurrentFileSystemLocation?.ToString(); + } + /// /// Gets the parent directory of a given path. /// @@ -258,7 +297,13 @@ public static string GetUnresolvedPath(PSCmdlet cmdlet, string path) /// A containing the references to the item(s) created. public static Collection NewItem(PSCmdlet cmdlet, string path, string name, string itemType) { - return cmdlet.InvokeProvider.Item.New(path, name, itemType, content: string.Empty); + var shouldProcessPath = string.IsNullOrEmpty(name) ? path : CombinePaths(cmdlet, path, name); + if (cmdlet.ShouldProcess(shouldProcessPath, $"Create {itemType}")) + { + return cmdlet.InvokeProvider.Item.New(path, name, itemType, content: string.Empty); + } + + return new Collection(); } /// @@ -296,6 +341,99 @@ public static Collection NewDirectory(PSCmdlet cmdlet, string path) return NewItem(cmdlet, path, itemType: "Directory"); } + /// + /// Similar to -match, returns true if the matches the given string. + /// + /// The string to compare + /// The pattern to compare against + /// Whether to match the string case sensitively + /// True if the string matches, false otherwise + public static bool IsMatch(string input, string pattern, bool caseSensitive) + { + return Regex.IsMatch(input, pattern, caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase); + } + + /// + /// Similar to -match, returns true if the matches the given string, case-insensitively. + /// + /// The string to compare + /// The pattern to compare against + /// True if the string matches, false otherwise + public static bool IsMatch(string input, string pattern) + { + return IsMatch(input, pattern, caseSensitive: false); + } + + /// + /// Similar to -replace, performs a Regex replacement in the input string, optionally case-sensitive. + /// + /// The source string. + /// The Regex pattern string. + /// The replacement string. + /// Performs the replacement case-sensitively if true, otherwise ignores case. + /// The resulting string after the replacement has been performed. + public static string Replace(string input, string pattern, string replacement, bool caseSensitive) + { + return Regex.Replace(input, pattern, replacement, caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase); + } + + /// + /// Similar to -replace, performs a Regex replacement in the input string, case-insensitively. + /// + /// The source string. + /// The Regex pattern string. + /// The replacement string. + /// The resulting string after the replacement has been performed. + public static string Replace(string input, string pattern, string replacement) + { + return Replace(input, pattern, replacement, caseSensitive: false); + } + + /// + /// Creates or overwrites the file specified by with the provided . + /// + /// The calling cmdlet. + /// The path to the file to create or overwrite. + /// The new file content. + /// The encoding to write the file as. + /// Error writing to the file. + public static void SetContent(PSCmdlet cmdlet, string path, string content, Encoding encoding) + { + var fullPath = GetUnresolvedPath(cmdlet, path); + + if (cmdlet.ShouldProcess(path, "Write content to file")) + { + using (var writer = new StreamWriter(fullPath, append: false, encoding)) + { + try + { + writer.Write(content); + writer.Flush(); + } + catch (IOException) + { + throw; + } + } + } + } + + /// + /// Sets the exit code for when the PowerShell host exits. Note that this does not terminate the process, just updates the + /// exit code for when it is exited. + /// + /// The calling cmdlet. + /// The exit code to set. + public static void SetExitCode(PSCmdlet cmdlet, int exitCode) + { + if (cmdlet.ShouldProcess("exit code", $"Set exit code to {exitCode}")) + { + Environment.SetEnvironmentVariable(EnvironmentVariables.Package.ChocolateyExitCode, exitCode.ToString()); + cmdlet.Host.SetShouldExit(exitCode); + } + } + + /// /// Gets the path to the location of powershell.exe. /// diff --git a/src/Chocolatey.PowerShell/Shared/ChocolateyCmdlet.cs b/src/Chocolatey.PowerShell/Shared/ChocolateyCmdlet.cs index da28acbf6..037aca29b 100644 --- a/src/Chocolatey.PowerShell/Shared/ChocolateyCmdlet.cs +++ b/src/Chocolatey.PowerShell/Shared/ChocolateyCmdlet.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Management.Automation; using System.Text; +using System.Threading; using Chocolatey.PowerShell.Helpers; namespace Chocolatey.PowerShell.Shared @@ -39,6 +40,36 @@ public abstract class ChocolateyCmdlet : PSCmdlet // { "Deprecated-CommandName", "New-CommandName" }, }; + // These members are used to coordinate use of StopProcessing() + private readonly object _lock = new object(); + private readonly CancellationTokenSource _pipelineStopTokenSource = new CancellationTokenSource(); + + /// + /// A cancellation token that will be triggered when is called. + /// Use this cancellation token for any .NET methods called that accept a cancellation token, + /// and prefer overloads that accept a cancellation token. + /// This will allow Ctrl+C / to be handled appropriately by commands. + /// + protected CancellationToken PipelineStopToken + { + get + { + return _pipelineStopTokenSource.Token; + } + } + + /// + /// Convenience accessor for , the bound parameters for the + /// cmdlet call. + /// + protected Dictionary BoundParameters + { + get + { + return MyInvocation.BoundParameters; + } + } + /// /// The canonical error ID for the command to assist with traceability. /// For more specific error IDs where needed, use "{ErrorId}.EventName". @@ -96,7 +127,7 @@ protected sealed override void BeginProcessing() } /// - /// Override this method to define the cmdlet's begin {} block behaviour. + /// Override this method to define the cmdlet's begin {} block behaviour. /// Note that parameters that are defined as ValueFromPipeline or ValueFromPipelineByPropertyName /// will not be available for the duration of this method. /// @@ -110,7 +141,7 @@ protected sealed override void ProcessRecord() } /// - /// Override this method to define the cmdlet's process {} block behaviour. + /// Override this method to define the cmdlet's process {} block behaviour. /// This is called once for every item the cmdlet receives to a pipeline parameter, or only once if the value is supplied directly. /// Parameters that are defined as ValueFromPipeline or ValueFromPipelineByPropertyName will be available during this method call. /// @@ -125,7 +156,7 @@ protected sealed override void EndProcessing() } /// - /// Override this method to define the cmdlet's end {} block behaviour. + /// Override this method to define the cmdlet's end {} block behaviour. /// Note that parameters that are defined as ValueFromPipeline or ValueFromPipelineByPropertyName /// may not be available or have complete data during this method call. /// @@ -133,11 +164,54 @@ protected virtual void End() { } + protected sealed override void StopProcessing() + { + lock (_lock) + { + _pipelineStopTokenSource.Cancel(); + Stop(); + } + } + + /// + /// Override this method to define the cmdlet's behaviour when being asked to stop/cancel processing, + /// such as when Ctrl+C is pressed at the command line, a downstream cmdlet throws a terminating + /// error during a process {} block, or Select-Object -First $x is included after the + /// cmdlet in a pipeline. + /// This method will be called by , after an exclusive lock is obtained. + /// + /// + /// + /// + /// Do not call this method. calls this method as part of + /// handling. + /// + /// + /// The will be triggered before this method is called. + /// This method should be overridden only if the cmdlet implementing it has its own Stop or Dispose + /// behaviour that needs to be managed which are not dependent on the . + /// + /// + /// + protected virtual void Stop() + { + } + + + /// + /// Write a message directly to the host console, bypassing any output streams. + /// + /// The message to be written to the host console. protected void WriteHost(string message) { PSHelper.WriteHost(this, message); } + /// + /// Write an object to the pipeline, enumerating its contents. + /// Use to disable enumerating collections. + /// + /// The value(s) to write to the output pipeline. protected new void WriteObject(object value) { PSHelper.WriteObject(this, value); diff --git a/src/Chocolatey.PowerShell/Shared/ProcessHandler.cs b/src/Chocolatey.PowerShell/Shared/ProcessHandler.cs new file mode 100644 index 000000000..54737a6e7 --- /dev/null +++ b/src/Chocolatey.PowerShell/Shared/ProcessHandler.cs @@ -0,0 +1,274 @@ +// Copyright © 2017 - 2025 Chocolatey Software, Inc +// Copyright © 2011 - 2017 RealDimensions Software, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Chocolatey.PowerShell.Helpers; +using System; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Management.Automation; +using System.Threading; + +namespace Chocolatey.PowerShell.Shared +{ + /// + /// Base class for handling console applications that need to be called from PowerShell, + /// ensuring that their output is redirected correctly. + /// + /// Each instance of this class may be used to handle operations for a single process. + /// + public abstract class ProcessHandler + { + private bool _started; + + /// + /// The underlying object. + /// + protected Process Process { get; private set; } + + /// + /// The blocking collection used to handle output messages from the process. + /// In order to customise how these messages are handled, override the virtual method. + /// + protected BlockingCollection ProcessMessages; + + /// + /// The original cmdlet used to instantiate and invoke the process. + /// + protected readonly PSCmdlet Cmdlet; + + /// + /// The cancellation token that indicates when to stop processing messages from the process. This will be set by + /// the calling cmdlet in order to properly respond to Ctrl+C or requests. + /// Triggering this token will cause the underlying process to be disposed. + /// + protected readonly CancellationToken CancellationToken; + + /// + /// Instantiates a new for a given using its . + /// + /// The cmdlet invoking the process. + /// The cmdlet's . + public ProcessHandler(PSCmdlet cmdlet, CancellationToken pipelineStopToken) + { + Cmdlet = cmdlet; + CancellationToken = pipelineStopToken; + } + + /// + /// Starts the given process by name or path, with the provided arguments. Does not return until the process terminates. + /// + /// The name or path of the process to start. + /// The working directory to start the process in. + /// Arguments to pass to the process. These will be logged. + /// Sensitive arguments to pass to the process. These will not be logged. + /// Whether to attempt elevation. This currently cannot elevate processes from a non-elevated context. + /// Whether to show windows for the process. + /// Whether to run in the current window. + /// The exit code from the process. + /// Thrown if the process has already been started. + /// Thrown if is not provided or empty. + /// Thrown if the executable could not be opened. + protected int Run(string processName, string workingDirectory, string arguments, string sensitiveStatements, bool elevated, ProcessWindowStyle windowStyle, bool noNewWindow) + { + if (_started) + { + throw new InvalidOperationException("A process has already been started."); + } + + if (string.IsNullOrWhiteSpace(processName)) + { + throw new ArgumentNullException(nameof(processName), "No process name was provided."); + } + + _started = true; + + var alreadyElevated = ProcessInformation.IsElevated(); + + var exitCode = 0; + Process = new Process + { + EnableRaisingEvents = true, + StartInfo = new ProcessStartInfo + { + FileName = processName, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + WorkingDirectory = workingDirectory, + WindowStyle = windowStyle, + CreateNoWindow = noNewWindow, + }, + }; + + using (Process) + { + if (!string.IsNullOrWhiteSpace(arguments)) + { + Process.StartInfo.Arguments = arguments; + } + + if (!string.IsNullOrWhiteSpace(sensitiveStatements)) + { + PSHelper.WriteHost(Cmdlet, "Sensitive arguments have been passed. Adding to arguments."); + Process.StartInfo.Arguments += " " + sensitiveStatements; + } + + if (elevated && !alreadyElevated && Environment.OSVersion.Version > new Version(6, 0)) + { + // SELF-ELEVATION: This currently doesn't work as we're not using ShellExecute + Cmdlet.WriteDebug("Setting RunAs for elevation"); + Process.StartInfo.Verb = "RunAs"; + } + + Process.OutputDataReceived += ProcessOutputHandler; + Process.ErrorDataReceived += ProcessErrorHandler; + + // process.WaitForExit() is a bit unreliable, we use the Exiting event handler to register when + // the process exits. + Process.Exited += ProcessExitingHandler; + + try + { + ProcessMessages = new BlockingCollection(); + Process.Start(); + Process.BeginOutputReadLine(); + Process.BeginErrorReadLine(); + + Cmdlet.WriteDebug("Waiting for process to exit"); + + // This will handle dispatching output/error messages until either the process has exited or the pipeline + // has been cancelled. + HandleProcessMessages(); + + exitCode = Process.ExitCode; + + Cmdlet.WriteDebug($"Command [\"{Process}\" {arguments}] exited with '{exitCode}'."); + + return exitCode; + } + catch (Win32Exception error) + { + throw new IOException($"There was an error starting the target process '{processName}': {error.Message}", error); + } + catch (ObjectDisposedException error) + { + // This means that something has disposed the process object before we could start it. + // This would typically mean that Ctrl+C / StopProcessing() has been called on the + // cmdlet before we got here, but after we created the Process object. + throw new OperationCanceledException($"The current operation was cancelled before the process could be started.", error); + } + } + } + + /// + /// This method is called after the process has been started and should not return until the + /// collection has been completely exhausted, or cancelled via + /// the . + /// + /// + /// In most cases, overriding this should not be necessary. Additional logic for handling + /// stdout or stderr can be implemented by registering handlers to + /// or , which will be invoked automatically as those messages + /// are emitted. Override this only if you need to disable or modify the behaviour of writing the + /// process' messages to Error or Verbose streams. + /// + protected virtual void HandleProcessMessages() + { + if (ProcessMessages is null) + { + return; + } + + // Use of the CancellationToken allows us to respect calls for StopProcessing() correctly. + foreach (var item in ProcessMessages.GetConsumingEnumerable(CancellationToken)) + { + if (item.StdErr) + { + Cmdlet.WriteError(new RuntimeException(item.Message).ErrorRecord); + } + else + { + Cmdlet.WriteVerbose(item.Message); + } + } + } + + /// + /// Raised when the underlying exits. + /// + protected event EventHandler ProcessExited; + + /// + /// Raised when the underlying emits non-null/empty data to stdout. + /// + protected event EventHandler ProcessOutputReceived; + + /// + /// Raised when the underlying emits non-null/empty data to stderr. + /// + protected event EventHandler ProcessErrorDataReceived; + + private void ProcessExitingHandler(object sender, EventArgs e) + { + ProcessMessages?.CompleteAdding(); + ProcessExited?.Invoke(sender, e); + } + + private void ProcessOutputHandler(object sender, DataReceivedEventArgs e) + { + if (!string.IsNullOrEmpty(e.Data)) + { + var message = new ProcessOutput(e.Data, isStdErr: false); + ProcessMessages?.Add(message); + ProcessOutputReceived?.Invoke(sender, message); + } + } + + private void ProcessErrorHandler(object sender, DataReceivedEventArgs e) + { + if (!(e.Data is null)) + { + var message = new ProcessOutput(e.Data, isStdErr: true); + ProcessMessages?.Add(message); + ProcessErrorDataReceived?.Invoke(sender, message); + } + } + + /// + /// The event args passed to and . + /// + protected class ProcessOutput : EventArgs + { + public ProcessOutput(string message, bool isStdErr) + { + Message = message; + StdErr = isStdErr; + } + + /// + /// The message string passed to stdout or stderr. + /// + public string Message { get; private set; } + + /// + /// True if the message came from stderr. + /// + public bool StdErr { get; private set; } + } + } +} diff --git a/src/Chocolatey.PowerShell/Shared/SevenZipException.cs b/src/Chocolatey.PowerShell/Shared/SevenZipException.cs new file mode 100644 index 000000000..97e733a08 --- /dev/null +++ b/src/Chocolatey.PowerShell/Shared/SevenZipException.cs @@ -0,0 +1,43 @@ +// Copyright © 2017 - 2025 Chocolatey Software, Inc +// Copyright © 2011 - 2017 RealDimensions Software, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Runtime.Serialization; + +namespace Chocolatey.PowerShell.Helpers +{ + /// + /// Exception class for wrapping and surfacing errors from 7-Zip exit codes. + /// + public class SevenZipException : Exception + { + public SevenZipException() + { + } + + public SevenZipException(string message) : base(message) + { + } + + public SevenZipException(string message, Exception innerException) : base(message, innerException) + { + } + + protected SevenZipException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + } +} diff --git a/src/chocolatey.resources/helpers/chocolateyInstaller.psm1 b/src/chocolatey.resources/helpers/chocolateyInstaller.psm1 index a350aedb9..ca739b4b1 100644 --- a/src/chocolatey.resources/helpers/chocolateyInstaller.psm1 +++ b/src/chocolatey.resources/helpers/chocolateyInstaller.psm1 @@ -135,6 +135,7 @@ if (Test-Path $extensionsPath) { } Set-Alias -Name 'Get-CheckSumValid' -Value 'Assert-ValidChecksum' +Set-Alias -Name 'Get-ChocolateyUnzip' -Value 'Expand-ChocolateyArchive' # Exercise caution and test _thoroughly_ with AND without the licensed extension installed # when making any changes here. And make sure to update this comment if needed when any diff --git a/src/chocolatey/StringResources.cs b/src/chocolatey/StringResources.cs index afe6b0130..4150f06a6 100644 --- a/src/chocolatey/StringResources.cs +++ b/src/chocolatey/StringResources.cs @@ -149,6 +149,13 @@ public static class Package /// public const string ChocolateyResponseTimeout = "chocolateyResponseTimeout"; + /// + /// Whether the use of 7-Zip when unpacking zip/etc archives during package installation should be disabled. + /// This is primarily used by Expand-Chocolatey-Archive and Install-ChocolateyZipPackage. + /// + /// The fallback is use of , which will have limitations and mainly work on zip archives. + public const string ChocolateyUseBuiltinCompression = nameof(ChocolateyUseBuiltinCompression); + /// /// The 4-part assembly version number of Chocolatey CLI that the user is currently using. /// @@ -293,6 +300,14 @@ public static class Package [Browsable(false)] internal const string ChocolateyInstallArguments = "chocolateyInstallArguments"; + /// + /// Not used by Chocolatey CLI, but here as a reference point as it is set by Chocolatey Licensed Extension + /// when using the --install-directory option. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [Browsable(false)] + internal const string ChocolateyInstallDirectoryPackage = nameof(ChocolateyInstallDirectoryPackage); + /// /// The identified type of the installer the package uses during installation. /// diff --git a/src/chocolatey/infrastructure.app/builders/ConfigurationBuilder.cs b/src/chocolatey/infrastructure.app/builders/ConfigurationBuilder.cs index d4358bce7..3e183f042 100644 --- a/src/chocolatey/infrastructure.app/builders/ConfigurationBuilder.cs +++ b/src/chocolatey/infrastructure.app/builders/ConfigurationBuilder.cs @@ -464,6 +464,9 @@ private static void SetGlobalOptions(IList args, ChocolateyConfiguration .Add("skipcompatibilitychecks|skip-compatibility-checks", "SkipCompatibilityChecks - Prevent warnings being shown before and after command execution when a runtime compatibility problem is found between the version of Chocolatey and the Chocolatey Licensed Extension.", option => config.DisableCompatibilityChecks = option != null) + .Add("use-builtin-compression", + "UseBuiltinCompression - Use builtin compression routines rather than 7-Zip for extracting archives during package installations. Available in 3.0.0+", + option => config.Features.UseBuiltinCompression = option != null) .Add(StringResources.Options.IgnoreHttpCache, StringResources.OptionDescriptions.IgnoreHttpCache, option => diff --git a/src/chocolatey/infrastructure.app/configuration/ChocolateyConfiguration.cs b/src/chocolatey/infrastructure.app/configuration/ChocolateyConfiguration.cs index c0bfe9f9c..9538304d3 100644 --- a/src/chocolatey/infrastructure.app/configuration/ChocolateyConfiguration.cs +++ b/src/chocolatey/infrastructure.app/configuration/ChocolateyConfiguration.cs @@ -599,6 +599,7 @@ public sealed class FeaturesConfiguration public bool RemovePackageInformationOnUninstall { get; set; } public bool ExitOnRebootDetected { get; set; } public bool LogValidationResultsOnWarnings { get; set; } + public bool UseBuiltinCompression { get; set; } public bool UsePackageRepositoryOptimizations { get; set; } public bool UsePackageHashValidation { get; set; } } diff --git a/src/chocolatey/infrastructure.app/configuration/EnvironmentSettings.cs b/src/chocolatey/infrastructure.app/configuration/EnvironmentSettings.cs index 7eb5d2629..d75a198c7 100644 --- a/src/chocolatey/infrastructure.app/configuration/EnvironmentSettings.cs +++ b/src/chocolatey/infrastructure.app/configuration/EnvironmentSettings.cs @@ -124,6 +124,11 @@ public static void SetEnvironmentVariables(ChocolateyConfiguration config) Environment.SetEnvironmentVariable(EnvironmentVariables.Package.ChocolateyAllowEmptyChecksumsSecure, "true"); } + if (config.Features.UseBuiltinCompression) + { + Environment.SetEnvironmentVariable(EnvironmentVariables.Package.ChocolateyUseBuiltinCompression, "true"); + } + Environment.SetEnvironmentVariable(EnvironmentVariables.Package.ChocolateyRequestTimeout, config.WebRequestTimeoutSeconds.ToStringSafe() + "000"); if (config.CommandExecutionTimeoutSeconds != 0) From be917242bc7defca8fa2b5fd68e5203db4ee1725 Mon Sep 17 00:00:00 2001 From: Rain Sallow Date: Fri, 22 Aug 2025 17:12:46 -0400 Subject: [PATCH 2/2] (#3477) Add tests for Expand-ChocolateyArchive --- .../Expand-ChocolateyArchive.Tests.ps1 | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 tests/pester-tests/powershell-commands/Expand-ChocolateyArchive.Tests.ps1 diff --git a/tests/pester-tests/powershell-commands/Expand-ChocolateyArchive.Tests.ps1 b/tests/pester-tests/powershell-commands/Expand-ChocolateyArchive.Tests.ps1 new file mode 100644 index 000000000..1e425a86e --- /dev/null +++ b/tests/pester-tests/powershell-commands/Expand-ChocolateyArchive.Tests.ps1 @@ -0,0 +1,258 @@ +Describe 'Expand-ChocolateyArchive helper function tests' -Tags ExpandChocolateyArchive, Cmdlets { + BeforeAll { + Initialize-ChocolateyTestInstall + + $testLocation = Get-ChocolateyTestLocation + } + + Context 'Unit tests' -Tags WhatIf { + BeforeAll { + $Guid = New-Guid + $Path = "$env:TEMP\$Guid.zip" + $Path64 = "$env:TEMP\$Guid-x64.zip" + $Destination = "$env:TEMP\$Guid" + $LogPath = "$env:TEMP\$Guid-packagefolder" + $PackageName = "$Guid" + + $tempFile = New-Item -Path $Path + $tempFile64 = New-Item -Path $Path64 + } + + AfterAll { + $tempFile, $tempFile64 | Remove-Item -Force + } + + It 'extracts the target zip file specified by <_> to the expected location' -TestCases @('-Path', '-Path64') { + $Preamble = [scriptblock]::Create("Import-Module '$testLocation\helpers\chocolateyInstaller.psm1'") + $Command = [scriptblock]::Create("Expand-ChocolateyArchive $_ '$Path' -Destination '$Destination' -WhatIf") + + $expectedResults = @( + "What if: Performing the operation `"Create Directory`" on target `"$Destination`"." + "What if: Performing the operation `"Extract zip file contents to '$Destination' with 7-Zip`" on target `"$Path`"." + ) + + $results = Get-WhatIfResult -Preamble $Preamble -Command $Command + $results.WhatIf | Should -BeExactly $expectedResults + } + + It 'always uses -Path if the chocolateyForceX86 environment variable is set' { + $Preamble = [scriptblock]::Create(@" + `$env:chocolateyForceX86 = 'true' + Import-Module '$testLocation\helpers\chocolateyInstaller.psm1' +"@) + $Command = [scriptblock]::Create("Expand-ChocolateyArchive -Path '$Path' -Path64 '$Path64' -Destination '$Destination' -WhatIf") + $results = Get-WhatIfResult -Preamble $Preamble -Command $Command + + $expectedResults = @( + "What if: Performing the operation `"Create Directory`" on target `"$Destination`"." + "What if: Performing the operation `"Extract zip file contents to '$Destination' with 7-Zip`" on target `"$Path`"." + ) + $results.WhatIf | Should -BeExactly $expectedResults + } + + It 'creates a lib folder to store the log files if needed when -PackageName is set' { + $Preamble = [scriptblock]::Create(@" + Import-Module '$testLocation\helpers\chocolateyInstaller.psm1' + `$env:chocolateyPackageFolder = '$LogPath' +"@) + $Command = [scriptblock]::Create("Expand-ChocolateyArchive -Path '$Path' -Destination '$Destination' -PackageName $PackageName -WhatIf") + $results = Get-WhatIfResult -Preamble $Preamble -Command $Command + + $expectedResult = "What if: Performing the operation `"Create Directory`" on target `"$LogPath`"." + $results.WhatIf | Should -Contain $expectedResult + } + + It 'creates a lib folder to store the log files if needed when chocolateyPackageName environment variable is set' { + $Preamble = [scriptblock]::Create(@" + Import-Module '$testLocation\helpers\chocolateyInstaller.psm1' + `$env:chocolateyPackageFolder = '$LogPath' + `$env:chocolateyPackageName = '$PackageName' +"@) + $Command = [scriptblock]::Create("Expand-ChocolateyArchive -Path '$Path' -Destination '$Destination' -WhatIf") + $results = Get-WhatIfResult -Preamble $Preamble -Command $Command + + $expectedResult = "What if: Performing the operation `"Create Directory`" on target `"$LogPath`"." + $results.WhatIf | Should -Contain $expectedResult + } + + It 'uses 7zip to decompress by default' { + $Preamble = [scriptblock]::Create("Import-Module '$testLocation\helpers\chocolateyInstaller.psm1'") + $Command = [scriptblock]::Create("Expand-ChocolateyArchive -Path '$Path' -Destination '$Destination' -WhatIf") + $results = Get-WhatIfResult -Preamble $Preamble -Command $Command + + $expectedResult = "What if: Performing the operation `"Extract zip file contents to '$Destination' with 7-Zip`" on target `"$Path`"." + $results.WhatIf | Should -Contain $expectedResult + } + + It 'will use the fallback builtin extraction method if using -UseBuiltinCompression' { + $Preamble = [scriptblock]::Create("Import-Module '$testLocation\helpers\chocolateyInstaller.psm1'") + $Command = [scriptblock]::Create("Expand-ChocolateyArchive -Path '$Path' -Destination '$Destination' -UseBuiltInCompression -WhatIf") + $results = Get-WhatIfResult -Preamble $Preamble -Command $Command + + $expectedResult = "What if: Performing the operation `"Extract zip file contents to '$Destination' with built-in decompression`" on target `"$Path`"." + $results.WhatIf | Should -Contain $expectedResult + } + + It 'will use the fallback builtin extraction method if specified by chocolateyUseBuiltinCompression environment variable' { + $Preamble = [scriptblock]::Create(@" + Import-Module '$testLocation\helpers\chocolateyInstaller.psm1' + `$env:chocolateyUseBuiltinCompression = 'true' +"@) + $Command = [scriptblock]::Create("Expand-ChocolateyArchive -Path '$Path' -Destination '$Destination' -WhatIf") + $results = Get-WhatIfResult -Preamble $Preamble -Command $Command + + $expectedResult = "What if: Performing the operation `"Extract zip file contents to '$Destination' with built-in decompression`" on target `"$Path`"." + $results.WhatIf | Should -Contain $expectedResult + } + + It 'applies a -FilesToExtract filter when provided' { + $Preamble = [scriptblock]::Create("Import-Module '$testLocation\helpers\chocolateyInstaller.psm1'") + $Command = [scriptblock]::Create("Expand-ChocolateyArchive -Path '$Path' -Destination '$Destination' -WhatIf -FilesToExtract '*.exe'") + + $expectedResults = @( + "What if: Performing the operation `"Create Directory`" on target `"$Destination`"." + "What if: Performing the operation `"Extract zip file contents matching pattern *.exe to '$Destination' with 7-Zip`" on target `"$Path`"." + ) + + $results = Get-WhatIfResult -Preamble $Preamble -Command $Command + $results.WhatIf | Should -BeExactly $expectedResults + } + } + + Context 'Integration tests' { + BeforeAll { + Import-Module "$testLocation\helpers\chocolateyInstaller.psm1" -Force + + $Guid = New-Guid + $Path = "$env:TEMP\$Guid.zip" + $Path64 = "$env:TEMP\$Guid-x64.zip" + $Destination = "$env:TEMP\$Guid" + + $LogPath = "$env:TEMP\$Guid-packagefolder" + $PackageName = "$Guid" + + $TempFiles = 1..10 | ForEach-Object { @{ Id = $_; File = New-TemporaryFile } } + foreach ($file in $TempFiles) { + # Populate the file with random data + $file.Content = 1..100 | ForEach-Object { (New-Guid).ToString() } + $file.Content | Set-Content -Path $file.File + } + + Compress-Archive -Path $TempFiles.File.FullName -DestinationPath $Path + New-Item -ItemType Directory -Path $LogPath > $null + + $env:chocolateyPackageName = $PackageName + $env:chocolateyPackageFolder = $LogPath + } + + AfterAll { + $cleanupItems = @( + $TempFiles.File.FullName + $Path + $Destination + $LogPath + ) + Remove-Item -Path $cleanupItems -Force -Recurse -ErrorAction Ignore + + $env:chocolateyPackageName = '' + $env:chocolateyPackageFolder = '' + } + + Describe 'Invalid parameters' { + BeforeAll { + $env:chocolateyForceX86 = 'true' + } + + AfterAll { + $env:chocolateyForceX86 = '' + } + + It 'throws an error if using only -Path64 with the chocolateyForceX86 environment variable set' { + { Expand-ChocolateyArchive -Path64 $Path64 -Destination $Destination } | + Should -Throw -ExpectedMessage "32-bit archive is not supported for $PackageName" -ExceptionType 'System.NotSupportedException' + } + } + + Describe 'Using decompression' -ForEach @( + @{ Mode = '7zip' } + @{ Mode = 'fallback' } + ) { + Context 'No filter' { + It 'completes successfully and returns the destination path' { + $params = @{ + Path = $Path + Destination = $Destination + UseBuiltinCompression = $Mode -eq 'fallback' + } + $result = Expand-ChocolateyArchive @params + + $result | Should -BeExactly $Destination + } + + It 'extracts the files from the archive' { + $extractedFiles = Get-ChildItem -Path $Destination + $extractedFiles.Count | Should -Be $TempFiles.Count + + foreach ($file in $extractedFiles) { + $expectedFile = $TempFiles | Where-Object { $_.File.Name -eq $file.Name } + $expectedFile | Should -Not -BeNullOrEmpty -Because 'we should not have any unexpected files extracted' + + $content = Get-Content -Path $file.FullName + $content | Should -BeExactly $expectedFile.Content -Because "$($file.Name) should have the same content as it originally did" + } + } + + It 'writes a log file to the package folder' { + $log = Get-ChildItem -Path $LogPath -File + + $log | Should -Not -BeNullOrEmpty -Because 'the command should have written an extraction log' + + $expectedContent = $TempFiles.File.Name | ForEach-Object { Join-Path $Destination -ChildPath $_ } + Get-Content -Path $log | Should -BeExactly $expectedContent + } + } + + Context 'Filter for specific files' { + BeforeAll { + $TargetFile = $TempFiles | Select-Object -First 1 + $SpecificDestination = "$Destination-test" + } + + AfterAll { + Remove-Item -Path $SpecificDestination -Force -Recurse -ErrorAction Ignore + } + + It 'completes successfully and returns the destination path' { + $params = @{ + Path = $Path + Destination = $SpecificDestination + UseBuiltinCompression = $Mode -eq 'fallback' + FilesToExtract = $TargetFile.File.Name + } + $result = Expand-ChocolateyArchive @params + + $result | Should -BeExactly $SpecificDestination + } + + It 'extracts the file from the archive' { + $extractedFiles = Get-ChildItem -Path $SpecificDestination + @($extractedFiles).Count | Should -Be 1 + + $extractedFiles.Name | Should -BeExactly $TargetFile.File.Name + + $extractedContent = Get-Content -Path $extractedFiles.FullName + $extractedContent | Should -BeExactly $TargetFile.Content -Because "$($extractedFiles.Name) should have the same content as it originally did" + } + + It 'writes a log file to the package folder' { + $log = Get-ChildItem -Path $LogPath -File + + $log | Should -Not -BeNullOrEmpty -Because 'the command should have written an extraction log' + + $expectedContent = Join-Path $SpecificDestination -ChildPath $TargetFile.File.Name + Get-Content -Path $log | Should -BeExactly $expectedContent + } + } + } + } +} \ No newline at end of file