Skip to content

Commit b15ed27

Browse files
authored
Support Windows local device paths in Path (#15590)
There are [7 types of user-space paths on Win32](https://googleprojectzero.blogspot.com/2016/02/the-definitive-guide-on-win32-to-nt.html): * Drive-absolute (`C:\foo`) * Drive-relative (`C:foo`) * Rooted (`\foo`) * Relative (`foo`) * UNC absolute (`\\server\share\foo`) * Local device (`\\.\NUL`, `\\?\C:\foo`) * Root local device (`\\.`, `\\?`) Local device paths starting with exactly `\\?\` are also known as raw local device paths. Other than this prefix, all forward and backward slashes are interchangeable. `Path` already recognizes the first 5 of them; this PR adds the remaining 2. As noted in #15587, the `\\.\` prefix appears when dealing with special devices, such as `\\.\NUL` and `\\.\COM15`, as well as named pipes. The `\\?\` prefix is useful when passing paths to Win32 functions that are already normalized, or if the path exceeds 260 UTF-16 code units (see also #13420). This does not cover NT paths of the form `\??\...`. They appear in the reparse data area of Windows symbolic links.
1 parent 5abb62c commit b15ed27

File tree

2 files changed

+134
-8
lines changed

2 files changed

+134
-8
lines changed

spec/std/path_spec.cr

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,19 @@ describe Path do
191191
assert_paths("C:\\", ".", "C:\\", &.parent)
192192
assert_paths("C:/foo", "C:", "C:/", &.parent)
193193
assert_paths("C:\\foo", ".", "C:\\", &.parent)
194+
assert_paths("\\\\.", ".", "\\\\.", &.parent)
195+
assert_paths("\\/?", "\\", "\\/?", &.parent)
196+
assert_paths("//.", "/", "//.", &.parent)
197+
assert_paths("//./", "/", "//./", &.parent)
198+
assert_paths("//.\\", "/", "//.\\", &.parent)
199+
assert_paths("//./foo", "//.", "//./", &.parent)
200+
assert_paths("//.\\foo", "/", "//.\\", &.parent)
201+
assert_paths("//?/", "/", "//?/", &.parent)
202+
assert_paths("//?\\", "/", "//?\\", &.parent)
203+
assert_paths("//?/foo", "//?", "//?/", &.parent)
204+
assert_paths("//?\\foo", "/", "//?\\", &.parent)
205+
assert_paths("\\\\?/", ".", "\\\\?/", &.parent)
206+
assert_paths("\\\\?\\", ".", "\\\\?\\", &.parent)
194207
assert_paths("/foo/C:/bar", "/foo/C:", "/foo/C:", &.parent)
195208
assert_paths("//some/share", "//some", "//some/share", &.parent)
196209
assert_paths("//some/share/", "//some", "//some/share/", &.parent)
@@ -241,6 +254,19 @@ describe Path do
241254
assert_paths("C:\\folder", ["."], ["C:\\"], &.parents)
242255
assert_paths("C:\\\\folder", ["."], ["C:\\\\"], &.parents)
243256
assert_paths("C:\\.", ["."], ["C:\\"], &.parents)
257+
assert_paths("\\\\.", ["."], [] of String, &.parents)
258+
assert_paths("\\/?", [".", "\\"], [] of String, &.parents)
259+
assert_paths("//.", ["//"], [] of String, &.parents)
260+
assert_paths("//./", ["//"], [] of String, &.parents)
261+
assert_paths("//.\\", ["//"], [] of String, &.parents)
262+
assert_paths("//./foo", ["//", "//."], ["//./"], &.parents)
263+
assert_paths("//.\\foo", ["//"], ["//.\\"], &.parents)
264+
assert_paths("//?/", ["//"], [] of String, &.parents)
265+
assert_paths("//?\\", ["//"], [] of String, &.parents)
266+
assert_paths("//?/foo", ["//", "//?"], ["//?/"], &.parents)
267+
assert_paths("//?\\foo", ["//"], ["//?\\"], &.parents)
268+
assert_paths("\\\\?/", ["."], [] of String, &.parents)
269+
assert_paths("\\\\?\\", ["."], [] of String, &.parents)
244270
assert_paths("//some/share", ["//", "//some"], [] of String, &.parents)
245271
assert_paths("//some/share/", ["//", "//some"], [] of String, &.parents)
246272
assert_paths("//some/share/a", ["//", "//some", "//some/share"], ["//some/share/"], &.parents)
@@ -264,6 +290,21 @@ describe Path do
264290
assert_paths_raw("C:", ".", "C:", &.dirname)
265291
assert_paths_raw("C:/", ".", "C:/", &.dirname)
266292
assert_paths_raw("C:\\", ".", "C:\\", &.dirname)
293+
assert_paths_raw("C:/foo", "C:", "C:/", &.dirname)
294+
assert_paths_raw("C:\\foo", ".", "C:\\", &.dirname)
295+
assert_paths_raw("\\\\.", ".", "\\\\.", &.dirname)
296+
assert_paths_raw("\\/?", "\\", "\\/?", &.dirname)
297+
assert_paths_raw("//.", "/", "//.", &.dirname)
298+
assert_paths_raw("//./", "/", "//./", &.dirname)
299+
assert_paths_raw("//.\\", "/", "//.\\", &.dirname)
300+
assert_paths_raw("//./foo", "//.", "//./", &.dirname)
301+
assert_paths_raw("//.\\foo", "/", "//.\\", &.dirname)
302+
assert_paths_raw("//?/", "/", "//?/", &.dirname)
303+
assert_paths_raw("//?\\", "/", "//?\\", &.dirname)
304+
assert_paths_raw("//?/foo", "//?", "//?/", &.dirname)
305+
assert_paths_raw("//?\\foo", "/", "//?\\", &.dirname)
306+
assert_paths_raw("\\\\?/", ".", "\\\\?/", &.dirname)
307+
assert_paths_raw("\\\\?\\", ".", "\\\\?\\", &.dirname)
267308
assert_paths_raw("//some/share", "//some", "//some/share", &.dirname)
268309
assert_paths_raw("//some/share/", "//some", "//some/share/", &.dirname)
269310
assert_paths_raw("//some/share/a", "//some/share", "//some/share/", &.dirname)
@@ -355,6 +396,14 @@ describe Path do
355396
it_iterates_parts("C:\\folder", ["C:\\folder"], ["C:\\", "folder"])
356397
it_iterates_parts("C:\\\\folder", ["C:\\\\folder"], ["C:\\\\", "folder"])
357398
it_iterates_parts("C:\\.", ["C:\\."], ["C:\\", "."])
399+
it_iterates_parts("//.", ["//", "."], ["//."])
400+
it_iterates_parts("//?", ["//", "?"], ["//?"])
401+
it_iterates_parts("\\\\.\\", ["\\\\.\\"], ["\\\\.\\"])
402+
it_iterates_parts("\\\\?\\", ["\\\\?\\"], ["\\\\?\\"])
403+
it_iterates_parts("\\\\.\\foo", ["\\\\.\\foo"], ["\\\\.\\", "foo"])
404+
it_iterates_parts("\\\\?\\foo", ["\\\\?\\foo"], ["\\\\?\\", "foo"])
405+
it_iterates_parts("\\\\.\\foo\\bar", ["\\\\.\\foo\\bar"], ["\\\\.\\", "foo", "bar"])
406+
it_iterates_parts("\\\\?\\foo\\bar", ["\\\\?\\foo\\bar"], ["\\\\?\\", "foo", "bar"])
358407
end
359408

360409
describe "#extension" do
@@ -386,6 +435,7 @@ describe Path do
386435
assert_paths_raw(".\\foo", false, &.absolute?)
387436
assert_paths_raw("~\\foo", false, &.absolute?)
388437
assert_paths_raw("C:", false, &.absolute?)
438+
assert_paths_raw("C:foo", false, &.absolute?)
389439

390440
assert_paths_raw("C:\\foo", false, true, &.absolute?)
391441
assert_paths_raw("C:/foo/bar", false, true, &.absolute?)
@@ -398,6 +448,13 @@ describe Path do
398448
assert_paths_raw("\\\\some\\share", false, false, &.absolute?)
399449
assert_paths_raw("//some/share/", true, true, &.absolute?)
400450
assert_paths_raw("\\\\some\\share\\", false, true, &.absolute?)
451+
452+
assert_paths_raw("//.", true, false, &.absolute?)
453+
assert_paths_raw("\\\\?", false, false, &.absolute?)
454+
assert_paths_raw("//./foo", true, true, &.absolute?)
455+
assert_paths_raw("\\\\.\\foo", false, true, &.absolute?)
456+
assert_paths_raw("//?/foo", true, true, &.absolute?)
457+
assert_paths_raw("\\\\?\\foo", false, true, &.absolute?)
401458
end
402459

403460
describe "#drive" do
@@ -416,6 +473,12 @@ describe Path do
416473
assert_paths("\\\\some\\share\\foo", nil, "\\\\some\\share", &.drive)
417474
assert_paths("\\\\\\not-a\\share", nil, nil, &.drive)
418475
assert_paths("\\\\not-a\\\\share", nil, nil, &.drive)
476+
assert_paths("\\\\?\\", nil, "\\\\?", &.drive)
477+
assert_paths("\\\\.\\", nil, "\\\\.", &.drive)
478+
assert_paths("//?/", nil, "//?", &.drive)
479+
assert_paths("//./", nil, "//.", &.drive)
480+
assert_paths("//?", nil, "//?", &.drive)
481+
assert_paths("//.", nil, "//.", &.drive)
419482

420483
assert_paths("\\\\some$\\share\\", nil, "\\\\some$\\share", &.drive)
421484
assert_paths("\\\\%10%20\\share\\", nil, "\\\\%10%20\\share", &.drive)
@@ -435,6 +498,12 @@ describe Path do
435498
assert_paths("\\\\some\\share", nil, &.root)
436499
assert_paths("//some/share/", "/", "/", &.root)
437500
assert_paths("\\\\some\\share\\", nil, "\\", &.root)
501+
assert_paths("\\\\?\\", nil, "\\", &.root)
502+
assert_paths("\\\\.\\", nil, "\\", &.root)
503+
assert_paths("//?/", "/", "/", &.root)
504+
assert_paths("//./", "/", "/", &.root)
505+
assert_paths("//?", "/", nil, &.root)
506+
assert_paths("//.", "/", nil, &.root)
438507
end
439508

440509
describe "#anchor" do
@@ -447,6 +516,12 @@ describe Path do
447516
assert_paths("//some/share/", "/", "//some/share/", &.anchor)
448517
assert_paths("\\\\some\\share", nil, "\\\\some\\share", &.anchor)
449518
assert_paths("\\\\some\\share\\", nil, "\\\\some\\share\\", &.anchor)
519+
assert_paths("\\\\?\\", nil, "\\\\?\\", &.anchor)
520+
assert_paths("\\\\.\\", nil, "\\\\.\\", &.anchor)
521+
assert_paths("//?/", "/", "//?/", &.anchor)
522+
assert_paths("//./", "/", "//./", &.anchor)
523+
assert_paths("//?", "/", "//?", &.anchor)
524+
assert_paths("//.", "/", "//.", &.anchor)
450525
end
451526

452527
describe "#normalize" do
@@ -566,6 +641,14 @@ describe Path do
566641
it_normalizes_path("C:\\foo", "C:\\foo")
567642
it_normalizes_path("C:/foo", "C:/foo", "C:\\foo")
568643
end
644+
645+
describe "windows local device paths" do
646+
it_normalizes_path("\\\\.\\C:\\..\\D:\\foo\\.\\bar", windows: "\\\\.\\D:\\foo\\bar")
647+
it_normalizes_path("//./c:/", "/c:", windows: "\\\\.\\c:\\")
648+
it_normalizes_path("//?/c:", "/?/c:", windows: "\\\\?\\c:")
649+
it_normalizes_path("\\/.\\c:/\\", windows: "\\\\.\\c:\\")
650+
it_normalizes_path("\\\\?\\c:\\")
651+
end
569652
end
570653

571654
describe "#join" do

src/path.cr

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ require "crystal/system/path"
4545
# * POSIX paths are generally case-sensitive, Windows paths case-insensitive
4646
# (see `#<=>`).
4747
# * A POSIX path is absolute if it begins with a forward slash (`/`). A Windows path
48-
# is absolute if it starts with a drive letter and root (`C:\`).
48+
# is absolute if it starts with both a drive and a root (see `#absolute?`).
4949
#
5050
# ```
5151
# Path.posix("/foo/./bar").normalize # => Path.posix("/foo/bar")
@@ -221,7 +221,7 @@ struct Path
221221
when 0 # Path only consists of separators
222222
String.new(slice[0, 1])
223223
when 1 # Path has no parent (ex. "hello/", "C:/", "crystal")
224-
return anchor.to_s if windows? && windows_drive?
224+
return anchor.to_s if windows? && (windows_drive? || dos_local_device_path?)
225225
"."
226226
else # Path has a parent (ex. "a/a", "/home/user//", "C://Users/mmm", "\\wsl.localhost\Debian")
227227
if windows? && (anchor = self.anchor) && pos < anchor.to_s.bytesize
@@ -407,7 +407,11 @@ struct Path
407407
#
408408
# If the path turns to be empty, the current directory (`"."`) is returned.
409409
#
410-
# The returned path ends in a slash only if it is the root (`"/"`, `\`, or `C:\`).
410+
# The returned path ends in a slash if it is the root (`"/"`, `\`, or `C:\`),
411+
# or if this path is a Windows local device path that also ends in a slash.
412+
# This trailing slash is significant; `\\.\C:` refers to the _volume_ `C:`, on
413+
# which most I/O functions fail, whereas `\\.\C:\` refers to the root
414+
# _directory_ of said volume.
411415
#
412416
# See also Rob Pike: *[Lexical File Names in Plan 9 or Getting Dot-Dot Right](https://9p.io/sys/doc/lexnames.html)*
413417
def normalize(*, remove_final_separator : Bool = true) : Path
@@ -417,7 +421,7 @@ struct Path
417421
reader = Char::Reader.new(@name)
418422
dotdot = 0
419423
separators = self.separators
420-
add_separator_at_end = !remove_final_separator && ends_with_separator?
424+
add_separator_at_end = (!remove_final_separator || (windows? && dos_local_device_path?)) && ends_with_separator?
421425

422426
new_name = String.build do |str|
423427
if drive
@@ -969,6 +973,11 @@ struct Path
969973
# Returns `nil` if `self` cannot be expressed as relative to *base* or if
970974
# knowing the current working directory would be necessary to resolve it. The
971975
# latter can be avoided by expanding the paths first.
976+
#
977+
# For Windows paths, the drive and the root must be identical; relative paths
978+
# between different path types are not supported, even if they would resolve
979+
# to the same roots (e.g. `\\.\C:\foo` and `C:\foo` are not equivalent, nor
980+
# are `\\?\UNC\server\share\foo` and `\\server\share\foo`).
972981
def relative_to?(base : Path) : Path?
973982
base_anchor = base.anchor
974983
target_anchor = self.anchor
@@ -1118,9 +1127,13 @@ struct Path
11181127
# ```
11191128
# Path.windows("C:\\Program Files").drive # => Path.windows("C:")
11201129
# Path.windows("\\\\host\\share\\folder").drive # => Path.windows("\\\\host\\share")
1130+
# Path.windows("\\\\.\\NUL").drive # => Path.windows("\\\\.")
1131+
# Path.windows("//?").drive # => Path.windows("//?")
11211132
# ```
11221133
#
1123-
# NOTE: Drives are only available for Windows paths. It can either be a drive letter (`C:`) or a UNC share (`\\host\share`).
1134+
# NOTE: Drives are only available for Windows paths. It can be a drive letter
1135+
# (`C:`), a UNC share (`\\host\share`), or a root local device path (`\\.`,
1136+
# `\\?`).
11241137
def drive : Path?
11251138
drive_end, _ = drive_and_root_indices
11261139

@@ -1139,6 +1152,8 @@ struct Path
11391152
# Path.windows("C:Program Files").root # => nil
11401153
# Path.windows("C:\\Program Files").root # => Path.windows("\\")
11411154
# Path.windows("\\\\host\\share\\folder").root # => Path.windows("\\")
1155+
# Path.windows("//./NUL").root # => Path.windows("/")
1156+
# Path.windows("\\\\?").root # => nil
11421157
# ```
11431158
def root : Path?
11441159
drive_end, root_end = drive_and_root_indices
@@ -1157,6 +1172,8 @@ struct Path
11571172
# Path.windows("C:Program Files").anchor # => Path.windows("C:")
11581173
# Path.windows("C:\\Program Files").anchor # => Path.windows("C:\\")
11591174
# Path.windows("\\\\host\\share\\folder").anchor # => Path.windows("\\\\host\\share\\")
1175+
# Path.windows("\\\\.\\NUL").anchor # => Path.windows("\\\\.\\")
1176+
# Path.windows("//?").anchor # => Path.windows("//?")
11601177
# ```
11611178
def anchor : Path?
11621179
drive_end, root_end = drive_and_root_indices
@@ -1191,6 +1208,12 @@ struct Path
11911208
else
11921209
{2, nil}
11931210
end
1211+
elsif dos_local_device_path?
1212+
if separators.includes?(@name.byte_at?(3).try(&.chr))
1213+
{3, 4}
1214+
else
1215+
{3, nil}
1216+
end
11941217
elsif unc_share = unc_share?
11951218
unc_share
11961219
elsif starts_with_separator?
@@ -1205,6 +1228,14 @@ struct Path
12051228
end
12061229
end
12071230

1231+
private def dos_local_device_path?
1232+
# `//./`, `\\?` etc.
1233+
@name.size >= 3 &&
1234+
separators.includes?(@name.to_unsafe[0].unsafe_chr) &&
1235+
separators.includes?(@name.to_unsafe[1].unsafe_chr) &&
1236+
{'.', '?'}.includes?(@name.to_unsafe[2].unsafe_chr)
1237+
end
1238+
12081239
private def unc_share?
12091240
# Test for UNC share
12101241
# path: //share/share
@@ -1280,14 +1311,19 @@ struct Path
12801311
# Returns `true` if this path is absolute.
12811312
#
12821313
# A POSIX path is absolute if it begins with a forward slash (`/`).
1283-
# A Windows path is absolute if it begins with a drive letter and root (`C:\`)
1284-
# or with a UNC share (`\\server\share\`).
1314+
#
1315+
# A Windows path is absolute if it begins with a drive letter (`C:`), a UNC
1316+
# share (`\\server\share`), or a root local device path (`\\.`, `\\?`), which
1317+
# is then followed by a root path separator. Drive-relative paths (`C:foo`),
1318+
# rooted paths (`\foo`), and root local device paths (`\\.`) are not absolute.
12851319
def absolute? : Bool
12861320
separators = self.separators
12871321
if windows?
12881322
first_is_separator = false
12891323
starts_with_double_separator = false
12901324
found_share_name = false
1325+
found_dot_or_question_mark = false
1326+
12911327
@name.each_char_with_index do |char, index|
12921328
case index
12931329
when 0
@@ -1303,14 +1339,21 @@ struct Path
13031339
return false unless char == ':'
13041340
end
13051341
else
1306-
if separators.includes?(char)
1342+
case char
1343+
when .in?(separators)
13071344
if index == 2
13081345
return !starts_with_double_separator && !found_share_name
1346+
elsif index == 3 && found_dot_or_question_mark
1347+
return true
13091348
elsif found_share_name
13101349
return true
13111350
else
13121351
found_share_name = true
13131352
end
1353+
when '.', '?'
1354+
if index == 2
1355+
found_dot_or_question_mark = true
1356+
end
13141357
end
13151358
end
13161359
end

0 commit comments

Comments
 (0)