evm-cli 2.4.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.
- evm/__init__.py +55 -0
- evm/__main__.py +9 -0
- evm/_completion.py +253 -0
- evm/_crypto.py +177 -0
- evm/_groups.py +127 -0
- evm/_history.py +141 -0
- evm/_io.py +421 -0
- evm/_json.py +52 -0
- evm/_schema.py +265 -0
- evm/_typing.py +40 -0
- evm/cli.py +961 -0
- evm/exceptions.py +150 -0
- evm/formatters.py +285 -0
- evm/manager.py +644 -0
- evm_cli-2.4.0.dist-info/METADATA +748 -0
- evm_cli-2.4.0.dist-info/RECORD +20 -0
- evm_cli-2.4.0.dist-info/WHEEL +5 -0
- evm_cli-2.4.0.dist-info/entry_points.txt +2 -0
- evm_cli-2.4.0.dist-info/licenses/LICENSE +21 -0
- evm_cli-2.4.0.dist-info/top_level.txt +1 -0
evm/manager.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
EVM 核心业务逻辑
|
|
4
|
+
|
|
5
|
+
EnvironmentManager 类通过 mixin 组合提供所有环境变量管理功能。
|
|
6
|
+
所有方法返回数据或抛出异常,不做任何 print() 或 sys.exit()。
|
|
7
|
+
|
|
8
|
+
模块拆分:
|
|
9
|
+
- _io.py → IOMixin(导入导出/备份恢复/diff)
|
|
10
|
+
- _groups.py → GroupMixin(分组管理)
|
|
11
|
+
- _history.py → HistoryMixin(操作日志)
|
|
12
|
+
- _schema.py → SchemaMixin(变量 schema)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import fcntl
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import platform
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from ._crypto import decrypt_v3, encrypt_v3
|
|
32
|
+
from ._groups import GroupMixin
|
|
33
|
+
from ._history import HistoryMixin
|
|
34
|
+
from ._io import IOMixin
|
|
35
|
+
from ._schema import SchemaMixin
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
CommandNotFoundError,
|
|
38
|
+
CorruptedStorageError,
|
|
39
|
+
DecryptionError,
|
|
40
|
+
EditorError,
|
|
41
|
+
EVMError,
|
|
42
|
+
KeyAlreadyExistsError,
|
|
43
|
+
KeyNotFoundError,
|
|
44
|
+
LockTimeoutError,
|
|
45
|
+
StorageError,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EnvironmentManager(IOMixin, GroupMixin, HistoryMixin, SchemaMixin):
|
|
50
|
+
"""环境变量管理器核心类
|
|
51
|
+
|
|
52
|
+
通过 mixin 组合获得分组、IO、历史、schema 功能。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# 加密前缀标识
|
|
56
|
+
SECRET_PREFIX = "ENC:"
|
|
57
|
+
SECRET_V2_PREFIX = "ENCv2:"
|
|
58
|
+
SECRET_V3_PREFIX = "ENCv3:"
|
|
59
|
+
# 模板引用模式 {{VAR_NAME}}
|
|
60
|
+
TEMPLATE_PATTERN = re.compile(r'\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}')
|
|
61
|
+
# 文件锁默认超时(秒)
|
|
62
|
+
LOCK_TIMEOUT = 5.0
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
env_file: Optional[str] = None,
|
|
67
|
+
lock_timeout: float = LOCK_TIMEOUT,
|
|
68
|
+
):
|
|
69
|
+
"""初始化环境管理器
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
env_file: 存储文件路径,默认 ~/.evm/env.json
|
|
73
|
+
lock_timeout: 文件锁超时秒数
|
|
74
|
+
"""
|
|
75
|
+
if env_file is None:
|
|
76
|
+
self.env_file = Path.home() / '.evm' / 'env.json'
|
|
77
|
+
else:
|
|
78
|
+
self.env_file = Path(env_file)
|
|
79
|
+
|
|
80
|
+
self.lock_timeout = lock_timeout
|
|
81
|
+
self._secret_warning_shown = False
|
|
82
|
+
self.env_file.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
self._env_vars = self._load_env_vars()
|
|
84
|
+
|
|
85
|
+
# ── 内部存储 ──────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def _load_env_vars(self) -> dict[str, str]:
|
|
88
|
+
"""从存储文件加载环境变量
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
CorruptedStorageError: JSON 文件损坏
|
|
92
|
+
StorageError: IO 或权限错误
|
|
93
|
+
"""
|
|
94
|
+
if not self.env_file.exists():
|
|
95
|
+
return {}
|
|
96
|
+
try:
|
|
97
|
+
with open(self.env_file, encoding='utf-8') as f:
|
|
98
|
+
content = f.read().strip()
|
|
99
|
+
if not content:
|
|
100
|
+
return {}
|
|
101
|
+
return json.loads(content) # type: ignore[no-any-return]
|
|
102
|
+
except json.JSONDecodeError as e:
|
|
103
|
+
raise CorruptedStorageError(
|
|
104
|
+
f"Storage file is corrupted: {e}. File: {self.env_file}"
|
|
105
|
+
) from e
|
|
106
|
+
except PermissionError as e:
|
|
107
|
+
raise StorageError(
|
|
108
|
+
f"Permission denied reading storage file: {self.env_file}"
|
|
109
|
+
) from e
|
|
110
|
+
except OSError as e:
|
|
111
|
+
raise StorageError(
|
|
112
|
+
f"IO error reading storage file: {e}"
|
|
113
|
+
) from e
|
|
114
|
+
|
|
115
|
+
def _save_env_vars(self, dry_run: bool = False) -> None:
|
|
116
|
+
"""保存环境变量到存储文件(原子写入 + 共享锁文件 + chmod 600)
|
|
117
|
+
|
|
118
|
+
#1 fix: 使用独立的 .lock 文件加锁,而非锁临时文件。
|
|
119
|
+
两个并发进程争夺同一个 .lock 文件的排他锁,
|
|
120
|
+
确保 write + move 操作的原子性。
|
|
121
|
+
"""
|
|
122
|
+
if dry_run:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
lock_path = str(self.env_file) + '.lock'
|
|
126
|
+
lock_fd = None
|
|
127
|
+
try:
|
|
128
|
+
# 打开共享锁文件(所有进程竞争同一资源)
|
|
129
|
+
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
130
|
+
self._acquire_lock(lock_fd)
|
|
131
|
+
try:
|
|
132
|
+
tmp_fd, tmp_path = tempfile.mkstemp(
|
|
133
|
+
dir=str(self.env_file.parent),
|
|
134
|
+
suffix='.tmp',
|
|
135
|
+
prefix='.env_',
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f:
|
|
139
|
+
json.dump(
|
|
140
|
+
self._env_vars, f, indent=2, ensure_ascii=False
|
|
141
|
+
)
|
|
142
|
+
f.flush()
|
|
143
|
+
os.fsync(f.fileno())
|
|
144
|
+
except Exception:
|
|
145
|
+
if os.path.exists(tmp_path):
|
|
146
|
+
os.unlink(tmp_path)
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
# 原子替换(在锁保护下)
|
|
150
|
+
shutil.move(tmp_path, str(self.env_file))
|
|
151
|
+
os.chmod(str(self.env_file), 0o600)
|
|
152
|
+
finally:
|
|
153
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
154
|
+
|
|
155
|
+
except LockTimeoutError:
|
|
156
|
+
raise
|
|
157
|
+
except PermissionError as e:
|
|
158
|
+
raise StorageError(
|
|
159
|
+
f"Permission denied writing to: {self.env_file}"
|
|
160
|
+
) from e
|
|
161
|
+
except OSError as e:
|
|
162
|
+
raise StorageError(
|
|
163
|
+
f"IO error writing storage file: {e}"
|
|
164
|
+
) from e
|
|
165
|
+
finally:
|
|
166
|
+
if lock_fd is not None:
|
|
167
|
+
try:
|
|
168
|
+
os.close(lock_fd)
|
|
169
|
+
except OSError:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
def _acquire_lock(self, fd: int) -> None:
|
|
173
|
+
"""获取排他文件锁,带超时重试
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
LockTimeoutError: 超时未获取锁
|
|
177
|
+
"""
|
|
178
|
+
deadline = time.monotonic() + self.lock_timeout
|
|
179
|
+
while time.monotonic() < deadline:
|
|
180
|
+
try:
|
|
181
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
182
|
+
return
|
|
183
|
+
except OSError:
|
|
184
|
+
time.sleep(0.05)
|
|
185
|
+
raise LockTimeoutError(str(self.env_file), self.lock_timeout)
|
|
186
|
+
|
|
187
|
+
# ── 基本 CRUD ────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
def set(self, key: str, value: str, dry_run: bool = False) -> str:
|
|
190
|
+
"""设置环境变量
|
|
191
|
+
|
|
192
|
+
#6 fix: 不记录 value 到操作日志,防止敏感信息泄露。
|
|
193
|
+
"""
|
|
194
|
+
if dry_run:
|
|
195
|
+
return f"[DRY-RUN] Would set: {key}={value}"
|
|
196
|
+
self._env_vars[key] = value
|
|
197
|
+
self._save_env_vars()
|
|
198
|
+
self.log_operation('set', key)
|
|
199
|
+
return f"Set: {key}={value}"
|
|
200
|
+
|
|
201
|
+
def get(self, key: str) -> str:
|
|
202
|
+
"""获取环境变量值
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
KeyNotFoundError: 变量不存在
|
|
206
|
+
"""
|
|
207
|
+
value = self._env_vars.get(key)
|
|
208
|
+
if value is None:
|
|
209
|
+
raise KeyNotFoundError(key)
|
|
210
|
+
return value # type: ignore[no-any-return]
|
|
211
|
+
|
|
212
|
+
def delete(self, key: str, dry_run: bool = False) -> str:
|
|
213
|
+
"""删除环境变量
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
KeyNotFoundError: 变量不存在
|
|
217
|
+
"""
|
|
218
|
+
if key not in self._env_vars:
|
|
219
|
+
raise KeyNotFoundError(key)
|
|
220
|
+
if dry_run:
|
|
221
|
+
return f"[DRY-RUN] Would delete: {key} (value={self._env_vars[key]})"
|
|
222
|
+
del self._env_vars[key]
|
|
223
|
+
self._save_env_vars()
|
|
224
|
+
self.log_operation('delete', key)
|
|
225
|
+
return f"Deleted: {key}"
|
|
226
|
+
|
|
227
|
+
def exists(self, key: str) -> bool:
|
|
228
|
+
"""检查环境变量是否存在"""
|
|
229
|
+
return key in self._env_vars
|
|
230
|
+
|
|
231
|
+
def list_vars(
|
|
232
|
+
self,
|
|
233
|
+
pattern: Optional[str] = None,
|
|
234
|
+
group: Optional[str] = None,
|
|
235
|
+
show_groups: bool = False,
|
|
236
|
+
no_prefix: bool = False,
|
|
237
|
+
) -> dict[str, str]:
|
|
238
|
+
"""列出环境变量
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
GroupNotFoundError: 指定的分组不存在
|
|
242
|
+
"""
|
|
243
|
+
from .exceptions import GroupNotFoundError
|
|
244
|
+
|
|
245
|
+
if group:
|
|
246
|
+
prefix = f"{group}:"
|
|
247
|
+
filtered = {
|
|
248
|
+
k: v for k, v in self._env_vars.items()
|
|
249
|
+
if k.startswith(prefix)
|
|
250
|
+
}
|
|
251
|
+
if not filtered:
|
|
252
|
+
raise GroupNotFoundError(group)
|
|
253
|
+
if no_prefix:
|
|
254
|
+
result = {}
|
|
255
|
+
for key, value in filtered.items():
|
|
256
|
+
new_key = key[len(prefix):] if key.startswith(prefix) else key
|
|
257
|
+
result[new_key] = value
|
|
258
|
+
return result
|
|
259
|
+
return filtered
|
|
260
|
+
elif pattern:
|
|
261
|
+
return {
|
|
262
|
+
k: v for k, v in self._env_vars.items()
|
|
263
|
+
if pattern.lower() in k.lower()
|
|
264
|
+
}
|
|
265
|
+
else:
|
|
266
|
+
return dict(self._env_vars)
|
|
267
|
+
|
|
268
|
+
def clear(self, dry_run: bool = False, force: bool = False) -> str:
|
|
269
|
+
"""清空所有环境变量
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
force: 跳过确认(CLI 层处理确认逻辑)
|
|
273
|
+
"""
|
|
274
|
+
if not self._env_vars:
|
|
275
|
+
return "No environment variables to clear"
|
|
276
|
+
count = len(self._env_vars)
|
|
277
|
+
if dry_run:
|
|
278
|
+
return f"[DRY-RUN] Would clear {count} variables"
|
|
279
|
+
self._env_vars.clear()
|
|
280
|
+
self._save_env_vars()
|
|
281
|
+
self.log_operation('clear', details=f'{count} variables')
|
|
282
|
+
return f"All environment variables cleared ({count} variables)"
|
|
283
|
+
|
|
284
|
+
# ── 搜索 ─────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
def search(self, pattern: str, search_value: bool = False) -> dict[str, str]:
|
|
287
|
+
"""搜索环境变量"""
|
|
288
|
+
results = {}
|
|
289
|
+
for key, value in self._env_vars.items():
|
|
290
|
+
if pattern.lower() in key.lower():
|
|
291
|
+
results[key] = value
|
|
292
|
+
elif search_value and pattern.lower() in str(value).lower():
|
|
293
|
+
results[key] = value
|
|
294
|
+
return results
|
|
295
|
+
|
|
296
|
+
# ── 重命名/复制 ──────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
def rename(
|
|
299
|
+
self, old_key: str, new_key: str, dry_run: bool = False
|
|
300
|
+
) -> str:
|
|
301
|
+
"""重命名环境变量"""
|
|
302
|
+
if old_key not in self._env_vars:
|
|
303
|
+
raise KeyNotFoundError(old_key)
|
|
304
|
+
if new_key in self._env_vars:
|
|
305
|
+
raise KeyAlreadyExistsError(new_key)
|
|
306
|
+
if dry_run:
|
|
307
|
+
return f"[DRY-RUN] Would rename: {old_key} -> {new_key}"
|
|
308
|
+
value = self._env_vars.pop(old_key)
|
|
309
|
+
self._env_vars[new_key] = value
|
|
310
|
+
self._save_env_vars()
|
|
311
|
+
self.log_operation('rename', old_key, f'-> {new_key}')
|
|
312
|
+
return f"Renamed: {old_key} -> {new_key}"
|
|
313
|
+
|
|
314
|
+
def copy(self, src_key: str, dst_key: str, dry_run: bool = False) -> str:
|
|
315
|
+
"""复制环境变量"""
|
|
316
|
+
if src_key not in self._env_vars:
|
|
317
|
+
raise KeyNotFoundError(src_key)
|
|
318
|
+
if dry_run:
|
|
319
|
+
return f"[DRY-RUN] Would copy: {src_key} -> {dst_key}"
|
|
320
|
+
self._env_vars[dst_key] = self._env_vars[src_key]
|
|
321
|
+
self._save_env_vars()
|
|
322
|
+
self.log_operation('copy', src_key, f'-> {dst_key}')
|
|
323
|
+
return f"Copied: {src_key} -> {dst_key}"
|
|
324
|
+
|
|
325
|
+
# ── 内存加载 ──────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
def load_to_memory(
|
|
328
|
+
self,
|
|
329
|
+
filter_prefix: Optional[str] = None,
|
|
330
|
+
add_evm_prefix: bool = True,
|
|
331
|
+
) -> tuple[int, bool, Optional[str]]:
|
|
332
|
+
"""加载环境变量到 os.environ"""
|
|
333
|
+
loaded_count = 0
|
|
334
|
+
for key, value in self._env_vars.items():
|
|
335
|
+
if filter_prefix and not key.startswith(filter_prefix):
|
|
336
|
+
continue
|
|
337
|
+
final_key = f"EVM:{key}" if add_evm_prefix else key
|
|
338
|
+
os.environ[final_key] = str(value)
|
|
339
|
+
loaded_count += 1
|
|
340
|
+
return loaded_count, add_evm_prefix, filter_prefix
|
|
341
|
+
|
|
342
|
+
# ── 执行命令 ──────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def execute(self, command: list[str]) -> int:
|
|
345
|
+
"""使用环境变量执行命令
|
|
346
|
+
|
|
347
|
+
P1: 改用 subprocess.run 替代 os.execvpe,
|
|
348
|
+
以便 Agent 可以捕获退出码。
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
子进程的退出码
|
|
352
|
+
"""
|
|
353
|
+
if not command:
|
|
354
|
+
raise EVMError("No command specified")
|
|
355
|
+
|
|
356
|
+
env_copy = os.environ.copy()
|
|
357
|
+
for key, value in self._env_vars.items():
|
|
358
|
+
env_copy[key] = str(value)
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
result = subprocess.run(command, env=env_copy)
|
|
362
|
+
return result.returncode
|
|
363
|
+
except FileNotFoundError:
|
|
364
|
+
raise CommandNotFoundError(command[0])
|
|
365
|
+
except KeyboardInterrupt:
|
|
366
|
+
return 130
|
|
367
|
+
except Exception as e:
|
|
368
|
+
raise EVMError(f"Error executing command: {e}")
|
|
369
|
+
|
|
370
|
+
# ── 编辑器编辑 ────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
def edit(self, key: str) -> str:
|
|
373
|
+
"""使用 $EDITOR 编辑变量值"""
|
|
374
|
+
if key not in self._env_vars:
|
|
375
|
+
raise KeyNotFoundError(key)
|
|
376
|
+
|
|
377
|
+
editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'vi'))
|
|
378
|
+
current_value = self._env_vars[key]
|
|
379
|
+
|
|
380
|
+
with tempfile.NamedTemporaryFile(
|
|
381
|
+
mode='w', suffix='.txt', prefix='evm_edit_', delete=False
|
|
382
|
+
) as tmp:
|
|
383
|
+
tmp.write(current_value)
|
|
384
|
+
tmp_path = tmp.name
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
try:
|
|
388
|
+
result = subprocess.run([editor, tmp_path])
|
|
389
|
+
except FileNotFoundError:
|
|
390
|
+
raise EditorError(
|
|
391
|
+
f"Editor not found: '{editor}'. "
|
|
392
|
+
f"Set $EDITOR or $VISUAL to a valid editor path."
|
|
393
|
+
)
|
|
394
|
+
if result.returncode != 0:
|
|
395
|
+
raise EditorError(
|
|
396
|
+
f"Editor exited with code {result.returncode}"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
with open(tmp_path, encoding='utf-8') as f:
|
|
400
|
+
new_value = f.read().rstrip('\n')
|
|
401
|
+
|
|
402
|
+
if new_value == current_value:
|
|
403
|
+
return f"No changes made to '{key}'"
|
|
404
|
+
|
|
405
|
+
self._env_vars[key] = new_value
|
|
406
|
+
self._save_env_vars()
|
|
407
|
+
self.log_operation('edit', key)
|
|
408
|
+
return f"Updated: {key}"
|
|
409
|
+
finally:
|
|
410
|
+
if os.path.exists(tmp_path):
|
|
411
|
+
os.unlink(tmp_path)
|
|
412
|
+
|
|
413
|
+
# ── 工具信息 ──────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
def info(self) -> dict[str, object]:
|
|
416
|
+
"""返回工具元信息"""
|
|
417
|
+
groups = self.list_groups()
|
|
418
|
+
total_vars = len(self._env_vars)
|
|
419
|
+
secret_count = sum(
|
|
420
|
+
1 for v in self._env_vars.values()
|
|
421
|
+
if isinstance(v, str) and (
|
|
422
|
+
v.startswith(self.SECRET_PREFIX)
|
|
423
|
+
or v.startswith(self.SECRET_V2_PREFIX)
|
|
424
|
+
or v.startswith(self.SECRET_V3_PREFIX)
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
'version': '2.3.0',
|
|
430
|
+
'author': 'EVM Tool',
|
|
431
|
+
'license': 'MIT',
|
|
432
|
+
'python': sys.version.split()[0],
|
|
433
|
+
'platform': platform.system(),
|
|
434
|
+
'storage_path': str(self.env_file),
|
|
435
|
+
'storage_exists': self.env_file.exists(),
|
|
436
|
+
'total_variables': total_vars,
|
|
437
|
+
'total_groups': len(groups),
|
|
438
|
+
'secret_variables': secret_count,
|
|
439
|
+
'groups': groups,
|
|
440
|
+
'repository': 'https://github.com/zxygithub/evm',
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# ── 模板展开 ──────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
def expand(self, key: str, depth: int = 0, max_depth: int = 10) -> str:
|
|
446
|
+
"""展开变量值中的模板引用 {{OTHER_VAR}}"""
|
|
447
|
+
if key not in self._env_vars:
|
|
448
|
+
raise KeyNotFoundError(key)
|
|
449
|
+
if depth > max_depth:
|
|
450
|
+
raise EVMError(
|
|
451
|
+
f"Template expansion exceeded max depth ({max_depth}). "
|
|
452
|
+
f"Possible circular reference."
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
value = self._env_vars[key]
|
|
456
|
+
|
|
457
|
+
def replace_match(match):
|
|
458
|
+
ref_key = match.group(1)
|
|
459
|
+
if ref_key in self._env_vars:
|
|
460
|
+
ref_val = self._env_vars[ref_key]
|
|
461
|
+
if self.TEMPLATE_PATTERN.search(ref_val):
|
|
462
|
+
return self._expand_value(
|
|
463
|
+
ref_val, depth + 1, max_depth
|
|
464
|
+
)
|
|
465
|
+
return ref_val
|
|
466
|
+
return match.group(0)
|
|
467
|
+
|
|
468
|
+
return self.TEMPLATE_PATTERN.sub(replace_match, value)
|
|
469
|
+
|
|
470
|
+
def _expand_value(
|
|
471
|
+
self, value: str, depth: int, max_depth: int
|
|
472
|
+
) -> str:
|
|
473
|
+
"""内部:展开字符串中的模板引用"""
|
|
474
|
+
if depth > max_depth:
|
|
475
|
+
return value
|
|
476
|
+
|
|
477
|
+
def replace_match(match):
|
|
478
|
+
ref_key = match.group(1)
|
|
479
|
+
if ref_key in self._env_vars:
|
|
480
|
+
return self._expand_value(
|
|
481
|
+
self._env_vars[ref_key], depth + 1, max_depth
|
|
482
|
+
)
|
|
483
|
+
return match.group(0)
|
|
484
|
+
|
|
485
|
+
return self.TEMPLATE_PATTERN.sub(replace_match, value)
|
|
486
|
+
|
|
487
|
+
# ── 加密变量(v3: HKDF + HMAC-CTR + Encrypt-then-MAC)──────
|
|
488
|
+
|
|
489
|
+
@staticmethod
|
|
490
|
+
def _get_machine_salt() -> bytes:
|
|
491
|
+
"""获取机器相关的盐值
|
|
492
|
+
|
|
493
|
+
#2: 此盐值与机器绑定。hostname/uid/arch 变化会导致密钥不同。
|
|
494
|
+
用户应知晓此限制。
|
|
495
|
+
"""
|
|
496
|
+
machine_id = (
|
|
497
|
+
platform.node()
|
|
498
|
+
+ str(os.getuid() if hasattr(os, 'getuid') else '')
|
|
499
|
+
+ platform.machine()
|
|
500
|
+
)
|
|
501
|
+
return machine_id.encode('utf-8')
|
|
502
|
+
|
|
503
|
+
def _derive_master_key(self, salt: bytes) -> bytes:
|
|
504
|
+
"""#4+#15: PBKDF2 派生主密钥,供 _crypto.py 使用"""
|
|
505
|
+
return hashlib.pbkdf2_hmac(
|
|
506
|
+
'sha256', self._get_machine_salt(), salt, 100000, dklen=32
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# ── v2 兼容(保留旧版解密,供自动迁移使用)────────────
|
|
510
|
+
|
|
511
|
+
def _derive_key_v2(self, salt: bytes) -> bytes:
|
|
512
|
+
"""v2 兼容:PBKDF2 密钥派生(与 v3 共用参数)"""
|
|
513
|
+
return hashlib.pbkdf2_hmac(
|
|
514
|
+
'sha256', self._get_machine_salt(), salt, 100000, dklen=32
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def _decrypt_v2(self, encoded: str) -> str:
|
|
518
|
+
"""v2 兼容解密(重复密钥 XOR + HMAC)"""
|
|
519
|
+
parts = encoded.split(':')
|
|
520
|
+
if len(parts) != 3:
|
|
521
|
+
raise DecryptionError("Invalid v2 encrypted data format")
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
salt = base64.b64decode(parts[0])
|
|
525
|
+
stored_mac = base64.b64decode(parts[1])
|
|
526
|
+
ciphertext = base64.b64decode(parts[2])
|
|
527
|
+
except Exception as e:
|
|
528
|
+
raise DecryptionError(
|
|
529
|
+
f"Failed to decode v2 data: {e}"
|
|
530
|
+
) from e
|
|
531
|
+
|
|
532
|
+
key = self._derive_key_v2(salt)
|
|
533
|
+
|
|
534
|
+
computed_mac = hmac.new(
|
|
535
|
+
key, salt + ciphertext, hashlib.sha256
|
|
536
|
+
).digest()
|
|
537
|
+
if not hmac.compare_digest(stored_mac, computed_mac):
|
|
538
|
+
raise DecryptionError(
|
|
539
|
+
"Data integrity check failed (v2)"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
plaintext = bytes(
|
|
543
|
+
d ^ key[i % len(key)] for i, d in enumerate(ciphertext)
|
|
544
|
+
)
|
|
545
|
+
try:
|
|
546
|
+
return plaintext.decode('utf-8')
|
|
547
|
+
except UnicodeDecodeError as e:
|
|
548
|
+
raise DecryptionError(
|
|
549
|
+
f"Decrypted data is not valid UTF-8: {e}"
|
|
550
|
+
) from e
|
|
551
|
+
|
|
552
|
+
def _decrypt_v1(self, encoded: str) -> str:
|
|
553
|
+
"""v1 兼容解密(简单 XOR + base64,无盐无 MAC)"""
|
|
554
|
+
machine_id = (
|
|
555
|
+
platform.node()
|
|
556
|
+
+ str(os.getuid() if hasattr(os, 'getuid') else '')
|
|
557
|
+
+ platform.machine()
|
|
558
|
+
)
|
|
559
|
+
key = hashlib.sha256(machine_id.encode()).digest()
|
|
560
|
+
try:
|
|
561
|
+
encrypted = base64.b64decode(encoded.encode('ascii'))
|
|
562
|
+
decrypted = bytes(
|
|
563
|
+
d ^ key[i % len(key)] for i, d in enumerate(encrypted)
|
|
564
|
+
)
|
|
565
|
+
return decrypted.decode('utf-8')
|
|
566
|
+
except Exception as e:
|
|
567
|
+
raise DecryptionError(
|
|
568
|
+
f"Failed to decrypt (v1): {e}"
|
|
569
|
+
) from e
|
|
570
|
+
|
|
571
|
+
def set_secret(
|
|
572
|
+
self, key: str, value: str, dry_run: bool = False
|
|
573
|
+
) -> str:
|
|
574
|
+
"""加密存储变量
|
|
575
|
+
|
|
576
|
+
使用 v3 格式: HKDF 密钥分离 + HMAC-CTR + Encrypt-then-MAC。
|
|
577
|
+
|
|
578
|
+
#2: 首次使用时打印机器绑定警告。
|
|
579
|
+
"""
|
|
580
|
+
if dry_run:
|
|
581
|
+
return f"[DRY-RUN] Would set secret: {key}=*** (encrypted)"
|
|
582
|
+
|
|
583
|
+
# #2: 首次使用加密功能时发出警告
|
|
584
|
+
warning = ''
|
|
585
|
+
if not self._secret_warning_shown:
|
|
586
|
+
self._secret_warning_shown = True
|
|
587
|
+
warning = (
|
|
588
|
+
" [WARNING: Encryption key is derived from machine identity "
|
|
589
|
+
"(hostname + uid + arch). Changing hostname or migrating to "
|
|
590
|
+
"another machine will make secrets unrecoverable.]"
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
encrypted = encrypt_v3(value, self._derive_master_key)
|
|
594
|
+
self._env_vars[key] = encrypted
|
|
595
|
+
self._save_env_vars()
|
|
596
|
+
self.log_operation('set_secret', key)
|
|
597
|
+
return f"Set secret: {key}=*** (encrypted){warning}"
|
|
598
|
+
|
|
599
|
+
def get_secret(self, key: str) -> str:
|
|
600
|
+
"""获取并解密变量
|
|
601
|
+
|
|
602
|
+
支持 v1/v2/v3 三种格式。
|
|
603
|
+
#16: 读取 v1/v2 格式时自动迁移到 v3。
|
|
604
|
+
"""
|
|
605
|
+
value = self._env_vars.get(key)
|
|
606
|
+
if value is None:
|
|
607
|
+
raise KeyNotFoundError(key)
|
|
608
|
+
|
|
609
|
+
if isinstance(value, str) and value.startswith(self.SECRET_V3_PREFIX):
|
|
610
|
+
# v3: 直接解密
|
|
611
|
+
return decrypt_v3(
|
|
612
|
+
value[len(self.SECRET_V3_PREFIX):],
|
|
613
|
+
self._derive_master_key,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
elif isinstance(value, str) and value.startswith(self.SECRET_V2_PREFIX):
|
|
617
|
+
# #16: v2 → 解密后自动迁移到 v3
|
|
618
|
+
plaintext = self._decrypt_v2(
|
|
619
|
+
value[len(self.SECRET_V2_PREFIX):]
|
|
620
|
+
)
|
|
621
|
+
self._env_vars[key] = encrypt_v3(
|
|
622
|
+
plaintext, self._derive_master_key
|
|
623
|
+
)
|
|
624
|
+
self._save_env_vars()
|
|
625
|
+
self.log_operation('migrate_secret', key, 'v2 -> v3')
|
|
626
|
+
return plaintext
|
|
627
|
+
|
|
628
|
+
elif isinstance(value, str) and value.startswith(self.SECRET_PREFIX):
|
|
629
|
+
# #16: v1 → 解密后自动迁移到 v3
|
|
630
|
+
plaintext = self._decrypt_v1(
|
|
631
|
+
value[len(self.SECRET_PREFIX):]
|
|
632
|
+
)
|
|
633
|
+
self._env_vars[key] = encrypt_v3(
|
|
634
|
+
plaintext, self._derive_master_key
|
|
635
|
+
)
|
|
636
|
+
self._save_env_vars()
|
|
637
|
+
self.log_operation('migrate_secret', key, 'v1 -> v3')
|
|
638
|
+
return plaintext
|
|
639
|
+
|
|
640
|
+
else:
|
|
641
|
+
raise DecryptionError(f"'{key}' is not an encrypted variable")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
__all__ = ['EnvironmentManager']
|