├── .bumpversion.cfg ├── .cookiecutterrc ├── .coveragerc ├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── github-actions.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── ci ├── bootstrap.py ├── py27 │ └── Dockerfile ├── py34 │ └── Dockerfile ├── py35 │ └── Dockerfile ├── requirements.txt └── templates │ ├── .github │ └── workflows │ │ └── github-actions.yml │ └── Dockerfile ├── pyproject.toml ├── pytest.ini ├── setup.py ├── src └── pysu │ ├── __init__.py │ ├── __main__.py │ └── cli.py ├── tests ├── test-alpine.Dockerfile ├── test-debian.Dockerfile ├── test_docker.py └── test_pysu.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.0.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version="{current_version}" 8 | replace = version="{new_version}" 9 | 10 | [bumpversion:file (badge):README.rst] 11 | search = /v{current_version}.svg 12 | replace = /v{new_version}.svg 13 | 14 | [bumpversion:file (link):README.rst] 15 | search = /v{current_version}...master 16 | replace = /v{new_version}...master 17 | 18 | [bumpversion:file:src/pysu/__init__.py] 19 | search = __version__ = "{current_version}" 20 | replace = __version__ = "{new_version}" 21 | 22 | [bumpversion:file:.cookiecutterrc] 23 | search = version: {current_version} 24 | replace = version: {new_version} 25 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # Generated by cookiepatcher, a small shim around cookiecutter (pip install cookiepatcher) 2 | 3 | default_context: 4 | c_extension_optional: 'no' 5 | c_extension_support: 'no' 6 | codacy: 'no' 7 | codacy_projectid: '[Get ID from https://app.codacy.com/gh/ionelmc/python-su/settings]' 8 | codeclimate: 'no' 9 | codecov: 'no' 10 | command_line_interface: plain 11 | command_line_interface_bin_name: pysu 12 | coveralls: 'no' 13 | distribution_name: pysu 14 | email: contact@ionelmc.ro 15 | formatter_quote_style: double 16 | full_name: Ionel Cristian Mărieș 17 | function_name: compute 18 | github_actions: 'yes' 19 | github_actions_osx: 'yes' 20 | github_actions_windows: 'no' 21 | license: BSD 2-Clause License 22 | module_name: core 23 | package_name: pysu 24 | pre_commit: 'yes' 25 | project_name: pysu 26 | project_short_description: Simple Python-based setuid+setgid+setgroups+exec. A port of https://github.com/tianon/gosu 27 | pypi_badge: 'yes' 28 | pypi_disable_upload: 'no' 29 | release_date: '2016-05-06' 30 | repo_hosting: github.com 31 | repo_hosting_domain: github.com 32 | repo_main_branch: master 33 | repo_name: python-su 34 | repo_username: ionelmc 35 | scrutinizer: 'no' 36 | setup_py_uses_setuptools_scm: 'no' 37 | sphinx_docs: 'no' 38 | sphinx_docs_hosting: https://python-su.readthedocs.io/ 39 | sphinx_doctest: 'no' 40 | sphinx_theme: sphinx-rtd-theme 41 | test_matrix_separate_coverage: 'no' 42 | tests_inside_package: 'no' 43 | version: 1.0.1 44 | version_manager: bump2version 45 | website: https://blog.ionelmc.ro 46 | year_from: '2016' 47 | year_to: '2024' 48 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | source = 9 | pysu 10 | tests 11 | parallel = true 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | omit = *migrations* 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .tox 2 | dist 3 | build 4 | *.egg-info 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | # Use Unix-style newlines for most files (except Windows files, see below). 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | insert_final_newline = true 10 | indent_size = 4 11 | charset = utf-8 12 | 13 | [*.{bat,cmd,ps1}] 14 | end_of_line = crlf 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | 19 | [*.tsv] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: ${{ matrix.name }} 6 | runs-on: ${{ matrix.os }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.11' 14 | toxpython: 'python3.11' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | - name: 'py38 (ubuntu)' 18 | python: '3.8' 19 | toxpython: 'python3.8' 20 | python_arch: 'x64' 21 | tox_env: 'py38' 22 | os: 'ubuntu-latest' 23 | - name: 'py38 (macos)' 24 | python: '3.8' 25 | toxpython: 'python3.8' 26 | python_arch: 'arm64' 27 | tox_env: 'py38' 28 | os: 'macos-latest' 29 | - name: 'py39 (ubuntu)' 30 | python: '3.9' 31 | toxpython: 'python3.9' 32 | python_arch: 'x64' 33 | tox_env: 'py39' 34 | os: 'ubuntu-latest' 35 | - name: 'py39 (macos)' 36 | python: '3.9' 37 | toxpython: 'python3.9' 38 | python_arch: 'arm64' 39 | tox_env: 'py39' 40 | os: 'macos-latest' 41 | - name: 'py310 (ubuntu)' 42 | python: '3.10' 43 | toxpython: 'python3.10' 44 | python_arch: 'x64' 45 | tox_env: 'py310' 46 | os: 'ubuntu-latest' 47 | - name: 'py310 (macos)' 48 | python: '3.10' 49 | toxpython: 'python3.10' 50 | python_arch: 'arm64' 51 | tox_env: 'py310' 52 | os: 'macos-latest' 53 | - name: 'py311 (ubuntu)' 54 | python: '3.11' 55 | toxpython: 'python3.11' 56 | python_arch: 'x64' 57 | tox_env: 'py311' 58 | os: 'ubuntu-latest' 59 | - name: 'py311 (macos)' 60 | python: '3.11' 61 | toxpython: 'python3.11' 62 | python_arch: 'arm64' 63 | tox_env: 'py311' 64 | os: 'macos-latest' 65 | - name: 'py312 (ubuntu)' 66 | python: '3.12' 67 | toxpython: 'python3.12' 68 | python_arch: 'x64' 69 | tox_env: 'py312' 70 | os: 'ubuntu-latest' 71 | - name: 'py312 (macos)' 72 | python: '3.12' 73 | toxpython: 'python3.12' 74 | python_arch: 'arm64' 75 | tox_env: 'py312' 76 | os: 'macos-latest' 77 | - name: 'pypy38 (ubuntu)' 78 | python: 'pypy-3.8' 79 | toxpython: 'pypy3.8' 80 | python_arch: 'x64' 81 | tox_env: 'pypy38' 82 | os: 'ubuntu-latest' 83 | - name: 'pypy38 (macos)' 84 | python: 'pypy-3.8' 85 | toxpython: 'pypy3.8' 86 | python_arch: 'arm64' 87 | tox_env: 'pypy38' 88 | os: 'macos-latest' 89 | - name: 'pypy39 (ubuntu)' 90 | python: 'pypy-3.9' 91 | toxpython: 'pypy3.9' 92 | python_arch: 'x64' 93 | tox_env: 'pypy39' 94 | os: 'ubuntu-latest' 95 | - name: 'pypy39 (macos)' 96 | python: 'pypy-3.9' 97 | toxpython: 'pypy3.9' 98 | python_arch: 'arm64' 99 | tox_env: 'pypy39' 100 | os: 'macos-latest' 101 | - name: 'pypy310 (ubuntu)' 102 | python: 'pypy-3.10' 103 | toxpython: 'pypy3.10' 104 | python_arch: 'x64' 105 | tox_env: 'pypy310' 106 | os: 'ubuntu-latest' 107 | - name: 'pypy310 (macos)' 108 | python: 'pypy-3.10' 109 | toxpython: 'pypy3.10' 110 | python_arch: 'arm64' 111 | tox_env: 'pypy310' 112 | os: 'macos-latest' 113 | steps: 114 | - uses: actions/checkout@v4 115 | with: 116 | fetch-depth: 0 117 | - uses: actions/setup-python@v5 118 | with: 119 | python-version: ${{ matrix.python }} 120 | architecture: ${{ matrix.python_arch }} 121 | - name: install dependencies 122 | run: | 123 | python -mpip install --progress-bar=off -r ci/requirements.txt 124 | virtualenv --version 125 | pip --version 126 | tox --version 127 | pip list --format=freeze 128 | - name: test 129 | env: 130 | TOXPYTHON: '${{ matrix.toxpython }}' 131 | run: > 132 | tox -e ${{ matrix.tox_env }} -v 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # Temp files 5 | .*.sw[po] 6 | *~ 7 | *.bak 8 | .DS_Store 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Build and package files 14 | *.egg 15 | *.egg-info 16 | .bootstrap 17 | .build 18 | .cache 19 | .eggs 20 | .env 21 | .installed.cfg 22 | .ve 23 | bin 24 | build 25 | develop-eggs 26 | dist 27 | eggs 28 | lib 29 | lib64 30 | parts 31 | pip-wheel-metadata/ 32 | pyvenv*/ 33 | sdist 34 | var 35 | venv*/ 36 | wheelhouse 37 | 38 | # Installer logs 39 | pip-log.txt 40 | 41 | # Unit test / coverage reports 42 | .benchmarks 43 | .coverage 44 | .coverage.* 45 | .pytest 46 | .pytest_cache/ 47 | .tox 48 | coverage.xml 49 | htmlcov 50 | nosetests.xml 51 | 52 | # Translations 53 | *.mo 54 | 55 | # Buildout 56 | .mr.developer.cfg 57 | 58 | # IDE project files 59 | *.iml 60 | *.komodoproject 61 | .idea 62 | .project 63 | .pydevproject 64 | .vscode 65 | 66 | # Complexity 67 | output/*.html 68 | output/*/index.html 69 | 70 | # Sphinx 71 | docs/_build 72 | 73 | # Mypy Cache 74 | .mypy_cache/ 75 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # To install the git pre-commit hooks run: 2 | # pre-commit install --install-hooks 3 | # To update the versions: 4 | # pre-commit autoupdate 5 | exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg)(/|$)' 6 | # Note the order is intentional to avoid multiple passes of the hooks 7 | repos: 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.4.3 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix, --show-fixes, --unsafe-fixes] 13 | - repo: https://github.com/psf/black 14 | rev: 24.4.2 15 | hooks: 16 | - id: black 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v4.6.0 19 | hooks: 20 | - id: trailing-whitespace 21 | - id: end-of-file-fixer 22 | - id: debug-statements 23 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * Ionel Cristian Mărieș - https://blog.ionelmc.ro 6 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 1.0.1 (2024-05-08) 6 | ------------------ 7 | 8 | * Removed debug print. 9 | 10 | 1.0.0 (2024-04-12) 11 | ------------------ 12 | 13 | * Dropped support for old Pythons. Minium supported version is 3.8. 14 | * Improved error handling and reporting. 15 | * Increased compatibility with `gosu `_. 16 | 17 | 0.2.0 (2016-05-06) 18 | ------------------ 19 | 20 | * Allow using ":group" as argument, just like ``gosu`` (it will use the current user, but with the given group). 21 | 22 | 0.1.0 (2016-04-19) 23 | ------------------ 24 | 25 | * First release on PyPI. 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | pysu could always use more documentation, whether as part of the 21 | official pysu docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/ionelmc/python-su/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `python-su` for local development: 39 | 40 | 1. Fork `python-su `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/python-su.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes run all the checks and docs builder with one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``). 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | Tips 77 | ---- 78 | 79 | To run a subset of tests:: 80 | 81 | tox -e envname -- pytest -k test_myfeature 82 | 83 | To run all the test environments in *parallel*:: 84 | 85 | tox -p auto 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2016-2024, Ionel Cristian Mărieș. All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.cfg 7 | include .cookiecutterrc 8 | include .coveragerc 9 | include .dockerignore 10 | include .editorconfig 11 | include .github/workflows/github-actions.yml 12 | include .pre-commit-config.yaml 13 | include pytest.ini 14 | include tox.ini 15 | 16 | include AUTHORS.rst 17 | include CHANGELOG.rst 18 | include CONTRIBUTING.rst 19 | include LICENSE 20 | include README.rst 21 | 22 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | * - tests 11 | - |github-actions| 12 | * - package 13 | - |version| |wheel| |supported-versions| |supported-implementations| |commits-since| 14 | 15 | .. |github-actions| image:: https://github.com/ionelmc/python-su/actions/workflows/github-actions.yml/badge.svg 16 | :alt: GitHub Actions Build Status 17 | :target: https://github.com/ionelmc/python-su/actions 18 | 19 | .. |version| image:: https://img.shields.io/pypi/v/pysu.svg 20 | :alt: PyPI Package latest release 21 | :target: https://pypi.org/project/pysu 22 | 23 | .. |wheel| image:: https://img.shields.io/pypi/wheel/pysu.svg 24 | :alt: PyPI Wheel 25 | :target: https://pypi.org/project/pysu 26 | 27 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/pysu.svg 28 | :alt: Supported versions 29 | :target: https://pypi.org/project/pysu 30 | 31 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/pysu.svg 32 | :alt: Supported implementations 33 | :target: https://pypi.org/project/pysu 34 | 35 | .. |commits-since| image:: https://img.shields.io/github/commits-since/ionelmc/python-su/v1.0.1.svg 36 | :alt: Commits since latest release 37 | :target: https://github.com/ionelmc/python-su/compare/v1.0.1...master 38 | 39 | 40 | 41 | .. end-badges 42 | 43 | Simple Python-based setuid+setgid+setgroups+exec. A port of https://github.com/tianon/gosu 44 | 45 | * Free software: BSD 2-Clause License 46 | 47 | Installation 48 | ============ 49 | 50 | :: 51 | 52 | pip install pysu 53 | 54 | You can also install the in-development version with:: 55 | 56 | pip install https://github.com/ionelmc/python-su/archive/master.zip 57 | 58 | 59 | Documentation 60 | ============= 61 | 62 | Usage: pysu [-h] user[:group] command 63 | 64 | Change user and exec command. 65 | 66 | positional arguments: 67 | user 68 | command 69 | 70 | optional arguments: 71 | -h, --help show this help message and exit 72 | 73 | Development 74 | =========== 75 | 76 | To run all the tests run:: 77 | 78 | tox 79 | 80 | Note, to combine the coverage data from all the tox environments run: 81 | 82 | .. list-table:: 83 | :widths: 10 90 84 | :stub-columns: 1 85 | 86 | - - Windows 87 | - :: 88 | 89 | set PYTEST_ADDOPTS=--cov-append 90 | tox 91 | 92 | - - Other 93 | - :: 94 | 95 | PYTEST_ADDOPTS=--cov-append tox 96 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import pathlib 4 | import subprocess 5 | import sys 6 | 7 | base_path: pathlib.Path = pathlib.Path(__file__).resolve().parent.parent 8 | templates_path = base_path / "ci" / "templates" 9 | 10 | 11 | def check_call(args): 12 | print("+", *args) 13 | subprocess.check_call(args) 14 | 15 | 16 | def exec_in_env(): 17 | env_path = base_path / ".tox" / "bootstrap" 18 | if sys.platform == "win32": 19 | bin_path = env_path / "Scripts" 20 | else: 21 | bin_path = env_path / "bin" 22 | if not env_path.exists(): 23 | import subprocess 24 | 25 | print(f"Making bootstrap env in: {env_path} ...") 26 | try: 27 | check_call([sys.executable, "-m", "venv", env_path]) 28 | except subprocess.CalledProcessError: 29 | try: 30 | check_call([sys.executable, "-m", "virtualenv", env_path]) 31 | except subprocess.CalledProcessError: 32 | check_call(["virtualenv", env_path]) 33 | print("Installing `jinja2` into bootstrap environment...") 34 | check_call([bin_path / "pip", "install", "jinja2", "tox"]) 35 | python_executable = bin_path / "python" 36 | if not python_executable.exists(): 37 | python_executable = python_executable.with_suffix(".exe") 38 | 39 | print(f"Re-executing with: {python_executable}") 40 | print("+ exec", python_executable, __file__, "--no-env") 41 | os.execv(python_executable, [python_executable, __file__, "--no-env"]) 42 | 43 | 44 | def main(): 45 | import jinja2 46 | 47 | print(f"Project path: {base_path}") 48 | 49 | jinja = jinja2.Environment( 50 | loader=jinja2.FileSystemLoader(str(templates_path)), 51 | trim_blocks=True, 52 | lstrip_blocks=True, 53 | keep_trailing_newline=True, 54 | ) 55 | tox_environments = [ 56 | line.strip() 57 | # 'tox' need not be installed globally, but must be importable 58 | # by the Python that is running this script. 59 | # This uses sys.executable the same way that the call in 60 | # cookiecutter-pylibrary/hooks/post_gen_project.py 61 | # invokes this bootstrap.py itself. 62 | for line in subprocess.check_output([sys.executable, "-m", "tox", "--listenvs"], universal_newlines=True).splitlines() 63 | ] 64 | tox_environments = [line for line in tox_environments if line.startswith("py")] 65 | for template in templates_path.rglob("*"): 66 | if template.is_file(): 67 | template_path = template.relative_to(templates_path).as_posix() 68 | destination = base_path / template_path 69 | destination.parent.mkdir(parents=True, exist_ok=True) 70 | destination.write_text(jinja.get_template(template_path).render(tox_environments=tox_environments)) 71 | print(f"Wrote {template_path}") 72 | print("DONE.") 73 | 74 | 75 | if __name__ == "__main__": 76 | args = sys.argv[1:] 77 | if args == ["--no-env"]: 78 | main() 79 | elif not args: 80 | exec_in_env() 81 | else: 82 | print(f"Unexpected arguments: {args}", file=sys.stderr) 83 | sys.exit(1) 84 | -------------------------------------------------------------------------------- /ci/py27/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2.7-alpine 2 | # taken from https://github.com/tianon/gosu/blob/master/Dockerfile.test 3 | # however, some tests are disabled as I believe the handling is incorrect - if user don't exist then it should be an error, 4 | # not a fallback to current user (which unfortunately is root). 5 | 6 | # add "nobody" to ALL groups (makes testing edge cases more interesting) 7 | RUN cut -d: -f1 /etc/group | xargs -n1 addgroup nobody 8 | 9 | RUN { \ 10 | echo '#!/bin/sh'; \ 11 | echo 'set -ex'; \ 12 | echo; \ 13 | echo 'spec="$1"; shift'; \ 14 | echo; \ 15 | echo 'expec="$1"; shift'; \ 16 | echo 'real="$(pysu "$spec" id -u):$(pysu "$spec" id -g):$(pysu "$spec" id -G)"'; \ 17 | echo '[ "$expec" = "$real" ]'; \ 18 | echo; \ 19 | echo 'expec="$1"; shift'; \ 20 | # have to "|| true" this one because of "id: unknown ID 1000" (rightfully) having a nonzero exit code 21 | echo 'real="$(pysu "$spec" id -un):$(pysu "$spec" id -gn):$(pysu "$spec" id -Gn)" || true'; \ 22 | echo '[ "$expec" = "$real" ]'; \ 23 | } > /usr/local/bin/pysu-t \ 24 | && chmod +x /usr/local/bin/pysu-t 25 | 26 | COPY . /root/ 27 | RUN pip install --disable-pip-version-check /root/ 28 | # adjust users so we can make sure the tests are interesting 29 | RUN chgrp nobody $(which python) $(which pysu) \ 30 | && chmod +s $(which python) $(which pysu) 31 | USER nobody 32 | ENV HOME /omg/really/gosu/nowhere 33 | # now we should be nobody, ALL groups, and have a bogus useless HOME value 34 | 35 | RUN id 36 | 37 | RUN pysu-t 0 "0:0:$(id -G root)" "root:root:$(id -Gn root)" 38 | RUN pysu-t 0:0 '0:0:0' 'root:root:root' 39 | RUN pysu-t root "0:0:$(id -G root)" "root:root:$(id -Gn root)" 40 | RUN pysu-t 0:root '0:0:0' 'root:root:root' 41 | RUN pysu-t root:0 '0:0:0' 'root:root:root' 42 | RUN pysu-t root:root '0:0:0' 'root:root:root' 43 | #RUN pysu-t 1000 "1000:$(id -g ):$(id -g)" "1000:$(id -gn):$(id -gn)" 44 | RUN pysu-t 0:1000 '0:1000:1000' 'root:1000:1000' 45 | RUN pysu-t 1000:1000 '1000:1000:1000' '1000:1000:1000' 46 | RUN pysu-t root:1000 '0:1000:1000' 'root:1000:1000' 47 | RUN pysu-t 1000:root '1000:0:0' '1000:root:root' 48 | RUN pysu-t 1000:daemon "1000:$(id -g daemon):$(id -g daemon)" '1000:daemon:daemon' 49 | RUN pysu-t games "$(id -u games):$(id -g games):$(id -G games)" 'games:games:games users' 50 | RUN pysu-t games:daemon "$(id -u games):$(id -g daemon):$(id -g daemon)" 'games:daemon:daemon' 51 | 52 | RUN pysu-t 0: "0:0:$(id -G root)" "root:root:$(id -Gn root)" 53 | #RUN pysu-t '' "$(id -u):$(id -g):$(id -G)" "$(id -un):$(id -gn):$(id -Gn)" 54 | RUN pysu-t ':0' "$(id -u):0:0" "$(id -un):root:root" 55 | 56 | RUN [ "$(pysu 0 env | grep '^HOME=')" = 'HOME=/root' ] 57 | RUN [ "$(pysu 0:0 env | grep '^HOME=')" = 'HOME=/root' ] 58 | RUN [ "$(pysu root env | grep '^HOME=')" = 'HOME=/root' ] 59 | RUN [ "$(pysu 0:root env | grep '^HOME=')" = 'HOME=/root' ] 60 | RUN [ "$(pysu root:0 env | grep '^HOME=')" = 'HOME=/root' ] 61 | RUN [ "$(pysu root:root env | grep '^HOME=')" = 'HOME=/root' ] 62 | RUN [ "$(pysu 0:1000 env | grep '^HOME=')" = 'HOME=/root' ] 63 | RUN [ "$(pysu root:1000 env | grep '^HOME=')" = 'HOME=/root' ] 64 | RUN [ "$(pysu 1000 env | grep '^HOME=')" = 'HOME=/' ] 65 | RUN [ "$(pysu 1000:0 env | grep '^HOME=')" = 'HOME=/' ] 66 | RUN [ "$(pysu 1000:root env | grep '^HOME=')" = 'HOME=/' ] 67 | RUN [ "$(pysu games env | grep '^HOME=')" = 'HOME=/usr/games' ] 68 | RUN [ "$(pysu games:daemon env | grep '^HOME=')" = 'HOME=/usr/games' ] 69 | -------------------------------------------------------------------------------- /ci/py34/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.4-alpine 2 | # taken from https://github.com/tianon/gosu/blob/master/Dockerfile.test 3 | # however, some tests are disabled as I believe the handling is incorrect - if user don't exist then it should be an error, 4 | # not a fallback to current user (which unfortunately is root). 5 | 6 | # add "nobody" to ALL groups (makes testing edge cases more interesting) 7 | RUN cut -d: -f1 /etc/group | xargs -n1 addgroup nobody 8 | 9 | RUN { \ 10 | echo '#!/bin/sh'; \ 11 | echo 'set -ex'; \ 12 | echo; \ 13 | echo 'spec="$1"; shift'; \ 14 | echo; \ 15 | echo 'expec="$1"; shift'; \ 16 | echo 'real="$(pysu "$spec" id -u):$(pysu "$spec" id -g):$(pysu "$spec" id -G)"'; \ 17 | echo '[ "$expec" = "$real" ]'; \ 18 | echo; \ 19 | echo 'expec="$1"; shift'; \ 20 | # have to "|| true" this one because of "id: unknown ID 1000" (rightfully) having a nonzero exit code 21 | echo 'real="$(pysu "$spec" id -un):$(pysu "$spec" id -gn):$(pysu "$spec" id -Gn)" || true'; \ 22 | echo '[ "$expec" = "$real" ]'; \ 23 | } > /usr/local/bin/pysu-t \ 24 | && chmod +x /usr/local/bin/pysu-t 25 | 26 | COPY . /root/ 27 | RUN pip install --disable-pip-version-check /root/ 28 | # adjust users so we can make sure the tests are interesting 29 | RUN chgrp nobody $(which python) $(which pysu) \ 30 | && chmod +s $(which python) $(which pysu) 31 | USER nobody 32 | ENV HOME /omg/really/gosu/nowhere 33 | # now we should be nobody, ALL groups, and have a bogus useless HOME value 34 | 35 | RUN id 36 | 37 | RUN pysu-t 0 "0:0:$(id -G root)" "root:root:$(id -Gn root)" 38 | RUN pysu-t 0:0 '0:0:0' 'root:root:root' 39 | RUN pysu-t root "0:0:$(id -G root)" "root:root:$(id -Gn root)" 40 | RUN pysu-t 0:root '0:0:0' 'root:root:root' 41 | RUN pysu-t root:0 '0:0:0' 'root:root:root' 42 | RUN pysu-t root:root '0:0:0' 'root:root:root' 43 | #RUN pysu-t 1000 "1000:$(id -g ):$(id -g)" "1000:$(id -gn):$(id -gn)" 44 | RUN pysu-t 0:1000 '0:1000:1000' 'root:1000:1000' 45 | RUN pysu-t 1000:1000 '1000:1000:1000' '1000:1000:1000' 46 | RUN pysu-t root:1000 '0:1000:1000' 'root:1000:1000' 47 | RUN pysu-t 1000:root '1000:0:0' '1000:root:root' 48 | RUN pysu-t 1000:daemon "1000:$(id -g daemon):$(id -g daemon)" '1000:daemon:daemon' 49 | RUN pysu-t games "$(id -u games):$(id -g games):$(id -G games)" 'games:games:games users' 50 | RUN pysu-t games:daemon "$(id -u games):$(id -g daemon):$(id -g daemon)" 'games:daemon:daemon' 51 | 52 | RUN pysu-t 0: "0:0:$(id -G root)" "root:root:$(id -Gn root)" 53 | #RUN pysu-t '' "$(id -u):$(id -g):$(id -G)" "$(id -un):$(id -gn):$(id -Gn)" 54 | RUN pysu-t ':0' "$(id -u):0:0" "$(id -un):root:root" 55 | 56 | RUN [ "$(pysu 0 env | grep '^HOME=')" = 'HOME=/root' ] 57 | RUN [ "$(pysu 0:0 env | grep '^HOME=')" = 'HOME=/root' ] 58 | RUN [ "$(pysu root env | grep '^HOME=')" = 'HOME=/root' ] 59 | RUN [ "$(pysu 0:root env | grep '^HOME=')" = 'HOME=/root' ] 60 | RUN [ "$(pysu root:0 env | grep '^HOME=')" = 'HOME=/root' ] 61 | RUN [ "$(pysu root:root env | grep '^HOME=')" = 'HOME=/root' ] 62 | RUN [ "$(pysu 0:1000 env | grep '^HOME=')" = 'HOME=/root' ] 63 | RUN [ "$(pysu root:1000 env | grep '^HOME=')" = 'HOME=/root' ] 64 | RUN [ "$(pysu 1000 env | grep '^HOME=')" = 'HOME=/' ] 65 | RUN [ "$(pysu 1000:0 env | grep '^HOME=')" = 'HOME=/' ] 66 | RUN [ "$(pysu 1000:root env | grep '^HOME=')" = 'HOME=/' ] 67 | RUN [ "$(pysu games env | grep '^HOME=')" = 'HOME=/usr/games' ] 68 | RUN [ "$(pysu games:daemon env | grep '^HOME=')" = 'HOME=/usr/games' ] 69 | -------------------------------------------------------------------------------- /ci/py35/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5-alpine 2 | # taken from https://github.com/tianon/gosu/blob/master/Dockerfile.test 3 | # however, some tests are disabled as I believe the handling is incorrect - if user don't exist then it should be an error, 4 | # not a fallback to current user (which unfortunately is root). 5 | 6 | # add "nobody" to ALL groups (makes testing edge cases more interesting) 7 | RUN cut -d: -f1 /etc/group | xargs -n1 addgroup nobody 8 | 9 | RUN { \ 10 | echo '#!/bin/sh'; \ 11 | echo 'set -ex'; \ 12 | echo; \ 13 | echo 'spec="$1"; shift'; \ 14 | echo; \ 15 | echo 'expec="$1"; shift'; \ 16 | echo 'real="$(pysu "$spec" id -u):$(pysu "$spec" id -g):$(pysu "$spec" id -G)"'; \ 17 | echo '[ "$expec" = "$real" ]'; \ 18 | echo; \ 19 | echo 'expec="$1"; shift'; \ 20 | # have to "|| true" this one because of "id: unknown ID 1000" (rightfully) having a nonzero exit code 21 | echo 'real="$(pysu "$spec" id -un):$(pysu "$spec" id -gn):$(pysu "$spec" id -Gn)" || true'; \ 22 | echo '[ "$expec" = "$real" ]'; \ 23 | } > /usr/local/bin/pysu-t \ 24 | && chmod +x /usr/local/bin/pysu-t 25 | 26 | COPY . /root/ 27 | RUN pip install --disable-pip-version-check /root/ 28 | # adjust users so we can make sure the tests are interesting 29 | RUN chgrp nobody $(which python) $(which pysu) \ 30 | && chmod +s $(which python) $(which pysu) 31 | USER nobody 32 | ENV HOME /omg/really/gosu/nowhere 33 | # now we should be nobody, ALL groups, and have a bogus useless HOME value 34 | 35 | RUN id 36 | 37 | RUN pysu-t 0 "0:0:$(id -G root)" "root:root:$(id -Gn root)" 38 | RUN pysu-t 0:0 '0:0:0' 'root:root:root' 39 | RUN pysu-t root "0:0:$(id -G root)" "root:root:$(id -Gn root)" 40 | RUN pysu-t 0:root '0:0:0' 'root:root:root' 41 | RUN pysu-t root:0 '0:0:0' 'root:root:root' 42 | RUN pysu-t root:root '0:0:0' 'root:root:root' 43 | #RUN pysu-t 1000 "1000:$(id -g ):$(id -g)" "1000:$(id -gn):$(id -gn)" 44 | RUN pysu-t 0:1000 '0:1000:1000' 'root:1000:1000' 45 | RUN pysu-t 1000:1000 '1000:1000:1000' '1000:1000:1000' 46 | RUN pysu-t root:1000 '0:1000:1000' 'root:1000:1000' 47 | RUN pysu-t 1000:root '1000:0:0' '1000:root:root' 48 | RUN pysu-t 1000:daemon "1000:$(id -g daemon):$(id -g daemon)" '1000:daemon:daemon' 49 | RUN pysu-t games "$(id -u games):$(id -g games):$(id -G games)" 'games:games:games users' 50 | RUN pysu-t games:daemon "$(id -u games):$(id -g daemon):$(id -g daemon)" 'games:daemon:daemon' 51 | 52 | RUN pysu-t 0: "0:0:$(id -G root)" "root:root:$(id -Gn root)" 53 | #RUN pysu-t '' "$(id -u):$(id -g):$(id -G)" "$(id -un):$(id -gn):$(id -Gn)" 54 | RUN pysu-t ':0' "$(id -u):0:0" "$(id -un):root:root" 55 | 56 | RUN [ "$(pysu 0 env | grep '^HOME=')" = 'HOME=/root' ] 57 | RUN [ "$(pysu 0:0 env | grep '^HOME=')" = 'HOME=/root' ] 58 | RUN [ "$(pysu root env | grep '^HOME=')" = 'HOME=/root' ] 59 | RUN [ "$(pysu 0:root env | grep '^HOME=')" = 'HOME=/root' ] 60 | RUN [ "$(pysu root:0 env | grep '^HOME=')" = 'HOME=/root' ] 61 | RUN [ "$(pysu root:root env | grep '^HOME=')" = 'HOME=/root' ] 62 | RUN [ "$(pysu 0:1000 env | grep '^HOME=')" = 'HOME=/root' ] 63 | RUN [ "$(pysu root:1000 env | grep '^HOME=')" = 'HOME=/root' ] 64 | RUN [ "$(pysu 1000 env | grep '^HOME=')" = 'HOME=/' ] 65 | RUN [ "$(pysu 1000:0 env | grep '^HOME=')" = 'HOME=/' ] 66 | RUN [ "$(pysu 1000:root env | grep '^HOME=')" = 'HOME=/' ] 67 | RUN [ "$(pysu games env | grep '^HOME=')" = 'HOME=/usr/games' ] 68 | RUN [ "$(pysu games:daemon env | grep '^HOME=')" = 'HOME=/usr/games' ] 69 | -------------------------------------------------------------------------------- /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | virtualenv>=16.6.0 2 | pip>=19.1.1 3 | setuptools>=18.0.1 4 | six>=1.14.0 5 | tox 6 | twine 7 | -------------------------------------------------------------------------------- /ci/templates/.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: {{ '${{ matrix.name }}' }} 6 | runs-on: {{ '${{ matrix.os }}' }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.11' 14 | toxpython: 'python3.11' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | {% for env in tox_environments %} 18 | {% set prefix = env.split('-')[0] -%} 19 | {% if prefix.startswith('pypy') %} 20 | {% set python %}pypy-{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 21 | {% set cpython %}pp{{ prefix[4:5] }}{% endset %} 22 | {% set toxpython %}pypy{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 23 | {% else %} 24 | {% set python %}{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 25 | {% set cpython %}cp{{ prefix[2:] }}{% endset %} 26 | {% set toxpython %}python{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 27 | {% endif %} 28 | {% for os, python_arch in [ 29 | ['ubuntu', 'x64'], 30 | ['macos', 'arm64'], 31 | ] %} 32 | - name: '{{ env }} ({{ os }})' 33 | python: '{{ python }}' 34 | toxpython: '{{ toxpython }}' 35 | python_arch: '{{ python_arch }}' 36 | tox_env: '{{ env }}' 37 | os: '{{ os }}-latest' 38 | {% endfor %} 39 | {% endfor %} 40 | steps: 41 | - uses: actions/checkout@v4 42 | with: 43 | fetch-depth: 0 44 | - uses: actions/setup-python@v5 45 | with: 46 | python-version: {{ '${{ matrix.python }}' }} 47 | architecture: {{ '${{ matrix.python_arch }}' }} 48 | - name: install dependencies 49 | run: | 50 | python -mpip install --progress-bar=off -r ci/requirements.txt 51 | virtualenv --version 52 | pip --version 53 | tox --version 54 | pip list --format=freeze 55 | - name: test 56 | env: 57 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 58 | run: > 59 | tox -e {{ '${{ matrix.tox_env }}' }} -v 60 | -------------------------------------------------------------------------------- /ci/templates/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:{{ version }}-alpine 2 | # taken from https://github.com/tianon/gosu/blob/master/Dockerfile.test 3 | # however, some tests are disabled as I believe the handling is incorrect - if user don't exist then it should be an error, 4 | # not a fallback to current user (which unfortunately is root). 5 | 6 | # add "nobody" to ALL groups (makes testing edge cases more interesting) 7 | RUN cut -d: -f1 /etc/group | xargs -n1 addgroup nobody 8 | 9 | RUN { \ 10 | echo '#!/bin/sh'; \ 11 | echo 'set -ex'; \ 12 | echo; \ 13 | echo 'spec="$1"; shift'; \ 14 | echo; \ 15 | echo 'expec="$1"; shift'; \ 16 | echo 'real="$(pysu "$spec" id -u):$(pysu "$spec" id -g):$(pysu "$spec" id -G)"'; \ 17 | echo '[ "$expec" = "$real" ]'; \ 18 | echo; \ 19 | echo 'expec="$1"; shift'; \ 20 | # have to "|| true" this one because of "id: unknown ID 1000" (rightfully) having a nonzero exit code 21 | echo 'real="$(pysu "$spec" id -un):$(pysu "$spec" id -gn):$(pysu "$spec" id -Gn)" || true'; \ 22 | echo '[ "$expec" = "$real" ]'; \ 23 | } > /usr/local/bin/pysu-t \ 24 | && chmod +x /usr/local/bin/pysu-t 25 | 26 | COPY . /root/ 27 | RUN pip install --disable-pip-version-check /root/ 28 | # adjust users so we can make sure the tests are interesting 29 | RUN chgrp nobody $(which python) $(which pysu) \ 30 | && chmod +s $(which python) $(which pysu) 31 | USER nobody 32 | ENV HOME /omg/really/gosu/nowhere 33 | # now we should be nobody, ALL groups, and have a bogus useless HOME value 34 | 35 | RUN id 36 | 37 | RUN pysu-t 0 "0:0:$(id -G root)" "root:root:$(id -Gn root)" 38 | RUN pysu-t 0:0 '0:0:0' 'root:root:root' 39 | RUN pysu-t root "0:0:$(id -G root)" "root:root:$(id -Gn root)" 40 | RUN pysu-t 0:root '0:0:0' 'root:root:root' 41 | RUN pysu-t root:0 '0:0:0' 'root:root:root' 42 | RUN pysu-t root:root '0:0:0' 'root:root:root' 43 | #RUN pysu-t 1000 "1000:$(id -g ):$(id -g)" "1000:$(id -gn):$(id -gn)" 44 | RUN pysu-t 0:1000 '0:1000:1000' 'root:1000:1000' 45 | RUN pysu-t 1000:1000 '1000:1000:1000' '1000:1000:1000' 46 | RUN pysu-t root:1000 '0:1000:1000' 'root:1000:1000' 47 | RUN pysu-t 1000:root '1000:0:0' '1000:root:root' 48 | RUN pysu-t 1000:daemon "1000:$(id -g daemon):$(id -g daemon)" '1000:daemon:daemon' 49 | RUN pysu-t games "$(id -u games):$(id -g games):$(id -G games)" 'games:games:games users' 50 | RUN pysu-t games:daemon "$(id -u games):$(id -g daemon):$(id -g daemon)" 'games:daemon:daemon' 51 | 52 | RUN pysu-t 0: "0:0:$(id -G root)" "root:root:$(id -Gn root)" 53 | #RUN pysu-t '' "$(id -u):$(id -g):$(id -G)" "$(id -un):$(id -gn):$(id -Gn)" 54 | RUN pysu-t ':0' "$(id -u):0:0" "$(id -un):root:root" 55 | 56 | RUN [ "$(pysu 0 env | grep '^HOME=')" = 'HOME=/root' ] 57 | RUN [ "$(pysu 0:0 env | grep '^HOME=')" = 'HOME=/root' ] 58 | RUN [ "$(pysu root env | grep '^HOME=')" = 'HOME=/root' ] 59 | RUN [ "$(pysu 0:root env | grep '^HOME=')" = 'HOME=/root' ] 60 | RUN [ "$(pysu root:0 env | grep '^HOME=')" = 'HOME=/root' ] 61 | RUN [ "$(pysu root:root env | grep '^HOME=')" = 'HOME=/root' ] 62 | RUN [ "$(pysu 0:1000 env | grep '^HOME=')" = 'HOME=/root' ] 63 | RUN [ "$(pysu root:1000 env | grep '^HOME=')" = 'HOME=/root' ] 64 | RUN [ "$(pysu 1000 env | grep '^HOME=')" = 'HOME=/' ] 65 | RUN [ "$(pysu 1000:0 env | grep '^HOME=')" = 'HOME=/' ] 66 | RUN [ "$(pysu 1000:root env | grep '^HOME=')" = 'HOME=/' ] 67 | RUN [ "$(pysu games env | grep '^HOME=')" = 'HOME=/usr/games' ] 68 | RUN [ "$(pysu games:daemon env | grep '^HOME=')" = 'HOME=/usr/games' ] 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=30.3.0", 4 | ] 5 | 6 | [tool.ruff] 7 | extend-exclude = ["static", "ci/templates"] 8 | line-length = 140 9 | src = ["src", "tests"] 10 | target-version = "py38" 11 | 12 | [tool.ruff.lint.per-file-ignores] 13 | "ci/*" = ["S"] 14 | 15 | [tool.ruff.lint] 16 | ignore = [ 17 | "RUF001", # ruff-specific rules ambiguous-unicode-character-string 18 | "S101", # flake8-bandit assert 19 | "S308", # flake8-bandit suspicious-mark-safe-usage 20 | "S603", # flake8-bandit subprocess-without-shell-equals-true 21 | "S607", # flake8-bandit start-process-with-partial-path 22 | "E501", # pycodestyle line-too-long 23 | ] 24 | select = [ 25 | "B", # flake8-bugbear 26 | "C4", # flake8-comprehensions 27 | "DTZ", # flake8-datetimez 28 | "E", # pycodestyle errors 29 | "EXE", # flake8-executable 30 | "F", # pyflakes 31 | "I", # isort 32 | "INT", # flake8-gettext 33 | "PIE", # flake8-pie 34 | "PLC", # pylint convention 35 | "PLE", # pylint errors 36 | "PT", # flake8-pytest-style 37 | "PTH", # flake8-use-pathlib 38 | "Q", # flake8-quotes 39 | "RSE", # flake8-raise 40 | "RUF", # ruff-specific rules 41 | "S", # flake8-bandit 42 | "UP", # pyupgrade 43 | "W", # pycodestyle warnings 44 | ] 45 | 46 | [tool.ruff.lint.flake8-pytest-style] 47 | fixture-parentheses = false 48 | mark-parentheses = false 49 | 50 | [tool.ruff.lint.isort] 51 | forced-separate = ["conftest"] 52 | force-single-line = true 53 | 54 | [tool.black] 55 | line-length = 140 56 | target-version = ["py38"] 57 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # If a pytest section is found in one of the possible config files 3 | # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, 4 | # so if you add a pytest config section elsewhere, 5 | # you will need to delete this section from setup.cfg. 6 | norecursedirs = 7 | migrations 8 | 9 | python_files = 10 | test_*.py 11 | *_test.py 12 | tests.py 13 | addopts = 14 | -ra 15 | --strict-markers 16 | --doctest-modules 17 | --doctest-glob=\*.rst 18 | --tb=short 19 | testpaths = 20 | tests 21 | # If you want to switch back to tests outside package just remove --pyargs 22 | # and edit testpaths to have "tests/" instead of "pysu". 23 | 24 | # Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors 25 | filterwarnings = 26 | error 27 | # You can add exclusions, some examples: 28 | # ignore:'pysu' defines default_app_config:PendingDeprecationWarning:: 29 | # ignore:The {{% if::: 30 | # ignore:Coverage disabled via --no-cov switch! 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | from pathlib import Path 4 | 5 | from setuptools import find_packages 6 | from setuptools import setup 7 | 8 | 9 | def read(*names, **kwargs): 10 | with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get("encoding", "utf8")) as fh: 11 | return fh.read() 12 | 13 | 14 | setup( 15 | name="pysu", 16 | version="1.0.1", 17 | license="BSD-2-Clause", 18 | description="Simple Python-based setuid+setgid+setgroups+exec. A port of https://github.com/tianon/gosu", 19 | long_description="{}\n{}".format( 20 | re.compile("^.. start-badges.*^.. end-badges", re.M | re.S).sub("", read("README.rst")), 21 | re.sub(":[a-z]+:`~?(.*?)`", r"``\1``", read("CHANGELOG.rst")), 22 | ), 23 | author="Ionel Cristian Mărieș", 24 | author_email="contact@ionelmc.ro", 25 | url="https://github.com/ionelmc/python-su", 26 | packages=find_packages("src"), 27 | package_dir={"": "src"}, 28 | py_modules=[path.stem for path in Path("src").glob("*.py")], 29 | include_package_data=True, 30 | zip_safe=False, 31 | classifiers=[ 32 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 33 | "Development Status :: 5 - Production/Stable", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: BSD License", 36 | "Operating System :: Unix", 37 | "Operating System :: POSIX", 38 | "Operating System :: Microsoft :: Windows", 39 | "Programming Language :: Python", 40 | "Programming Language :: Python :: 3", 41 | "Programming Language :: Python :: 3 :: Only", 42 | "Programming Language :: Python :: 3.8", 43 | "Programming Language :: Python :: 3.9", 44 | "Programming Language :: Python :: 3.10", 45 | "Programming Language :: Python :: 3.11", 46 | "Programming Language :: Python :: 3.12", 47 | "Programming Language :: Python :: Implementation :: CPython", 48 | "Programming Language :: Python :: Implementation :: PyPy", 49 | # uncomment if you test on these interpreters: 50 | # "Programming Language :: Python :: Implementation :: IronPython", 51 | # "Programming Language :: Python :: Implementation :: Jython", 52 | # "Programming Language :: Python :: Implementation :: Stackless", 53 | "Topic :: Utilities", 54 | ], 55 | project_urls={ 56 | "Changelog": "https://github.com/ionelmc/python-su/blob/master/CHANGELOG.rst", 57 | "Issue Tracker": "https://github.com/ionelmc/python-su/issues", 58 | }, 59 | keywords=[ 60 | # eg: "keyword1", "keyword2", "keyword3", 61 | ], 62 | python_requires=">=3.8", 63 | install_requires=[ 64 | # eg: "aspectlib==1.1.1", "six>=1.7", 65 | ], 66 | extras_require={ 67 | # eg: 68 | # "rst": ["docutils>=0.11"], 69 | # ":python_version=='3.8'": ["backports.zoneinfo"], 70 | }, 71 | entry_points={ 72 | "console_scripts": [ 73 | "pysu = pysu.cli:main", 74 | ] 75 | }, 76 | ) 77 | -------------------------------------------------------------------------------- /src/pysu/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.1" 2 | -------------------------------------------------------------------------------- /src/pysu/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entrypoint module, in case you use `python -mpysu`. 3 | 4 | 5 | Why does this file exist, and why __main__? For more info, read: 6 | 7 | - https://www.python.org/dev/peps/pep-0338/ 8 | - https://docs.python.org/2/using/cmdline.html#cmdoption-m 9 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m 10 | """ 11 | 12 | import sys 13 | 14 | from pysu.cli import main 15 | 16 | if __name__ == "__main__": 17 | sys.exit(main()) 18 | -------------------------------------------------------------------------------- /src/pysu/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import grp 3 | import os 4 | import pwd 5 | import sys 6 | 7 | parser = argparse.ArgumentParser(description="Change user and exec command.") 8 | parser.add_argument("user") 9 | parser.add_argument("command") 10 | 11 | 12 | def main(argv=None): 13 | opts, args = parser.parse_known_args(args=argv) 14 | if ":" in opts.user: 15 | user, group = opts.user.split(":", 1) 16 | else: 17 | user, group = opts.user, None 18 | 19 | if user.isdigit(): 20 | uid = int(user) 21 | try: 22 | pw = pwd.getpwuid(uid) 23 | except KeyError: 24 | pw = None 25 | elif user: 26 | try: 27 | pw = pwd.getpwnam(user) 28 | except KeyError: 29 | print(f"pysu: error: unknown user name {user!r}.", file=sys.stderr) 30 | return 1 31 | else: 32 | uid = pw.pw_uid 33 | else: 34 | uid = os.getuid() 35 | try: 36 | pw = pwd.getpwuid(uid) 37 | except KeyError: 38 | pw = None 39 | 40 | if pw: 41 | home = pw.pw_dir 42 | name = pw.pw_name 43 | else: 44 | home = "/" 45 | name = user 46 | 47 | if group: 48 | if group.isdigit(): 49 | gid = int(group) 50 | else: 51 | try: 52 | gr = grp.getgrnam(group) 53 | except KeyError: 54 | print(f"pysu: error: unknown group name {group!r}.", file=sys.stderr) 55 | return 2 56 | else: 57 | gid = gr.gr_gid 58 | elif pw: 59 | gid = pw.pw_gid 60 | else: 61 | gid = os.getgid() 62 | print(f"pysu: warning: could not figure our a group id for {user!r}, defaulting to current {gid=}.", file=sys.stderr) 63 | 64 | current_gid = os.getgid() 65 | current_uid = os.getuid() 66 | current_gl = set(os.getgroups()) 67 | 68 | if group: 69 | gl = {gid} 70 | else: 71 | gl = set(os.getgrouplist(name, gid)) 72 | 73 | if current_gl != gl: 74 | try: 75 | os.setgroups(list(gl)) 76 | except PermissionError: 77 | print( 78 | f"pysu: error: could not set supplemental group ids to {gl} (current uid={current_uid} gid={current_gid}).", file=sys.stderr 79 | ) 80 | return 3 81 | else: 82 | print(f"pysu: warning: requested supplemental group ids {gl} are identical to the current ones.", file=sys.stderr) 83 | 84 | if current_gid == gid: 85 | print(f"pysu: warning: requested gid {gid} is identical to the current one.", file=sys.stderr) 86 | 87 | try: 88 | os.setgid(gid) 89 | except PermissionError: 90 | print(f"pysu: error: could not set gid to {gid} (current uid={current_uid} gid={current_gid}).", file=sys.stderr) 91 | return 3 92 | 93 | if current_uid == uid: 94 | print(f"pysu: warning: requested uid {uid} is identical to the current one.", file=sys.stderr) 95 | 96 | try: 97 | os.setuid(uid) 98 | except PermissionError: 99 | print(f"pysu: error: could not set uid to {uid} (current uid={current_uid} gid={current_gid}).", file=sys.stderr) 100 | return 4 101 | 102 | os.environ["USER"] = name 103 | os.environ["HOME"] = home 104 | os.environ["UID"] = str(uid) 105 | 106 | os.execvp(opts.command, [opts.command, *args]) # noqa: S606 107 | 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /tests/test-alpine.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON 2 | FROM python:${PYTHON}-alpine 3 | 4 | # add "nobody" to ALL groups (makes testing edge cases more interesting) 5 | RUN cut -d: -f1 /etc/group | xargs -rtn1 addgroup nobody 6 | 7 | RUN { \ 8 | echo '#!/bin/sh'; \ 9 | echo 'set -ex'; \ 10 | echo; \ 11 | echo 'spec="$1"; shift'; \ 12 | echo; \ 13 | echo 'expec="$1"; shift'; \ 14 | echo 'real="$(pysu "$spec" id -u):$(pysu "$spec" id -g):$(pysu "$spec" id -G)"'; \ 15 | echo '[ "$expec" = "$real" ]'; \ 16 | echo; \ 17 | echo 'expec="$1"; shift'; \ 18 | # have to "|| true" this one because of "id: unknown ID 1000" (rightfully) having a nonzero exit code 19 | echo 'real="$(pysu "$spec" id -un):$(pysu "$spec" id -gn):$(pysu "$spec" id -Gn)" || true'; \ 20 | echo '[ "$expec" = "$real" ]'; \ 21 | } > /usr/local/bin/pysu-t \ 22 | && chmod +x /usr/local/bin/pysu-t 23 | 24 | COPY . /root/ 25 | RUN pip install --disable-pip-version-check /root/ 26 | 27 | # adjust users so we can make sure the tests are interesting 28 | RUN chgrp nobody $(which python) $(which pysu) \ 29 | && chmod +s $(which python) $(which pysu) 30 | USER nobody 31 | ENV HOME /omg/really/pysu/nowhere 32 | # now we should be nobody, ALL groups, and have a bogus useless HOME value 33 | 34 | RUN id 35 | 36 | RUN pysu-t 0 "0:0:$(id -G root)" "root:root:$(id -Gn root)" 37 | RUN pysu-t 0:0 '0:0:0' 'root:root:root' 38 | RUN pysu-t root "0:0:$(id -G root)" "root:root:$(id -Gn root)" 39 | RUN pysu-t 0:root '0:0:0' 'root:root:root' 40 | RUN pysu-t root:0 '0:0:0' 'root:root:root' 41 | RUN pysu-t root:root '0:0:0' 'root:root:root' 42 | RUN pysu-t 1000 "1000:$(id -g):$(id -g)" "1000:$(id -gn):$(id -gn)" 43 | RUN pysu-t 0:1000 '0:1000:1000' 'root:1000:1000' 44 | RUN pysu-t 1000:1000 '1000:1000:1000' '1000:1000:1000' 45 | RUN pysu-t root:1000 '0:1000:1000' 'root:1000:1000' 46 | RUN pysu-t 1000:root '1000:0:0' '1000:root:root' 47 | RUN pysu-t 1000:daemon "1000:$(id -g daemon):$(id -g daemon)" '1000:daemon:daemon' 48 | RUN pysu-t games "$(id -u games):$(id -g games):$(id -G games)" 'games:games:games users' 49 | RUN pysu-t games:daemon "$(id -u games):$(id -g daemon):$(id -g daemon)" 'games:daemon:daemon' 50 | 51 | RUN pysu-t 0: "0:0:$(id -G root)" "root:root:$(id -Gn root)" 52 | RUN pysu-t '' "$(id -u):$(id -g):$(id -G)" "$(id -un):$(id -gn):$(id -Gn)" 53 | RUN pysu-t ':0' "$(id -u):0:0" "$(id -un):root:root" 54 | 55 | RUN [ "$(pysu 0 env | grep '^HOME=')" = 'HOME=/root' ] 56 | RUN [ "$(pysu 0:0 env | grep '^HOME=')" = 'HOME=/root' ] 57 | RUN [ "$(pysu root env | grep '^HOME=')" = 'HOME=/root' ] 58 | RUN [ "$(pysu 0:root env | grep '^HOME=')" = 'HOME=/root' ] 59 | RUN [ "$(pysu root:0 env | grep '^HOME=')" = 'HOME=/root' ] 60 | RUN [ "$(pysu root:root env | grep '^HOME=')" = 'HOME=/root' ] 61 | RUN [ "$(pysu 0:1000 env | grep '^HOME=')" = 'HOME=/root' ] 62 | RUN [ "$(pysu root:1000 env | grep '^HOME=')" = 'HOME=/root' ] 63 | RUN [ "$(pysu 1000 env | grep '^HOME=')" = 'HOME=/' ] 64 | RUN [ "$(pysu 1000:0 env | grep '^HOME=')" = 'HOME=/' ] 65 | RUN [ "$(pysu 1000:root env | grep '^HOME=')" = 'HOME=/' ] 66 | RUN [ "$(pysu games env | grep '^HOME=')" = 'HOME=/usr/games' ] 67 | RUN [ "$(pysu games:daemon env | grep '^HOME=')" = 'HOME=/usr/games' ] 68 | 69 | # make sure we error out properly in unexpected cases like an invalid username 70 | RUN ! pysu bogus true 71 | RUN ! pysu 0day true 72 | RUN ! pysu 0:bogus true 73 | RUN ! pysu 0:0day true 74 | 75 | # something missing? some other functionality we could test easily? PR! :D 76 | -------------------------------------------------------------------------------- /tests/test-debian.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON 2 | FROM python:${PYTHON}-slim-bookworm 3 | 4 | # add "nobody" to ALL groups (makes testing edge cases more interesting) 5 | RUN cut -d: -f1 /etc/group | xargs -rtI'{}' usermod -aG '{}' nobody 6 | # emulate Alpine's "games" user (which is part of the "users" group) 7 | RUN usermod -aG users games 8 | 9 | RUN { \ 10 | echo '#!/bin/sh'; \ 11 | echo 'set -ex'; \ 12 | echo; \ 13 | echo 'spec="$1"; shift'; \ 14 | echo; \ 15 | echo 'expec="$1"; shift'; \ 16 | echo 'real="$(pysu "$spec" id -u):$(pysu "$spec" id -g):$(pysu "$spec" id -G)"'; \ 17 | echo '[ "$expec" = "$real" ]'; \ 18 | echo; \ 19 | echo 'expec="$1"; shift'; \ 20 | # have to "|| true" this one because of "id: unknown ID 1000" (rightfully) having a nonzero exit code 21 | echo 'real="$(pysu "$spec" id -un):$(pysu "$spec" id -gn):$(pysu "$spec" id -Gn)" || true'; \ 22 | echo '[ "$expec" = "$real" ]'; \ 23 | } > /usr/local/bin/pysu-t \ 24 | && chmod +x /usr/local/bin/pysu-t 25 | 26 | COPY . /root/ 27 | RUN pip install --disable-pip-version-check /root/ 28 | 29 | # adjust users so we can make sure the tests are interesting 30 | RUN chgrp nogroup $(which python) $(which pysu) \ 31 | && chmod +s $(which python) $(which pysu) 32 | USER nobody 33 | ENV HOME /omg/really/pysu/nowhere 34 | # now we should be nobody, ALL groups, and have a bogus useless HOME value 35 | 36 | RUN id 37 | 38 | RUN pysu-t 0 "0:0:$(id -G root)" "root:root:$(id -Gn root)" 39 | RUN pysu-t 0:0 '0:0:0' 'root:root:root' 40 | RUN pysu-t root "0:0:$(id -G root)" "root:root:$(id -Gn root)" 41 | RUN pysu-t 0:root '0:0:0' 'root:root:root' 42 | RUN pysu-t root:0 '0:0:0' 'root:root:root' 43 | RUN pysu-t root:root '0:0:0' 'root:root:root' 44 | RUN pysu-t 1000 "1000:$(id -g):$(id -g)" "1000:$(id -gn):$(id -gn)" 45 | RUN pysu-t 0:1000 '0:1000:1000' 'root:1000:1000' 46 | RUN pysu-t 1000:1000 '1000:1000:1000' '1000:1000:1000' 47 | RUN pysu-t root:1000 '0:1000:1000' 'root:1000:1000' 48 | RUN pysu-t 1000:root '1000:0:0' '1000:root:root' 49 | RUN pysu-t 1000:daemon "1000:$(id -g daemon):$(id -g daemon)" '1000:daemon:daemon' 50 | RUN pysu-t games "$(id -u games):$(id -g games):$(id -G games)" 'games:games:games users' 51 | RUN pysu-t games:daemon "$(id -u games):$(id -g daemon):$(id -g daemon)" 'games:daemon:daemon' 52 | 53 | RUN pysu-t 0: "0:0:$(id -G root)" "root:root:$(id -Gn root)" 54 | RUN pysu-t '' "$(id -u):$(id -g):$(id -G)" "$(id -un):$(id -gn):$(id -Gn)" 55 | RUN pysu-t ':0' "$(id -u):0:0" "$(id -un):root:root" 56 | 57 | RUN [ "$(pysu 0 env | grep '^HOME=')" = 'HOME=/root' ] 58 | RUN [ "$(pysu 0:0 env | grep '^HOME=')" = 'HOME=/root' ] 59 | RUN [ "$(pysu root env | grep '^HOME=')" = 'HOME=/root' ] 60 | RUN [ "$(pysu 0:root env | grep '^HOME=')" = 'HOME=/root' ] 61 | RUN [ "$(pysu root:0 env | grep '^HOME=')" = 'HOME=/root' ] 62 | RUN [ "$(pysu root:root env | grep '^HOME=')" = 'HOME=/root' ] 63 | RUN [ "$(pysu 0:1000 env | grep '^HOME=')" = 'HOME=/root' ] 64 | RUN [ "$(pysu root:1000 env | grep '^HOME=')" = 'HOME=/root' ] 65 | RUN [ "$(pysu 1000 env | grep '^HOME=')" = 'HOME=/' ] 66 | RUN [ "$(pysu 1000:0 env | grep '^HOME=')" = 'HOME=/' ] 67 | RUN [ "$(pysu 1000:root env | grep '^HOME=')" = 'HOME=/' ] 68 | RUN [ "$(pysu games env | grep '^HOME=')" = 'HOME=/usr/games' ] 69 | RUN [ "$(pysu games:daemon env | grep '^HOME=')" = 'HOME=/usr/games' ] 70 | 71 | # make sure we error out properly in unexpected cases like an invalid username 72 | RUN ! pysu bogus true 73 | RUN ! pysu 0day true 74 | RUN ! pysu 0:bogus true 75 | RUN ! pysu 0:0day true 76 | 77 | # something missing? some other functionality we could test easily? PR! :D 78 | -------------------------------------------------------------------------------- /tests/test_docker.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | from sysconfig import get_python_version 4 | 5 | import pytest 6 | 7 | 8 | @pytest.mark.skipif(sys.platform == "darwin", reason="no Docker") 9 | @pytest.mark.parametrize("dockerfile", ["tests/test-alpine.Dockerfile", "tests/test-debian.Dockerfile"]) 10 | def test_main(dockerfile): 11 | subprocess.check_call(["docker", "build", ".", "-f", dockerfile, "--build-arg", f"PYTHON={get_python_version()}"]) 12 | -------------------------------------------------------------------------------- /tests/test_pysu.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pysu.cli import main 4 | 5 | 6 | def test_unknown_user(): 7 | assert main(["foo:bar", "echo"]) == 1 8 | 9 | 10 | def test_unknown_group(): 11 | assert main([f"{os.getuid()}:foobar", "echo"]) == 2 12 | 13 | 14 | def test_no_perm_user(): 15 | assert main([f"0:{os.getgid()}", "echo"]) == 3 16 | 17 | 18 | def test_no_perm_group(): 19 | assert main([f"{os.getuid()}:0", "echo"]) == 3 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv:bootstrap] 2 | deps = 3 | jinja2 4 | tox 5 | skip_install = true 6 | commands = 7 | python ci/bootstrap.py --no-env 8 | passenv = 9 | * 10 | 11 | ; a generative tox configuration, see: https://tox.wiki/en/latest/user_guide.html#generative-environments 12 | [tox] 13 | envlist = 14 | clean, 15 | check, 16 | {py38,py39,py310,py311,py312,pypy38,pypy39,pypy310}, 17 | report 18 | ignore_basepython_conflict = true 19 | 20 | [testenv] 21 | basepython = 22 | pypy38: {env:TOXPYTHON:pypy3.8} 23 | pypy39: {env:TOXPYTHON:pypy3.9} 24 | pypy310: {env:TOXPYTHON:pypy3.10} 25 | py38: {env:TOXPYTHON:python3.8} 26 | py39: {env:TOXPYTHON:python3.9} 27 | py310: {env:TOXPYTHON:python3.10} 28 | py311: {env:TOXPYTHON:python3.11} 29 | py312: {env:TOXPYTHON:python3.12} 30 | {bootstrap,clean,check,report}: {env:TOXPYTHON:python3} 31 | setenv = 32 | PYTHONPATH={toxinidir}/tests 33 | PYTHONUNBUFFERED=yes 34 | passenv = 35 | * 36 | usedevelop = false 37 | deps = 38 | pytest 39 | pytest-cov 40 | commands = 41 | {posargs:pytest --cov --cov-report=term-missing --cov-report=xml -vv tests} 42 | 43 | [testenv:check] 44 | deps = 45 | docutils 46 | check-manifest 47 | pre-commit 48 | readme-renderer 49 | pygments 50 | isort 51 | skip_install = true 52 | commands = 53 | python setup.py check --strict --metadata --restructuredtext 54 | check-manifest . 55 | pre-commit run --all-files --show-diff-on-failure 56 | 57 | [testenv:report] 58 | deps = 59 | coverage 60 | skip_install = true 61 | commands = 62 | coverage report 63 | coverage html 64 | 65 | [testenv:clean] 66 | commands = 67 | python setup.py clean 68 | coverage erase 69 | skip_install = true 70 | deps = 71 | coverage 72 | --------------------------------------------------------------------------------