Без объяснения заголовок этой статьи может показаться настоящей задачкой на сообразительность, а проверить результат можно (например) при помощи встроенного в 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] будет 0x20ACpath[1] будет 0x3A (:), а path[2] будет 0x5C (\), и это выглядит как путь, абсолютный в пределах диска

  • В кодировке WTF-8, где U+20AC представляется в виде трёх единичных элементов кода  u8 (0xE2 0x82 0xAC), получим: path[0] будет 0xE2path[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 нет соответствующей символической ссылки, ничего хорошего из этого не выйдет.

Как оказалось, в командной строке это требование также не является обязательным, и могу предположить, что на этом странности вокруг этого случая не заканчиваются, а ждут своих первооткрывателей.

Комментарии (5)


  1. Tzimie
    06.12.2025 13:36

    Надо назвать диск русской Х и потом требовать разобраться, почему не видно в explorer


    1. AVX
      06.12.2025 13:36

      Диск С

      Даже если какой-то файловый менеджер будет этот диск видеть, пользователь будет озадачен)


      1. toygallery
        06.12.2025 13:36

        и зачем мне два диска цэ - удалюка я первый


  1. AVX
    06.12.2025 13:36

    Тема интересная, особенно если попробовать раскрутить в сторону скрытия каких-либо объектов от антивирусов и т.д. Вполне может быть, что какие-то вредоносы уже это используют.

    Хотелось бы почитать аналогичную статью про имена файлов в NT (NTFS). Есть там ряд ограничений как по набору символов, так и по возможному расположению их (пробелы, точки в конце имени). И судя по всему, эти ограничения со временем менялись.


    1. toygallery
      06.12.2025 13:36

      вредоносы прячутся внутри файловых систем