diff --git a/docs/KNOWNISSUES.md b/docs/KNOWNISSUES.md index 34f6f312e66..5b6587183cf 100644 --- a/docs/KNOWNISSUES.md +++ b/docs/KNOWNISSUES.md @@ -87,3 +87,38 @@ following cmdlets that exist in the FullCLR version: - Send-MailMessage - Show-Command - Update-List + +## File paths with literal backward slashes + +On some filesystems (Linux, OS X), file paths are allowed to contain literal +backward slashes, '\', as valid filename characters. These slashes, when +escaped, are not directory separators. In Bash, the backward slash is the escape +character, so a `path/with/a\\slash` is two directories, `path` and `with`, and +one file, `a\slash`. In PowerShell, we *will* support this using the normal +backtick escape character, so a `path\with\a``\slash` or a +`path/with/a``\slash`, but this edge case is *currently unsupported*. + +That being said, native commands will work as expected. Thus this is the current +scenario: + +```powershell +PS > Get-Content a`\slash +Get-Content : Cannot find path '/home/andrew/src/PowerShell/a/slash' because it does not exist. +At line:1 char:1 ++ Get-Content a`\slash ++ ~~~~~~~~~~~~~~~~~~~~ + + CategoryInfo : ObjectNotFound: (/home/andrew/src/PowerShell/a/slash:String) [Get-Co + ntent], ItemNotFoundException + + FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand + +PS > /bin/cat a\slash +hi + +``` + +The PowerShell cmdlet `Get-Content` cannot yet understand the escaped backward +slash, but the path is passed literally to the native command `/bin/cat`. Most +file operations are thus implicitly supported by the native commands. The +notable exception is `cd` since it is not a command, but a shell built-in, +`Set-Location`. So until this issue is resolved, PowerShell cannot change to a +directory whose name contains a literal backward slash. diff --git a/src/System.Management.Automation/engine/SessionStateStrings.cs b/src/System.Management.Automation/engine/SessionStateStrings.cs index 4ea503ed5dc..93e40863c45 100644 --- a/src/System.Management.Automation/engine/SessionStateStrings.cs +++ b/src/System.Management.Automation/engine/SessionStateStrings.cs @@ -26,6 +26,9 @@ internal static class StringLiterals /// /// The default path separator used by the base implementation of the providers. + /// + /// Porting note: IO.Path.DirectorySeparatorChar is correct for all platforms. On Windows, + /// it is '\', and on Linux, it is '/', as expected. /// /// internal static readonly char DefaultPathSeparator = System.IO.Path.DirectorySeparatorChar; @@ -33,9 +36,14 @@ internal static class StringLiterals /// /// The alternate path separator used by the base implementation of the providers. + /// + /// Porting note: we do not use .NET's AlternatePathSeparatorChar here because it correctly + /// states that both the default and alternate are '/' on Linux. However, for PowerShell to + /// be "slash agnostic", we need to use the assumption that a '\' is the alternate path + /// separator on Linux. /// /// - internal static readonly char AlternatePathSeparator = System.IO.Path.AltDirectorySeparatorChar; + internal static readonly char AlternatePathSeparator = Platform.IsWindows ? '/' : '\\'; internal static readonly string AlternatePathSeparatorString = AlternatePathSeparator.ToString(); /// diff --git a/src/System.Management.Automation/namespaces/FileSystemProvider.cs b/src/System.Management.Automation/namespaces/FileSystemProvider.cs index 65541d578a6..8f691de069f 100644 --- a/src/System.Management.Automation/namespaces/FileSystemProvider.cs +++ b/src/System.Management.Automation/namespaces/FileSystemProvider.cs @@ -951,7 +951,7 @@ protected override Collection InitializeDefaultDrives() // add the filesystem with the root "/" to the initial drive list, // otherwise path handling will not work correctly because there // is no : available to separate the filesystems from each other - if (Platform.HasSingleRootFilesystem() && root != "/") + if (Platform.HasSingleRootFilesystem() && root != StringLiterals.DefaultPathSeparatorString) continue; // Porting notes: On non-windows platforms .net can report two @@ -4765,12 +4765,13 @@ protected override string GetParentPath (string path, string root) return parentPath; } // GetParentPath + // Note: we don't use IO.Path.IsPathRooted as this deals with "invalid" i.e. unnormalized paths private static bool IsAbsolutePath(string path) { bool result = false; - // this needs to be done differently on single root filesystems - if (Platform.HasSingleRootFilesystem() && path.StartsWith("/")) + // check if we're on a single root filesystem and it's an absolute path + if (LocationGlobber.IsSingleFileSystemAbsolutePath(path)) { return true; } @@ -5239,18 +5240,15 @@ private string NormalizeRelativePathHelper (string path, string basePath) private string RemoveRelativeTokens(string path) { - string sep = System.IO.Path.DirectorySeparatorChar.ToString(); - string altSep = System.IO.Path.AltDirectorySeparatorChar.ToString(); - - string testPath = path.Replace(altSep,sep); + string testPath = path.Replace('/', '\\'); if ( - (testPath.IndexOf(sep, StringComparison.OrdinalIgnoreCase) < 0) || - testPath.StartsWith("." + sep, StringComparison.OrdinalIgnoreCase) || - testPath.StartsWith(".." + sep, StringComparison.OrdinalIgnoreCase) || - testPath.EndsWith(sep + ".", StringComparison.OrdinalIgnoreCase) || - testPath.EndsWith(sep + "..", StringComparison.OrdinalIgnoreCase) || - (testPath.IndexOf(sep + "." + sep, StringComparison.OrdinalIgnoreCase) > 0) || - (testPath.IndexOf(sep + ".." + sep, StringComparison.OrdinalIgnoreCase) > 0)) + (testPath.IndexOf("\\", StringComparison.OrdinalIgnoreCase) < 0) || + testPath.StartsWith(".\\", StringComparison.OrdinalIgnoreCase) || + testPath.StartsWith("..\\", StringComparison.OrdinalIgnoreCase) || + testPath.EndsWith("\\.", StringComparison.OrdinalIgnoreCase) || + testPath.EndsWith("\\..", StringComparison.OrdinalIgnoreCase) || + (testPath.IndexOf("\\.\\", StringComparison.OrdinalIgnoreCase) > 0) || + (testPath.IndexOf("\\..\\", StringComparison.OrdinalIgnoreCase) > 0)) { try { diff --git a/src/System.Management.Automation/namespaces/LocationGlobber.cs b/src/System.Management.Automation/namespaces/LocationGlobber.cs index 4b5441c2925..313e9e0e6c5 100644 --- a/src/System.Management.Automation/namespaces/LocationGlobber.cs +++ b/src/System.Management.Automation/namespaces/LocationGlobber.cs @@ -1570,6 +1570,30 @@ internal static bool IsProviderQualifiedPath(string path, out string providerId) return result; } // IsProviderQualifiedPath + /// + /// Determines if the given path is absolute while on a single root filesystem. + /// + /// + /// + /// Porting notes: absolute paths on non-Windows filesystems start with a '/' (no "C:" drive + /// prefix, the slash is the prefix). We compare against both '/' and '\' (default and + /// alternate path separator) in order for PowerShell to be slash agnostic. + /// + /// + /// + /// The path used in the determination + /// + /// + /// + /// Returns true if we're on a single root filesystem and the path is absolute. + /// + internal static bool IsSingleFileSystemAbsolutePath(string path) + { + return Platform.HasSingleRootFilesystem() && + (path.StartsWith(StringLiterals.DefaultPathSeparatorString, StringComparison.Ordinal) || + path.StartsWith(StringLiterals.AlternatePathSeparatorString, StringComparison.Ordinal)); + } // IsSingleFileSystemAbsolutePath + /// /// Determines if the given path is relative or absolute /// @@ -1619,11 +1643,8 @@ internal static bool IsAbsolutePath(string path) break; } - // non-windows porting notes: - // this needs to be handled differently on non-windows, paths starting with / are always absolute - // -> still continue with other isAbsolute processing, colons can still happen in drives - // of other providers - if (Platform.HasSingleRootFilesystem() && path.StartsWith("/",StringComparison.Ordinal)) + // check if we're on a single root filesystem and it's an absolute path + if (IsSingleFileSystemAbsolutePath(path)) { result = true; break; @@ -1716,13 +1737,10 @@ internal bool IsAbsolutePath(string path, out string driveName) break; } - // non-windows porting notes: - // this needs to be handled differently on non-windows, paths starting with / are always absolute - // -> still continue with other isAbsolute processing, colons can still happen in drives - // of other providers - if (Platform.HasSingleRootFilesystem() && path.StartsWith("/",StringComparison.Ordinal)) + // check if we're on a single root filesystem and it's an absolute path + if (IsSingleFileSystemAbsolutePath(path)) { - driveName = "/"; + driveName = StringLiterals.DefaultPathSeparatorString; result = true; break; } @@ -2266,7 +2284,8 @@ internal string GenerateRelativePath( driveRootRelativeWorkingPath = String.Empty; - // Remove the \ or / from the drive relative path + // Remove the \ or / from the drive relative + // path path = path.Substring(1); @@ -2442,41 +2461,18 @@ internal string GenerateRelativePath( private bool HasRelativePathTokens(string path) { - if (System.IO.Path.DirectorySeparatorChar == '/') - { - string comparePath = path; - - // the next line will only replace something, if the directory separators - // are different on the platform - if (System.IO.Path.DirectorySeparatorChar != System.IO.Path.AltDirectorySeparatorChar) - comparePath = path.Replace(System.IO.Path.AltDirectorySeparatorChar,System.IO.Path.DirectorySeparatorChar); - - return ( - comparePath.Equals(".", StringComparison.OrdinalIgnoreCase) || - comparePath.Equals("..", StringComparison.OrdinalIgnoreCase) || - comparePath.Contains("/./") || - comparePath.Contains("/../") || - comparePath.EndsWith("/..", StringComparison.OrdinalIgnoreCase) || - comparePath.EndsWith("/.", StringComparison.OrdinalIgnoreCase) || - comparePath.StartsWith("../", StringComparison.OrdinalIgnoreCase) || - comparePath.StartsWith("./", StringComparison.OrdinalIgnoreCase) || - comparePath.StartsWith("~", StringComparison.OrdinalIgnoreCase)); - } - else - { - string comparePath = path.Replace('/', '\\'); - - return ( - comparePath.Equals(".", StringComparison.OrdinalIgnoreCase) || - comparePath.Equals("..", StringComparison.OrdinalIgnoreCase) || - comparePath.Contains("\\.\\") || - comparePath.Contains("\\..\\") || - comparePath.EndsWith("\\..", StringComparison.OrdinalIgnoreCase) || - comparePath.EndsWith("\\.", StringComparison.OrdinalIgnoreCase) || - comparePath.StartsWith("..\\", StringComparison.OrdinalIgnoreCase) || - comparePath.StartsWith(".\\", StringComparison.OrdinalIgnoreCase) || - comparePath.StartsWith("~", StringComparison.OrdinalIgnoreCase)); - } + string comparePath = path.Replace('/', '\\'); + + return ( + comparePath.Equals(".", StringComparison.OrdinalIgnoreCase) || + comparePath.Equals("..", StringComparison.OrdinalIgnoreCase) || + comparePath.Contains("\\.\\") || + comparePath.Contains("\\..\\") || + comparePath.EndsWith("\\..", StringComparison.OrdinalIgnoreCase) || + comparePath.EndsWith("\\.", StringComparison.OrdinalIgnoreCase) || + comparePath.StartsWith("..\\", StringComparison.OrdinalIgnoreCase) || + comparePath.StartsWith(".\\", StringComparison.OrdinalIgnoreCase) || + comparePath.StartsWith("~", StringComparison.OrdinalIgnoreCase)); } /// @@ -3130,50 +3126,43 @@ private Collection ExpandMshGlobPath( } else { - // Platform note: - // this needs to be done differently on non-windows platforms - string unescapedPath = context.SuppressWildcardExpansion ? path : RemoveGlobEscaping(path); - // this is the output of the platform specific code right below - string resolvedPath; + string formatString = "{0}:" + StringLiterals.DefaultPathSeparator + "{1}"; - if (drive.VolumeSeparatedByColon) + // Check to see if its a hidden provider drive. + if (drive.Hidden) { - string formatString = "{0}:" + StringLiterals.DefaultPathSeparator + "{1}"; - - // Check to see if its a hidden provider drive. - if (drive.Hidden) + if (IsProviderDirectPath(unescapedPath)) { - if (IsProviderDirectPath(unescapedPath)) - { - formatString = "{1}"; - } - else - { - formatString = "{0}::{1}"; - } + formatString = "{1}"; } else { - if (path.StartsWith(StringLiterals.DefaultPathSeparator.ToString(), StringComparison.Ordinal)) - { - formatString = "{0}:{1}"; - } + formatString = "{0}::{1}"; } - - resolvedPath = - String.Format( - System.Globalization.CultureInfo.InvariantCulture, - formatString, - drive.Name, - unescapedPath); } else { - resolvedPath = drive.Name + unescapedPath; + if (path.StartsWith(StringLiterals.DefaultPathSeparatorString, StringComparison.Ordinal)) + { + formatString = "{0}:{1}"; + } + } + + // Porting note: if the volume is not separated by a colon (non-Windows filesystems), don't add it. + if (!drive.VolumeSeparatedByColon) + { + formatString = "{0}{1}"; } + string resolvedPath = + String.Format( + System.Globalization.CultureInfo.InvariantCulture, + formatString, + drive.Name, + unescapedPath); + // Since we didn't do globbing, be sure the path exists if (allowNonexistingPaths || provider.ItemExists(GetProviderPath(resolvedPath, context), context)) @@ -3332,13 +3321,10 @@ internal static string GetDriveQualifiedPath(string path, PSDriveInfo drive) } string result = path; + bool treatAsRelative = true; - // platform notes: - // this needs to be implemented differently depending if the drive uses colon to separate paths if (drive.VolumeSeparatedByColon) { - bool treatAsRelative = true; - // Ensure the drive name is the same as the portion of the path before // :. If not add the drive name and colon as if it was a relative path @@ -3359,32 +3345,39 @@ internal static string GetDriveQualifiedPath(string path, PSDriveInfo drive) } } } - - if (treatAsRelative) + } + else + { + if (IsAbsolutePath(path)) { - string formatString = "{0}:" + StringLiterals.DefaultPathSeparator + "{1}"; + treatAsRelative = false; + } + } - if (path.StartsWith(StringLiterals.DefaultPathSeparator.ToString(), StringComparison.Ordinal)) + if (treatAsRelative) + { + string formatString; + if (drive.VolumeSeparatedByColon) + { + formatString = "{0}:" + StringLiterals.DefaultPathSeparator + "{1}"; + if (path.StartsWith(StringLiterals.DefaultPathSeparatorString, StringComparison.Ordinal)) { formatString = "{0}:{1}"; } - result = - String.Format( - System.Globalization.CultureInfo.InvariantCulture, - formatString, - drive.Name, - path); } - } - else - { - if (path.StartsWith(drive.Name)) - result = path; else - result = drive.Name + path; + { + formatString = "{0}{1}"; + } + + result = + String.Format( + System.Globalization.CultureInfo.InvariantCulture, + formatString, + drive.Name, + path); } - tracer.WriteLine("result = {0}", result); return result; } // GetDriveQualifiedPath diff --git a/src/System.Management.Automation/namespaces/NavigationProviderBase.cs b/src/System.Management.Automation/namespaces/NavigationProviderBase.cs index b234acd0917..beeb0d80d22 100644 --- a/src/System.Management.Automation/namespaces/NavigationProviderBase.cs +++ b/src/System.Management.Automation/namespaces/NavigationProviderBase.cs @@ -416,8 +416,8 @@ protected string MakePath(string parent, string child, bool childIsLeaf) } else { - // Normalize the path so that only the backslash is used as a separator even if the - // user types a forward slash. + // Normalize the path so that only the default path separator is used as a + // separator even if the user types the alternate slash. parent = parent.Replace(StringLiterals.AlternatePathSeparator, StringLiterals.DefaultPathSeparator); child = child.Replace(StringLiterals.AlternatePathSeparator, StringLiterals.DefaultPathSeparator); diff --git a/src/System.Management.Automation/namespaces/RegistryProvider.cs b/src/System.Management.Automation/namespaces/RegistryProvider.cs index e33492c8f6e..1951f59eead 100644 --- a/src/System.Management.Automation/namespaces/RegistryProvider.cs +++ b/src/System.Management.Automation/namespaces/RegistryProvider.cs @@ -3888,7 +3888,7 @@ private string NormalizePath(string path) if (!String.IsNullOrEmpty(path)) { - result = path.Replace('/', '\\'); + result = path.Replace(StringLiterals.AlternatePathSeparator, StringLiterals.DefaultPathSeparator); // Remove relative path tokens if (HasRelativePathTokens(path)) diff --git a/test/powershell/Hierarchical-Path.Tests.ps1 b/test/powershell/Hierarchical-Path.Tests.ps1 new file mode 100644 index 00000000000..f45c1e8b8de --- /dev/null +++ b/test/powershell/Hierarchical-Path.Tests.ps1 @@ -0,0 +1,46 @@ +Describe "Hierarchical paths" { + BeforeAll { + $data = "Hello World" + Setup -File testFile.txt -Content $data + } + + It "should work with Join-Path " { + $testPath = Join-Path $TestDrive testFile.txt + Get-Content $testPath | Should Be $data + } + + It "should work with platform's slashes" { + $testPath = "$TestDrive$([IO.Path]::DirectorySeparatorChar)testFile.txt" + Get-Content $testPath | Should Be $data + } + + It "should work with forward slashes" { + $testPath = "$TestDrive/testFile.txt" + Get-Content $testPath | Should Be $data + } + + It "should work with backward slashes" { + $testPath = "$TestDrive\testFile.txt" + Get-Content $testPath | Should Be $data + } + + It "should work with backward slashes for each separator" { + $testPath = "$TestDrive\testFile.txt".Replace("/","\") + Get-Content $testPath | should be $data + } + + It "should work with forward slashes for each separator" { + $testPath = "$TestDrive/testFile.txt".Replace("\","/") + Get-Content $testPath | should be $data + } + + It "should work even if there are too many forward slashes" { + $testPath = "$TestDrive//////testFile.txt" + Get-Content $testPath | should be $data + } + + It "should work even if there are too many backward slashes" { + $testPath = "$TestDrive\\\\\\\testFile.txt" + Get-Content $testPath | should be $data + } +}