Skip to content

Support Windows local device paths in Path #15590

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions spec/std/path_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,19 @@ describe Path do
assert_paths("C:\\", ".", "C:\\", &.parent)
assert_paths("C:/foo", "C:", "C:/", &.parent)
assert_paths("C:\\foo", ".", "C:\\", &.parent)
assert_paths("\\\\.", ".", "\\\\.", &.parent)
assert_paths("\\/?", "\\", "\\/?", &.parent)
assert_paths("//.", "/", "//.", &.parent)
assert_paths("//./", "/", "//./", &.parent)
assert_paths("//.\\", "/", "//.\\", &.parent)
assert_paths("//./foo", "//.", "//./", &.parent)
assert_paths("//.\\foo", "/", "//.\\", &.parent)
assert_paths("//?/", "/", "//?/", &.parent)
assert_paths("//?\\", "/", "//?\\", &.parent)
assert_paths("//?/foo", "//?", "//?/", &.parent)
assert_paths("//?\\foo", "/", "//?\\", &.parent)
assert_paths("\\\\?/", ".", "\\\\?/", &.parent)
assert_paths("\\\\?\\", ".", "\\\\?\\", &.parent)
assert_paths("/foo/C:/bar", "/foo/C:", "/foo/C:", &.parent)
assert_paths("//some/share", "//some", "//some/share", &.parent)
assert_paths("//some/share/", "//some", "//some/share/", &.parent)
Expand Down Expand Up @@ -241,6 +254,19 @@ describe Path do
assert_paths("C:\\folder", ["."], ["C:\\"], &.parents)
assert_paths("C:\\\\folder", ["."], ["C:\\\\"], &.parents)
assert_paths("C:\\.", ["."], ["C:\\"], &.parents)
assert_paths("\\\\.", ["."], [] of String, &.parents)
assert_paths("\\/?", [".", "\\"], [] of String, &.parents)
assert_paths("//.", ["//"], [] of String, &.parents)
assert_paths("//./", ["//"], [] of String, &.parents)
assert_paths("//.\\", ["//"], [] of String, &.parents)
assert_paths("//./foo", ["//", "//."], ["//./"], &.parents)
assert_paths("//.\\foo", ["//"], ["//.\\"], &.parents)
assert_paths("//?/", ["//"], [] of String, &.parents)
assert_paths("//?\\", ["//"], [] of String, &.parents)
assert_paths("//?/foo", ["//", "//?"], ["//?/"], &.parents)
assert_paths("//?\\foo", ["//"], ["//?\\"], &.parents)
assert_paths("\\\\?/", ["."], [] of String, &.parents)
assert_paths("\\\\?\\", ["."], [] of String, &.parents)
assert_paths("//some/share", ["//", "//some"], [] of String, &.parents)
assert_paths("//some/share/", ["//", "//some"], [] of String, &.parents)
assert_paths("//some/share/a", ["//", "//some", "//some/share"], ["//some/share/"], &.parents)
Expand All @@ -264,6 +290,21 @@ describe Path do
assert_paths_raw("C:", ".", "C:", &.dirname)
assert_paths_raw("C:/", ".", "C:/", &.dirname)
assert_paths_raw("C:\\", ".", "C:\\", &.dirname)
assert_paths_raw("C:/foo", "C:", "C:/", &.dirname)
assert_paths_raw("C:\\foo", ".", "C:\\", &.dirname)
assert_paths_raw("\\\\.", ".", "\\\\.", &.dirname)
assert_paths_raw("\\/?", "\\", "\\/?", &.dirname)
assert_paths_raw("//.", "/", "//.", &.dirname)
assert_paths_raw("//./", "/", "//./", &.dirname)
assert_paths_raw("//.\\", "/", "//.\\", &.dirname)
assert_paths_raw("//./foo", "//.", "//./", &.dirname)
assert_paths_raw("//.\\foo", "/", "//.\\", &.dirname)
assert_paths_raw("//?/", "/", "//?/", &.dirname)
assert_paths_raw("//?\\", "/", "//?\\", &.dirname)
assert_paths_raw("//?/foo", "//?", "//?/", &.dirname)
assert_paths_raw("//?\\foo", "/", "//?\\", &.dirname)
assert_paths_raw("\\\\?/", ".", "\\\\?/", &.dirname)
assert_paths_raw("\\\\?\\", ".", "\\\\?\\", &.dirname)
assert_paths_raw("//some/share", "//some", "//some/share", &.dirname)
assert_paths_raw("//some/share/", "//some", "//some/share/", &.dirname)
assert_paths_raw("//some/share/a", "//some/share", "//some/share/", &.dirname)
Expand Down Expand Up @@ -355,6 +396,14 @@ describe Path do
it_iterates_parts("C:\\folder", ["C:\\folder"], ["C:\\", "folder"])
it_iterates_parts("C:\\\\folder", ["C:\\\\folder"], ["C:\\\\", "folder"])
it_iterates_parts("C:\\.", ["C:\\."], ["C:\\", "."])
it_iterates_parts("//.", ["//", "."], ["//."])
it_iterates_parts("//?", ["//", "?"], ["//?"])
it_iterates_parts("\\\\.\\", ["\\\\.\\"], ["\\\\.\\"])
it_iterates_parts("\\\\?\\", ["\\\\?\\"], ["\\\\?\\"])
it_iterates_parts("\\\\.\\foo", ["\\\\.\\foo"], ["\\\\.\\", "foo"])
it_iterates_parts("\\\\?\\foo", ["\\\\?\\foo"], ["\\\\?\\", "foo"])
it_iterates_parts("\\\\.\\foo\\bar", ["\\\\.\\foo\\bar"], ["\\\\.\\", "foo", "bar"])
it_iterates_parts("\\\\?\\foo\\bar", ["\\\\?\\foo\\bar"], ["\\\\?\\", "foo", "bar"])
end

describe "#extension" do
Expand Down Expand Up @@ -386,6 +435,7 @@ describe Path do
assert_paths_raw(".\\foo", false, &.absolute?)
assert_paths_raw("~\\foo", false, &.absolute?)
assert_paths_raw("C:", false, &.absolute?)
assert_paths_raw("C:foo", false, &.absolute?)

assert_paths_raw("C:\\foo", false, true, &.absolute?)
assert_paths_raw("C:/foo/bar", false, true, &.absolute?)
Expand All @@ -398,6 +448,13 @@ describe Path do
assert_paths_raw("\\\\some\\share", false, false, &.absolute?)
assert_paths_raw("//some/share/", true, true, &.absolute?)
assert_paths_raw("\\\\some\\share\\", false, true, &.absolute?)

assert_paths_raw("//.", true, false, &.absolute?)
assert_paths_raw("\\\\?", false, false, &.absolute?)
assert_paths_raw("//./foo", true, true, &.absolute?)
assert_paths_raw("\\\\.\\foo", false, true, &.absolute?)
assert_paths_raw("//?/foo", true, true, &.absolute?)
assert_paths_raw("\\\\?\\foo", false, true, &.absolute?)
end

describe "#drive" do
Expand All @@ -416,6 +473,12 @@ describe Path do
assert_paths("\\\\some\\share\\foo", nil, "\\\\some\\share", &.drive)
assert_paths("\\\\\\not-a\\share", nil, nil, &.drive)
assert_paths("\\\\not-a\\\\share", nil, nil, &.drive)
assert_paths("\\\\?\\", nil, "\\\\?", &.drive)
assert_paths("\\\\.\\", nil, "\\\\.", &.drive)
assert_paths("//?/", nil, "//?", &.drive)
assert_paths("//./", nil, "//.", &.drive)
assert_paths("//?", nil, "//?", &.drive)
assert_paths("//.", nil, "//.", &.drive)

assert_paths("\\\\some$\\share\\", nil, "\\\\some$\\share", &.drive)
assert_paths("\\\\%10%20\\share\\", nil, "\\\\%10%20\\share", &.drive)
Expand All @@ -435,6 +498,12 @@ describe Path do
assert_paths("\\\\some\\share", nil, &.root)
assert_paths("//some/share/", "/", "/", &.root)
assert_paths("\\\\some\\share\\", nil, "\\", &.root)
assert_paths("\\\\?\\", nil, "\\", &.root)
assert_paths("\\\\.\\", nil, "\\", &.root)
assert_paths("//?/", "/", "/", &.root)
assert_paths("//./", "/", "/", &.root)
assert_paths("//?", "/", nil, &.root)
assert_paths("//.", "/", nil, &.root)
end

describe "#anchor" do
Expand All @@ -447,6 +516,12 @@ describe Path do
assert_paths("//some/share/", "/", "//some/share/", &.anchor)
assert_paths("\\\\some\\share", nil, "\\\\some\\share", &.anchor)
assert_paths("\\\\some\\share\\", nil, "\\\\some\\share\\", &.anchor)
assert_paths("\\\\?\\", nil, "\\\\?\\", &.anchor)
assert_paths("\\\\.\\", nil, "\\\\.\\", &.anchor)
assert_paths("//?/", "/", "//?/", &.anchor)
assert_paths("//./", "/", "//./", &.anchor)
assert_paths("//?", "/", "//?", &.anchor)
assert_paths("//.", "/", "//.", &.anchor)
end

describe "#normalize" do
Expand Down Expand Up @@ -566,6 +641,14 @@ describe Path do
it_normalizes_path("C:\\foo", "C:\\foo")
it_normalizes_path("C:/foo", "C:/foo", "C:\\foo")
end

describe "windows local device paths" do
it_normalizes_path("\\\\.\\C:\\..\\D:\\foo\\.\\bar", windows: "\\\\.\\D:\\foo\\bar")
it_normalizes_path("//./c:/", "/c:", windows: "\\\\.\\c:\\")
it_normalizes_path("//?/c:", "/?/c:", windows: "\\\\?\\c:")
it_normalizes_path("\\/.\\c:/\\", windows: "\\\\.\\c:\\")
it_normalizes_path("\\\\?\\c:\\")
end
end

describe "#join" do
Expand Down
59 changes: 51 additions & 8 deletions src/path.cr
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ require "crystal/system/path"
# * POSIX paths are generally case-sensitive, Windows paths case-insensitive
# (see `#<=>`).
# * A POSIX path is absolute if it begins with a forward slash (`/`). A Windows path
# is absolute if it starts with a drive letter and root (`C:\`).
# is absolute if it starts with both a drive and a root (see `#absolute?`).
#
# ```
# Path.posix("/foo/./bar").normalize # => Path.posix("/foo/bar")
Expand Down Expand Up @@ -221,7 +221,7 @@ struct Path
when 0 # Path only consists of separators
String.new(slice[0, 1])
when 1 # Path has no parent (ex. "hello/", "C:/", "crystal")
return anchor.to_s if windows? && windows_drive?
return anchor.to_s if windows? && (windows_drive? || dos_local_device_path?)
"."
else # Path has a parent (ex. "a/a", "/home/user//", "C://Users/mmm", "\\wsl.localhost\Debian")
if windows? && (anchor = self.anchor) && pos < anchor.to_s.bytesize
Expand Down Expand Up @@ -407,7 +407,11 @@ struct Path
#
# If the path turns to be empty, the current directory (`"."`) is returned.
#
# The returned path ends in a slash only if it is the root (`"/"`, `\`, or `C:\`).
# The returned path ends in a slash if it is the root (`"/"`, `\`, or `C:\`),
# or if this path is a Windows local device path that also ends in a slash.
# This trailing slash is significant; `\\.\C:` refers to the _volume_ `C:`, on
# which most I/O functions fail, whereas `\\.\C:\` refers to the root
# _directory_ of said volume.
#
# See also Rob Pike: *[Lexical File Names in Plan 9 or Getting Dot-Dot Right](https://9p.io/sys/doc/lexnames.html)*
def normalize(*, remove_final_separator : Bool = true) : Path
Expand All @@ -417,7 +421,7 @@ struct Path
reader = Char::Reader.new(@name)
dotdot = 0
separators = self.separators
add_separator_at_end = !remove_final_separator && ends_with_separator?
add_separator_at_end = (!remove_final_separator || (windows? && dos_local_device_path?)) && ends_with_separator?

new_name = String.build do |str|
if drive
Expand Down Expand Up @@ -969,6 +973,11 @@ struct Path
# Returns `nil` if `self` cannot be expressed as relative to *base* or if
# knowing the current working directory would be necessary to resolve it. The
# latter can be avoided by expanding the paths first.
#
# For Windows paths, the drive and the root must be identical; relative paths
# between different path types are not supported, even if they would resolve
# to the same roots (e.g. `\\.\C:\foo` and `C:\foo` are not equivalent, nor
# are `\\?\UNC\server\share\foo` and `\\server\share\foo`).
def relative_to?(base : Path) : Path?
base_anchor = base.anchor
target_anchor = self.anchor
Expand Down Expand Up @@ -1118,9 +1127,13 @@ struct Path
# ```
# Path.windows("C:\\Program Files").drive # => Path.windows("C:")
# Path.windows("\\\\host\\share\\folder").drive # => Path.windows("\\\\host\\share")
# Path.windows("\\\\.\\NUL").drive # => Path.windows("\\\\.")
# Path.windows("//?").drive # => Path.windows("//?")
# ```
#
# NOTE: Drives are only available for Windows paths. It can either be a drive letter (`C:`) or a UNC share (`\\host\share`).
# NOTE: Drives are only available for Windows paths. It can be a drive letter
# (`C:`), a UNC share (`\\host\share`), or a root local device path (`\\.`,
# `\\?`).
def drive : Path?
drive_end, _ = drive_and_root_indices

Expand All @@ -1139,6 +1152,8 @@ struct Path
# Path.windows("C:Program Files").root # => nil
# Path.windows("C:\\Program Files").root # => Path.windows("\\")
# Path.windows("\\\\host\\share\\folder").root # => Path.windows("\\")
# Path.windows("//./NUL").root # => Path.windows("/")
# Path.windows("\\\\?").root # => nil
# ```
def root : Path?
drive_end, root_end = drive_and_root_indices
Expand All @@ -1157,6 +1172,8 @@ struct Path
# Path.windows("C:Program Files").anchor # => Path.windows("C:")
# Path.windows("C:\\Program Files").anchor # => Path.windows("C:\\")
# Path.windows("\\\\host\\share\\folder").anchor # => Path.windows("\\\\host\\share\\")
# Path.windows("\\\\.\\NUL").anchor # => Path.windows("\\\\.\\")
# Path.windows("//?").anchor # => Path.windows("//?")
# ```
def anchor : Path?
drive_end, root_end = drive_and_root_indices
Expand Down Expand Up @@ -1191,6 +1208,12 @@ struct Path
else
{2, nil}
end
elsif dos_local_device_path?
if separators.includes?(@name.byte_at?(3).try(&.chr))
{3, 4}
else
{3, nil}
end
elsif unc_share = unc_share?
unc_share
elsif starts_with_separator?
Expand All @@ -1205,6 +1228,14 @@ struct Path
end
end

private def dos_local_device_path?
# `//./`, `\\?` etc.
@name.size >= 3 &&
separators.includes?(@name.to_unsafe[0].unsafe_chr) &&
separators.includes?(@name.to_unsafe[1].unsafe_chr) &&
{'.', '?'}.includes?(@name.to_unsafe[2].unsafe_chr)
end

private def unc_share?
# Test for UNC share
# path: //share/share
Expand Down Expand Up @@ -1280,14 +1311,19 @@ struct Path
# Returns `true` if this path is absolute.
#
# A POSIX path is absolute if it begins with a forward slash (`/`).
# A Windows path is absolute if it begins with a drive letter and root (`C:\`)
# or with a UNC share (`\\server\share\`).
#
# A Windows path is absolute if it begins with a drive letter (`C:`), a UNC
# share (`\\server\share`), or a root local device path (`\\.`, `\\?`), which
# is then followed by a root path separator. Drive-relative paths (`C:foo`),
# rooted paths (`\foo`), and root local device paths (`\\.`) are not absolute.
def absolute? : Bool
separators = self.separators
if windows?
first_is_separator = false
starts_with_double_separator = false
found_share_name = false
found_dot_or_question_mark = false

@name.each_char_with_index do |char, index|
case index
when 0
Expand All @@ -1303,14 +1339,21 @@ struct Path
return false unless char == ':'
end
else
if separators.includes?(char)
case char
when .in?(separators)
if index == 2
return !starts_with_double_separator && !found_share_name
elsif index == 3 && found_dot_or_question_mark
return true
elsif found_share_name
return true
else
found_share_name = true
end
when '.', '?'
if index == 2
found_dot_or_question_mark = true
end
end
end
end
Expand Down
Loading