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.
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ import json
5
+ import socket
6
+ import ssl
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Optional, Tuple
11
+
12
+ from cs_sync.protocol import (
13
+ MAX_BYTES,
14
+ ROUTE_CHANGES,
15
+ ROUTE_DELETE,
16
+ ROUTE_FILE,
17
+ ROUTE_HEALTH,
18
+ ROUTE_IGNORE,
19
+ ROUTE_MANIFEST,
20
+ ROUTE_PULL,
21
+ ROUTE_STAGE,
22
+ )
23
+
24
+ def ssl_context(insecure: bool) -> ssl.SSLContext:
25
+ if insecure:
26
+ context = ssl._create_unverified_context()
27
+ return context
28
+ return ssl.create_default_context()
29
+
30
+
31
+ def origin_of(endpoint: str) -> str:
32
+ parts = urllib.parse.urlsplit(endpoint)
33
+ return urllib.parse.urlunsplit((parts.scheme, parts.netloc, "", "", ""))
34
+
35
+
36
+ class Client:
37
+ def __init__(self, config: dict):
38
+ self.endpoint = config["endpoint"].rstrip("/")
39
+ self.token = config.get("token") or ""
40
+ self.cookie = config.get("cookie") or ""
41
+ self.auth_optional = config.get("auth") == "none"
42
+ self.insecure = bool(config.get("insecure"))
43
+ self.context = ssl_context(self.insecure)
44
+
45
+ def request(self, route: str, payload: Optional[bytes] = None, headers: Optional[dict] = None, method: Optional[str] = None):
46
+ data = payload
47
+ request_method = method or ("POST" if payload is not None else "GET")
48
+ request = urllib.request.Request(self.endpoint + route, data=data, method=request_method)
49
+ if self.cookie:
50
+ request.add_header("Cookie", self.cookie)
51
+ if self.token and (headers is None or "X-Sync-Token" not in headers):
52
+ request.add_header("X-Sync-Token", self.token)
53
+ if headers:
54
+ for key, value in headers.items():
55
+ request.add_header(key, value)
56
+ if payload is not None:
57
+ request.add_header("Content-Length", str(len(payload)))
58
+ try:
59
+ with urllib.request.urlopen(request, context=self.context, timeout=60) as response:
60
+ return response.status, response.headers, response.read()
61
+ except urllib.error.HTTPError as exc:
62
+ return exc.code, exc.headers, exc.read()
63
+ except urllib.error.URLError:
64
+ raise
65
+ except (TimeoutError, socket.timeout, ConnectionError, ssl.SSLError, http.client.IncompleteRead) as exc:
66
+ # 读超时等瞬时断网:统一成 URLError,供 sync 监视循环捕获后重试
67
+ raise urllib.error.URLError(exc) from exc
68
+
69
+ def open(self, route: str, payload: Optional[bytes] = None, headers: Optional[dict] = None, method: Optional[str] = None):
70
+ status, _headers, body = self.request(route, payload, headers, method)
71
+ return status, body
72
+
73
+ def health(self) -> Tuple[int, bytes]:
74
+ return self.open(ROUTE_HEALTH)
75
+
76
+ def put_file(self, rel: str, payload: bytes, mode: str) -> Tuple[int, bytes]:
77
+ headers = {
78
+ "X-Sync-Token": self.token,
79
+ "X-Rel-Path": urllib.parse.quote(rel, safe="/"),
80
+ "X-File-Mode": mode,
81
+ "Content-Type": "application/octet-stream",
82
+ }
83
+ return self.open(ROUTE_FILE, payload, headers, "POST")
84
+
85
+ def delete_file(self, rel: str) -> Tuple[int, bytes]:
86
+ headers = {
87
+ "X-Sync-Token": self.token,
88
+ "X-Rel-Path": urllib.parse.quote(rel, safe="/"),
89
+ "Content-Length": "0",
90
+ }
91
+ return self.open(ROUTE_DELETE, b"", headers, "POST")
92
+
93
+ def push_ignore(self, names) -> Tuple[int, bytes]:
94
+ payload = json.dumps({"dirs": sorted(names)}).encode("utf-8")
95
+ headers = {"X-Sync-Token": self.token, "Content-Type": "application/json"}
96
+ return self.open(ROUTE_IGNORE, payload, headers, "POST")
97
+
98
+ def manifest(self) -> Tuple[int, dict]:
99
+ status, body = self.open(ROUTE_MANIFEST, headers={"X-Sync-Token": self.token})
100
+ if status != 200:
101
+ return status, {}
102
+ try:
103
+ data = json.loads(body.decode("utf-8"))
104
+ except ValueError:
105
+ return status, {}
106
+ files = data.get("files") if isinstance(data, dict) else None
107
+ if not isinstance(files, dict):
108
+ return status, {}
109
+ return status, {str(key): str(value) for key, value in files.items()}
110
+
111
+ def pull_file(self, rel: str) -> Tuple[int, bytes, int]:
112
+ headers = {
113
+ "X-Sync-Token": self.token,
114
+ "X-Rel-Path": urllib.parse.quote(rel, safe="/"),
115
+ }
116
+ status, response_headers, body = self.request(ROUTE_PULL, headers=headers, method="GET")
117
+ if status == 200 and len(body) > MAX_BYTES:
118
+ return 400, ("文件过大\n").encode("utf-8"), 0o644
119
+ mode = 0o755 if response_headers and response_headers.get("X-File-Mode") == "755" else 0o644
120
+ return status, body, mode
121
+
122
+ def stage(self) -> Tuple[int, dict]:
123
+ headers = {"X-Sync-Token": self.token, "Content-Type": "application/json"}
124
+ status, body = self.open(ROUTE_STAGE, b"{}", headers, "POST")
125
+ if status != 200:
126
+ return status, {}
127
+ try:
128
+ data = json.loads(body.decode("utf-8"))
129
+ except ValueError:
130
+ return status, {}
131
+ if not isinstance(data, dict) or not isinstance(data.get("staged"), list):
132
+ return status, {}
133
+ return status, data
134
+
135
+ def changes(self) -> Tuple[int, dict]:
136
+ headers = {"X-Sync-Token": self.token, "Content-Type": "application/json"}
137
+ status, body = self.open(ROUTE_CHANGES, b"{}", headers, "POST")
138
+ if status != 200:
139
+ return status, {}
140
+ try:
141
+ data = json.loads(body.decode("utf-8"))
142
+ except ValueError:
143
+ return status, {}
144
+ if not isinstance(data, dict) or not isinstance(data.get("changes"), list):
145
+ return status, {}
146
+ return status, data
147
+
148
+
149
+ def explain_http(status: int, body: bytes) -> str:
150
+ text = body.decode("utf-8", errors="replace")[:180].replace("\n", " ")
151
+ if status == 200:
152
+ return "通"
153
+ if b"<html" in body.lower() or b"<!doctype" in body.lower():
154
+ return "code-server 返回了网页(登录已失效,或代理没开)。状态 %d" % status
155
+ if status in (500, 502) and b"ECONNREFUSED" in body:
156
+ return "云端接收程序还没启动。先在 code-server 终端执行 token 命令打印的那一行。"
157
+ if status == 502 or status == 404:
158
+ return "云端接收程序没在听同步端口,或 /proxy 被关掉。状态 %d %s" % (status, text)
159
+ if status == 403:
160
+ return "令牌不一致。状态 403"
161
+ if status == 400:
162
+ if "文件过大" in text:
163
+ return "文件过大(超过 %d 字节)。状态 400" % MAX_BYTES
164
+ return "云端拒绝了路径。状态 400 %s" % text
165
+ return "状态 %d %s" % (status, text)
166
+
167
+
168
+ def check_ready(client: Client) -> None:
169
+ if not client.token or len(client.token) < 16:
170
+ raise SystemExit("配置里的 token 为空。先运行 cs-sync init --name 名称 --local 本地目录 --remote 云端绝对路径 --base https://主机:端口")
171
+ if client.endpoint.startswith("https://") and not client.cookie and not client.auth_optional:
172
+ raise SystemExit(
173
+ "还没有 code-server 登录凭证。运行 cs-sync login,"
174
+ "或执行 cs-sync cookie '浏览器里的 code-server-session'。"
175
+ )
176
+ try:
177
+ status, body = client.health()
178
+ except urllib.error.URLError as exc:
179
+ raise SystemExit("连不上 %s :%s" % (client.endpoint, exc.reason))
180
+ if status != 200 or body.strip() != b"ok":
181
+ raise SystemExit(explain_http(status, body))
182
+ status, body = client.put_file("../probe", b"", "644")
183
+ if status == 403:
184
+ raise SystemExit("接收程序拒绝了令牌。确认云端 --token 和本机配置是同一个。")
185
+ if status != 400:
186
+ raise SystemExit(explain_http(status, body))
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+
6
+ from cs_sync.ignore import DEFAULT_IGNORE_DIRS
7
+ from cs_sync.protocol import DEFAULT_PORT
8
+
9
+ def config_dir() -> str:
10
+ return os.path.abspath(os.path.expanduser(os.environ.get("CS_SYNC_HOME") or "~/.config/cs-sync"))
11
+
12
+
13
+ def host_path() -> str:
14
+ return os.path.join(config_dir(), "host.json")
15
+
16
+
17
+ def projects_dir() -> str:
18
+ return os.path.join(config_dir(), "projects")
19
+
20
+
21
+ def load_json(path: str) -> dict:
22
+ with open(path, "r", encoding="utf-8") as handle:
23
+ data = json.load(handle)
24
+ if not isinstance(data, dict):
25
+ raise SystemExit("配置文件格式不对: %s" % path)
26
+ return data
27
+
28
+
29
+ def load_host() -> dict:
30
+ path = host_path()
31
+ if not os.path.exists(path):
32
+ return {}
33
+ return load_json(path)
34
+
35
+
36
+ def save_host(host: dict) -> None:
37
+ save_config(host_path(), host)
38
+
39
+
40
+ def valid_name(name: str) -> bool:
41
+ if not name or len(name) > 64 or not name[0].isalnum():
42
+ return False
43
+ return all(char.isalnum() or char in "._-" for char in name)
44
+
45
+
46
+ def project_filename(name: str) -> str:
47
+ parts = []
48
+ for char in name:
49
+ if char.isupper():
50
+ parts.append("~" + char.lower())
51
+ elif char == "~":
52
+ parts.append("~~")
53
+ else:
54
+ parts.append(char)
55
+ return "".join(parts) + ".json"
56
+
57
+
58
+ def project_entries() -> list:
59
+ entries = []
60
+ root = config_dir()
61
+ if os.path.isdir(root):
62
+ for filename in os.listdir(root):
63
+ if filename.endswith(".json") and filename != "host.json":
64
+ entries.append(os.path.join(root, filename))
65
+ folder = projects_dir()
66
+ if os.path.isdir(folder):
67
+ for filename in os.listdir(folder):
68
+ if filename.endswith(".json"):
69
+ entries.append(os.path.join(folder, filename))
70
+ return entries
71
+
72
+
73
+ def stored_project_name(path: str) -> str:
74
+ stored = load_json(path).get("name")
75
+ if isinstance(stored, str) and stored:
76
+ return stored
77
+ return os.path.basename(path)[: -len(".json")]
78
+
79
+
80
+ def project_paths() -> Dict[str, list]:
81
+ grouped: Dict[str, list] = {}
82
+ for path in project_entries():
83
+ try:
84
+ key = stored_project_name(path)
85
+ except (OSError, ValueError):
86
+ continue
87
+ grouped.setdefault(key, []).append(path)
88
+ return grouped
89
+
90
+
91
+ def project_files() -> Dict[str, str]:
92
+ found: Dict[str, str] = {}
93
+ folder = projects_dir()
94
+ for key, paths in project_paths().items():
95
+ chosen = paths[0]
96
+ for path in paths:
97
+ if os.path.dirname(path) == folder:
98
+ chosen = path
99
+ break
100
+ found[key] = chosen
101
+ return found
102
+
103
+
104
+ def compose(host: dict, project: dict) -> dict:
105
+ config = dict(project)
106
+ port = int(config.get("port") or DEFAULT_PORT)
107
+ config["port"] = port
108
+ if not config.get("endpoint"):
109
+ base = str(host.get("baseUrl") or "").rstrip("/")
110
+ if not base:
111
+ raise SystemExit("还没有 code-server 地址。初始化项目时加上 --base https://主机:端口")
112
+ config["endpoint"] = "%s/proxy/%d" % (base, port)
113
+ if not config.get("cookie"):
114
+ config["cookie"] = host.get("cookie") or ""
115
+ if host.get("auth") and not config.get("auth"):
116
+ config["auth"] = host["auth"]
117
+ if "insecure" not in project:
118
+ config["insecure"] = bool(host.get("insecure", True))
119
+ if not config.get("ignoreDirs"):
120
+ config["ignoreDirs"] = sorted(DEFAULT_IGNORE_DIRS)
121
+ return config
122
+
123
+
124
+ def next_port() -> int:
125
+ used = set()
126
+ host = load_host()
127
+ for path in project_files().values():
128
+ config = compose(host, load_json(path))
129
+ used.add(int(config.get("port") or DEFAULT_PORT))
130
+ port = DEFAULT_PORT
131
+ while port in used:
132
+ port += 1
133
+ return port
134
+
135
+
136
+ def resolve_project(name: str = "", config_path: str = "") -> dict:
137
+ if config_path:
138
+ return compose(load_host(), load_json(config_path))
139
+ files = project_files()
140
+ if name:
141
+ path = files.get(name)
142
+ if not path:
143
+ known = "、".join(sorted(files)) or "(无)"
144
+ raise SystemExit("没有名为 %s 的项目。已有: %s" % (name, known))
145
+ return compose(load_host(), load_json(path))
146
+ cwd = os.path.abspath(os.getcwd())
147
+ host = load_host()
148
+ matches = []
149
+ for path in files.values():
150
+ config = compose(host, load_json(path))
151
+ root = os.path.abspath(os.path.expanduser(str(config.get("localRoot") or "")))
152
+ if root and (cwd == root or cwd.startswith(root + os.sep)):
153
+ matches.append(config)
154
+ if len(matches) == 1:
155
+ return matches[0]
156
+ if len(files) == 1 and not matches:
157
+ only = next(iter(files.values()))
158
+ return compose(host, load_json(only))
159
+ known = "、".join(sorted(files)) or "(无)"
160
+ raise SystemExit("请用 --name 指定项目。已有: %s" % known)
161
+
162
+
163
+ def save_config(path: str, config: dict) -> None:
164
+ directory = os.path.dirname(path)
165
+ os.makedirs(directory, exist_ok=True)
166
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
167
+ fd = os.open(path, flags, 0o600)
168
+ try:
169
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
170
+ json.dump(config, handle, ensure_ascii=False, indent=2)
171
+ handle.write("\n")
172
+ fd = -1
173
+ finally:
174
+ if fd >= 0:
175
+ os.close(fd)
176
+ os.chmod(path, 0o600)
177
+
178
+
179
+ def ensure_config(name: str = "", config_path: str = "") -> dict:
180
+ if not config_path and not project_files() and not name:
181
+ raise SystemExit(
182
+ "还没有项目。先运行: cs-sync init --name 名称 --local 本地目录 --remote 云端绝对路径 --base https://主机:端口"
183
+ )
184
+ return resolve_project(name, config_path)
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import threading
6
+ import urllib.parse
7
+ from typing import Dict, Tuple
8
+
9
+ from cs_sync.ignore import DEFAULT_IGNORE_DIRS, IGNORE_PREFIXES, PathIgnore, load_path_ignore, should_skip
10
+ from cs_sync.protocol import MAX_BYTES, eprint
11
+
12
+ FileState = Tuple[int, int]
13
+
14
+
15
+ def safe_destination(root: str, rel: str) -> str:
16
+ rel = urllib.parse.unquote(rel).replace("\\", "/").strip()
17
+ if not rel or rel.startswith("/") or "\x00" in rel:
18
+ raise ValueError("非法路径")
19
+ parts = rel.split("/")
20
+ if any(part in ("", ".", "..") for part in parts):
21
+ raise ValueError("非法路径")
22
+ root_real = os.path.realpath(root)
23
+ current = root_real
24
+ for part in parts[:-1]:
25
+ current = os.path.join(current, part)
26
+ if os.path.islink(current):
27
+ raise ValueError("路径中含有符号链接")
28
+ if os.path.exists(current) and not os.path.isdir(current):
29
+ raise ValueError("父级不是目录")
30
+ if os.path.isdir(current):
31
+ current_real = os.path.realpath(current)
32
+ if current_real != root_real and not current_real.startswith(root_real + os.sep):
33
+ raise ValueError("路径越界")
34
+ dest = os.path.normpath(os.path.join(root_real, *parts))
35
+ if dest != root_real and not dest.startswith(root_real + os.sep):
36
+ raise ValueError("路径越界")
37
+ return dest
38
+
39
+
40
+ def write_file(root: str, rel: str, payload: bytes, mode: int) -> str:
41
+ dest = safe_destination(root, rel)
42
+ parent = os.path.dirname(dest)
43
+ root_real = os.path.realpath(root)
44
+ current = root_real
45
+ relative_parent = os.path.relpath(parent, root_real)
46
+ if relative_parent != ".":
47
+ for part in relative_parent.split(os.sep):
48
+ current = os.path.join(current, part)
49
+ if os.path.islink(current):
50
+ raise ValueError("路径中含有符号链接")
51
+ if os.path.isdir(current):
52
+ continue
53
+ if os.path.exists(current):
54
+ raise ValueError("父级不是目录")
55
+ os.mkdir(current, 0o755)
56
+ tmp = dest + ".cs-sync-tmp"
57
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
58
+ if hasattr(os, "O_NOFOLLOW"):
59
+ flags |= os.O_NOFOLLOW
60
+ fd = os.open(tmp, flags, mode)
61
+ try:
62
+ view = payload
63
+ while view:
64
+ written = os.write(fd, view)
65
+ if written <= 0:
66
+ raise OSError("写入中断")
67
+ view = view[written:]
68
+ finally:
69
+ os.close(fd)
70
+ os.chmod(tmp, mode)
71
+ os.replace(tmp, dest)
72
+ return dest
73
+
74
+
75
+ def remove_file(root: str, rel: str) -> str:
76
+ dest = safe_destination(root, rel)
77
+ if os.path.islink(dest) or os.path.isfile(dest):
78
+ os.remove(dest)
79
+ parent = os.path.dirname(dest)
80
+ root_real = os.path.realpath(root)
81
+ while parent != root_real and parent.startswith(root_real + os.sep):
82
+ try:
83
+ os.rmdir(parent)
84
+ except OSError:
85
+ break
86
+ parent = os.path.dirname(parent)
87
+ return dest
88
+
89
+
90
+ def sha256_file(path: str) -> str:
91
+ digest = hashlib.sha256()
92
+ with open(path, "rb") as handle:
93
+ while True:
94
+ chunk = handle.read(1024 * 1024)
95
+ if not chunk:
96
+ break
97
+ digest.update(chunk)
98
+ return digest.hexdigest()
99
+
100
+
101
+ def sha256_bytes(payload: bytes) -> str:
102
+ return hashlib.sha256(payload).hexdigest()
103
+
104
+
105
+ class FileIndex:
106
+ def __init__(self, root: str):
107
+ self.root = root
108
+ self.ignore = set(DEFAULT_IGNORE_DIRS)
109
+ self.files: Dict[str, str] = {}
110
+ self.stats: Dict[str, FileState] = {}
111
+ self.lock = threading.Lock()
112
+
113
+ def set_ignore(self, names) -> None:
114
+ with self.lock:
115
+ self.ignore = set(names) or set(DEFAULT_IGNORE_DIRS)
116
+
117
+ def note_write(self, rel: str, payload: bytes) -> None:
118
+ digest = sha256_bytes(payload)
119
+ full = os.path.join(self.root, rel)
120
+ try:
121
+ stat = os.stat(full)
122
+ state = (stat.st_mtime_ns, stat.st_size)
123
+ except OSError:
124
+ state = (0, len(payload))
125
+ with self.lock:
126
+ self.files[rel] = digest
127
+ self.stats[rel] = state
128
+
129
+ def note_delete(self, rel: str) -> None:
130
+ with self.lock:
131
+ self.files.pop(rel, None)
132
+ self.stats.pop(rel, None)
133
+
134
+ def manifest(self) -> Dict[str, str]:
135
+ with self.lock:
136
+ return dict(self.files)
137
+
138
+ def scan(self, announce: bool = False) -> None:
139
+ ignored = set(self.ignore)
140
+ current = scan(self.root, ignored)
141
+ digests: Dict[str, str] = {}
142
+ with self.lock:
143
+ previous = dict(self.stats)
144
+ cached = dict(self.files)
145
+ for rel, state in current.items():
146
+ if previous.get(rel) == state and rel in cached:
147
+ digests[rel] = cached[rel]
148
+ continue
149
+ full = os.path.join(self.root, rel)
150
+ try:
151
+ digests[rel] = sha256_file(full)
152
+ except OSError:
153
+ continue
154
+ if announce and cached.get(rel) != digests[rel]:
155
+ print("云端改动 %s (%d 字节)" % (rel, state[1]), flush=True)
156
+ if announce:
157
+ for rel in cached:
158
+ if rel not in current and previous.get(rel) == self.stats.get(rel):
159
+ print("云端改动(删除) %s" % rel, flush=True)
160
+ with self.lock:
161
+ for rel, state in list(current.items()):
162
+ if self.stats.get(rel) != previous.get(rel) and rel in self.files:
163
+ digests[rel] = self.files[rel]
164
+ current[rel] = self.stats[rel]
165
+ self.stats = current
166
+ self.files = digests
167
+
168
+
169
+ def file_mode(path: str) -> str:
170
+ return "755" if os.stat(path).st_mode & 0o111 else "644"
171
+
172
+
173
+ def scan(root: str, ignored: set, path_ignore: PathIgnore = None) -> Dict[str, FileState]:
174
+ if path_ignore is None:
175
+ path_ignore = load_path_ignore(root)
176
+ found: Dict[str, FileState] = {}
177
+ root = os.path.abspath(root)
178
+ for dirpath, dirnames, filenames in os.walk(root):
179
+ kept = []
180
+ for name in dirnames:
181
+ if name in ignored or name.startswith(IGNORE_PREFIXES):
182
+ continue
183
+ rel_dir = os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, "/")
184
+ if should_skip(rel_dir, ignored, path_ignore, is_dir=True):
185
+ continue
186
+ kept.append(name)
187
+ dirnames[:] = kept
188
+ for name in filenames:
189
+ full = os.path.join(dirpath, name)
190
+ rel = os.path.relpath(full, root).replace(os.sep, "/")
191
+ if should_skip(rel, ignored, path_ignore):
192
+ continue
193
+ if os.path.islink(full) or not os.path.isfile(full):
194
+ continue
195
+ stat = os.stat(full)
196
+ if stat.st_size > MAX_BYTES:
197
+ continue
198
+ found[rel] = (stat.st_mtime_ns, stat.st_size)
199
+ return found