From 3c8d7bcea029b55505d74f50acf84102b1e0ba7a Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:30:22 +0000 Subject: [PATCH] Fixes split_args handling of quotes that start or end inside an argument. --- src/manage/scriptutils.py | 68 +++++++++++++++++++++------------------ tests/test_scriptutils.py | 20 ++++++++++++ 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/manage/scriptutils.py b/src/manage/scriptutils.py index 0a517c4..1f74b69 100644 --- a/src/manage/scriptutils.py +++ b/src/manage/scriptutils.py @@ -344,44 +344,50 @@ def split_args(arg_string, argv0=False): """Splits a single argument string into separate unquoted items. If argv0 is True, the first argument is parsed as if it is the executable name. + +Arguments are split using the same rules as the C runtime, so quotes may begin +and end anywhere within an argument. """ args = [] if argv0 and arg_string[:1] == '"': a, _, arg_string = arg_string[1:].partition('"') args.append(a) - arg_buffer = None - quoted_arg = [] - bits = arg_string.strip().split(' ') - while bits: - a = bits.pop(0) - pre, quot, post = a.partition('"') - if arg_buffer: - pre = arg_buffer + pre - arg_buffer = None - if not quot: - if quoted_arg: - quoted_arg.append(pre) - quoted_arg.append(' ') - else: - args.append(pre.replace('\\\\', '\\')) + arg = None + in_quotes = False + backslashes = 0 + i = 0 + while i < len(arg_string): + c = arg_string[i] + i += 1 + if c == '\\': + backslashes += 1 continue - if pre[-1:] == '\\' and (len(pre) - len(pre.rstrip('\\'))) % 2 == 1: - arg_buffer = pre[:-1] + quot - if post: - bits.insert(0, post) + if c == '"': + # 2n backslashes + quote -> n backslashes and toggle quoting + # 2n+1 backslashes + quote -> n backslashes and a literal quote + arg = (arg or '') + '\\' * (backslashes // 2) + if backslashes % 2: + arg += '"' + elif in_quotes and arg_string[i:i + 1] == '"': + # A doubled quote inside quotes is a literal quote + arg += '"' + i += 1 + else: + in_quotes = not in_quotes + backslashes = 0 continue - elif quoted_arg: - quoted_arg.append(pre) - args.append(''.join(quoted_arg).replace('\\\\', '\\')) - quoted_arg.clear() + if backslashes: + arg = (arg or '') + '\\' * backslashes + backslashes = 0 + if c in ' \t' and not in_quotes: + if arg is not None: + args.append(arg) + arg = None continue - - quoted_arg.append(pre) - if pre: - quoted_arg.append(quot) - if post: - bits.insert(0, post) - if quoted_arg: - args.append(''.join(quoted_arg)) + arg = (arg or '') + c + if backslashes: + arg = (arg or '') + '\\' * backslashes + if arg is not None: + args.append(arg) return args diff --git a/tests/test_scriptutils.py b/tests/test_scriptutils.py index bd35f89..f80a343 100644 --- a/tests/test_scriptutils.py +++ b/tests/test_scriptutils.py @@ -279,6 +279,26 @@ def test_quote_args(args, expect): assert args == split_args(expect), expect +# Examples from "Parsing C command-line arguments" in the Microsoft C runtime +# documentation, plus quotes that begin or end partway through an argument. +@pytest.mark.parametrize("arg_string, expect", [pytest.param(*a, id=a[0]) for a in [ + ('"a b c" d e', ['a b c', 'd', 'e']), + ('"ab\\"c" "\\\\" d', ['ab"c', '\\', 'd']), + ('a\\\\\\b d"e f"g h', ['a\\\\\\b', 'de fg', 'h']), + ('a\\\\\\"b c d', ['a\\"b', 'c', 'd']), + ('a\\\\\\\\"b c" d e', ['a\\\\b c', 'd', 'e']), + ('a"b"" c d', ['ab" c d']), + ('--opt="a b" -E', ['--opt=a b', '-E']), + ('-W"ignore"', ['-Wignore']), + ('"a b"c d', ['a bc', 'd']), + ('-X utf8', ['-X', 'utf8']), + ('x "" y', ['x', '', 'y']), + ('a\tb', ['a', 'b']), +]]) +def test_split_args(arg_string, expect): + assert expect == split_args(arg_string) + + @pytest.mark.parametrize("line, expect_id, expect_line", [pytest.param(*a, id=a[0]) for a in [ ("#!/usr/bin/python", "Test1", None), ("#!/usr/bin/python -Es", "Test1", None),