Skip to content

Commit c6e6f70

Browse files
committed
Validate bounded CLI integer limits
1 parent bccd47a commit c6e6f70

7 files changed

Lines changed: 154 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
128128
### Fixed
129129
- Reject CR/LF, control, and whitespace characters in a `wait_for_http` host
130130
before HTTP serialization, preventing header injection.
131+
- Reject CLI resource and restart limit values above their binding widths with
132+
an argparse usage error before constructing a `ProcessGroup` or `Supervisor`.
131133
- CLI duration, CPU, backoff, and health-check numeric options now reject
132134
non-finite values before constructing a command or supervisor.
133135
- Keep Nightly hardening actionable: its mutation sandbox now includes the

docs/changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
131131
### Fixed
132132
- Reject CR/LF, control, and whitespace characters in a `wait_for_http` host
133133
before HTTP serialization, preventing header injection.
134+
- Reject CLI resource and restart limit values above their binding widths with
135+
an argparse usage error before constructing a `ProcessGroup` or `Supervisor`.
134136
- CLI duration, CPU, backoff, and health-check numeric options now reject
135137
non-finite values before constructing a command or supervisor.
136138
- Keep Nightly hardening actionable: its mutation sandbox now includes the

docs/cli.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ python -m processkit run --max-memory 536870912 --max-processes 64 -- ./build.sh
6868
| `--timeout SECONDS` | `Command.timeout(seconds)` | Kills the whole tree once the deadline passes. |
6969
| `--timeout-grace SECONDS` | `Command.timeout_grace(seconds)` | Signal first, hard-kill after `SECONDS`. Requires `--timeout`; a usage error otherwise. |
7070
| `--idle-timeout SECONDS` | `Command.idle_timeout(seconds)` | Kill the child if it emits no output line for `SECONDS`. Exit `123` (distinct from `--timeout`'s `124`). Pipes and re-emits stdout/stderr line-by-line; incompatible with `--profile` and `--stdout-file`. See [below](#--idle-timeout-a-silence-watchdog). |
71-
| `--max-memory BYTES` | `ProcessGroup(max_memory=...)` | Whole-tree memory cap. |
72-
| `--max-processes N` | `ProcessGroup(max_processes=...)` | Fork-bomb ceiling for the tree. |
71+
| `--max-memory BYTES` | `ProcessGroup(max_memory=...)` | Whole-tree memory cap; accepts `1..=2^64-1` bytes. |
72+
| `--max-processes N` | `ProcessGroup(max_processes=...)` | Fork-bomb ceiling for the tree; accepts `1..=2^32-1`. |
7373
| `--cpu-quota FLOAT` | `ProcessGroup(cpu_quota=...)` | Fraction of a **single** core (`0.5` = half, `2.0` = two cores). |
7474
| `--env-clear` | `Command.env_clear()` | Start the child with an empty environment. |
7575
| `--inherit-env NAME` | `Command.inherit_env([...])` | Allow-list a parent variable through (implies `--env-clear`). Repeatable. |
@@ -101,6 +101,12 @@ Files are UTF-8 (an optional BOM is accepted); values are literal and may contai
101101
additional `=` characters. Missing/unreadable files and non-comment lines
102102
without `=` are usage errors with the file and line number, never tracebacks.
103103

104+
The resource and restart limits are parsed against the widths of their binding
105+
types before a `ProcessGroup` or `Supervisor` is constructed: `--max-memory`
106+
accepts `1..=2^64-1` (`u64`), while `--max-processes` and `--max-restarts`
107+
accept `1..=2^32-1` (`u32`). Values above those bounds are argparse usage
108+
errors; the other positive-integer flags keep their existing contracts.
109+
104110
### `--sanitize-vt`: clean terminal output
105111

106112
Use `--sanitize-vt` for a color-sensitive tool whose output must become plain
@@ -290,14 +296,14 @@ just line-buffered rather than a byte-for-byte fd passthrough.
290296
| Flag | Description |
291297
|---|---|
292298
| `--restart {always,on_crash,never}` | Restart policy passed to `Supervisor`. |
293-
| `--max-restarts N` | Stop after `N` restarts. `N` must be positive. |
299+
| `--max-restarts N` | Stop after `N` restarts; accepts `1..=2^32-1` (`u32`). |
294300
| `--backoff-initial SECONDS` | Initial delay before a restart. Must be positive. |
295301
| `--backoff-factor FLOAT` | Multiplier for successive restart delays. Must be at least `1`. |
296302
| `--max-backoff SECONDS` | Upper bound for restart delay. Must be positive. |
297303
| `--no-jitter` | Disable restart-delay jitter; jitter is enabled by default. |
298304
| `--timeout SECONDS` | Apply `Command.timeout(seconds)` independently to every incarnation. A final timed-out incarnation exits `124`. |
299-
| `--max-memory BYTES` | Cap every incarnation's whole process tree memory. |
300-
| `--max-processes N` | Cap every incarnation's process-tree size. |
305+
| `--max-memory BYTES` | Cap every incarnation's whole process tree memory; accepts `1..=2^64-1` bytes (`u64`). |
306+
| `--max-processes N` | Cap every incarnation's process-tree size; accepts `1..=2^32-1` (`u32`). |
301307
| `--cpu-quota FLOAT` | Cap every incarnation's CPU as a fraction of one core. |
302308
| `--cpu-affinity CPU[,CPU...]` | Pin every incarnation to logical CPUs on Linux/Windows. |
303309
| `--create-no-window` | Apply `Command.create_no_window()` to every incarnation. |

docs/llms-full.txt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5238,8 +5238,8 @@ python -m processkit run --max-memory 536870912 --max-processes 64 -- ./build.sh
52385238
| `--timeout SECONDS` | `Command.timeout(seconds)` | Kills the whole tree once the deadline passes. |
52395239
| `--timeout-grace SECONDS` | `Command.timeout_grace(seconds)` | Signal first, hard-kill after `SECONDS`. Requires `--timeout`; a usage error otherwise. |
52405240
| `--idle-timeout SECONDS` | `Command.idle_timeout(seconds)` | Kill the child if it emits no output line for `SECONDS`. Exit `123` (distinct from `--timeout`'s `124`). Pipes and re-emits stdout/stderr line-by-line; incompatible with `--profile` and `--stdout-file`. See [below](#--idle-timeout-a-silence-watchdog). |
5241-
| `--max-memory BYTES` | `ProcessGroup(max_memory=...)` | Whole-tree memory cap. |
5242-
| `--max-processes N` | `ProcessGroup(max_processes=...)` | Fork-bomb ceiling for the tree. |
5241+
| `--max-memory BYTES` | `ProcessGroup(max_memory=...)` | Whole-tree memory cap; accepts `1..=2^64-1` bytes. |
5242+
| `--max-processes N` | `ProcessGroup(max_processes=...)` | Fork-bomb ceiling for the tree; accepts `1..=2^32-1`. |
52435243
| `--cpu-quota FLOAT` | `ProcessGroup(cpu_quota=...)` | Fraction of a **single** core (`0.5` = half, `2.0` = two cores). |
52445244
| `--env-clear` | `Command.env_clear()` | Start the child with an empty environment. |
52455245
| `--inherit-env NAME` | `Command.inherit_env([...])` | Allow-list a parent variable through (implies `--env-clear`). Repeatable. |
@@ -5271,6 +5271,12 @@ Files are UTF-8 (an optional BOM is accepted); values are literal and may contai
52715271
additional `=` characters. Missing/unreadable files and non-comment lines
52725272
without `=` are usage errors with the file and line number, never tracebacks.
52735273

5274+
The resource and restart limits are parsed against the widths of their binding
5275+
types before a `ProcessGroup` or `Supervisor` is constructed: `--max-memory`
5276+
accepts `1..=2^64-1` (`u64`), while `--max-processes` and `--max-restarts`
5277+
accept `1..=2^32-1` (`u32`). Values above those bounds are argparse usage
5278+
errors; the other positive-integer flags keep their existing contracts.
5279+
52745280
### `--sanitize-vt`: clean terminal output
52755281

52765282
Use `--sanitize-vt` for a color-sensitive tool whose output must become plain
@@ -5460,14 +5466,14 @@ just line-buffered rather than a byte-for-byte fd passthrough.
54605466
| Flag | Description |
54615467
|---|---|
54625468
| `--restart {always,on_crash,never}` | Restart policy passed to `Supervisor`. |
5463-
| `--max-restarts N` | Stop after `N` restarts. `N` must be positive. |
5469+
| `--max-restarts N` | Stop after `N` restarts; accepts `1..=2^32-1` (`u32`). |
54645470
| `--backoff-initial SECONDS` | Initial delay before a restart. Must be positive. |
54655471
| `--backoff-factor FLOAT` | Multiplier for successive restart delays. Must be at least `1`. |
54665472
| `--max-backoff SECONDS` | Upper bound for restart delay. Must be positive. |
54675473
| `--no-jitter` | Disable restart-delay jitter; jitter is enabled by default. |
54685474
| `--timeout SECONDS` | Apply `Command.timeout(seconds)` independently to every incarnation. A final timed-out incarnation exits `124`. |
5469-
| `--max-memory BYTES` | Cap every incarnation's whole process tree memory. |
5470-
| `--max-processes N` | Cap every incarnation's process-tree size. |
5475+
| `--max-memory BYTES` | Cap every incarnation's whole process tree memory; accepts `1..=2^64-1` bytes (`u64`). |
5476+
| `--max-processes N` | Cap every incarnation's process-tree size; accepts `1..=2^32-1` (`u32`). |
54715477
| `--cpu-quota FLOAT` | Cap every incarnation's CPU as a fraction of one core. |
54725478
| `--cpu-affinity CPU[,CPU...]` | Pin every incarnation to logical CPUs on Linux/Windows. |
54735479
| `--create-no-window` | Apply `Command.create_no_window()` to every incarnation. |
@@ -6347,6 +6353,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
63476353
### Fixed
63486354
- Reject CR/LF, control, and whitespace characters in a `wait_for_http` host
63496355
before HTTP serialization, preventing header injection.
6356+
- Reject CLI resource and restart limit values above their binding widths with
6357+
an argparse usage error before constructing a `ProcessGroup` or `Supervisor`.
63506358
- CLI duration, CPU, backoff, and health-check numeric options now reject
63516359
non-finite values before constructing a command or supervisor.
63526360
- Keep Nightly hardening actionable: its mutation sandbox now includes the

src/processkit/_cli/parser.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import argparse
88
import math
9-
from collections.abc import Sequence
9+
from collections.abc import Callable, Sequence
1010

1111
#: Sentinel `const=` for ``--profile``'s optional `FILE` argument (`nargs="?"`):
1212
#: distinguishes "flag absent" (`args.profile is None`, the default) from
@@ -19,6 +19,8 @@
1919
#: a caller wants "collect a profile" and "where to put it" to vary
2020
#: independently of each other.
2121
PROFILE_STDERR_MARKER = object()
22+
_U32_MAX = (1 << 32) - 1
23+
_U64_MAX = (1 << 64) - 1
2224

2325

2426
def _positive_int(value: str) -> int:
@@ -28,6 +30,22 @@ def _positive_int(value: str) -> int:
2830
return parsed
2931

3032

33+
def _bounded_positive_int(maximum: int) -> Callable[[str], int]:
34+
def parse(value: str) -> int:
35+
parsed = _positive_int(value)
36+
if parsed > maximum:
37+
raise argparse.ArgumentTypeError(
38+
f"must be a positive integer in the range 1..={maximum}, got {value!r}"
39+
)
40+
return parsed
41+
42+
return parse
43+
44+
45+
_U32_POSITIVE_INT = _bounded_positive_int(_U32_MAX)
46+
_U64_POSITIVE_INT = _bounded_positive_int(_U64_MAX)
47+
48+
3149
def _positive_float(value: str) -> float:
3250
parsed = float(value)
3351
if not math.isfinite(parsed) or parsed <= 0:
@@ -130,15 +148,15 @@ def _build_parser() -> tuple[
130148
run_parser.add_argument(
131149
"--max-memory",
132150
dest="max_memory",
133-
type=_positive_int,
151+
type=_U64_POSITIVE_INT,
134152
default=None,
135153
metavar="BYTES",
136154
help="Cap the whole child tree's memory, in bytes (needs a real container).",
137155
)
138156
run_parser.add_argument(
139157
"--max-processes",
140158
dest="max_processes",
141-
type=_positive_int,
159+
type=_U32_POSITIVE_INT,
142160
default=None,
143161
metavar="N",
144162
help="Cap the number of processes in the tree (needs a real container).",
@@ -323,7 +341,7 @@ def _build_parser() -> tuple[
323341
supervise_parser.add_argument(
324342
"--max-restarts",
325343
dest="max_restarts",
326-
type=_positive_int,
344+
type=_U32_POSITIVE_INT,
327345
default=None,
328346
metavar="N",
329347
help="Stop supervising after N restarts (Supervisor(max_restarts=...)).",
@@ -384,15 +402,15 @@ def _build_parser() -> tuple[
384402
supervise_parser.add_argument(
385403
"--max-memory",
386404
dest="max_memory",
387-
type=_positive_int,
405+
type=_U64_POSITIVE_INT,
388406
default=None,
389407
metavar="BYTES",
390408
help="Cap each incarnation's whole process tree memory, in bytes.",
391409
)
392410
supervise_parser.add_argument(
393411
"--max-processes",
394412
dest="max_processes",
395-
type=_positive_int,
413+
type=_U32_POSITIVE_INT,
396414
default=None,
397415
metavar="N",
398416
help="Cap each incarnation's process-tree size.",

tests/test_cli_main.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,33 @@ def test_idle_timeout_rejects_nonpositive_value_as_usage_error() -> None:
527527
assert "positive, finite number" in result.stderr
528528

529529

530+
@pytest.mark.parametrize(
531+
("subcommand", "option", "overflow"),
532+
[
533+
("run", "--max-memory", 1 << 64),
534+
("run", "--max-processes", 1 << 32),
535+
("supervise", "--max-memory", 1 << 64),
536+
("supervise", "--max-processes", 1 << 32),
537+
("supervise", "--max-restarts", 1 << 64),
538+
],
539+
)
540+
def test_resource_limit_overflow_is_argparse_usage_error_before_child_launch(
541+
subcommand: str, option: str, overflow: int
542+
) -> None:
543+
args = [subcommand]
544+
if subcommand == "supervise":
545+
args.extend(["--restart", "never"])
546+
args.extend([option, str(overflow), "--", PY, "-c", "print('child started')"])
547+
548+
result = _run_cli(*args)
549+
550+
assert result.returncode == 2
551+
assert "range 1..=" in result.stderr
552+
assert f"usage: python -m processkit {subcommand}" in result.stderr
553+
assert "child started" not in result.stdout
554+
assert "Traceback (most recent call last)" not in result.stderr
555+
556+
530557
@pytest.mark.parametrize(
531558
"option", ["--timeout", "--timeout-grace", "--idle-timeout", "--cpu-quota"]
532559
)

tests/test_cli_units.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,81 @@ def test_positive_int_rejects_non_positive_values(value: str) -> None:
102102
parser._positive_int(value)
103103

104104

105+
@pytest.mark.parametrize(
106+
("subcommand", "option", "destination", "maximum"),
107+
[
108+
("run", "--max-memory", "max_memory", (1 << 64) - 1),
109+
("run", "--max-processes", "max_processes", (1 << 32) - 1),
110+
("supervise", "--max-memory", "max_memory", (1 << 64) - 1),
111+
("supervise", "--max-processes", "max_processes", (1 << 32) - 1),
112+
("supervise", "--max-restarts", "max_restarts", (1 << 32) - 1),
113+
],
114+
)
115+
def test_bounded_resource_integer_parsers_accept_binding_upper_bound(
116+
subcommand: str, option: str, destination: str, maximum: int
117+
) -> None:
118+
top, _, _, _ = parser._build_parser()
119+
args = top.parse_args([subcommand, option, str(maximum)])
120+
assert getattr(args, destination) == maximum
121+
122+
123+
@pytest.mark.parametrize(
124+
("subcommand", "option", "destination"),
125+
[
126+
("run", "--max-memory", "max_memory"),
127+
("run", "--max-processes", "max_processes"),
128+
("supervise", "--max-memory", "max_memory"),
129+
("supervise", "--max-processes", "max_processes"),
130+
("supervise", "--max-restarts", "max_restarts"),
131+
],
132+
)
133+
def test_bounded_resource_integer_parsers_accept_binding_lower_bound(
134+
subcommand: str, option: str, destination: str
135+
) -> None:
136+
top, _, _, _ = parser._build_parser()
137+
args = top.parse_args([subcommand, option, "1"])
138+
assert getattr(args, destination) == 1
139+
140+
141+
@pytest.mark.parametrize(
142+
("subcommand", "option", "value"),
143+
[
144+
("run", "--max-memory", 1 << 64),
145+
("run", "--max-processes", 1 << 32),
146+
("supervise", "--max-memory", 1 << 64),
147+
("supervise", "--max-processes", 1 << 32),
148+
("supervise", "--max-restarts", 1 << 32),
149+
],
150+
)
151+
def test_bounded_resource_integer_parsers_reject_first_value_above_binding_bound(
152+
subcommand: str, option: str, value: int
153+
) -> None:
154+
top, _, _, _ = parser._build_parser()
155+
with pytest.raises(SystemExit) as exc_info:
156+
top.parse_args([subcommand, option, str(value)])
157+
assert exc_info.value.code == 2
158+
159+
160+
@pytest.mark.parametrize(
161+
("subcommand", "option"),
162+
[
163+
("run", "--max-memory"),
164+
("run", "--max-processes"),
165+
("supervise", "--max-memory"),
166+
("supervise", "--max-processes"),
167+
("supervise", "--max-restarts"),
168+
],
169+
)
170+
@pytest.mark.parametrize("value", ["0", "-1"])
171+
def test_bounded_resource_integer_parsers_preserve_non_positive_rejection(
172+
subcommand: str, option: str, value: str
173+
) -> None:
174+
top, _, _, _ = parser._build_parser()
175+
with pytest.raises(SystemExit) as exc_info:
176+
top.parse_args([subcommand, option, value])
177+
assert exc_info.value.code == 2
178+
179+
105180
@pytest.mark.parametrize("value", ["0", "-1", "nan", "inf", "+inf", "-inf"])
106181
def test_positive_float_rejects_non_positive_or_non_finite_values(value: str) -> None:
107182
with pytest.raises(argparse.ArgumentTypeError):

0 commit comments

Comments
 (0)