adbproxy 0.1.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 (49) hide show
  1. adbproxy/__init__.py +2 -0
  2. adbproxy/__main__.py +5 -0
  3. adbproxy/cli/__init__.py +1 -0
  4. adbproxy/cli/device.py +13 -0
  5. adbproxy/cli/main.py +60 -0
  6. adbproxy/cli/server.py +13 -0
  7. adbproxy/config.py +39 -0
  8. adbproxy/device/__init__.py +1 -0
  9. adbproxy/device/app.py +532 -0
  10. adbproxy/device/backend.py +242 -0
  11. adbproxy/device/banner.py +33 -0
  12. adbproxy/device/connection.py +505 -0
  13. adbproxy/device/session.py +304 -0
  14. adbproxy/device/stream_table.py +125 -0
  15. adbproxy/observers/__init__.py +1 -0
  16. adbproxy/observers/events.py +19 -0
  17. adbproxy/observers/install.py +126 -0
  18. adbproxy/observers/shell.py +371 -0
  19. adbproxy/observers/stream.py +94 -0
  20. adbproxy/observers/sync.py +550 -0
  21. adbproxy/protocols/__init__.py +1 -0
  22. adbproxy/protocols/adb.py +66 -0
  23. adbproxy/protocols/payload/__init__.py +17 -0
  24. adbproxy/protocols/payload/install.py +44 -0
  25. adbproxy/protocols/payload/shell_v2.py +28 -0
  26. adbproxy/protocols/payload/sync.py +180 -0
  27. adbproxy/protocols/smart_socket/__init__.py +33 -0
  28. adbproxy/protocols/smart_socket/classifier.py +78 -0
  29. adbproxy/protocols/smart_socket/conversation.py +335 -0
  30. adbproxy/protocols/smart_socket/handshake.py +75 -0
  31. adbproxy/protocols/smart_socket/wire.py +82 -0
  32. adbproxy/protocols/transport/__init__.py +52 -0
  33. adbproxy/protocols/transport/codec.py +90 -0
  34. adbproxy/protocols/transport/constants.py +21 -0
  35. adbproxy/protocols/transport/errors.py +5 -0
  36. adbproxy/runtime/__init__.py +1 -0
  37. adbproxy/runtime/adb_process.py +195 -0
  38. adbproxy/runtime/capture.py +41 -0
  39. adbproxy/runtime/logging.py +113 -0
  40. adbproxy/runtime/sockets.py +14 -0
  41. adbproxy/server/__init__.py +1 -0
  42. adbproxy/server/app.py +221 -0
  43. adbproxy/server/connection.py +378 -0
  44. adbproxy-0.1.0.dist-info/METADATA +254 -0
  45. adbproxy-0.1.0.dist-info/RECORD +49 -0
  46. adbproxy-0.1.0.dist-info/WHEEL +5 -0
  47. adbproxy-0.1.0.dist-info/entry_points.txt +4 -0
  48. adbproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. adbproxy-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,28 @@
1
+ """Pure shell_v2 framing: [1B id][4B LE len][data]."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+
7
+ from adbproxy.config import MAX_V2_BUF
8
+
9
+
10
+ def split_v2_frames(buf):
11
+ """切分 v2 帧 [1B id][4B LE len][data]。
12
+
13
+ 返回 (frames:list[(id,payload)], leftover:bytearray, ok:bool)。
14
+ ok=False 表示遇到非法 len,调用方应降级为碎片流。
15
+ """
16
+ frames = []
17
+ pos = 0
18
+ n = len(buf)
19
+ while pos + 5 <= n:
20
+ id_ = buf[pos]
21
+ length = struct.unpack_from("<I", buf, pos + 1)[0]
22
+ if length > MAX_V2_BUF:
23
+ return (frames, bytearray(buf[pos:]), False)
24
+ if pos + 5 + length > n:
25
+ break # 半包,等后续
26
+ frames.append((id_, bytes(buf[pos + 5 : pos + 5 + length])))
27
+ pos += 5 + length
28
+ return (frames, bytearray(buf[pos:]), True)
@@ -0,0 +1,180 @@
1
+ """Pure sync wire framing (file_sync_protocol.h shapes).
2
+
3
+ No capture, no compression, no I/O. One SyncWireDecoder per direction.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import struct
9
+ from dataclasses import dataclass
10
+
11
+ from adbproxy.config import (
12
+ MAX_V2_BUF,
13
+ SYNC_DATA_MAX,
14
+ _DENT_V1_SIZE,
15
+ _DENT_V2_SIZE,
16
+ _RECV_V2_SIZE,
17
+ _SEND_V2_SIZE,
18
+ _STAT_V1_SIZE,
19
+ _STAT_V2_SIZE,
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SyncFrame:
25
+ """One complete sync frame (id + full raw frame bytes including id)."""
26
+
27
+ id: bytes
28
+ raw: bytes
29
+
30
+
31
+ def plan_sync_frame(id_: bytes, side: str, awaiting_details: str | None):
32
+ """返回帧分帧方案:("var",) / ("fixed", n) / ("dent", header, namelen_off) / None(未知)。
33
+
34
+ 依赖 (id, side, awaiting_details) 区分 SND2/RCV2 双发的 path 帧 vs details struct。
35
+ """
36
+ if side == "c":
37
+ # client→server:均为请求。路径请求类(8+path_length+path)
38
+ if id_ in (b"STA2", b"LST2", b"STAT", b"LIST", b"SEND", b"RECV"):
39
+ return ("var",)
40
+ if id_ == b"SND2":
41
+ # 双发第二帧 = sync_send_v2(12B 定长);否则 = path 请求
42
+ if awaiting_details == "send":
43
+ return ("fixed", _SEND_V2_SIZE)
44
+ return ("var",)
45
+ if id_ == b"RCV2":
46
+ if awaiting_details == "recv":
47
+ return ("fixed", _RECV_V2_SIZE)
48
+ return ("var",)
49
+ if id_ == b"DATA":
50
+ return ("var",)
51
+ if id_ in (b"DONE", b"QUIT"):
52
+ return ("fixed", 8)
53
+ if id_ == b"FAIL":
54
+ return ("var",)
55
+ return None
56
+ else: # server→client:均为响应
57
+ if id_ in (b"STA2", b"LST2"):
58
+ return ("fixed", _STAT_V2_SIZE) # sync_stat_v2 定长
59
+ if id_ == b"STAT":
60
+ return ("fixed", _STAT_V1_SIZE) # sync_stat_v1 定长
61
+ if id_ == b"DNT2":
62
+ return (
63
+ "dent",
64
+ _DENT_V2_SIZE,
65
+ _DENT_V2_SIZE - 4,
66
+ ) # 76B 头 + namelen name
67
+ if id_ == b"DENT":
68
+ return (
69
+ "dent",
70
+ _DENT_V1_SIZE,
71
+ _DENT_V1_SIZE - 4,
72
+ ) # 20B 头 + namelen name
73
+ if id_ == b"DATA":
74
+ return ("var",)
75
+ if id_ in (b"DONE", b"OKAY", b"QUIT"):
76
+ return ("fixed", 8)
77
+ if id_ == b"FAIL":
78
+ return ("var",)
79
+ return None
80
+
81
+
82
+ class SyncWireDecoder:
83
+ """Incremental per-direction sync framer.
84
+
85
+ Prefer ``try_take_frame(plan_fn)`` so session can update ``awaiting_details``
86
+ between frames inside one TCP chunk (SND2/RCV2 dual-send).
87
+ """
88
+
89
+ def __init__(self):
90
+ self.buf = bytearray()
91
+ self.bypass = False
92
+ self.degrade_reason: str | None = None
93
+
94
+ def reset(self):
95
+ self.buf = bytearray()
96
+ self.bypass = False
97
+ self.degrade_reason = None
98
+
99
+ def extend(self, data: bytes | bytearray | memoryview) -> None:
100
+ if data and not self.bypass:
101
+ self.buf.extend(data)
102
+
103
+ def try_take_frame(self, plan_fn) -> SyncFrame | None:
104
+ """Extract at most one complete frame.
105
+
106
+ Returns SyncFrame when a full frame is available, None when more data is
107
+ needed. On framing errors sets ``bypass`` and returns None.
108
+ ``plan_fn(id_)`` must return plan_sync_frame-style plan or None.
109
+ """
110
+ if self.bypass:
111
+ return None
112
+ if len(self.buf) < 4:
113
+ return None
114
+ id_ = bytes(self.buf[:4])
115
+ plan = plan_fn(id_)
116
+ if plan is None:
117
+ self.bypass = True
118
+ self.degrade_reason = f"unknown id {id_!r}"
119
+ return None
120
+ kind = plan[0]
121
+ if kind == "var":
122
+ if len(self.buf) < 8:
123
+ return None
124
+ length = struct.unpack_from("<I", self.buf, 4)[0]
125
+ length_limit = SYNC_DATA_MAX if id_ == b"DATA" else MAX_V2_BUF
126
+ if length > length_limit:
127
+ self.bypass = True
128
+ self.degrade_reason = (
129
+ f"var len too large id={id_!r} len={length} limit={length_limit}"
130
+ )
131
+ return None
132
+ if len(self.buf) < 8 + length:
133
+ return None
134
+ raw = bytes(self.buf[: 8 + length])
135
+ del self.buf[: 8 + length]
136
+ return SyncFrame(id=id_, raw=raw)
137
+ if kind == "fixed":
138
+ size = plan[1]
139
+ if len(self.buf) < size:
140
+ return None
141
+ raw = bytes(self.buf[:size])
142
+ del self.buf[:size]
143
+ return SyncFrame(id=id_, raw=raw)
144
+ # dent: header + namelen name
145
+ header_size = plan[1]
146
+ namelen_off = plan[2]
147
+ if len(self.buf) < header_size:
148
+ return None
149
+ namelen = struct.unpack_from("<I", self.buf, namelen_off)[0]
150
+ if namelen > MAX_V2_BUF:
151
+ self.bypass = True
152
+ self.degrade_reason = (
153
+ f"dent namelen too large id={id_!r} namelen={namelen}"
154
+ )
155
+ return None
156
+ if len(self.buf) < header_size + namelen:
157
+ return None
158
+ raw = bytes(self.buf[: header_size + namelen])
159
+ del self.buf[: header_size + namelen]
160
+ return SyncFrame(id=id_, raw=raw)
161
+
162
+ def feed(
163
+ self,
164
+ data: bytes | bytearray | memoryview,
165
+ side: str,
166
+ awaiting_details: str | None = None,
167
+ ) -> list[SyncFrame]:
168
+ """Append data and emit all complete frames under a fixed awaiting snapshot.
169
+
170
+ For dual-send mid-chunk plan flips, use try_take_frame with a live plan_fn.
171
+ """
172
+ self.extend(data)
173
+ frames: list[SyncFrame] = []
174
+ while True:
175
+ frame = self.try_take_frame(
176
+ lambda id_, s=side, a=awaiting_details: plan_sync_frame(id_, s, a)
177
+ )
178
+ if frame is None:
179
+ return frames
180
+ frames.append(frame)
@@ -0,0 +1,33 @@
1
+ """Smart-socket protocol primitives (wire / conversation / handshake)."""
2
+
3
+ from adbproxy.protocols.smart_socket.classifier import ServiceClassifier
4
+ from adbproxy.protocols.smart_socket.conversation import SmartEvent, SmartSocketConversation
5
+ from adbproxy.protocols.smart_socket.handshake import HandshakeStatus, SmartSocketHandshake
6
+ from adbproxy.protocols.smart_socket.wire import (
7
+ FAIL,
8
+ OKAY,
9
+ decode_hex4,
10
+ encode_hex4,
11
+ encode_protocol_string,
12
+ format_service_frame,
13
+ parse_status,
14
+ try_parse_hex4_length_prefix,
15
+ try_parse_status,
16
+ )
17
+
18
+ __all__ = [
19
+ "FAIL",
20
+ "OKAY",
21
+ "HandshakeStatus",
22
+ "ServiceClassifier",
23
+ "SmartEvent",
24
+ "SmartSocketConversation",
25
+ "SmartSocketHandshake",
26
+ "decode_hex4",
27
+ "encode_hex4",
28
+ "encode_protocol_string",
29
+ "format_service_frame",
30
+ "parse_status",
31
+ "try_parse_hex4_length_prefix",
32
+ "try_parse_status",
33
+ ]
@@ -0,0 +1,78 @@
1
+ """Service classification for smart-socket service strings.
2
+
3
+ Pure helpers: no I/O. Used by server conversation policy, device open path,
4
+ and observers. Install grammar lives in payload.install; classifier re-exports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from adbproxy.protocols.payload.install import (
10
+ is_install_stream_service as _is_install_stream_service,
11
+ )
12
+ from adbproxy.protocols.payload.install import (
13
+ parse_install_size as _parse_install_size,
14
+ )
15
+
16
+
17
+ class ServiceClassifier:
18
+ """Classify adb smart-socket service strings (host / shell / sync / install)."""
19
+
20
+ @staticmethod
21
+ def parse_service(svc: str):
22
+ """拆 service 串 -> (kind, args_set, cmd)。
23
+
24
+ kind ∈ {"host","shell","sync","other"};args_set 含 raw/pty/v2 等 flag;
25
+ cmd 为命令文本。shell 真实语法:shell[,args]:cmd(无 v1,v2 是 flag)。
26
+ """
27
+ if svc.startswith("host:"):
28
+ return ("host", set(), svc)
29
+ if svc.startswith("sync:") or svc == "sync":
30
+ return ("sync", set(), svc)
31
+ if svc.startswith("shell"):
32
+ rest = svc[len("shell") :]
33
+ if rest.startswith(":"):
34
+ return ("shell", set(), rest[1:])
35
+ if rest.startswith(","):
36
+ colon = rest.find(":")
37
+ if colon == -1:
38
+ return ("shell", set(a for a in rest[1:].split(",") if a), "")
39
+ args = set(a for a in rest[1:colon].split(",") if a)
40
+ return ("shell", args, rest[colon + 1 :])
41
+ return ("shell", set(), "")
42
+ return ("other", set(), svc)
43
+
44
+ @staticmethod
45
+ def is_host_service(svc: str | None) -> bool:
46
+ # AOSP host-services 家族(host:xxx / host-serial: / host-internal: 等)
47
+ return svc is not None and (svc.startswith("host:") or svc.startswith("host-"))
48
+
49
+ @staticmethod
50
+ def host_response_kind(svc: str | None) -> str:
51
+ """按 AOSP host service 选择响应 wire shape。"""
52
+ if not ServiceClassifier.is_host_service(svc):
53
+ return "stream"
54
+ if ":track-devices" in svc:
55
+ return "track"
56
+ if svc.startswith("host:tport:"):
57
+ return "fixed8"
58
+ if ":forward:" in svc or ":killforward:" in svc:
59
+ return "forward"
60
+ if svc.startswith("host:transport") or svc == "host:kill":
61
+ return "status"
62
+ return "protocol_string"
63
+
64
+ @staticmethod
65
+ def is_shell_service(svc: str | None) -> bool:
66
+ return svc is not None and svc.startswith("shell")
67
+
68
+ @staticmethod
69
+ def is_sync_service(svc: str | None) -> bool:
70
+ return svc is not None and (svc.startswith("sync:") or svc == "sync")
71
+
72
+ @staticmethod
73
+ def is_install_stream_service(svc: str | None) -> bool:
74
+ return _is_install_stream_service(svc)
75
+
76
+ @staticmethod
77
+ def parse_install_size(svc: str | None) -> int | None:
78
+ return _parse_install_size(svc)
@@ -0,0 +1,335 @@
1
+ """Server-side smart-socket conversation policy (pure bypass, no I/O).
2
+
3
+ Incremental multi-shape request/response observation.
4
+
5
+ Connection orchestration (not this module):
6
+ - I2: service classify / set_mode / activate may run **pre-sendall** via
7
+ ``feed_request`` / ``try_parse_service_frame`` (parse single-source only).
8
+ - I1/I3: ``sendall(original)`` must not be skipped on observe errors; payload
9
+ ``add_*`` runs **post-sendall** (fail-open).
10
+ - Response path typically ``sendall`` then ``feed_response``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from adbproxy.protocols.smart_socket.classifier import ServiceClassifier
19
+ from adbproxy.protocols.smart_socket.wire import FAIL, OKAY
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SmartEvent:
24
+ """Observation event emitted by SmartSocketConversation."""
25
+
26
+ kind: str
27
+ # kind values:
28
+ # service_request — svc=str, leftover=bytes
29
+ # status — status=str (OKAY/FAIL)
30
+ # fail_reason — reason=bytes
31
+ # payload — data=bytes (host/shell/sync leftover after status strip)
32
+ # host_payload — data=bytes (alias of payload for host multi-shape)
33
+ data: Any = None
34
+ leftover: bytes = b""
35
+ status: str | None = None
36
+ reason: bytes | None = None
37
+ svc: str | None = None
38
+
39
+
40
+ class SmartSocketConversation:
41
+ """Per-TCP-connection pure smart-socket observer state.
42
+
43
+ Request path: reassemble ``<hex4><service>`` frames across TCP fragments.
44
+ Response path: multi-shape host policy + first-response OKAY/FAIL strip for
45
+ shell/sync streams. No sockets/threads/subprocess.
46
+ """
47
+
48
+ def __init__(self):
49
+ # client request reassembly
50
+ self._service_request_buf = bytearray()
51
+ # shell / sync first response (independent)
52
+ self._shell_first_response = self._new_first_response_state()
53
+ self._sync_first_response = self._new_first_response_state()
54
+ # host multi-shape SM
55
+ self.host_phase = "status"
56
+ self.host_status_buf = bytearray()
57
+ self.host_hex_buf = bytearray()
58
+ self.host_payload_remaining = 0
59
+ self.host_fixed_remaining = 0
60
+ self.host_track_active = False
61
+ self._last_host_svc = None
62
+ self.svc = None # last observed service string (mirrors Connection.svc)
63
+ self._status_events: list[str] = []
64
+
65
+ @staticmethod
66
+ def _new_first_response_state():
67
+ return {"phase": "status", "buf": bytearray(), "remaining": 0}
68
+
69
+ def reset_host_state(self):
70
+ self.host_phase = "status"
71
+ self.host_status_buf = bytearray()
72
+ self.host_hex_buf = bytearray()
73
+ self.host_payload_remaining = 0
74
+ self.host_fixed_remaining = 0
75
+ self.host_track_active = False
76
+
77
+ def try_parse_service_frame(self, data: bytes):
78
+ """尝试解析一帧 4-hex 长度 + service 串(纯解析,无副作用除缓冲)。
79
+
80
+ 返回 (svc:str, leftover:bytes) 或 None(数据不足/非协议)。
81
+ """
82
+ self._service_request_buf.extend(data)
83
+ if len(self._service_request_buf) < 4:
84
+ return None
85
+ try:
86
+ cmd_len = int(self._service_request_buf[:4].decode("ascii"), 16)
87
+ except ValueError:
88
+ # 非 smart-socket 数据不应长期占用观察缓冲
89
+ self._service_request_buf = bytearray()
90
+ return None
91
+ frame_size = 4 + cmd_len
92
+ if len(self._service_request_buf) < frame_size:
93
+ return None # 半包
94
+ svc = self._service_request_buf[4:frame_size].decode("ascii", errors="ignore")
95
+ leftover = bytes(self._service_request_buf[frame_size:])
96
+ self._service_request_buf = bytearray()
97
+ self.svc = svc
98
+ return (svc, leftover)
99
+
100
+ def feed_request(self, data: bytes) -> list[SmartEvent]:
101
+ """Observe client→server bytes. Returns service_request events when a frame completes."""
102
+ events: list[SmartEvent] = []
103
+ parsed = self.try_parse_service_frame(data)
104
+ if parsed is not None:
105
+ svc, leftover = parsed
106
+ events.append(
107
+ SmartEvent(
108
+ kind="service_request",
109
+ svc=svc,
110
+ leftover=leftover,
111
+ data=svc,
112
+ )
113
+ )
114
+ return events
115
+
116
+ def feed_response(
117
+ self,
118
+ data: bytes,
119
+ *,
120
+ shell_active: bool = False,
121
+ shell_okay_consumed: bool = False,
122
+ sync_active: bool = False,
123
+ sync_okay_consumed: bool = False,
124
+ svc: str | None = None,
125
+ ) -> tuple[list[SmartEvent], bool]:
126
+ """Observe server→client bytes under current stream activation flags.
127
+
128
+ Returns ``(events, complete)`` where events may include status /
129
+ fail_reason / payload. Does not mutate sockets. Host status words are
130
+ both appended as status events and left in ``_status_events`` until the
131
+ connection drains that side-channel (S5).
132
+ """
133
+ if svc is not None:
134
+ self.svc = svc
135
+ events: list[SmartEvent] = []
136
+
137
+ if shell_active and not shell_okay_consumed:
138
+ complete, leftover = self.consume_first_response(
139
+ data, self._shell_first_response, emit=events
140
+ )
141
+ if complete and leftover:
142
+ events.append(SmartEvent(kind="payload", data=leftover))
143
+ return events, complete
144
+ if shell_active:
145
+ events.append(SmartEvent(kind="payload", data=data))
146
+ return events, True
147
+
148
+ if sync_active and not sync_okay_consumed:
149
+ complete, leftover = self.consume_first_response(
150
+ data, self._sync_first_response, emit=events
151
+ )
152
+ if complete and leftover:
153
+ events.append(SmartEvent(kind="payload", data=leftover))
154
+ return events, complete
155
+ if sync_active:
156
+ events.append(SmartEvent(kind="payload", data=data))
157
+ return events, True
158
+
159
+ # host / other
160
+ if ServiceClassifier.is_host_service(self.svc):
161
+ leftover = self.parse_host_response(data)
162
+ for st in self._status_events:
163
+ events.append(SmartEvent(kind="status", status=st, data=st))
164
+ else:
165
+ # Stateless first-status strip for non-host; mirror loggable status.
166
+ if data.startswith(OKAY) or data.startswith(FAIL):
167
+ status = data[:4].decode("ascii", errors="ignore")
168
+ events.append(SmartEvent(kind="status", status=status, data=status))
169
+ leftover = self.parse_server_first(data)
170
+ if leftover:
171
+ events.append(SmartEvent(kind="payload", data=leftover))
172
+ return events, True
173
+
174
+ def consume_first_response(self, data, state, emit: list[SmartEvent] | None = None):
175
+ """重组服务首响应,确保 OKAY/FAIL 及 FAIL protocol-string 不进入 payload。"""
176
+ buf = state["buf"]
177
+ buf.extend(data)
178
+ status_events = emit if emit is not None else []
179
+
180
+ while True:
181
+ if state["phase"] == "status":
182
+ if len(buf) < 4:
183
+ return False, b""
184
+ status = bytes(buf[:4])
185
+ del buf[:4]
186
+ if status not in (OKAY, FAIL):
187
+ payload = status + bytes(buf)
188
+ buf.clear()
189
+ state["phase"] = "done"
190
+ return True, payload
191
+ status_events.append(
192
+ SmartEvent(kind="status", status=status.decode("ascii"), data=status)
193
+ )
194
+ if status == OKAY:
195
+ payload = bytes(buf)
196
+ buf.clear()
197
+ state["phase"] = "done"
198
+ return True, payload
199
+ state["phase"] = "fail_length"
200
+ elif state["phase"] == "fail_length":
201
+ if len(buf) < 4:
202
+ return False, b""
203
+ raw_length = bytes(buf[:4])
204
+ del buf[:4]
205
+ try:
206
+ state["remaining"] = int(raw_length.decode("ascii"), 16)
207
+ except ValueError:
208
+ payload = raw_length + bytes(buf)
209
+ buf.clear()
210
+ state["phase"] = "done"
211
+ return True, payload
212
+ state["phase"] = "fail_reason"
213
+ elif state["phase"] == "fail_reason":
214
+ remaining = state["remaining"]
215
+ if len(buf) < remaining:
216
+ return False, b""
217
+ reason = bytes(buf[:remaining])
218
+ del buf[:remaining]
219
+ status_events.append(SmartEvent(kind="fail_reason", reason=reason, data=reason))
220
+ payload = bytes(buf)
221
+ buf.clear()
222
+ state["phase"] = "done"
223
+ return True, payload
224
+ else:
225
+ payload = bytes(buf)
226
+ buf.clear()
227
+ return True, payload
228
+
229
+ def parse_server_first(self, data: bytes) -> bytes:
230
+ """非 host 服务的状态剥除(stateless 兜底)。"""
231
+ if not (data.startswith(OKAY) or data.startswith(FAIL)):
232
+ return data
233
+ # status word present; leftover may include host hex for host services
234
+ leftover = data[4:]
235
+ if ServiceClassifier.is_host_service(self.svc) and len(leftover) >= 4:
236
+ hex_str = leftover[:4].decode("ascii", errors="ignore")
237
+ if len(hex_str) == 4 and all(
238
+ c in "0123456789abcdefABCDEF" for c in hex_str
239
+ ):
240
+ leftover = leftover[4:]
241
+ return leftover
242
+
243
+ def parse_host_response(self, data: bytes) -> bytes:
244
+ """按 service wire shape 增量消费 host 响应,返回纯 payload bytes。
245
+
246
+ Status words consumed during this call are appended to ``_status_events``
247
+ for the connection logger (fail-open observer side-channel).
248
+ """
249
+ self._status_events = []
250
+ # host 服务 svc 变化时复位状态机
251
+ if self._last_host_svc != self.svc:
252
+ self.reset_host_state()
253
+ self._last_host_svc = self.svc
254
+ out = bytearray()
255
+ pos = 0
256
+ n = len(data)
257
+ response_kind = ServiceClassifier.host_response_kind(self.svc)
258
+ while pos < n:
259
+ if self.host_phase == "status":
260
+ need = 4 - len(self.host_status_buf)
261
+ take = min(need, n - pos)
262
+ self.host_status_buf.extend(data[pos : pos + take])
263
+ pos += take
264
+ if len(self.host_status_buf) < 4:
265
+ return bytes(out)
266
+ status = bytes(self.host_status_buf).decode("ascii", errors="ignore")
267
+ self.host_status_buf = bytearray()
268
+ if status in ("OKAY", "FAIL"):
269
+ self._status_events.append(status)
270
+ self.host_track_active = status == "OKAY" and response_kind == "track"
271
+ if status == "FAIL":
272
+ self.host_phase = "hex"
273
+ elif response_kind == "status":
274
+ self.host_phase = "done"
275
+ elif response_kind == "fixed8":
276
+ self.host_fixed_remaining = 8
277
+ self.host_phase = "fixed"
278
+ elif response_kind == "forward":
279
+ self.host_phase = "second_status"
280
+ else:
281
+ self.host_phase = "hex"
282
+ else:
283
+ out.extend(status.encode("ascii", errors="ignore"))
284
+ self.host_phase = "done"
285
+ elif self.host_phase == "second_status":
286
+ need = 4 - len(self.host_status_buf)
287
+ take = min(need, n - pos)
288
+ self.host_status_buf.extend(data[pos : pos + take])
289
+ pos += take
290
+ if len(self.host_status_buf) < 4:
291
+ return bytes(out)
292
+ status = bytes(self.host_status_buf).decode("ascii", errors="ignore")
293
+ self.host_status_buf = bytearray()
294
+ if status in ("OKAY", "FAIL"):
295
+ self._status_events.append(status)
296
+ self.host_phase = "hex"
297
+ else:
298
+ out.extend(status.encode("ascii", errors="ignore"))
299
+ self.host_phase = "done"
300
+ elif self.host_phase == "fixed":
301
+ take = min(self.host_fixed_remaining, n - pos)
302
+ out.extend(data[pos : pos + take])
303
+ pos += take
304
+ self.host_fixed_remaining -= take
305
+ if self.host_fixed_remaining == 0:
306
+ self.host_phase = "done"
307
+ elif self.host_phase == "hex":
308
+ need = 4 - len(self.host_hex_buf)
309
+ take = min(need, n - pos)
310
+ self.host_hex_buf.extend(data[pos : pos + take])
311
+ pos += take
312
+ if len(self.host_hex_buf) < 4:
313
+ return bytes(out)
314
+ hex_str = bytes(self.host_hex_buf).decode("ascii", errors="ignore")
315
+ self.host_hex_buf = bytearray()
316
+ if all(c in "0123456789abcdefABCDEF" for c in hex_str):
317
+ self.host_payload_remaining = int(hex_str, 16)
318
+ self.host_phase = "payload"
319
+ else:
320
+ out.extend(hex_str.encode("ascii", errors="ignore"))
321
+ self.host_phase = "done"
322
+ elif self.host_phase == "payload":
323
+ if self.host_payload_remaining == 0:
324
+ self.host_phase = "hex" if self.host_track_active else "status"
325
+ continue
326
+ take = min(self.host_payload_remaining, n - pos)
327
+ out.extend(data[pos : pos + take])
328
+ pos += take
329
+ self.host_payload_remaining -= take
330
+ if self.host_payload_remaining == 0:
331
+ self.host_phase = "hex" if self.host_track_active else "status"
332
+ else: # "done"
333
+ out.extend(data[pos:])
334
+ pos = n
335
+ return bytes(out)