diff --git a/spec/std/path_spec.cr b/spec/std/path_spec.cr index 9c2b69aaf45b..5c1ba284a8d5 100644 --- a/spec/std/path_spec.cr +++ b/spec/std/path_spec.cr @@ -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) @@ -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) @@ -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) @@ -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 @@ -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?) @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/src/path.cr b/src/path.cr index 1063cdacc84f..21daa97b14ed 100644 --- a/src/path.cr +++ b/src/path.cr @@ -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") @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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? @@ -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 @@ -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 @@ -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