Split-Path works with uri, but Join-Path does not
Prerequisites
- [X] Write a descriptive title.
- [X] Make sure you are able to repro it on the latest released version
- [X] Search the existing issues.
- [X] Refer to the FAQ.
- [X] Refer to Differences between Windows PowerShell 5.1 and PowerShell.
Steps to reproduce
Join-Path fails on uris and Windows-style absolute paths when on Linux (I suspect it also fails with Unix-style absolute paths on Windows, but don't have a machine to confirm). This is different to Resolve-Path failing on non-existant files (see https://github.com/PowerShell/PowerShell/issues/2993), as those work just fine. It is also inconsistent with Split-Path, which does not care about anything.
I discovered it when trying to use Join-Path to form uri for Invoke-RestMethod, as ${Parent}/${Leaf} would sometimes fail due to trailing /.
It is best demonstrated with the following test function:
function Test-RejoinPath($Path) {
Split-Path -Parent $Path | Tee-Object -Variable Parent
Split-Path -Leaf $Path | Tee-Object -Variable Leaf
Join-Path $Parent $Leaf
}
Expected behavior
> test-RejoinPath '/home/me/file.txt'
/home/me
file.txt
/home/me/file.txt
> Test-RejoinPath 'C:/Users/me/file.txt' # On Linux
C:/Users/me
file.txt
C:/Users/me/file.txt
> Test-RejoinPath 'example.com/file.txt'
example.com
file.txt
example.com/file.txt
> Test-RejoinPath 'https://example.com/file.txt'
https://example.com
file.txt
https://example.com/file.txt
Actual behavior
> test-RejoinPath '/home/me/file.txt'
/home/me
file.txt
/home/me/file.txt
> Test-RejoinPath 'C:/Users/me/file.txt' # On Linux
C:/Users/me
file.txt
Join-Path:
Line |
4 | Join-Path $Parent $Leaf
| ~~~~~~~~~~~~~~~~~~~~~~~
| Cannot find drive. A drive with the name 'C' does not exist.
> Test-RejoinPath 'example.com/file.txt'
example.com
file.txt
example.com/file.txt
> Test-RejoinPath 'https://example.com/file.txt'
https://example.com
file.txt
Join-Path:
Line |
4 | Join-Path $Parent $Leaf
| ~~~~~~~~~~~~~~~~~~~~~~~
| Cannot find drive. A drive with the name 'https' does not exist.
Error details
Exception :
Type : System.Management.Automation.DriveNotFoundException
ErrorRecord :
Exception :
Type : System.Management.Automation.ParentContainsErrorRecordException
Message : Cannot find drive. A drive with the name 'https' does not exist.
HResult : -2146233087
TargetObject : https
CategoryInfo : ObjectNotFound: (https:String) [], ParentContainsErrorRecordException
FullyQualifiedErrorId : DriveNotFound
ItemName : https
SessionStateCategory : Drive
TargetSite :
Name : GetDrive
DeclaringType : System.Management.Automation.SessionStateInternal, System.Management.Automation,
Version=7.4.0.4, Culture=neutral, PublicKeyToken=31bf3856ad364e35
MemberType : Method
Module : System.Management.Automation.dll
Message : Cannot find drive. A drive with the name 'https' does not exist.
Source : System.Management.Automation
HResult : -2146233087
StackTrace :
at System.Management.Automation.SessionStateInternal.GetDrive(String name, Boolean automount)
at System.Management.Automation.SessionStateInternal.GetDrive(String name, Boolean automount)
at System.Management.Automation.LocationGlobber.GetDriveRootRelativePathFromPSPath(String path,
CmdletProviderContext context, Boolean escapeCurrentLocation, PSDriveInfo& workingDriveForPath, CmdletProvider&
providerInstance)
at System.Management.Automation.LocationGlobber.GetProviderPath(String path, CmdletProviderContext context,
Boolean isTrusted, ProviderInfo& provider, PSDriveInfo& drive)
at System.Management.Automation.SessionStateInternal.MakePath(String parent, String child,
CmdletProviderContext context)
at Microsoft.PowerShell.Commands.JoinPathCommand.ProcessRecord()
TargetObject : https
CategoryInfo : ObjectNotFound: (https:String) [Join-Path], DriveNotFoundException
FullyQualifiedErrorId : DriveNotFound,Microsoft.PowerShell.Commands.JoinPathCommand
InvocationInfo :
MyCommand : Join-Path
ScriptLineNumber : 4
OffsetInLine : 5
HistoryId : 276
Line : Join-Path $Parent $Leaf
Statement : Join-Path $Parent $Leaf
PositionMessage : At line:4 char:5
+ Join-Path $Parent $Leaf
+ ~~~~~~~~~~~~~~~~~~~~~~~
InvocationName : Join-Path
CommandOrigin : Internal
ScriptStackTrace : at Test-RejoinPath, <No file>: line 4
at <ScriptBlock>, <No file>: line 1
PipelineIterationInfo :
Environment data
Name Value
---- -----
PSVersion 7.4.0-preview.4
PSEdition Core
GitCommitId 7.4.0-preview.4
OS Ubuntu 22.04.2 LTS
Platform Unix
PSCompatibleVersions {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion 2.3
SerializationVersion 1.1.0.1
WSManStackVersion 3.0
Visuals
No response
Good find.
It makes sense that Join-Path shouldn't care about whether drives exist, just like Split-Path doesn't.
(It already doesn't care if a path on an existing drive exists or not).
I suspect it also fails with Unix-style absolute paths on Windows
It doesn't, given the interchangeable use of \ and /; that is, something like /foo/bar is equivalent to \foo\bar and is considered a root path relative to the current - by definition existing - drive.
However, note that Join-Path currently normalizes the path separators to the platform-native ones, because it assumes that its arguments are file-system paths.
E.g., on Windows Join-Path /foo bar yields \foo\bar.
Thus, without additional effort, even making Join-Path accepts _URLs) such as 'file:///some/path' wouldn't yield the expected result. (Join-String allows you to use a -Separator, but it doesn't handle removing duplicate path separators).
@mklement0
However, note that Join-Path currently normalizes the path separators to the platform-native ones, because it assumes that its arguments are file-system paths.
Yeah, you can see in the third example that without https:// it just treats example.com as a current directory, which may in fact absolutely be the case. And I suspect on Windows, it will result in example.com\file.txt.
I think that is an edge case that is not that important, as there is nothing we can do to distinguish between directory which does not exist and website which does not exist (without checking for both, and that is not a good idea). Best I can think of is a flag -Separator (Environment|Windows|Linux|Current) = Environment where Current uses the one already in string, erroring out if mix.
Still, because the uris currently error out, we can just check whether the path has uri protocol. If so, always use /, even on Windows.
The following seems to do a good job in all cases I tested, ignoring stuff like $Path being an array and $AdditionalChildPath which can easily be implemented with iteration:
function Join-Path2([string] $Path, [string] $ChildPath) {
$IsUri = [Uri]::IsWellFormedUriString($Path, [System.UriKind]::Absolute)
$Separator = $IsUri ? '/' : [Io.Path]::DirectorySeparatorChar
$Path.TrimEnd('/', '\') + $Separator + $ChildPath.TrimStart('/', '\')
}
[Uri]::IsWellFormedUriString($Path, [System.UriKind]::Absolute) only matches absolute paths with explicit Uri protocol, so only cases which error out due to the protocol being mistaken for drive name.
@daxian-dbw Can you please check how Join-Path 'C://Windows' 'System32' is treated on Windows? If this also errors out due to double slash, then we are safe even if somebody has a drive with the same name as some Uri protocol.
This is my workaround, it's a one liner. Just add it to the top of the script and it'll just work as one'd expect. I had issues when I tried to join paths intended for a remote system on drive letters the controlling computer didn't have.
This workaround basically just prefixes the input with "Filesystem::" and stripping it from the output. "Filesystem::" is the name of the provider. The PSPath syntax allows for it being specified explicitly. This way the Join-Path module won't try to use the Get-PSDrive data to find it.
function Join-Path ([String]$Path,[String]$ChildPath,[Switch]$Resolve) {return (Microsoft.PowerShell.Management\Join-Path -Path "Filesystem::$Path" -ChildPath $ChildPath -Resolve:$Resolve) -replace "^Filesystem::", ""}
Another workaround would be to use New-PSDrive to first register a dummy drive using the correct provider. Only the drive has to exist using the correct provider, and as long as "-Resolve" isn't specified it doesn't matter what data it has.
The WG discussed this and believe that this could be achieved with a new ParameterSet (to avoid a breaking change) and new parameter (Uri) which would only support parameters uri and ChildPath . The implementation should call the Uri constructor with both elements (new uri(
The WG discussed this and believe that this could be achieved with a new ParameterSet (to avoid a breaking change) and new parameter (Uri) which would only support parameters uri and ChildPath . The implementation should call the Uri constructor with both elements (new uri(, ), and then return the newly created Uri.
@JamesWTruher
I suggest allowing the user to choose / or \. Like:
param (
[ValidateSet('System', '/', '\')]
[string]$Separator
)
# System is [System.IO.Path]::DirectorySeparatorChar
Related issues: #24193