diff --git a/.flake8 b/.flake8 index 0f42e666..6742d3dc 100644 --- a/.flake8 +++ b/.flake8 @@ -18,4 +18,4 @@ ignore = # Type Annotations ANN002,ANN003,ANN101,ANN102,ANN204,ANN206 -per-file-ignores = tests/*:D1,ANN +per-file-ignores = tests/*:D1,ANN,E202,E231,E241,E272,E702 diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1124b8ed..67ad71b4 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -64,11 +64,11 @@ Other things to look out for are breaking changes to NsJail's config format, its ## Adding and Updating Python Interpreters -Python interpreters are built using pyenv via the `scripts/build_python.sh` helper script. This script accepts a pyenv version specifier (`pyenv install --list`) and builds the interpreter in a version-specific directory under `/lang/python`. In the image, each minor version of a Python interpreter should have its own build stage and the resulting `/lang/python` directory can be copied from that stage into the `base` stage. +Python interpreters are built using pyenv via the `scripts/build_python.sh` helper script. This script accepts a pyenv version specifier (`pyenv install --list`) and builds the interpreter in a version-specific directory under `/snekbin/python`. In the image, each minor version of a Python interpreter should have its own build stage and the resulting `/snekbin/python` directory can be copied from that stage into the `base` stage. When updating a patch version (e.g. 3.11.3 to 3.11.4), edit the existing build stage in the image for the minor version (3.11); do not add a new build stage. To have access to a new version, pyenv likely needs to be updated. To do so, change the tag in the `git clone` command in the image, but only for the build stage that needs access to the new version. Updating pyenv for all build stages will just cause unnecessary build cache invalidations. -To change the default interpreter used by NsJail, update the target of the `/lang/python/default` symlink created in the `base` stage. +To change the default interpreter used by NsJail, update the target of the `/snekbin/python/default` symlink created in the `base` stage. [readme]: ../README.md [Dockerfile]: ../Dockerfile diff --git a/Dockerfile b/Dockerfile index ed198c4f..cb990269 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,11 +54,11 @@ RUN apt-get -y update \ && rm -rf /var/lib/apt/lists/* COPY --link --from=builder-nsjail /nsjail/nsjail /usr/sbin/ -COPY --link --from=builder-py-3_12 /lang/ /lang/ -COPY --link --from=builder-py-3_13 /lang/ /lang/ +COPY --link --from=builder-py-3_12 /snekbin/ /snekbin/ +COPY --link --from=builder-py-3_13 /snekbin/ /snekbin/ RUN chmod +x /usr/sbin/nsjail \ - && ln -s /lang/python/3.12/ /lang/python/default + && ln -s /snekbin/python/3.12/ /snekbin/python/default # ------------------------------------------------------------------------------ FROM base as venv @@ -79,7 +79,7 @@ RUN if [ -n "${DEV}" ]; \ then \ pip install -U -r requirements/coverage.pip \ && export PYTHONUSERBASE=/snekbox/user_base \ - && /lang/python/default/bin/python -m pip install --user numpy~=1.19; \ + && /snekbin/python/default/bin/python -m pip install --user numpy~=1.19; \ fi # At the end to avoid re-installing dependencies when only a config changes. diff --git a/README.md b/README.md index c4779a44..52cc7f15 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,14 @@ To run it in the background, use the `-d` option. See the documentation on [`doc The above command will make the API accessible on the host via `http://localhost:8060/`. Currently, there's only one endpoint: `http://localhost:8060/eval`. +### Python multi-version support + +By default, the executable that runs within nsjail is defined by `DEFAULT_EXECUTABLE_PATH` at the top of [`nsjail.py`]. This can be overridden by specifying `executable_path` in the request body of calls to `POST /eval` or by setting the `executable_path` kwarg if calling `NSJail.python3()` directly. + +Any executable that exists within the container is a valid value for `executable_path`. The main use case of this feature is currently to specify the version of Python to use. + +Python versions currently available can be found in the [`Dockerfile`] by looking for build stages that match `builder-py-*`. These binaries are then copied into the `base` build stage further down. + ## Configuration Configuration files can be edited directly. However, this requires rebuilding the image. Alternatively, a Docker volume or bind mounts can be used to override the configuration files at their default locations. @@ -105,7 +113,7 @@ To expose third-party Python packages during evaluation, install them to a custo ```sh docker exec snekbox /bin/sh -c \ - 'PYTHONUSERBASE=/snekbox/user_base /lang/python/default/bin/python -m pip install --user numpy' + 'PYTHONUSERBASE=/snekbox/user_base /snekbin/python/default/bin/python -m pip install --user numpy' ``` In the above command, `snekbox` is the name of the running container. The name may be different and can be checked with `docker ps`. @@ -126,9 +134,11 @@ See [CONTRIBUTING.md](.github/CONTRIBUTING.md). [7]: https://github.com/google/nsjail/blob/master/config.proto [`gunicorn.conf.py`]: config/gunicorn.conf.py [`snekbox.cfg`]: config/snekbox.cfg +[`nsjail.py`]: snekbox/nsjail.py [`snekapi.py`]: snekbox/api/snekapi.py [`resources`]: snekbox/api/resources [`docker-compose.yml`]: docker-compose.yml +[`Dockerfile`]: Dockerfile [`docker run`]: https://docs.docker.com/engine/reference/commandline/run/ [nsjail]: https://github.com/google/nsjail [falcon]: https://falconframework.org/ diff --git a/config/snekbox.cfg b/config/snekbox.cfg index b4055219..778f90e8 100644 --- a/config/snekbox.cfg +++ b/config/snekbox.cfg @@ -81,8 +81,8 @@ mount { } mount { - src: "/lang" - dst: "/lang" + src: "/snekbin" + dst: "/snekbin" is_bind: true rw: false } @@ -103,8 +103,3 @@ cgroup_pids_max: 6 cgroup_pids_mount: "/sys/fs/cgroup/pids" iface_no_lo: true - -exec_bin { - path: "/lang/python/default/bin/python" - arg: "" -} diff --git a/scripts/build_python.sh b/scripts/build_python.sh index da937c25..77f50ab6 100755 --- a/scripts/build_python.sh +++ b/scripts/build_python.sh @@ -4,14 +4,14 @@ shopt -s inherit_errexit py_version="${1}" -# Install Python interpreter under e.g. /lang/python/3.11/ (no patch version). +# Install Python interpreter under e.g. /snekbin/python/3.11/ (no patch version). "${PYENV_ROOT}/plugins/python-build/bin/python-build" \ "${py_version}" \ - "/lang/python/${py_version%.*}" -"/lang/python/${py_version%.*}/bin/python" -m pip install -U pip + "/snekbin/python/${py_version%[-.]*}" +"/snekbin/python/${py_version%[-.]*}/bin/python" -m pip install -U pip # Clean up some unnecessary files to reduce image size bloat. -find /lang/python/ -depth \ +find /snekbin/python/ -depth \ \( \ \( -type d -a \( \ -name test -o -name tests -o -name idle_test \ diff --git a/scripts/install_eval_deps.sh b/scripts/install_eval_deps.sh index 8fa53169..b57a6543 100644 --- a/scripts/install_eval_deps.sh +++ b/scripts/install_eval_deps.sh @@ -1,5 +1,5 @@ set -euo pipefail export PYTHONUSERBASE=/snekbox/user_base -find /lang/python -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0I{} bash -c \ +find /snekbin/python -mindepth 1 -maxdepth 1 -type d -print0 | xargs -0I{} bash -c \ '{}/bin/python -m pip install --user -U -r requirements/eval-deps.pip' \; diff --git a/snekbox/api/resources/eval.py b/snekbox/api/resources/eval.py index 55bba984..b53899a5 100644 --- a/snekbox/api/resources/eval.py +++ b/snekbox/api/resources/eval.py @@ -1,11 +1,12 @@ from __future__ import annotations import logging +from pathlib import Path import falcon from falcon.media.validators.jsonschema import validate -from snekbox.nsjail import NsJail +from snekbox.nsjail import DEFAULT_EXECUTABLE_PATH, NsJail from snekbox.snekio import FileAttachment, ParsingError __all__ = ("EvalResource",) @@ -43,6 +44,7 @@ class EvalResource: "required": ["path"], }, }, + "executable_path": {"type": "string"}, }, "anyOf": [ {"required": ["input"]}, @@ -122,10 +124,25 @@ def on_post(self, req: falcon.Request, resp: falcon.Response) -> None: if "input" in body: body.setdefault("args", ["-c"]) body["args"].append(body["input"]) + + executable_path = body.get("executable_path") + if not executable_path: + executable_path = DEFAULT_EXECUTABLE_PATH + else: + executable_path = Path(executable_path) + if not executable_path.exists(): + raise falcon.HTTPBadRequest(title="executable_path does not exist") + if not executable_path.is_file(): + raise falcon.HTTPBadRequest(title="executable_path is not a file") + if not executable_path.stat().st_mode & 0o100 == 0o100: + raise falcon.HTTPBadRequest(title="executable_path is not executable") + executable_path = executable_path.resolve().as_posix() + try: result = self.nsjail.python3( py_args=body["args"], files=[FileAttachment.from_dict(file) for file in body.get("files", [])], + executable_path=executable_path, ) except ParsingError as e: raise falcon.HTTPBadRequest(title="Request file is invalid", description=str(e)) diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index 0d5e3c61..adbf69e0 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -26,6 +26,7 @@ LOG_PATTERN = re.compile( r"\[(?P(I)|[DWEF])\]\[.+?\](?(2)|(?P\[\d+\] .+?:\d+ )) ?(?P.+)" ) +DEFAULT_EXECUTABLE_PATH = "/snekbin/python/default/bin/python" class NsJail: @@ -168,7 +169,12 @@ def _consume_stdout(self, nsjail: subprocess.Popen) -> str: return "".join(output) def _build_args( - self, py_args: Iterable[str], nsjail_args: Iterable[str], log_path: str, fs_home: str + self, + py_args: Iterable[str], + nsjail_args: Iterable[str], + log_path: str, + fs_home: str, + executable_path: str, ) -> Sequence[str]: if self.cgroup_version == 2: nsjail_args = ("--use_cgroupv2", *nsjail_args) @@ -185,7 +191,7 @@ def _build_args( nsjail_args = ( # Mount `home` with Read/Write access "--bindmount", - f"{fs_home}:home", + f"{fs_home}:home", # noqa: E231 *nsjail_args, ) @@ -197,10 +203,7 @@ def _build_args( log_path, *nsjail_args, "--", - self.config.exec_bin.path, - # Filter out empty strings at start of Python args - # (causes issues with python cli) - *iter_lstrip(self.config.exec_bin.arg), + executable_path, *iter_lstrip(py_args), ] @@ -259,6 +262,7 @@ def python3( py_args: Iterable[str], files: Iterable[FileAttachment] = (), nsjail_args: Iterable[str] = (), + executable_path: Path = DEFAULT_EXECUTABLE_PATH, ) -> EvalResult: """ Execute Python 3 code in an isolated environment and return the completed process. @@ -267,13 +271,20 @@ def python3( py_args: Arguments to pass to Python. files: FileAttachments to write to the sandbox prior to running Python. nsjail_args: Overrides for the NsJail configuration. + executable_path: The path to the executable to run within nsjail. """ with NamedTemporaryFile() as nsj_log, MemFS( instance_size=self.memfs_instance_size, home=self.memfs_home, output=self.memfs_output, ) as fs: - args = self._build_args(py_args, nsjail_args, nsj_log.name, str(fs.home)) + args = self._build_args( + py_args, + nsjail_args, + nsj_log.name, + str(fs.home), + executable_path, + ) try: files_written = self._write_files(fs.home, files) diff --git a/tests/test_integration.py b/tests/test_integration.py index 91b01e68..0d8f700d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -52,6 +52,60 @@ def test_memory_limit_separate_per_process(self): self.assertTrue(all(status == 200 for status in statuses)) self.assertTrue(all(json.loads(response)["returncode"] == 0 for response in responses)) + def test_alternate_executable_support(self): + """Test eval requests with different executable paths set.""" + with run_gunicorn(): + get_python_version_body = { + "input": "import sys; print('.'.join(map(str, sys.version_info[:2])))" + } + cases = [ + ( + get_python_version_body, + "3.12\n", + "test default executable is used when executable_path not specified", + ), + ( + get_python_version_body + | {"executable_path": "/snekbin/python/3.12/bin/python"}, + "3.12\n", + "test default executable is used when explicitly set", + ), + ( + get_python_version_body + | {"executable_path": "/snekbin/python/3.13/bin/python"}, + "3.13\n", + "test alternative executable is used when set", + ), + ] + for body, expected, msg in cases: + with self.subTest(msg=msg, body=body, expected=expected): + response, status = snekbox_request(body) + self.assertEqual(status, 200) + self.assertEqual(json.loads(response)["stdout"], expected) + + def invalid_executable_paths(self): + """Test that passing invalid executable paths result in no code execution.""" + with run_gunicorn(): + cases = [ + ( + "/abc/def", + "test non-existent files are not run", + "executable_path does not exist", + ), + ("/snekbin", "test directories are not run", "executable_path is not a file"), + ( + "/etc/hostname", + "test non-executable files are not run", + "executable_path is not executable", + ), + ] + for path, msg, expected in cases: + with self.subTest(msg=msg, path=path, expected=expected): + body = {"args": ["-c", "echo", "hi"], "executable_path": path} + response, status = snekbox_request(body) + self.assertEqual(status, 400) + self.assertEqual(json.loads(response)["stdout"], expected) + def test_eval(self): """Test normal eval requests without files.""" with run_gunicorn(): diff --git a/tests/test_nsjail.py b/tests/test_nsjail.py index dde20bc8..6764f1ef 100644 --- a/tests/test_nsjail.py +++ b/tests/test_nsjail.py @@ -9,7 +9,7 @@ from pathlib import Path from textwrap import dedent -from snekbox.nsjail import NsJail +from snekbox.nsjail import DEFAULT_EXECUTABLE_PATH, NsJail from snekbox.snekio import FileAttachment from snekbox.snekio.filesystem import Size @@ -82,7 +82,7 @@ def test_subprocess_resource_unavailable(self): for _ in range({max_pids}): print(subprocess.Popen( [ - '/lang/python/default/bin/python', + '/snekbin/python/default/bin/python', '-c', 'import time; time.sleep(1)' ], @@ -547,7 +547,7 @@ def test_py_args(self): for args, expected in cases: with self.subTest(args=args): result = self.nsjail.python3(py_args=args) - idx = result.args.index(self.nsjail.config.exec_bin.path) + idx = result.args.index(DEFAULT_EXECUTABLE_PATH) self.assertEqual(result.args[idx + 1 :], expected) self.assertEqual(result.returncode, 0)