Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions menuinst/platforms/win.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,35 +338,45 @@ def _write_script(self, script_path: os.PathLike | None = None) -> Path:
return script_path

def _process_command(self, with_arg1: bool = False) -> tuple[str]:
"""Process command and run it via WinLex.quote_args."""
if self.metadata["activate"]:
script = self._write_script()
if self.metadata["terminal"]:
command = ["cmd", "/D", "/K", f'"{script}"']
command = ["cmd", "/D", "/K", str(script)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will removing the quotes be a problem for paths with spaces? I don't see a test covering that example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quotes are actually not removed, it's just that we let WinLex.quote_args do the work instead of manually quoting it here, to have more of a single source of quoting. Note that we return return WinLex.quote_args(command) instead of return command in this PR.

if with_arg1:
command.append("%1")
command.append('"%1"')
else:
system32 = Path(os.environ.get("SystemRoot", "C:\\Windows")) / "system32"
arg1 = "%1 " if with_arg1 else ""
# This is an UGLY hack to start the script in a hidden window
# We use CMD to call PowerShell to call the BAT file
# This flashes faster than Powershell -> BAT! Don't ask me why.
system32 = Path(os.environ.get("SystemRoot", "C:\\Windows")) / "system32"
pwsh = system32 / "WindowsPowerShell" / "v1.0" / "powershell.exe"

arg1 = "%1 " if with_arg1 else ""
# Write the start 'script' as one string (instead of multiple args)
# below to simplify quoting of args later.
start_script = f"start '{script}' {arg1}-WindowStyle hidden"
command = [
f'"{system32 / "cmd.exe"}"',
str(system32 / "cmd.exe"),
"/D",
"/C",
"START",
"/MIN",
'""',
f'"{system32 / "WindowsPowerShell" / "v1.0" / "powershell.exe"}"',
'""', # START requires this empty window title (must remain quoted)
str(pwsh),
"-WindowStyle",
"hidden",
f"\"start '{script}' {arg1}-WindowStyle hidden\"",
start_script,
]
return command

return WinLex.quote_args(command)

# Continue below without the activation
command = self.render_key("command")

# Ensure "%1" is present and quoted when requested
if with_arg1 and all("%1" not in arg for arg in command):
command.append("%1")
command.append('"%1"')
return WinLex.quote_args(command)

def _add_remove_windows_terminal_profile(self, location: Path, remove: bool = False):
Expand Down
136 changes: 113 additions & 23 deletions menuinst/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from contextlib import suppress
from functools import lru_cache, wraps
from logging import getLogger
from pathlib import Path
from pathlib import Path, PurePath
from typing import Any, Callable, Iterable, Literal, Mapping, Optional, Sequence, Union
from unicodedata import normalize

Expand Down Expand Up @@ -136,50 +136,140 @@ def add_xml_child(parent: XMLTree.Element, tag: str, text: Optional[str] = None)


class WinLex:
# Note that there are more characters, for example parenthesis,
# but in our case parenthesis are required to be quoted for example with
# python -c "print('foo')"
_META_CHARS = (">", "<", "|", "&")

@classmethod
def quote_args(cls, args: Sequence[str]):
# cmd.exe /K or /C expects a single string argument and requires
# doubled-up quotes when any sub-arguments have spaces:
# https://stackoverflow.com/a/6378038/3257826
if (
len(args) > 2
and ("CMD.EXE" in args[0].upper() or "%COMSPEC%" in args[0].upper())
and (args[1].upper() == "/K" or args[1].upper() == "/C")
and any(" " in arg for arg in args[2:])
):
args = [
cls.ensure_pad(args[0], '"'), # cmd.exe
args[1], # /K or /C
'"%s"' % (" ".join(cls.ensure_pad(arg, '"') for arg in args[2:])), # double-quoted
]
else:
args = [cls.quote_string(arg) for arg in args]

if len(args) > 2 and cls._is_cmd_exe(args[0]):
# and (args[1].upper() == "/K" or args[1].upper() == "/C")
# and any(" " in arg for arg in args[2:])
switch_index = None
for index in range(1, len(args)):
a = args[index].upper()
if a == "/C" or a == "/K":
switch_index = index
break

# If we found /C or /K, proceed
if switch_index is not None:
tail = args[switch_index + 1 :]
if tail and any(" " in entry for entry in tail):
# Quote the call to cmd.exe only if it needs it
cmd0 = (
cls.ensure_pad(args[0], '"')
if cls._needs_quotes_for_cmd(args[0])
else args[0]
)

# Preserve the flags between cmd and /C or /K
pre_flags = args[1:switch_index]

# For the tail (the command string passed to cmd.exe), quote each
# sub-arg with " only if its needed; then join; then
# wrap the WHOLE tail in one pair of quotes.
tail_parts = [
(cls.ensure_pad(a, '"') if cls._needs_quotes_for_cmd(a) else a)
for a in tail
]
tail_str = " ".join(tail_parts)
return [cmd0, *pre_flags, args[switch_index], f'"{tail_str}"']

# Generic quoting for non-cmd
args = [cls.quote_string(arg) for arg in args]
return args

@classmethod
def quote_string(cls, s: Sequence[str]):
def _is_cmd_exe(cls, s: str) -> bool:
"""
Return True if input refers to cmd.exe or %COMSPEC%. Here, 'refers' implies variants of
cmd, cmd.exe, <some path>/cmd and accounting for quoted input.
Comment on lines +191 to +192
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe call it _is_cmd_exe then? _is_cmd could also be interpreted as "is (a) command"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, fixed!

"""
t = s.strip('"')
# Accept %COMSPEC%
if t.upper() == "%COMSPEC%":
return True
# Accept bare name or any path whose basename startswith 'cmd' and
# endswith '.exe' (or no extension)
name = PurePath(t).name.upper()
return name == "CMD" or name == "CMD.EXE"

@classmethod
def quote_string(cls, s: str):
"""
quotes a string if necessary.
Quote given input if necessary.
This is based on the following rules:
* Preserve already-quoted input..
* Quote args with spaces
* Quote path-like input with variables, for example %FOO%\\foo.exe.
* Quote positional args such as %1, %2, ..., %n where n is a positive integer.
* Don't auto-quote shell metacharacters (>, <, |, &).
* Don't auto-quote just because of '%' (it changes observable output).
"""
# strip any existing quotes
s = s.strip('"')
# don't add quotes for minus or leading space
if s == "":
return '""'

# Don't quote already quoted input.
# Examples: '""', '"%1"', '"C:\\Path With Spaces"'
if len(s) >= 2 and s[0] == s[-1] == '"':
return s

# Don't add quotes for minus or leading space
if s[0] in ("-", " "):
return s
if " " in s or "/" in s:
return '"%s"' % s

# Don't quote shell meta tokens; quoting would change meaning.
# Example: echo %FOO%> output.txt
if cls._has_shell_meta(s):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was added because tests broke from my initial changes (in particular it was the or '%' in s in quote_string that caused it:
https://github.com/conda/menuinst/actions/runs/19366298809

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to understand the impact of this. That would mean that echo %FOO%> output.txt would be quoted differently than echo %FOO% > output.txt

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of more changes now but it works now based on the testing. I've done some additional testing locally with other workflows where menuinst is used.
There are a lot more changes now compared to before, but it's ready for another round of review.

return s

# %1, %2, %3...
if len(s) >= 2 and s[0] == "%" and s[1:].isdigit():
return f'"{s}"'

# Situation with %VAR%\\f.exe or %VAR%/f.exe
if "%" in s and ("/" in s or "\\" in s):
return f'"{s}"'

if " " in s:
return f'"{s}"'
return s

@classmethod
def ensure_pad(cls, name: str, pad: str = "_"):
def _has_shell_meta(cls, a: str) -> bool:
"""
Detect shell metacharacters that must remain unquoted to preserve meaning.
"""
return any(ch in a for ch in cls._META_CHARS)

@classmethod
def _needs_quotes_for_cmd(cls, s: str) -> bool:
"""
Return True if input contains space.
"""
return " " in s

@classmethod
def ensure_pad(cls, name: str, pad: str = "_") -> str:
"""
Pad the input set via 'name' with the string set via keyword-argument 'pad'.
If pad='"', then padding is only added if the input contains a space or
a percentage sign.

Examples:
>>> ensure_pad('conda')
'_conda_'

"""
if not name or name[0] == name[-1] == pad:
if not name:
return name
if name[0] == name[-1] == pad:
return name
else:
return "%s%s%s" % (pad, name, pad)
Expand Down
19 changes: 19 additions & 0 deletions news/404-fix-quotes-registry-entries
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Enhancements

* <news item>

### Bug fixes

* Registry entries are now properly quoted. This resolves an issue where opening files associated with a program could fail if its path included blank spaces. (#290 via #404)

### Deprecations

* <news item>

### Docs

* <news item>

### Other

* <news item>
129 changes: 129 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""
Tests for specific functions in the utils package.
"""

from typing import TYPE_CHECKING

import pytest
from conftest import PLATFORM

pytestmark = pytest.mark.skipif(
PLATFORM != "win", reason="This file contains tests that are Windows only."
)

if TYPE_CHECKING:
from menuinst.utils import WinLex
else:
WinLex = pytest.importorskip("menuinst.utils").WinLex


def test_quote_args_1():
"""Verify that input arguments are quoted."""
wl = WinLex()
test_str = [r"%SystemRoot%\system32\foo.exe", "/pt", "%1", "%2", "%3", "%4"]
output = wl.quote_args(test_str)
assert output == [r'"%SystemRoot%\system32\foo.exe"', "/pt", '"%1"', '"%2"', '"%3"', '"%4"']


def test_quote_args_2():
"""Verify correct quotes with blank spaces."""
wl = WinLex()
args = [
r"C:\Windows\System32\notepad.exe",
"/pt",
r"C:\Users\Foo Bar\file.txt",
"S p a c e s",
]
output = wl.quote_args(args)
assert output == [
r"C:\Windows\System32\notepad.exe",
"/pt",
r'"C:\Users\Foo Bar\file.txt"',
'"S p a c e s"',
]


def test_quote_args_3():
"""Verify special case with cmd.exe and /C."""
args = ["cmd.exe", "/C", "echo", "Hello World"]
wl = WinLex()
output = wl.quote_args(args)
assert output == ["cmd.exe", "/C", '"echo "Hello World""']


def test_quote_args_4():
"""Verify special case with cmd.exe and /K many words with spaces and percentage signs."""
args = ["cmd.exe", "/K", "dir", "Hello World", "%1 %2 %foo% with spaces", "x", "y"]
wl = WinLex()
output = wl.quote_args(args)
assert output == ["cmd.exe", "/K", '"dir "Hello World" "%1 %2 %foo% with spaces" x y"']


def test_quote_args_5():
"""Verify quotation works with mix of paths and spaces."""
cmd = [r"C:\Program Files\App\app.exe", r"C:\Users\Me\My File.txt"]
assert " ".join(WinLex.quote_args(cmd)) == (
r'"C:\Program Files\App\app.exe" "C:\Users\Me\My File.txt"'
)


def test_quote_args_6():
"""Verify command with metachars quotes as expected."""
cmd = ["cmd", "/K", "echo", "%FOO%>", "out.txt"]
line = " ".join(WinLex.quote_args(cmd))
assert "cmd /K echo %FOO%> out.txt" == line


def test_quote_args_7():
"""Test with Powershell and spaced script path."""
cmd = [
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"-WindowStyle",
"hidden",
r"C:\path with spaces\script.ps1",
]
line = " ".join(WinLex.quote_args(cmd))
assert r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" in line
assert r'"C:\path with spaces\script.ps1"' in line


def test_quote_args_8():
"""Verify special case with spaces, cmd.exe and /K and percentage signs."""
args = [
r"C:\path with spaces\cmd.exe",
"/K",
"dir",
"Hello World",
"%1 %2 %foo% with spaces",
"x",
"y",
]
wl = WinLex()
output = wl.quote_args(args)
assert output == [
r'"C:\path with spaces\cmd.exe"',
"/K",
'"dir "Hello World" "%1 %2 %foo% with spaces" x y"',
]


@pytest.mark.parametrize("special_flag", ("/C", "/K"))
def test_quote_args_special_flag(special_flag):
"""Verify special case with spaces, cmd.exe, multiple flags and percentage sign."""
args = [
"cmd.exe",
"/A",
"/B",
special_flag,
r"C:\Foo Bar\bin.exe",
"%1",
]
wl = WinLex()
output = wl.quote_args(args)
assert output == [
"cmd.exe",
"/A",
"/B",
special_flag,
'""C:\\Foo Bar\\bin.exe" %1"',
]
Loading
Loading