ciphergate-cli 0.7.0__py3-none-any.whl

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.
Files changed (74) hide show
  1. ciphergate/__init__.py +95 -0
  2. ciphergate/__main__.py +6 -0
  3. ciphergate/cli.py +832 -0
  4. ciphergate/config.py +50 -0
  5. ciphergate/constants.py +274 -0
  6. ciphergate/detectors/__init__.py +5 -0
  7. ciphergate/detectors/base.py +53 -0
  8. ciphergate/detectors/custom_regex.py +37 -0
  9. ciphergate/detectors/dates.py +92 -0
  10. ciphergate/detectors/email.py +56 -0
  11. ciphergate/detectors/field_names.py +220 -0
  12. ciphergate/detectors/ip_address.py +85 -0
  13. ciphergate/detectors/network.py +42 -0
  14. ciphergate/detectors/payment_card.py +77 -0
  15. ciphergate/detectors/phone.py +67 -0
  16. ciphergate/detectors/presidio_ner.py +112 -0
  17. ciphergate/detectors/regex_safety.py +40 -0
  18. ciphergate/detectors/registry.py +194 -0
  19. ciphergate/detectors/secrets.py +130 -0
  20. ciphergate/errors.py +119 -0
  21. ciphergate/models/__init__.py +1 -0
  22. ciphergate/models/context.py +69 -0
  23. ciphergate/models/finding.py +108 -0
  24. ciphergate/models/policy.py +402 -0
  25. ciphergate/models/report.py +115 -0
  26. ciphergate/pipeline/__init__.py +1 -0
  27. ciphergate/pipeline/atomic_output.py +143 -0
  28. ciphergate/pipeline/batch.py +533 -0
  29. ciphergate/pipeline/inspector.py +282 -0
  30. ciphergate/pipeline/paths.py +98 -0
  31. ciphergate/pipeline/planner.py +192 -0
  32. ciphergate/pipeline/rescan.py +120 -0
  33. ciphergate/pipeline/sanitizer.py +392 -0
  34. ciphergate/pipeline/stdio.py +173 -0
  35. ciphergate/pipeline/streams.py +152 -0
  36. ciphergate/policy/__init__.py +26 -0
  37. ciphergate/policy/context.py +203 -0
  38. ciphergate/policy/engine.py +194 -0
  39. ciphergate/policy/loader.py +116 -0
  40. ciphergate/policy/precedence.py +149 -0
  41. ciphergate/policy/validator.py +632 -0
  42. ciphergate/py.typed +0 -0
  43. ciphergate/readers/__init__.py +113 -0
  44. ciphergate/readers/accesslog_reader.py +244 -0
  45. ciphergate/readers/base.py +131 -0
  46. ciphergate/readers/csv_reader.py +241 -0
  47. ciphergate/readers/json_reader.py +184 -0
  48. ciphergate/readers/jsonl_reader.py +97 -0
  49. ciphergate/readers/jsonlog_reader.py +178 -0
  50. ciphergate/readers/line_structure.py +125 -0
  51. ciphergate/readers/logfmt_reader.py +100 -0
  52. ciphergate/readers/stacktrace_reader.py +103 -0
  53. ciphergate/readers/syslog_reader.py +309 -0
  54. ciphergate/readers/text_reader.py +59 -0
  55. ciphergate/reporting/__init__.py +1 -0
  56. ciphergate/reporting/audit.py +85 -0
  57. ciphergate/reporting/safe_text.py +71 -0
  58. ciphergate/reporting/serializer.py +44 -0
  59. ciphergate/reporting/terminal.py +147 -0
  60. ciphergate/templates/starter_policy.yml +77 -0
  61. ciphergate/transforms/__init__.py +6 -0
  62. ciphergate/transforms/date_shift.py +79 -0
  63. ciphergate/transforms/generalize.py +107 -0
  64. ciphergate/transforms/keys.py +177 -0
  65. ciphergate/transforms/pseudonymize.py +113 -0
  66. ciphergate/transforms/redact.py +9 -0
  67. ciphergate/transforms/registry.py +201 -0
  68. ciphergate/transforms/replace.py +16 -0
  69. ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
  70. ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
  71. ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
  72. ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
  73. ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
  74. ciphergate_cli-0.7.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,113 @@
1
+ """Format adapters (readers + writers): CSV, JSON, JSONL/NDJSON, text/log and log formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import IO
7
+
8
+ from ciphergate.constants import InputFormat
9
+ from ciphergate.errors import InputFormatError, UsageError
10
+ from ciphergate.readers.accesslog_reader import ACCESSLOG_STRUCTURE, AccessLogReader
11
+ from ciphergate.readers.base import Reader, ReaderOptions, Record, Writer
12
+ from ciphergate.readers.csv_reader import CSVReader, CSVWriter
13
+ from ciphergate.readers.json_reader import JSONReader, JSONWriter
14
+ from ciphergate.readers.jsonl_reader import JSONLReader, JSONLWriter
15
+ from ciphergate.readers.jsonlog_reader import JSONLOG_STRUCTURE, JsonLogReader
16
+ from ciphergate.readers.line_structure import LineStructure
17
+ from ciphergate.readers.logfmt_reader import LogfmtReader
18
+ from ciphergate.readers.stacktrace_reader import StackTraceReader
19
+ from ciphergate.readers.syslog_reader import SYSLOG_STRUCTURE, SyslogReader
20
+ from ciphergate.readers.text_reader import TextReader, TextWriter
21
+
22
+ # Line formats whose lines are parsed into fields (see readers/line_structure.py).
23
+ STRUCTURED_LINE_FORMATS: dict[InputFormat, LineStructure] = {
24
+ InputFormat.JSONLOG: JSONLOG_STRUCTURE,
25
+ InputFormat.ACCESSLOG: ACCESSLOG_STRUCTURE,
26
+ InputFormat.SYSLOG: SYSLOG_STRUCTURE,
27
+ }
28
+
29
+ EXTENSION_FORMATS: dict[str, InputFormat] = {
30
+ ".csv": InputFormat.CSV,
31
+ ".json": InputFormat.JSON,
32
+ ".jsonl": InputFormat.JSONL,
33
+ ".ndjson": InputFormat.JSONL,
34
+ ".txt": InputFormat.TEXT,
35
+ ".log": InputFormat.TEXT,
36
+ }
37
+
38
+ _JSON_PREFIXES = (
39
+ b"{",
40
+ b"[",
41
+ b'"',
42
+ b"-",
43
+ b"true",
44
+ b"false",
45
+ b"null",
46
+ *(bytes([d]) for d in b"0123456789"),
47
+ )
48
+
49
+
50
+ def detect_format(path: Path, explicit: InputFormat | None = None) -> InputFormat:
51
+ """Explicit ``--format`` wins; else use the extension, refusing contradicting content."""
52
+ if explicit is not None:
53
+ return explicit
54
+ fmt = EXTENSION_FORMATS.get(path.suffix.lower())
55
+ if fmt is None:
56
+ raise UsageError(
57
+ "cannot infer the input format from this file extension; pass --format "
58
+ + "|".join(InputFormat),
59
+ code="format_required",
60
+ )
61
+ if fmt in (InputFormat.JSON, InputFormat.JSONL):
62
+ with path.open("rb") as fh:
63
+ head = fh.read(4096).lstrip(b"\xef\xbb\xbf \t\r\n")
64
+ if head and not head.startswith(_JSON_PREFIXES):
65
+ raise InputFormatError(
66
+ "file content does not look like JSON despite its extension; pass --format",
67
+ code="format_mismatch",
68
+ )
69
+ return fmt
70
+
71
+
72
+ def open_reader(path: Path, fmt: InputFormat, options: ReaderOptions) -> Reader:
73
+ if fmt == InputFormat.CSV:
74
+ return CSVReader(path, options)
75
+ if fmt == InputFormat.JSON:
76
+ return JSONReader(path, options)
77
+ if fmt == InputFormat.JSONL:
78
+ return JSONLReader(path, options)
79
+ if fmt == InputFormat.LOGFMT:
80
+ return LogfmtReader(path, options)
81
+ if fmt == InputFormat.JSONLOG:
82
+ return JsonLogReader(path, options)
83
+ if fmt == InputFormat.ACCESSLOG:
84
+ return AccessLogReader(path, options)
85
+ if fmt == InputFormat.SYSLOG:
86
+ return SyslogReader(path, options)
87
+ if fmt == InputFormat.STACKTRACE:
88
+ return StackTraceReader(path, options)
89
+ return TextReader(path, options)
90
+
91
+
92
+ def make_writer(stream: IO[str], reader: Reader, dropped_columns: set[int]) -> Writer:
93
+ if isinstance(reader, CSVReader):
94
+ return CSVWriter(stream, reader, dropped_columns)
95
+ if isinstance(reader, JSONReader):
96
+ return JSONWriter(stream, reader)
97
+ if isinstance(reader, JSONLReader):
98
+ return JSONLWriter(stream)
99
+ if isinstance(reader, TextReader):
100
+ return TextWriter(stream, reader)
101
+ raise TypeError("unsupported reader")
102
+
103
+
104
+ __all__ = [
105
+ "EXTENSION_FORMATS",
106
+ "Reader",
107
+ "ReaderOptions",
108
+ "Record",
109
+ "Writer",
110
+ "detect_format",
111
+ "make_writer",
112
+ "open_reader",
113
+ ]
@@ -0,0 +1,244 @@
1
+ """Access-log adapter: NCSA common/combined lines as written by nginx and Apache httpd.
2
+
3
+ ``192.0.2.10 - alice [05/Jan/2026:10:00:00 +0000] "GET /p?email=a%40b HTTP/1.1" 200 12 "ref" "ua"``
4
+
5
+ Detection never relies on the parse alone. Every line is scanned exactly like ``text``; the
6
+ parse only adds field context on top:
7
+
8
+ * fields get nginx variable names as field paths: ``/remote_addr``, ``/ident``,
9
+ ``/remote_user``, ``/time_local``, ``/request`` (split into ``/request/method``,
10
+ ``/request/path``, ``/request/protocol`` when it has the usual three parts), ``/status``,
11
+ ``/body_bytes_sent``, ``/http_referer`` and ``/http_user_agent``;
12
+ * query strings of the request target and the referer become fields too
13
+ (``/request/query/<name>``), so field-name heuristics and ``field_names`` rules apply to
14
+ parameters such as ``email`` or ``access_token``;
15
+ * log escapes (``\\x22``, ``\\"``) and percent-encoding are decoded and the decoded value is
16
+ scanned as well, because the raw-text scan cannot see through them.
17
+
18
+ A ``-`` field means "no value" and gets no field context. Output keeps the line as it was; only
19
+ protected spans change. Replacements are re-encoded for their field (percent-encoding in paths
20
+ and query strings, ``\\xHH`` log escapes elsewhere), and the line must still parse with as many
21
+ fields afterwards, or the run fails. Only lines that *start* with the common/combined layout are
22
+ recognised; anything after the user agent (e.g. ``$http_x_forwarded_for``) and lines in other
23
+ layouts get text-mode scanning only. ``drop_field`` is not supported (it becomes ``review``).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from typing import Any
30
+
31
+ from ciphergate.constants import InputFormat
32
+ from ciphergate.readers.json_reader import pointer_escape
33
+ from ciphergate.readers.line_structure import FieldRegion, FieldToken, LineStructure
34
+ from ciphergate.readers.text_reader import TextReader
35
+
36
+ # Three space-free fields, then the bracketed time. Classes never overlap the separators, so
37
+ # matching is linear.
38
+ _HEAD_RE = re.compile(r'([^\s"]{1,1024}) ([^\s"]{1,1024}) ([^\s"]{1,1024}) \[([^\]\n]{1,128})\] ')
39
+ _SHORT_RE = re.compile(r' ([^\s"]{1,64}) ([^\s"]{1,64})')
40
+ _REQUEST_RE = re.compile(r"([^ ]{1,64}) ([^ ]{1,8192}) ([^ ]{1,64})")
41
+ _MAX_QUOTED = 16384
42
+ _LOG_ESCAPES = {'"': '"', "\\": "\\", "n": "\n", "t": "\t", "r": "\r"}
43
+ # Characters kept literally when percent-encoding a replacement (markers stay readable).
44
+ _URL_SAFE = frozenset(
45
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*,:@/[]"
46
+ )
47
+ _HEX = "0123456789abcdefABCDEF"
48
+
49
+
50
+ def _quoted(line: str, i: int) -> int | None:
51
+ """End offset of the quoted field whose opening quote is at ``i`` (after the closing quote)."""
52
+ if i >= len(line) or line[i] != '"':
53
+ return None
54
+ j, limit = i + 1, min(len(line), i + 1 + _MAX_QUOTED)
55
+ while j < limit:
56
+ ch = line[j]
57
+ if ch == '"':
58
+ return j + 1
59
+ j += 2 if ch == "\\" else 1
60
+ return None
61
+
62
+
63
+ def _log_unescape(raw: str) -> str:
64
+ """Undo nginx (``\\xHH``) and Apache (``\\"``, ``\\\\``, ``\\n``) log escapes."""
65
+ if "\\" not in raw:
66
+ return raw
67
+ out: list[str] = []
68
+ i, n = 0, len(raw)
69
+ while i < n:
70
+ ch = raw[i]
71
+ if ch == "\\" and i + 1 < n:
72
+ nxt = raw[i + 1]
73
+ if nxt == "x" and i + 3 < n and raw[i + 2] in _HEX and raw[i + 3] in _HEX:
74
+ out.append(chr(int(raw[i + 2 : i + 4], 16)))
75
+ i += 4
76
+ continue
77
+ if nxt in _LOG_ESCAPES:
78
+ out.append(_LOG_ESCAPES[nxt])
79
+ i += 2
80
+ continue
81
+ out.append(ch)
82
+ i += 1
83
+ return "".join(out)
84
+
85
+
86
+ def _percent_decode(text: str, *, plus: bool) -> str:
87
+ """Percent-decoding (``+`` is a space in query strings); invalid UTF-8 becomes U+FFFD."""
88
+ if "%" not in text and not (plus and "+" in text):
89
+ return text
90
+ data = bytearray()
91
+ i, n = 0, len(text)
92
+ while i < n:
93
+ ch = text[i]
94
+ if ch == "%" and i + 2 < n and text[i + 1] in _HEX and text[i + 2] in _HEX:
95
+ data.append(int(text[i + 1 : i + 3], 16))
96
+ i += 3
97
+ continue
98
+ data.extend(b" " if plus and ch == "+" else ch.encode("utf-8"))
99
+ i += 1
100
+ return data.decode("utf-8", errors="replace")
101
+
102
+
103
+ def _kind(pointer: str) -> str:
104
+ """How a field is encoded in the line, from its path."""
105
+ if "/query/" in pointer or pointer == "/request/path":
106
+ return "url"
107
+ if pointer == "/http_referer":
108
+ return "referer"
109
+ if pointer in ("/request/method", "/request/protocol"):
110
+ return "request_part"
111
+ if pointer in ("/request", "/http_user_agent"):
112
+ return "quoted"
113
+ if pointer == "/time_local":
114
+ return "time"
115
+ return "bare"
116
+
117
+
118
+ def _decode(pointer: str, raw: str) -> str:
119
+ text = _log_unescape(raw)
120
+ if _kind(pointer) == "url":
121
+ return _percent_decode(text, plus="/query/" in pointer)
122
+ return text
123
+
124
+
125
+ def _hex_escape(ch: str) -> str:
126
+ return "".join(f"\\x{b:02X}" for b in ch.encode("utf-8"))
127
+
128
+
129
+ def _escape(owner: FieldToken, text: str) -> str:
130
+ kind = _kind(owner.pointer)
131
+ if kind == "url":
132
+ return "".join(
133
+ ch if ch in _URL_SAFE else "".join(f"%{b:02X}" for b in ch.encode("utf-8"))
134
+ for ch in text
135
+ )
136
+ special = {
137
+ "quoted": '"\\',
138
+ "referer": '"\\',
139
+ "request_part": '"\\ ',
140
+ "time": '"\\]',
141
+ "bare": '"\\',
142
+ }[kind]
143
+ out: list[str] = []
144
+ for ch in text:
145
+ if ch in special or ord(ch) < 0x20 or ord(ch) == 0x7F or (kind == "bare" and ch.isspace()):
146
+ out.append(_hex_escape(ch))
147
+ elif kind == "referer" and ch in "?#":
148
+ out.append(f"%{ord(ch):02X}")
149
+ else:
150
+ out.append(ch)
151
+ return "".join(out)
152
+
153
+
154
+ def _unescape(owner: FieldToken, raw: str) -> Any:
155
+ return _decode(owner.pointer, raw)
156
+
157
+
158
+ def _field(
159
+ tokens: list[FieldToken], line: str, pointer: str, key: str, start: int, end: int
160
+ ) -> None:
161
+ raw = line[start:end]
162
+ if raw in ("", "-"):
163
+ return # "-" is the log's "no value"
164
+ value = _decode(pointer, raw)
165
+ tokens.append(FieldToken("string", start, end, pointer, key, value, value != raw))
166
+
167
+
168
+ def _query(tokens: list[FieldToken], base: str, line: str, start: int, end: int) -> None:
169
+ """``name=value&...`` between ``start`` and ``end`` as key and value tokens."""
170
+ i = start
171
+ while i <= end:
172
+ amp = line.find("&", i, end)
173
+ stop = end if amp < 0 else amp
174
+ if stop > i:
175
+ eq = line.find("=", i, stop)
176
+ key_end = stop if eq < 0 else eq
177
+ name_raw = line[i:key_end]
178
+ name = _percent_decode(_log_unescape(name_raw), plus=True)
179
+ pointer = f"{base}/query/{pointer_escape(name)}"
180
+ tokens.append(FieldToken("key", i, key_end, pointer, name, name, name != name_raw))
181
+ if eq >= 0:
182
+ raw = line[eq + 1 : stop]
183
+ value = _decode(pointer, raw)
184
+ tokens.append(
185
+ FieldToken("string", eq + 1, stop, pointer, name, value, value != raw)
186
+ )
187
+ i = stop + 1
188
+
189
+
190
+ def _target(tokens: list[FieldToken], line: str, start: int, end: int) -> None:
191
+ q = line.find("?", start, end)
192
+ _field(tokens, line, "/request/path", "path", start, end if q < 0 else q)
193
+ if q >= 0:
194
+ _query(tokens, "/request", line, q + 1, end)
195
+
196
+
197
+ def parse_regions(line: str, max_depth: int = 64) -> list[FieldRegion]:
198
+ """The access-log fields of ``line`` as one region, or ``[]`` if it is not in that layout."""
199
+ head = _HEAD_RE.match(line)
200
+ if head is None:
201
+ return []
202
+ request_end = _quoted(line, head.end())
203
+ if request_end is None:
204
+ return []
205
+ short = _SHORT_RE.match(line, request_end)
206
+ if short is None:
207
+ return []
208
+ tokens: list[FieldToken] = []
209
+ for group, name in enumerate(("remote_addr", "ident", "remote_user", "time_local"), 1):
210
+ _field(tokens, line, "/" + name, name, head.start(group), head.end(group))
211
+ req_start, req_end = head.end() + 1, request_end - 1
212
+ request = _REQUEST_RE.fullmatch(line, req_start, req_end)
213
+ if request is None:
214
+ _field(tokens, line, "/request", "request", req_start, req_end)
215
+ else:
216
+ _field(tokens, line, "/request/method", "method", request.start(1), request.end(1))
217
+ _target(tokens, line, request.start(2), request.end(2))
218
+ _field(tokens, line, "/request/protocol", "protocol", request.start(3), request.end(3))
219
+ _field(tokens, line, "/status", "status", short.start(1), short.end(1))
220
+ _field(tokens, line, "/body_bytes_sent", "body_bytes_sent", short.start(2), short.end(2))
221
+ end = short.end()
222
+ referer_end = _quoted(line, end + 1) if line.startswith(' "', end) else None
223
+ if referer_end is not None:
224
+ agent_end = _quoted(line, referer_end + 1) if line.startswith(' "', referer_end) else None
225
+ if agent_end is not None:
226
+ ref_start, ref_stop = end + 2, referer_end - 1
227
+ q = line.find("?", ref_start, ref_stop)
228
+ _field(
229
+ tokens, line, "/http_referer", "http_referer", ref_start, ref_stop if q < 0 else q
230
+ )
231
+ if q >= 0:
232
+ _query(tokens, "/http_referer", line, q + 1, ref_stop)
233
+ _field(
234
+ tokens, line, "/http_user_agent", "http_user_agent", referer_end + 2, agent_end - 1
235
+ )
236
+ end = agent_end
237
+ return [FieldRegion(0, end, tuple(tokens))]
238
+
239
+
240
+ ACCESSLOG_STRUCTURE = LineStructure(parse=parse_regions, unescape=_unescape, escape=_escape)
241
+
242
+
243
+ class AccessLogReader(TextReader):
244
+ format = InputFormat.ACCESSLOG
@@ -0,0 +1,131 @@
1
+ """Format adapter contracts shared by all readers/writers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import IO, Any, Protocol
10
+
11
+ from ciphergate.config import RuntimeSettings
12
+ from ciphergate.constants import InputFormat
13
+ from ciphergate.errors import InputFormatError, InputLimitError, UsageError
14
+ from ciphergate.models.finding import ScanItem
15
+ from ciphergate.models.policy import PolicySettings
16
+
17
+ # Readers split lines on bytes, so only ASCII-compatible encodings are supported.
18
+ SUPPORTED_ENCODINGS = frozenset({"utf-8", "utf-8-sig", "ascii", "latin-1", "cp1252"})
19
+
20
+
21
+ def normalize_encoding(name: str) -> str:
22
+ try:
23
+ canonical = codecs.lookup(name).name
24
+ except LookupError:
25
+ raise UsageError("unknown encoding", code="unsupported_encoding") from None
26
+ aliases = {
27
+ "utf-8": "utf-8",
28
+ "utf-8-sig": "utf-8-sig",
29
+ "ascii": "ascii",
30
+ "iso8859-1": "latin-1",
31
+ "latin-1": "latin-1",
32
+ "cp1252": "cp1252",
33
+ }
34
+ if canonical not in aliases:
35
+ raise UsageError(
36
+ "unsupported encoding; supported: " + ", ".join(sorted(SUPPORTED_ENCODINGS)),
37
+ code="unsupported_encoding",
38
+ )
39
+ return aliases[canonical]
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class Record:
44
+ index: int
45
+ line: int | None
46
+ data: Any
47
+ line_ending: str = ""
48
+ malformed: bool = False
49
+ error_code: str | None = None
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class ReadStats:
54
+ bytes_read: int = 0
55
+ records: int = 0
56
+ blank_lines: int = 0
57
+ parse_errors: list[tuple[int | None, str]] = field(default_factory=list)
58
+
59
+
60
+ class Reader(Protocol):
61
+ format: InputFormat
62
+ stats: ReadStats
63
+
64
+ def records(self) -> Iterator[Record]: ...
65
+
66
+ def iter_items(self, record: Record) -> Iterator[ScanItem]: ...
67
+
68
+
69
+ class Writer(Protocol):
70
+ def write_record(self, record: Record, data: Any) -> None: ...
71
+
72
+ def close(self) -> None: ...
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class ReaderOptions:
77
+ runtime: RuntimeSettings
78
+ policy_settings: PolicySettings
79
+ encoding: str = "utf-8"
80
+
81
+
82
+ def read_bounded_lines(
83
+ stream: IO[bytes], options: ReaderOptions, stats: ReadStats
84
+ ) -> Iterator[tuple[int, bytes]]:
85
+ """Yield (line_number, raw line bytes incl. terminator) enforcing per-line and total limits."""
86
+ max_line = options.runtime.max_record_bytes
87
+ line_no = 0
88
+ while True:
89
+ raw = stream.readline(max_line + 2)
90
+ if not raw:
91
+ return
92
+ line_no += 1
93
+ stats.bytes_read += len(raw)
94
+ if stats.bytes_read > options.runtime.max_input_bytes:
95
+ raise InputLimitError("input exceeds max_input_bytes", code="size_limit_exceeded")
96
+ content_len = len(raw.rstrip(b"\r\n"))
97
+ if content_len > max_line:
98
+ raise InputLimitError(
99
+ f"line {line_no} exceeds max_record_bytes ({max_line})", code="record_too_large"
100
+ )
101
+ yield line_no, raw
102
+
103
+
104
+ def split_line_ending(text: str) -> tuple[str, str]:
105
+ if text.endswith("\r\n"):
106
+ return text[:-2], "\r\n"
107
+ if text.endswith("\n") or text.endswith("\r"):
108
+ return text[:-1], text[-1]
109
+ return text, ""
110
+
111
+
112
+ def decode_line(raw: bytes, encoding: str, line_no: int, *, first: bool) -> str:
113
+ if first and raw.startswith(codecs.BOM_UTF8) and encoding.startswith("utf-8"):
114
+ raw = raw[len(codecs.BOM_UTF8) :]
115
+ try:
116
+ return raw.decode(encoding.replace("-sig", ""))
117
+ except UnicodeDecodeError:
118
+ raise InputFormatError(
119
+ f"line {line_no} is not valid {encoding}", code="decode_error"
120
+ ) from None
121
+
122
+
123
+ def has_utf8_bom(path: Path) -> bool:
124
+ with path.open("rb") as fh:
125
+ return fh.read(3) == codecs.BOM_UTF8
126
+
127
+
128
+ def detect_newline(path: Path) -> str:
129
+ with path.open("rb") as fh:
130
+ chunk = fh.read(64 * 1024)
131
+ return "\r\n" if b"\r\n" in chunk else "\n"