From e4c6629fd1d627f4ab7482301bbbd1fd75178a42 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 23 Jul 2026 11:12:58 +0800 Subject: [PATCH 1/3] Bound CLI paths and clear the new-code findings on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging the release exposed a batch of SonarCloud findings that the PR gates never saw, because a PR only rates its own diff. The CLI entry points took a path straight from argv into write_text / mkdir. They now canonicalise it through a shared `path_guard` helper — realpath first, so `..` and symlinks resolve before the check — and refuse anything outside the working directory, the user's home or the temp directory. AUTOCONTROL_ALLOWED_PATH_ROOTS re-opens a mounted volume when a deployment needs one. The rest: the container images pin pip and take wheels only; the reusable action-json-lint workflow pins its default install spec; determinism tests bind their two calls to names so the assertion is not literally `f(x) == f(x)`; the two record scripts no longer hide an assert inside a handler that catches AssertionError; and the file, language and token inputs get real `label for=` associations. --- .github/workflows/action-json-lint.yml | 10 ++- docker/Dockerfile | 11 ++- docker/Dockerfile.xfce | 6 +- .../utils/config_bundle/__main__.py | 25 +++--- je_auto_control/utils/path_guard/__init__.py | 10 +++ .../utils/path_guard/path_guard.py | 89 +++++++++++++++++++ .../remote_desktop/connect_coordinator.py | 4 +- .../utils/remote_desktop/turn_config.py | 14 ++- .../remote_desktop/web_viewer/index.html | 7 ++ .../utils/rest_api/dashboard/swagger.html | 2 +- je_auto_control/utils/stubs/generator.py | 15 ++-- .../total_record_and_html_report_test.py | 10 ++- .../headless/test_feature_flags_batch.py | 10 ++- .../headless/test_image_dedup_batch.py | 2 +- .../headless/test_loop_guard_batch.py | 8 +- .../headless/test_multipart_batch.py | 3 +- .../headless/test_path_guard_batch.py | 78 ++++++++++++++++ .../unit_test/headless/test_stub_generator.py | 3 +- .../total_record/total_record_test.py | 5 +- 19 files changed, 269 insertions(+), 43 deletions(-) create mode 100644 je_auto_control/utils/path_guard/__init__.py create mode 100644 je_auto_control/utils/path_guard/path_guard.py create mode 100644 test/unit_test/headless/test_path_guard_batch.py diff --git a/.github/workflows/action-json-lint.yml b/.github/workflows/action-json-lint.yml index bf74830d..3e8058f4 100644 --- a/.github/workflows/action-json-lint.yml +++ b/.github/workflows/action-json-lint.yml @@ -17,10 +17,12 @@ on: type: string default: "**/*.action.json" autocontrol_ref: - description: "Pip spec for je_auto_control (e.g. == 0.1.0 or git+https://...)." + description: "Pip spec for je_auto_control (e.g. ==0.1.0 or git+https://...)." required: false type: string - default: "je_auto_control" + # Pinned by default so a downstream lint run is reproducible; pass + # your own spec to track a different release or a git ref. + default: "je_auto_control==0.0.214" jobs: lint: @@ -36,8 +38,8 @@ jobs: env: AUTOCONTROL_REF: ${{ inputs.autocontrol_ref }} run: | - python -m pip install --upgrade pip - python -m pip install "$AUTOCONTROL_REF" + python -m pip install --only-binary :all: --upgrade "pip==26.0.1" + python -m pip install "$AUTOCONTROL_REF" # NOSONAR githubactions:S8544 # reason: documented workflow input, pinned by default, so callers can point at their own release or git ref - name: Lint action JSON files shell: bash diff --git a/docker/Dockerfile b/docker/Dockerfile index 44539c4a..e5cf6c7a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -38,10 +38,13 @@ COPY autocontrol-lsp ./autocontrol-lsp COPY README.md ./ # Install the package + the [webrtc] extra so the optional WebRTC host -# also works inside the container. Pin pip first to keep layer churn -# minimal across CI rebuilds. -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -e . +# also works inside the container. pip itself is pinned so a rebuild +# resolves the same installer, and only wheels are accepted so no +# dependency gets to run a setup script during the build. +RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" +# The editable install targets the source tree copied in above; there is +# no upstream version to lock and the project's own build must run. +RUN pip install --no-cache-dir -e . # NOSONAR docker:S8541,docker:S8544 ENV DISPLAY=:99 \ PYTHONUNBUFFERED=1 \ diff --git a/docker/Dockerfile.xfce b/docker/Dockerfile.xfce index fa43e0a8..380822b0 100644 --- a/docker/Dockerfile.xfce +++ b/docker/Dockerfile.xfce @@ -39,8 +39,10 @@ COPY je_auto_control ./je_auto_control COPY autocontrol-lsp ./autocontrol-lsp COPY README.md ./ -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -e . +RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" +# The editable install targets the source tree copied in above; there is +# no upstream version to lock and the project's own build must run. +RUN pip install --no-cache-dir -e . # NOSONAR docker:S8541,docker:S8544 ENV DISPLAY=:99 \ PYTHONUNBUFFERED=1 \ diff --git a/je_auto_control/utils/config_bundle/__main__.py b/je_auto_control/utils/config_bundle/__main__.py index f57395a7..4cb52fb8 100644 --- a/je_auto_control/utils/config_bundle/__main__.py +++ b/je_auto_control/utils/config_bundle/__main__.py @@ -11,6 +11,9 @@ ConfigBundleError, default_bundle_root, export_config_bundle, import_config_bundle, ) +from je_auto_control.utils.path_guard.path_guard import ( + PathNotAllowedError, validate_path, +) def _build_arg_parser() -> argparse.ArgumentParser: @@ -38,19 +41,21 @@ def _build_arg_parser() -> argparse.ArgumentParser: def main(argv: Optional[list] = None) -> int: args = _build_arg_parser().parse_args(argv) - if args.action == "export": - return _do_export(args.output, args.root) - return _do_import(args.input, args.root, args.dry_run) + try: + if args.action == "export": + return _do_export(validate_path(args.output), args.root) + return _do_import(validate_path(args.input, must_exist=True), + args.root, args.dry_run) + except PathNotAllowedError as error: + print(f"refusing to use that path: {error}", file=sys.stderr) + return 2 def _do_export(output: Path, root: Optional[Path]) -> int: bundle = export_config_bundle(root=root) output.parent.mkdir(parents=True, exist_ok=True) - # The output path comes from argv on a CLI entry point. The operator - # running ``python -m ... export `` is the trust boundary; - # restricting where they can write would break the documented - # export workflow. - output.write_text( # NOSONAR — operator-controlled CLI argument by design (see comment above) + # ``output`` was canonicalised and bounded by validate_path() in main(). + output.write_text( json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8", ) @@ -64,8 +69,8 @@ def _do_export(output: Path, root: Optional[Path]) -> int: def _do_import(source: Path, root: Optional[Path], dry_run: bool) -> int: try: - # source is an operator-supplied CLI path, not remote input - bundle = json.loads(source.read_text(encoding="utf-8")) # NOSONAR + # ``source`` was canonicalised and bounded by validate_path() in main(). + bundle = json.loads(source.read_text(encoding="utf-8")) except (OSError, ValueError) as error: print(f"failed to read {source}: {error}", file=sys.stderr) return 2 diff --git a/je_auto_control/utils/path_guard/__init__.py b/je_auto_control/utils/path_guard/__init__.py new file mode 100644 index 00000000..6f65b514 --- /dev/null +++ b/je_auto_control/utils/path_guard/__init__.py @@ -0,0 +1,10 @@ +"""Canonicalise and bound filesystem paths supplied on the command line.""" +from je_auto_control.utils.path_guard.path_guard import ( + ALLOWED_ROOTS_ENV, PathNotAllowedError, default_allowed_roots, + validate_path, +) + +__all__ = [ + "ALLOWED_ROOTS_ENV", "PathNotAllowedError", "default_allowed_roots", + "validate_path", +] diff --git a/je_auto_control/utils/path_guard/path_guard.py b/je_auto_control/utils/path_guard/path_guard.py new file mode 100644 index 00000000..18ba3d65 --- /dev/null +++ b/je_auto_control/utils/path_guard/path_guard.py @@ -0,0 +1,89 @@ +"""Canonicalise and bound filesystem paths that arrive from a CLI argument. + +The ``python -m je_auto_control...`` entry points take output/input paths from +``argv``. A mistaken — or generated — argument such as ``../../etc/passwd`` +would otherwise be handed straight to ``write_text``/``mkdir``. Every such +path goes through :func:`validate_path` first: it is canonicalised with +``os.path.realpath`` (so ``..`` and symlinks are resolved before the check) +and must land inside one of the allowed roots. + +The default roots are the current working directory, the user's home and the +system temp directory — the places an operator actually exports to. Set +``AUTOCONTROL_ALLOWED_PATH_ROOTS`` (``os.pathsep``-separated) to add more, for +example a mounted volume in a container. + +Headless module: imports no PySide6. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Iterable, List, Optional, Sequence + +from je_auto_control.utils.exception.exceptions import AutoControlException + +ALLOWED_ROOTS_ENV = "AUTOCONTROL_ALLOWED_PATH_ROOTS" + + +class PathNotAllowedError(AutoControlException): + """A supplied path is malformed or resolves outside the allowed roots.""" + + +def default_allowed_roots() -> List[Path]: + """Return the roots a CLI path may resolve into, extras from env first.""" + roots: List[Path] = [] + for entry in os.environ.get(ALLOWED_ROOTS_ENV, "").split(os.pathsep): + if entry.strip(): + roots.append(_canonical(entry.strip())) + roots.append(_canonical(Path.cwd())) + roots.append(_canonical(Path.home())) + roots.append(_canonical(tempfile.gettempdir())) + return roots + + +def validate_path(raw: os.PathLike | str, *, + allowed_roots: Optional[Iterable[os.PathLike | str]] = None, + allowed_suffixes: Optional[Sequence[str]] = None, + must_exist: bool = False) -> Path: + """Return ``raw`` canonicalised, or raise :class:`PathNotAllowedError`. + + ``allowed_suffixes`` is matched case-insensitively against the final + suffix. ``must_exist`` additionally requires the resolved path to be + present on disk. + """ + text = os.fspath(raw) + if not text or "\x00" in text: + raise PathNotAllowedError(f"invalid path: {text!r}") + candidate = _canonical(text) + _check_suffix(candidate, allowed_suffixes) + roots = default_allowed_roots() if allowed_roots is None \ + else [_canonical(root) for root in allowed_roots] + if not any(_is_within(candidate, root) for root in roots): + raise PathNotAllowedError( + f"{candidate} is outside the allowed roots " + f"({', '.join(str(root) for root in roots)}); " + f"set {ALLOWED_ROOTS_ENV} to permit it") + if must_exist and not candidate.exists(): + raise PathNotAllowedError(f"{candidate} does not exist") + return candidate + + +# --- internals ---------------------------------------------------- + +def _canonical(value: os.PathLike | str) -> Path: + return Path(os.path.realpath(Path(value).expanduser())) + + +def _check_suffix(candidate: Path, + allowed_suffixes: Optional[Sequence[str]]) -> None: + if not allowed_suffixes: + return + wanted = {suffix.lower() for suffix in allowed_suffixes} + if candidate.suffix.lower() not in wanted: + raise PathNotAllowedError( + f"{candidate.name} does not end in {' / '.join(sorted(wanted))}") + + +def _is_within(candidate: Path, root: Path) -> bool: + return candidate == root or root in candidate.parents diff --git a/je_auto_control/utils/remote_desktop/connect_coordinator.py b/je_auto_control/utils/remote_desktop/connect_coordinator.py index a1acc25c..6933896f 100644 --- a/je_auto_control/utils/remote_desktop/connect_coordinator.py +++ b/je_auto_control/utils/remote_desktop/connect_coordinator.py @@ -30,7 +30,9 @@ ) _TCP_SCHEMES = ("tcp://",) -_WS_SCHEME = "ws://" +# Recognised, never defaulted to: a target is classified as ``ws`` only when +# the operator typed the scheme. ``wss://`` is matched first. +_WS_SCHEME = "ws://" # NOSONAR python:S5332 # reason: prefix used to parse operator input, not to open a connection _WSS_SCHEME = "wss://" _DIGIT_GROUP_PATTERN = re.compile(r"^[\d\s\-_]+$") _MIN_PORT = 1 diff --git a/je_auto_control/utils/remote_desktop/turn_config.py b/je_auto_control/utils/remote_desktop/turn_config.py index a9373a32..08301707 100644 --- a/je_auto_control/utils/remote_desktop/turn_config.py +++ b/je_auto_control/utils/remote_desktop/turn_config.py @@ -28,6 +28,9 @@ from pathlib import Path from typing import Optional +from je_auto_control.utils.path_guard.path_guard import ( + PathNotAllowedError, validate_path, +) _DEFAULT_PORT = 3478 _DEFAULT_TLS_PORT = 5349 @@ -142,8 +145,8 @@ def write_bundle(output_dir: Path, *, realm: str, user: str, secret: str, listen_port: int, tls_port: int, tls_cert: Optional[str], tls_key: Optional[str], external_ip: Optional[str]) -> None: - # output_dir is an operator-supplied CLI path, not remote input - output_dir.mkdir(parents=True, exist_ok=True) # NOSONAR + # CLI callers reach this through main(), which bounds the path first. + output_dir.mkdir(parents=True, exist_ok=True) conf_path = output_dir / "turnserver.conf" conf_path.write_text(render_turnserver_conf( realm=realm, listen_port=listen_port, tls_port=tls_port, @@ -197,9 +200,14 @@ def _build_arg_parser() -> argparse.ArgumentParser: def main(argv: Optional[list] = None) -> int: args = _build_arg_parser().parse_args(argv) + try: + output_dir = validate_path(args.output_dir) + except PathNotAllowedError as error: + print(f"refusing to write there: {error}", file=sys.stderr) + return 2 secret = args.secret or secrets.token_urlsafe(24) write_bundle( - args.output_dir, + output_dir, realm=args.realm, user=args.user, secret=secret, listen_port=args.listen, tls_port=args.tls_port, tls_cert=args.tls_cert, tls_key=args.tls_key, diff --git a/je_auto_control/utils/remote_desktop/web_viewer/index.html b/je_auto_control/utils/remote_desktop/web_viewer/index.html index f371c074..6c64038c 100644 --- a/je_auto_control/utils/remote_desktop/web_viewer/index.html +++ b/je_auto_control/utils/remote_desktop/web_viewer/index.html @@ -89,6 +89,11 @@ #screen { max-width: 100%; max-height: 100%; cursor: crosshair; display: block; } #placeholder { color: #555; font-size: 14px; padding: 24px; text-align: center; } + /* Off-screen but still announced by screen readers and reachable by + `for=` association, for controls whose purpose is shown elsewhere. */ + .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; + padding: 0; overflow: hidden; clip: rect(0 0 0 0); + white-space: nowrap; border: 0; } @@ -108,7 +113,9 @@ + + diff --git a/je_auto_control/utils/stubs/generator.py b/je_auto_control/utils/stubs/generator.py index fcec4444..43b604f8 100644 --- a/je_auto_control/utils/stubs/generator.py +++ b/je_auto_control/utils/stubs/generator.py @@ -24,6 +24,10 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional +from je_auto_control.utils.path_guard.path_guard import ( + PathNotAllowedError, validate_path, +) + _HEADER = ( "# AUTOGENERATED by je_auto_control.utils.stubs.generator — do not edit.\n" "# Regenerate with: python -m je_auto_control.utils.stubs.generator\n" @@ -88,8 +92,8 @@ def write_pyi(target: Path, target.parent.mkdir(parents=True, exist_ok=True) tmp = target.with_suffix(target.suffix + ".tmp") tmp.write_text(body, encoding="utf-8") - # target is an operator-supplied CLI path, not remote input - tmp.replace(target) # NOSONAR + # CLI callers reach this through _cli(), which bounds the path first. + tmp.replace(target) return target @@ -192,11 +196,12 @@ def _cli(argv: Optional[List[str]] = None) -> int: signatures = collect_signatures() body = render_pyi(signatures) if args.path: - write_pyi(Path(args.path), signatures) - print(f"wrote {len(signatures)} signatures to {args.path}") + target = validate_path(args.path, allowed_suffixes=(".pyi",)) + write_pyi(target, signatures) + print(f"wrote {len(signatures)} signatures to {target}") else: sys.stdout.write(body) - except (OSError, ValueError, TypeError) as error: + except (OSError, ValueError, TypeError, PathNotAllowedError) as error: print(f"stub generation failed: {error}", file=sys.stderr) return 1 return 0 diff --git a/test/integrated_test/total_record_and_html_report_test/total_record_and_html_report_test.py b/test/integrated_test/total_record_and_html_report_test/total_record_and_html_report_test.py index cf1d2344..d7581627 100644 --- a/test/integrated_test/total_record_and_html_report_test/total_record_and_html_report_test.py +++ b/test/integrated_test/total_record_and_html_report_test/total_record_and_html_report_test.py @@ -12,13 +12,19 @@ print(keyboard_keys_table.keys()) press_keyboard_key("shift") write("123456789") - assert write("abcdefghijklmnopqrstuvwxyz") == "abcdefghijklmnopqrstuvwxyz" # noqa: S101 # reason: integration test + # SystemExit is not an Exception, so a mismatch escapes the enclosing + # try block instead of being swallowed by its handler. + if write("abcdefghijklmnopqrstuvwxyz") != "abcdefghijklmnopqrstuvwxyz": + sys.exit("shift + write did not round-trip the alphabet") release_keyboard_key("shift") # this write will print one error -> keyboard write error can't find key : Ѓ and write remain string try: - assert write("?123456789") == "123456789" # noqa: S101 # reason: integration test + written = write("?123456789") except Exception as error: print(repr(error), file=sys.stderr) + else: + if written != "123456789": + print(f"Unexpected write result: {written!r}", file=sys.stderr) try: write("!#@L@#{@#PL#{!@#L{!#{|##PO}!@#O@!O#P!)KI#O_!K") except Exception as error: diff --git a/test/unit_test/headless/test_feature_flags_batch.py b/test/unit_test/headless/test_feature_flags_batch.py index 0c7f3d64..a58a712b 100644 --- a/test/unit_test/headless/test_feature_flags_batch.py +++ b/test/unit_test/headless/test_feature_flags_batch.py @@ -63,13 +63,15 @@ def test_is_enabled_shortcut(): def test_bucket_is_deterministic_and_in_range(): - assert percentage_bucket("f", "u1") == percentage_bucket("f", "u1") - assert 0 <= percentage_bucket("f", "u1") < 100 + first, second = percentage_bucket("f", "u1"), percentage_bucket("f", "u1") + assert first == second + assert 0 <= first < 100 def test_assign_variant_sticky_and_distributed(): - assert assign_variant("f", {"on": 50, "off": 50}, "stable") == \ - assign_variant("f", {"on": 50, "off": 50}, "stable") + first = assign_variant("f", {"on": 50, "off": 50}, "stable") + second = assign_variant("f", {"on": 50, "off": 50}, "stable") + assert first == second on = sum(1 for i in range(2000) if assign_variant("f", {"on": 50, "off": 50}, f"u{i}") == "on") assert 850 < on < 1150 # ~50% diff --git a/test/unit_test/headless/test_image_dedup_batch.py b/test/unit_test/headless/test_image_dedup_batch.py index 32b08be3..e1a7462d 100644 --- a/test/unit_test/headless/test_image_dedup_batch.py +++ b/test/unit_test/headless/test_image_dedup_batch.py @@ -42,7 +42,7 @@ def test_real_pillow_hashing(tmp_path): h_black = average_hash(str(black)) assert isinstance(h_black, str) and h_black - assert average_hash(str(black)) == average_hash(str(black)) # stable + assert average_hash(str(black)) == h_black # stable assert isinstance(dhash(str(black)), str) # two solid-but-different images dedupe down by perceptual hash diff --git a/test/unit_test/headless/test_loop_guard_batch.py b/test/unit_test/headless/test_loop_guard_batch.py index 0b026121..186f6d3c 100644 --- a/test/unit_test/headless/test_loop_guard_batch.py +++ b/test/unit_test/headless/test_loop_guard_batch.py @@ -57,9 +57,11 @@ def test_reset_clears_history(): def test_digest_result_stable_and_bytes(): - assert digest_result(b"abc") == digest_result(b"abc") - assert digest_result({"a": 1}) == digest_result({"a": 1}) - assert digest_result(b"abc") != digest_result(b"abd") + first_bytes, second_bytes = digest_result(b"abc"), digest_result(b"abc") + first_dict, second_dict = digest_result({"a": 1}), digest_result({"a": 1}) + assert first_bytes == second_bytes + assert first_dict == second_dict + assert first_bytes != digest_result(b"abd") # --- wiring --------------------------------------------------------------- diff --git a/test/unit_test/headless/test_multipart_batch.py b/test/unit_test/headless/test_multipart_batch.py index 0027cecd..143cd1cc 100644 --- a/test/unit_test/headless/test_multipart_batch.py +++ b/test/unit_test/headless/test_multipart_batch.py @@ -42,7 +42,8 @@ def test_field_list_form(): def test_new_boundary_is_unique(): - assert new_boundary() != new_boundary() + first, second = new_boundary(), new_boundary() + assert first != second def test_parse_requires_boundary(): diff --git a/test/unit_test/headless/test_path_guard_batch.py b/test/unit_test/headless/test_path_guard_batch.py new file mode 100644 index 00000000..554a2a24 --- /dev/null +++ b/test/unit_test/headless/test_path_guard_batch.py @@ -0,0 +1,78 @@ +"""Headless tests for the CLI path guard. No Qt imports.""" +import os +from pathlib import Path + +import pytest + +from je_auto_control.utils.path_guard import ( + ALLOWED_ROOTS_ENV, PathNotAllowedError, default_allowed_roots, + validate_path, +) + + +# === canonicalisation ==================================================== + +def test_returns_a_resolved_absolute_path(tmp_path: Path): + target = validate_path(tmp_path / "sub" / ".." / "bundle.json") + assert target.is_absolute() + assert ".." not in target.parts + assert target.name == "bundle.json" + + +def test_relative_path_resolves_against_cwd(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert validate_path("out.pyi").parent == Path(os.path.realpath(tmp_path)) + + +# === containment ========================================================= + +def test_traversal_outside_the_allowed_roots_is_rejected(tmp_path: Path): + root = tmp_path / "workdir" + root.mkdir() + with pytest.raises(PathNotAllowedError): + validate_path(root / ".." / "escaped.json", allowed_roots=[root]) + + +def test_path_inside_an_explicit_root_is_accepted(tmp_path: Path): + target = validate_path(tmp_path / "nested" / "bundle.json", + allowed_roots=[tmp_path]) + assert target.name == "bundle.json" + + +def test_env_var_extends_the_default_roots(tmp_path: Path, monkeypatch): + outside = tmp_path / "volume" + outside.mkdir() + monkeypatch.setenv(ALLOWED_ROOTS_ENV, str(outside)) + assert str(outside) in [str(root) for root in default_allowed_roots()] + assert validate_path(outside / "x.json").name == "x.json" + + +# === argument validation ================================================= + +def test_suffix_allowlist_is_enforced(tmp_path: Path): + with pytest.raises(PathNotAllowedError): + validate_path(tmp_path / "stub.txt", allowed_suffixes=(".pyi",)) + assert validate_path(tmp_path / "stub.PYI", allowed_suffixes=(".pyi",)) + + +def test_missing_file_rejected_when_existence_required(tmp_path: Path): + with pytest.raises(PathNotAllowedError): + validate_path(tmp_path / "absent.json", must_exist=True) + + +def test_empty_and_nul_paths_are_rejected(): + with pytest.raises(PathNotAllowedError): + validate_path("") + with pytest.raises(PathNotAllowedError): + validate_path("bundle\x00.json") + + +# === CLI wiring ========================================================== + +def test_config_bundle_cli_refuses_a_path_outside_the_roots(capsys, + monkeypatch): + from je_auto_control.utils.config_bundle import __main__ as cli + monkeypatch.setenv(ALLOWED_ROOTS_ENV, "") + blocked = Path(os.path.realpath(os.sep)) / "ac-escape-test.json" + assert cli.main(["export", str(blocked)]) == 2 + assert "refusing to use that path" in capsys.readouterr().err diff --git a/test/unit_test/headless/test_stub_generator.py b/test/unit_test/headless/test_stub_generator.py index 5b96eed0..8d84299d 100644 --- a/test/unit_test/headless/test_stub_generator.py +++ b/test/unit_test/headless/test_stub_generator.py @@ -90,7 +90,8 @@ def test_render_pyi_emits_ellipsis_when_no_docstring(): def test_render_pyi_deterministic(): sigs = collect_signatures() - assert render_pyi(sigs) == render_pyi(sigs) + first, second = render_pyi(sigs), render_pyi(sigs) + assert first == second # === write_pyi =========================================================== diff --git a/test/unit_test/total_record/total_record_test.py b/test/unit_test/total_record/total_record_test.py index d5689da5..8cca3a3f 100644 --- a/test/unit_test/total_record/total_record_test.py +++ b/test/unit_test/total_record/total_record_test.py @@ -15,9 +15,12 @@ # this write will raise an error for unsupported character try: - assert (write("?123456789") == "123456789") + written = write("?123456789") except Exception as error: print(f"Expected error for special character: {repr(error)}", file=sys.stderr) +else: + if written != "123456789": + print(f"Unexpected write result: {written!r}", file=sys.stderr) try: write("!#@L@#{@#PL#{!@#L{!#{|##PO}!@#O@!O#P!)KI#O_!K") From 3213ee73e08100be623e3e7bb89615fe84370a9d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 23 Jul 2026 11:23:16 +0800 Subject: [PATCH 2/3] Install the container image from prebuilt wheels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime layer resolved and built its dependencies straight from PyPI, so a rebuild could pick up a different set and any sdist in the graph got to run its setup script inside the image. A throwaway builder stage now produces every wheel, and the runtime installs with --only-binary and --no-index against that directory — nothing new can be fetched there. Drop the two suppressions that turned out to be inert: NOSONAR is not honoured inside a Dockerfile or a `run: |` block, so they were noise. --- .github/workflows/action-json-lint.yml | 5 ++++- docker/Dockerfile | 29 +++++++++++++++++++------- docker/Dockerfile.xfce | 21 +++++++++++++++---- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/.github/workflows/action-json-lint.yml b/.github/workflows/action-json-lint.yml index 3e8058f4..d5d98348 100644 --- a/.github/workflows/action-json-lint.yml +++ b/.github/workflows/action-json-lint.yml @@ -39,7 +39,10 @@ jobs: AUTOCONTROL_REF: ${{ inputs.autocontrol_ref }} run: | python -m pip install --only-binary :all: --upgrade "pip==26.0.1" - python -m pip install "$AUTOCONTROL_REF" # NOSONAR githubactions:S8544 # reason: documented workflow input, pinned by default, so callers can point at their own release or git ref + # The spec is a documented workflow input — pinned by default so a + # lint run is reproducible, overridable so a caller can track their + # own release or a git ref. + python -m pip install "$AUTOCONTROL_REF" - name: Lint action JSON files shell: bash diff --git a/docker/Dockerfile b/docker/Dockerfile index e5cf6c7a..8e0ae605 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -5,6 +5,20 @@ # Build: docker build -f docker/Dockerfile -t autocontrol:latest . # Run: docker run --rm -p 9939:9939 -p 9940:9940 autocontrol:latest +# Build every wheel — the project and its dependencies — in a throwaway +# stage. The runtime image then installs binaries only, so no dependency +# gets to execute a setup script there and the resolved set is fixed at +# build time instead of being re-resolved against PyPI. +FROM python:3.12-slim AS builder + +WORKDIR /src +COPY pyproject.toml README.md ./ +COPY je_auto_control ./je_auto_control +COPY autocontrol-lsp ./autocontrol-lsp +RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \ + && pip wheel --no-cache-dir --wheel-dir /wheels . + + FROM python:3.12-slim AS runtime ARG DEBIAN_FRONTEND=noninteractive @@ -37,14 +51,13 @@ COPY je_auto_control ./je_auto_control COPY autocontrol-lsp ./autocontrol-lsp COPY README.md ./ -# Install the package + the [webrtc] extra so the optional WebRTC host -# also works inside the container. pip itself is pinned so a rebuild -# resolves the same installer, and only wheels are accepted so no -# dependency gets to run a setup script during the build. -RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" -# The editable install targets the source tree copied in above; there is -# no upstream version to lock and the project's own build must run. -RUN pip install --no-cache-dir -e . # NOSONAR docker:S8541,docker:S8544 +# Install from the wheels built above: pinned pip, wheels only, and no +# index lookup, so the runtime layer cannot pull anything new from PyPI. +COPY --from=builder /wheels /wheels +RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \ + && pip install --no-cache-dir --only-binary :all: --no-index \ + --find-links=/wheels je_auto_control \ + && rm -rf /wheels ENV DISPLAY=:99 \ PYTHONUNBUFFERED=1 \ diff --git a/docker/Dockerfile.xfce b/docker/Dockerfile.xfce index 380822b0..e6501727 100644 --- a/docker/Dockerfile.xfce +++ b/docker/Dockerfile.xfce @@ -13,6 +13,18 @@ # Connect a VNC viewer to localhost:5900 (no password by default; set # AUTOCONTROL_VNC_PASSWORD to enable a TightVNC password). +# Same two-stage build as docker/Dockerfile: wheels are produced here so +# the runtime layer installs binaries only, with no index lookup. +FROM python:3.12-slim AS builder + +WORKDIR /src +COPY pyproject.toml README.md ./ +COPY je_auto_control ./je_auto_control +COPY autocontrol-lsp ./autocontrol-lsp +RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \ + && pip wheel --no-cache-dir --wheel-dir /wheels . + + FROM python:3.12-slim AS runtime ARG DEBIAN_FRONTEND=noninteractive @@ -39,10 +51,11 @@ COPY je_auto_control ./je_auto_control COPY autocontrol-lsp ./autocontrol-lsp COPY README.md ./ -RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" -# The editable install targets the source tree copied in above; there is -# no upstream version to lock and the project's own build must run. -RUN pip install --no-cache-dir -e . # NOSONAR docker:S8541,docker:S8544 +COPY --from=builder /wheels /wheels +RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \ + && pip install --no-cache-dir --only-binary :all: --no-index \ + --find-links=/wheels je_auto_control \ + && rm -rf /wheels ENV DISPLAY=:99 \ PYTHONUNBUFFERED=1 \ From 89565f4efbd1324afe9929b8cc7dc6a65cc7226c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 23 Jul 2026 11:32:38 +0800 Subject: [PATCH 3/3] Install the container's own wheel by path so the version is explicit --- docker/Dockerfile | 2 +- docker/Dockerfile.xfce | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e0ae605..912da703 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -56,7 +56,7 @@ COPY README.md ./ COPY --from=builder /wheels /wheels RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \ && pip install --no-cache-dir --only-binary :all: --no-index \ - --find-links=/wheels je_auto_control \ + --find-links=/wheels /wheels/je_auto_control-*.whl \ && rm -rf /wheels ENV DISPLAY=:99 \ diff --git a/docker/Dockerfile.xfce b/docker/Dockerfile.xfce index e6501727..cd026965 100644 --- a/docker/Dockerfile.xfce +++ b/docker/Dockerfile.xfce @@ -54,7 +54,7 @@ COPY README.md ./ COPY --from=builder /wheels /wheels RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \ && pip install --no-cache-dir --only-binary :all: --no-index \ - --find-links=/wheels je_auto_control \ + --find-links=/wheels /wheels/je_auto_control-*.whl \ && rm -rf /wheels ENV DISPLAY=:99 \