From f83ccddf80100516019bfb0a2fad9411bb1b62ed Mon Sep 17 00:00:00 2001 From: Kirtikumar Anandrao Ramchandani <33368817+KirtiRamchandani@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:05:04 +0530 Subject: [PATCH] Add New-TemporaryDirectory cmdlet (#27549) Co-authored-by: Justin Chung <124807742+jshigetomi@users.noreply.github.com> --- .../utility/NewTemporaryDirectoryCommand.cs | 113 ++++++++++++++++++ .../Microsoft.PowerShell.Utility.psd1 | 2 +- .../Microsoft.PowerShell.Utility.psd1 | 2 +- .../New-TemporaryDirectory.Tests.ps1 | 110 +++++++++++++++++ .../engine/Basic/DefaultCommands.Tests.ps1 | 1 + 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.PowerShell.Commands.Utility/commands/utility/NewTemporaryDirectoryCommand.cs create mode 100644 test/powershell/Modules/Microsoft.PowerShell.Utility/New-TemporaryDirectory.Tests.ps1 diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/NewTemporaryDirectoryCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/NewTemporaryDirectoryCommand.cs new file mode 100644 index 000000000..6a19c0792 --- /dev/null +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/NewTemporaryDirectoryCommand.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using System.Management.Automation; + +namespace Microsoft.PowerShell.Commands +{ + /// + /// The implementation of the "New-TemporaryDirectory" cmdlet. + /// + [Cmdlet(VerbsCommon.New, "TemporaryDirectory", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low, HelpUri = "https://go.microsoft.com/fwlink/?LinkId=2097032")] + [OutputType(typeof(System.IO.DirectoryInfo))] + public class NewTemporaryDirectoryCommand : Cmdlet + { + private const string NewTemporaryDirectoryInvalidPrefix = "NewTemporaryDirectoryInvalidPrefix"; + private const string NewTemporaryDirectoryWriteError = "NewTemporaryDirectoryWriteError"; + + /// + /// Gets or sets an optional prefix for the temporary directory name. + /// + [Parameter] + [ValidateNotNullOrEmpty] + public string Prefix { get; set; } + + /// + /// Returns a temporary directory. + /// + protected override void EndProcessing() + { + string tempPath = Path.GetTempPath(); + + if (!string.IsNullOrEmpty(Prefix) && IsInvalidPrefix(Prefix)) + { + ThrowTerminatingError( + CreateErrorRecord( + new System.ArgumentException("The prefix contains invalid characters.", nameof(Prefix)), + NewTemporaryDirectoryInvalidPrefix, + ErrorCategory.InvalidArgument, + Prefix)); + return; + } + + string targetDescription = string.IsNullOrEmpty(Prefix) + ? $"temporary directory under '{tempPath}'" + : $"temporary directory under '{tempPath}' with prefix '{Prefix}'"; + + if (!ShouldProcess(targetDescription, "Create temporary directory")) + { + return; + } + + try + { + DirectoryInfo createdDirectory = string.IsNullOrEmpty(Prefix) + ? Directory.CreateTempSubdirectory() + : Directory.CreateTempSubdirectory(Prefix); + + WriteObject(createdDirectory); + } + catch (IOException ioException) + { + ThrowTerminatingError( + CreateErrorRecord( + ioException, + NewTemporaryDirectoryWriteError, + ErrorCategory.WriteError, + targetDescription)); + } + catch (System.UnauthorizedAccessException unauthorizedAccessException) + { + ThrowTerminatingError( + CreateErrorRecord( + unauthorizedAccessException, + NewTemporaryDirectoryWriteError, + ErrorCategory.PermissionDenied, + targetDescription)); + } + } + + private static bool IsInvalidPrefix(string prefix) + { + if (prefix.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + return true; + } + + foreach (char prefixCharacter in prefix) + { + if (prefixCharacter is '/' or '\\' || char.IsControl(prefixCharacter)) + { + return true; + } + } + + return prefix.Equals(".", System.StringComparison.Ordinal) + || prefix.Equals("..", System.StringComparison.Ordinal); + } + + private static ErrorRecord CreateErrorRecord( + System.Exception exception, + string errorId, + ErrorCategory category, + object targetObject) + { + return new ErrorRecord( + exception, + errorId, + category, + targetObject); + } + } +} diff --git a/src/Modules/Unix/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 b/src/Modules/Unix/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 index df8418376..477dc1c5a 100644 --- a/src/Modules/Unix/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 +++ b/src/Modules/Unix/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 @@ -21,7 +21,7 @@ CmdletsToExport = @( 'Get-PSBreakpoint', 'Remove-PSBreakpoint', 'Set-PSBreakpoint', 'Get-PSCallStack', 'Export-PSSession', 'Import-PSSession', 'Get-Random', 'Get-SecureRandom', 'Invoke-RestMethod', 'Debug-Runspace', 'Get-Runspace', 'Disable-RunspaceDebug', 'Enable-RunspaceDebug', 'Get-RunspaceDebug', 'Start-Sleep', 'Join-String', - 'Out-String', 'Select-String', 'ConvertFrom-StringData', 'Format-Table', 'New-TemporaryFile', 'New-TimeSpan', + 'Out-String', 'Select-String', 'ConvertFrom-StringData', 'Format-Table', 'New-TemporaryDirectory', 'New-TemporaryFile', 'New-TimeSpan', 'Get-TraceSource', 'Set-TraceSource', 'Add-Type', 'Get-TypeData', 'Remove-TypeData', 'Update-TypeData', 'Get-UICulture', 'Get-Unique', 'Get-Uptime', 'Clear-Variable', 'Get-Variable', 'New-Variable', 'Remove-Variable', 'Set-Variable', 'Get-Verb', 'Write-Verbose', 'Write-Warning', 'Invoke-WebRequest', diff --git a/src/Modules/Windows/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 b/src/Modules/Windows/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 index 2043543a8..64813ba5b 100644 --- a/src/Modules/Windows/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 +++ b/src/Modules/Windows/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 @@ -20,7 +20,7 @@ CmdletsToExport = @( 'Remove-PSBreakpoint', 'Set-PSBreakpoint', 'Get-PSCallStack', 'Export-PSSession', 'Import-PSSession', 'Get-Random', 'Get-SecureRandom' 'Invoke-RestMethod', 'Debug-Runspace', 'Get-Runspace', 'Disable-RunspaceDebug', 'Enable-RunspaceDebug', 'Get-RunspaceDebug', 'ConvertFrom-SddlString', 'Start-Sleep', 'Join-String', 'Out-String', 'Select-String', - 'ConvertFrom-StringData', 'Format-Table', 'New-TemporaryFile', 'New-TimeSpan', 'Get-TraceSource', 'Set-TraceSource', + 'ConvertFrom-StringData', 'Format-Table', 'New-TemporaryDirectory', 'New-TemporaryFile', 'New-TimeSpan', 'Get-TraceSource', 'Set-TraceSource', 'Add-Type', 'Get-TypeData', 'Remove-TypeData', 'Update-TypeData', 'Get-UICulture', 'Get-Unique', 'Get-Uptime', 'Clear-Variable', 'Get-Variable', 'New-Variable', 'Remove-Variable', 'Set-Variable', 'Get-Verb', 'Write-Verbose', 'Write-Warning', 'Invoke-WebRequest', 'Format-Wide', 'ConvertTo-Xml', 'Select-Xml', 'Get-Error', 'Update-List', diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/New-TemporaryDirectory.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/New-TemporaryDirectory.Tests.ps1 new file mode 100644 index 000000000..333e8db5b --- /dev/null +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/New-TemporaryDirectory.Tests.ps1 @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe "New-TemporaryDirectory" -Tags "CI" { + BeforeEach { + $tempDirectory = $null + } + + AfterEach { + if ($tempDirectory -and (Test-Path -LiteralPath $tempDirectory.FullName)) { + Remove-Item -LiteralPath $tempDirectory.FullName -Force -Recurse -ErrorAction SilentlyContinue + } + } + + It "creates a new temporary directory" { + $tempDirectory = New-TemporaryDirectory + + $tempDirectory | Should -Exist + $tempDirectory | Should -BeOfType System.IO.DirectoryInfo + $tempDirectory.FullName | Should -BeLikeExactly "$([System.IO.Path]::GetTempPath())*" + (Get-Item -LiteralPath $tempDirectory.FullName).PSIsContainer | Should -BeTrue + } + + It "creates unique temporary directories" { + $tempDirectory = New-TemporaryDirectory + $secondTempDirectory = New-TemporaryDirectory + + try { + $secondTempDirectory.FullName | Should -Not -BeExactly $tempDirectory.FullName + $secondTempDirectory | Should -Exist + $secondTempDirectory | Should -BeOfType System.IO.DirectoryInfo + } + finally { + if ($secondTempDirectory -and (Test-Path -LiteralPath $secondTempDirectory.FullName)) { + Remove-Item -LiteralPath $secondTempDirectory.FullName -Force -Recurse -ErrorAction SilentlyContinue + } + } + } + + It "with WhatIf does not create a directory" { + New-TemporaryDirectory -WhatIf | Should -BeNullOrEmpty + } + + It "with WhatIf and Prefix does not create a directory" { + New-TemporaryDirectory -Prefix "Test_" -WhatIf | Should -BeNullOrEmpty + } + + It "with WhatIf and Prefix reports a descriptive target" { + $prefix = "Test_" + $rawTarget = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), $prefix) + $whatIfOutput = & "$PSHOME/pwsh" -NoProfile -CommandWithArgs 'param($prefix) New-TemporaryDirectory -Prefix $prefix -WhatIf' $prefix 2>&1 | Out-String + + $whatIfOutput | Should -Not -Match ([regex]::Escape("`"$rawTarget`"")) + $whatIfOutput | Should -Match ([regex]::Escape("with prefix '$prefix'")) + } + + It "rejects special relative path Prefix ''" -TestCases @( + @{ Prefix = "." } + @{ Prefix = ".." } + ) { + param($Prefix) + + { New-TemporaryDirectory -Prefix $Prefix -ErrorAction Stop } | Should -Throw -ErrorId "NewTemporaryDirectoryInvalidPrefix,Microsoft.PowerShell.Commands.NewTemporaryDirectoryCommand" + } + + It "rejects separator-like Prefix ''" -TestCases @( + @{ Prefix = "path/name" } + @{ Prefix = "path\name" } + @{ Prefix = "../name" } + @{ Prefix = "..\name" } + ) { + param($Prefix) + + { New-TemporaryDirectory -Prefix $Prefix -ErrorAction Stop } | Should -Throw -ErrorId "NewTemporaryDirectoryInvalidPrefix,Microsoft.PowerShell.Commands.NewTemporaryDirectoryCommand" + } + + It "rejects Prefix with control character ''" -TestCases @( + @{ CharacterCode = 10 } + @{ CharacterCode = 13 } + @{ CharacterCode = 9 } + @{ CharacterCode = 27 } + ) { + param($CharacterCode) + + $Prefix = "bad$([char]$CharacterCode)name" + { New-TemporaryDirectory -Prefix $Prefix -ErrorAction Stop } | Should -Throw -ErrorId "NewTemporaryDirectoryInvalidPrefix,Microsoft.PowerShell.Commands.NewTemporaryDirectoryCommand" + } + + It "has an OutputType of System.IO.DirectoryInfo" { + (Get-Command New-TemporaryDirectory).OutputType.Name | Should -Contain "System.IO.DirectoryInfo" + } + + It "creates a directory with custom prefix" { + $prefix = "MyApp" + $tempDirectory = New-TemporaryDirectory -Prefix $prefix + + $tempDirectory | Should -Exist + $tempDirectory | Should -BeOfType System.IO.DirectoryInfo + $tempDirectory.Name | Should -BeLike "$prefix*" + } + + It "rejects Prefix with invalid file name characters" { + $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() + + if ($invalidChars.Count -gt 0) { + $badPrefix = "bad$($invalidChars[0])prefix" + { New-TemporaryDirectory -Prefix $badPrefix -ErrorAction Stop } | Should -Throw -ErrorId "NewTemporaryDirectoryInvalidPrefix,Microsoft.PowerShell.Commands.NewTemporaryDirectoryCommand" + } + } +} diff --git a/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 b/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 index 7a757e6ea..f69ba01a0 100644 --- a/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 +++ b/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 @@ -401,6 +401,7 @@ Describe "Verify aliases and cmdlets" -Tags "CI" { "Cmdlet", "New-PSSessionOption", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "New-PSTransportOption", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "New-Service", "", $($FullCLR -or $CoreWindows ), "", "", "Medium" +"Cmdlet", "New-TemporaryDirectory", "", $( $CoreWindows -or $CoreUnix), "", "", "Low" "Cmdlet", "New-TemporaryFile", "", $( $CoreWindows -or $CoreUnix), "", "", "Low" "Cmdlet", "New-TimeSpan", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "New-Variable", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "Low"