code-server-sync 0.0.0-stage → 0.1.0
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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +251 -2
- package/bin/cs-sync.js +68 -0
- package/cs_sync/__init__.py +0 -0
- package/cs_sync/__main__.py +6 -0
- package/cs_sync/cli.py +650 -0
- package/cs_sync/client.py +186 -0
- package/cs_sync/config.py +184 -0
- package/cs_sync/files.py +199 -0
- package/cs_sync/git.py +683 -0
- package/cs_sync/ignore.py +165 -0
- package/cs_sync/merge.py +279 -0
- package/cs_sync/protocol.py +28 -0
- package/cs_sync/self_test.py +730 -0
- package/cs_sync/server.py +360 -0
- package/cs_sync/sync.py +812 -0
- package/package.json +19 -4
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
|
|
14
|
+
from cs_sync.files import FileIndex, remove_file, safe_destination, write_file
|
|
15
|
+
from cs_sync.git import StageError, collect_changes, stage_changes
|
|
16
|
+
from cs_sync.protocol import (
|
|
17
|
+
DEFAULT_PORT,
|
|
18
|
+
MAX_BYTES,
|
|
19
|
+
ROUTE_CHANGES,
|
|
20
|
+
ROUTE_DELETE,
|
|
21
|
+
ROUTE_FILE,
|
|
22
|
+
ROUTE_HEALTH,
|
|
23
|
+
ROUTE_IGNORE,
|
|
24
|
+
ROUTE_MANIFEST,
|
|
25
|
+
ROUTE_PULL,
|
|
26
|
+
ROUTE_STAGE,
|
|
27
|
+
eprint,
|
|
28
|
+
route_of,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def make_handler(root: str, token: str, index: FileIndex):
|
|
32
|
+
class Handler(BaseHTTPRequestHandler):
|
|
33
|
+
protocol_version = "HTTP/1.1"
|
|
34
|
+
|
|
35
|
+
def log_message(self, fmt: str, *args) -> None:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
def _send(self, status: int, body: bytes, content_type: str = "text/plain; charset=utf-8") -> None:
|
|
39
|
+
self.send_response(status)
|
|
40
|
+
self.send_header("Content-Type", content_type)
|
|
41
|
+
self.send_header("Content-Length", str(len(body)))
|
|
42
|
+
self.send_header("Cache-Control", "no-store")
|
|
43
|
+
self.end_headers()
|
|
44
|
+
self.wfile.write(body)
|
|
45
|
+
|
|
46
|
+
def _authorized(self) -> bool:
|
|
47
|
+
given = self.headers.get("X-Sync-Token", "")
|
|
48
|
+
if not token or len(given) != len(token):
|
|
49
|
+
return False
|
|
50
|
+
return hmac.compare_digest(given, token)
|
|
51
|
+
|
|
52
|
+
def _rel_path(self) -> str:
|
|
53
|
+
raw = self.headers.get("X-Rel-Path", "")
|
|
54
|
+
if not raw:
|
|
55
|
+
raise ValueError("缺少 X-Rel-Path")
|
|
56
|
+
return urllib.parse.unquote(raw)
|
|
57
|
+
|
|
58
|
+
def _read_body(self) -> bytes:
|
|
59
|
+
length_raw = self.headers.get("Content-Length")
|
|
60
|
+
if length_raw is None:
|
|
61
|
+
raise ValueError("缺少 Content-Length")
|
|
62
|
+
length = int(length_raw)
|
|
63
|
+
if length < 0 or length > MAX_BYTES:
|
|
64
|
+
raise ValueError("文件过大")
|
|
65
|
+
payload = self.rfile.read(length) if length else b""
|
|
66
|
+
if len(payload) != length:
|
|
67
|
+
raise ValueError("请求体不完整")
|
|
68
|
+
return payload
|
|
69
|
+
|
|
70
|
+
def do_GET(self) -> None: # noqa: N802
|
|
71
|
+
route = route_of(self.path)
|
|
72
|
+
if route == ROUTE_HEALTH:
|
|
73
|
+
self._send(200, b"ok\n")
|
|
74
|
+
return
|
|
75
|
+
if not self._authorized():
|
|
76
|
+
self._send(403, b"bad token\n")
|
|
77
|
+
return
|
|
78
|
+
if route == ROUTE_MANIFEST:
|
|
79
|
+
body = json.dumps({"files": index.manifest()}).encode("utf-8")
|
|
80
|
+
self._send(200, body, "application/json")
|
|
81
|
+
return
|
|
82
|
+
if route == ROUTE_PULL:
|
|
83
|
+
try:
|
|
84
|
+
rel = self._rel_path()
|
|
85
|
+
dest = safe_destination(root, rel)
|
|
86
|
+
if not os.path.isfile(dest) or os.path.islink(dest):
|
|
87
|
+
self._send(404, b"missing\n")
|
|
88
|
+
return
|
|
89
|
+
if os.path.getsize(dest) > MAX_BYTES:
|
|
90
|
+
self._send(400, ("文件过大\n").encode("utf-8"))
|
|
91
|
+
return
|
|
92
|
+
with open(dest, "rb") as handle:
|
|
93
|
+
payload = handle.read(MAX_BYTES + 1)
|
|
94
|
+
if len(payload) > MAX_BYTES:
|
|
95
|
+
self._send(400, ("文件过大\n").encode("utf-8"))
|
|
96
|
+
return
|
|
97
|
+
mode = "755" if os.stat(dest).st_mode & 0o111 else "644"
|
|
98
|
+
except (ValueError, OSError) as exc:
|
|
99
|
+
self._send(400, (str(exc) + "\n").encode("utf-8"))
|
|
100
|
+
return
|
|
101
|
+
self.send_response(200)
|
|
102
|
+
self.send_header("Content-Type", "application/octet-stream")
|
|
103
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
104
|
+
self.send_header("X-File-Mode", mode)
|
|
105
|
+
self.send_header("Cache-Control", "no-store")
|
|
106
|
+
self.end_headers()
|
|
107
|
+
self.wfile.write(payload)
|
|
108
|
+
return
|
|
109
|
+
self._send(404, b"not found\n")
|
|
110
|
+
|
|
111
|
+
def do_POST(self) -> None: # noqa: N802
|
|
112
|
+
route = route_of(self.path)
|
|
113
|
+
if route not in (ROUTE_FILE, ROUTE_DELETE, ROUTE_IGNORE, ROUTE_STAGE, ROUTE_CHANGES):
|
|
114
|
+
self._send(404, b"not found\n")
|
|
115
|
+
return
|
|
116
|
+
if not self._authorized():
|
|
117
|
+
self._send(403, b"bad token\n")
|
|
118
|
+
return
|
|
119
|
+
try:
|
|
120
|
+
if route == ROUTE_STAGE:
|
|
121
|
+
if self.headers.get("Content-Length") not in (None, "0"):
|
|
122
|
+
self._read_body()
|
|
123
|
+
try:
|
|
124
|
+
staged, repo_count = stage_changes(root, set(index.ignore))
|
|
125
|
+
except StageError as exc:
|
|
126
|
+
self._send(500, (str(exc) + "\n").encode("utf-8"))
|
|
127
|
+
return
|
|
128
|
+
body = json.dumps({"staged": staged, "repos": repo_count}).encode("utf-8")
|
|
129
|
+
self._send(200, body, "application/json")
|
|
130
|
+
return
|
|
131
|
+
if route == ROUTE_CHANGES:
|
|
132
|
+
if self.headers.get("Content-Length") not in (None, "0"):
|
|
133
|
+
self._read_body()
|
|
134
|
+
try:
|
|
135
|
+
changes, repo_count = collect_changes(root, set(index.ignore))
|
|
136
|
+
except StageError as exc:
|
|
137
|
+
self._send(500, (str(exc) + "\n").encode("utf-8"))
|
|
138
|
+
return
|
|
139
|
+
body = json.dumps({"changes": changes, "repos": repo_count}).encode("utf-8")
|
|
140
|
+
self._send(200, body, "application/json")
|
|
141
|
+
return
|
|
142
|
+
if route == ROUTE_IGNORE:
|
|
143
|
+
payload = self._read_body()
|
|
144
|
+
names = json.loads(payload.decode("utf-8")).get("dirs") or []
|
|
145
|
+
index.set_ignore(names)
|
|
146
|
+
index.scan()
|
|
147
|
+
self._send(200, b"ok\n")
|
|
148
|
+
return
|
|
149
|
+
rel = self._rel_path()
|
|
150
|
+
if route == ROUTE_FILE:
|
|
151
|
+
payload = self._read_body()
|
|
152
|
+
mode = 0o644
|
|
153
|
+
mode_raw = self.headers.get("X-File-Mode", "644")
|
|
154
|
+
if mode_raw == "755":
|
|
155
|
+
mode = 0o755
|
|
156
|
+
write_file(root, rel, payload, mode)
|
|
157
|
+
index.note_write(rel, payload)
|
|
158
|
+
print("云端写入 %s (%d 字节)" % (rel, len(payload)), flush=True)
|
|
159
|
+
else:
|
|
160
|
+
if self.headers.get("Content-Length") not in (None, "0"):
|
|
161
|
+
self._read_body()
|
|
162
|
+
remove_file(root, rel)
|
|
163
|
+
index.note_delete(rel)
|
|
164
|
+
print("云端写入(删除) %s" % rel, flush=True)
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
self._send(400, (str(exc) + "\n").encode("utf-8"))
|
|
167
|
+
return
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
self._send(500, (str(exc) + "\n").encode("utf-8"))
|
|
170
|
+
return
|
|
171
|
+
self._send(200, b"ok\n")
|
|
172
|
+
|
|
173
|
+
return Handler
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class PortProbeError(Exception):
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def listening_pids_linux(port: int) -> list:
|
|
181
|
+
hexport = "%04X" % port
|
|
182
|
+
inodes = set()
|
|
183
|
+
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
184
|
+
try:
|
|
185
|
+
lines = open(path).read().splitlines()[1:]
|
|
186
|
+
except OSError:
|
|
187
|
+
continue
|
|
188
|
+
for line in lines:
|
|
189
|
+
parts = line.split()
|
|
190
|
+
if len(parts) < 10:
|
|
191
|
+
continue
|
|
192
|
+
local, state, inode = parts[1], parts[3], parts[9]
|
|
193
|
+
if local.rsplit(":", 1)[-1].upper() == hexport and state == "0A":
|
|
194
|
+
inodes.add(inode)
|
|
195
|
+
pids = []
|
|
196
|
+
try:
|
|
197
|
+
proc_names = os.listdir("/proc")
|
|
198
|
+
except OSError:
|
|
199
|
+
return pids
|
|
200
|
+
for name in proc_names:
|
|
201
|
+
if not name.isdigit():
|
|
202
|
+
continue
|
|
203
|
+
fd_dir = "/proc/%s/fd" % name
|
|
204
|
+
try:
|
|
205
|
+
fds = os.listdir(fd_dir)
|
|
206
|
+
except OSError:
|
|
207
|
+
continue
|
|
208
|
+
for fd in fds:
|
|
209
|
+
try:
|
|
210
|
+
target = os.readlink("%s/%s" % (fd_dir, fd))
|
|
211
|
+
except OSError:
|
|
212
|
+
continue
|
|
213
|
+
if target.startswith("socket:[") and target[8:-1] in inodes:
|
|
214
|
+
pids.append(int(name))
|
|
215
|
+
break
|
|
216
|
+
return pids
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def listening_pids_lsof(port: int) -> list:
|
|
220
|
+
try:
|
|
221
|
+
result = subprocess.run(
|
|
222
|
+
["lsof", "-nP", "-t", "-iTCP:%d" % port, "-sTCP:LISTEN"],
|
|
223
|
+
stdout=subprocess.PIPE,
|
|
224
|
+
stderr=subprocess.PIPE,
|
|
225
|
+
check=False,
|
|
226
|
+
)
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
raise PortProbeError("无法执行 lsof:%s" % exc)
|
|
229
|
+
# lsof 在没有匹配时退出码为 1
|
|
230
|
+
if result.returncode not in (0, 1):
|
|
231
|
+
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
232
|
+
raise PortProbeError(detail or ("lsof 失败,退出码 %d" % result.returncode))
|
|
233
|
+
pids = []
|
|
234
|
+
for line in result.stdout.decode("utf-8", errors="replace").splitlines():
|
|
235
|
+
text = line.strip()
|
|
236
|
+
if text.isdigit():
|
|
237
|
+
pids.append(int(text))
|
|
238
|
+
return pids
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def listening_pids(port: int) -> list:
|
|
242
|
+
if os.path.isdir("/proc") and os.path.exists("/proc/net/tcp"):
|
|
243
|
+
return listening_pids_linux(port)
|
|
244
|
+
return listening_pids_lsof(port)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def process_command_line(pid: int) -> str:
|
|
248
|
+
try:
|
|
249
|
+
return open("/proc/%d/cmdline" % pid, "rb").read().replace(b"\0", b" ").decode()
|
|
250
|
+
except OSError:
|
|
251
|
+
pass
|
|
252
|
+
try:
|
|
253
|
+
result = subprocess.run(
|
|
254
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
255
|
+
stdout=subprocess.PIPE,
|
|
256
|
+
stderr=subprocess.PIPE,
|
|
257
|
+
check=False,
|
|
258
|
+
)
|
|
259
|
+
except OSError:
|
|
260
|
+
return ""
|
|
261
|
+
if result.returncode != 0:
|
|
262
|
+
return ""
|
|
263
|
+
return result.stdout.decode("utf-8", errors="replace").strip()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_our_recv(command: str) -> bool:
|
|
267
|
+
text = command or ""
|
|
268
|
+
if "cs-sync.py" in text:
|
|
269
|
+
return True
|
|
270
|
+
return "cs_sync" in text and "recv" in text
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def release_sync_port(port: int) -> None:
|
|
274
|
+
try:
|
|
275
|
+
found = listening_pids(port)
|
|
276
|
+
except PortProbeError as exc:
|
|
277
|
+
raise SystemExit(
|
|
278
|
+
"端口 %d 被占用,但无法列出监听进程:%s\n"
|
|
279
|
+
"请手动检查并结束后再启动:lsof -nP -iTCP:%d -sTCP:LISTEN"
|
|
280
|
+
% (port, exc, port)
|
|
281
|
+
)
|
|
282
|
+
pids = [pid for pid in found if pid != os.getpid()]
|
|
283
|
+
if not pids:
|
|
284
|
+
raise SystemExit(
|
|
285
|
+
"端口 %d 被占用,但未找到可停止的监听进程。\n"
|
|
286
|
+
"请手动检查:lsof -nP -iTCP:%d -sTCP:LISTEN"
|
|
287
|
+
% (port, port)
|
|
288
|
+
)
|
|
289
|
+
for pid in pids:
|
|
290
|
+
command = process_command_line(pid)
|
|
291
|
+
if not is_our_recv(command):
|
|
292
|
+
raise SystemExit("端口 %d 被其他程序占用:%s %s" % (port, pid, command))
|
|
293
|
+
print("停掉已在端口 %d 上运行的接收程序 %d" % (port, pid), flush=True)
|
|
294
|
+
os.kill(pid, signal.SIGTERM)
|
|
295
|
+
for _ in range(50):
|
|
296
|
+
try:
|
|
297
|
+
remaining = listening_pids(port)
|
|
298
|
+
except PortProbeError:
|
|
299
|
+
break
|
|
300
|
+
if not [pid for pid in remaining if pid != os.getpid()]:
|
|
301
|
+
return
|
|
302
|
+
time.sleep(0.1)
|
|
303
|
+
try:
|
|
304
|
+
remaining = listening_pids(port)
|
|
305
|
+
except PortProbeError as exc:
|
|
306
|
+
eprint("端口 %d 仍可能被占用,且无法再次列出进程:%s" % (port, exc))
|
|
307
|
+
return
|
|
308
|
+
for pid in remaining:
|
|
309
|
+
if pid == os.getpid():
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
os.kill(pid, signal.SIGKILL)
|
|
313
|
+
except OSError:
|
|
314
|
+
pass
|
|
315
|
+
time.sleep(0.2)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class ReuseHTTPServer(ThreadingHTTPServer):
|
|
319
|
+
allow_reuse_address = True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def serve(root: str, token: str, port: int) -> None:
|
|
323
|
+
if not os.path.isabs(root):
|
|
324
|
+
raise SystemExit("云端项目路径必须是绝对路径")
|
|
325
|
+
if len(token) < 16:
|
|
326
|
+
raise SystemExit("令牌太短")
|
|
327
|
+
os.makedirs(root, exist_ok=True)
|
|
328
|
+
index = FileIndex(root)
|
|
329
|
+
index.scan()
|
|
330
|
+
|
|
331
|
+
def refresh() -> None:
|
|
332
|
+
while True:
|
|
333
|
+
time.sleep(1)
|
|
334
|
+
try:
|
|
335
|
+
index.scan(announce=True)
|
|
336
|
+
except OSError as exc:
|
|
337
|
+
eprint("扫描云端目录失败: %s" % exc)
|
|
338
|
+
|
|
339
|
+
threading.Thread(target=refresh, daemon=True).start()
|
|
340
|
+
try:
|
|
341
|
+
server = ReuseHTTPServer(("0.0.0.0", port), make_handler(root, token, index))
|
|
342
|
+
except OSError as exc:
|
|
343
|
+
if getattr(exc, "errno", None) not in (errno.EADDRINUSE, 98):
|
|
344
|
+
raise
|
|
345
|
+
release_sync_port(port)
|
|
346
|
+
try:
|
|
347
|
+
server = ReuseHTTPServer(("0.0.0.0", port), make_handler(root, token, index))
|
|
348
|
+
except OSError as again:
|
|
349
|
+
if getattr(again, "errno", None) in (errno.EADDRINUSE, 98):
|
|
350
|
+
raise SystemExit("端口 %d 仍被占用。先停掉旧的 cs-sync recv,再启动。" % port)
|
|
351
|
+
raise
|
|
352
|
+
server.daemon_threads = True
|
|
353
|
+
print("接收程序已听在 0.0.0.0:%d" % port, flush=True)
|
|
354
|
+
print("项目目录: %s" % root, flush=True)
|
|
355
|
+
print("本机经 code-server 访问时使用 /proxy/%d/" % port, flush=True)
|
|
356
|
+
try:
|
|
357
|
+
server.serve_forever()
|
|
358
|
+
except KeyboardInterrupt:
|
|
359
|
+
print("\n已停止", flush=True)
|
|
360
|
+
server.server_close()
|