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)
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