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
co-authored by Claude Sonnet 4.6
parent c037242037
commit 90257cfb1b
4 changed files with 92 additions and 40 deletions
+4 -1
View File
@@ -13,6 +13,7 @@ RUN apt-get update \
ocl-icd-libopencl1 \ ocl-icd-libopencl1 \
pocl-opencl-icd \ pocl-opencl-icd \
p7zip-full \ p7zip-full \
transmission-cli \
transmission-daemon \ transmission-daemon \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -20,9 +21,11 @@ RUN python -m pip install -q uv==0.9.28
COPY . /workspace COPY . /workspace
ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0
RUN make install RUN make install
ENV PATH="${HOME}/.local/bin:${PATH}" ENV PATH="/workspace/.venv/bin:/root/.local/bin:${PATH}"
ENV HATE_CRACK_SKIP_INIT=1 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"] 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.startup_timeout = startup_timeout
self.shutdown_timeout = shutdown_timeout self.shutdown_timeout = shutdown_timeout
self._cfg_dir = "" self._cfg_dir = ""
self._watch_dir = ""
self._port = 0 self._port = 0
self._rpc = "" self._rpc = ""
self._proc = None self._proc = None
@@ -270,8 +269,6 @@ class TransmissionSession:
import subprocess import subprocess
self._cfg_dir = tempfile.mkdtemp(prefix="hate_crack_transmission_") 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._port = _pick_free_port()
self._rpc = f"127.0.0.1:{self._port}" self._rpc = f"127.0.0.1:{self._port}"
self._proc = subprocess.Popen( self._proc = subprocess.Popen(
@@ -288,8 +285,7 @@ class TransmissionSession:
"--download-dir", "--download-dir",
self.save_dir, self.save_dir,
"--no-portmap", "--no-portmap",
"--watch-dir", "--no-watch-dir",
self._watch_dir,
], ],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@@ -345,22 +341,38 @@ class TransmissionSession:
return None return None
def add(self, torrent_path: str) -> int: def add(self, torrent_path: str) -> int:
import re
import subprocess
before_ids = {e["id"] for e in self.list()} before_ids = {e["id"] for e in self.list()}
shutil.copy2(torrent_path, self._watch_dir) result = subprocess.run(
deadline = time.monotonic() + 30.0 [
while time.monotonic() < deadline: "transmission-remote",
after_entries = self.list() self._rpc,
new_ids = [e["id"] for e in after_entries if e["id"] not in before_ids] "-a",
if new_ids: torrent_path,
return new_ids[0] ],
time.sleep(0.5) 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}") raise RuntimeError(f"Failed to add torrent: {torrent_path}")
def list(self) -> list: def list(self) -> list:
import subprocess import subprocess
result = subprocess.run( result = subprocess.run(
["transmission-remote", self._rpc, "--no-auth", "-l"], ["transmission-remote", self._rpc, "-l"],
capture_output=True, capture_output=True,
text=True, text=True,
) )
@@ -417,7 +429,6 @@ class TransmissionSession:
[ [
"transmission-remote", "transmission-remote",
self._rpc, self._rpc,
"--no-auth",
f"-t{torrent_id}", f"-t{torrent_id}",
"--info-files", "--info-files",
], ],
@@ -461,7 +472,6 @@ class TransmissionSession:
[ [
"transmission-remote", "transmission-remote",
self._rpc, self._rpc,
"--no-auth",
f"-t{torrent_id}", f"-t{torrent_id}",
"--remove", "--remove",
], ],
+20 -23
View File
@@ -140,44 +140,41 @@ class TestTransmissionSession:
with pytest.raises(RuntimeError, match="Transmission daemon failed"): with pytest.raises(RuntimeError, match="Transmission daemon failed"):
ts.__enter__() 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 = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999" ts._rpc = "127.0.0.1:9999"
ts._watch_dir = str(tmp_path / "watch") # Before: IDs 3 and 5. After add: ID 7 appears.
# Before: IDs 3 and 5. After first poll: ID 7 appears.
list_calls = iter([ list_calls = iter([
[{"id": 3}, {"id": 5}], [{"id": 3}, {"id": 5}],
[{"id": 3}, {"id": 5}, {"id": 7}], [{"id": 3}, {"id": 5}, {"id": 7}],
]) ])
with patch("shutil.copy2"), patch.object(ts, "list", side_effect=list_calls), \ run_result = MagicMock(returncode=0, stdout="", stderr="")
patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 1.0]): with patch("subprocess.run", return_value=run_result), \
patch.object(ts, "list", side_effect=list_calls):
tid = ts.add("/tmp/foo.torrent") tid = ts.add("/tmp/foo.torrent")
assert tid == 7 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 = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999" ts._rpc = "127.0.0.1:9999"
ts._watch_dir = str(tmp_path / "watch") before_list = [{"id": 1}]
# First two polls: no new ID. Third poll: ID 9 appears. run_result = MagicMock(
list_calls = iter([ returncode=0,
[{"id": 1}], stdout="torrent added (id 42)\n",
[{"id": 1}], stderr="",
[{"id": 1}], )
[{"id": 1}, {"id": 9}], with patch("subprocess.run", return_value=run_result), \
]) patch.object(ts, "list", return_value=before_list):
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):
tid = ts.add("/tmp/foo.torrent") 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 = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999" ts._rpc = "127.0.0.1:9999"
ts._watch_dir = str(tmp_path / "watch") # list returns the same IDs before and after; output has no ID.
# list never returns a new ID; monotonic jumps past the 10s deadline. run_result = MagicMock(returncode=1, stdout="", stderr="error")
with patch("shutil.copy2"), patch.object(ts, "list", return_value=[{"id": 1}]), \ with patch("subprocess.run", return_value=run_result), \
patch("time.sleep"), patch("time.monotonic", side_effect=[0.0, 100.0]): patch.object(ts, "list", return_value=[{"id": 1}]):
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
ts.add("/tmp/foo.torrent") 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, ( assert run.returncode == 0, (
f"Docker hashcat crack failed. stdout={run.stdout} stderr={run.stderr}" 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}"
)