Без объяснения заголовок этой статьи может показаться настоящей задачкой на сообразительность, а проверить результат можно (например) при помощи встроенного в 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 нет соответствующей символической ссылки, ничего хорошего из этого не выйдет.
Как оказалось, в командной строке это требование также не является обязательным, и могу предположить, что на этом странности вокруг этого случая не заканчиваются, а ждут своих первооткрывателей.
