codereview-loop 0.13.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.
codereview/feedback.py ADDED
@@ -0,0 +1,416 @@
1
+ """User feedback — release-gate row L19. Opt-in only.
2
+
3
+ Nothing in this module runs, or sends anything, unless the user
4
+ asks for it:
5
+
6
+ codereview report build the redacted support bundle, show
7
+ exactly what it holds, and send it only
8
+ after the user confirms (or passes --yes);
9
+ codereview telemetry enable | disable | status for the optional
10
+ anonymous crash ping. OFF by default.
11
+
12
+ The crash ping carries a fixed, minimal field set (see
13
+ `ping_payload`) and never code, diffs, findings, paths, repository
14
+ names, policy contents, environment values or hostnames. The
15
+ random install id is generated when the user enables the ping and
16
+ is deleted when they disable it.
17
+
18
+ Rules that protect the gate (pinned by tests):
19
+ * sending never changes an exit code, never raises, and uses one
20
+ short attempt;
21
+ * HTTPS only, certificate verification on, no redirects; the one
22
+ exception is plain http to a loopback host, which exists so the
23
+ client can be tested against a local server;
24
+ * the endpoint is the owner's, set in ENDPOINT at release; the
25
+ user can override it with CODEREVIEW_FEEDBACK_URL. While no
26
+ endpoint is configured nothing can be sent, and the report
27
+ command says so and shows the manual route;
28
+ * hooks, probes, evidence commands and agent seats run with a
29
+ scrubbed environment and a relocated HOME, so they find no
30
+ telemetry state and cannot send.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import datetime
36
+ import hashlib
37
+ import ipaddress
38
+ import json
39
+ import os
40
+ import sys
41
+ import urllib.error
42
+ import urllib.parse
43
+ import urllib.request
44
+ import uuid
45
+
46
+ from . import providers as _providers
47
+ from .core import VERSION
48
+
49
+ PING_SCHEMA = "codereview.crash-ping/1"
50
+ ENDPOINT = "https://phpatch.com/codereview/report" # owner, at release
51
+ ISSUES_URL = "https://gitlab.com/superstartelecom/codereview/-/issues"
52
+ TIMEOUT_S = 3.0
53
+ DAILY_CAP = 5
54
+ _MAX_RESPONSE = 65536
55
+
56
+ PING_FIELDS = ("schema", "version", "git_hash", "python",
57
+ "platform", "machine", "command", "exception_type",
58
+ "top_frame", "exit_code", "install_id")
59
+
60
+ _OFF = ("0", "false", "off", "no")
61
+ _ON = ("1", "true", "on", "yes")
62
+
63
+
64
+ # ------------------------------------------------------------ state
65
+
66
+ def state_dir(environ=None):
67
+ environ = os.environ if environ is None else environ
68
+ override = environ.get("CODEREVIEW_STATE_DIR", "").strip()
69
+ if override:
70
+ return override
71
+ xdg = environ.get("XDG_STATE_HOME", "").strip()
72
+ base = xdg or os.path.join(environ.get("HOME", "~"), ".local",
73
+ "state")
74
+ return os.path.join(base, "codereview")
75
+
76
+
77
+ def _state_path(environ=None):
78
+ return os.path.join(state_dir(environ), "telemetry.json")
79
+
80
+
81
+ def _ratelimit_path(environ=None):
82
+ return os.path.join(state_dir(environ), "ping-limit.json")
83
+
84
+
85
+ def _read_json(path):
86
+ try:
87
+ with open(path, encoding="utf-8") as fh:
88
+ data = json.load(fh)
89
+ return data if isinstance(data, dict) else {}
90
+ except (OSError, ValueError):
91
+ return {}
92
+
93
+
94
+ def _write_json(path, data):
95
+ os.makedirs(os.path.dirname(path), exist_ok=True)
96
+ tmp = f"{path}.tmp-{os.getpid()}"
97
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
98
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
99
+ json.dump(data, fh, indent=2, sort_keys=True)
100
+ fh.write("\n")
101
+ os.replace(tmp, path)
102
+
103
+
104
+ def telemetry_mode(environ=None):
105
+ """-> ("off"|"on", why). The environment variable, when set to
106
+ an off value, always wins over any stored consent."""
107
+ environ = os.environ if environ is None else environ
108
+ raw = environ.get("CODEREVIEW_TELEMETRY", "").strip().lower()
109
+ if raw in _OFF:
110
+ return "off", "CODEREVIEW_TELEMETRY is set to off"
111
+ if raw in _ON:
112
+ return "on", "CODEREVIEW_TELEMETRY is set to on"
113
+ state = _read_json(_state_path(environ))
114
+ if state.get("enabled") is True and state.get("install_id"):
115
+ return "on", "enabled with 'codereview telemetry enable'"
116
+ return "off", "not enabled (the default)"
117
+
118
+
119
+ def install_id(environ=None):
120
+ state = _read_json(_state_path(environ))
121
+ value = state.get("install_id")
122
+ return value if isinstance(value, str) and value else None
123
+
124
+
125
+ # --------------------------------------------------------- endpoint
126
+
127
+ def resolve_endpoint(environ=None):
128
+ """-> (url or None, problem or None)."""
129
+ environ = os.environ if environ is None else environ
130
+ url = environ.get("CODEREVIEW_FEEDBACK_URL", "").strip() \
131
+ or ENDPOINT
132
+ if not url:
133
+ return None, "no feedback endpoint is configured in this build"
134
+ problem = _endpoint_problem(url)
135
+ return (None, problem) if problem else (url, None)
136
+
137
+
138
+ def _is_loopback(host):
139
+ if host in ("localhost",):
140
+ return True
141
+ try:
142
+ return ipaddress.ip_address(host).is_loopback
143
+ except ValueError:
144
+ return False
145
+
146
+
147
+ def _endpoint_problem(url):
148
+ parts = urllib.parse.urlsplit(url)
149
+ if parts.scheme == "https" and parts.hostname:
150
+ return None
151
+ if parts.scheme == "http" and parts.hostname \
152
+ and _is_loopback(parts.hostname):
153
+ return None
154
+ return ("the feedback endpoint must be https:// "
155
+ "(plain http is accepted only for a loopback host)")
156
+
157
+
158
+ def _post(url, body, content_type, headers=None, timeout=TIMEOUT_S):
159
+ """One POST, no redirects, bounded response. -> (status, text).
160
+ Raises on network failure; callers swallow it."""
161
+ opener = urllib.request.build_opener(_providers._NoRedirect())
162
+ req_headers = {"Content-Type": content_type,
163
+ "User-Agent": f"codereview/{VERSION}"}
164
+ req_headers.update(headers or {})
165
+ req = urllib.request.Request(url, data=body, headers=req_headers,
166
+ method="POST")
167
+ try:
168
+ with opener.open(req, timeout=timeout) as resp:
169
+ text = resp.read(_MAX_RESPONSE).decode("utf-8", "replace")
170
+ return resp.status, text
171
+ except urllib.error.HTTPError as exc:
172
+ return exc.code, ""
173
+
174
+
175
+ # ------------------------------------------------------- crash ping
176
+
177
+ def _top_frame(exc):
178
+ """module:function of the innermost frame inside this package,
179
+ or 'external'. Never a path, a line number or source text."""
180
+ import traceback
181
+ try:
182
+ frames = traceback.extract_tb(exc.__traceback__)
183
+ except Exception: # noqa: BLE001
184
+ return "unknown"
185
+ package_dir = os.path.dirname(os.path.abspath(__file__))
186
+ for frame in reversed(frames):
187
+ try:
188
+ if os.path.abspath(frame.filename).startswith(package_dir):
189
+ module = os.path.splitext(
190
+ os.path.basename(frame.filename))[0]
191
+ return f"codereview.{module}:{frame.name}"
192
+ except Exception: # noqa: BLE001
193
+ continue
194
+ return "external"
195
+
196
+
197
+ def ping_payload(note, exc, install):
198
+ payload = {
199
+ "schema": PING_SCHEMA,
200
+ "version": note.get("version"),
201
+ "git_hash": note.get("git_hash"),
202
+ "python": note.get("python"),
203
+ "platform": note.get("platform"),
204
+ "machine": note.get("machine"),
205
+ "command": note.get("command"),
206
+ "exception_type": note.get("exception_type"),
207
+ "top_frame": _top_frame(exc),
208
+ "exit_code": 2,
209
+ "install_id": install,
210
+ }
211
+ return {k: payload[k] for k in PING_FIELDS}
212
+
213
+
214
+ def _today():
215
+ return datetime.datetime.now(datetime.timezone.utc) \
216
+ .strftime("%Y-%m-%d")
217
+
218
+
219
+ def _allow_by_rate_limit(signature, environ=None):
220
+ """At most one ping per signature per day and DAILY_CAP a day.
221
+ Records the attempt. Returns False when limited."""
222
+ path = _ratelimit_path(environ)
223
+ data = _read_json(path)
224
+ today = _today()
225
+ if data.get("day") != today:
226
+ data = {"day": today, "count": 0, "seen": []}
227
+ seen = data.get("seen") if isinstance(data.get("seen"), list) \
228
+ else []
229
+ if signature in seen or int(data.get("count") or 0) >= DAILY_CAP:
230
+ return False
231
+ data["seen"] = seen + [signature]
232
+ data["count"] = int(data.get("count") or 0) + 1
233
+ try:
234
+ _write_json(path, data)
235
+ except OSError:
236
+ return False
237
+ return True
238
+
239
+
240
+ def maybe_send_crash_ping(note, exc, environ=None):
241
+ """Send the minimal ping if (and only if) the user enabled it.
242
+ Never raises; returns True when a request was attempted."""
243
+ try:
244
+ mode, _why = telemetry_mode(environ)
245
+ if mode != "on":
246
+ return False
247
+ url, problem = resolve_endpoint(environ)
248
+ if problem:
249
+ return False
250
+ install = install_id(environ) or str(uuid.uuid4())
251
+ payload = ping_payload(note, exc, install)
252
+ signature = hashlib.sha256(
253
+ f"{payload['exception_type']}|{payload['top_frame']}"
254
+ .encode()).hexdigest()[:16]
255
+ if not _allow_by_rate_limit(signature, environ):
256
+ return False
257
+ _post(url, json.dumps(payload).encode("utf-8"),
258
+ "application/json")
259
+ return True
260
+ except Exception: # noqa: BLE001 — feedback never breaks a run
261
+ return False
262
+
263
+
264
+ # ------------------------------------------------ telemetry command
265
+
266
+ def _confirm(prompt, assume_yes, stdin=None):
267
+ if assume_yes:
268
+ return True
269
+ stdin = sys.stdin if stdin is None else stdin
270
+ try:
271
+ interactive = stdin.isatty()
272
+ except Exception: # noqa: BLE001
273
+ interactive = False
274
+ if not interactive:
275
+ return False
276
+ try:
277
+ return input(prompt).strip().lower() in ("y", "yes")
278
+ except EOFError:
279
+ return False
280
+
281
+
282
+ FIELDS_TEXT = (
283
+ "The crash ping sends only: product version, git hash, Python "
284
+ "version, OS family and architecture, command name, exception "
285
+ "type, the module and function where the crash happened, the "
286
+ "exit code, and a random install id. It never sends code, "
287
+ "diffs, findings, file paths, repository or branch names, "
288
+ "policy contents, environment values, host or user names. "
289
+ "At most one ping per kind of crash per day.")
290
+
291
+
292
+ def telemetry_main(argv, environ=None, stdin=None):
293
+ import argparse
294
+ ap = argparse.ArgumentParser(
295
+ prog="codereview telemetry",
296
+ description="Opt-in anonymous crash ping. Off by default.")
297
+ ap.add_argument("action", choices=("enable", "disable", "status"))
298
+ ap.add_argument("--yes", action="store_true",
299
+ help="confirm without prompting (scripts)")
300
+ args = ap.parse_args(argv)
301
+ environ = os.environ if environ is None else environ
302
+ if args.action == "status":
303
+ mode, why = telemetry_mode(environ)
304
+ url, problem = resolve_endpoint(environ)
305
+ print(f"crash ping: {mode} ({why})")
306
+ print("endpoint: " + (url if url else f"none ({problem})"))
307
+ print(FIELDS_TEXT)
308
+ return 0
309
+ if args.action == "disable":
310
+ removed = False
311
+ for path in (_state_path(environ), _ratelimit_path(environ)):
312
+ try:
313
+ os.unlink(path)
314
+ removed = True
315
+ except OSError:
316
+ pass
317
+ print("crash ping disabled; the install id was deleted."
318
+ if removed else "crash ping was not enabled.")
319
+ return 0
320
+ print(FIELDS_TEXT)
321
+ if not _confirm("Enable the crash ping? [y/N] ", args.yes, stdin):
322
+ print("codereview telemetry: not enabled (confirm at a "
323
+ "prompt, or pass --yes).", file=sys.stderr)
324
+ return 2
325
+ try:
326
+ _write_json(_state_path(environ), {
327
+ "enabled": True, "install_id": str(uuid.uuid4()),
328
+ "enabled_at": _today()})
329
+ except OSError as exc:
330
+ print(f"codereview telemetry: cannot save the setting: "
331
+ f"{exc}", file=sys.stderr)
332
+ return 2
333
+ print("crash ping enabled. Turn it off any time with "
334
+ "'codereview telemetry disable'.")
335
+ return 0
336
+
337
+
338
+ # ----------------------------------------------------- report command
339
+
340
+ def report_main(argv, environ=None, stdin=None):
341
+ import argparse
342
+ import tempfile
343
+ from . import support
344
+ ap = argparse.ArgumentParser(
345
+ prog="codereview report",
346
+ description="Send a redacted bug report. Nothing is sent "
347
+ "until you have seen what it holds and "
348
+ "confirmed.")
349
+ ap.add_argument("--message", default="",
350
+ help="what went wrong, in your words")
351
+ ap.add_argument("--records", default=None,
352
+ help="directory holding the run records "
353
+ "(default: ./codereview-out, then .)")
354
+ ap.add_argument("--no-records", action="store_true",
355
+ help="leave run records out of the report")
356
+ ap.add_argument("--yes", action="store_true",
357
+ help="send without prompting (scripts)")
358
+ ap.add_argument("--show", action="store_true",
359
+ help="print the full contents that would be sent")
360
+ args = ap.parse_args(argv)
361
+ environ = os.environ if environ is None else environ
362
+ with tempfile.TemporaryDirectory(prefix="codereview-report-") \
363
+ as tmp:
364
+ path = os.path.join(tmp, "report.zip")
365
+ try:
366
+ manifest = support.build_bundle(
367
+ path, records_dir=args.records,
368
+ include_records=not args.no_records,
369
+ message=args.message, environ=environ)
370
+ except Exception as exc: # noqa: BLE001
371
+ print(f"codereview report: could not build the report: "
372
+ f"{exc}", file=sys.stderr)
373
+ return 2
374
+ print(support.describe(manifest, show=args.show, path=path))
375
+ print(support.WARNING)
376
+ url, problem = resolve_endpoint(environ)
377
+ if url is None:
378
+ print(f"codereview report: nothing was sent — {problem}.",
379
+ file=sys.stderr)
380
+ _print_manual_route()
381
+ return 2
382
+ if not _confirm(f"Send this report to {url}? [y/N] ",
383
+ args.yes, stdin):
384
+ print("codereview report: nothing was sent (confirm at "
385
+ "a prompt, or pass --yes).", file=sys.stderr)
386
+ return 2
387
+ try:
388
+ with open(path, "rb") as fh:
389
+ body = fh.read()
390
+ status, text = _post(url, body, "application/zip",
391
+ {"X-CodeReview-Version": VERSION},
392
+ timeout=15.0)
393
+ except Exception as exc: # noqa: BLE001
394
+ print(f"codereview report: sending failed ({type(exc).__name__})"
395
+ f"; nothing was received.", file=sys.stderr)
396
+ _print_manual_route()
397
+ return 2
398
+ if 200 <= status < 300:
399
+ rid = ""
400
+ try:
401
+ rid = str(json.loads(text).get("id") or "")
402
+ except (ValueError, AttributeError):
403
+ pass
404
+ print("report sent" + (f" (id {rid})." if rid else "."))
405
+ return 0
406
+ print(f"codereview report: the server answered {status}; "
407
+ f"nothing was received.", file=sys.stderr)
408
+ _print_manual_route()
409
+ return 2
410
+
411
+
412
+ def _print_manual_route():
413
+ where = ISSUES_URL or "the project's issue tracker (see README)"
414
+ print(f"You can still report by hand: run 'codereview "
415
+ f"support-bundle', read the file, and attach it at {where}. "
416
+ f"Security problems: see SECURITY.md.", file=sys.stderr)