Почему ломаются аргументы ── правила аргументов командной строки Windows

· Обновлено: · · Windows, Разработка для Windows, C#, C++, Win32 API, .NET, Процесс

История изменений (первая версия, опубликована 2 Sep 2026)
Первая публикация

«В тестах прошло, а на ПК, где в пути есть пробел, внешнее средство не запускается.» «Перевёл C:\data\ — слилось со следующим аргументом в один.» «Перевёл JSON аргументом, кавычки пропали, другая сторона не разобрала.» Аварии, которые снова и снова бывают в коде, запускающем дочерние процессы. Причина почти никогда не в логике, а в записи без предпосылки: в Windows нет механизма передать «массив аргументов».

CreateProcess, которым в Windows создают процесс, принимает одну строку lpCommandLine. Как бы аккуратно вызывающая сторона ни готовила массив, на границе ОС он всегда склеивается в одну, и принимающая сторона делит снова. Правила деления задаёт среда выполнения принимающей стороны: среда выполнения C, CommandLineToArgvW, среда выполнения .NET, cmd.exe — разный код. Передать аргументы — собрать строку, которую парсер другой стороны разрежет как было.

Статья смотрит не со стороны сценариев PowerShell, а со стороны кода Win32 и .NET, который запускает дочерний процесс: где строка склеивается, где делится, каким правилам следует. Сторону PowerShell (смена передачи аргументов в 7.3, --%, $PSNativeCommandArgumentPassing) разбирает «Как правильно вызывать внешний exe из PowerShell»; здесь копаем слой ниже.

Слой, который разбирает статьяПередачу аргументов PowerShell разбирает другая статья; эта берёт слой ниже — от CreateProcess Win32 и ProcessStartInfo .NET до парсера чужого exeОбласть этой статьиПередача аргументов PowerShell (другая статья)ProcessStartInfo .NETCreateProcessW Win32Одна строка командной строкиПарсер чужого exe

Рис. 1: Под PowerShell — слои .NET и Win32; с какого бы ни запускать, в конце одна строка. Статья разбирает правила этого слоя.

1. Сначала выводы

  • Процессу Windows массив аргументов не передают. Одна строка, переданная в CreateProcess, доходит до нового процесса (полное имя ведущего исполняемого файла ОС иногда дополняет), GetCommandLineW её возвращает. argv собирает сама принимающая сторона.1 2
  • Тело правил деления — три. Делить по пробелу и табуляции, диапазон в двойных кавычках не делить, обратную косую считать особой только когда сразу за ней двойная кавычка (2n — n плюс открытие или закрытие кавычек, 2n+1 — n плюс кавычка как символ).3 4
  • Только ведущий токен (argv[0], имя исполняемого файла) — другое правило: обернуть кавычками можно, экранирование обратной косой не действует. Если lpApplicationNameNULL, толкование пути с пробелами двусмысленно, сначала пробуют C:\Program.exe.1 4
  • На стороне сборки достаточно одного: «если есть пробел или кавычка или пустая строка — обернуть кавычками, обратные косые сразу перед кавычкой и в конце удвоить, кавычку сделать \"». С .NET Core 2.1 это делает ProcessStartInfo.ArgumentList.5 6
  • Форму со двумя смежными кавычками внутри непустого аргумента ("ab""c") приёмники толкуют по-разному — не порождать. "" как пустой аргумент — другое и верно. cmd.exe и пакетные файлы вне этих правил, поэтому недоверенные значения через них не пускать.6 7
  • Предел lpCommandLine — 32 767 единиц кода UTF-16 (включая завершающий нуль; символы вроде эмодзи, суррогатная пара, считают за два), cmd.exe — 8 191 символ. Если близко к пределу — переключаться на файл ответов, только если другая сторона умеет читать @file (или её можно так поправить).1 8

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 28, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Массива аргументов нет ── CreateProcess и одна строка

Второй аргумент CreateProcessW, lpCommandLine, — одна оканчивающаяся нулём строка, в которой имя исполняемого файла и аргументы стоят через пробел. Верхняя граница длины — 32 767 единиц кода UTF-16 включая завершающий нуль (число wchar_t; символы вроде эмодзи, суррогатная пара, тратят два на один видимый символ, поэтому заранее проверять «на глаз» нельзя). Юникодная версия эту строку может переписать, поэтому литерал или буфер const могут дать нарушение доступа.1

Эта строка как есть уходит в параметры нового процесса; дочерний забирает её GetCommandLineW. ОС иногда дополняет полное имя ведущего исполняемого файла, поэтому строка, которую видит дочерний, с переданной родителем совпадает не полностью.2 lpCmdLine, который получает WinMain GUI-приложения, — эта строка без имени программы.9

Путь, которым аргументы доходят до дочернего процессаМассив аргументов вызывающей стороны в lpCommandLine CreateProcess склеивается в одну строку и уходит в новый процесс; дочерний делит строку, забранную GetCommandLineW, своим парсером и собирает argvМассив аргументов вызывающей стороныСклеить в одну строку (ответственность вызывающей стороны)lpCommandLine CreateProcessWПараметры нового процессаСтрока, которую возвращает GetCommandLineWПарсер принимающей стороны делитМассив argv / args

Рис. 2: Массив границу не пересекает. Склейка — ответственность вызывающей стороны, деление — принимающей; исходный массив восстанавливается, только когда правила совпадают.

Важно: склейка и деление идут в разных процессах, разным кодом. Вызывающая сторона не соберёт верно, не зная, «чем другая сторона делит»; принимающая сторона не знает, «как склеили». В ОС линии Unix в execve массив передают как есть, этой задачи нет. Предпосылка, свойственная Windows, но висящая на каждом запуске процесса.

3. Кто делит ── три парсера

Код, которым принимающая сторона режет строку в argv, в основном три.

Принимающая сторона Код деления Когда вызывается
main / wmain C/C++ Стартовый код среды выполнения C MSVC При старте программы сам собирает argc / argv4
Прямой вызов Win32 API CommandLineToArgvW Передают возврат GetCommandLineW и превращают в форму argv3
Main(string[] args) / Environment.GetCommandLineArgs() .NET (обычная конфигурация запуска через apphost / dotnet.exe) Стартовый код среды выполнения C хоста (apphost / dotnet.exe) Хост на Windows — программа wmain; из argv, который собрала среда выполнения C, снимает свои параметры и путь приложения и вместе с путём приложения передаёт среде выполнения. Среда выполнения при запуске собирает массив с именем программы в начале (имя запуска, переданное хостом, иначе путь сборки) для GetCommandLineArgs() и в args у Main передаёт только аргументы без имени программы10 11 12
Конфигурация, где среду выполнения .NET загружают как размещённую библиотеку и аргументы запуска не принимают Собственный код деления среды выполнения .NET (SegmentCommandLine) GetCommandLineArgs() как запасной путь сам делит возврат GetCommandLineW. Реализован под правила среды выполнения C; CommandLineToArgvW не используют, потому что «поведение чуть иное»12

Код деления — три линии: стартовый код среды выполнения C, CommandLineToArgvW, собственный код деления среды выполнения .NET. Скелет правил один, код не тот же. Приложение .NET, запущенное через apphost или dotnet.exe, по сути делится правилами первой линии (стартовый код среды выполнения C), потому что сам хост — программа wmain на среде выполнения C MSVC. В исходниках среды выполнения .NET остаётся комментарий: CommandLineToArgvW чуть иначе себя ведёт, не используем.12 Разница вылезает на краях вроде обращения с ""; в повседневных аргументах почти не наступают, но «одни правила, значит всё пройдёт» на краю даёт аварию.

Три парсера принимающей стороныОдну строку GetCommandLineW делят стартовый код среды выполнения C в C/C++, CommandLineToArgvW при прямом Win32, собственный код деления среды выполнения у .NET, загруженного как размещённая библиотека; скелет правил один, реализации разные. Обычное приложение .NET через apphost или dotnet.exe получает массив, который разделил стартовый код среды выполнения C хостаСтрока GetCommandLineWСтартовый код среды выполнения CCommandLineToArgvWСобственный код деления .NET (при загрузке хостом)То же у .NET через apphost / dotnet.exeСкелет правил один, реализации разные

Рис. 3: Код деления — три линии. Приложение .NET через apphost или dotnet.exe получает массив, который разделил стартовый код среды выполнения C хоста; собственный код деления среды выполнения — запасной путь конфигурации размещённой библиотеки. Снаружи не видно, на чём чужой exe, поэтому практическое решение — собирать строку, которая даёт один результат у любого.

У Main(string[] args) .NET в args имени программы нет; в первом элементе Environment.GetCommandLineArgs() имя программы есть. То же место, что argv[0] C/C++, — второе.13 При обычном запуске параметры хоста и путь приложения (dotnet.exe и app.dll) вроде dotnet app.dll x хост снимает, в args у Main доходит только x.14 GetCommandLineArgs() возвращает массив, в начало которого среда выполнения при запуске добавила имя программы (путь app.dll и x).11 Собственный код деления среды выполнения делит GetCommandLineW только в конфигурации размещённой библиотеки, которая не принимает аргументы запуска; в конфигурации, где машинный хост передаёт свои argc/argv и вызывает Main, args у Main — значения, которые передал хост.

4. Правила деления ── пробел, кавычки, обратная косая

Правила, общие трём парсерам, для argv[1] и дальше.3 4

  1. Аргументы делят пробелом или табуляцией.
  2. Диапазон в двойных кавычках — один аргумент, даже с пробелами. Сами кавычки в аргумент не входят. Кавычки можно начать с середины аргумента; если строка кончилась, не закрыв, до конца — последний аргумент.
  3. Обратная косая — обычный символ. Особое правило только когда сразу за ней двойная кавычка.
  4. Если перед двойной кавычкой 2n обратных косых — выводят n обратных косых, кавычка работает как «начало/конец обёртки».
  5. Если перед двойной кавычкой 2n+1 обратных косых — выводят n обратных косых и кавычку как символ, состояние обёртки не меняется.
  6. Карет (^) не символ экранирования (это правило cmd.exe, не парсера).

У парсера один бит состояния «внутри кавычек»; кавычка его переворачивает, строку читают слева направо. Делить ли по пробелу, решает это состояние.

Поток деления: переключают «внутри или снаружи кавычек»Снаружи кавычек парсер делит аргументы по пробелу; встретив кавычку, входит внутрь и пробел считает частью аргумента; снова встретив кавычку, выходит. Обратная косая особая, только когда сразу за ней кавычкаВстретили кавычкуВстретили кавычкуСразу за обратной косой кавычкаСразу за обратной косой кавычка2n: вывести n и открыть/закрыть2n+1: вывести n и кавычку как символСнаружи кавычек: делить по пробелуВнутри кавычек: пробел тоже часть аргументаПрименить правило обратной косойПеревернуть состояние обёрткиСостояние обёртки не менять

Рис. 4: Тело деления — один бит «внутри кавычек или снаружи» и число обратных косых сразу перед кавычкой.

Правила надёжнее смотреть соответствием ввода и вывода, чем заучивать текстом.

Фрагмент командной строки (вход) Получаемые аргументы Какое правило действует
a b c a, b, c Делить по пробелу
"a b" c a b, c Диапазон в кавычках не делить
C:\data\ next C:\data\, next Сразу за обратной косой не кавычка — обычный символ
"C:\data\\" next C:\data\, next Две перед кавычкой становятся одной, кавычка закрывает
"C:\data\" next C:\data" next Одна — кавычка как символ, обёртка не закрывается и проглатывает следующий аргумент
"say \"hi\"" say "hi" Нечётное число — кавычка как символ
"" Пустая строка Единственная запись пустого аргумента
'a b' 'a, b' У одинарных кавычек особого смысла нет15

Пятая строка — лицо «перевёл C:\data\ — слилось со следующим аргументом в один» из начала. В момент, когда конечную обратную косую пути обернули кавычками, закрывающая кавычка становится символом, обёртка не закрывается.

Как конечная обратная косая проглатывает следующий аргументЕсли путь с конечной обратной косой обернуть кавычками, закрывающая кавычка стоит сразу после одной обратной косой и толкуется как кавычка-символ; обёртка не закрывается и следующий аргумент читается как одинУдвоить обратные косыеВ конце пути в кавычках одна обратная косаяПеред закрывающей кавычкой нечётное числоКавычка выводится как символ, обёртка не закрываетсяДальнейшие пробелы разделителями не становятсяДо следующего аргумента доходит как одинОбёртка закрывается, аргументы разделяются

Рис. 5: Зачем «конечную обратную косую удваивать». Кавычки, написанные без знания правила, ломаются на конце пути.

Где реализации расходятся: две кавычки подряд внутри обёртки

В правилах среды выполнения C MSVC есть ещё пункт: «две подряд кавычки внутри строки в кавычках считают одной кавычкой» (форма вроде "ab""c", не "" пустого аргумента).4 В официальных правилах CommandLineToArgvW этого пункта нет, и код сборки среды выполнения .NET явно избегает порождать эту форму, потому что «кавычка вслед за закрывающей до и после VC 2008 толкуется по-разному».6

Принимающей стороне достаточно знать «такой ввод бывает». Собирающая сторона, чтобы передать кавычку как символ, пусть использует только форму \". Тогда любой парсер даёт один результат.

5. argv[0] — другое правило ── lpApplicationName и проблема Program.exe

Ведущий токен, то есть имя исполняемого файла, вне правил выше. Предпосылка — строка, допустимая как путь файловой системы: обернуть кавычками и включить пробел можно, правило экранирования обратной косой не применяют. Способа включить саму кавычку в argv[0] нет.4 3 Код сборки .NET для первого элемента тоже другой: «если есть пробел — только обернуть кавычками; если есть кавычка — исключение».6

На вызывающей стороне проблема — поведение, когда lpApplicationName у CreateProcessNULL. Тогда исполняемый модуль угадывают по ведущему токену, делённому по пробелам, из lpCommandLine. Если в пути есть пробел, кандидатов несколько, ОС пробует от короткого к длинному.1

Порядок угадывания исполняемого файла, когда lpApplicationName — NULLБез кавычек строка C:\Program Files\MyApp -L -S заставляет CreateProcess сначала проверить C:\Program.exe, затем C:\Program Files\MyApp.exe; если C:\Program.exe есть, запускается онЕстьНетПередать lpApplicationName или обернуть начало кавычкамиПуть без кавычек (с пробелом) в lpCommandLineКандидат 1: пробуют C:\Program.exeЗапускается не тот исполняемый файлКандидат 2: пробуют C:\Program Files\MyApp.exeЗапускается задуманный исполняемый файл

Рис. 6: Путь с пробелом без кавычек в начале пробуют от короткого кандидата. Официальная документация прямо пишет «опасно».

Официальная документация прямо пишет: если положить C:\Program.exe, вместо задуманного приложения запустится он; не передавать NULL в lpApplicationName, а если передаёте — обернуть ведущий путь кавычками.1 На практике делают оба. Полный путь исполняемого файла в lpApplicationName, тот же путь в кавычках в начале lpCommandLine. Когда переданы оба, исполняемый модуль задаёт lpApplicationName, argv[0] дочернего — ведущий токен lpCommandLine. По обычаю их совмещают, иначе ломается код, который свой путь берёт из argv[0]. Свой путь надёжно брать GetModuleFileNameW.4

Как решаются исполняемый модуль и argv[0]Если переданы и lpApplicationName, и lpCommandLine, исполняемый модуль задаёт lpApplicationName, argv[0] дочернего — ведущий токен lpCommandLine. Если они разъедутся, ломается код, который свой путь берёт из argv[0]; свой путь берут GetModuleFileNameWРазъедутся — сломаетсяВместо этогоlpApplicationNameИсполняемый модульВедущий токен lpCommandLineargv[0] дочернегоКод, который свой путь берёт из argv[0]GetModuleFileNameW

Рис. 7: «Что исполняется» и «что в argv[0]» решаются раздельно. Дизайн «свой путь из argv[0]» на этом разделении не стоит.

Ещё: когда lpApplicationNameNULL, часть имени исполняемого файла в lpCommandLine ограничена MAX_PATH.1 Длинные пути — в «MAX_PATH и ловушки путей и имён файлов в Windows — лимит 260 символов, зарезервированные имена, точка в конце, регистр».

6. Правила стороны сборки ── одной функции достаточно

Зная правила деления, «строку, которую другая сторона разрежет как было», получают, идя в обратную сторону. Для каждого аргумента с argv[1] дальше делают следующее.6

  1. Если не пустая строка и нет ни пробела, ни кавычки — ставить как есть.
  2. Иначе обернуть целиком кавычками. Внутри обёртки:
    • k обратных косых сразу перед кавычкой сделать 2k+1, затем кавычку (нечётное — «кавычка как символ»);
    • k обратных косых в конце сделать 2k (сразу перед закрывающей кавычкой чётное — «конец обёртки»);
    • остальные обратные косые как есть.
  3. Пустую строку ставить как "".
Поток решения, как собрать один аргументЕсли аргумент не пуст и нет ни пробела, ни кавычки — ставить как есть; иначе обернуть кавычками, обратные косые сразу перед кавычкой удвоить плюс одна, конечные удвоить, кавычку закрыть с обратной косойНетДаПринять один аргументПуст или есть пробел или кавычка?Ставить как естьКавычка в началоСканировать слеваk обратных косых сразу перед кавычкой → 2k+1k обратных косых в конце → 2kОстальное как естьКавычка в конец

Рис. 8: Сборка — обратное отображение правил деления. Ветвей три; достаточно поправить число обратных косых в конце и сразу перед кавычкой — и любая строка без NUL, укладывающаяся в предел lpCommandLine (32 767 единиц кода UTF-16 включая завершающий нуль), круговым путём проходит, если принимающая сторона делит широкими символами теми же правилами, что CommandLineToArgvW, среда выполнения C и .NET (глава 4) (цель со своей грамматикой сырой командной строки или парсер оболочки посередине — вне области) и не включила раскрытие подстановочных знаков вроде wsetargv.obj (командная строка — строка с завершающим нулём, NUL принципиально не передать; у цели с раскрытием подстановочных знаков аргумент с * или ? заменяется именем файла; глава 8; строка сверх предела CreateProcessW не примет; глава 10).

Это правило прямо отражает асимметрию «обратная косая особая только сразу перед кавычкой». Обратные косые-разделители пути механически удваивать не нужно: трогают только сразу перед кавычкой и в конце.

7. Реализация в .NET ── ArgumentList и Arguments

С .NET Core 2.1 у ProcessStartInfo есть ArgumentList, который берёт эту сборку на себя. Один элемент — один аргумент; добавленную строку заранее экранировать не нужно; в момент Process.Start .NET внутри собирает одну строку и отдаёт ОС.5

var psi = new ProcessStartInfo
{
    FileName = @"C:\Program Files\MyTool\convert.exe",
    UseShellExecute = false,
};
psi.ArgumentList.Add("--input");
psi.ArgumentList.Add(inputPath);      // можно с пробелом, конечной обратной косой, кавычками
psi.ArgumentList.Add("--output");
psi.ArgumentList.Add(outputPath);
psi.ArgumentList.Add("--label");
psi.ArgumentList.Add("");             // пустой аргумент тоже верно уйдёт как ""

using var proc = Process.Start(psi)
    ?? throw new InvalidOperationException("Process.Start вернул null");
proc.WaitForExit();
if (proc.ExitCode != 0)
    throw new InvalidOperationException($"convert.exe завершился неудачей (ExitCode={proc.ExitCode})");

Arguments — свойство, которое передаёт собранную вами одну строку как есть. Они независимы: когда пользуются одним, другое должно быть пустым.16 Официальная документация тоже советует ArgumentList, если в кавычках не уверены.5

Где ArgumentList и Arguments становятся строкойArgumentList — .NET экранирует по элементам, склеивает в одну строку и передаёт в CreateProcess; Arguments — строку, собранную вызывающей стороной, передаёт как есть. К ОС в обоих случаях доходит одна строкаArgumentList (1 элемент = 1 аргумент).NET экранирует по элементам и склеиваетArguments (одна строка, собранная вами)Как естьОдна строка командной строкиCreateProcess

Рис. 9: Что ни взять, ОС получает одну строку. Разница только в «кто собирает»; ArgumentList отдаёт тому, кто знает правила.

Код сборки ArgumentList — сами правила главы 6. Если не пуст и нет ни пробела, ни кавычки — как есть; иначе обернуть кавычками, обратные косые сразу перед кавычкой удвоить плюс одна, конечные удвоить, перед кавычкой всегда обратная косая. Форму смежных кавычек внутри непустого аргумента не порождает. Только пустой аргумент ставят как "" — и это верно.6

На .NET Framework собирают сами

ArgumentList — API с .NET Core 2.1; у ProcessStartInfo .NET Framework его нет.5 В приложении .NET Framework 4.8 и внутренних средствах на нём правила главы 6 пишут сами и передают в Arguments.

// Под .NET Framework. Собрать одну строку для ProcessStartInfo.Arguments.
// Правила те же, что внутри ProcessStartInfo.ArgumentList.
static string BuildArguments(IEnumerable<string> args)
{
    var sb = new StringBuilder();
    foreach (var arg in args)
    {
        if (sb.Length > 0) sb.Append(' ');
        AppendArgument(sb, arg);
    }
    return sb.ToString();
}

static void AppendArgument(StringBuilder sb, string arg)
{
    if (arg.IndexOf('\0') >= 0)
        throw new ArgumentException("В аргументе не может быть символа NUL (командная строка — строка с завершающим нулём, на нём обрежется)");

    bool needsQuote = arg.Length == 0 || arg.Any(c => char.IsWhiteSpace(c) || c == '"');
    if (!needsQuote)
    {
        sb.Append(arg);                       // как есть
        return;
    }

    sb.Append('"');
    int i = 0;
    while (i < arg.Length)
    {
        int backslashes = 0;
        while (i < arg.Length && arg[i] == '\\') { i++; backslashes++; }

        if (i == arg.Length)
        {
            sb.Append('\\', backslashes * 2); // конец: сразу перед закрывающей кавычкой удвоить
        }
        else if (arg[i] == '"')
        {
            sb.Append('\\', backslashes * 2 + 1).Append('"'); // сразу перед кавычкой: удвоить плюс одна
            i++;
        }
        else
        {
            sb.Append('\\', backslashes).Append(arg[i]);      // иначе как есть
            i++;
        }
    }
    sb.Append('"');
}

Ввод и вывод рядом.

Значение, которое хотят передать Строка, которую выводит AppendArgument
strict strict
Пустая строка ""
C:\Program Files\input "C:\Program Files\input"
C:\Program Files\input\ "C:\Program Files\input\\"
say "hi" "say \"hi\""
a\"b "a\\\"b"
C:\data\ (без пробела) C:\data\

Смотрите последнюю строку. Значение без пробела и кавычек не оборачивают, поэтому конечная обратная косая выходит как есть. Без обёртки правила 4 и 5 не срабатывают, и C:\data\ доходит верно.

Выбор средства сборки по версии .NETС .NET Core 2.1 отдают ProcessStartInfo.ArgumentList; на .NET Framework строку Arguments собирают своей функцией по тем же правилам. В обоих случаях кавычки вручную в склейке не пишутCore 2.1 и новееFrameworkВерсия .NET?Добавлять в ArgumentList по одному элементуСвоей функцией собрать ArgumentsКавычки не писать вручную

Рис. 10: Средств два, принцип один. «Кавычки не писать руками» — и авария на конце пути не случается.

Если UseShellExecute = true, идёт не CreateProcess, а ShellExecuteEx; содержимое ArgumentList становится параметром, который передают оболочке. Когда открывают документ или URL, сопоставление файлов собирает фактическую командную строку обработчика, поэтому собранная здесь строка до другой стороны как есть не обязана дойти. Чтобы перенаправить вывод или надёжно взять код выхода, ставят UseShellExecute = false и проектируют одновременное чтение стандартного вывода и стандартной ошибки. Это сведено в «Чек-лист безопасной работы с дочерними процессами в Windows-приложении».

8. Реализация в C++ / Win32

В C++ и сборку, и деление пишут сами. Сборка — правила главы 6 как функция.

#include <windows.h>
#include <string>
#include <stdexcept>
#include <string_view>
#include <vector>

// Добавить один аргумент с argv[1] дальше. Правила — обратные правилам деления CommandLineToArgvW / CRT.
void AppendArgument(std::wstring& cmd, std::wstring_view arg)
{
    if (!cmd.empty()) cmd += L' ';
    if (arg.find(L'\0') != std::wstring_view::npos)
        throw std::invalid_argument("В аргументе не может быть символа NUL (командная строка — строка с завершающим нулём, на нём обрежется)");

    const bool needsQuote =
        arg.empty() || arg.find_first_of(L" \t\"") != std::wstring_view::npos;
    if (!needsQuote) { cmd += arg; return; }

    cmd += L'"';
    for (size_t i = 0; ; ) {
        size_t backslashes = 0;
        while (i < arg.size() && arg[i] == L'\\') { ++i; ++backslashes; }

        if (i == arg.size()) {
            cmd.append(backslashes * 2, L'\\');           // конец: удвоить
            break;
        }
        if (arg[i] == L'"') {
            cmd.append(backslashes * 2 + 1, L'\\');       // сразу перед кавычкой: удвоить плюс одна
            cmd += L'"';
        } else {
            cmd.append(backslashes, L'\\');               // иначе как есть
            cmd += arg[i];
        }
        ++i;
    }
    cmd += L'"';
}

// argv[0] (исполняемый файл) — другое правило: если есть пробел, только обернуть кавычками. Кавычку включить нельзя.
std::wstring QuoteArgv0(std::wstring_view exe)
{
    if (exe.find(L'\0') != std::wstring_view::npos)
        throw std::invalid_argument("В пути исполняемого файла не может быть символа NUL (и lpApplicationName, и командная строка на нём обрежутся, может запуститься путь до него)");
    if (exe.find(L'"') != std::wstring_view::npos)
        throw std::invalid_argument("В пути исполняемого файла кавычки нельзя");
    if (exe.empty() || exe.find_first_of(L" \t") != std::wstring_view::npos)
        return L'"' + std::wstring(exe) + L'"';
    return std::wstring(exe);
}

При вызове полный путь исполняемого файла передают в lpApplicationName, в lpCommandLineбуфер, который можно переписать.

const std::wstring exe = LR"(C:\Program Files\MyTool\convert.exe)";

std::wstring cmd = QuoteArgv0(exe);          // argv[0] совместить с исполняемым файлом
AppendArgument(cmd, L"--input");
AppendArgument(cmd, inputPath);
AppendArgument(cmd, L"--output");
AppendArgument(cmd, outputPath);

std::vector<wchar_t> buffer(cmd.begin(), cmd.end());
buffer.push_back(L'\0');                     // CreateProcessW строку может переписать

STARTUPINFOW si{}; si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(exe.c_str(),             // lpApplicationName: не NULL
                    buffer.data(),           // lpCommandLine: в начале тот же путь в кавычках
                    nullptr, nullptr, FALSE, CREATE_UNICODE_ENVIRONMENT,
                    nullptr, nullptr, &si, &pi)) {
    const DWORD err = GetLastError();
    // err записать в журнал и вернуть вызывающему. не проглатывать
    return;
}
CloseHandle(pi.hThread);                     // дескриптор главного потока не нужен, закрыть сразу

switch (WaitForSingleObject(pi.hProcess, INFINITE)) {   // при необходимости с таймаутом
case WAIT_OBJECT_0: {                        // завершился. код выхода читать только в этой ветви
    DWORD exitCode = 0;
    if (!GetExitCodeProcess(pi.hProcess, &exitCode)) {
        const DWORD err = GetLastError();
        // неудачу получения тоже в журнал и вернуть вызывающему как неудачу
    } else if (exitCode != 0) {
        // другая сторона запустилась, но обработка не удалась. не считать как 0;
        // код выхода в журнал и вернуть вызывающему (то же, что проверка ExitCode в примере C#)
    }
    break;
}
case WAIT_TIMEOUT:
    // ещё работает. GetExitCodeProcess здесь вернёт только STILL_ACTIVE(259),
    // это не код выхода. в этом примере политика «превышение времени свернуть как неудачу»:
    // дождаться завершения, только если запрос на завершение прошёл, затем к CloseHandle ниже.
    // если политика — продолжать ждать, здесь break и закрывать дескриптор нельзя
    // (отпустить дочернего, пока он ещё работает). вернуться к ожиданию
    if (!TerminateProcess(pi.hProcess, 1)) {
        const DWORD err = GetLastError();
        // завершить не удалось (нехватка прав и т. п.). ждать INFINITE здесь обессмыслит
        // срок, поставленный против превышения времени. err в журнал и вернуть вызывающему
        // как неудачу, не ожидая (дочерний отпускают ещё работающим — это тоже в журнал)
        break;
    }
    WaitForSingleObject(pi.hProcess, INFINITE); // запрос на завершение прошёл, дождаться завершения, потом закрыть
    // превышение времени вернуть вызывающему как неудачу
    break;
default: {                                   // WAIT_FAILED
    const DWORD err = GetLastError();
    // неудачу самого ожидания тоже в журнал
    break;
}
}
CloseHandle(pi.hProcess);                    // забыть закрыть — при каждом запуске утечёт один дескриптор
Разделение ролей двух аргументов CreateProcessWlpApplicationName фиксирует исполняемый модуль, lpCommandLine задаёт строку, которую дочерний получит GetCommandLineW. lpCommandLine передают буфером, который можно переписать; ведущий argv[0] совмещают с lpApplicationNameСовместитьlpApplicationName: полный путь исполняемого файлаИсполняемый модуль фиксируетсяlpCommandLine: буфер, который можно переписатьСтрока, которую дочерний получит GetCommandLineWВедущий токен = argv[0]Дальше = аргументы, собранные правилами главы 6

Рис. 11: «Что исполнять» и «что передать» задают разные аргументы. Явно указать оба — не будет ни проблемы Program.exe, ни нарушения доступа на буфере, который нельзя переписать.

На принимающей стороне возврат GetCommandLineW передают в CommandLineToArgvW и получают форму argv. Возврат освобождают одним LocalFree. Крайнее поведение: пустая строка lpCmdLine возвращает путь текущего исполняемого файла; ведущий пробел делает первый аргумент пустой строкой.3

int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (argv == nullptr) {
    const DWORD err = GetLastError();
    // неудачу разбора тоже в журнал
    return 1;
}
for (int i = 0; i < argc; ++i) {
    // argv[0] — имя исполняемого файла. ОС иногда дополняет полный путь
}
LocalFree(argv);

Если пользуются main / wmain, то же при старте делает среда выполнения C. Но argv у main — узкая строка, переведённая в текущую кодовую страницу, поэтому символы, которые кодовая страница не представляет (японский путь на ПК не в японской среде и т. п.), здесь теряются. Функция сборки главы 6 «круговым путём проходит» против принимающей стороны, которая делит широкими символами: wmain, CommandLineToArgvW, .NET. По умолчанию подстановочные знаки не раскрывают, но связав setargv.obj (для wmainwsetargv.obj), раскрывают * и ?.4 Если цель с этой настройкой получает аргумент с * в имени файла, дойдут не те аргументы, что задумывали.

9. Когда посередине cmd.exe и пакетный файл

Правила выше — когда из CreateProcess прямо до чужого exe. Если посередине cmd.exe, входит ещё один этап толкования.

cmd.exe считает &, |, (, ) грамматикой; чтобы передать их как аргумент, экранируют ^ или оборачивают кавычками. У обращения с кавычками строки после /c и /k свои правила: снимать ли внешние кавычки, зависит от наличия /s, числа кавычек и особых символов.17 Пакетный файл аргументы не делит и принимает сырую строку командной строки. Официальная документация PowerShell прямо предупреждает не передавать пакетному файлу недоверенный ввод.7 Документация CreateProcess пишет, что для запуска пакета в lpApplicationName указывают cmd.exe и передают /c и имя пакета, и сноской — что инженерная команда MSRC это не рекомендует, со ссылкой на разбор MS14-019.1 MS14-019 закрыл захват, когда пакет передавали в CreateProcess напрямую и cmd.exe искали сначала в текущем каталоге; рекомендация MSRC — «передать полностью квалифицированный путь cmd.exe, пакет — его аргументом».18 То есть проблема — запускать пакет, не указывая cmd.exe полным путём (lpApplicationName = NULL, запуск от имени пакета), а не сам запуск /c с полным путём cmd.exe в lpApplicationName.

cmd.exe посередине добавляет этапы толкованияПрямой запуск чужого exe — одно деление парсером другой стороны; через cmd.exe /c добавляется толкование грамматики cmd.exe, а пакетный файл принимает сырую строку, поэтому правила кавычек меняются на каждом этапеСвой процесс → чужой exeДеление одно: парсер другой стороныСвой процесс → cmd.exe /c → чужой exeДобавляется толкование грамматики cmd.exe (амперсанд, конвейер, круглые скобки, карет)Деление парсером другой стороныСвой процесс → cmd.exe /c → пакетПакет принимает сырую строкуПропустить недоверенное значение — внедрение команд

Рис. 12: Чем больше этапов, тем больше смешение правил. Что можно запустить напрямую — запускать напрямую; в пакет значения извне не передавать.

Практическое решение простое. Если другая сторона — exe, cmd.exe не вставлять. Если без .bat не обойтись, принцип — не давать пакету толковать значения извне. Значение пишут в файл, пакет передаёт путь этого файла нижестоящему exe фиксированной строкой, содержимое читает сторона exe. Положить в переменную среды не граница: как только пакет раскроет %VAR%, & и | cmd.exe толкует снова. В переменную среды можно, только если нижестоящий exe читает её напрямую, минуя пакет. Если и это трудно — содержимое пакета переносят в PowerShell или свой exe («Стоит ли переносить BAT на PowerShell ── критерии решения и практика миграции»).

10. Предел длины

Предел разный по пути.

Путь Предел Источник
lpCommandLine у CreateProcess 32 767 единиц кода UTF-16 (включая завершающий нуль; суррогатная пара — два) 1
Часть имени исполняемого файла, когда lpApplicationNameNULL MAX_PATH 1
Командная строка cmd.exe (включая строки внутри пакета) 8 191 символ 8
ProcessStartInfo.Arguments .NET Длина строки (единицы кода UTF-16) меньше 32 699 16

Дизайн «перечень файлов и подобные переменной длины значения ставить аргументами» в день, когда число вырастет, наступит на предел. Для применений, близких к пределу, переключайтесь на способ файла ответов: значения пишут в один файл и передают только путь этого файла. Официальный обход ограничения cmd.exe — тот же способ.8 Но ни CreateProcess, ни cmd.exe файл сами не раскроют. Способ стоит, только если чужая программа умеет читать файл ответов синтаксисом вроде @file, или её можно так поправить. Если цель — готовый exe, в который руки не вложить, остаётся дробить вызовы, чтобы уложиться в предел.

Предел дизайна «передать значения переменной длины аргументами» и обходПеречень файлов и подобные значения переменной длины аргументами при росте числа упираются в 8191 символ cmd.exe или 32767 единиц кода UTF-16 CreateProcess. Если цель умеет читать файл ответов (или её можно так поправить) — значения пишут в файл и передают только путь; если готовый exe читать не умеет — дробят вызовыЦель умеет читать файл ответовГотовый exe читать не умеетЗначения переменной длины (перечень файлов и т. п.) ставить аргументамиЧисло растёт — строка удлиняетсяДоходят до предела (cmd.exe 8 191 / CreateProcess 32 767)В какой-то день запуск внезапно не удаётсяЗначения в файл, передать только путь (файл ответов)Дробить вызовы

Рис. 13: Предел — задача вида «сегодня ещё нормально». Аргументы, которые растут пропорционально числу, с самого начала так, если цель умеет читать файл ответов (или её можно так поправить).

11. Как проверить, что на самом деле дошло

Прежде чем наугад добавлять кавычки, смотреть аргументы, которые дошли до другой стороны, — самый короткий путь. Смотреть три вещи: «строка, собранная на вызывающей стороне», «строка, дошедшая до другой стороны», «массив после деления»; средств четыре. Сначала одно обещание. Любым средством, журналируя командную строку, секреты перед записью заменяют заглушкой. Если в аргументах по дизайну пароль, ключ API, токен — и в журнале вызывающей стороны, и в журнале запуска другой стороны запись как есть оставит секрет в журнале. Журнал держат дольше процесса, видит больше людей. Командную строку, как ниже у Process Explorer, может прочитать другой процесс на той же машине, поэтому пароль и токен аргументом не передают — корень меры: стандартный ввод или защищённое хранилище настроек; заглушка в журнале — поверх этого. Либо толкуют аргументы после деления (на вызывающей стороне — элементы до сборки) и значения опасных параметров перед записью заменяют заглушкой, либо запись сырой строки включают только в ограниченном диагностическом режиме.

  1. На вызывающей стороне оставить в журнале собранную строку. lpCommandLine сразу перед передачей в CreateProcess. Эта сверка исходит из запуска с UseShellExecute = false или прямого CreateProcess. При UseShellExecute = true документ или URL идут через ShellExecuteEx, сопоставление файлов собирает фактическую командную строку (глава 7), поэтому несовпадение строки вызывающей стороны и строки другой стороны бывает и без cmd.exe и пакета — это не задача главы 9. Если пользуются ArgumentList .NET, порядок элементов как есть для сравнения не годится. Элементы — значения до кавычек и удвоения конечной обратной косой; ОС получает строку, которую из них собрал .NET. Либо заново соберите одну строку из элементов теми же правилами, что BuildArguments главы 7 (тот же результат, что внутренняя сборка ArgumentList), либо сравнивайте порядок элементов напрямую с массивом после деления. Только это средство видит «исходный буфер вызывающей стороны»; Process Explorer ниже и журнал другой стороны, если посередине cmd.exe или пакет, покажут только строку, которую тот этап собрал заново. Записывая, по обещанию в начале значения элементов, которые могут быть секретом, заменяют заглушкой (заглушенный элемент со строкой другой стороны уже не совпадёт — сравнивают, исключив этот элемент).
  2. Приготовить exe, который только показывает аргументы. Запускают вместо чужого exe и выводят дошедшие args по одному на строку. Писать значение как есть — аргумент с переводом строки или управляющим символом выглядит несколькими строками или затирает соседние, считают неправильно; поэтому выводят форму, экранированную как строка JSON, и длину (экранирование обратимо, исходное значение восстанавливается). Но парсеров, как в главе 3, три линии, на краю вроде двух кавычек подряд внутри обёртки толкование расходится. Берите показывающий exe, собранный на той же среде выполнения, что цель (если цель — C/C++ MSVC, то C++ с wmain; если .NET — .NET). Если цель — ваша программа, надёжнее не вставлять показывающий exe, а в журнале запуска самой цели оставить argv (по правилу заглушки следующего пункта). Для .NET хватит нескольких строк.
using System.Text.Encodings.Web;
using System.Text.Json;

// Перевод строки, управляющие символы, кавычки, обратную косую экранировать; кириллицу оставить как есть
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };

Console.WriteLine("CommandLine: " + JsonSerializer.Serialize(Environment.CommandLine, json)); // одна строка
for (int i = 0; i < args.Length; i++)
    Console.WriteLine($"[{i}] len={args[i].Length} {JsonSerializer.Serialize(args[i], json)}");
    // после деления. один элемент всегда на одной строке; пустая строка видна как len=0 и "". len — единицы кода UTF-16
  1. Смотреть командную строку дочернего процесса в Process Explorer. Во свойствах процесса видна строка командной строки, которую держит дочерний. Средство подтвердить «строку, дошедшую до другой стороны»; «массив после деления» не видно. Показана строка, которую держит сторона дочернего, поэтому, как в главе 2, ведущее имя исполняемого файла ОС иногда дополняет полным путём, а если посередине cmd.exe или пакет — видна строка, которую собрал cmd.exe. Не пугаться одной разницы ведущего токена; исходную строку вызывающей стороны видит только журнал пункта 1. Как пользоваться — в «Практическое руководство Sysinternals Process Explorer / Handle / VMMap».
  2. При запуске своего приложения оставить в журнале принятую командную строку. Когда на месте говорят «не запускается», если осталось, какой строкой запускали, сразу отделяют, задача ли это аргументов. И здесь возврат GetCommandLineW как есть не сохраняют. По обещанию в начале толкуют аргументы после деления и значения, которые могут быть секретом, заменяют заглушкой, либо запись сырой строки включают только в ограниченном диагностическом режиме.

Порядок сверки такой. Сначала строку вызывающей стороны (пункт 1) сравнивают со строкой другой стороны (пункт 3 или 4). Если без ведущего имени исполняемого файла не совпадают — деформировал промежуточный этап. Прямой запуск — cmd.exe или пакет (глава 9); UseShellExecute = true — сопоставление оболочки (глава 7). Замена на функцию главы 6 не починит. Если совпадают — эту строку сверяют с массивом после деления (пункт 2). Режется по правилам, но не тот массив, что хотели — задача стороны сборки; не режется по правилам — задача парсера принимающей стороны.

Порядок разбора неполадки аргументовСначала сравнивают журнал строки, собранной на вызывающей стороне, со строкой другой стороны, видимой в Process Explorer или журнале запуска. Если без ведущего имени исполняемого файла не совпадают — деформировал промежуточный этап (прямой запуск — cmd.exe или пакет, UseShellExecute=true — сопоставление оболочки). Если совпадают — сверяют с массивом после деления: режется по правилам, но не тот массив — задача стороны сборки; не режется по правилам — задача парсера принимающей стороныНетДаДа: режется, но не тот массивНет: не режется по правиламАргументы не теСмотреть строку, собранную на вызывающей стороне (журнал вызывающей стороны)Смотреть строку другой стороны (Process Explorer / журнал запуска другой стороны)Совпадает без ведущего имени исполняемого файла?Деформировал промежуточный этап (главы 9 и 7)Смотреть массив после деления (показывающий exe на той же среде выполнения, что цель)Строка и массив соответствуют правилам?Задача стороны сборки: заменить на функцию главы 6Задача парсера принимающей стороны

Рис. 14: Сравнив по порядку «строку вызывающей стороны», «строку другой стороны», «массив», ответственность механически оказывается у промежуточного этапа, стороны сборки или принимающей стороны. Экранирование наугад — после этой проверки.

12. Грубая таблица выбора

Ситуация Что делать
Запускать exe из .NET Core 2.1 и новее / .NET 5 и новее Добавлять в ProcessStartInfo.ArgumentList по одному элементу
Запускать exe из .NET Framework Собирать Arguments функцией правил главы 6. Кавычки не писать руками
Запускать из C++ Передать lpApplicationName, lpCommandLine собрать по правилам в буфер, который можно переписать
В значении аргумента нужна кавычка Только форма \". Внутри непустого аргумента кавычки не ставить смежно
В конце пути обратная косая Если оборачиваете — конец удвоить. Без пробела не оборачивать
Передать пустой аргумент Поставить "". Пропустить — пропадёт сам аргумент
В пути исполняемого файла пробел Передать lpApplicationName, ведущий токен тоже обернуть кавычками
Без .bat не обойтись Не давать пакету толковать значения извне. Писать в файл, читать нижестоящим exe (переменная среды, которую пакет раскрывает %VAR%, не граница)
Аргументы длиннеют Если цель умеет читать файл ответов (или её можно поправить) — переключить на файл ответов. Готовый exe — дробить вызовы
Неясно, что доходит По порядку сверить журнал вызывающей стороны, строку другой стороны (Process Explorer / журнал запуска), массив после деления (показывающий exe на той же среде выполнения, что цель)

13. Итог

Аргументы командной строки Windows границу пересекают не массивом, а одной строкой. Склеивает вызывающая сторона, делит принимающая; правила деления сводятся к трём: «делить по пробелу», «обернуть кавычками», «обратная косая особая только сразу перед кавычкой». Только ведущее имя исполняемого файла — другое правило; опущение lpApplicationName делает толкование пути с пробелами двусмысленным.

На стороне сборки всё укладывается в одну функцию; с .NET Core 2.1 её несёт ArgumentList. Исполняемый файл — полный путь в lpApplicationName и тот же путь в кавычках в начале lpCommandLine (в .NET отдают FileName). Форму смежных кавычек внутри непустого аргумента не порождают ("" пустого аргумента — другое); в cmd.exe и пакетный файл значения извне не пускают; аргументы, которые растут пропорционально числу, — файл ответов, только если цель умеет его читать (или её можно так поправить), иначе дробят вызовы. Эти пять пунктов — и не будет аварий «не запускается только на ПК с пробелом» и «конечная обратная косая съела следующий аргумент».

Пять обещаний против аварий аргументовИсполняемый файл — полный путь в lpApplicationName и ведущий токен в кавычках; кавычки отдают функции по правилам или ArgumentList; форму смежных кавычек внутри непустого аргумента не порождают; в cmd.exe и пакет значения извне не пускают; растущие аргументы — файл ответов, если цель умеет читать. Исходя из того, что цель толкует опубликованными правилами деления и не включила раскрытие подстановочных знаков, эти пять пунктов предотвращают аварии пути с пробелом и конечной обратной косойПолный путь в lpApplicationName, ведущий токен тоже в кавычкахКавычки отдать функции по правилам или ArgumentListФорму смежных кавычек внутри обёртки не порождатьВ cmd.exe и пакет значения извне не пускатьРастущие аргументы — файл ответов (если цель умеет читать)Аварий пробела и конечной обратной косой нет

Рис. 15: Все пять обещаний — перефраз «фиксировать исполняемый модуль и передавать только строку, которую парсер другой стороны разрежет». Предпосылка — цель толкует опубликованными правилами деления и не включила раскрытие подстановочных знаков (главы 6 и 8); на ней эти пять пунктов предотвращают аварии пробела и конечной обратной косой.

Когда не получается — прежде чем наугад добавлять экранирование, смотрите три: строку, собранную на вызывающей стороне, строку, дошедшую до другой стороны, массив после деления. Если строки вызывающей стороны и другой стороны разные — промежуточный этап (cmd.exe или пакет, при UseShellExecute = true — сопоставление оболочки); если те же — по соответствию строки и массива решается, сторона сборки или принимающая.

Похожие статьи

Смежные области консультирования

Компания KomuraSoft LLC занимается проектированием Windows-приложений, которые сочетают внешние средства и внутренние EXE; расследованием запуска дочернего процесса, который «в одной среде запускается, в другой нет»; пересмотром запуска процессов при переходе с .NET Framework на .NET. Можно начать с одного случая «аргументы портятся».

Справочные ссылки

  1. Microsoft Learn, CreateProcessW function (processthreadsapi.h). О том, что lpCommandLine — одна строка максимум 32 767 символов (включая завершающий нуль; широкая строка, поэтому единицы кода UTF-16); юникодная версия содержимое может переписать, поэтому память только для чтения передавать нельзя; когда lpApplicationNameNULL, ведущий токен, делённый по пробелам, становится именем модуля, путь с пробелом толкуют начиная с c:\program.exe; опасность, что положенный Program.exe запустит другой исполняемый файл, и что нужно избегать NULL или обернуть кавычками; при указании обоих argv[0] может не совпасть с именем модуля; при NULL часть имени модуля ограничена MAX_PATH; для запуска пакетного файла нужен cmd.exe /c. Вместе с CreateProcessA function: сноска, что этот способ инженерная команда MSRC не рекомендует (со ссылкой на разбор MS14-019).  2 3 4 5 6 7 8 9 10

  2. Microsoft Learn, GetCommandLineW function (processenv.h). О том, что возвращает строку командной строки текущего процесса; возврат нельзя освобождать и менять; можно передать в CommandLineToArgvW и превратить в форму argv; ОС дополняет полное имя исполняемого файла, поэтому может не совпасть со строкой, которую родитель передал в CreateProcess 2

  3. Microsoft Learn, CommandLineToArgvW function (shellapi.h). Об особом обращении с обратной косой сразу перед двойной кавычкой (2n — n плюс открытие или закрытие обёртки, 2n+1 — n плюс кавычка как символ, без следующей кавычки как есть); в режиме «внутри кавычек» пробел — часть аргумента; ведущее имя программы можно обернуть кавычками или нет; если lpCmdLine начинается с пробела, первый аргумент — пустая строка; пустая строка возвращает путь текущего исполняемого файла; возврат освобождают одним LocalFree 2 3 4 5

  4. Microsoft Learn, main function and command-line arguments. О правилах, которыми стартовый код Microsoft C/C++ толкует командную строку (делить по пробелу и табуляции; argv[0] можно обернуть кавычками, последующие правила не применяют; строка в кавычках — один аргумент; карет не символ экранирования; две подряд кавычки внутри кавычек — одна кавычка; без закрывающей до конца — последний аргумент; чётное/нечётное число обратных косых), таблице соответствия ввода и argv, раскрытии подстановочных знаков setargv.obj; о том, что при указании и lpApplicationName, и lpCommandLine argv[0] может не быть именем исполняемого файла и его следует брать GetModuleFileName 2 3 4 5 6 7 8

  5. Microsoft Learn, ProcessStartInfo.ArgumentList Property. О том, что добавленную строку заранее экранировать не нужно; ArgumentList и Arguments независимы и одновременно не используются; ArgumentList экранирует аргументы, внутри собирает одну строку и в Process.Start отдаёт ОС; если в кавычках не уверены, выбирать ArgumentList; опасность сочетания с недоверенными данными; область применения — .NET Core 2.1 и новее.  2 3 4

  6. dotnet/runtime (GitHub), PasteArguments.cs и PasteArguments.Windows.cs. Код сборки внутри ArgumentList. Непустой аргумент без пробела и кавычки — как есть; иначе обернуть кавычками, конечные обратные косые удвоить, сразу перед кавычкой удвоить плюс одна, перед кавычкой всегда обратная косая; форму кавычки вслед за закрывающей не порождают, потому что VC до и после 2008 толкуют по-разному; для argv[0] если есть пробел — только обернуть кавычками, если есть кавычка — исключение.  2 3 4 5 6

  7. Microsoft Learn, about_Parsing. О предупреждении не передавать недоверенный ввод пакетному файлу, потому что аргументы пакетного файла cmd.exe передаёт сырой строкой командной строки.  2

  8. Microsoft Learn, Command prompt (Cmd.exe) command-line string limitation. О том, что максимальная длина строки командной строки — 8 191 символ; применяется и к командной строке внутри пакетного файла; обход — записать аргументы в файл и передать имя этого файла.  2 3

  9. Microsoft Learn, WinMain function (winbase.h). О том, что lpCmdLine — командная строка без имени программы; всю командную строку берут GetCommandLine; юникодная точка входа — wWinMain

  10. dotnet/runtime (GitHub), apphost.c и dotnet.cpp. О том, что точки входа apphost и dotnet.exe на Windows — wmain(int argc, wchar_t* argv[]), и argv, собранный средой выполнения C, как есть передают в запуск хоста. 

  11. dotnet/runtime (GitHub), corhost.cpp. О том, что ExecuteAssembly собирает массив Environment.GetCommandLineArgs() через SetCommandLineArgs(pwzAssemblyPath, argc, argv); первый элемент — имя запуска, переданное хостом (иначе путь сборки), дальше argv; в Main передают только этот argv 2

  12. dotnet/runtime (GitHub), Environment.cs и Environment.Windows.cs. О том, что GetCommandLineArgs возвращает массив, инициализированный при запуске (s_commandLineArgs); в размещённой библиотеке без него запасной путь — возврат GetCommandLineW делит собственный SegmentCommandLine среды выполнения; правила следуют документации функции main MSVC; CommandLineToArgvW не используют, потому что поведение чуть иное.  2 3

  13. Microsoft Learn, Main() and command-line arguments. О том, что args у Main не бывает null; в отличие от C/C++ имя программы в начале args не входит и является первым элементом GetCommandLineArgs()

  14. Microsoft Learn, dotnet command. О форме запуска приложения dotnet [параметры среды выполнения] <путь приложения> [аргументы]; аргументы приложения — то, что после пути приложения. 

  15. Microsoft Learn, Environment.GetCommandLineArgs Method. О том, что первый элемент — имя исполняемого файла; аргументы делят пробелом, двойными кавычками можно включить пробел; у одинарных кавычек этой функции нет; правила чётного/нечётного числа обратных косых и кавычек; таблица соответствия ввода и результата. 

  16. Microsoft Learn, ProcessStartInfo.Arguments Property. О том, что длина строки меньше 32 699; аргументы толкует целевое приложение, нужно соответствовать ожиданиям другой стороны; аргумент с пробелом, обёрнутый кавычками, сами кавычки другой стороне не передаёт; независим от ArgumentList 2

  17. Microsoft Learn, cmd. О том, что &, |, ( ) — особые символы и нужны ^ или кавычки; перечень особых символов, которые следует оборачивать кавычками; условия, при которых при /c /k кавычки сохраняются (без /s, одна пара кавычек, без особых символов, есть пробел, это имя исполняемого файла), и как снимают ведущую кавычку, если условия не выполнены. 

  18. Microsoft Security Response Center, MS14-019 – Fixing a binary hijacking via .cmd or .bat file и Microsoft Security Bulletin MS14-019. О том, что CreateProcess при прямой передаче .cmd / .bat искал cmd.exe сначала в текущем каталоге и его можно было захватить; после исправления всегда берут системный cmd.exe; приложениям следует передавать полностью квалифицированный путь cmd.exe, пакет — аргументом. 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

В Windows нет API, который передаёт массив аргументов?
Нет. CreateProcess принимает одну строку lpCommandLine, и эта строка доходит до нового процесса (полное имя ведущего исполняемого файла ОС иногда дополняет). То, что выглядит массивом argv, внутри принимающего процесса собирают стартовый код среды выполнения C, CommandLineToArgvW или среда выполнения .NET, деля строку. Поэтому «передать аргументы» — то же, что «собрать строку, которую парсер другой стороны снова разрежет как было».
Когда обратная косая становится символом экранирования?
Только когда сразу за ней идёт двойная кавычка. Обратная косая, за которой двойной кавычки нет, остаётся как есть, сколько бы их ни стояло подряд. Если перед двойной кавычкой 2n обратных косых, получаются n обратных косых и открытие или закрытие кавычек; если 2n+1 — n обратных косых и кавычка как символ. Из-за этой асимметрии конечную обратную косую пути нужно удваивать, только когда путь оборачивают кавычками.
Что брать: ProcessStartInfo.ArgumentList или Arguments?
Если значения приходят из переменных — ArgumentList. Один элемент — один аргумент; нужные кавычки и экранирование делает .NET, внутри собирает одну строку и отдаёт ОС. Arguments — свойство, которое передаёт собранную вами строку как есть; они независимы и одновременно не используются. Но ArgumentList — API с .NET Core 2.1, на .NET Framework его нет. На .NET Framework Arguments собирайте функцией из этой статьи.
Можно ли внутри аргумента в кавычках поставить две кавычки подряд?
На стороне сборки не порождайте: приёмники толкуют по-разному. Имеется в виду обернуть непустой аргумент кавычками и внутри поставить две смежные кавычки. "" как пустой аргумент (только две кавычки) — другое, и это верный способ передать пустую строку. По правилам среды выполнения C MSVC две подряд кавычки внутри строки в кавычках считают одной кавычкой, но в официальных правилах CommandLineToArgvW этого нет, а исходники среды выполнения .NET прямо пишут, что форму не порождают, потому что VC до и после 2008 толкуют её по-разному. Чтобы передать кавычку как символ, ставьте перед ней обратную косую — тогда любой парсер даёт один результат.
Если в пути исполняемого файла есть пробел, что безопасно передать в CreateProcess?
Надёжно передать полный путь исполняемого файла в lpApplicationName и тот же путь, обёрнутый кавычками, поставить в начало lpCommandLine. Если lpApplicationName — NULL, CreateProcess угадывает имя исполняемого файла с начала lpCommandLine, деля по пробелам. Для строки C:\\Program Files\\MyApp -L -S сначала проверяет, есть ли C:\\Program.exe, и если там вредоносный файл — запускается он. Официальная документация прямо называет эту опасность и просит не передавать NULL или обернуть путь кавычками.
Те же правила, когда аргументы передают пакетному файлу?
Нет. Пакетный файл толкует cmd.exe, а cmd.exe не делит аргументы и берёт сырую строку командной строки. Символы вроде &, |, круглых скобок и ^ работают как грамматика cmd.exe, поэтому кавычки по правилам CommandLineToArgvW безопасными их не делают. Официальная документация предупреждает не передавать пакетному файлу недоверенный ввод. Значения пишите в файл и читайте не пакетом, а нижестоящим exe, либо перенесите содержимое пакета в PowerShell или свой exe. Положить в переменную среды тоже не граница: как только пакет раскроет %VAR%, cmd.exe снова толкует символы.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог