@tencent-ai/codebuddy-code 2.106.0-dev.bc75b8d.202606121347 → 2.106.1

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,471 @@
1
+ """
2
+ genie-safe-delete — Python 运行时安全删除 shim
3
+
4
+ 通过 PYTHONPATH 中的 sitecustomize.py 自动加载,
5
+ 拦截 os.remove / os.rmdir / shutil.rmtree / pathlib 的删除 API,
6
+ 把文件移入操作系统的回收站而不是真删。
7
+
8
+ 实现:优先调用 genie-trash native binary;不可用时回退到平台内联实现
9
+ (macOS: ctypes FSMoveObjectToTrashSync,Windows: ctypes SHFileOperationW,
10
+ Linux: freedesktop Trash spec 纯 Python 实现)。
11
+
12
+ 设计要点见 apps/workbuddy-desktop/docs/safe-delete-runtime-trash.md
13
+ """
14
+
15
+ import errno
16
+ import os
17
+ import stat
18
+ import sys
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # 触发条件
22
+ # ---------------------------------------------------------------------------
23
+
24
+ _SESSION_ID = (
25
+ os.environ.get("CODEBUDDY_SESSION_ID")
26
+ or os.environ.get("CLAUDE_SESSION_ID")
27
+ )
28
+ _IN_SANDBOX = os.environ.get("CODEBUDDY_SAFE_DELETE_SANDBOX") == "1"
29
+
30
+ if _SESSION_ID:
31
+ # __file__ 在 sitecustomize 加载时指向本文件路径,dirname 即 vendor/shim/
32
+ _SHIM_DIR = os.path.dirname(os.path.abspath(__file__))
33
+
34
+ # sitecustomize 是 Python 保留文件名,Python 启动时会自动 import
35
+ # sys.path 上第一个匹配的模块。把 shim 目录前置到 PYTHONPATH 后,
36
+ # 用户环境原有的 sitecustomize(conda / virtualenv / 企业配置等)会被覆盖。
37
+ # 因此在注入 shim 前,先临时从 sys.modules 和 sys.path 中移除自身,
38
+ # 尝试加载原始的 sitecustomize,再恢复自身。
39
+ _self = sys.modules.pop("sitecustomize", None)
40
+ if _SHIM_DIR in sys.path:
41
+ sys.path.remove(_SHIM_DIR)
42
+ try:
43
+ __import__("sitecustomize")
44
+ except ImportError as e:
45
+ # 只有模块本身不存在时才静默(e.name == "sitecustomize");
46
+ # 若模块存在但内部导入失败(e.name 为其他名),输出 traceback 到 stderr
47
+ if e.name is not None and e.name != "sitecustomize":
48
+ import traceback
49
+ traceback.print_exc(file=sys.stderr)
50
+ if _SHIM_DIR not in sys.path:
51
+ sys.path.insert(0, _SHIM_DIR)
52
+ if _self is not None:
53
+ sys.modules["sitecustomize"] = _self
54
+
55
+ # -----------------------------------------------------------------------
56
+ # 平台内联 trash 实现(genie-trash binary 不可用时的降级路径)
57
+ # 仅使用标准库,无第三方依赖。
58
+ # -----------------------------------------------------------------------
59
+
60
+ if sys.platform == "darwin":
61
+ # macOS: ctypes → CoreServices.FSMoveObjectToTrashSync
62
+ # API 在 macOS 10.8 标记 deprecated,但至今仍可用;不依赖 Finder,headless 安全。
63
+ from ctypes import cdll, byref, Structure, c_char, c_char_p
64
+ from ctypes.util import find_library as _find_library
65
+
66
+ _Foundation = cdll.LoadLibrary(_find_library("Foundation"))
67
+ _CoreServices = cdll.LoadLibrary(_find_library("CoreServices"))
68
+ _GetOSStatusComment = _Foundation.GetMacOSStatusCommentString
69
+ _GetOSStatusComment.restype = c_char_p
70
+ _FSPathMakeRefWithOptions = _CoreServices.FSPathMakeRefWithOptions
71
+ _FSMoveObjectToTrashSync = _CoreServices.FSMoveObjectToTrashSync
72
+
73
+ class _FSRef(Structure):
74
+ _fields_ = [("hidden", c_char * 80)]
75
+
76
+ def _platform_trash(abs_path):
77
+ path_b = abs_path.encode("utf-8") if isinstance(abs_path, str) else abs_path
78
+ fp = _FSRef()
79
+ rc = _FSPathMakeRefWithOptions(path_b, 0x01, byref(fp), None)
80
+ if rc:
81
+ raise OSError(_GetOSStatusComment(rc).decode("utf-8", errors="replace"))
82
+ rc = _FSMoveObjectToTrashSync(byref(fp), None, 0)
83
+ if rc:
84
+ raise OSError(_GetOSStatusComment(rc).decode("utf-8", errors="replace"))
85
+
86
+ elif sys.platform == "win32":
87
+ # Windows: ctypes → shell32.SHFileOperationW
88
+ # API 在 Vista 后标记 deprecated(推荐 IFileOperation),但仍广泛可用。
89
+ from ctypes import (
90
+ windll, Structure, byref, c_uint,
91
+ create_unicode_buffer, addressof,
92
+ GetLastError, FormatError,
93
+ )
94
+ from ctypes.wintypes import HWND, UINT, BOOL, LPCWSTR
95
+
96
+ _kernel32 = windll.kernel32
97
+ _shell32 = windll.shell32
98
+ _FO_DELETE = 3
99
+ _FOF_FLAGS = 64 | 16 | 1024 | 4 # ALLOWUNDO | NOCONFIRMATION | NOERRORUI | SILENT
100
+
101
+ class _SHFILEOPSTRUCTW(Structure):
102
+ _fields_ = [
103
+ ("hwnd", HWND), ("wFunc", UINT), ("pFrom", LPCWSTR),
104
+ ("pTo", LPCWSTR), ("fFlags", c_uint), ("fAnyOperationsAborted", BOOL),
105
+ ("hNameMappings", c_uint), ("lpszProgressTitle", LPCWSTR),
106
+ ]
107
+
108
+ def _get_short_path(path):
109
+ """调用 GetShortPathNameW 处理超长路径和特殊字符。"""
110
+ prefix = "\\\\?\\"
111
+ long_path = (prefix + "UNC" + path[1:]) if path.startswith("\\\\") else (prefix + path)
112
+ n = _kernel32.GetShortPathNameW(long_path, None, 0)
113
+ if not n:
114
+ err = GetLastError()
115
+ raise OSError(err, FormatError(err), path)
116
+ buf = create_unicode_buffer(n)
117
+ _kernel32.GetShortPathNameW(long_path, buf, n)
118
+ s = buf.value
119
+ # 还原 UNC 前缀:\\?\UNC\server → \\server
120
+ if s.startswith("\\\\?\\UNC"):
121
+ return "\\" + s[7:]
122
+ # 普通路径:去掉 \\?\
123
+ return s[4:] if s.startswith(prefix) else s
124
+
125
+ def _platform_trash(abs_path):
126
+ path = _get_short_path(os.path.abspath(abs_path))
127
+ # pFrom 需要双 null 终止的字符串(SHFileOperationW 规范)
128
+ buf = create_unicode_buffer(path, len(path) + 2)
129
+ fileop = _SHFILEOPSTRUCTW()
130
+ fileop.wFunc = _FO_DELETE
131
+ fileop.pFrom = LPCWSTR(addressof(buf))
132
+ fileop.fFlags = _FOF_FLAGS
133
+ result = _shell32.SHFileOperationW(byref(fileop))
134
+ if result:
135
+ raise OSError("SHFileOperationW 失败: 0x%x" % result)
136
+
137
+ else:
138
+ # Linux / 其他 POSIX: freedesktop Trash spec(XDG Base Directory)
139
+ from datetime import datetime as _datetime
140
+ import shutil as _shutil
141
+
142
+ def _trashinfo_encode(s):
143
+ """仅编码非 ASCII 和控制字符,与 Node.js/Bash 版本保持一致。"""
144
+ parts = []
145
+ for ch in s:
146
+ if '\x20' <= ch <= '\x7e':
147
+ parts.append(ch)
148
+ else:
149
+ parts.append(''.join('%{:02X}'.format(b) for b in ch.encode('utf-8')))
150
+ return ''.join(parts)
151
+
152
+ _uid = os.getuid()
153
+ _xdg_data = os.fsencode(
154
+ os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
155
+ )
156
+ _home_trash = os.path.join(_xdg_data, b"Trash")
157
+
158
+ def _find_ext_volume_trash(volume_root):
159
+ """freedesktop spec §1: 优先 .Trash/$uid(需 sticky bit),否则 .Trash-$uid。"""
160
+ uid_b = str(_uid).encode()
161
+ trash_base = os.path.join(volume_root, b".Trash")
162
+ if (
163
+ os.path.isdir(trash_base)
164
+ and not os.path.islink(trash_base)
165
+ and bool(os.lstat(trash_base).st_mode & 0o1000) # sticky bit
166
+ ):
167
+ trash_dir = os.path.join(trash_base, uid_b)
168
+ os.makedirs(trash_dir, 0o700, exist_ok=True)
169
+ return trash_dir
170
+ trash_dir = os.path.join(volume_root, b".Trash-" + uid_b)
171
+ os.makedirs(trash_dir, 0o700, exist_ok=True)
172
+ return trash_dir
173
+
174
+ def _trash_move(path_b, dest_trash, topdir):
175
+ """将文件移动到 dest_trash,并写入对应的 .trashinfo。"""
176
+ files_dir = os.path.join(dest_trash, b"files")
177
+ info_dir = os.path.join(dest_trash, b"info")
178
+ os.makedirs(files_dir, 0o700, exist_ok=True)
179
+ os.makedirs(info_dir, 0o700, exist_ok=True)
180
+
181
+ filename = os.path.basename(path_b)
182
+ base, ext = os.path.splitext(filename)
183
+ dest_name = filename
184
+ counter = 0
185
+ while os.path.exists(os.path.join(files_dir, dest_name)) or \
186
+ os.path.exists(os.path.join(info_dir, dest_name + b".trashinfo")):
187
+ counter += 1
188
+ dest_name = base + b"." + str(counter).encode() + ext
189
+
190
+ real_path = os.path.realpath(path_b)
191
+ real_top = os.path.realpath(topdir)
192
+ if real_path.startswith(real_top):
193
+ info_path = _trashinfo_encode(os.fsdecode(os.path.relpath(path_b, topdir)))
194
+ else:
195
+ info_path = _trashinfo_encode(os.fsdecode(os.path.abspath(path_b)))
196
+
197
+ info_content = "[Trash Info]\nPath=%s\nDeletionDate=%s\n" % (
198
+ info_path, _datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
199
+ )
200
+ info_file = os.path.join(info_dir, dest_name + b".trashinfo")
201
+ fd = os.open(info_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
202
+ try:
203
+ with os.fdopen(fd, "w") as f:
204
+ f.write(info_content)
205
+ except Exception:
206
+ try:
207
+ _orig_unlink(info_file)
208
+ except OSError:
209
+ pass
210
+ raise
211
+
212
+ dest_file = os.path.join(files_dir, dest_name)
213
+ try:
214
+ os.rename(path_b, dest_file)
215
+ except OSError as e:
216
+ if e.errno == errno.EXDEV:
217
+ # _shutil.move 内部调用 shutil.rmtree,后者已被补丁为
218
+ # _safe_shutil_rmtree —— 这会重入 _try_trash 形成循环。
219
+ # 临时将 shutil.rmtree 恢复为原始版本后再调用。
220
+ _saved_rmtree = shutil.rmtree
221
+ shutil.rmtree = _orig_shutil_rmtree
222
+ try:
223
+ _shutil.move(os.fsdecode(path_b), os.fsdecode(dest_file))
224
+ except Exception:
225
+ try:
226
+ _orig_unlink(info_file)
227
+ except OSError:
228
+ pass
229
+ raise
230
+ finally:
231
+ shutil.rmtree = _saved_rmtree
232
+ else:
233
+ try:
234
+ _orig_unlink(info_file)
235
+ except OSError:
236
+ pass
237
+ raise
238
+
239
+ def _platform_trash(abs_path):
240
+ path_b = os.fsencode(abs_path)
241
+ path_dev = os.lstat(path_b).st_dev
242
+ home_dev = os.lstat(os.path.expanduser(b"~")).st_dev
243
+ if path_dev == home_dev:
244
+ _trash_move(path_b, _home_trash, _xdg_data)
245
+ else:
246
+ mount = os.path.realpath(path_b)
247
+ while not os.path.ismount(mount):
248
+ mount = os.path.split(mount)[0]
249
+ dest_trash = _find_ext_volume_trash(mount)
250
+ try:
251
+ _trash_move(path_b, dest_trash, mount)
252
+ except OSError as e:
253
+ if e.errno == errno.EXDEV:
254
+ _trash_move(path_b, _home_trash, _xdg_data)
255
+ else:
256
+ raise
257
+
258
+ # -----------------------------------------------------------------------
259
+ # 全局状态
260
+ # -----------------------------------------------------------------------
261
+
262
+ # 保存原始引用(patch 前)
263
+ import shutil
264
+ import subprocess
265
+ import pathlib
266
+ import platform
267
+
268
+ _orig_remove = os.remove
269
+ _orig_unlink = os.unlink
270
+ _orig_rmdir = os.rmdir
271
+ _orig_shutil_rmtree = shutil.rmtree
272
+ _orig_path_unlink = pathlib.Path.unlink
273
+ _orig_path_rmdir = pathlib.Path.rmdir
274
+
275
+ # -----------------------------------------------------------------------
276
+ # genie-trash native binary 支持(优先路径,不可用自动降级)
277
+ # 优先使用 GENIE_TRASH_DIR 环境变量(由 buildSafeDeleteEnv() 注入),
278
+ # 解决打包后 extraResources 与 shim 不共父目录的路径错位问题。
279
+ # -----------------------------------------------------------------------
280
+
281
+ def _get_trash_bin():
282
+ system = "win32" if sys.platform == "win32" else platform.system().lower()
283
+ machine = platform.machine().lower()
284
+ if machine in ("arm64", "aarch64"):
285
+ arch = "arm64"
286
+ elif machine in ("x86_64", "amd64"):
287
+ arch = "x64"
288
+ else:
289
+ arch = machine
290
+ ext = ".exe" if sys.platform == "win32" else ""
291
+ file_name = "%s-%s%s" % (system, arch, ext)
292
+ trash_dir = os.environ.get("GENIE_TRASH_DIR")
293
+ if trash_dir:
294
+ return os.path.join(trash_dir, file_name)
295
+ shim_dir = os.path.dirname(os.path.abspath(__file__))
296
+ return os.path.join(shim_dir, "..", "genie-trash", file_name)
297
+
298
+ _trash_bin_available = None # None=未探测, True=可用, False=不可用
299
+
300
+ def _try_trash_via_binary(abs_path):
301
+ global _trash_bin_available
302
+ if _trash_bin_available is False:
303
+ return False
304
+ try:
305
+ result = subprocess.run(
306
+ [_get_trash_bin(), abs_path],
307
+ capture_output=True, text=True, timeout=5
308
+ )
309
+ if result.returncode == 0:
310
+ _trash_bin_available = True
311
+ return True
312
+ # binary 已启动但对该路径失败:抛错,不降级真删
313
+ raise OSError(
314
+ "[safe-delete] 操作失败: %s"
315
+ % (result.stderr.strip() or "exit %d" % result.returncode)
316
+ )
317
+ except FileNotFoundError:
318
+ _trash_bin_available = False # binary 不存在,进程生命周期内不会凭空出现
319
+ return False
320
+ except PermissionError:
321
+ # 不缓存:权限问题可能是临时状态(如 chmod +x 后恢复)
322
+ return False
323
+ except subprocess.TimeoutExpired:
324
+ return False # 超时是临时问题,不永久禁用 native binary
325
+ except OSError:
326
+ raise
327
+ except Exception:
328
+ pass
329
+ _trash_bin_available = False
330
+ return False
331
+
332
+ # -----------------------------------------------------------------------
333
+ # 核心:尝试移入回收站。优先 binary,降级到平台内联实现。
334
+ # 返回 True=已移入回收站;抛错=fail-closed,调用方不得真删。
335
+ # -----------------------------------------------------------------------
336
+
337
+ def _try_trash(abs_path):
338
+ if sys.platform == "win32" and _IN_SANDBOX:
339
+ message = (
340
+ "[safe-delete][SAFE_DELETE_FAIL_CLOSED] "
341
+ "target=%s reason=windows-sandbox-recycle-bin-unavailable" % abs_path
342
+ )
343
+ try:
344
+ sys.stderr.write(message + "\n")
345
+ sys.stderr.flush()
346
+ except Exception:
347
+ pass
348
+ raise OSError(message)
349
+ if _try_trash_via_binary(abs_path):
350
+ return True
351
+ _platform_trash(abs_path)
352
+ return True
353
+
354
+ def _safe_lstat(abs_path):
355
+ try:
356
+ return os.lstat(abs_path)
357
+ except OSError as e:
358
+ if e.errno == errno.ENOENT:
359
+ return None
360
+ # EACCES / ELOOP 等错误:让调用方回退原生
361
+ return False # 与 None 区分开
362
+
363
+ def _is_dir_empty(abs_path):
364
+ try:
365
+ return len(os.listdir(abs_path)) == 0
366
+ except OSError:
367
+ return False
368
+
369
+ # -----------------------------------------------------------------------
370
+ # 包装:os.remove / os.unlink (文件,不能是目录)
371
+ # -----------------------------------------------------------------------
372
+
373
+ def _safe_remove(path, *, dir_fd=None):
374
+ # dir_fd 不为 None → 走原生(无法解析到 abs_path)
375
+ if dir_fd is not None:
376
+ return _orig_remove(path, dir_fd=dir_fd)
377
+ abs_path = os.path.abspath(os.fspath(path))
378
+ st = _safe_lstat(abs_path)
379
+ if not st:
380
+ return _orig_remove(path) # ENOENT / stat 错误 → 让原生抛
381
+ if stat.S_ISDIR(st.st_mode):
382
+ return _orig_remove(path) # 让原生抛 EISDIR / EPERM
383
+ _try_trash(abs_path)
384
+
385
+ # -----------------------------------------------------------------------
386
+ # 包装:os.rmdir(必须是空目录)
387
+ # -----------------------------------------------------------------------
388
+
389
+ def _safe_rmdir(path, *, dir_fd=None):
390
+ if dir_fd is not None:
391
+ return _orig_rmdir(path, dir_fd=dir_fd)
392
+ abs_path = os.path.abspath(os.fspath(path))
393
+ st = _safe_lstat(abs_path)
394
+ if not st:
395
+ return _orig_rmdir(path) # ENOENT / stat 错误
396
+ if not stat.S_ISDIR(st.st_mode):
397
+ return _orig_rmdir(path) # ENOTDIR
398
+ if not _is_dir_empty(abs_path):
399
+ return _orig_rmdir(path) # ENOTEMPTY
400
+ _try_trash(abs_path)
401
+
402
+ # -----------------------------------------------------------------------
403
+ # 包装:shutil.rmtree(递归删目录)
404
+ # -----------------------------------------------------------------------
405
+
406
+ def _safe_shutil_rmtree(path, ignore_errors=False, onerror=None, **kwargs):
407
+ # Python 3.12+ 引入 onexc,3.14+ 还有 dir_fd——一并 forward 给原生
408
+ try:
409
+ abs_path = os.path.abspath(os.fspath(path))
410
+ except TypeError:
411
+ return _orig_shutil_rmtree(
412
+ path, ignore_errors=ignore_errors, onerror=onerror, **kwargs
413
+ )
414
+
415
+ st = _safe_lstat(abs_path)
416
+ if not st:
417
+ return _orig_shutil_rmtree(
418
+ path, ignore_errors=ignore_errors, onerror=onerror, **kwargs
419
+ )
420
+ if not stat.S_ISDIR(st.st_mode):
421
+ # 不是目录 → 让原生处理(rmtree 会抛 NotADirectoryError)
422
+ return _orig_shutil_rmtree(
423
+ path, ignore_errors=ignore_errors, onerror=onerror, **kwargs
424
+ )
425
+
426
+ try:
427
+ _try_trash(abs_path)
428
+ except Exception as e:
429
+ if ignore_errors:
430
+ return
431
+ if onerror is not None:
432
+ onerror(_safe_shutil_rmtree, path, sys.exc_info())
433
+ return
434
+ raise
435
+
436
+ # -----------------------------------------------------------------------
437
+ # 包装:pathlib.Path.unlink / rmdir
438
+ # -----------------------------------------------------------------------
439
+
440
+ def _safe_path_unlink(self, missing_ok=False):
441
+ abs_path = os.path.abspath(str(self))
442
+ st = _safe_lstat(abs_path)
443
+ if not st:
444
+ if st is None and missing_ok:
445
+ return None
446
+ return _orig_path_unlink(self, missing_ok=missing_ok) # 让原生抛
447
+ if stat.S_ISDIR(st.st_mode):
448
+ return _orig_path_unlink(self, missing_ok=missing_ok) # 抛 IsADirectoryError
449
+ _try_trash(abs_path)
450
+
451
+ def _safe_path_rmdir(self):
452
+ abs_path = os.path.abspath(str(self))
453
+ st = _safe_lstat(abs_path)
454
+ if not st:
455
+ return _orig_path_rmdir(self) # ENOENT / stat 错误 → 让原生抛
456
+ if not stat.S_ISDIR(st.st_mode):
457
+ return _orig_path_rmdir(self) # 抛 NotADirectoryError
458
+ if not _is_dir_empty(abs_path):
459
+ return _orig_path_rmdir(self) # 抛 OSError(ENOTEMPTY)
460
+ _try_trash(abs_path)
461
+
462
+ # -----------------------------------------------------------------------
463
+ # 安装 patch
464
+ # -----------------------------------------------------------------------
465
+
466
+ os.remove = _safe_remove
467
+ os.unlink = _safe_remove # CPython 里 os.unlink is os.remove,显式 patch 兼容其它实现
468
+ os.rmdir = _safe_rmdir
469
+ shutil.rmtree = _safe_shutil_rmtree
470
+ pathlib.Path.unlink = _safe_path_unlink
471
+ pathlib.Path.rmdir = _safe_path_rmdir