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,304 @@
1
+ """Device transport session decision layer (Track 2b).
2
+
3
+ ``AdbdSession.receive`` / ``on_session_event`` produce TransportAction lists.
4
+ No socket I/O, no thread join, no ``_try_flush`` algorithm.
5
+ Connection executes actions and owns flush/backpressure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from adbproxy.device.banner import build_cnxn_banner
14
+ from adbproxy.protocols.transport import (
15
+ A_AUTH,
16
+ A_CLSE,
17
+ A_CNXN,
18
+ A_OKAY,
19
+ A_OPEN,
20
+ A_STLS,
21
+ A_VERSION,
22
+ A_WRTE,
23
+ LOCAL_MAXDATA,
24
+ )
25
+
26
+
27
+ # ---- TransportAction (plan §4.2.3 A) ----
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Ignore:
32
+ reason: str = ""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class RejectOpen:
37
+ peer_local: int
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class AcceptOpen:
42
+ peer_local: int
43
+ service: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class AttachObserver:
48
+ entry: Any # StreamEntry
49
+ service: str
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class OpenBackend:
54
+ entry: Any
55
+ generation: int
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class SendPacket:
60
+ cmd: int
61
+ arg0: int
62
+ arg1: int
63
+ payload: bytes = b""
64
+ log_status: str | None = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ForwardToBackend:
69
+ entry: Any
70
+ payload: bytes
71
+ also_okay: bool = True
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class QueueFromBackend:
76
+ entry: Any
77
+ data: bytes
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class SetCanSend:
82
+ entry: Any
83
+ value: bool = True
84
+ flush: bool = True
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class CloseStream:
89
+ entry: Any
90
+ send_clse: bool = True
91
+ close_arg0: int | None = None
92
+ close_arg1: int | None = None
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class MarkTransportTerminal:
97
+ reason: str
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ResetStreamsForCnxn:
102
+ """Close all existing streams without sending CLSE (CNXN reset)."""
103
+
104
+ pass
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class NegotiateCodec:
109
+ version: int
110
+ peer_maxdata: int
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class LogEvent:
115
+ message: str
116
+
117
+
118
+ # Session events from workers (plan §4.2.3 B)
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class BackendReady:
123
+ entry: Any
124
+ generation: int
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class BackendFailed:
129
+ entry: Any
130
+ generation: int
131
+
132
+
133
+ class AdbdSession:
134
+ """Pure-ish decision engine for one front transport connection.
135
+
136
+ Holds CNXN flag, banner config, and stream_table reference. Mutates stream
137
+ table state under caller-held conventions; never performs socket I/O.
138
+ """
139
+
140
+ def __init__(
141
+ self,
142
+ stream_table,
143
+ *,
144
+ banner_features: str,
145
+ banner_type: str,
146
+ banner_product,
147
+ banner_model,
148
+ banner_device,
149
+ peer_maxdata: int,
150
+ ):
151
+ self.stream_table = stream_table
152
+ self.banner_features = banner_features
153
+ self.banner_type = banner_type
154
+ self.banner_product = banner_product
155
+ self.banner_model = banner_model
156
+ self.banner_device = banner_device
157
+ self.peer_maxdata = peer_maxdata
158
+ self._cnxn_done = False
159
+
160
+ @property
161
+ def cnxn_done(self) -> bool:
162
+ return self._cnxn_done
163
+
164
+ def receive(self, packet) -> list:
165
+ """Map one transport packet to a list of TransportAction."""
166
+ cmd, arg0, arg1, payload = packet
167
+ if cmd == A_CNXN:
168
+ return self._on_cnxn(arg0, arg1, payload)
169
+ if cmd == A_OPEN:
170
+ return self._on_open(arg0, arg1, payload)
171
+ if cmd == A_OKAY:
172
+ return self._on_okay(arg0, arg1, payload)
173
+ if cmd == A_WRTE:
174
+ return self._on_wrte(arg0, arg1, payload)
175
+ if cmd == A_CLSE:
176
+ return self._on_clse(arg0, arg1, payload)
177
+ if cmd == A_STLS:
178
+ return [MarkTransportTerminal("STLS/TLS 首版不支持")]
179
+ if cmd == A_AUTH:
180
+ return [MarkTransportTerminal("AUTH 配对模拟首版不支持")]
181
+ return [LogEvent(f"[!] 未知传输命令 0x{cmd:08x}")]
182
+
183
+ def on_session_event(self, event) -> list:
184
+ """Handle BackendReady / BackendFailed from worker threads."""
185
+ if isinstance(event, BackendReady):
186
+ return self._on_backend_ready(event.entry, event.generation)
187
+ if isinstance(event, BackendFailed):
188
+ return self._on_backend_failed(event.entry, event.generation)
189
+ return [Ignore(reason=f"unknown event {type(event)!r}")]
190
+
191
+ def _on_cnxn(self, arg0, arg1, payload) -> list:
192
+ actions: list = [ResetStreamsForCnxn()]
193
+ self._cnxn_done = False
194
+ self.peer_maxdata = arg1 or self.peer_maxdata
195
+ parts = payload.split(b"::", 1)
196
+ banner = parts[1].decode("utf-8", errors="replace") if len(parts) > 1 else ""
197
+ actions.append(LogEvent("========"))
198
+ actions.append(
199
+ LogEvent(f"📥 [CNXN]: version=0x{arg0:08x} maxdata={self.peer_maxdata}")
200
+ )
201
+ if banner:
202
+ actions.append(LogEvent(f" banner: {banner}"))
203
+ actions.append(NegotiateCodec(arg0, self.peer_maxdata))
204
+ our_banner = build_cnxn_banner(
205
+ banner_type=self.banner_type,
206
+ features=self.banner_features,
207
+ product=self.banner_product,
208
+ model=self.banner_model,
209
+ device=self.banner_device,
210
+ )
211
+ actions.append(
212
+ SendPacket(
213
+ A_CNXN,
214
+ A_VERSION,
215
+ LOCAL_MAXDATA,
216
+ our_banner.encode("utf-8"),
217
+ )
218
+ )
219
+ self._cnxn_done = True
220
+ return actions
221
+
222
+ def _on_open(self, arg0, arg1, payload) -> list:
223
+ if not self._cnxn_done:
224
+ return [
225
+ LogEvent("[!] OPEN 在 CNXN 握手前到达,忽略该包"),
226
+ Ignore(reason="open_before_cnxn"),
227
+ ]
228
+ if arg0 == 0:
229
+ return [
230
+ LogEvent("[!] OPEN local ID 为 0,忽略该包"),
231
+ Ignore(reason="open_zero_local"),
232
+ ]
233
+ advertised_features = set((self.banner_features or "").split(","))
234
+ if arg1 != 0 and "delayed_ack" not in advertised_features:
235
+ return [
236
+ LogEvent(
237
+ f"[!] OPEN send-buffer={arg1} 但未广告 delayed_ack,关闭该流"
238
+ ),
239
+ RejectOpen(peer_local=arg0),
240
+ ]
241
+ peer_local = arg0
242
+ service = payload.rstrip(b"\x00").decode("utf-8", errors="ignore")
243
+ entry = self.stream_table.allocate(peer_local, service)
244
+ entry.state = "opening"
245
+ entry.generation = entry.local_id
246
+ return [
247
+ AcceptOpen(peer_local=peer_local, service=service),
248
+ AttachObserver(entry=entry, service=service),
249
+ OpenBackend(entry=entry, generation=entry.generation),
250
+ ]
251
+
252
+ def _on_okay(self, arg0, arg1, payload) -> list:
253
+ entry = self.stream_table.find_by_pair(arg1, arg0)
254
+ if entry is None:
255
+ return [Ignore(reason="okay_unknown_pair")]
256
+ return [SetCanSend(entry=entry, value=True, flush=True)]
257
+
258
+ def _on_wrte(self, arg0, arg1, payload) -> list:
259
+ entry = self.stream_table.find_by_pair(arg1, arg0)
260
+ if entry is None:
261
+ return [Ignore(reason="wrte_unknown_pair")]
262
+ return [ForwardToBackend(entry=entry, payload=payload, also_okay=True)]
263
+
264
+ def _on_clse(self, arg0, arg1, payload) -> list:
265
+ entry = self.stream_table.find_by_pair(arg1, arg0)
266
+ if entry is None and arg0 == 0 and arg1:
267
+ candidate = self.stream_table.find_by_remote(arg1)
268
+ if candidate is not None and candidate.state == "opening":
269
+ entry = candidate
270
+ if entry is None:
271
+ return [Ignore(reason="clse_unknown")]
272
+ return [CloseStream(entry=entry)]
273
+
274
+ def _on_backend_ready(self, entry, generation) -> list:
275
+ current = self.stream_table.get(entry.local_id)
276
+ with entry.lock:
277
+ if (
278
+ current is not entry
279
+ or entry.generation != generation
280
+ or entry.closed
281
+ or entry.state != "opening"
282
+ ):
283
+ return [Ignore(reason="backend_ready_stale")]
284
+ entry.state = "ready"
285
+ entry.can_send = True
286
+ # OKAY first; connection flushes after send (SetCanSend flush=True)
287
+ return [
288
+ SendPacket(A_OKAY, entry.local_id, entry.remote_id),
289
+ SetCanSend(entry=entry, value=True, flush=True),
290
+ ]
291
+
292
+ def _on_backend_failed(self, entry, generation) -> list:
293
+ current = self.stream_table.get(entry.local_id)
294
+ with entry.lock:
295
+ if current is not entry or entry.generation != generation:
296
+ return [Ignore(reason="backend_failed_stale")]
297
+ return [
298
+ CloseStream(
299
+ entry=entry,
300
+ send_clse=True,
301
+ close_arg0=0,
302
+ close_arg1=entry.remote_id,
303
+ )
304
+ ]
@@ -0,0 +1,125 @@
1
+ """Device transport stream registry (Track 2a).
2
+
3
+ Dual-lock semantics (must preserve):
4
+ - ``lock`` (registry): streams dict, next local id allocation, find/pop
5
+ - ``entry.lock`` / ``entry.flow_cv``: per-stream queue, can_send, closed, flush wait
6
+
7
+ No TransportAction; no flush algorithm.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ from collections import deque
14
+
15
+
16
+ class StreamEntry:
17
+ """per-stream 状态:本端/对端 id、service、后端、v1 停等流控标志、幂等 closed、
18
+ per-stream StreamBuffer(隔离 shell/sync/install 解析,防多 stream 串流)。"""
19
+
20
+ __slots__ = (
21
+ "local_id",
22
+ "remote_id",
23
+ "service",
24
+ "back",
25
+ "can_send",
26
+ "back_eof",
27
+ "closed",
28
+ "send_queue",
29
+ "queued_bytes",
30
+ "lock",
31
+ "flow_cv",
32
+ "sb",
33
+ "state",
34
+ "generation",
35
+ )
36
+
37
+ def __init__(self, local_id, remote_id, service):
38
+ self.local_id = local_id
39
+ self.remote_id = remote_id
40
+ self.service = service
41
+ self.back = None
42
+ self.can_send = False
43
+ self.back_eof = False
44
+ self.closed = False
45
+ self.send_queue = deque()
46
+ self.queued_bytes = 0
47
+ self.lock = threading.Lock()
48
+ self.flow_cv = threading.Condition(self.lock)
49
+ self.sb = None
50
+ self.state = "ready"
51
+ self.generation = 0
52
+
53
+
54
+ # Backward-compatible alias for tests that import ``_StreamEntry``.
55
+ _StreamEntry = StreamEntry
56
+
57
+
58
+ class StreamTable:
59
+ """Registry of open streams for one TransportConnection.
60
+
61
+ Owns the registry lock and stream map. Entry-level locks remain on StreamEntry.
62
+ """
63
+
64
+ def __init__(self):
65
+ self._lock = threading.Lock()
66
+ self._streams: dict = {}
67
+ self._next_local_id = 1
68
+
69
+ @property
70
+ def lock(self):
71
+ """Registry lock (same role as TransportConnection._streams_lock)."""
72
+ return self._lock
73
+
74
+ @property
75
+ def streams(self) -> dict:
76
+ return self._streams
77
+
78
+ def allocate(self, remote_id, service) -> StreamEntry:
79
+ """Allocate local id, create StreamEntry, insert into map. Holds registry lock."""
80
+ with self._lock:
81
+ local_id = self._next_local_id
82
+ self._next_local_id += 1
83
+ entry = StreamEntry(local_id, remote_id, service)
84
+ self._streams[local_id] = entry
85
+ return entry
86
+
87
+ def get(self, local_id):
88
+ with self._lock:
89
+ return self._streams.get(local_id)
90
+
91
+ def find_by_local(self, local_id):
92
+ return self.get(local_id)
93
+
94
+ def find_by_remote(self, remote_id):
95
+ with self._lock:
96
+ for e in self._streams.values():
97
+ if e.remote_id == remote_id:
98
+ return e
99
+ return None
100
+
101
+ def find_by_pair(self, local_id, remote_id):
102
+ if not local_id or not remote_id:
103
+ return None
104
+ with self._lock:
105
+ entry = self._streams.get(local_id)
106
+ if entry is not None and entry.remote_id == remote_id:
107
+ return entry
108
+ return None
109
+
110
+ def remove_if_same(self, entry: StreamEntry) -> bool:
111
+ """Pop entry from registry if map still points at this object."""
112
+ with self._lock:
113
+ if self._streams.get(entry.local_id) is entry:
114
+ self._streams.pop(entry.local_id, None)
115
+ return True
116
+ return False
117
+
118
+ def snapshot(self) -> list:
119
+ """List current entries (for CNXN reset / finish)."""
120
+ with self._lock:
121
+ return list(self._streams.values())
122
+
123
+ def clear_map_locked(self):
124
+ """Caller already holds registry lock; clear map without releasing."""
125
+ self._streams.clear()
@@ -0,0 +1 @@
1
+ """只读的 shell、sync 和 install 流观察器。"""
@@ -0,0 +1,19 @@
1
+ """Shallow observer events for StreamBuffer routing.
2
+
3
+ O-track (phase2): only ClientBytes/ServerBytes. Activate* and deep
4
+ session-internal event SMs are follow-ups — do not expand here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ClientBytes:
14
+ data: bytes
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ServerBytes:
19
+ data: bytes
@@ -0,0 +1,126 @@
1
+ import os
2
+ import threading
3
+
4
+ from adbproxy.config import MAX_SYNC_FILE
5
+ from adbproxy.runtime.capture import open_unique_capture
6
+
7
+
8
+ class InstallStreamCapture:
9
+ """adb install 流式 raw 字节捕获:apk 内容只落盘,绝不进控制台/日志。
10
+
11
+ 命中 abb_exec:package install(-write)? -S <size> 后激活,
12
+ 按 -S size 计数捕获 client→server 的 raw apk 流;server→client 只回 Success/Failure
13
+ 短文本,不在此落盘(交碎片流走可读路径)。单文件上限复用 MAX_SYNC_FILE。
14
+ """
15
+
16
+ def __init__(self, logger, files_dir, capture_enabled=True):
17
+ self.logger = logger
18
+ self.files_dir = files_dir
19
+ self.capture_enabled = capture_enabled
20
+ self.lock = threading.Lock()
21
+ self.remaining = 0 # 待捕获字节数(按 -S size 递减)
22
+ self.total_size = 0
23
+ self.remote_label = None # service 串或其中的 name,用于事件与文件命名
24
+ self.accumulated = 0 # 已落盘字节数
25
+ self.file_fp = None # 落盘文件句柄("wb" 覆写,时间戳防重名)
26
+ self.capture_path = None
27
+ self.capture_overflow = False # 超 MAX_SYNC_FILE 后停止写盘
28
+
29
+ @property
30
+ def active(self):
31
+ return self.remaining > 0
32
+
33
+ def activate(self, total_size, remote_label):
34
+ """由 Connection 在 client 首帧命中 install 流式服务时调用。开 capture 文件并置计数。"""
35
+ with self.lock:
36
+ self.total_size = total_size
37
+ self.remaining = total_size
38
+ self.accumulated = 0
39
+ self.capture_overflow = False
40
+ self.remote_label = remote_label
41
+ self._open_capture()
42
+
43
+ def on_client(self, data):
44
+ """捕获 client→server raw apk 字节。返回 take(本次消耗字节数),
45
+ data[take:] 由调用方交回可读路径。apk 字节绝不进 StreamBuffer.c_buf。"""
46
+ if not data or self.remaining <= 0:
47
+ return 0
48
+ with self.lock:
49
+ if self.remaining <= 0:
50
+ return 0
51
+ take = min(self.remaining, len(data))
52
+ chunk = bytes(data[:take])
53
+ self._write_capture(chunk)
54
+ self.accumulated += len(chunk)
55
+ self.remaining -= take
56
+ if self.remaining <= 0:
57
+ cap = (
58
+ self.capture_path
59
+ if (self.capture_enabled and self.capture_path)
60
+ else None
61
+ )
62
+ if cap:
63
+ # 路径=创建路径(无 rename),唯一 captured
64
+ self.logger.event(
65
+ f"[install captured]: {cap} size={self.total_size}"
66
+ )
67
+ else:
68
+ # --no-capture:无 captured,但仍打 size 摘要不崩
69
+ self.logger.event(f"[install done]: size={self.total_size}")
70
+ self._close_capture()
71
+ return take
72
+
73
+ def on_server(self, data):
74
+ """install 上下文 server 只回 Success/Failure 短文本,不在此落盘。
75
+ 返回 False 让 StreamBuffer 走可读路径(不拦 server)。"""
76
+ return False
77
+
78
+ def _open_capture(self):
79
+ # 统一命名:install-apk-conn{N}-{size}B-{time_ns}-{seq}.apk
80
+ # activate 时 -S size 已知,一次命名无 rename;唯一性由 capture token 保证
81
+ if not self.capture_enabled:
82
+ self.capture_path = None
83
+ self.file_fp = None
84
+ return
85
+ try:
86
+ prefix = f"install-apk-conn{self.logger.conn_id}-{self.total_size}B"
87
+ self.capture_path, self.file_fp = open_unique_capture(
88
+ self.files_dir, prefix, ".apk"
89
+ )
90
+ except IOError as e:
91
+ self.logger.event(f"[!] install capture open failed: {e}")
92
+ self.capture_path = None
93
+ self.file_fp = None
94
+ self.capture_overflow = True
95
+
96
+ def _write_capture(self, payload):
97
+ """流式写盘:每段 payload 直接 write 到已 open file_fp。IOError 内化不冒泡。"""
98
+ if not self.file_fp or self.capture_overflow:
99
+ return
100
+ if self.accumulated + len(payload) > MAX_SYNC_FILE:
101
+ self.logger.event(
102
+ f"[!] install file > {MAX_SYNC_FILE} bytes, stop capturing"
103
+ )
104
+ self._close_capture()
105
+ self.capture_overflow = True
106
+ return
107
+ try:
108
+ self.file_fp.write(payload)
109
+ except IOError as e:
110
+ self.logger.event(f"[!] install capture write failed: {e}")
111
+ self._close_capture()
112
+ self.capture_overflow = True
113
+
114
+ def _close_capture(self):
115
+ if self.file_fp:
116
+ try:
117
+ self.file_fp.close()
118
+ except Exception:
119
+ pass
120
+ self.file_fp = None
121
+
122
+ def close(self):
123
+ """连接关闭时兜底关闭未收完的文件(保留部分落盘便于排查)。"""
124
+ with self.lock:
125
+ self._close_capture()
126
+ self.remaining = 0