fix(docker): fix E2E torrent test and transmission-remote compatibility

- Add transmission-cli package (provides transmission-remote binary)
- Fix SETUPTOOLS_SCM_PRETEND_VERSION placement before RUN make install
- Fix PATH to use /root/.local/bin and include venv bin
- Switch TransmissionSession.add() from watch-dir polling to transmission-remote -a
- Remove --no-auth flag (unrecognized in transmission-remote 4.1.0)
- Add test_docker_torrent_downloads_wordlists E2E test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-04-25 22:56:13 -04:00
parent 144941d0ed
commit 3aa7138c9c
4 changed files with 92 additions and 40 deletions
+4 -1
View File
@@ -13,6 +13,7 @@ RUN apt-get update \
ocl-icd-libopencl1 \
pocl-opencl-icd \
p7zip-full \
transmission-cli \
transmission-daemon \
&& rm -rf /var/lib/apt/lists/*
@@ -20,9 +21,11 @@ RUN python -m pip install -q uv==0.9.28
COPY . /workspace
ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0
RUN make install
ENV PATH="${HOME}/.local/bin:${PATH}"
ENV PATH="/workspace/.venv/bin:/root/.local/bin:${PATH}"
ENV HATE_CRACK_SKIP_INIT=1
CMD ["bash", "-lc", "${HOME}/.local/bin/hate_crack --help >/tmp/hc_help.txt && ./hate_crack.py --help >/tmp/hc_script_help.txt"]
+26 -16
View File
@@ -259,7 +259,6 @@ class TransmissionSession:
self.startup_timeout = startup_timeout
self.shutdown_timeout = shutdown_timeout
self._cfg_dir = ""
self._watch_dir = ""
self._port = 0
self._rpc = ""
self._proc = None
@@ -270,8 +269,6 @@ class TransmissionSession:
import subprocess
self._cfg_dir = tempfile.mkdtemp(prefix="hate_crack_transmission_")
self._watch_dir = os.path.join(self._cfg_dir, "watch")
os.makedirs(self._watch_dir, exist_ok=True)
self._port = _pick_free_port()
self._rpc = f"127.0.0.1:{self._port}"
self._proc = subprocess.Popen(
@@ -288,8 +285,7 @@ class TransmissionSession:
"--download-dir",
self.save_dir,
"--no-portmap",
"--watch-dir",
self._watch_dir,
"--no-watch-dir",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
@@ -345,22 +341,38 @@ class TransmissionSession:
return None
def add(self, torrent_path: str) -> int:
import re
import subprocess
before_ids = {e["id"] for e in self.list()}
shutil.copy2(torrent_path, self._watch_dir)
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
after_entries = self.list()
new_ids = [e["id"] for e in after_entries if e["id"] not in before_ids]
if new_ids:
return new_ids[0]
time.sleep(0.5)
result = subprocess.run(
[
"transmission-remote",
self._rpc,
"-a",
torrent_path,
],
capture_output=True,
text=True,
)
out = result.stdout or ""
m = re.search(r"Added torrent.*\n.*ID:\s*(\d+)", out)
if m:
return int(m.group(1))
m = re.search(r"torrent added\s*\(id\s+(\d+)\)", out, re.IGNORECASE)
if m:
return int(m.group(1))
after_entries = self.list()
new_ids = [e["id"] for e in after_entries if e["id"] not in before_ids]
if new_ids:
return new_ids[0]
raise RuntimeError(f"Failed to add torrent: {torrent_path}")
def list(self) -> list:
import subprocess
result = subprocess.run(
["transmission-remote", self._rpc, "--no-auth", "-l"],
["transmission-remote", self._rpc, "-l"],
capture_output=True,
text=True,
)
@@ -417,7 +429,6 @@ class TransmissionSession:
[
"transmission-remote",
self._rpc,
"--no-auth",
f"-t{torrent_id}",
"--info-files",
],
@@ -461,7 +472,6 @@ class TransmissionSession:
[
"transmission-remote",
self._rpc,
"--no-auth",
f"-t{torrent_id}",
"--remove",
],
+20 -23
View File
@@ -140,44 +140,41 @@ class TestTransmissionSession:
with pytest.raises(RuntimeError, match="Transmission daemon failed"):
ts.__enter__()
def test_add_copies_to_watch_dir_and_returns_new_id(self, tmp_path):
def test_add_uses_transmission_remote_and_returns_new_id(self, tmp_path):
ts = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999"
ts._watch_dir = str(tmp_path / "watch")
# Before: IDs 3 and 5. After first poll: ID 7 appears.
# Before: IDs 3 and 5. After add: ID 7 appears.
list_calls = iter([
[{"id": 3}, {"id": 5}],
[{"id": 3}, {"id": 5}, {"id": 7}],
])
with patch("shutil.copy2"), patch.object(ts, "list", side_effect=list_calls), \
patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 1.0]):
run_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=run_result), \
patch.object(ts, "list", side_effect=list_calls):
tid = ts.add("/tmp/foo.torrent")
assert tid == 7
def test_add_polls_until_daemon_picks_up_torrent(self, tmp_path):
def test_add_parses_id_from_output(self, tmp_path):
ts = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999"
ts._watch_dir = str(tmp_path / "watch")
# First two polls: no new ID. Third poll: ID 9 appears.
list_calls = iter([
[{"id": 1}],
[{"id": 1}],
[{"id": 1}],
[{"id": 1}, {"id": 9}],
])
monotonic_vals = iter([0.0, 1.0, 2.0, 3.0, 4.0])
with patch("shutil.copy2"), patch.object(ts, "list", side_effect=list_calls), \
patch("time.sleep"), patch("time.monotonic", side_effect=monotonic_vals):
before_list = [{"id": 1}]
run_result = MagicMock(
returncode=0,
stdout="torrent added (id 42)\n",
stderr="",
)
with patch("subprocess.run", return_value=run_result), \
patch.object(ts, "list", return_value=before_list):
tid = ts.add("/tmp/foo.torrent")
assert tid == 9
assert tid == 42
def test_add_raises_on_timeout(self, tmp_path):
def test_add_raises_when_torrent_not_added(self, tmp_path):
ts = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999"
ts._watch_dir = str(tmp_path / "watch")
# list never returns a new ID; monotonic jumps past the 10s deadline.
with patch("shutil.copy2"), patch.object(ts, "list", return_value=[{"id": 1}]), \
patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 100.0]):
# list returns the same IDs before and after; output has no ID.
run_result = MagicMock(returncode=1, stdout="", stderr="error")
with patch("subprocess.run", return_value=run_result), \
patch.object(ts, "list", return_value=[{"id": 1}]):
with pytest.raises(RuntimeError):
ts.add("/tmp/foo.torrent")
+42
View File
@@ -97,3 +97,45 @@ def test_docker_hashcat_cracks_simple_password(docker_image):
assert run.returncode == 0, (
f"Docker hashcat crack failed. stdout={run.stdout} stderr={run.stderr}"
)
@pytest.mark.timeout(300)
def test_docker_torrent_downloads_wordlists(docker_image, tmp_path):
downloads_dir = tmp_path / "downloads"
downloads_dir.mkdir()
py_cmd = (
"from hate_crack.api import fetch_torrent_metadata, run_torrent_session; "
"t1 = fetch_torrent_metadata('ignis-10K.txt'); "
"t2 = fetch_torrent_metadata('hashmob.net_2025.micro.found'); "
"files = [f for f in (t1, t2) if f]; "
"run_torrent_session(files, '/downloads')"
)
try:
run = subprocess.run(
[
"docker", "run", "--rm",
"-v", f"{downloads_dir}:/downloads",
docker_image,
"bash", "-lc", f"/workspace/.venv/bin/python -c \"{py_cmd}\"",
],
capture_output=True,
text=True,
timeout=300,
)
except subprocess.TimeoutExpired as exc:
pytest.fail(f"Docker torrent test timed out after {exc.timeout}s")
assert run.returncode == 0, (
f"Torrent session failed. stdout={run.stdout} stderr={run.stderr}"
)
ignis10k = downloads_dir / "ignis-10K.txt"
micro = downloads_dir / "hashmob.net_2025.micro.found"
assert ignis10k.exists() and ignis10k.stat().st_size > 0, (
f"ignis-10K.txt missing/empty. stdout={run.stdout} stderr={run.stderr}"
)
assert micro.exists() and micro.stat().st_size > 0, (
f"hashmob.net_2025.micro.found missing/empty. stdout={run.stdout} stderr={run.stderr}"
)