Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ 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 unrar

- 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: Test archive support with RAR installed
run: uv run pytest tests/test_archives.py -q

test:
runs-on: ubuntu-latest
strategy:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
95 changes: 80 additions & 15 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from __future__ import annotations

import io
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import zipfile
Expand All @@ -24,6 +26,58 @@
]


def _usable_rar_cli() -> str | None:
"""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, 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":
rar_cli = shutil.which("rar")
if rar_cli is None:
return None
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:
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:
Expand Down Expand Up @@ -103,19 +157,26 @@ 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:
f = tmp_path / name
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")
Expand Down Expand Up @@ -212,22 +273,26 @@ 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:
f = tmp_path / name
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

Expand Down
39 changes: 35 additions & 4 deletions tests/test_archives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -266,8 +266,35 @@ 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_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: 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:
Expand All @@ -281,7 +308,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

Expand Down
Loading