#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Copyright (c) 2024, Geoffrey M. Poore # All rights reserved. # # Licensed under the LaTeX Project Public License version 1.3c: # https://www.latex-project.org/lppl.txt # ''' This Python executable is intended for installation within a TeX distribution, along with Python wheels for the following Python packages: * latexminted: https://pypi.org/project/latexminted/ * latexrestricted: https://pypi.org/project/latexrestricted/ * latex2pydata: https://pypi.org/project/latex2pydata/ * Pygments: https://pypi.org/project/Pygments/ The combined executable plus wheels provide everything that is needed for the Python side of the minted LaTeX package. No additional Python libraries are required. The wheels require Python >= 3.8. If this executable is launched with an earlier Python version, then it will attempt to locate a more recent Python installation and run itself with that Python version in a subprocess. The search for more recent Python versions looks for executables of the form `python3.x` on PATH. If the latexminted Python package is installed separately, outside TeX, then it will create a separate `latexminted` executable as part of the installation process. That makes it possible to install latexminted and dependencies separately and then customize Pygments with additional packages that provide plugins. However, running that separate `latexminted` executable is not straightforward under Windows. Under Windows, if this executable finds a suitable `latexminted` executable elsewhere, outside a TeX installation, then this executable will run that separate `latexminted` executable in a subprocess and exit. There are two reasons for this approach: 1. Under Windows with TeX Live, the default restricted shell escape can only run executables such as `latexminted` that are part of the TeX installation; the executable that runs is not the first executable on PATH. That is part of TeX Live's security measures to prevent running executables in the current working directory, which is typically writable by LaTeX and is the first place Windows checks when searching for executables. This script and the latexrestricted Python package enforce equivalent security, but do so in a less restrictive manner by expanding executable names into executable paths with Python's `shutil.which()` and then comparing the result with locations writable by LaTeX. 2. Under non-Windows operating systems, it is possible to modify PATH so that the desired `latexminted` executable is first. Under Windows, the system PATH is prepended to the user PATH, so a system-wide TeX installation will prevent a user-installed `latexminted` executable from being accessible. Requirements for locating and running a separate `latexminted` executable under Windows: * The separate executable must be the first `latexminted.exe` found on PATH, or it must be the first `latexminted.exe` on PATH that is located under the user home directory. * The separate executable must be outside a TeX installation. There is a check for a `tex.exe` executable in the same directory as `latexminted.exe`. There is a check for the case-insensitive strings "texlive", "miktex", and "tinytex" in the path to the `latexminted` executable. With TeX Live, the path to the `latexminted` executable is also compared to the environment variable `SELFAUTOLOC`. * The separate executable must be outside the current working directory, TEXMFOUTPUT, and TEXMF_OUTPUT_DIRECTORY. * The current directory, TEXMFOUTPUT, and TEXMF_OUTPUT_DIRECTORY cannot be subdirectories of the directory in which the executable is located. ''' __version__ = '0.1.0' import os import pathlib import platform import shutil import subprocess import sys # This is an abbreviated variant of `AnyPath` from latexrestricted: # https://github.com/gpoore/latexrestricted/blob/main/latexrestricted/_anypath.py class Path(type(pathlib.Path())): __slots__ = ( '_cache_key', ) if sys.version_info[:2] < (3, 9): def is_relative_to(self, other): try: self.relative_to(other) return True except ValueError: return False @property def cache_key(self): try: return self._cache_key except AttributeError: self._cache_key = (type(self), self) return self._cache_key _resolved_set = set() def resolve(self): resolved = super().resolve() self._resolved_set.add(resolved.cache_key) return resolved def is_resolved(self) -> bool: return self.cache_key in self._resolved_set # Define function that determines whether subprocess executable paths are # permitted. prohibited_path_roots = set() prohibited_path_roots.add(Path.cwd()) env_TEXMFOUTPUT = os.getenv('TEXMFOUTPUT') env_TEXMF_OUTPUT_DIRECTORY = os.getenv('TEXMF_OUTPUT_DIRECTORY') for env_var in (env_TEXMFOUTPUT, env_TEXMF_OUTPUT_DIRECTORY): if env_var: env_var_path = Path(env_var) prohibited_path_roots.add(env_var_path) prohibited_path_roots.add(env_var_path.resolve()) def is_permitted_executable_path(executable_path, executable_path_resolved): if not executable_path_resolved.is_resolved(): raise Exception('Second argument must be resolved path') if any(e.is_relative_to(p) or p.is_relative_to(e) for e in set([executable_path.parent, executable_path_resolved.parent]) for p in prohibited_path_roots): return False return True # TeX Live allows setting `TEXMFOUTPUT` in LaTeX configuration. # Retrieving that value with kpsewhich follows the approach in latexrestricted: # https://github.com/gpoore/latexrestricted/blob/main/latexrestricted/_latex_config.py env_SELFAUTOLOC = os.getenv('SELFAUTOLOC') env_TEXSYSTEM = os.getenv('TEXSYSTEM') if not env_TEXMFOUTPUT and env_SELFAUTOLOC and (not env_TEXSYSTEM or env_TEXSYSTEM.lower() != 'miktex'): if platform.system() == 'Windows': # Under Windows, shell escape executables will often be launched with # the TeX Live `runscript.exe` executable wrapper. This overwrites # `SELFAUTOLOC` from TeX with the location of the wrapper, so # `SELFAUTOLOC` may not be correct. which_tlmgr = shutil.which('tlmgr') # No `.exe`; likely `.bat` if not which_tlmgr: sys.exit('Failed to find TeX Live "tlmgr" executable on PATH') which_tlmgr_resolved = Path(which_tlmgr).resolve() texlive_bin_path = which_tlmgr_resolved.parent # Make sure executable is *.exe, not *.bat or *.cmd: # https://docs.python.org/3/library/subprocess.html#security-considerations which_kpsewhich = shutil.which('kpsewhich.exe', path=str(texlive_bin_path)) if not which_kpsewhich: sys.exit('Failed to find a TeX Live "tlmgr" executable with accompanying "kpsewhich" executable on PATH') which_kpsewhich_path = Path(which_kpsewhich) which_kpsewhich_resolved = which_kpsewhich_path.resolve() if not texlive_bin_path == which_kpsewhich_resolved.parent: sys.exit(' '.join([ '"tlmgr" executable from PATH resolved to "{}" '.format(which_tlmgr_resolved.as_posix()), 'while "kpsewhich" resolved to "{}";'.format(which_kpsewhich_resolved.as_posix()), '"tlmgr" and "kpsewhich" should be in the same location', ])) if not which_kpsewhich_resolved.name.lower().endswith('.exe'): sys.exit(' '.join([ 'Executable "kpsewhich" resolved to "{}",'.format(which_kpsewhich_resolved.as_posix()), 'but *.exe is required', ])) else: which_kpsewhich = shutil.which('kpsewhich', path=env_SELFAUTOLOC) if not which_kpsewhich: sys.exit(' '.join([ 'Environment variable SELFAUTOLOC has value "{}",'.format(env_SELFAUTOLOC), 'but a "kpsewhich" executable was not found at that location', ])) which_kpsewhich_path = Path(which_kpsewhich) which_kpsewhich_resolved = which_kpsewhich_path.resolve() if not is_permitted_executable_path(which_kpsewhich_path, which_kpsewhich_resolved): # As in the latexrestricted case, this doesn't initially check for the # TeX Live scenario where `TEXMFOUTPUT` is set in a `texmf.cnf` config # file to a location that includes the `kpsewhich` executable. There # isn't a good way to get the value of `TEXMFOUTPUT` without running # `kpsewhich` in that case. sys.exit( 'Executable "kpsewhich" is located under the current directory, TEXMFOUTPUT, or ' 'TEXMF_OUTPUT_DIRECTORY, or one of these locations is under the same directory as the executable' ) kpsewhich_cmd = [which_kpsewhich_resolved.as_posix(), '--var-value', 'TEXMFOUTPUT'] try: kpsewhich_proc = subprocess.run(kpsewhich_cmd, shell=False, capture_output=True) except PermissionError: sys.exit('Insufficient permission to run "{}"'.format(which_kpsewhich_resolved.as_posix())) kpsewhich_TEXMFOUTPUT = kpsewhich_proc.stdout.decode(sys.stdout.encoding) or None if kpsewhich_TEXMFOUTPUT: kpsewhich_TEXMFOUTPUT_path = Path(kpsewhich_TEXMFOUTPUT) prohibited_path_roots.add(kpsewhich_TEXMFOUTPUT_path) prohibited_path_roots.add(kpsewhich_TEXMFOUTPUT_path.resolve()) if not is_permitted_executable_path(which_kpsewhich_path, which_kpsewhich_resolved): # It is now possible to check for the TeX Live scenario where # `TEXMFOUTPUT` is set in a `texmf.cnf` config file to a location that # includes the `kpsewhich` executable. Giving an error message after # already running `kpsewhich` isn't ideal, but there isn't a good # alternative. As in the latexrestricted case, the impact on overall # security is negligible because an unsafe value of `TEXMFOUTPUT` # means that all TeX-related executables are potentially compromised. sys.exit( 'Executable "kpsewhich" is located under the current directory, TEXMFOUTPUT, or ' 'TEXMF_OUTPUT_DIRECTORY, or one of these locations is under the same directory as the executable' ) # If Python version is < 3.8, try to locate a more recent version and then # relaunch this script with that Python version in a subprocess. if sys.version_info[:2] < (3, 8): for minor_version in range(13, 7, -1): if platform.system() == 'Windows': # Batch files must be prohibited: # https://docs.python.org/3/library/subprocess.html#security-considerations which_python = shutil.which('python3.{}.exe'.format(minor_version)) else: which_python = shutil.which('python3.{}'.format(minor_version)) if which_python: which_python_path = Path(which_python) which_python_resolved = which_python_path.resolve() if platform.system() == 'Windows' and not which_python_resolved.name.lower().endswith('.exe'): continue if is_permitted_executable_path(which_python_path, which_python_resolved): python_cmd = [which_python_resolved.as_posix(), __file__] + sys.argv[1:] python_proc = subprocess.run(python_cmd, shell=False, capture_output=True) sys.stderr.buffer.write(python_proc.stderr) sys.stdout.buffer.write(python_proc.stdout) sys.exit(python_proc.returncode) sys.exit('latexminted requires Python >= 3.8, but a compatible Python executable was not found on PATH') # Check for required wheel dependencies and add them to Python's `sys.path`. script_resolved = Path(__file__).resolve() required_wheel_packages = ( 'latexminted', 'latexrestricted', 'latex2pydata', 'pygments', ) wheel_paths = [p for p in script_resolved.parent.glob('*.whl') if p.name.startswith(required_wheel_packages)] if not wheel_paths: sys.exit('latexminted failed to find bundled wheels *.whl') for pkg in required_wheel_packages: if not any(whl.name.startswith(pkg) for whl in wheel_paths): sys.exit('latexminted failed to find all required bundled wheels *.whl') for wheel_path in wheel_paths: sys.path.insert(0, wheel_path.as_posix()) # Under Windows, check PATH for a `latexminted` executable outside a TeX # installation. If a `latexminted` executable is found in a suitable location # with sufficient precedence, run it in a subprocess and exit. # # The environment variable `LATEXMINTED_SUBPROCESS` is used to prevent an # endless recursion of subprocesses in the event that a `latexminted` # executable *inside* a TeX installation somehow manages to pass the tests for # an executable *outside* a TeX installation. if platform.system() == 'Windows' and not os.getenv('LATEXMINTED_SUBPROCESS'): os.environ['LATEXMINTED_SUBPROCESS'] = '1' fallback_path_search = True if env_SELFAUTOLOC: env_SELFAUTOLOC_resolved = Path(env_SELFAUTOLOC).resolve() else: env_SELFAUTOLOC_resolved = None which_latexminted = shutil.which('latexminted.exe') if which_latexminted: which_latexminted_path = Path(which_latexminted) which_latexminted_resolved = which_latexminted_path.resolve() if not which_latexminted_resolved.name.lower().endswith('.exe'): sys.exit(' '.join([ 'Executable "latexminted" resolved to "{}",'.format(which_latexminted_resolved.as_posix()), 'but *.exe is required', ])) if which_latexminted_resolved == script_resolved: pass elif (which_latexminted_resolved.parent / 'tex.exe').exists(): pass elif any(x in which_latexminted_resolved.as_posix().lower() for x in ('texlive', 'miktex', 'tinytex')): pass elif env_SELFAUTOLOC_resolved and which_latexminted_resolved.is_relative_to(env_SELFAUTOLOC_resolved): pass elif is_permitted_executable_path(which_latexminted_path, which_latexminted_resolved): latexminted_cmd = [which_latexminted_resolved.as_posix()] + sys.argv[1:] latexminted_proc = subprocess.run(latexminted_cmd, shell=False, capture_output=True) sys.stderr.buffer.write(latexminted_proc.stderr) sys.stdout.buffer.write(latexminted_proc.stdout) sys.exit(latexminted_proc.returncode) else: # If there was a `latexminted` executable on PATH outside a TeX # installation, but it wasn't permitted due to its location, don't # perform fallback search. fallback_path_search = False if fallback_path_search: # Windows appends user PATH to system PATH, so the system PATH may # prevent finding a user installation of `latexminted`. Search # through PATH elements under user home directory to check for # `latexminted.exe` outside a TeX installation. home_path = Path.home() env_PATH = os.environ.get('PATH', '') for path_elem in env_PATH.split(os.pathsep): if not path_elem or not Path(path_elem).is_relative_to(home_path): continue which_latexminted = shutil.which('latexminted.exe', path=path_elem) if not which_latexminted: continue which_latexminted_path = Path(which_latexminted) which_latexminted_resolved = which_latexminted_path.resolve() if which_latexminted_resolved == script_resolved: break elif (which_latexminted_resolved.parent / 'tex.exe').exists(): break elif any(x in which_latexminted_resolved.as_posix().lower() for x in ('texlive', 'miktex', 'tinytex')): break elif env_SELFAUTOLOC_resolved and which_latexminted_resolved.is_relative_to(env_SELFAUTOLOC_resolved): break elif is_permitted_executable_path(which_latexminted_path, which_latexminted_resolved): latexminted_cmd = [which_latexminted_resolved.as_posix()] + sys.argv[1:] try: latexminted_proc = subprocess.run(latexminted_cmd, shell=False, capture_output=True) except PermissionError: break sys.stderr.buffer.write(latexminted_proc.stderr) sys.stdout.buffer.write(latexminted_proc.stdout) sys.exit(latexminted_proc.returncode) else: break from latexminted.cmdline import main main()