Skip to content
Closed
14 changes: 14 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ jobs:
- name: Run tests
run: python -m unittest discover -s tests -v

# Same suite again with env vars exported that would flip outcomes if any
# test leaked the developer's shell environment into production code.
- name: Run tests with a hostile environment
run: python -m unittest discover -s tests
env:
SKIP_RATIO_CHECK: "true"
CLEANUP_MIN_RATIO: "5"
CLEANUP_SKIP_RATIO_CHECK: "true"
TRANSMISSION_RPC_USERNAME: x
TRANSMISSION_RPC_PASSWORD: y
FETCH_TORRENTS_DISTROS: arch
FETCH_TORRENTS_LOG_MAX_BYTES: "7"
FETCH_TORRENTS_ALWAYS_LOG: "false"

build:
needs: test
runs-on: ubuntu-latest
Expand Down
51 changes: 51 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,57 @@ When making changes to the fetch logic or features:
- Verify regex patterns match all intended torrent names (e.g., Ubuntu variants)
- Ratio checking is per ISO type (e.g., `installer-amd64`) rather than per distro

## Mutation testing

Mutation testing checks that the tests actually pin behavior, not just
execute lines. It uses mutmut 2.x (`mutmut<3`):

```
mutmut run --paths-to-mutate fetch_torrents.py,torrent_scrape.py,configure_transmission.py \
--tests-dir tests/ --runner "python -m unittest discover -s tests -f -q"
mutmut results # list surviving mutants
mutmut show <id> # show one mutant's diff
```

mutmut edits the source files in place while it runs, so run it on a clean
working tree (or a copy of the repo). A full run takes a while (~40 minutes on
Windows).

### Handling surviving mutants

A survivor is either a missing test or a mutant that cannot change behavior
(an *equivalent* mutant). Deal with them in this order:

1. **Write the missing test.** Most survivors are real gaps: an untested
boundary (`>=` vs `>`), a `continue` that could be a `break`, a branch whose
result is never asserted. Confirm the new test fails with the mutant applied
(`mutmut apply <id>`, run the test, then `git checkout` the file).
2. **If no test can kill it, remove the cause.** An equivalent mutant usually
points at redundant code: a variable initialised but never read, or a
condition that an earlier step already guarantees. Simplify the code so the
mutant can no longer be generated. Pin the current behavior with a test
before refactoring.
3. **If the code is already as simple as it should be, exclude the line** with
`# pragma: no mutate` and say why on the same line, so the exclusion can be
reviewed:

```python
if os.getenv("SKIP_RATIO_CHECK", "false").lower() == "true": # pragma: no mutate (any default but "true" is equivalent)
```

Don't use the pragma to hide a gap you could test.

**Log and error-message strings.** mutmut changes string literals
(`"Could not…"` → `"XXCould not…XX"`), and most of these survive because tests
don't assert exact log text. They're expected and are not worth a pragma on
every log line. Assert the message only where its content matters, for example
an error that names the bad input.

**In CI**, don't gate on zero survivors. Use a score threshold for the critical
modules, a committed baseline of known survivors (fail only on new ones), or
mutate only the lines changed in the PR. Run it on a schedule rather than on
every push, given how long it takes.

## Cutting a release

Releases and container publishing are automated from Git tags:
Expand Down
56 changes: 32 additions & 24 deletions configure_transmission.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
build_port_forwarding_overrides()'s first-run default - see its docstring -
which only ever applies before there's anything on disk to disturb.
"""

import json
import os
import sys
Expand All @@ -17,25 +18,25 @@
def build_rpc_overrides(env):
overrides = {}

whitelist = env.get('TRANSMISSION_RPC_WHITELIST', '').strip()
whitelist = env.get("TRANSMISSION_RPC_WHITELIST", "").strip()
if whitelist:
overrides['rpc-whitelist-enabled'] = True
overrides['rpc-whitelist'] = whitelist
overrides["rpc-whitelist-enabled"] = True
overrides["rpc-whitelist"] = whitelist

username = env.get('TRANSMISSION_RPC_USERNAME', '').strip()
password = env.get('TRANSMISSION_RPC_PASSWORD', '').strip()
username = env.get("TRANSMISSION_RPC_USERNAME", "").strip()
password = env.get("TRANSMISSION_RPC_PASSWORD", "").strip()
if username and password:
overrides['rpc-authentication-required'] = True
overrides['rpc-username'] = username
overrides["rpc-authentication-required"] = True
overrides["rpc-username"] = username
# Plaintext is intentional: transmission-daemon hashes rpc-password
# in place on its next start, same as if set by hand.
overrides['rpc-password'] = password
overrides["rpc-password"] = password

return overrides


def _parse_positive_int(value):
value = (value or '').strip()
value = (value or "").strip()
if not value:
return None
try:
Expand All @@ -54,21 +55,23 @@ def build_peer_limit_overrides(env):
honor (or that blocks all peers, in the case of 0)."""
overrides = {}

global_limit = _parse_positive_int(env.get('TRANSMISSION_PEER_LIMIT_GLOBAL', ''))
global_limit = _parse_positive_int(env.get("TRANSMISSION_PEER_LIMIT_GLOBAL", ""))
if global_limit is not None:
overrides['peer-limit-global'] = global_limit
overrides["peer-limit-global"] = global_limit

per_torrent_limit = _parse_positive_int(env.get('TRANSMISSION_PEER_LIMIT_PER_TORRENT', ''))
per_torrent_limit = _parse_positive_int(
env.get("TRANSMISSION_PEER_LIMIT_PER_TORRENT", "")
)
if per_torrent_limit is not None:
overrides['peer-limit-per-torrent'] = per_torrent_limit
overrides["peer-limit-per-torrent"] = per_torrent_limit

return overrides


def _parse_bool(value):
value = (value or '').strip().lower()
if value in ('true', 'false'):
return value == 'true'
value = (value or "").strip().lower()
if value in ("true", "false"):
return value == "true"
return None


Expand All @@ -82,11 +85,11 @@ def build_port_forwarding_overrides(env, settings_file_exists):
Transmission itself would otherwise pick its own default. Once the file
exists, this is untouched unless TRANSMISSION_PORT_FORWARDING says
otherwise, same as every other setting here."""
explicit = _parse_bool(env.get('TRANSMISSION_PORT_FORWARDING', ''))
explicit = _parse_bool(env.get("TRANSMISSION_PORT_FORWARDING", ""))
if explicit is not None:
return {'port-forwarding-enabled': explicit}
return {"port-forwarding-enabled": explicit}
if not settings_file_exists:
return {'port-forwarding-enabled': False}
return {"port-forwarding-enabled": False}
return {}


Expand All @@ -95,7 +98,9 @@ def warn_if_open_without_auth(overrides):
anyone who can reach the RPC port has full control (add/remove/delete
data) - Transmission has no read-only RPC mode. Returns a warning
message to log, or None if there's nothing to warn about."""
if overrides.get('rpc-whitelist-enabled') and not overrides.get('rpc-authentication-required'):
if overrides.get("rpc-whitelist-enabled") and not overrides.get(
"rpc-authentication-required"
):
return (
"TRANSMISSION_RPC_WHITELIST is set but TRANSMISSION_RPC_USERNAME/"
"TRANSMISSION_RPC_PASSWORD are not - the web UI/RPC endpoint is "
Expand All @@ -114,22 +119,25 @@ def load_settings(path):
if not os.path.exists(path):
return {}
try:
with open(path, 'r', encoding='utf-8') as f:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as exc:
print(f"configure_transmission: could not read {path} ({exc}) - starting fresh.", file=sys.stderr)
print(
f"configure_transmission: could not read {path} ({exc}) - starting fresh.",
file=sys.stderr,
)
return {}


def save_settings(path, settings):
tmp_path = f"{path}.tmp"
with open(tmp_path, 'w', encoding='utf-8') as f:
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=4, sort_keys=True)
os.replace(tmp_path, path)


def main():
settings_path = os.getenv('TRANSMISSION_SETTINGS_FILE', '/config/settings.json')
settings_path = os.getenv("TRANSMISSION_SETTINGS_FILE", "/config/settings.json")
settings_file_exists = os.path.exists(settings_path)
overrides = {
**build_rpc_overrides(os.environ),
Expand Down
Loading
Loading