Без объяснения заголовок этой статьи может показаться настоящей задачкой на сообразительность, а проверить результат можно (например) при помощи встроенного в Windows инструмента subst.
Вот как создать диск +:\ в качестве псевдонима для каталога, находящегося по адресу C:\foo:
subst +: C:\foo
Затем диск +:\ работает совершенно нормально (как минимум, в cmd.exe, о чём мы подробнее поговорим ниже):
> cd /D +:\ +:\> tree . Folder PATH listing Volume serial number is 00000001 12AB:23BC +:\ └───bar
Но, если разобраться, почему всё именно так, то мы узнаем много нового о работе Windows под капотом и сможем инициировать несколько очень любопытных вариантов поведения.
Что вообще представляет собой буква диска?
Пути, знакомые большинству из вас, пролегают в пространствах имён Win32, имеют вид, например, C:\foo. Это путь Win32, абсолютный в пределах диска. Однако высокоуровневые API, принимающие пути Win32, например CreateFileW, в конечном итоге сконвертируют путь вида C:\foo в путь пространства имён NT, а только потом выполнят вызов к более низкоуровневому API внутри ntdll.dll, например NtCreateFile.
В этом можно убедиться на примере NtTrace, где вызов к CreateFileW с C:\foo в конечном счёте приводит к вызову NtCreateFile с \??\C:\foo:
NtCreateFile( FileHandle=0x40c07ff640 [0xb8], DesiredAccess=SYNCHRONIZE|GENERIC_READ|0x80, ObjectAttributes="\??\C:\foo", IoStatusBlock=0x40c07ff648 [0/1], AllocationSize=null, FileAttributes=0, ShareAccess=7, CreateDisposition=1, CreateOptions=0x4000, EaBuffer=null, EaLength=0 ) => 0 NtClose( Handle=0xb8 ) => 0
Обратите внимание: наиболее важна здесь часть ObjectAttributes="\??\C:\foo"
Тестовый код, вот как его воспроизвести:
createfilew.zig: const std = @import("std"); const windows = std.os.windows; const L = std.unicode.wtf8ToWtf16LeStringLiteral; pub extern "kernel32" fn CreateFileW( lpFileName: windows.LPCWSTR, dwDesiredAccess: windows.DWORD, dwShareMode: windows.DWORD, lpSecurityAttributes: ?*windows.SECURITY_ATTRIBUTES, dwCreationDisposition: windows.DWORD, dwFlagsAndAttributes: windows.DWORD, hTemplateFile: ?windows.HANDLE, ) callconv(.winapi) windows.HANDLE; pub fn main() !void { const path = L("C:\\foo"); const dir_handle = CreateFileW( path, windows.GENERIC_READ, windows.FILE_SHARE_DELETE | windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE, null, windows.OPEN_EXISTING, windows.FILE_FLAG_BACKUP_SEMANTICS | windows.FILE_FLAG_OVERLAPPED, null, ); if (dir_handle == windows.INVALID_HANDLE_VALUE) return error.FailedToOpenDir; defer windows.CloseHandle(dir_handle); }
Собрано при помощи:
zig build-exe createfilew.zig
Чтобы выполнить его при помощи NtTrace:
nttrace createfilew.exe > createfilew.log
Здесь \??\C:\foo — это путь пространства имён NT, именно его и ожидает NtCreateFile. Но, чтобы понять этот путь, давайте поговорим о «менеджере объектов» (Object Manager), который отвечает за обработку путей NT.
Менеджер объектов
Примечание: в основном я собираюсь перефразировать этот отличный рассказ о путях в NT, поэтому, если вас интересуют подробности — лучше сами почитайте первоисточник.
Менеджер объектов отвечает за отслеживание именованных объектов, которые можно подробнее изучить при помощи инструмента WinObj. Элемент \?? пути \??\C:\foo представляет собой особый виртуальный каталог внутри менеджера объектов, и в этом каталоге одновременно содержится каталог \GLOBAL??, а также по каталогу DosDevices на каждого пользователя.
Я считаю, что объект C: находится внутри \GLOBAL?? и, в сущности, представляет собой символическую ссылку на \Device\HarddiskVolume4:

Таким образом, \??\C:\foo в конечном итоге разрешается в \Device\HarddiskVolume4\foo, и далее уже от конкретного устройства зависит, как будет обрабатываться часть foo в составе пути.
Но здесь наиболее важно, что \??\C:\foo — всего лишь один из способов сослаться на путь к устройству \Device\HarddiskVolume4\foo. Например, каждому тому также будет присваиваться именованный объект на основе GUID этого тома в формате Volume{18123456-abcd-efab-cdef-1234abcdabcd}. Это тоже символическая ссылка на что-то вроде \Device\HarddiskVolume4, так что путь вида \??\Volume{18123456-abcd-efab-cdef-1234abcdabcd}\foo, фактически, эквивалентен \??\C:\foo.
Замечание: в \GLOBAL?? содержится объект Global, который сам является символической ссылкой на \GLOBAL??, так что \??\GLOBAL\GLOBAL\C:\foo (и любая комбинация из них) также разрешается в \Device\HarddiskVolume4\foo
Я всё это рассказываю, чтобы подчеркнуть: в именованном объекте C: нет ничего принципиально особенного; менеджер объектов обращается с ним как с любой другой символической ссылкой и соответствующим образом его разрешает.
Так что же в самом деле представляет собой буква диска?
На мой взгляд, буквы диска — это просто соглашение, возникшее в ходе преобразования путей Win32 в пути NT. В частности, такое соглашение прослеживается вплоть до реализации RtlDosPathNameToNtPathName_U.
Иными словами, поскольку RtlDosPathNameToNtPathName_U преобразует C:\foo в \??\C:\foo, объект с именем C: будет вести себя как буква диска. Чтобы пояснить эту мысль, приведу пример: в параллельной Вселенной RtlDosPathNameToNtPathName_U могла бы преобразовывать путь FOO:\bar в \??\FOO:\bar, и после этого FOO: вела бы себя как буква диска.
Итак, возвращаясь к заголовку, как RtlDosPathNameToNtPathName_U обращается с чем-либо вроде +:\foo? Ну, точно как с C:\foo:
> paths.exe C:\foo path type: .DriveAbsolute nt path: \??\C:\foo > paths.exe +:\foo path type: .DriveAbsolute nt path: \??\+:\foo
Код тестовой программы:
paths.zig: const std = @import("std"); const windows = std.os.windows; pub fn main() !void { var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const args = try std.process.argsAlloc(arena); if (args.len <= 1) return error.ExpectedArg; const path = try std.unicode.wtf8ToWtf16LeAllocZ(arena, args[1]); const path_type = RtlDetermineDosPathNameType_U(path); std.debug.print("path type: {}\n", .{path_type}); const nt_path = try RtlDosPathNameToNtPathName_U(path); std.debug.print(" nt path: {f}\n", .{std.unicode.fmtUtf16Le(nt_path.span())}); } const RTL_PATH_TYPE = enum(c_int) { Unknown, UncAbsolute, DriveAbsolute, DriveRelative, Rooted, Relative, LocalDevice, RootLocalDevice, }; pub extern "ntdll" fn RtlDetermineDosPathNameType_U( Path: [*:0]const u16, ) callconv(.winapi) RTL_PATH_TYPE; fn RtlDosPathNameToNtPathName_U(path: [:0]const u16) !windows.PathSpace { var out: windows.UNICODE_STRING = undefined; const rc = windows.ntdll.RtlDosPathNameToNtPathName_U(path, &out, null, null); if (rc != windows.TRUE) return error.BadPathName; defer windows.ntdll.RtlFreeUnicodeString(&out); var path_space: windows.PathSpace = undefined; const out_path = out.Buffer.?[0 .. out.Length / 2]; @memcpy(path_space.data[0..out_path.len], out_path); path_space.len = out.Length / 2; path_space.data[path_space.len] = 0; return path_space; }
Следовательно, если объект с именем +: находится в виртуальном каталоге \??, то можно ожидать, что путь Win32 к +:\ будет работать точно как любой другой путь, абсолютный в пределах диска — именно это мы и наблюдаем.
Замечание: при использовании subst так, как показано в начале этого поста, такой объект +: создаётся в каталоге DosDevices (по каталогу на каждого пользователя), о котором также говорилось выше:

Подробнее о том, к чему это приводит
В этом разделе я подчеркну лишь некоторые вещи, оказавшиеся важными в контексте именно того проекта, над которым я работал. Если у кого-то будет настроение — рекомендую подробнее исследовать, к чему всё это может привести.
explorer.exe в эти игры не играет
Если диск обозначен символом, не относящимся к латинице (A-Z), то в Проводнике такие диски не отображаются, и перейти к ним через Проводник тоже нельзя.

Ошибка при попытке перейти к +:\ в Проводнике
Пытаясь объяснить, почему «не появляется», предположу следующее: explorer.exe проходит по \?? и ищет именно такие объекты, названия которых относятся к диапазону от A: до Z:. Почему «не переходит» — объяснить сложнее, выглядит таинственно, но я думаю, что внутри у explorer.exe много специальной логики, касающейся обработки путей, вводимых в адресную строку, и часть этой логики — ограничение поиска буквами A-Z (т.e., попытка обрывается ещё до того, как мы попытаемся открыть путь).
PowerShell с этим тоже не справляется.
По-видимому, PowerShell также отклоняет все диски, не относящиеся к A-Z:
PS C:\> cd +:\ cd : Cannot find drive. A drive with the name '+' does not exist. At line:1 char:1 + cd +:\ + ~~~~~~ + CategoryInfo : ObjectNotFound: (+:String) [Set-Location], DriveNotFoundException + FullyQualifiedErrorId : DriveNotFound,Microsoft.PowerShell.Commands.SetLocationCommand
Буквы дисков, не относящиеся к кодировке ASCII
Совсем не обязательно, чтобы буквы дисков относились к кодировке ASCII; это могут быть и совсем другие символы.
> subst €: C:\foo > cd /D €:\ €:\> tree . Folder PATH listing Volume serial number is 000000DE 12AB:23BC €:\ └───bar
Вдобавок буквы дисков, не относящиеся к ASCII, нечувствительны к регистру:
> subst Λ: C:\foo > cd /D λ:\ λ:\> tree . Folder PATH listing Volume serial number is 000000DE 12AB:23BC λ:\ └───bar
Но в качестве букв дисков нам не подойдут произвольные графемы Юникода или даже произвольные точки кода; в таком качестве могут использоваться лишь цельные элементы кода WTF-16 (например, u16, то есть, <= U+FFFF). Тот инструмент, которым мы здесь пользуемся (subst.exe), выдаст ошибку с формулировкой Invalid parameter, если вы попробуете использовать для названия диска точку кода более U+FFFF. Но это препятствие можно обойти, если напрямую проследовать через MountPointManager:

Код для создания символической ссылки 𤭢:
const std = @import("std"); const windows = std.os.windows; const L = std.unicode.wtf8ToWtf16LeStringLiteral; const MOUNTMGR_CREATE_POINT_INPUT = extern struct { SymbolicLinkNameOffset: windows.USHORT, SymbolicLinkNameLength: windows.USHORT, DeviceNameOffset: windows.USHORT, DeviceNameLength: windows.USHORT, }; pub fn main() !void { const mgmt_handle = try windows.OpenFile(L("\\??\\MountPointManager"), .{ .access_mask = windows.SYNCHRONIZE | windows.GENERIC_READ | windows.GENERIC_WRITE, .share_access = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE, .creation = windows.FILE_OPEN, }); defer windows.CloseHandle(mgmt_handle); const volume_name = L("\\Device\\HarddiskVolume4"); const mount_point = L("\\DosDevices\\𤭢:"); const buf_size = @sizeOf(MOUNTMGR_CREATE_POINT_INPUT) + windows.MAX_PATH * 2 + windows.MAX_PATH * 2; var input_buf: [buf_size]u8 align(@alignOf(MOUNTMGR_CREATE_POINT_INPUT)) = [_]u8{0} ** buf_size; var input_struct: *MOUNTMGR_CREATE_POINT_INPUT = @ptrCast(&input_buf[0]); input_struct.SymbolicLinkNameOffset = @sizeOf(MOUNTMGR_CREATE_POINT_INPUT); input_struct.SymbolicLinkNameLength = mount_point.len * 2; input_struct.DeviceNameOffset = input_struct.SymbolicLinkNameOffset + input_struct.SymbolicLinkNameLength; input_struct.DeviceNameLength = volume_name.len * 2; @memcpy(input_buf[input_struct.SymbolicLinkNameOffset..][0..input_struct.SymbolicLinkNameLength], @as([*]const u8, @ptrCast(mount_point))); @memcpy(input_buf[input_struct.DeviceNameOffset..][0..input_struct.DeviceNameLength], @as([*]const u8, @ptrCast(volume_name))); const IOCTL_MOUNTMGR_CREATE_POINT = windows.CTL_CODE(windows.MOUNTMGRCONTROLTYPE, 0, .METHOD_BUFFERED, windows.FILE_READ_ACCESS | windows.FILE_WRITE_ACCESS); try windows.DeviceIoControl(mgmt_handle, IOCTL_MOUNTMGR_CREATE_POINT, &input_buf, null); }
(скомпилированный исполняемый файл необходимо запускать от имени администратора).
Но даже если у вас есть готовая символическая ссылка, само по себе это ничего не даёт:
> cd /D 𤭢:\ The filename, directory name, or volume label syntax is incorrect.
Замечание: на самом деле, такое поведение было ожидаемым, поскольку в Windows кодировка Юникод поддерживается раньше, чем UTF-16. Поэтому Windows обычно не работает с суррогатными парами, а, напротив, оперирует почти исключительно элементарными единицами кода WTF-16 напрямую.
Поэтому, если мы попытаемся проверить, является ли путь 𤭢:\ (в кодировке WTF-16 имеет вид <0xD852><0xDF62><0x003A><0x005C>) абсолютным в пределах диска, то потребуется убедиться, что path[1] == ':', и это не подтвердится, так как path[1] — это 0xDF62.
Несоответствие классификаций путей
Очень часто функции, относящиеся к путям, пишут без привлечения системно-специфичных API. Поэтому велика вероятность, что RtlDosPathNameToNtPathName_U и какая-нибудь конкретная реализация path.isAbsolute будут трактовать путь к файлу совершенно по-разному.
Пример навскидку: Rust считает абсолютными лишь такие пути, в которых буква диска относится к диапазону A-Z:
use std::path::Path; fn main() { println!("C:\\ {}", Path::new("C:\\foo").is_absolute()); println!("+:\\ {}", Path::new("+:\\foo").is_absolute()); println!("€:\\ {}", Path::new("€:\\foo").is_absolute()); } > rustc test.rs > test.exe C:\ true +:\ false €:\ false
Предложу читателям самим выяснить, так ли серьёзна эта проблема, чтобы специально заниматься её устранением (честно говоря, не знаю, проблема ли это вообще). Но есть ещё одна шероховатость (на которую я уже намекал выше), связанная с кодировкой текста. Она может привести к тому, что, например, реализация isAbsolute станет возвращать разные результаты для одного и того же пути. Именно для того, чтобы с ней разобраться, я и взялся разрабатывать всю эту тему. Недавно, занимаясь кое-какой работой в области функций Zig, касающихся обработки путей, я осознал, что, если поискать в path[0], path[1] и path[2] паттерн вида C:\, то, в зависимости от применяемой кодировки, будут рассматриваться разные части пути. То есть, для условного €:\ (составленного из точек кода <U+20AC><U+003A><U+005C>) имеем два варианта:
В кодировке WTF-16, где
U+20ACможет быть представлено как единичный элемент кодаu16—0x20AC, получим, чтоpath[0]будет0x20AC,path[1]будет0x3A (:), аpath[2]будет0x5C (\), и это выглядит как путь, абсолютный в пределах диска.В кодировке WTF-8, где
U+20ACпредставляется в виде трёх единичных элементов кодаu8 (0xE2 0x82 0xAC), получим:path[0]будет0xE2,path[1]будет0x82, аpath[2]будет0xAC, то есть, ничуть не похоже на путь, абсолютный в пределах диска.
Поэтому, чтобы написать реализацию, которая бы единообразно трактовала все пути, независимо от применяемой кодировки, нужно принять одно из следующих решений:
Если желательна строгая совместимость с
RtlDetermineDosPathNameType_U/RtlDosPathNameToNtPathName_U, то нужно декодировать первую точку кода и проверять<= 0xFFFFпри работе с WTF-8 (именно такой вариант я выбрал при работе со стандартной библиотекой Zig, но не могу сказать, что был этим совершенно доволен).Если хочется всегда иметь возможность проверить
path[0]/path[1]/path[2], при этом не обращая внимания, относятся ли буквы дисков к кодировке ASCII — проверятьpath[0] <= 0x7Fнезависимо от кодировки.Если вас не интересует ничего сверх стандартных букв диска из диапазона A-Z, то проверяйте этот показатель явно (кстати, в Rust так и делается).
Это не EURO-диск
Занимаясь всем этим, я натолкнулся на по-настоящему странную вещь, а именно: у API kernel32.dll SetVolumeMountPointW возникает собственная уникальная причуда при обработке букв дисков, не относящихся к кодировке ASCII. В частности, следующий код (в котором мы пытаемся создать диск €:\) сработает успешно:
const std = @import("std"); const windows = std.os.windows; const L = std.unicode.wtf8ToWtf16LeStringLiteral; extern "kernel32" fn SetVolumeMountPointW( VolumeMountPoint: windows.LPCWSTR, VolumeName: windows.LPCWSTR, ) callconv(.winapi) windows.BOOL; pub fn main() !void { const volume_name = L("\\\\?\\Volume{18123456-abcd-efab-cdef-1234abcdabcd}\\"); const mount_point = L("€:\\"); if (SetVolumeMountPointW(mount_point, volume_name) == 0) { const err = windows.GetLastError(); std.debug.print("{any}\n", .{err}); return error.Failed; } }
Но, заглянув в Менеджер Объектов, обнаружим, что там найдётся не символическая ссылка €:, а… ¬::

Когда в своё время я подробно занимался всевозможными причудами Windows, у меня появилась версия, что здесь может происходить: вероятно, 0x20AC усекается функцией SetVolumeMountPointW до 0xAC, а U+00AC оказывается ¬. Если действительно всё так и происходит, то, как мне кажется, весьма странно отсекать букву диска, а не отклонять путь. Но при этом вполне понятно, почему буквы дисков, не относящиеся к кодировке ASCII — как раз тот пограничный случай, который не успели как следует продумать.
Заключение
Не представляю, нашли ли вы для себя что-то новое в том, о чём я здесь рассказал, однако поиск по диагонали показывает, что описанные вещи почти нигде не фигурирует. Единственное упоминание о буквах дисков за пределами алфавита A-Z я нашёл в статье The Definitive Guide on Win32 to NT Path Conversion, где сказано:
Естественно предположить, что «буквы» дисков могут быть лишь от A до Z. Оказывается, что в API RtlGetFullPathName_U это требование не является обязательным, хотя, в оболочке Explorer и командной строке почти наверняка является. Следовательно, если вторым символом в пути идёт точка с запятой, то после преобразования это будет трактоваться как абсолютный или относительный путь в пределах диска. Конечно же, если в каталоге объектов DosDevices нет соответствующей символической ссылки, ничего хорошего из этого не выйдет.
Как оказалось, в командной строке это требование также не является обязательным, и могу предположить, что на этом странности вокруг этого случая не заканчиваются, а ждут своих первооткрывателей.