pi-webdav-backup 1.0.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/LICENSE +21 -0
- package/README.md +355 -0
- package/extensions/webdav-backup/audit-real.mjs +84 -0
- package/extensions/webdav-backup/auth-migrate.mjs +242 -0
- package/extensions/webdav-backup/backup.mjs +684 -0
- package/extensions/webdav-backup/cli.mjs +352 -0
- package/extensions/webdav-backup/collect.mjs +125 -0
- package/extensions/webdav-backup/config-paths.mjs +32 -0
- package/extensions/webdav-backup/config.mjs +199 -0
- package/extensions/webdav-backup/cron.mjs +90 -0
- package/extensions/webdav-backup/crypto.mjs +163 -0
- package/extensions/webdav-backup/dav-mock.mjs +145 -0
- package/extensions/webdav-backup/migrate.mjs +167 -0
- package/extensions/webdav-backup/restore.mjs +220 -0
- package/extensions/webdav-backup/run-tests.mjs +41 -0
- package/extensions/webdav-backup/secrets.mjs +404 -0
- package/extensions/webdav-backup/tar.mjs +111 -0
- package/extensions/webdav-backup/test-auth-migrate.mjs +187 -0
- package/extensions/webdav-backup/test-cli.mjs +331 -0
- package/extensions/webdav-backup/test-redact.mjs +271 -0
- package/extensions/webdav-backup/test-restore.mjs +219 -0
- package/extensions/webdav-backup/test-secrets.mjs +268 -0
- package/extensions/webdav-backup/test.mjs +442 -0
- package/extensions/webdav-backup/verify-load.mjs +89 -0
- package/extensions/webdav-backup/webdav-backup.example.json +38 -0
- package/extensions/webdav-backup/webdav.mjs +218 -0
- package/extensions/webdav-backup.ts +766 -0
- package/package.json +41 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secrets.mjs — 密钥引用解析与安全存储
|
|
3
|
+
*
|
|
4
|
+
* 设计目标:配置文件里不出现明文密钥。
|
|
5
|
+
*
|
|
6
|
+
* 支持的值语法(与 pi 自身的 resolve-config-value 约定保持一致,便于理解):
|
|
7
|
+
* "$ENV_VAR" / "${ENV_VAR}" 环境变量
|
|
8
|
+
* "!command" 执行命令取 stdout(如密码管理器 CLI)
|
|
9
|
+
* "dpapi:名称" Windows DPAPI 用户级加密(本机本用户可解,无需主密码)
|
|
10
|
+
* "file:路径" 从文件读取(自动 strip 换行)
|
|
11
|
+
* "plain:值" 显式声明明文(会告警,仅用于临时调试)
|
|
12
|
+
* 其他任意值 视为明文(会告警)
|
|
13
|
+
*
|
|
14
|
+
* DPAPI 存储位置:~/.pi/agent/secrets.dpapi.json
|
|
15
|
+
* 该文件即使被复制走,在别的机器/用户下也无法解密。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
chmodSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { getAgentDir } from "./config-paths.mjs";
|
|
30
|
+
|
|
31
|
+
export const DPAPI_SCHEME = "dpapi:";
|
|
32
|
+
export const FILE_SCHEME = "file:";
|
|
33
|
+
export const PLAIN_SCHEME = "plain:";
|
|
34
|
+
|
|
35
|
+
/** DPAPI 密文仓库路径 */
|
|
36
|
+
export function dpapiStorePath() {
|
|
37
|
+
return join(getAgentDir(), "secrets.dpapi.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isWindows = process.platform === "win32";
|
|
41
|
+
|
|
42
|
+
// ─────────────────────────────────────────────── DPAPI 原语
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 用 PowerShell 调用 Windows DPAPI(ProtectedData, CurrentUser 作用域)。
|
|
46
|
+
* 密文以 base64 存储。
|
|
47
|
+
*/
|
|
48
|
+
function dpapiEncrypt(text) {
|
|
49
|
+
if (!isWindows) throw new Error("DPAPI 仅支持 Windows");
|
|
50
|
+
const script = `
|
|
51
|
+
$ErrorActionPreference = "Stop"
|
|
52
|
+
Add-Type -AssemblyName System.Security
|
|
53
|
+
$plain = [Console]::In.ReadToEnd()
|
|
54
|
+
$bytes = [Text.Encoding]::UTF8.GetBytes($plain)
|
|
55
|
+
$enc = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
56
|
+
[Console]::Out.Write([Convert]::ToBase64String($enc))
|
|
57
|
+
`;
|
|
58
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
59
|
+
input: text,
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
windowsHide: true,
|
|
62
|
+
timeout: 30_000,
|
|
63
|
+
});
|
|
64
|
+
if (r.error) throw new Error(`DPAPI 加密失败:${r.error.message}`);
|
|
65
|
+
if (r.status !== 0) throw new Error(`DPAPI 加密失败:${(r.stderr ?? "").trim() || `退出码 ${r.status}`}`);
|
|
66
|
+
const b64 = (r.stdout ?? "").trim();
|
|
67
|
+
if (!b64) throw new Error("DPAPI 加密失败:输出为空");
|
|
68
|
+
return b64;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function dpapiDecrypt(b64) {
|
|
72
|
+
if (!isWindows) throw new Error("DPAPI 仅支持 Windows");
|
|
73
|
+
const script = `
|
|
74
|
+
$ErrorActionPreference = "Stop"
|
|
75
|
+
Add-Type -AssemblyName System.Security
|
|
76
|
+
$b64 = [Console]::In.ReadToEnd().Trim()
|
|
77
|
+
$enc = [Convert]::FromBase64String($b64)
|
|
78
|
+
$bytes = [Security.Cryptography.ProtectedData]::Unprotect($enc, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
79
|
+
[Console]::Out.Write([Text.Encoding]::UTF8.GetString($bytes))
|
|
80
|
+
`;
|
|
81
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
82
|
+
input: b64,
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
timeout: 30_000,
|
|
86
|
+
});
|
|
87
|
+
if (r.error) throw new Error(`DPAPI 解密失败:${r.error.message}`);
|
|
88
|
+
if (r.status !== 0) {
|
|
89
|
+
throw new Error(`DPAPI 解密失败(可能不是本机本用户加密的):${(r.stderr ?? "").trim()}`);
|
|
90
|
+
}
|
|
91
|
+
return r.stdout ?? "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function dpapiAvailable() {
|
|
95
|
+
return isWindows;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─────────────────────────────────────────────── DPAPI 仓库读写
|
|
99
|
+
|
|
100
|
+
function readDpapiStore() {
|
|
101
|
+
const p = dpapiStorePath();
|
|
102
|
+
try {
|
|
103
|
+
if (!existsSync(p)) return {};
|
|
104
|
+
const raw = readFileSync(p, "utf8").replace(/^\uFEFF/, "").trim();
|
|
105
|
+
if (!raw) return {};
|
|
106
|
+
const parsed = JSON.parse(raw);
|
|
107
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
108
|
+
} catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeDpapiStore(store) {
|
|
114
|
+
const p = dpapiStorePath();
|
|
115
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
116
|
+
writeFileSync(p, JSON.stringify(store, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
117
|
+
try {
|
|
118
|
+
chmodSync(p, 0o600);
|
|
119
|
+
} catch {
|
|
120
|
+
/* Windows 上可能无效,忽略 */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 写入一个 DPAPI 加密的密钥 */
|
|
125
|
+
export function setDpapiSecret(name, value) {
|
|
126
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
127
|
+
throw new Error(`密钥名称只能包含字母、数字、点、下划线、连字符:${name}`);
|
|
128
|
+
}
|
|
129
|
+
const store = readDpapiStore();
|
|
130
|
+
store[name] = {
|
|
131
|
+
v: 1,
|
|
132
|
+
scope: "CurrentUser",
|
|
133
|
+
updatedAt: new Date().toISOString(),
|
|
134
|
+
value: dpapiEncrypt(String(value)),
|
|
135
|
+
};
|
|
136
|
+
writeDpapiStore(store);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getDpapiSecret(name) {
|
|
141
|
+
const entry = readDpapiStore()[name];
|
|
142
|
+
if (!entry) return undefined;
|
|
143
|
+
const payload = typeof entry === "string" ? entry : entry?.value;
|
|
144
|
+
if (!payload) return undefined;
|
|
145
|
+
return dpapiDecrypt(payload);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function deleteDpapiSecret(name) {
|
|
149
|
+
const store = readDpapiStore();
|
|
150
|
+
if (!(name in store)) return false;
|
|
151
|
+
delete store[name];
|
|
152
|
+
writeDpapiStore(store);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 列出已存的密钥名与元信息(不含明文) */
|
|
157
|
+
export function listDpapiSecrets() {
|
|
158
|
+
const store = readDpapiStore();
|
|
159
|
+
return Object.entries(store).map(([name, entry]) => ({
|
|
160
|
+
name,
|
|
161
|
+
updatedAt: typeof entry === "object" ? entry.updatedAt : undefined,
|
|
162
|
+
scope: typeof entry === "object" ? entry.scope : "CurrentUser",
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─────────────────────────────────────────────── 值解析
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 解析一个配置值引用。
|
|
170
|
+
*
|
|
171
|
+
* @param {unknown} value 原始配置值
|
|
172
|
+
* @param {{env?: Record<string, string>, allowCommand?: boolean, allowPlain?: boolean}} [opts]
|
|
173
|
+
* @returns {{value: string|undefined, source: string, warning?: string, error?: string}}
|
|
174
|
+
*/
|
|
175
|
+
export function resolveSecret(value, opts = {}) {
|
|
176
|
+
const { env = process.env, allowCommand = true, allowPlain = true } = opts;
|
|
177
|
+
|
|
178
|
+
if (value === undefined || value === null || value === "") {
|
|
179
|
+
return { value: undefined, source: "empty" };
|
|
180
|
+
}
|
|
181
|
+
if (typeof value !== "string") {
|
|
182
|
+
return { value: String(value), source: "non-string" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const raw = value;
|
|
186
|
+
|
|
187
|
+
// dpapi:名称
|
|
188
|
+
if (raw.startsWith(DPAPI_SCHEME)) {
|
|
189
|
+
const name = raw.slice(DPAPI_SCHEME.length).trim();
|
|
190
|
+
if (!name) return { value: undefined, source: "dpapi", error: "dpapi: 后缺少密钥名称" };
|
|
191
|
+
if (!dpapiAvailable()) {
|
|
192
|
+
return { value: undefined, source: "dpapi", error: "DPAPI 仅支持 Windows;请改用 $ENV 或 file:" };
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const v = getDpapiSecret(name);
|
|
196
|
+
if (v === undefined) {
|
|
197
|
+
return { value: undefined, source: "dpapi", error: `DPAPI 仓库中找不到密钥「${name}」` };
|
|
198
|
+
}
|
|
199
|
+
return { value: v, source: "dpapi" };
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return { value: undefined, source: "dpapi", error: e instanceof Error ? e.message : String(e) };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// file:路径
|
|
206
|
+
if (raw.startsWith(FILE_SCHEME)) {
|
|
207
|
+
const p0 = raw.slice(FILE_SCHEME.length).trim();
|
|
208
|
+
if (!p0) return { value: undefined, source: "file", error: "file: 后缺少路径" };
|
|
209
|
+
const p = isAbsolute(p0) ? p0 : resolve(getAgentDir(), p0);
|
|
210
|
+
try {
|
|
211
|
+
if (!existsSync(p)) return { value: undefined, source: "file", error: `密钥文件不存在:${p}` };
|
|
212
|
+
const text = readFileSync(p, "utf8").replace(/^\uFEFF/, "").trim();
|
|
213
|
+
if (!text) return { value: undefined, source: "file", error: `密钥文件为空:${p}` };
|
|
214
|
+
return { value: text, source: "file" };
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return { value: undefined, source: "file", error: e instanceof Error ? e.message : String(e) };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// plain:值 —— 显式明文,告警
|
|
221
|
+
if (raw.startsWith(PLAIN_SCHEME)) {
|
|
222
|
+
const v = raw.slice(PLAIN_SCHEME.length);
|
|
223
|
+
if (!allowPlain) return { value: undefined, source: "plain", error: "不允许明文密钥" };
|
|
224
|
+
return {
|
|
225
|
+
value: v,
|
|
226
|
+
source: "plain",
|
|
227
|
+
warning: "该值以 plain: 明文存储,建议改用 dpapi: / $ENV / file:",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// !command —— 执行命令取 stdout
|
|
232
|
+
if (raw.startsWith("!")) {
|
|
233
|
+
if (!allowCommand) return { value: undefined, source: "command", error: "不允许执行命令取值" };
|
|
234
|
+
const cmd = raw.slice(1);
|
|
235
|
+
if (!cmd.trim()) return { value: undefined, source: "command", error: "! 后缺少命令" };
|
|
236
|
+
try {
|
|
237
|
+
const out = execSync(cmd, {
|
|
238
|
+
encoding: "utf8",
|
|
239
|
+
timeout: 15_000,
|
|
240
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
241
|
+
windowsHide: true,
|
|
242
|
+
});
|
|
243
|
+
const v = String(out ?? "").replace(/\r?\n$/, "").trim();
|
|
244
|
+
if (!v) return { value: undefined, source: "command", error: `命令无输出:${cmd}` };
|
|
245
|
+
return { value: v, source: "command" };
|
|
246
|
+
} catch (e) {
|
|
247
|
+
return {
|
|
248
|
+
value: undefined,
|
|
249
|
+
source: "command",
|
|
250
|
+
error: `命令执行失败:${cmd}(${e instanceof Error ? e.message.split("\n")[0] : String(e)})`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// $ENV / ${ENV} 以及模板插值(与 pi 的 resolve-config-value 语义一致)
|
|
256
|
+
if (raw.includes("$")) {
|
|
257
|
+
const parts = parseTemplate(raw);
|
|
258
|
+
if (parts.missing.length > 0) {
|
|
259
|
+
return {
|
|
260
|
+
value: undefined,
|
|
261
|
+
source: "env",
|
|
262
|
+
error: `环境变量未设置:${parts.missing.join(", ")}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const v = parts.parts
|
|
266
|
+
.map((p) => (p.type === "literal" ? p.value : (env[p.name] ?? "")))
|
|
267
|
+
.join("");
|
|
268
|
+
if (!v) return { value: undefined, source: "env", error: "解析结果为空" };
|
|
269
|
+
return { value: v, source: "env" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 其他:字面量,即明文
|
|
273
|
+
if (!allowPlain) return { value: undefined, source: "literal", error: "配置中不允许出现明文" };
|
|
274
|
+
return {
|
|
275
|
+
value: raw,
|
|
276
|
+
source: "literal",
|
|
277
|
+
warning: "检测到明文密钥,建议改用 dpapi:名称(推荐)或 $ENV_VAR",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
282
|
+
|
|
283
|
+
function parseTemplate(config) {
|
|
284
|
+
const parts = [];
|
|
285
|
+
const missing = [];
|
|
286
|
+
let i = 0;
|
|
287
|
+
while (i < config.length) {
|
|
288
|
+
const d = config.indexOf("$", i);
|
|
289
|
+
if (d < 0) {
|
|
290
|
+
if (i < config.length) parts.push({ type: "literal", value: config.slice(i) });
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
if (d > i) parts.push({ type: "literal", value: config.slice(i, d) });
|
|
294
|
+
const next = config[d + 1];
|
|
295
|
+
if (next === "$" || next === "!") {
|
|
296
|
+
parts.push({ type: "literal", value: next });
|
|
297
|
+
i = d + 2;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (next === "{") {
|
|
301
|
+
const end = config.indexOf("}", d + 2);
|
|
302
|
+
if (end < 0) {
|
|
303
|
+
parts.push({ type: "literal", value: "$" });
|
|
304
|
+
i = d + 1;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const name = config.slice(d + 2, end);
|
|
308
|
+
if (ENV_NAME_RE.test(name)) {
|
|
309
|
+
parts.push({ type: "env", name });
|
|
310
|
+
if (process.env[name] === undefined) missing.push(name);
|
|
311
|
+
} else {
|
|
312
|
+
parts.push({ type: "literal", value: config.slice(d, end + 1) });
|
|
313
|
+
}
|
|
314
|
+
i = end + 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const m = config.slice(d + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
318
|
+
if (m) {
|
|
319
|
+
parts.push({ type: "env", name: m[0] });
|
|
320
|
+
if (process.env[m[0]] === undefined) missing.push(m[0]);
|
|
321
|
+
i = d + 1 + m[0].length;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
parts.push({ type: "literal", value: "$" });
|
|
325
|
+
i = d + 1;
|
|
326
|
+
}
|
|
327
|
+
return { parts, missing };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 判断某个值是否为「明文」(需要提示用户) */
|
|
331
|
+
export function isPlaintextSecret(value) { if (value === undefined || value === null || value === "") return false;
|
|
332
|
+
if (typeof value !== "string") return true;
|
|
333
|
+
const v = value.trim();
|
|
334
|
+
if (!v) return false;
|
|
335
|
+
if (v.startsWith(DPAPI_SCHEME) || v.startsWith(FILE_SCHEME) || v.startsWith(PLAIN_SCHEME)) return false;
|
|
336
|
+
if (v.startsWith("!")) return false;
|
|
337
|
+
if (v.includes("$")) return false;
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─────────────────────────────────────────────── 环境变量持久化
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 设置用户级环境变量。
|
|
345
|
+
*
|
|
346
|
+
* 安全细节:通过 stdin 把值传给 PowerShell,而不是作为命令行参数,
|
|
347
|
+
* 避免密钥出现在进程列表(tasklist / ps)中。
|
|
348
|
+
*
|
|
349
|
+
* 注意:用户级环境变量写入注册表,当前进程不会立即看到,需新进程生效。
|
|
350
|
+
*/
|
|
351
|
+
export function setUserEnvVar(name, value) {
|
|
352
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
353
|
+
throw new Error(`环境变量名不合法:${name}`);
|
|
354
|
+
}
|
|
355
|
+
const str = String(value);
|
|
356
|
+
|
|
357
|
+
if (isWindows) {
|
|
358
|
+
const script = `
|
|
359
|
+
$ErrorActionPreference = "Stop"
|
|
360
|
+
$v = [Console]::In.ReadToEnd()
|
|
361
|
+
[Environment]::SetEnvironmentVariable(${JSON.stringify(name)}, $v, 'User')
|
|
362
|
+
`;
|
|
363
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
364
|
+
input: str,
|
|
365
|
+
encoding: "utf8",
|
|
366
|
+
windowsHide: true,
|
|
367
|
+
timeout: 30_000,
|
|
368
|
+
});
|
|
369
|
+
if (r.error) throw new Error(`设置环境变量失败:${r.error.message}`);
|
|
370
|
+
if (r.status !== 0) {
|
|
371
|
+
throw new Error(`设置环境变量失败:${(r.stderr ?? "").trim() || `退出码 ${r.status}`}`);
|
|
372
|
+
}
|
|
373
|
+
return { ok: true, method: "registry", note: "需新开进程/重启 pi 后生效" };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
method: "manual",
|
|
379
|
+
note: `请手动把 export ${name}=... 写入 ~/.bashrc 或 ~/.zshrc`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 从注册表读回用户级环境变量(用于验证写入结果,当前进程看不到新值) */
|
|
384
|
+
export function readUserEnvVar(name) {
|
|
385
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return undefined;
|
|
386
|
+
if (!isWindows) return undefined;
|
|
387
|
+
const script = `
|
|
388
|
+
$ErrorActionPreference = "Stop"
|
|
389
|
+
[Environment]::GetEnvironmentVariable(${JSON.stringify(name)}, 'User')
|
|
390
|
+
`;
|
|
391
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
392
|
+
encoding: "utf8",
|
|
393
|
+
windowsHide: true,
|
|
394
|
+
timeout: 20_000,
|
|
395
|
+
});
|
|
396
|
+
if (r.status !== 0) return undefined;
|
|
397
|
+
const v = (r.stdout ?? "").replace(/\r?\n$/, "");
|
|
398
|
+
return v || undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 检查某环境变量是否已在当前进程中可见 */
|
|
402
|
+
export function envVarVisible(name) {
|
|
403
|
+
return process.env[name] !== undefined && process.env[name] !== "";
|
|
404
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tar.mjs — 极简 ustar tar 读取器(与 backup.mjs 的 TarWriter 配对)
|
|
3
|
+
*
|
|
4
|
+
* 只处理我们自己生成的归档:ustar 格式,文件类型 0(普通文件),
|
|
5
|
+
* 支持 GNU/ustar prefix 长路径。不做符号链接、稀疏文件等。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createReadStream } from "node:fs";
|
|
9
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join, normalize, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
const BLOCK = 512;
|
|
13
|
+
|
|
14
|
+
function readString(buf, start, len) {
|
|
15
|
+
const slice = buf.subarray(start, start + len);
|
|
16
|
+
const end = slice.indexOf(0);
|
|
17
|
+
return slice.subarray(0, end === -1 ? len : end).toString("utf8").trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readOctal(buf, start, len) {
|
|
21
|
+
const s = readString(buf, start, len).replace(/\0/g, "").trim();
|
|
22
|
+
if (!s) return 0;
|
|
23
|
+
const n = parseInt(s, 8);
|
|
24
|
+
return Number.isNaN(n) ? 0 : n;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isZeroBlock(buf) {
|
|
28
|
+
for (const b of buf) if (b !== 0) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 防目录穿越:拒绝绝对路径与 .. */
|
|
33
|
+
function safeJoin(destDir, entryName) {
|
|
34
|
+
const normalized = normalize(entryName.replace(/\\/g, "/")).replace(/^([/\\])+/, "");
|
|
35
|
+
const target = join(destDir, normalized);
|
|
36
|
+
const base = normalize(destDir + sep);
|
|
37
|
+
if (!normalize(target + sep).startsWith(base) && normalize(target) !== normalize(destDir)) {
|
|
38
|
+
throw new Error(`归档包含不安全路径,已拒绝:${entryName}`);
|
|
39
|
+
}
|
|
40
|
+
return { target, normalized };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 流式解包 tar(未压缩)。
|
|
45
|
+
* @param {string} tarFile
|
|
46
|
+
* @param {string} destDir
|
|
47
|
+
* @param {{onEntry?: (name: string, size: number) => void, list?: boolean, overwrite?: boolean}} opts
|
|
48
|
+
*/
|
|
49
|
+
export async function extractTar(tarFile, destDir, opts = {}) {
|
|
50
|
+
const { onEntry, list = false } = opts;
|
|
51
|
+
const stream = createReadStream(tarFile, { highWaterMark: BLOCK * 32 });
|
|
52
|
+
const entries = [];
|
|
53
|
+
|
|
54
|
+
let pending = Buffer.alloc(0);
|
|
55
|
+
const take = async (n) => {
|
|
56
|
+
while (pending.length < n) {
|
|
57
|
+
const chunk = await once(stream);
|
|
58
|
+
if (chunk === null) return null;
|
|
59
|
+
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]);
|
|
60
|
+
}
|
|
61
|
+
const out = pending.subarray(0, n);
|
|
62
|
+
pending = pending.subarray(n);
|
|
63
|
+
return out;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const iter = stream[Symbol.asyncIterator]();
|
|
67
|
+
const once = async () => {
|
|
68
|
+
const { value, done } = await iter.next();
|
|
69
|
+
return done ? null : value;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let zeroCount = 0;
|
|
73
|
+
while (true) {
|
|
74
|
+
const header = await take(BLOCK);
|
|
75
|
+
if (header === null) break;
|
|
76
|
+
if (header.length < BLOCK) break;
|
|
77
|
+
if (isZeroBlock(header)) {
|
|
78
|
+
zeroCount++;
|
|
79
|
+
if (zeroCount >= 2) break;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const name = readString(header, 0, 100);
|
|
84
|
+
const prefix = readString(header, 345, 155);
|
|
85
|
+
const fullName = prefix ? `${prefix}/${name}` : name;
|
|
86
|
+
const size = readOctal(header, 124, 12);
|
|
87
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
88
|
+
|
|
89
|
+
const padded = Math.ceil(size / BLOCK) * BLOCK;
|
|
90
|
+
const body = size > 0 ? await take(padded) : Buffer.alloc(0);
|
|
91
|
+
if (body === null) break;
|
|
92
|
+
|
|
93
|
+
if (type !== "0" && type !== "\0" && type !== "") {
|
|
94
|
+
// 目录/其他类型:跳过
|
|
95
|
+
if (type === "5") entries.push({ name: fullName, size: 0, dir: true });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
entries.push({ name: fullName, size, dir: false });
|
|
100
|
+
onEntry?.(fullName, size);
|
|
101
|
+
|
|
102
|
+
if (!list) {
|
|
103
|
+
const { target, normalized } = safeJoin(destDir, fullName);
|
|
104
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
105
|
+
writeFileSync(target, body.subarray(0, size));
|
|
106
|
+
void normalized;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return entries;
|
|
111
|
+
}
|