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,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)
|
package/cs_sync/files.py
ADDED
|
@@ -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
|