From 753d767401d131c85392ace37024afd60fdb6e49 Mon Sep 17 00:00:00 2001 From: Dinu Gherman Date: Tue, 20 Oct 2026 13:13:07 +0200 Subject: [PATCH 1/7] test: skip RAR fixtures when CLI is blocked --- tests/conftest.py | 73 +++++++++++++++++++++++++++++++++--------- tests/test_archives.py | 22 ++++++++++--- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 008ae0f..a34613d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,38 @@ ] +def _usable_rar_cli() -> str | None: + """Return RAR CLI only if it can create a tiny archive within a timeout. + + Merely finding an executable on PATH is not sufficient: macOS Gatekeeper + can block it, and some unusable binaries hang without output. Treat those + CLIs as unavailable so RAR fixtures remain optional. + """ + rar_cli = shutil.which("rar") + if rar_cli is None: + return None + try: + with tempfile.TemporaryDirectory() as tmp: + probe_dir = Path(tmp) + source = probe_dir / "probe.txt" + source.write_text("probe", encoding="utf-8") + archive = probe_dir / "probe.rar" + subprocess.run( + [rar_cli, "a", str(archive), str(source)], + capture_output=True, + timeout=3, + check=True, + ) + if not archive.is_file() or archive.stat().st_size == 0: + return None + except (OSError, subprocess.SubprocessError): + return None + return rar_cli + + +RAR_CLI = _usable_rar_cli() + + def _zip_bytes() -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: @@ -103,8 +135,7 @@ def sample_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Path] archives[".7z"] = p # RAR (only if the rar CLI is available) - rar_cli = shutil.which("rar") - if rar_cli: + if RAR_CLI: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) for name, data in _SAMPLE_FILES: @@ -112,10 +143,18 @@ def sample_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Path] f.parent.mkdir(parents=True, exist_ok=True) f.write_bytes(data) p = base / "sample.rar" - subprocess.run( - [rar_cli, "a", "-r", str(p), "."], cwd=tmp_path, check=True, capture_output=True - ) - archives[".rar"] = p + try: + subprocess.run( + [RAR_CLI, "a", "-r", str(p), "."], + cwd=tmp_path, + check=True, + capture_output=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + pass + else: + archives[".rar"] = p # .tar.zst / .tzst (only if bsdtar with zstd support is available) bsdtar_cli = shutil.which("bsdtar") @@ -212,8 +251,7 @@ def encrypted_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Pa archives[".zip"] = p # --- RAR with header encryption (requires rar CLI) --- - rar_cli = shutil.which("rar") - if rar_cli: + if RAR_CLI: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) for name, data in _SAMPLE_FILES: @@ -221,13 +259,18 @@ def encrypted_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Pa f.parent.mkdir(parents=True, exist_ok=True) f.write_bytes(data) p = base / "encrypted.rar" - subprocess.run( - [rar_cli, "a", "-r", f"-hp{ENCRYPTED_PASSWORD}", str(p), "."], - cwd=str(tmp_path), - check=True, - capture_output=True, - ) - archives[".rar"] = p + try: + subprocess.run( + [RAR_CLI, "a", "-r", f"-hp{ENCRYPTED_PASSWORD}", str(p), "."], + cwd=str(tmp_path), + check=True, + capture_output=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + pass + else: + archives[".rar"] = p return archives diff --git a/tests/test_archives.py b/tests/test_archives.py index 26294a9..b3aee6b 100644 --- a/tests/test_archives.py +++ b/tests/test_archives.py @@ -14,7 +14,7 @@ from dirplot.archives import PasswordRequired, build_tree_archive, is_archive_path from dirplot.main import app -from tests.conftest import ENCRYPTED_PASSWORD +from tests.conftest import ENCRYPTED_PASSWORD, RAR_CLI, _usable_rar_cli # --------------------------------------------------------------------------- # is_archive_path @@ -266,8 +266,18 @@ def test_7z_dotfile_skipped(tmp_path: Path) -> None: # RAR tests (skipped if the `rar` CLI is not installed) # --------------------------------------------------------------------------- -rar_cli = shutil.which("rar") -skip_no_rar = pytest.mark.skipif(rar_cli is None, reason="rar CLI not installed") +rar_cli = RAR_CLI +skip_no_rar = pytest.mark.skipif(rar_cli is None, reason="rar CLI not installed or not runnable") + + +def test_rar_probe_skips_hanging_cli(monkeypatch: pytest.MonkeyPatch) -> None: + def timeout(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: + raise subprocess.TimeoutExpired("rar", 3) + + monkeypatch.setattr("tests.conftest.shutil.which", lambda _name: "/fake/rar") + monkeypatch.setattr("tests.conftest.subprocess.run", timeout) + + assert _usable_rar_cli() is None def _make_rar(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: @@ -281,7 +291,11 @@ def _make_rar(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: dest.write_bytes(data) archive = tmp_path / "test.rar" subprocess.run( - [rar_cli, "a", "-r", str(archive), "."], cwd=src, check=True, capture_output=True + [rar_cli, "a", "-r", str(archive), "."], + cwd=src, + check=True, + capture_output=True, + timeout=15, ) return archive From 3caf3232cfc6e3becb695f0bf9cde3107286a411 Mon Sep 17 00:00:00 2001 From: Dinu Gherman Date: Tue, 20 Oct 2026 13:18:17 +0200 Subject: [PATCH 2/7] test: avoid launching blocked RAR on macOS --- tests/conftest.py | 16 ++++++++++++---- tests/test_archives.py | 14 +++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a34613d..a4ddc2b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,10 @@ from __future__ import annotations import io +import os import shutil import subprocess +import sys import tarfile import tempfile import zipfile @@ -25,12 +27,18 @@ def _usable_rar_cli() -> str | None: - """Return RAR CLI only if it can create a tiny archive within a timeout. + """Return a usable RAR CLI, without invoking it on macOS by default. - Merely finding an executable on PATH is not sufficient: macOS Gatekeeper - can block it, and some unusable binaries hang without output. Treat those - CLIs as unavailable so RAR fixtures remain optional. + Starting a Gatekeeper-blocked binary can open a system dialog even when + launched with a timeout. On macOS, require an explicit opt-in before any + RAR subprocess is run. Set DIRPLOT_TEST_RAR=1 to opt in. Elsewhere, a + bounded archive creation probe checks that the executable works. """ + if sys.platform == "darwin": + if os.environ.get("DIRPLOT_TEST_RAR") != "1": + return None + return shutil.which("rar") + rar_cli = shutil.which("rar") if rar_cli is None: return None diff --git a/tests/test_archives.py b/tests/test_archives.py index b3aee6b..1950154 100644 --- a/tests/test_archives.py +++ b/tests/test_archives.py @@ -270,13 +270,13 @@ def test_7z_dotfile_skipped(tmp_path: Path) -> None: skip_no_rar = pytest.mark.skipif(rar_cli is None, reason="rar CLI not installed or not runnable") -def test_rar_probe_skips_hanging_cli(monkeypatch: pytest.MonkeyPatch) -> None: - def timeout(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: - raise subprocess.TimeoutExpired("rar", 3) - - monkeypatch.setattr("tests.conftest.shutil.which", lambda _name: "/fake/rar") - monkeypatch.setattr("tests.conftest.subprocess.run", timeout) - +def test_macos_rar_is_not_invoked_without_opt_in(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("tests.conftest.sys.platform", "darwin") + monkeypatch.delenv("DIRPLOT_TEST_RAR", raising=False) + monkeypatch.setattr( + "tests.conftest.shutil.which", + lambda _name: pytest.fail("RAR lookup should be skipped on macOS"), + ) assert _usable_rar_cli() is None From d24d5e2a6f6819af22714f5bb0a9153a147c3bb5 Mon Sep 17 00:00:00 2001 From: Dinu Gherman Date: Tue, 20 Oct 2026 13:22:02 +0200 Subject: [PATCH 3/7] test: assess RAR with Gatekeeper before use --- tests/conftest.py | 26 ++++++++++++++++++++------ tests/test_archives.py | 27 ++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a4ddc2b..becbb6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,17 +27,31 @@ def _usable_rar_cli() -> str | None: - """Return a usable RAR CLI, without invoking it on macOS by default. + """Return RAR CLI only when available and permitted by platform policy. Starting a Gatekeeper-blocked binary can open a system dialog even when - launched with a timeout. On macOS, require an explicit opt-in before any - RAR subprocess is run. Set DIRPLOT_TEST_RAR=1 to opt in. Elsewhere, a - bounded archive creation probe checks that the executable works. + launched with a timeout. On macOS, use ``spctl`` to assess the executable + without launching it. Set DIRPLOT_TEST_RAR=1 to override a rejected or + unavailable assessment. Elsewhere, a bounded archive creation probe checks + that the executable works. """ if sys.platform == "darwin": - if os.environ.get("DIRPLOT_TEST_RAR") != "1": + rar_cli = shutil.which("rar") + if rar_cli is None: return None - return shutil.which("rar") + if os.environ.get("DIRPLOT_TEST_RAR") == "1": + return rar_cli + spctl = shutil.which("spctl") or "/usr/sbin/spctl" + try: + assessment = subprocess.run( + [spctl, "--assess", "--type", "execute", rar_cli], + capture_output=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + return rar_cli if assessment.returncode == 0 else None rar_cli = shutil.which("rar") if rar_cli is None: diff --git a/tests/test_archives.py b/tests/test_archives.py index 1950154..4750ca6 100644 --- a/tests/test_archives.py +++ b/tests/test_archives.py @@ -270,16 +270,33 @@ def test_7z_dotfile_skipped(tmp_path: Path) -> None: skip_no_rar = pytest.mark.skipif(rar_cli is None, reason="rar CLI not installed or not runnable") -def test_macos_rar_is_not_invoked_without_opt_in(monkeypatch: pytest.MonkeyPatch) -> None: +def test_macos_rar_gatekeeper_rejection_skips_cli(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("tests.conftest.sys.platform", "darwin") monkeypatch.delenv("DIRPLOT_TEST_RAR", raising=False) - monkeypatch.setattr( - "tests.conftest.shutil.which", - lambda _name: pytest.fail("RAR lookup should be skipped on macOS"), - ) + monkeypatch.setattr("tests.conftest.shutil.which", lambda name: f"/fake/{name}") + + def reject(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: + assert args[0] == ["/fake/spctl", "--assess", "--type", "execute", "/fake/rar"] + return subprocess.CompletedProcess(args[0], 3, stdout=b"rejected", stderr=b"") + + monkeypatch.setattr("tests.conftest.subprocess.run", reject) + assert _usable_rar_cli() is None +def test_macos_rar_gatekeeper_acceptance_enables_cli(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("tests.conftest.sys.platform", "darwin") + monkeypatch.delenv("DIRPLOT_TEST_RAR", raising=False) + monkeypatch.setattr("tests.conftest.shutil.which", lambda name: f"/fake/{name}") + + def accept(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: + return subprocess.CompletedProcess(args[0], 0, stdout=b"accepted", stderr=b"") + + monkeypatch.setattr("tests.conftest.subprocess.run", accept) + + assert _usable_rar_cli() == "/fake/rar" + + def _make_rar(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: """Write a rar to tmp_path/test.rar using the rar CLI and return the path.""" assert rar_cli is not None From df1ebd0b33263de4636576a49195f256c751c6c3 Mon Sep 17 00:00:00 2001 From: Dinu Gherman Date: Tue, 20 Oct 2026 13:29:56 +0200 Subject: [PATCH 4/7] ci: run archive tests with RAR installed --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6232630..8096b1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,32 @@ on: branches: [main] jobs: + test-rar: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Install RAR CLI + run: | + sudo apt-get update + sudo apt-get install --yes rar + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python 3.12 + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Verify RAR CLI + run: rar --version + + - name: Test archive support with RAR installed + run: uv run pytest tests/test_archives.py -q + test: runs-on: ubuntu-latest strategy: From 1c53b611bfb79c7d8b2014260b1d341fe0b3c6a1 Mon Sep 17 00:00:00 2001 From: Dinu Gherman Date: Tue, 20 Oct 2026 13:32:16 +0200 Subject: [PATCH 5/7] ci: remove unsupported RAR version flag --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8096b1b..2a83f8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,6 @@ jobs: - name: Install dependencies run: uv sync --all-extras - - name: Verify RAR CLI - run: rar --version - - name: Test archive support with RAR installed run: uv run pytest tests/test_archives.py -q From 53288b85b272667ef0175406c5f201394406e4a7 Mon Sep 17 00:00:00 2001 From: Dinu Gherman Date: Tue, 20 Oct 2026 13:34:41 +0200 Subject: [PATCH 6/7] ci: install unrar for archive tests --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a83f8f..1f02bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Install RAR CLI run: | sudo apt-get update - sudo apt-get install --yes rar + sudo apt-get install --yes rar unrar - name: Install uv uses: astral-sh/setup-uv@v7 From 779115d2788cda2fca2456a46c4590f226a0e0d9 Mon Sep 17 00:00:00 2001 From: Dinu Gherman Date: Tue, 20 Oct 2026 13:40:37 +0200 Subject: [PATCH 7/7] docs: note RAR archive CI coverage --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 831a8ae..c8d16b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Testing + +- **RAR archive coverage in CI** — a dedicated Ubuntu job installs `rar` and `unrar` and runs + the archive tests with RAR enabled. On macOS, tests assess `rar` with Gatekeeper before using it + and skip RAR coverage if macOS rejects it, avoiding blocked-executable popups. + ### Added - **Manual reload and auto-reload controls in `serve`** — the web UI gained a toolbar **Reload**