#!/usr/bin/env python
# cardinal_pythonlib/platformfunc.py
"""
===============================================================================
Original code copyright (C) 2009-2022 Rudolf Cardinal (rudolf@pobox.com).
This file is part of cardinal_pythonlib.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
===============================================================================
**Support for platform-specific problems.**
"""
from collections import OrderedDict
import itertools
from pprint import pformat
import subprocess
import sys
from typing import Any, Dict, Generator, Iterator, List, Tuple, Union
from cardinal_pythonlib.fileops import require_executable
from cardinal_pythonlib.logs import get_brace_style_log_with_null_handler
log = get_brace_style_log_with_null_handler(__name__)
# =============================================================================
# Fix UTF-8 output problems on Windows
# =============================================================================
# https://stackoverflow.com/questions/5419
def fix_windows_utf8_output() -> None:
# Python 3 only now, so nothing to do
return
[docs]def test_windows_utf8_output() -> None:
"""
Print a short string with unusual Unicode characters.
"""
print(
"This is an Е乂αmp١ȅ testing Unicode support using Arabic, Latin, "
"Cyrillic, Greek, Hebrew and CJK code points.\n"
)
if __name__ == "__main__":
fix_windows_utf8_output()
test_windows_utf8_output()
# =============================================================================
# Check package presence on Debian
# =============================================================================
DPKG_QUERY = "dpkg-query"
[docs]def are_debian_packages_installed(packages: List[str]) -> Dict[str, bool]:
"""
Check which of a list of Debian packages are installed, via ``dpkg-query``.
Args:
packages: list of Debian package names
Returns:
dict: mapping from package name to boolean ("present?")
"""
assert len(packages) >= 1
require_executable(DPKG_QUERY)
args = [
DPKG_QUERY,
"-W", # --show
# "-f='${Package} ${Status} ${Version}\n'",
"-f=${Package} ${Status}\n", # --showformat
] + packages
completed_process = subprocess.run(
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
)
encoding = sys.getdefaultencoding()
stdout = completed_process.stdout.decode(encoding)
stderr = completed_process.stderr.decode(encoding)
present = OrderedDict()
for line in stdout.split("\n"):
if line: # e.g. "autoconf install ok installed"
words = line.split()
assert len(words) >= 2
package = words[0]
present[package] = "installed" in words[1:]
for line in stderr.split("\n"):
if line: # e.g. "dpkg-query: no packages found matching XXX"
words = line.split()
assert len(words) >= 2
package = words[-1]
present[package] = False
log.debug("Debian package presence: {}", present)
return present
[docs]def require_debian_packages(packages: List[str]) -> None:
"""
Ensure specific packages are installed under Debian.
Args:
packages: list of packages
Raises:
ValueError: if any are missing
"""
present = are_debian_packages_installed(packages)
missing_packages = [k for k, v in present.items() if not v]
if missing_packages:
missing_packages.sort()
msg = (
f"Debian packages are missing, as follows. Suggest:\n\n"
f"sudo apt install {' '.join(missing_packages)}"
)
log.critical(msg)
raise ValueError(msg)
# =============================================================================
# Get the environment from a subprocess in Windows
# =============================================================================
[docs]def validate_pair(ob: Any) -> bool:
"""
Does the object have length 2?
"""
try:
if len(ob) != 2:
log.warning("Unexpected result: {!r}", ob)
raise ValueError()
except ValueError:
return False
return True
[docs]def consume(iterator: Iterator[Any]) -> None:
"""
Consume all remaining values of an iterator.
A reminder: iterable versus iterator:
https://anandology.com/python-practice-book/iterators.html.
"""
try:
while True:
next(iterator)
except StopIteration:
pass
[docs]def windows_get_environment_from_batch_command(
env_cmd: Union[str, List[str]], initial_env: Dict[str, str] = None
) -> Dict[str, str]:
"""
Take a command (either a single command or list of arguments) and return
the environment created after running that command. Note that the command
must be a batch (``.bat``) file or ``.cmd`` file, or the changes to the
environment will not be captured.
If ``initial_env`` is supplied, it is used as the initial environment
passed to the child process. (Otherwise, this process's ``os.environ()``
will be used by default.)
From https://stackoverflow.com/questions/1214496/how-to-get-environment-from-a-subprocess-in-python,
with decoding bug fixed for Python 3.
PURPOSE: under Windows, ``VCVARSALL.BAT`` sets up a lot of environment
variables to compile for a specific target architecture. We want to be able
to read them, not to replicate its work.
METHOD: create a composite command that executes the specified command,
then echoes an unusual string tag, then prints the environment via ``SET``;
capture the output, work out what came from ``SET``.
Args:
env_cmd: command, or list of command arguments
initial_env: optional dictionary containing initial environment
Returns:
dict: environment created after running the command
""" # noqa: E501
if not isinstance(env_cmd, (list, tuple)):
env_cmd = [env_cmd]
# construct the command that will alter the environment
env_cmd = subprocess.list2cmdline(env_cmd)
# create a tag so we can tell in the output when the proc is done
tag = "+/!+/!+/! Finished command to set/print env +/!+/!+/!" # RNC
# construct a cmd.exe command to do accomplish this
cmd = f'cmd.exe /s /c "{env_cmd} && echo "{tag}" && set"'
# launch the process
log.info("Fetching environment using command: {}", env_cmd)
log.debug("Full command: {}", cmd)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, env=initial_env)
# parse the output sent to stdout
encoding = sys.getdefaultencoding()
def gen_lines() -> Generator[str, None, None]: # RNC: fix decode problem
for line in proc.stdout:
yield line.decode(encoding)
# define a way to handle each KEY=VALUE line
def handle_line(line: str) -> Tuple[str, str]: # RNC: as function
# noinspection PyTypeChecker
parts = line.rstrip().split("=", 1)
# split("=", 1) means "split at '=' and do at most 1 split"
if len(parts) < 2:
return parts[0], ""
return parts[0], parts[1]
lines = gen_lines() # RNC
# consume whatever output occurs until the tag is reached
consume(itertools.takewhile(lambda line: tag not in line, lines))
# ... RNC: note that itertools.takewhile() generates values not matching
# the condition, but then consumes the condition value itself. So the
# tag's already gone. Example:
#
# def gen():
# mylist = [1, 2, 3, 4, 5]
# for x in mylist:
# yield x
#
# g = gen()
# list(itertools.takewhile(lambda x: x != 3, g)) # [1, 2]
# next(g) # 4, not 3
#
# parse key/values into pairs
pairs = map(handle_line, lines)
# make sure the pairs are valid (this also eliminates the tag)
valid_pairs = filter(validate_pair, pairs)
# construct a dictionary of the pairs
result = dict(valid_pairs) # consumes generator
# let the process finish
proc.communicate()
log.debug("Fetched environment:\n" + pformat(result))
return result
# =============================================================================
# Check for a special environment danger (vulnerability) in Windows
# =============================================================================
[docs]def contains_unquoted_target(
x: str, quote: str = '"', target: str = "&"
) -> bool:
"""
Checks if ``target`` exists in ``x`` outside quotes (as defined by
``quote``). Principal use: from
:func:`contains_unquoted_ampersand_dangerous_to_windows`.
"""
in_quote = False
for c in x:
if c == quote:
in_quote = not in_quote
elif c == target:
if not in_quote:
return True
return False
[docs]def contains_unquoted_ampersand_dangerous_to_windows(x: str) -> bool:
"""
Under Windows, if an ampersand is in a path and is not quoted, it'll break
lots of things.
See https://stackoverflow.com/questions/34124636.
Simple example:
.. code-block:: bat
set RUBBISH=a & b # 'b' is not recognizable as a... command
set RUBBISH='a & b' # 'b'' is not recognizable as a... command
set RUBBISH="a & b" # OK
... and you get similar knock-on effects, e.g. if you set RUBBISH using the
Control Panel to the literal
.. code-block:: bat
a & dir
and then do
.. code-block:: bat
echo %RUBBISH%
it will (1) print "a" and then (2) print a directory listing as it RUNS
"dir"! That's pretty dreadful.
See also
https://www.thesecurityfactory.be/command-injection-windows.html
Anyway, this is a sanity check for that sort of thing.
"""
return contains_unquoted_target(x, quote='"', target="&")