Skip to content

Commit e291c13

Browse files
authored
pwnshop: per-test timeout (#84)
1 parent 864920d commit e291c13

4 files changed

Lines changed: 25 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ This is the pwn.college challenge monorepo containing cybersecurity CTF challeng
1010

1111
### Challenge CLI
1212

13-
All workflows run through the `./pwnshop` CLI (implemented with Click/Rich in `tools/pwnshop/src/pwnshop/commands` and backed by shared helpers in `tools/pwnshop/src/pwnshop/lib`). The older `./build` script has been retired; never call it or duplicate its behavior.
13+
All workflows run through the `./pwnshop` CLI (implemented in `tools/pwnshop/src/pwnshop/commands` and backed by shared helpers in `tools/pwnshop/src/pwnshop/lib`).
1414

15-
Each subcommand accepts either a direct path or a challenge slug (e.g., `web-security/path-traversal-1`). Slugs must contain the module and challenge, and the CLI searches `./challenges` for that exact path.
15+
Each subcommand accepts either a direct path or a challenge slug (e.g., `challenge/web-security/path-traversal-1`). Slugs must contain the base path to all challenges ("challenge"), then the module and challenge.
1616

1717
Primary commands:
1818

tools/pwnshop/src/pwnshop/commands/run.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
logger = logging.getLogger(__name__)
1010

1111

12-
@click.command("run")
12+
@click.command("run", context_settings=dict(allow_interspersed_args=False))
1313
@click.argument(
1414
"challenge_path",
1515
type=click.Path(path_type=pathlib.Path, exists=True, dir_okay=True, file_okay=False, resolve_path=True),

tools/pwnshop/src/pwnshop/commands/test.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
@click.option("--modified-since", metavar="REF", help="Only include challenges changed versus REF.")
2525
@click.option("--jobs", "-j", metavar="N", type=click.IntRange(1, None), help="Parallel challenges (default: cores).")
2626
@click.option("--require-tests", is_flag=True, help="Fail if any challenge has no tests.")
27+
@click.option(
28+
"--test-timeout",
29+
metavar="N",
30+
type=click.IntRange(1, None),
31+
default=None,
32+
help="Timeout in seconds for each individual test.",
33+
)
2734
@click.argument(
2835
"targets",
2936
nargs=-1,
@@ -36,7 +43,7 @@
3643
resolve_path=False,
3744
),
3845
)
39-
def test_command(targets, modified_since, jobs, require_tests):
46+
def test_command(targets, modified_since, jobs, require_tests, test_timeout):
4047
"""Test one or more challenges."""
4148
if not (challenge_paths := lib.resolve_targets(targets, modified_since=modified_since)):
4249
if modified_since:
@@ -63,17 +70,23 @@ def test_challenge(challenge_path):
6370
test_name = test.relative_to(rendered)
6471
logger.debug("running test %s in %s", test_name, challenge_path)
6572
with lib.run_challenge(image_id, volumes=[test]) as (container, _):
66-
run = subprocess.run(
67-
["docker", "exec", "--user=1000:1000", container, f"{test}"],
68-
stdout=subprocess.PIPE,
69-
stderr=subprocess.STDOUT,
70-
text=True,
71-
)
73+
try:
74+
run = subprocess.run(
75+
["docker", "exec", "--user=1000:1000", container, f"{test}"],
76+
stdout=subprocess.PIPE,
77+
stderr=subprocess.STDOUT,
78+
text=True,
79+
timeout=test_timeout,
80+
)
81+
except subprocess.TimeoutExpired as e:
82+
logger.warning("test %s timed out after %ds in %s", test_name, test_timeout, challenge_path)
83+
results.append((test_name, False, f"TIMEOUT after {test_timeout}s\n{e.stdout or ''}"))
84+
continue
7285
passed = run.returncode == 0
7386
logger.debug("test %s %s (rc=%d)", test_name, "PASSED" if passed else "FAILED", run.returncode)
7487
results.append((test_name, passed, run.stdout or ""))
7588
return {"path": challenge_path, "tests": results}
76-
except (FileNotFoundError, RuntimeError) as error:
89+
except (FileNotFoundError, RuntimeError, subprocess.CalledProcessError) as error:
7790
logger.error("test setup failed for %s: %s", challenge_path, error)
7891
return {"path": challenge_path, "error": str(error)}
7992
finally:

tools/pwnshop/src/pwnshop/lib/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ def run_challenge(
208208
subprocess.run(
209209
["docker", "kill", container],
210210
stdout=subprocess.DEVNULL,
211-
check=True,
211+
stderr=subprocess.DEVNULL,
212212
)
213213

214214

0 commit comments

Comments
 (0)