inpguard 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
inpguard-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Onur Cetinkaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: inpguard
3
+ Version: 0.1.0
4
+ Summary: Defensive linting for EPANET .inp water-network model files
5
+ Author: Onur Çetinkaya
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/onurcetinkaya-ce/inpguard
8
+ Project-URL: Source, https://github.com/onurcetinkaya-ce/inpguard
9
+ Project-URL: Issues, https://github.com/onurcetinkaya-ce/inpguard/issues
10
+ Project-URL: Security, https://github.com/onurcetinkaya-ce/inpguard/security/policy
11
+ Keywords: epanet,water,scada,ics,security,lint
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Hydrology
24
+ Classifier: Topic :: Security
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Dynamic: license-file
30
+
31
+ # inpguard
32
+
33
+ Defensive linting for EPANET `.inp` water-network model files.
34
+
35
+ `inpguard` is a dependency-free Python library and CLI that inspects an EPANET
36
+ input file **before** it reaches a hydraulic solver. It reports malformed
37
+ structure, dangling cross-references, numeric values that overflow single
38
+ precision, embedded control bytes that desynchronise C string handling, and
39
+ other input hazards.
40
+
41
+ It is a *linter*, not a fuzzer and not an exploit. It never executes the file.
42
+
43
+ ## Why this exists
44
+
45
+ Water utilities exchange `.inp` files constantly — with consultants, with
46
+ contractors, between distribution-system teams, and increasingly through
47
+ automated pipelines. The format is plain text, section-delimited, and widely
48
+ reimplemented in memory-unsafe languages. Most parsers assume the file is
49
+ benign because historically it always was.
50
+
51
+ That assumption is aging badly. Water systems are now an explicit target class,
52
+ and the people who steward these models — civil and environmental engineers —
53
+ are trained in hydraulics, not in adversarial input handling. The realistic
54
+ failure mode isn't a nation-state exploit; it's a subtle malformed file that
55
+ silently corrupts a hydraulic model, or a parser that crashes and takes a
56
+ modelling session with it.
57
+
58
+ `inpguard` gives the engineering side a two-second sanity check that speaks in
59
+ byte offsets and line numbers rather than stack traces.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install inpguard
65
+ ```
66
+
67
+ For development, install from a clone:
68
+
69
+ ```bash
70
+ git clone https://github.com/onurcetinkaya-ce/inpguard.git
71
+ cd inpguard
72
+ pip install -e .
73
+ ```
74
+
75
+ No runtime dependencies. Python 3.9+.
76
+
77
+ ## Use
78
+
79
+ ```bash
80
+ inpguard check network.inp
81
+ inpguard check models/*.inp --json
82
+ ```
83
+
84
+ ```
85
+ network.inp: 412 bytes, 34 lines, 6 sections
86
+ ERROR INP032 line 14: link endpoint 'J9' is not a defined node
87
+ WARN INP022 line 12: value 1e39 in column 4 exceeds float32 range and will overflow to inf on a single-precision parser
88
+ 1 error(s), 1 warning(s)
89
+ ```
90
+
91
+ Exit codes: `0` clean, `1` error findings, `2` I/O or usage failure.
92
+
93
+ As a library:
94
+
95
+ ```python
96
+ from inpguard import lint_text
97
+
98
+ report = lint_text(open("network.inp").read())
99
+ for finding in report.sorted_findings():
100
+ print(finding)
101
+ print(report.ok())
102
+ ```
103
+
104
+ ## Rule reference
105
+
106
+ | Code | Severity | Meaning |
107
+ |------|----------|---------|
108
+ | INP001 | ERROR | File exceeds the 64 MiB ceiling (checked before parsing) |
109
+ | INP002 | ERROR | Physical line exceeds 8 KiB |
110
+ | INP003 | ERROR | Forbidden control byte (NUL, vertical tab, form feed, etc.) |
111
+ | INP004 | ERROR | Line is not valid UTF-8 |
112
+ | INP005 | ERROR | Empty or malformed section header |
113
+ | INP006 | ERROR | Data before the first section header |
114
+ | INP007 | ERROR | Section exceeds its row ceiling |
115
+ | INP008 | ERROR | Too many sections |
116
+ | INP010 | WARN | Unknown section name |
117
+ | INP011 | WARN | Section appears more than once |
118
+ | INP012 | WARN | Token exceeds 256 bytes (fixed-buffer hazard) |
119
+ | INP013 | WARN | No `[END]` section |
120
+ | INP014 | WARN | Cross-reference limit reached; reference findings are a lower bound |
121
+ | INP020 | ERROR | Duplicate node/link identifier |
122
+ | INP021 | ERROR | Non-finite numeric literal (`nan`, `inf`) |
123
+ | INP022 | WARN | Value overflows IEEE-754 single precision |
124
+ | INP023 | WARN | Numeric literal uses digit separators (`1_000`) |
125
+ | INP030 | WARN | Reference to an undefined pattern |
126
+ | INP031 | WARN | Reference to an undefined curve |
127
+ | INP032 | ERROR | Link endpoint is not a defined node |
128
+
129
+ ## Design notes
130
+
131
+ **Line splitting is deliberately narrow.** The file is split on LF and CRLF
132
+ only. It does *not* split on `\v`, `\f`, `\x1c`–`\x1e`, `\u2028`, or `\u0085`,
133
+ which `str.splitlines()` does honour. If the linter and the target parser
134
+ disagree about where a record ends, an attacker can craft a file that reads one
135
+ way to the linter and another way to the parser. The corpus contains an
136
+ explicit `unicode_line_sep` case asserting this.
137
+
138
+ **The UTF-8 BOM is stripped, not rejected.** Windows and spreadsheet tooling
139
+ emits BOMs routinely. A linter that rejects every such file gets switched off
140
+ in production within a week, and a linter that is switched off protects nobody.
141
+
142
+ **Limits are checked before the work they bound.** An oversized file is
143
+ reported and abandoned without walking its body.
144
+
145
+ **Findings carry byte offsets.** A finding you cannot locate is a finding
146
+ nobody acts on.
147
+
148
+ ## Corpus
149
+
150
+ `inpguard/corpus.py` ships eleven cases: a clean model, a truncated transfer, a
151
+ Windows CRLF+BOM export, and adversarial inputs for dangling references,
152
+ duplicate identifiers, float32 overflow, NaN/Inf literals, embedded NULs,
153
+ Unicode line-separator smuggling, unknown sections, and oversized tokens.
154
+
155
+ These are not exploits. They are malformed or hostile *inputs* that a parser
156
+ should reject or survive. The corpus exists so that the check has something to
157
+ fail against, and so that a parser author can reproduce a report.
158
+
159
+ ## Validation
160
+
161
+ Validated against 17 `.inp` files it did not author, ~37,400 lines in total,
162
+ from two independent publishing bodies.
163
+
164
+ **Nine benchmark distribution networks** — C-Town, d-town, Modena, Jilin, Zhi
165
+ Jiang, KL, ky1, ky8, RuralNetwork — sourced from the DiTEC-WDN corpus
166
+ (Truong et al., *Scientific Data* 2025): **zero errors, zero warnings**. Sizes
167
+ range from 234 to 7,731 lines.
168
+
169
+ **Eight files from the USEPA/WNTR reference suite**, including the 847 KB
170
+ `Net6_plus.inp` benchmark: five lint completely clean; the three that do not are
171
+ malformed on purpose and produce exactly one finding each, the one they were
172
+ built to contain.
173
+
174
+ The first run against those files produced 66 false positives and exposed three
175
+ real bugs (inline `;` comments, `[PUMPS]`/`[VALVES]` column indexing, repeated
176
+ sections). Each is fixed and carries a regression test. Adversarial self-testing
177
+ separately found and fixed a resource-amplification bug in this tool.
178
+
179
+ See `VALIDATION.md` for the full tables, method, and a candid statement of what
180
+ a clean result does and does not demonstrate.
181
+
182
+ ## Status
183
+
184
+ `0.1.0` — alpha. The rule set is deliberately conservative: it flags only what
185
+ is unambiguous in the format specification. Rules that would warn on a
186
+ legitimate file exported from a common modelling tool are bugs, not features.
187
+
188
+ Output and memory are bounded: findings are capped per rule code (with the number
189
+ of elided occurrences reported, never silently dropped), and cross-reference
190
+ checking is capped with an explicit `INP014` rather than growing without limit.
191
+ A 3.2 MB adversarial file that once produced 400,002 findings and 287.9 MB peak
192
+ RSS now produces 106 findings and 38.7 MB. See `VALIDATION.md`.
193
+
194
+ ## License
195
+
196
+ MIT.
@@ -0,0 +1,166 @@
1
+ # inpguard
2
+
3
+ Defensive linting for EPANET `.inp` water-network model files.
4
+
5
+ `inpguard` is a dependency-free Python library and CLI that inspects an EPANET
6
+ input file **before** it reaches a hydraulic solver. It reports malformed
7
+ structure, dangling cross-references, numeric values that overflow single
8
+ precision, embedded control bytes that desynchronise C string handling, and
9
+ other input hazards.
10
+
11
+ It is a *linter*, not a fuzzer and not an exploit. It never executes the file.
12
+
13
+ ## Why this exists
14
+
15
+ Water utilities exchange `.inp` files constantly — with consultants, with
16
+ contractors, between distribution-system teams, and increasingly through
17
+ automated pipelines. The format is plain text, section-delimited, and widely
18
+ reimplemented in memory-unsafe languages. Most parsers assume the file is
19
+ benign because historically it always was.
20
+
21
+ That assumption is aging badly. Water systems are now an explicit target class,
22
+ and the people who steward these models — civil and environmental engineers —
23
+ are trained in hydraulics, not in adversarial input handling. The realistic
24
+ failure mode isn't a nation-state exploit; it's a subtle malformed file that
25
+ silently corrupts a hydraulic model, or a parser that crashes and takes a
26
+ modelling session with it.
27
+
28
+ `inpguard` gives the engineering side a two-second sanity check that speaks in
29
+ byte offsets and line numbers rather than stack traces.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install inpguard
35
+ ```
36
+
37
+ For development, install from a clone:
38
+
39
+ ```bash
40
+ git clone https://github.com/onurcetinkaya-ce/inpguard.git
41
+ cd inpguard
42
+ pip install -e .
43
+ ```
44
+
45
+ No runtime dependencies. Python 3.9+.
46
+
47
+ ## Use
48
+
49
+ ```bash
50
+ inpguard check network.inp
51
+ inpguard check models/*.inp --json
52
+ ```
53
+
54
+ ```
55
+ network.inp: 412 bytes, 34 lines, 6 sections
56
+ ERROR INP032 line 14: link endpoint 'J9' is not a defined node
57
+ WARN INP022 line 12: value 1e39 in column 4 exceeds float32 range and will overflow to inf on a single-precision parser
58
+ 1 error(s), 1 warning(s)
59
+ ```
60
+
61
+ Exit codes: `0` clean, `1` error findings, `2` I/O or usage failure.
62
+
63
+ As a library:
64
+
65
+ ```python
66
+ from inpguard import lint_text
67
+
68
+ report = lint_text(open("network.inp").read())
69
+ for finding in report.sorted_findings():
70
+ print(finding)
71
+ print(report.ok())
72
+ ```
73
+
74
+ ## Rule reference
75
+
76
+ | Code | Severity | Meaning |
77
+ |------|----------|---------|
78
+ | INP001 | ERROR | File exceeds the 64 MiB ceiling (checked before parsing) |
79
+ | INP002 | ERROR | Physical line exceeds 8 KiB |
80
+ | INP003 | ERROR | Forbidden control byte (NUL, vertical tab, form feed, etc.) |
81
+ | INP004 | ERROR | Line is not valid UTF-8 |
82
+ | INP005 | ERROR | Empty or malformed section header |
83
+ | INP006 | ERROR | Data before the first section header |
84
+ | INP007 | ERROR | Section exceeds its row ceiling |
85
+ | INP008 | ERROR | Too many sections |
86
+ | INP010 | WARN | Unknown section name |
87
+ | INP011 | WARN | Section appears more than once |
88
+ | INP012 | WARN | Token exceeds 256 bytes (fixed-buffer hazard) |
89
+ | INP013 | WARN | No `[END]` section |
90
+ | INP014 | WARN | Cross-reference limit reached; reference findings are a lower bound |
91
+ | INP020 | ERROR | Duplicate node/link identifier |
92
+ | INP021 | ERROR | Non-finite numeric literal (`nan`, `inf`) |
93
+ | INP022 | WARN | Value overflows IEEE-754 single precision |
94
+ | INP023 | WARN | Numeric literal uses digit separators (`1_000`) |
95
+ | INP030 | WARN | Reference to an undefined pattern |
96
+ | INP031 | WARN | Reference to an undefined curve |
97
+ | INP032 | ERROR | Link endpoint is not a defined node |
98
+
99
+ ## Design notes
100
+
101
+ **Line splitting is deliberately narrow.** The file is split on LF and CRLF
102
+ only. It does *not* split on `\v`, `\f`, `\x1c`–`\x1e`, `\u2028`, or `\u0085`,
103
+ which `str.splitlines()` does honour. If the linter and the target parser
104
+ disagree about where a record ends, an attacker can craft a file that reads one
105
+ way to the linter and another way to the parser. The corpus contains an
106
+ explicit `unicode_line_sep` case asserting this.
107
+
108
+ **The UTF-8 BOM is stripped, not rejected.** Windows and spreadsheet tooling
109
+ emits BOMs routinely. A linter that rejects every such file gets switched off
110
+ in production within a week, and a linter that is switched off protects nobody.
111
+
112
+ **Limits are checked before the work they bound.** An oversized file is
113
+ reported and abandoned without walking its body.
114
+
115
+ **Findings carry byte offsets.** A finding you cannot locate is a finding
116
+ nobody acts on.
117
+
118
+ ## Corpus
119
+
120
+ `inpguard/corpus.py` ships eleven cases: a clean model, a truncated transfer, a
121
+ Windows CRLF+BOM export, and adversarial inputs for dangling references,
122
+ duplicate identifiers, float32 overflow, NaN/Inf literals, embedded NULs,
123
+ Unicode line-separator smuggling, unknown sections, and oversized tokens.
124
+
125
+ These are not exploits. They are malformed or hostile *inputs* that a parser
126
+ should reject or survive. The corpus exists so that the check has something to
127
+ fail against, and so that a parser author can reproduce a report.
128
+
129
+ ## Validation
130
+
131
+ Validated against 17 `.inp` files it did not author, ~37,400 lines in total,
132
+ from two independent publishing bodies.
133
+
134
+ **Nine benchmark distribution networks** — C-Town, d-town, Modena, Jilin, Zhi
135
+ Jiang, KL, ky1, ky8, RuralNetwork — sourced from the DiTEC-WDN corpus
136
+ (Truong et al., *Scientific Data* 2025): **zero errors, zero warnings**. Sizes
137
+ range from 234 to 7,731 lines.
138
+
139
+ **Eight files from the USEPA/WNTR reference suite**, including the 847 KB
140
+ `Net6_plus.inp` benchmark: five lint completely clean; the three that do not are
141
+ malformed on purpose and produce exactly one finding each, the one they were
142
+ built to contain.
143
+
144
+ The first run against those files produced 66 false positives and exposed three
145
+ real bugs (inline `;` comments, `[PUMPS]`/`[VALVES]` column indexing, repeated
146
+ sections). Each is fixed and carries a regression test. Adversarial self-testing
147
+ separately found and fixed a resource-amplification bug in this tool.
148
+
149
+ See `VALIDATION.md` for the full tables, method, and a candid statement of what
150
+ a clean result does and does not demonstrate.
151
+
152
+ ## Status
153
+
154
+ `0.1.0` — alpha. The rule set is deliberately conservative: it flags only what
155
+ is unambiguous in the format specification. Rules that would warn on a
156
+ legitimate file exported from a common modelling tool are bugs, not features.
157
+
158
+ Output and memory are bounded: findings are capped per rule code (with the number
159
+ of elided occurrences reported, never silently dropped), and cross-reference
160
+ checking is capped with an explicit `INP014` rather than growing without limit.
161
+ A 3.2 MB adversarial file that once produced 400,002 findings and 287.9 MB peak
162
+ RSS now produces 106 findings and 38.7 MB. See `VALIDATION.md`.
163
+
164
+ ## License
165
+
166
+ MIT.
@@ -0,0 +1,30 @@
1
+ """inpguard — defensive linting for EPANET .inp water-network model files.
2
+
3
+ Water utilities exchange .inp files constantly: between utilities, with
4
+ consultants, with contractors, and increasingly through automated pipelines.
5
+ Most parsers handle that input with the assumption that it is benign. This
6
+ package makes the malicious and the merely-malformed cases visible *before* the
7
+ file reaches a parser.
8
+
9
+ >>> from inpguard import lint_text
10
+ >>> report = lint_text(open("network.inp").read())
11
+ >>> print(report.ok())
12
+ """
13
+
14
+ from .lint import ERROR, INFO, WARN, Finding, Report, lint_bytes, lint_text
15
+ from .spec import KNOWN_SECTIONS, LIMITS
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "ERROR",
21
+ "INFO",
22
+ "WARN",
23
+ "Finding",
24
+ "Report",
25
+ "KNOWN_SECTIONS",
26
+ "LIMITS",
27
+ "lint_bytes",
28
+ "lint_text",
29
+ "__version__",
30
+ ]
@@ -0,0 +1,113 @@
1
+ """CLI entry point: `inpguard check network.inp`.
2
+
3
+ Exit codes are the contract for CI:
4
+ 0 clean (no ERROR findings)
5
+ 1 at least one ERROR finding
6
+ 2 usage or I/O failure
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from . import __version__, lint_bytes
17
+ from .lint import Report
18
+
19
+
20
+ def _render_text(report: Report, path: str) -> str:
21
+ lines = [f"{path}: {report.bytes_read} bytes, {report.lines_read} lines, "
22
+ f"{len(report.sections)} sections"]
23
+ findings = report.sorted_findings()
24
+ if not findings:
25
+ lines.append(" clean")
26
+ return "\n".join(lines)
27
+ for finding in findings:
28
+ lines.append(f" {finding}")
29
+ for code, count in sorted(report.suppressed.items()):
30
+ lines.append(
31
+ f" NOTE {code:12s} {count} further finding(s) of this code suppressed"
32
+ )
33
+ errors = len(report.errors)
34
+ warns = len(report.warnings)
35
+ lines.append(f" {errors} error(s), {warns} warning(s)")
36
+ return "\n".join(lines)
37
+
38
+
39
+ def _render_json(report: Report, path: str) -> str:
40
+ payload = {
41
+ "path": path,
42
+ "bytes": report.bytes_read,
43
+ "lines": report.lines_read,
44
+ "ok": report.ok(),
45
+ "sections": [s.name for s in report.sections],
46
+ "findings": [
47
+ {
48
+ "severity": f.severity,
49
+ "code": f.code,
50
+ "message": f.message,
51
+ "offset": f.offset,
52
+ "line": f.line,
53
+ "section": f.section,
54
+ }
55
+ for f in report.sorted_findings()
56
+ ],
57
+ "suppressed": dict(sorted(report.suppressed.items())),
58
+ }
59
+ return json.dumps(payload, indent=2)
60
+
61
+
62
+ def build_parser() -> argparse.ArgumentParser:
63
+ parser = argparse.ArgumentParser(
64
+ prog="inpguard",
65
+ description="Defensive linting for EPANET .inp water-network model files.",
66
+ )
67
+ parser.add_argument("--version", action="version", version=f"inpguard {__version__}")
68
+ sub = parser.add_subparsers(dest="command", required=True)
69
+
70
+ check = sub.add_parser("check", help="lint one or more .inp files")
71
+ check.add_argument("files", nargs="+", type=Path)
72
+ check.add_argument("--json", action="store_true", help="machine-readable output")
73
+ check.add_argument(
74
+ "--allow-references",
75
+ action="store_true",
76
+ help="skip cross-section reference checks (partial files)",
77
+ )
78
+ check.add_argument(
79
+ "--max-float",
80
+ type=float,
81
+ default=None,
82
+ help="override the single-precision ceiling",
83
+ )
84
+ return parser
85
+
86
+
87
+ def main(argv: list[str] | None = None) -> int:
88
+ args = build_parser().parse_args(argv)
89
+
90
+ worst = 0
91
+ for path in args.files:
92
+ try:
93
+ raw = path.read_bytes()
94
+ except OSError as exc:
95
+ print(f"{path}: cannot read: {exc}", file=sys.stderr)
96
+ worst = max(worst, 2)
97
+ continue
98
+
99
+ report = lint_bytes(
100
+ raw,
101
+ strict_references=not args.allow_references,
102
+ float_ceiling=args.max_float,
103
+ )
104
+ out = _render_json(report, str(path)) if args.json else _render_text(report, str(path))
105
+ print(out)
106
+ if not report.ok():
107
+ worst = max(worst, 1)
108
+
109
+ return worst
110
+
111
+
112
+ if __name__ == "__main__":
113
+ raise SystemExit(main())
@@ -0,0 +1,155 @@
1
+ """CORPUS — a regression corpus of hand-built EPANET .inp inputs.
2
+
3
+ Every case here is a file a real water utility could plausibly receive: a clean
4
+ export from a modelling tool, a truncated transfer, a legacy file with CRLF
5
+ endings, and a set of adversarial files exercising specific parser assumptions.
6
+
7
+ The adversarial cases are NOT exploits. They are malformed or hostile *inputs*
8
+ that a parser should reject or survive. Publishing them is the point: the
9
+ defender needs a test suite, and the vendor needs a reproducible report.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ CLEAN = """\
17
+ [TITLE]
18
+ METU campus distribution model - synthetic
19
+
20
+ [JUNCTIONS]
21
+ ;ID Elev Dem Pattern
22
+ J1 10.0 5.0 P1
23
+ J2 12.5 3.5 P1
24
+
25
+ [RESERVOIRS]
26
+ ;ID Head
27
+ R1 55.0
28
+
29
+ [PIPES]
30
+ ;ID Node1 Node2 Length Diam Rough Mloss Status
31
+ P1 R1 J1 120.0 300 130 0 Open
32
+ P2 J1 J2 240.0 200 130 0 Open
33
+
34
+ [PATTERNS]
35
+ ;ID Factor
36
+ P1 1.0
37
+ P1 1.2
38
+
39
+ [OPTIONS]
40
+ Units LPS
41
+ Headloss H-W
42
+
43
+ [END]
44
+ """
45
+
46
+ # Truncated download / interrupted transfer: valid prefix, no terminator.
47
+ TRUNCATED = CLEAN.split("[OPTIONS]")[0]
48
+
49
+ # Legacy export with CRLF and a BOM.
50
+ CRLF_BOM = "\ufeff" + CLEAN.replace("\n", "\r\n")
51
+
52
+ # Dangling reference: pipe names a node that does not exist.
53
+ DANGLING_NODE = """\
54
+ [JUNCTIONS]
55
+ J1 10.0 5.0
56
+
57
+ [RESERVOIRS]
58
+ R1 55.0
59
+
60
+ [PIPES]
61
+ P1 R1 J9 120.0 300 130 0 Open
62
+
63
+ [END]
64
+ """
65
+
66
+ # Duplicate identifiers: depends on whether the parser overwrites or appends.
67
+ DUPLICATE_ID = """\
68
+ [JUNCTIONS]
69
+ J1 10.0 5.0
70
+ J1 12.0 4.0
71
+
72
+ [END]
73
+ """
74
+
75
+ # Numeric bombs: values that overflow single precision.
76
+ NUMERIC_BOMB = """\
77
+ [JUNCTIONS]
78
+ J1 10.0 5.0
79
+
80
+ [PIPES]
81
+ P1 J1 J1 1e39 300 130 0 Open
82
+
83
+ [END]
84
+ """
85
+
86
+ # NaN/Inf literals: strtod accepts these; many downstream models do not.
87
+ NONFINITE = """\
88
+ [JUNCTIONS]
89
+ J1 10.0 nan
90
+ J2 inf 5.0
91
+
92
+ [END]
93
+ """
94
+
95
+ # Embedded NUL: truncates C string handling, classic parser desync.
96
+ EMBEDDED_NUL = "[JUNCTIONS]\n J1\x0010.0 5.0\n\n[END]\n"
97
+
98
+ # Line-separator smuggling: the linter must NOT split these as newlines,
99
+ # otherwise an attacker can make the linter and the parser disagree.
100
+ UNICODE_LINE_SEP = "[JUNCTIONS]\n J1 10.0 5.0\u2028 J2 99.0 99.0\n\n[END]\n"
101
+
102
+ # Unknown section: an extension the parser may or may not understand.
103
+ UNKNOWN_SECTION = """\
104
+ [JUNCTIONS]
105
+ J1 10.0 5.0
106
+
107
+ [MYSTERY_EXTENSION]
108
+ whatever 1 2 3
109
+
110
+ [END]
111
+ """
112
+
113
+ # Long token: fixed-buffer parsers may truncate or overflow.
114
+ LONG_TOKEN = "[JUNCTIONS]\n " + ("A" * 5000) + " 10.0 5.0\n\n[END]\n"
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Case:
119
+ name: str
120
+ data: str
121
+ expect_codes: frozenset[str]
122
+ expect_ok: bool
123
+ note: str
124
+
125
+
126
+ CASES: tuple[Case, ...] = (
127
+ Case("clean", CLEAN, frozenset(), True, "well-formed file; must lint clean"),
128
+ Case("truncated", TRUNCATED, frozenset({"INP013"}), True,
129
+ "interrupted transfer; no [END]"),
130
+ Case("crlf_bom", CRLF_BOM, frozenset(), True,
131
+ "Windows export with BOM; must not false-positive"),
132
+ Case("dangling_node", DANGLING_NODE, frozenset({"INP032"}), False,
133
+ "referential integrity failure"),
134
+ Case("duplicate_id", DUPLICATE_ID, frozenset({"INP020"}), False,
135
+ "identifier collision"),
136
+ Case("numeric_bomb", NUMERIC_BOMB, frozenset({"INP022"}), True,
137
+ "float32 overflow"),
138
+ Case("nonfinite", NONFINITE, frozenset({"INP021"}), False,
139
+ "NaN/Inf literals"),
140
+ Case("embedded_nul", EMBEDDED_NUL, frozenset({"INP003"}), False,
141
+ "NUL truncates C strings"),
142
+ Case("unicode_line_sep", UNICODE_LINE_SEP, frozenset(), True,
143
+ "U+2028 must not split lines"),
144
+ Case("unknown_section", UNKNOWN_SECTION, frozenset({"INP010"}), True,
145
+ "undocumented extension"),
146
+ Case("long_token", LONG_TOKEN, frozenset({"INP012"}), True,
147
+ "fixed-buffer truncation hazard"),
148
+ )
149
+
150
+
151
+ def get(name: str) -> Case:
152
+ for case in CASES:
153
+ if case.name == name:
154
+ return case
155
+ raise KeyError(name)