hexproxy 0.2.2__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.
hexproxy/store.py ADDED
@@ -0,0 +1,1001 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import tempfile
12
+ from threading import Event, Lock
13
+ from urllib.parse import urlsplit
14
+
15
+ from .models import MatchReplaceRule, RequestData, ResponseData, TrafficEntry
16
+
17
+
18
+ PROJECT_VERSION = 1
19
+ INTERCEPT_MODES = ("off", "request", "response", "both")
20
+ VIEW_FILTER_QUERY_MODES = ("all", "with_query", "without_query")
21
+ VIEW_FILTER_FAILURE_MODES = ("all", "failures", "hide_failures", "client_errors", "server_errors", "connection_errors")
22
+ VIEW_FILTER_BODY_MODES = ("all", "with_body", "without_body")
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class PendingInterception:
27
+ record_id: int
28
+ entry_id: int
29
+ phase: str
30
+ raw_text: str
31
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
32
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
33
+ decision: str = "pending"
34
+ active: bool = True
35
+ event: Event = field(default_factory=Event, repr=False)
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class PendingInterceptionView:
40
+ record_id: int
41
+ entry_id: int
42
+ phase: str
43
+ raw_text: str
44
+ created_at: datetime
45
+ updated_at: datetime
46
+ decision: str
47
+ active: bool
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class InterceptionResult:
52
+ entry_id: int
53
+ phase: str
54
+ decision: str
55
+ raw_text: str
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class ViewFilterSettings:
60
+ show_out_of_scope: bool = False
61
+ query_mode: str = "all"
62
+ failure_mode: str = "all"
63
+ body_mode: str = "all"
64
+ methods: list[str] = field(default_factory=list)
65
+ hidden_methods: list[str] = field(default_factory=list)
66
+ hidden_extensions: list[str] = field(default_factory=list)
67
+
68
+
69
+ class TrafficStore:
70
+ def __init__(self, project_path: str | Path | None = None) -> None:
71
+ self._lock = Lock()
72
+ self._entries: list[TrafficEntry] = []
73
+ self._next_id = 1
74
+ self._project_path: Path | None = None
75
+ self._last_save_at: datetime | None = None
76
+ self._last_save_error = ""
77
+ self._intercept_mode = "off"
78
+ self._pending_interceptions: dict[int, PendingInterception] = {}
79
+ self._interception_log: list[PendingInterception] = []
80
+ self._next_interception_id = 1
81
+ self._match_replace_rules: list[MatchReplaceRule] = []
82
+ self._scope_hosts: list[str] = []
83
+ self._view_filters = ViewFilterSettings()
84
+ self._keybindings: dict[str, str] = {}
85
+ self._plugin_state: dict[str, dict[str, object]] = {}
86
+ if project_path is not None:
87
+ self.set_project_path(project_path)
88
+
89
+ def create_entry(self, client_addr: str) -> int:
90
+ project = None
91
+ with self._lock:
92
+ entry_id = self._next_id
93
+ self._next_id += 1
94
+ self._entries.append(TrafficEntry(id=entry_id, client_addr=client_addr))
95
+ project = self._build_project_locked()
96
+ self._autosave(project)
97
+ return entry_id
98
+
99
+ def mutate(self, entry_id: int, updater) -> None:
100
+ project = None
101
+ with self._lock:
102
+ entry = self._find_locked(entry_id)
103
+ updater(entry)
104
+ project = self._build_project_locked()
105
+ self._autosave(project)
106
+
107
+ def complete(self, entry_id: int) -> None:
108
+ finished_at = datetime.now(timezone.utc)
109
+
110
+ def _update(entry: TrafficEntry) -> None:
111
+ entry.finished_at = finished_at
112
+ entry.duration_ms = (finished_at - entry.started_at).total_seconds() * 1000
113
+ if entry.state == "pending":
114
+ entry.state = "complete"
115
+
116
+ self.mutate(entry_id, _update)
117
+
118
+ def snapshot(self) -> list[TrafficEntry]:
119
+ with self._lock:
120
+ return deepcopy(self._entries)
121
+
122
+ def visible_entries(self, scope_only: bool | None = None) -> list[TrafficEntry]:
123
+ with self._lock:
124
+ filters = deepcopy(self._view_filters)
125
+ if scope_only is not None:
126
+ filters.show_out_of_scope = not scope_only
127
+ visible = [entry for entry in self._entries if self._entry_visible_locked(entry, filters)]
128
+ return deepcopy(visible)
129
+
130
+ def save(self, path: str | Path | None = None) -> Path:
131
+ if path is not None:
132
+ self.set_project_path(path)
133
+ with self._lock:
134
+ if self._project_path is None:
135
+ raise ValueError("project path is not configured")
136
+ project_path = self._project_path
137
+ payload = self._build_project_locked()
138
+ if payload is None:
139
+ raise ValueError("project path is not configured")
140
+ self._write_project(project_path, payload)
141
+ return project_path
142
+
143
+ def load(self, path: str | Path) -> int:
144
+ project_path = Path(path)
145
+ payload = json.loads(project_path.read_text(encoding="utf-8"))
146
+ if payload.get("version") != PROJECT_VERSION:
147
+ raise ValueError(f"unsupported project version: {payload.get('version')!r}")
148
+
149
+ entries = [self._entry_from_dict(item) for item in payload.get("entries", [])]
150
+ next_id = max((entry.id for entry in entries), default=0) + 1
151
+ saved_at = payload.get("saved_at")
152
+
153
+ with self._lock:
154
+ self._entries = entries
155
+ self._next_id = max(next_id, int(payload.get("next_id", next_id)))
156
+ self._project_path = project_path
157
+ self._last_save_error = ""
158
+ self._last_save_at = self._parse_datetime(saved_at) if saved_at else None
159
+ self._pending_interceptions = {}
160
+ self._interception_log = []
161
+ self._next_interception_id = 1
162
+ self._match_replace_rules = self._rules_from_list(payload.get("match_replace_rules", []))
163
+ self._scope_hosts = self._scope_hosts_from_list(payload.get("scope_hosts", []))
164
+ self._view_filters = self._view_filters_from_dict(payload.get("view_filters", {}))
165
+ self._keybindings = self._keybindings_from_dict(payload.get("keybindings", {}))
166
+ self._plugin_state = self._plugin_state_from_dict(payload.get("plugin_state", {}))
167
+ return len(entries)
168
+
169
+ def set_project_path(self, path: str | Path) -> None:
170
+ with self._lock:
171
+ self._project_path = Path(path)
172
+
173
+ def project_path(self) -> Path | None:
174
+ with self._lock:
175
+ return self._project_path
176
+
177
+ def save_status(self) -> tuple[datetime | None, str]:
178
+ with self._lock:
179
+ return self._last_save_at, self._last_save_error
180
+
181
+ def get(self, entry_id: int) -> TrafficEntry | None:
182
+ with self._lock:
183
+ for entry in self._entries:
184
+ if entry.id == entry_id:
185
+ return deepcopy(entry)
186
+ return None
187
+
188
+ def count(self) -> int:
189
+ with self._lock:
190
+ return len(self._entries)
191
+
192
+ def match_replace_rules(self) -> list[MatchReplaceRule]:
193
+ with self._lock:
194
+ return deepcopy(self._match_replace_rules)
195
+
196
+ def set_match_replace_rules(self, rules: list[MatchReplaceRule]) -> None:
197
+ self._validate_match_replace_rules(rules)
198
+ project = None
199
+ with self._lock:
200
+ self._match_replace_rules = deepcopy(rules)
201
+ project = self._build_project_locked()
202
+ self._autosave(project)
203
+
204
+ def scope_hosts(self) -> list[str]:
205
+ with self._lock:
206
+ return list(self._scope_hosts)
207
+
208
+ def set_scope_hosts(self, hosts: list[str]) -> None:
209
+ normalized_hosts = self._scope_hosts_from_list(hosts)
210
+ project = None
211
+ with self._lock:
212
+ self._scope_hosts = normalized_hosts
213
+ project = self._build_project_locked()
214
+ self._autosave(project)
215
+
216
+ def view_filters(self) -> ViewFilterSettings:
217
+ with self._lock:
218
+ return deepcopy(self._view_filters)
219
+
220
+ def set_view_filters(self, filters: ViewFilterSettings) -> None:
221
+ normalized = self._normalize_view_filters(filters)
222
+ project = None
223
+ with self._lock:
224
+ self._view_filters = normalized
225
+ project = self._build_project_locked()
226
+ self._autosave(project)
227
+
228
+ def keybindings(self) -> dict[str, str]:
229
+ with self._lock:
230
+ return dict(self._keybindings)
231
+
232
+ def set_keybindings(self, bindings: dict[str, str]) -> None:
233
+ normalized = self._keybindings_from_dict(bindings)
234
+ project = None
235
+ with self._lock:
236
+ self._keybindings = normalized
237
+ project = self._build_project_locked()
238
+ self._autosave(project)
239
+
240
+ def plugin_state(self, plugin_id: str | None = None) -> dict[str, object] | dict[str, dict[str, object]]:
241
+ with self._lock:
242
+ if plugin_id is None:
243
+ return {
244
+ name: deepcopy(values)
245
+ for name, values in self._plugin_state.items()
246
+ }
247
+ return deepcopy(self._plugin_state.get(str(plugin_id).strip(), {}))
248
+
249
+ def set_plugin_state(self, plugin_id: str, values: dict[str, object]) -> None:
250
+ normalized_id = str(plugin_id).strip()
251
+ if not normalized_id:
252
+ raise ValueError("plugin id must not be empty")
253
+ if not isinstance(values, dict):
254
+ raise ValueError("plugin state must be a dict")
255
+ project = None
256
+ with self._lock:
257
+ self._plugin_state[normalized_id] = deepcopy(values)
258
+ project = self._build_project_locked()
259
+ self._autosave(project)
260
+
261
+ def plugin_value(self, plugin_id: str, key: str, default: object = None) -> object:
262
+ with self._lock:
263
+ return deepcopy(
264
+ self._plugin_state.get(str(plugin_id).strip(), {}).get(str(key).strip(), default)
265
+ )
266
+
267
+ def set_plugin_value(self, plugin_id: str, key: str, value: object) -> None:
268
+ normalized_id = str(plugin_id).strip()
269
+ normalized_key = str(key).strip()
270
+ if not normalized_id or not normalized_key:
271
+ raise ValueError("plugin id and key must not be empty")
272
+ project = None
273
+ with self._lock:
274
+ bucket = deepcopy(self._plugin_state.get(normalized_id, {}))
275
+ bucket[normalized_key] = deepcopy(value)
276
+ self._plugin_state[normalized_id] = bucket
277
+ project = self._build_project_locked()
278
+ self._autosave(project)
279
+
280
+ def set_entry_plugin_metadata(
281
+ self,
282
+ entry_id: int,
283
+ plugin_id: str,
284
+ metadata: dict[str, str],
285
+ ) -> None:
286
+ normalized_id = str(plugin_id).strip()
287
+ if not normalized_id:
288
+ raise ValueError("plugin id must not be empty")
289
+
290
+ def _update(entry: TrafficEntry) -> None:
291
+ entry.plugin_metadata[normalized_id] = {
292
+ str(key): str(value)
293
+ for key, value in metadata.items()
294
+ if str(key).strip()
295
+ }
296
+
297
+ self.mutate(entry_id, _update)
298
+
299
+ def set_entry_plugin_findings(
300
+ self,
301
+ entry_id: int,
302
+ plugin_id: str,
303
+ findings: list[str],
304
+ ) -> None:
305
+ normalized_id = str(plugin_id).strip()
306
+ if not normalized_id:
307
+ raise ValueError("plugin id must not be empty")
308
+
309
+ def _update(entry: TrafficEntry) -> None:
310
+ entry.plugin_findings[normalized_id] = [
311
+ str(item)
312
+ for item in findings
313
+ if str(item).strip()
314
+ ]
315
+
316
+ self.mutate(entry_id, _update)
317
+
318
+ def set_intercept_enabled(self, enabled: bool) -> None:
319
+ self.set_intercept_mode("request" if enabled else "off")
320
+
321
+ def set_intercept_mode(self, mode: str) -> None:
322
+ if mode not in INTERCEPT_MODES:
323
+ raise ValueError(f"invalid intercept mode: {mode!r}")
324
+ with self._lock:
325
+ self._intercept_mode = mode
326
+
327
+ def intercept_enabled(self) -> bool:
328
+ with self._lock:
329
+ return self._intercept_mode != "off"
330
+
331
+ def intercept_mode(self) -> str:
332
+ with self._lock:
333
+ return self._intercept_mode
334
+
335
+ def should_intercept(self, phase: str, host: str | None = None) -> bool:
336
+ if phase not in {"request", "response"}:
337
+ raise ValueError(f"invalid interception phase: {phase!r}")
338
+ with self._lock:
339
+ if self._intercept_mode not in {phase, "both"}:
340
+ return False
341
+ return self._host_is_in_scope_locked(host or "")
342
+
343
+ def pending_interceptions(self) -> list[PendingInterceptionView]:
344
+ with self._lock:
345
+ return [self._view_interception(item) for item in self._pending_interceptions.values()]
346
+
347
+ def interception_history(self) -> list[PendingInterceptionView]:
348
+ with self._lock:
349
+ return [self._view_interception(item) for item in self._interception_log]
350
+
351
+ def get_pending_interception(self, entry_id: int) -> PendingInterceptionView | None:
352
+ with self._lock:
353
+ item = self._pending_interceptions.get(entry_id)
354
+ if item is None:
355
+ return None
356
+ return self._view_interception(item)
357
+
358
+ def get_pending_interception_record(self, record_id: int) -> PendingInterceptionView | None:
359
+ with self._lock:
360
+ pending = self._find_pending_by_record_locked(record_id)
361
+ if pending is None:
362
+ return None
363
+ return self._view_interception(pending)
364
+
365
+ def begin_interception(self, entry_id: int, phase: str, raw_text: str, host: str | None = None) -> bool:
366
+ project = None
367
+ with self._lock:
368
+ if phase not in {"request", "response"}:
369
+ raise ValueError(f"invalid interception phase: {phase!r}")
370
+ if self._intercept_mode not in {phase, "both"}:
371
+ return False
372
+ if not self._host_is_in_scope_locked(host or ""):
373
+ return False
374
+ pending = PendingInterception(
375
+ record_id=self._next_interception_id,
376
+ entry_id=entry_id,
377
+ phase=phase,
378
+ raw_text=raw_text,
379
+ )
380
+ self._next_interception_id += 1
381
+ self._pending_interceptions[entry_id] = pending
382
+ self._interception_log.append(pending)
383
+ entry = self._find_locked(entry_id)
384
+ entry.state = "intercepted"
385
+ project = self._build_project_locked()
386
+ self._autosave(project)
387
+ return True
388
+
389
+ def update_pending_interception(self, entry_id: int, raw_text: str) -> None:
390
+ with self._lock:
391
+ pending = self._pending_interceptions.get(entry_id)
392
+ if pending is None:
393
+ raise KeyError(f"interception {entry_id} not found")
394
+ pending.raw_text = raw_text
395
+ pending.updated_at = datetime.now(timezone.utc)
396
+
397
+ def forward_pending_interception(self, entry_id: int) -> None:
398
+ with self._lock:
399
+ pending = self._pending_interceptions.get(entry_id)
400
+ if pending is None:
401
+ raise KeyError(f"interception {entry_id} not found")
402
+ pending.decision = "forward"
403
+ pending.updated_at = datetime.now(timezone.utc)
404
+ pending.event.set()
405
+
406
+ def update_pending_interception_record(self, record_id: int, raw_text: str) -> None:
407
+ with self._lock:
408
+ pending = self._find_pending_by_record_locked(record_id)
409
+ if pending is None:
410
+ raise KeyError(f"interception record {record_id} not found")
411
+ pending.raw_text = raw_text
412
+ pending.updated_at = datetime.now(timezone.utc)
413
+
414
+ def forward_pending_interception_record(self, record_id: int) -> None:
415
+ with self._lock:
416
+ pending = self._find_pending_by_record_locked(record_id)
417
+ if pending is None:
418
+ raise KeyError(f"interception record {record_id} not found")
419
+ pending.decision = "forward"
420
+ pending.updated_at = datetime.now(timezone.utc)
421
+ pending.event.set()
422
+
423
+ def drop_pending_interception(self, entry_id: int) -> None:
424
+ project = None
425
+ with self._lock:
426
+ pending = self._pending_interceptions.get(entry_id)
427
+ if pending is None:
428
+ raise KeyError(f"interception {entry_id} not found")
429
+ pending.decision = "drop"
430
+ pending.updated_at = datetime.now(timezone.utc)
431
+ pending.event.set()
432
+ entry = self._find_locked(entry_id)
433
+ entry.state = "dropped"
434
+ entry.error = f"{pending.phase} dropped by interceptor"
435
+ project = self._build_project_locked()
436
+ self._autosave(project)
437
+
438
+ def drop_pending_interception_record(self, record_id: int) -> None:
439
+ project = None
440
+ with self._lock:
441
+ pending = self._find_pending_by_record_locked(record_id)
442
+ if pending is None:
443
+ raise KeyError(f"interception record {record_id} not found")
444
+ pending.decision = "drop"
445
+ pending.updated_at = datetime.now(timezone.utc)
446
+ pending.event.set()
447
+ entry = self._find_locked(pending.entry_id)
448
+ entry.state = "dropped"
449
+ entry.error = f"{pending.phase} dropped by interceptor"
450
+ project = self._build_project_locked()
451
+ self._autosave(project)
452
+
453
+ def wait_for_interception(self, entry_id: int) -> InterceptionResult:
454
+ with self._lock:
455
+ pending = self._pending_interceptions.get(entry_id)
456
+ if pending is None:
457
+ raise KeyError(f"interception {entry_id} not found")
458
+ event = pending.event
459
+
460
+ event.wait()
461
+
462
+ with self._lock:
463
+ pending = self._pending_interceptions.pop(entry_id, None)
464
+ if pending is None:
465
+ raise KeyError(f"interception {entry_id} not found after release")
466
+ pending.active = False
467
+ return InterceptionResult(
468
+ entry_id=entry_id,
469
+ phase=pending.phase,
470
+ decision=pending.decision,
471
+ raw_text=pending.raw_text,
472
+ )
473
+
474
+ def release_pending_interceptions(self, reason: str = "proxy shutting down") -> None:
475
+ project = None
476
+ with self._lock:
477
+ if not self._pending_interceptions:
478
+ return
479
+ released_at = datetime.now(timezone.utc)
480
+ for entry_id, pending in self._pending_interceptions.items():
481
+ pending.decision = "drop"
482
+ pending.updated_at = released_at
483
+ pending.active = False
484
+ pending.event.set()
485
+ entry = self._find_locked(entry_id)
486
+ entry.state = "dropped"
487
+ entry.error = reason
488
+ project = self._build_project_locked()
489
+ self._autosave(project)
490
+
491
+ def _autosave(self, payload: dict[str, object] | None) -> None:
492
+ with self._lock:
493
+ project_path = self._project_path
494
+ if project_path is None or payload is None:
495
+ return
496
+ self._write_project(project_path, payload)
497
+
498
+ def _write_project(self, project_path: Path, payload: dict[str, object]) -> None:
499
+ project_path.parent.mkdir(parents=True, exist_ok=True)
500
+ rendered = json.dumps(payload, indent=2, ensure_ascii=True) + "\n"
501
+
502
+ fd, temp_name = tempfile.mkstemp(prefix=f".{project_path.name}.", suffix=".tmp", dir=project_path.parent)
503
+ temp_path = Path(temp_name)
504
+ try:
505
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
506
+ handle.write(rendered)
507
+ handle.flush()
508
+ os.fsync(handle.fileno())
509
+ temp_path.replace(project_path)
510
+ except Exception as exc:
511
+ temp_path.unlink(missing_ok=True)
512
+ with self._lock:
513
+ self._last_save_error = str(exc)
514
+ raise
515
+
516
+ with self._lock:
517
+ self._last_save_at = datetime.now(timezone.utc)
518
+ self._last_save_error = ""
519
+
520
+ def _build_project_locked(self) -> dict[str, object] | None:
521
+ if self._project_path is None:
522
+ return None
523
+ return {
524
+ "version": PROJECT_VERSION,
525
+ "saved_at": datetime.now(timezone.utc).isoformat(),
526
+ "next_id": self._next_id,
527
+ "entries": [self._entry_to_dict(entry) for entry in self._entries],
528
+ "match_replace_rules": [self._rule_to_dict(rule) for rule in self._match_replace_rules],
529
+ "scope_hosts": list(self._scope_hosts),
530
+ "view_filters": self._view_filters_to_dict(self._view_filters),
531
+ "keybindings": dict(self._keybindings),
532
+ "plugin_state": deepcopy(self._plugin_state),
533
+ }
534
+
535
+ def _find_locked(self, entry_id: int) -> TrafficEntry:
536
+ for entry in self._entries:
537
+ if entry.id == entry_id:
538
+ return entry
539
+ raise KeyError(f"entry {entry_id} not found")
540
+
541
+ def _find_pending_by_record_locked(self, record_id: int) -> PendingInterception | None:
542
+ for pending in self._pending_interceptions.values():
543
+ if pending.record_id == record_id:
544
+ return pending
545
+ return None
546
+
547
+ @staticmethod
548
+ def _view_interception(item: PendingInterception) -> PendingInterceptionView:
549
+ return PendingInterceptionView(
550
+ record_id=item.record_id,
551
+ entry_id=item.entry_id,
552
+ phase=item.phase,
553
+ raw_text=item.raw_text,
554
+ created_at=item.created_at,
555
+ updated_at=item.updated_at,
556
+ decision=item.decision,
557
+ active=item.active,
558
+ )
559
+
560
+ @staticmethod
561
+ def _entry_to_dict(entry: TrafficEntry) -> dict[str, object]:
562
+ return {
563
+ "id": entry.id,
564
+ "client_addr": entry.client_addr,
565
+ "started_at": entry.started_at.isoformat(),
566
+ "finished_at": entry.finished_at.isoformat() if entry.finished_at else None,
567
+ "duration_ms": entry.duration_ms,
568
+ "upstream_addr": entry.upstream_addr,
569
+ "error": entry.error,
570
+ "state": entry.state,
571
+ "plugin_metadata": {
572
+ str(plugin_id): {
573
+ str(key): str(value)
574
+ for key, value in values.items()
575
+ }
576
+ for plugin_id, values in entry.plugin_metadata.items()
577
+ },
578
+ "plugin_findings": {
579
+ str(plugin_id): [str(item) for item in values]
580
+ for plugin_id, values in entry.plugin_findings.items()
581
+ },
582
+ "request": {
583
+ "method": entry.request.method,
584
+ "target": entry.request.target,
585
+ "version": entry.request.version,
586
+ "headers": list(entry.request.headers),
587
+ "body_b64": TrafficStore._encode_bytes(entry.request.body),
588
+ "host": entry.request.host,
589
+ "port": entry.request.port,
590
+ "path": entry.request.path,
591
+ },
592
+ "response": {
593
+ "version": entry.response.version,
594
+ "status_code": entry.response.status_code,
595
+ "reason": entry.response.reason,
596
+ "headers": list(entry.response.headers),
597
+ "body_b64": TrafficStore._encode_bytes(entry.response.body),
598
+ },
599
+ }
600
+
601
+ @staticmethod
602
+ def _entry_from_dict(data: dict[str, object]) -> TrafficEntry:
603
+ request = data.get("request", {})
604
+ response = data.get("response", {})
605
+ started_at = TrafficStore._parse_datetime(data.get("started_at"))
606
+ if started_at is None:
607
+ started_at = datetime.now(timezone.utc)
608
+ return TrafficEntry(
609
+ id=int(data["id"]),
610
+ client_addr=str(data.get("client_addr", "-")),
611
+ started_at=started_at,
612
+ finished_at=TrafficStore._parse_datetime(data["finished_at"]),
613
+ duration_ms=data.get("duration_ms"),
614
+ upstream_addr=str(data.get("upstream_addr", "")),
615
+ error=str(data.get("error", "")),
616
+ state=str(data.get("state", "pending")),
617
+ plugin_metadata={
618
+ str(plugin_id): {
619
+ str(key): str(value)
620
+ for key, value in values.items()
621
+ }
622
+ for plugin_id, values in dict(data.get("plugin_metadata", {})).items()
623
+ if isinstance(values, dict) and str(plugin_id).strip()
624
+ },
625
+ plugin_findings={
626
+ str(plugin_id): [str(item) for item in values]
627
+ for plugin_id, values in dict(data.get("plugin_findings", {})).items()
628
+ if isinstance(values, list) and str(plugin_id).strip()
629
+ },
630
+ request=RequestData(
631
+ method=str(request.get("method", "")),
632
+ target=str(request.get("target", "")),
633
+ version=str(request.get("version", "HTTP/1.1")),
634
+ headers=[(str(name), str(value)) for name, value in request.get("headers", [])],
635
+ body=TrafficStore._decode_bytes(request.get("body_b64")),
636
+ host=str(request.get("host", "")),
637
+ port=int(request.get("port", 80)),
638
+ path=str(request.get("path", "/")),
639
+ ),
640
+ response=ResponseData(
641
+ version=str(response.get("version", "HTTP/1.1")),
642
+ status_code=int(response.get("status_code", 0)),
643
+ reason=str(response.get("reason", "")),
644
+ headers=[(str(name), str(value)) for name, value in response.get("headers", [])],
645
+ body=TrafficStore._decode_bytes(response.get("body_b64")),
646
+ ),
647
+ )
648
+
649
+ @staticmethod
650
+ def _encode_bytes(value: bytes) -> str:
651
+ return base64.b64encode(value).decode("ascii")
652
+
653
+ @staticmethod
654
+ def _decode_bytes(value: object) -> bytes:
655
+ if not value:
656
+ return b""
657
+ return base64.b64decode(str(value).encode("ascii"))
658
+
659
+ @staticmethod
660
+ def _plugin_state_from_dict(values: object) -> dict[str, dict[str, object]]:
661
+ if not isinstance(values, dict):
662
+ raise ValueError("plugin_state must be a JSON object")
663
+ normalized: dict[str, dict[str, object]] = {}
664
+ for plugin_id, bucket in values.items():
665
+ plugin_name = str(plugin_id).strip()
666
+ if not plugin_name:
667
+ continue
668
+ if not isinstance(bucket, dict):
669
+ raise ValueError(f"plugin_state for {plugin_name!r} must be a JSON object")
670
+ normalized[plugin_name] = deepcopy(bucket)
671
+ return normalized
672
+
673
+ @staticmethod
674
+ def _parse_datetime(value: object) -> datetime | None:
675
+ if value in (None, ""):
676
+ return None
677
+ return datetime.fromisoformat(str(value))
678
+
679
+ @staticmethod
680
+ def _rule_to_dict(rule: MatchReplaceRule) -> dict[str, object]:
681
+ return {
682
+ "enabled": rule.enabled,
683
+ "scope": rule.scope,
684
+ "mode": rule.mode,
685
+ "match": rule.match,
686
+ "replace": rule.replace,
687
+ "description": rule.description,
688
+ }
689
+
690
+ @classmethod
691
+ def _rules_from_list(cls, values: object) -> list[MatchReplaceRule]:
692
+ if not isinstance(values, list):
693
+ raise ValueError("match_replace_rules must be a list")
694
+
695
+ rules = [
696
+ MatchReplaceRule(
697
+ enabled=bool(item.get("enabled", True)),
698
+ scope=str(item.get("scope", "request")),
699
+ mode=str(item.get("mode", "literal")),
700
+ match=str(item.get("match", "")),
701
+ replace=str(item.get("replace", "")),
702
+ description=str(item.get("description", "")),
703
+ )
704
+ for item in values
705
+ if isinstance(item, dict)
706
+ ]
707
+ cls._validate_match_replace_rules(rules)
708
+ return rules
709
+
710
+ @classmethod
711
+ def _scope_hosts_from_list(cls, values: object) -> list[str]:
712
+ if not isinstance(values, list):
713
+ raise ValueError("scope_hosts must be a list")
714
+ hosts: list[str] = []
715
+ seen: set[str] = set()
716
+ for item in values:
717
+ host = cls._normalize_scope_pattern(str(item))
718
+ if not host or host in seen:
719
+ continue
720
+ hosts.append(host)
721
+ seen.add(host)
722
+ return hosts
723
+
724
+ @staticmethod
725
+ def _keybindings_from_dict(values: object) -> dict[str, str]:
726
+ if not isinstance(values, dict):
727
+ raise ValueError("keybindings must be an object")
728
+ normalized: dict[str, str] = {}
729
+ seen: set[str] = set()
730
+ for action, key in values.items():
731
+ action_name = str(action).strip()
732
+ key_name = str(key)
733
+ if not action_name:
734
+ continue
735
+ if len(key_name) != 1:
736
+ raise ValueError(f"keybinding {action_name!r}: key must be a single character")
737
+ if key_name in seen:
738
+ raise ValueError(f"duplicate keybinding detected for {key_name!r}")
739
+ normalized[action_name] = key_name
740
+ seen.add(key_name)
741
+ return normalized
742
+
743
+ @classmethod
744
+ def _view_filters_to_dict(cls, filters: ViewFilterSettings) -> dict[str, object]:
745
+ normalized = cls._normalize_view_filters(filters)
746
+ return {
747
+ "show_out_of_scope": normalized.show_out_of_scope,
748
+ "query_mode": normalized.query_mode,
749
+ "failure_mode": normalized.failure_mode,
750
+ "body_mode": normalized.body_mode,
751
+ "methods": list(normalized.methods),
752
+ "hidden_methods": list(normalized.hidden_methods),
753
+ "hidden_extensions": list(normalized.hidden_extensions),
754
+ }
755
+
756
+ @classmethod
757
+ def _view_filters_from_dict(cls, values: object) -> ViewFilterSettings:
758
+ if not isinstance(values, dict):
759
+ values = {}
760
+ return cls._normalize_view_filters(
761
+ ViewFilterSettings(
762
+ show_out_of_scope=bool(values.get("show_out_of_scope", False)),
763
+ query_mode=str(values.get("query_mode", "all")),
764
+ failure_mode=str(values.get("failure_mode", "all")),
765
+ body_mode=str(values.get("body_mode", "all")),
766
+ methods=list(values.get("methods", [])) if isinstance(values.get("methods", []), list) else [],
767
+ hidden_methods=(
768
+ list(values.get("hidden_methods", []))
769
+ if isinstance(values.get("hidden_methods", []), list)
770
+ else []
771
+ ),
772
+ hidden_extensions=(
773
+ list(values.get("hidden_extensions", []))
774
+ if isinstance(values.get("hidden_extensions", []), list)
775
+ else []
776
+ ),
777
+ )
778
+ )
779
+
780
+ @classmethod
781
+ def _normalize_view_filters(cls, filters: ViewFilterSettings) -> ViewFilterSettings:
782
+ query_mode = str(filters.query_mode).strip().lower() or "all"
783
+ if query_mode not in VIEW_FILTER_QUERY_MODES:
784
+ raise ValueError(f"invalid query_mode: {filters.query_mode!r}")
785
+ failure_mode = str(filters.failure_mode).strip().lower() or "all"
786
+ if failure_mode not in VIEW_FILTER_FAILURE_MODES:
787
+ raise ValueError(f"invalid failure_mode: {filters.failure_mode!r}")
788
+ body_mode = str(filters.body_mode).strip().lower() or "all"
789
+ if body_mode not in VIEW_FILTER_BODY_MODES:
790
+ raise ValueError(f"invalid body_mode: {filters.body_mode!r}")
791
+ methods: list[str] = []
792
+ seen_methods: set[str] = set()
793
+ for item in filters.methods:
794
+ method = str(item).strip().upper()
795
+ if not method or method in seen_methods:
796
+ continue
797
+ methods.append(method)
798
+ seen_methods.add(method)
799
+ hidden_methods: list[str] = []
800
+ seen_hidden_methods: set[str] = set()
801
+ for item in filters.hidden_methods:
802
+ method = str(item).strip().upper()
803
+ if not method or method in seen_hidden_methods:
804
+ continue
805
+ hidden_methods.append(method)
806
+ seen_hidden_methods.add(method)
807
+ hidden_extensions: list[str] = []
808
+ seen_extensions: set[str] = set()
809
+ for item in filters.hidden_extensions:
810
+ extension = cls._normalize_extension(str(item))
811
+ if not extension or extension in seen_extensions:
812
+ continue
813
+ hidden_extensions.append(extension)
814
+ seen_extensions.add(extension)
815
+ return ViewFilterSettings(
816
+ show_out_of_scope=bool(filters.show_out_of_scope),
817
+ query_mode=query_mode,
818
+ failure_mode=failure_mode,
819
+ body_mode=body_mode,
820
+ methods=methods,
821
+ hidden_methods=hidden_methods,
822
+ hidden_extensions=hidden_extensions,
823
+ )
824
+
825
+ @staticmethod
826
+ def _validate_match_replace_rules(rules: list[MatchReplaceRule]) -> None:
827
+ for index, rule in enumerate(rules, start=1):
828
+ if rule.scope not in {"request", "response", "both"}:
829
+ raise ValueError(f"rule {index}: invalid scope {rule.scope!r}")
830
+ if rule.mode not in {"literal", "regex"}:
831
+ raise ValueError(f"rule {index}: invalid mode {rule.mode!r}")
832
+ if not rule.match:
833
+ raise ValueError(f"rule {index}: match must not be empty")
834
+ if rule.mode == "regex":
835
+ try:
836
+ re.compile(rule.match)
837
+ except re.error as exc:
838
+ raise ValueError(f"rule {index}: invalid regex: {exc}") from exc
839
+
840
+ @staticmethod
841
+ def _normalize_scope_host(value: str) -> str:
842
+ host = value.strip().lower()
843
+ if not host:
844
+ return ""
845
+ if "://" in host:
846
+ host = urlsplit(host).hostname or ""
847
+ else:
848
+ host = host.split("/", 1)[0]
849
+ if host.startswith("."):
850
+ host = host[1:]
851
+ if host.count(":") == 1:
852
+ head, _, tail = host.partition(":")
853
+ if tail.isdigit():
854
+ host = head
855
+ return host.rstrip(".")
856
+
857
+ @classmethod
858
+ def _normalize_scope_pattern(cls, value: str) -> str:
859
+ candidate = value.strip().lower()
860
+ if not candidate:
861
+ return ""
862
+ excluded = candidate.startswith("!")
863
+ if excluded:
864
+ candidate = candidate[1:].strip()
865
+ if not candidate:
866
+ return ""
867
+ if candidate == "*":
868
+ return "!*" if excluded else "*"
869
+ wildcard = candidate.startswith("*.")
870
+ if wildcard:
871
+ candidate = candidate[2:]
872
+ host = cls._normalize_scope_host(candidate)
873
+ if not host:
874
+ return ""
875
+ normalized = f"*.{host}" if wildcard else host
876
+ return f"!{normalized}" if excluded else normalized
877
+
878
+ @staticmethod
879
+ def _split_scope_patterns(patterns: list[str]) -> tuple[list[str], list[str]]:
880
+ includes: list[str] = []
881
+ excludes: list[str] = []
882
+ for pattern in patterns:
883
+ if pattern.startswith("!"):
884
+ excludes.append(pattern[1:])
885
+ else:
886
+ includes.append(pattern)
887
+ return includes, excludes
888
+
889
+ @staticmethod
890
+ def _scope_matches(pattern: str, host: str) -> bool:
891
+ if pattern == "*":
892
+ return True
893
+ if pattern.startswith("*."):
894
+ suffix = pattern[2:]
895
+ return host.endswith(f".{suffix}")
896
+ return host == pattern or host.endswith(f".{pattern}")
897
+
898
+ def _host_is_in_scope_locked(self, host: str) -> bool:
899
+ if not self._scope_hosts:
900
+ return True
901
+ normalized_host = self._normalize_scope_host(host)
902
+ if not normalized_host:
903
+ return False
904
+ includes, excludes = self._split_scope_patterns(self._scope_hosts)
905
+ if includes and not any(self._scope_matches(pattern, normalized_host) for pattern in includes):
906
+ return False
907
+ if any(self._scope_matches(pattern, normalized_host) for pattern in excludes):
908
+ return False
909
+ return True
910
+
911
+ @staticmethod
912
+ def _normalize_extension(value: str) -> str:
913
+ extension = value.strip().lower()
914
+ if extension.startswith("."):
915
+ extension = extension[1:]
916
+ return extension
917
+
918
+ @staticmethod
919
+ def _header_value(headers: list[tuple[str, str]], name: str) -> str:
920
+ target = name.lower()
921
+ for header_name, value in headers:
922
+ if header_name.lower() == target:
923
+ return value
924
+ return ""
925
+
926
+ @classmethod
927
+ def _entry_extension_locked(cls, entry: TrafficEntry) -> str:
928
+ request_path = entry.request.path or entry.request.target or ""
929
+ parsed = urlsplit(request_path if "://" in request_path else f"http://placeholder{request_path}")
930
+ path = parsed.path or request_path
931
+ filename = Path(path).name
932
+ suffix = Path(filename).suffix
933
+ if suffix:
934
+ return cls._normalize_extension(suffix)
935
+
936
+ content_type = cls._header_value(entry.response.headers, "Content-Type").split(";", 1)[0].strip().lower()
937
+ return {
938
+ "image/jpeg": "jpg",
939
+ "image/png": "png",
940
+ "image/gif": "gif",
941
+ "image/webp": "webp",
942
+ "image/svg+xml": "svg",
943
+ "application/javascript": "js",
944
+ "text/javascript": "js",
945
+ "text/css": "css",
946
+ "application/json": "json",
947
+ "text/html": "html",
948
+ }.get(content_type, "")
949
+
950
+ @staticmethod
951
+ def _entry_has_query_locked(entry: TrafficEntry) -> bool:
952
+ request_target = entry.request.target or ""
953
+ request_path = entry.request.path or ""
954
+ if "?" in request_target or "?" in request_path:
955
+ return True
956
+ parsed = urlsplit(request_target if "://" in request_target else f"http://placeholder{request_target}")
957
+ return bool(parsed.query)
958
+
959
+ @staticmethod
960
+ def _entry_is_failure_locked(entry: TrafficEntry) -> bool:
961
+ if entry.error or entry.state == "error":
962
+ return True
963
+ return (entry.response.status_code or 0) >= 400
964
+
965
+ @staticmethod
966
+ def _entry_has_body_locked(entry: TrafficEntry) -> bool:
967
+ return bool(entry.request.body or entry.response.body)
968
+
969
+ def _entry_visible_locked(self, entry: TrafficEntry, filters: ViewFilterSettings | None = None) -> bool:
970
+ active_filters = filters or self._view_filters
971
+ if self._scope_hosts and not active_filters.show_out_of_scope:
972
+ host = self._normalize_scope_host(entry.request.host or entry.summary_host)
973
+ if not self._host_is_in_scope_locked(host):
974
+ return False
975
+ if active_filters.query_mode == "with_query" and not self._entry_has_query_locked(entry):
976
+ return False
977
+ if active_filters.query_mode == "without_query" and self._entry_has_query_locked(entry):
978
+ return False
979
+ if active_filters.failure_mode == "failures" and not self._entry_is_failure_locked(entry):
980
+ return False
981
+ if active_filters.failure_mode == "hide_failures" and self._entry_is_failure_locked(entry):
982
+ return False
983
+ if active_filters.failure_mode == "client_errors" and not (400 <= (entry.response.status_code or 0) <= 499):
984
+ return False
985
+ if active_filters.failure_mode == "server_errors" and not (500 <= (entry.response.status_code or 0) <= 599):
986
+ return False
987
+ if active_filters.failure_mode == "connection_errors" and not (entry.error or entry.state == "error"):
988
+ return False
989
+ if active_filters.body_mode == "with_body" and not self._entry_has_body_locked(entry):
990
+ return False
991
+ if active_filters.body_mode == "without_body" and self._entry_has_body_locked(entry):
992
+ return False
993
+ request_method = entry.request.method.upper()
994
+ if active_filters.methods and request_method not in active_filters.methods:
995
+ return False
996
+ if request_method in active_filters.hidden_methods:
997
+ return False
998
+ extension = self._entry_extension_locked(entry)
999
+ if extension and extension in active_filters.hidden_extensions:
1000
+ return False
1001
+ return True