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,392 @@
1
+ """inspect / plan / sanitize / rescan orchestration.
2
+
3
+ Sanitize flow (apply mode):
4
+
5
+ 1. validate input and output paths (no aliasing, no accidental overwrite);
6
+ 2. pass one: full analysis and plan; stop here unless the plan status is ``ready``;
7
+ 3. pass two: re-read, re-decide (same engine) and transform into an ``AtomicOutput`` temp file;
8
+ 4. verify pass-two decision counts equal the plan (otherwise internal error, nothing published);
9
+ 5. residual rescan of the temp file (when enabled) — gating residuals withhold the output;
10
+ 6. verify the input file did not change during processing;
11
+ 7. atomically publish. Any failure or interruption removes the temp file.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import IO, Any
20
+
21
+ from ciphergate.config import RuntimeSettings
22
+ from ciphergate.constants import LINE_FORMATS, Action, InputFormat
23
+ from ciphergate.detectors.field_names import normalize_field_name
24
+ from ciphergate.errors import InputFormatError, InternalError, TransformError
25
+ from ciphergate.models.context import ContextInput
26
+ from ciphergate.models.report import RunReport, RunStatus
27
+ from ciphergate.pipeline.atomic_output import AtomicOutput
28
+ from ciphergate.pipeline.inspector import Analyzer, Evaluated, RunSetup, prepare_run
29
+ from ciphergate.pipeline.paths import display_path, file_digest, validate_output_path
30
+ from ciphergate.pipeline.planner import Analysis, analyze_file
31
+ from ciphergate.pipeline.rescan import rescan_path
32
+ from ciphergate.policy.validator import Policy
33
+ from ciphergate.readers import STRUCTURED_LINE_FORMATS, Reader, Record, make_writer
34
+ from ciphergate.readers.json_reader import pointer_escape
35
+ from ciphergate.readers.line_structure import (
36
+ FieldRegion,
37
+ SpanCodec,
38
+ region_tokens,
39
+ structure_still_valid,
40
+ )
41
+ from ciphergate.readers.logfmt_reader import parse_fields
42
+ from ciphergate.reporting.audit import build_report
43
+ from ciphergate.transforms.keys import KeyProvider
44
+ from ciphergate.transforms.registry import EntityLookup, Transformer
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class SanitizeOptions:
49
+ apply: bool = True
50
+ force: bool = False
51
+ keys: KeyProvider | None = None
52
+ format: InputFormat | None = None
53
+ runtime: RuntimeSettings | None = None
54
+
55
+
56
+ # --------------------------------------------------------------------------- public operations
57
+
58
+
59
+ def inspect_file(
60
+ path: str | Path,
61
+ policy: Policy,
62
+ context: ContextInput | None = None,
63
+ *,
64
+ runtime: RuntimeSettings | None = None,
65
+ fmt: InputFormat | None = None,
66
+ keys: KeyProvider | None = None,
67
+ ) -> RunReport:
68
+ """Detect and evaluate; never writes any data file."""
69
+ setup = prepare_run(path, policy, context, runtime, fmt)
70
+ analysis = analyze_file(setup, keys or KeyProvider())
71
+ return build_report("inspect", analysis)
72
+
73
+
74
+ def plan_file(
75
+ path: str | Path,
76
+ policy: Policy,
77
+ context: ContextInput | None = None,
78
+ *,
79
+ runtime: RuntimeSettings | None = None,
80
+ fmt: InputFormat | None = None,
81
+ keys: KeyProvider | None = None,
82
+ ) -> RunReport:
83
+ """Preview the decisions apply mode would execute; never writes any data file."""
84
+ setup = prepare_run(path, policy, context, runtime, fmt)
85
+ analysis = analyze_file(setup, keys or KeyProvider())
86
+ return build_report("plan", analysis, output=_withheld(None, analysis, "plan_only"))
87
+
88
+
89
+ def rescan_file(
90
+ path: str | Path,
91
+ policy: Policy,
92
+ context: ContextInput | None = None,
93
+ *,
94
+ strict: bool | None = None,
95
+ runtime: RuntimeSettings | None = None,
96
+ fmt: InputFormat | None = None,
97
+ ) -> RunReport:
98
+ """Report residual findings in an existing (sanitized) file. Read-only."""
99
+ setup = prepare_run(path, policy, context, runtime, fmt)
100
+ effective_strict = policy.document.settings.strict if strict is None else strict
101
+ analysis = analyze_file(setup, KeyProvider({}))
102
+ summary = rescan_path(setup, setup.input_path, strict=effective_strict)
103
+ if file_digest(setup.input_path) != analysis.digest:
104
+ raise InputFormatError("file changed while being rescanned", code="input_changed")
105
+ status = RunStatus.RESCAN_FAILED if summary.failed else RunStatus.PASSED
106
+ warnings = []
107
+ if not summary.failed and summary.counts.get("unexpected", 0):
108
+ warnings.append("unexpected residual findings present (not gating in this mode)")
109
+ analysis.key_statuses = {}
110
+ return build_report("rescan", analysis, status=status, rescan=summary, extra_warnings=warnings)
111
+
112
+
113
+ def sanitize_file(
114
+ input_path: str | Path,
115
+ output_path: str | Path,
116
+ policy: Policy,
117
+ context: ContextInput | None = None,
118
+ options: SanitizeOptions | None = None,
119
+ ) -> RunReport:
120
+ options = options or SanitizeOptions()
121
+ setup = prepare_run(input_path, policy, context, options.runtime, options.format)
122
+ out_path = Path(output_path)
123
+ validate_output_path(out_path, protected=[setup.input_path], force=options.force)
124
+ keys = options.keys or KeyProvider()
125
+ analysis = analyze_file(setup, keys)
126
+ shown = display_path(out_path, setup.runtime.path_display)
127
+ if not options.apply:
128
+ return build_report(
129
+ "sanitize", analysis, output=_withheld(shown, analysis, "apply_not_set")
130
+ )
131
+ if analysis.status != RunStatus.READY:
132
+ return build_report(
133
+ "sanitize", analysis, output=_withheld(shown, analysis, str(analysis.status))
134
+ )
135
+
136
+ encoding = setup.reader_options.encoding.replace("-sig", "")
137
+ with AtomicOutput(out_path, force=options.force, encoding=encoding) as out:
138
+ changes = _write_transformed(setup, analysis, out.stream, keys)
139
+ out.finish_writing()
140
+ rescan = None
141
+ if policy.document.settings.rescan.enabled:
142
+ if out.temp_path is None:
143
+ raise InternalError("temporary output missing", code="output_temp_missing")
144
+ rescan = rescan_path(setup, out.temp_path, strict=True)
145
+ if rescan.failed:
146
+ out.abort()
147
+ return build_report(
148
+ "sanitize",
149
+ analysis,
150
+ status=RunStatus.RESCAN_FAILED,
151
+ output=_withheld(shown, analysis, "residual_finding"),
152
+ changes=changes,
153
+ rescan=rescan,
154
+ )
155
+ if file_digest(setup.input_path) != analysis.digest:
156
+ raise InputFormatError(
157
+ "input changed during processing; output discarded", code="input_changed"
158
+ )
159
+ out.publish()
160
+ warnings = [] if rescan is not None else ["rescan gate disabled by policy"]
161
+ return build_report(
162
+ "sanitize",
163
+ analysis,
164
+ status=RunStatus.SANITIZED,
165
+ output={"written": True, "path": shown},
166
+ changes=changes,
167
+ rescan=rescan,
168
+ extra_warnings=warnings,
169
+ )
170
+
171
+
172
+ def _withheld(shown: str | None, analysis: Analysis, reason: str) -> dict[str, Any]:
173
+ return {"written": False, "path": shown, "withheld_reason": reason}
174
+
175
+
176
+ # --------------------------------------------------------------------------- pass two
177
+
178
+
179
+ def _write_transformed(
180
+ setup: RunSetup, analysis: Analysis, stream: IO[str], keys: KeyProvider
181
+ ) -> dict[str, Any]:
182
+ reader = setup.new_reader()
183
+ analyzer = Analyzer(setup, reader)
184
+ transformer = Transformer(setup.policy, keys)
185
+ writer = make_writer(stream, reader, set(analysis.acc.dropped_columns))
186
+ actions: Counter[str] = Counter()
187
+ stats = {
188
+ "records_written": 0,
189
+ "records_dropped": 0,
190
+ "malformed_records_dropped": 0,
191
+ "values_changed": 0,
192
+ "type_changes": 0,
193
+ }
194
+ for _, evaluated in analyzer.analyze_header():
195
+ for _, decision in evaluated:
196
+ actions[str(decision.action)] += 1
197
+ for record in reader.records():
198
+ if record.malformed:
199
+ stats["malformed_records_dropped"] += 1
200
+ continue
201
+ if record.index < 0:
202
+ writer.write_record(record, record.data)
203
+ continue
204
+ analyzed = analyzer.analyze(record)
205
+ for _, evaluated in analyzed:
206
+ for _, decision in evaluated:
207
+ actions[str(decision.action)] += 1
208
+ if any(d.action == Action.DROP_RECORD for _, ev in analyzed for _, d in ev):
209
+ stats["records_dropped"] += 1
210
+ continue
211
+ data = _transform_record(
212
+ setup.fmt, record, analyzed, transformer, stats, reader, setup.runtime.max_json_depth
213
+ )
214
+ writer.write_record(record, data)
215
+ stats["records_written"] += 1
216
+ writer.close()
217
+ if dict(actions) != dict(analysis.acc.by_action):
218
+ raise InternalError(
219
+ "apply-mode decisions differ from the plan; output discarded",
220
+ code="plan_apply_mismatch",
221
+ )
222
+ dropped = [analysis.acc.dropped_columns[i] for i in sorted(analysis.acc.dropped_columns)]
223
+ return {**stats, "dropped_columns": dropped, "schema_changed": bool(dropped)}
224
+
225
+
226
+ def _csv_text(value: Any) -> str:
227
+ if value is None:
228
+ return ""
229
+ if isinstance(value, bool):
230
+ return "true" if value else "false"
231
+ return str(value)
232
+
233
+
234
+ def _transform_record(
235
+ fmt: InputFormat,
236
+ record: Record,
237
+ analyzed: list[tuple[Any, Evaluated]],
238
+ transformer: Transformer,
239
+ stats: dict[str, int],
240
+ reader: Reader,
241
+ max_depth: int,
242
+ ) -> Any:
243
+ def note(changed: bool, type_changed: bool) -> None:
244
+ stats["values_changed"] += int(changed)
245
+ stats["type_changes"] += int(type_changed)
246
+
247
+ if fmt in LINE_FORMATS:
248
+ line = record.data
249
+ lookup: EntityLookup | None = None
250
+ codec: SpanCodec | None = None
251
+ regions: list[FieldRegion] = []
252
+ structure = STRUCTURED_LINE_FORMATS.get(fmt)
253
+ if fmt == InputFormat.LOGFMT:
254
+ lookup = _logfmt_lookup(line)
255
+ elif structure is not None:
256
+ regions = structure.parse(line, max_depth)
257
+ lookup, codec = _structured_lookup(regions), SpanCodec(structure, regions)
258
+ original = line
259
+ for item, evaluated in analyzed:
260
+ outcome = transformer.transform_value(
261
+ line, evaluated, field_path=item.field_path, entity_lookup=lookup, span_codec=codec
262
+ )
263
+ note(outcome.changed, False)
264
+ line = outcome.value
265
+ if (
266
+ structure is not None
267
+ and regions
268
+ and line != original
269
+ and not structure_still_valid(structure, line, regions, max_depth)
270
+ ):
271
+ raise TransformError(
272
+ f"rewriting a line would break its {fmt} structure; nothing was published",
273
+ code=f"{fmt}_rewrite_invalid",
274
+ )
275
+ return line
276
+
277
+ if fmt == InputFormat.CSV:
278
+ row = list(record.data)
279
+ lookup = _csv_lookup(record, getattr(reader, "field_names", []))
280
+ for item, evaluated in analyzed:
281
+ outcome = transformer.transform_value(
282
+ row[item.column_index], evaluated, field_path=item.field_path, entity_lookup=lookup
283
+ )
284
+ if not outcome.drop_field:
285
+ row[item.column_index] = _csv_text(outcome.value)
286
+ note(outcome.changed, False)
287
+ return row
288
+
289
+ values: dict[str, Evaluated] = {}
290
+ key_drops: set[str] = set()
291
+ for item, evaluated in analyzed:
292
+ if item.in_key:
293
+ if any(d.action == Action.DROP_FIELD for _, d in evaluated):
294
+ key_drops.add(item.field_path)
295
+ else:
296
+ values[item.field_path] = evaluated
297
+ prefix = f"/{record.index}" if getattr(reader, "top_level_array", False) else ""
298
+ lookup = _json_lookup(record.data)
299
+
300
+ def rebuild(node: Any, pointer: str) -> tuple[Any, bool]:
301
+ if isinstance(node, dict):
302
+ out: dict[str, Any] = {}
303
+ for key, value in node.items():
304
+ child = f"{pointer}/{pointer_escape(key)}"
305
+ if child in key_drops:
306
+ note(True, False)
307
+ continue
308
+ new_value, drop = rebuild(value, child)
309
+ if drop:
310
+ continue
311
+ out[key] = new_value
312
+ return out, False
313
+ if isinstance(node, list):
314
+ items_out = []
315
+ drop_any = False
316
+ for i, value in enumerate(node):
317
+ new_value, drop = rebuild(value, f"{pointer}/{i}")
318
+ drop_any = drop_any or drop
319
+ items_out.append(new_value)
320
+ return items_out, drop_any
321
+ evaluated = values.get(pointer)
322
+ if not evaluated:
323
+ return node, False
324
+ outcome = transformer.transform_value(
325
+ node, evaluated, field_path=pointer, entity_lookup=lookup
326
+ )
327
+ note(outcome.changed, outcome.type_changed)
328
+ if outcome.drop_field:
329
+ return node, True
330
+ return outcome.value, False
331
+
332
+ data, dropped_root = rebuild(record.data, prefix)
333
+ if dropped_root:
334
+ raise TransformError(
335
+ "drop_field cannot remove the root of a record", code="drop_field_unsupported"
336
+ )
337
+ return data
338
+
339
+
340
+ def _csv_lookup(record: Record, field_names: list[str | None]) -> EntityLookup:
341
+ columns: dict[str, Any] = {}
342
+ for i, name in enumerate(field_names):
343
+ if name is not None:
344
+ columns.setdefault(normalize_field_name(name), record.data[i])
345
+
346
+ def lookup(name: str) -> Any:
347
+ return columns.get(normalize_field_name(name))
348
+
349
+ return lookup
350
+
351
+
352
+ def _structured_lookup(regions: list[FieldRegion]) -> EntityLookup:
353
+ values: dict[str, Any] = {}
354
+ for token in region_tokens(regions):
355
+ if token.kind != "key" and token.key is not None:
356
+ values.setdefault(normalize_field_name(token.key), token.value)
357
+
358
+ def lookup(name: str) -> Any:
359
+ return values.get(normalize_field_name(name))
360
+
361
+ return lookup
362
+
363
+
364
+ def _logfmt_lookup(line: str) -> EntityLookup:
365
+ values: dict[str, str] = {}
366
+ for field in parse_fields(line) or []:
367
+ values.setdefault(normalize_field_name(field.key), field.value)
368
+
369
+ def lookup(name: str) -> Any:
370
+ return values.get(normalize_field_name(name))
371
+
372
+ return lookup
373
+
374
+
375
+ def _json_lookup(data: Any) -> EntityLookup:
376
+ def lookup(name: str) -> Any:
377
+ target = normalize_field_name(name)
378
+ stack = [data]
379
+ while stack:
380
+ node = stack.pop(0)
381
+ if isinstance(node, dict):
382
+ for key, value in node.items():
383
+ if (key == name or normalize_field_name(key) == target) and not isinstance(
384
+ value, dict | list
385
+ ):
386
+ return value
387
+ stack.extend(v for v in node.values() if isinstance(v, dict | list))
388
+ elif isinstance(node, list):
389
+ stack.extend(v for v in node if isinstance(v, dict | list))
390
+ return None
391
+
392
+ return lookup
@@ -0,0 +1,173 @@
1
+ """Standard input/output support for the CLI (``-`` as INPUT or ``--output``).
2
+
3
+ The pipeline reads its input twice (plan, then apply) and verifies it did not change, and it
4
+ publishes output only after every check passes. A pipe allows neither, so:
5
+
6
+ * stdin is spooled into a private workspace (``mkdtemp``: owner-only on POSIX, the user's temp
7
+ directory ACL on Windows) as a ``0600`` file, capped at ``max_input_bytes`` while reading;
8
+ * ``--output -`` is sanitized into the same workspace and copied to stdout only after the run
9
+ succeeded, so a blocked, review or failed run writes nothing to stdout;
10
+ * the workspace is removed on success, failure and interruption. A hard kill (``SIGKILL``,
11
+ power loss) can leave it behind in the temp directory; the name is ``ciphergate-stdio-*``.
12
+
13
+ The spool holds raw input for the duration of the run, exactly like the input file would.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import logging
20
+ import os
21
+ import shutil
22
+ import tempfile
23
+ from pathlib import Path
24
+ from types import TracebackType
25
+ from typing import IO
26
+
27
+ from ciphergate.constants import InputFormat
28
+ from ciphergate.errors import InputLimitError, OutputWriteError, UsageError
29
+ from ciphergate.reporting.safe_text import escape_control
30
+
31
+ logger = logging.getLogger("ciphergate")
32
+
33
+ STDIO = "-"
34
+ STDIN_DISPLAY = "<stdin>"
35
+ STDOUT_DISPLAY = "<stdout>"
36
+ _CHUNK = 1024 * 1024
37
+ _SUFFIX = {
38
+ InputFormat.CSV: ".csv",
39
+ InputFormat.JSON: ".json",
40
+ InputFormat.JSONL: ".jsonl",
41
+ InputFormat.TEXT: ".txt",
42
+ InputFormat.LOGFMT: ".log",
43
+ InputFormat.JSONLOG: ".log",
44
+ InputFormat.ACCESSLOG: ".log",
45
+ InputFormat.SYSLOG: ".log",
46
+ }
47
+
48
+
49
+ def is_stdio(path: Path | None) -> bool:
50
+ return path is not None and str(path) == STDIO
51
+
52
+
53
+ class StdioWorkspace:
54
+ """Private temporary directory holding the stdin spool and the stdout staging file."""
55
+
56
+ def __init__(self) -> None:
57
+ self.root: Path | None = None
58
+
59
+ def __enter__(self) -> StdioWorkspace:
60
+ try:
61
+ self.root = Path(tempfile.mkdtemp(prefix="ciphergate-stdio-"))
62
+ except OSError:
63
+ raise OutputWriteError(
64
+ "cannot create a private temporary workspace for stdin/stdout",
65
+ code="stdio_workspace_failed",
66
+ ) from None
67
+ with contextlib.suppress(OSError):
68
+ os.chmod(self.root, 0o700)
69
+ return self
70
+
71
+ def _dir(self) -> Path:
72
+ if self.root is None:
73
+ raise OutputWriteError("stdio workspace is not open", code="stdio_workspace_failed")
74
+ return self.root
75
+
76
+ def spool_input(
77
+ self, stream: IO[bytes], fmt: InputFormat, max_bytes: int, *, label: str = "stdin"
78
+ ) -> Path:
79
+ """Copy ``stream`` into the workspace; fail (never truncate) beyond ``max_bytes``."""
80
+ path = self._dir() / f"input{_SUFFIX[fmt]}"
81
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
82
+ total = 0
83
+ try:
84
+ with os.fdopen(os.open(path, flags, 0o600), "wb") as out:
85
+ while True:
86
+ chunk = stream.read(_CHUNK)
87
+ if not chunk:
88
+ break
89
+ if not isinstance(chunk, bytes | bytearray | memoryview):
90
+ raise UsageError(
91
+ f"{label} must be a binary stream (open it with 'rb')",
92
+ code="stream_not_binary",
93
+ )
94
+ total += len(chunk)
95
+ if total > max_bytes:
96
+ raise InputLimitError(
97
+ f"{label} exceeds max_input_bytes ({max_bytes})",
98
+ code="size_limit_exceeded",
99
+ )
100
+ out.write(chunk)
101
+ except OSError:
102
+ raise OutputWriteError(
103
+ f"cannot spool {label} into the temporary workspace", code="stdin_spool_failed"
104
+ ) from None
105
+ return path
106
+
107
+ def output_path(self) -> Path:
108
+ return self._dir() / "sanitized.out"
109
+
110
+ def __exit__(
111
+ self,
112
+ exc_type: type[BaseException] | None,
113
+ exc: BaseException | None,
114
+ tb: TracebackType | None,
115
+ ) -> None:
116
+ if self.root is None:
117
+ return
118
+ shutil.rmtree(self.root, ignore_errors=True)
119
+ if self.root.exists():
120
+ logger.warning(
121
+ "could not remove temporary stdin/stdout workspace %s; delete it manually",
122
+ escape_control(str(self.root)),
123
+ )
124
+
125
+
126
+ def require_stdin_format(fmt: InputFormat | None, stdin: IO[bytes]) -> InputFormat:
127
+ if fmt is None:
128
+ raise UsageError(
129
+ "reading stdin ('-') requires --format " + "|".join(InputFormat),
130
+ code="format_required",
131
+ )
132
+ if stdin.isatty():
133
+ raise UsageError(
134
+ "INPUT is '-' but stdin is a terminal; pipe or redirect the data in",
135
+ code="stdin_is_terminal",
136
+ )
137
+ return fmt
138
+
139
+
140
+ def copy_to_stdout(path: Path, stdout: IO[bytes]) -> None:
141
+ """Write the published staging file to stdout. A closed pipe is an output error.
142
+
143
+ Once copying starts, data may already be downstream, so an interruption here must not be
144
+ reported as "no output was published" (the generic Ctrl-C message): it is an output error.
145
+ """
146
+ try:
147
+ with path.open("rb") as fh:
148
+ shutil.copyfileobj(fh, stdout, _CHUNK)
149
+ stdout.flush()
150
+ except KeyboardInterrupt:
151
+ raise OutputWriteError(
152
+ "interrupted while writing to stdout; the output received downstream is incomplete",
153
+ code="stdout_interrupted",
154
+ ) from None
155
+ except BrokenPipeError:
156
+ silence_stdout()
157
+ raise OutputWriteError(
158
+ "stdout was closed before the sanitized output was fully written; "
159
+ "the output received downstream is incomplete",
160
+ code="stdout_broken_pipe",
161
+ ) from None
162
+ except OSError:
163
+ raise OutputWriteError(
164
+ "failed to write the output to stdout", code="stdout_failed"
165
+ ) from None
166
+
167
+
168
+ def silence_stdout() -> None:
169
+ """Point fd 1 at devnull so the interpreter's exit-time flush cannot raise again."""
170
+ with contextlib.suppress(OSError, ValueError, AttributeError):
171
+ devnull = os.open(os.devnull, os.O_WRONLY)
172
+ os.dup2(devnull, 1)
173
+ os.close(devnull)