PowerShell icon indicating copy to clipboard operation
PowerShell copied to clipboard

Split-Path works with uri, but Join-Path does not

Open dkaszews opened this issue 2 years ago • 6 comments

Prerequisites

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

dkaszews avatar Jul 22 '23 08:07 dkaszews

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 avatar Jul 22 '23 20:07 mklement0

@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.

dkaszews avatar Jul 23 '23 06:07 dkaszews

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.

dkaszews avatar Aug 20 '23 10:08 dkaszews

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.

agowa avatar Nov 02 '23 13:11 agowa

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 avatar May 01 '24 16:05 JamesWTruher

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

ZSkycat avatar Aug 22 '24 10:08 ZSkycat