@xiaoqiong0v0/opencode-ssh-tool 0.6.18 → 0.7.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,1001 @@
1
+ // 文件传输(v1:仅 SSH/SFTP):路径校验、独占创建/临时文件 + rename、overwrite/mkdir 语义、全链路超时与清理
2
+ //
3
+ // 设计要点:
4
+ // - 只走 SFTP subsystem;不使用 exec / scp / base64 / PTY 传文件;内容按二进制流式传输;
5
+ // - 默认(无 overwrite)使用**独占创建**(远端 flags "wx" / 本地 fs flags "wx"),目标存在即失败,
6
+ // 不覆盖、不自动改名/备份;只有 overwrite=true 才走"同目录临时文件 + rename"提交;
7
+ // - 默认不建父目录(parent_missing),mkdir=true 才递归创建;
8
+ // - 只传普通文件;目录 / 符号链接一律拒绝;
9
+ // - 整个传输(开通道后的路径检查/建目录/读写/提交)共用一个 deadline;超时中止 SFTP 通道,
10
+ // 且不再主动发起新的 rename/commit(已发出的 rename 仍可能被服务端执行);
11
+ // - overwrite 备份旧目标后若新文件未就位,本地/远端都尽力回滚旧目标;回滚失败时错误 detail 给出备份路径。
12
+ // - 传输内容不进入模型上下文与日志:本模块不打印任何文件内容,只回传路径/实际字节/耗时。
13
+ //
14
+ // 分层:本文件只依赖可注入的最小 SFTP 接口(TransferSftp),不依赖 SshSession,
15
+ // 便于单测用 fake SFTP 覆盖;SshSession 负责开/关 SFTP subsystem、通道 error 监听与串行化(见 session.ts)。
16
+ import { randomUUID } from "node:crypto";
17
+ import { createReadStream as createLocalReadStream, createWriteStream as createLocalWriteStream } from "node:fs";
18
+ import { lstat as lstatLocal, mkdir as mkdirLocal, open as openLocal, rename as renameLocal, stat as statLocal, unlink as unlinkLocal } from "node:fs/promises";
19
+ import { basename, dirname, join, posix } from "node:path";
20
+ import { MAX_TRANSFER_BYTES, TRANSFER_COMMIT_MIN_REMAINING_MS, TRANSFER_CLEANUP_TIMEOUT_MS, TRANSFER_TIMEOUT_MS, } from "./constants.js";
21
+ import { expandLocalHome } from "./path-utils.js";
22
+ /**
23
+ * 传输错误:只携带机器码 + 路径 + 内部诊断信息,展示文案由调用方按语言生成。
24
+ */
25
+ export class TransferError extends Error {
26
+ code;
27
+ /** 出错涉及的路径(本地或远端,供文案回显) */
28
+ path;
29
+ /** 内部诊断信息(仅用于日志/io 文案,不是可见文案本身) */
30
+ detail;
31
+ /**
32
+ * @param code 机器码
33
+ * @param path 出错路径
34
+ * @param detail 内部诊断信息
35
+ */
36
+ constructor(code, path, detail) {
37
+ super(detail ?? code);
38
+ this.name = "TransferError";
39
+ this.code = code;
40
+ this.path = path;
41
+ this.detail = detail;
42
+ }
43
+ }
44
+ /**
45
+ * 判定错误是否为"文件不存在"(SFTP 状态码 2 / 本地 ENOENT / 文本兜底)。
46
+ * @param err 原始错误
47
+ * @returns true 表示不存在
48
+ */
49
+ function isNoSuchFile(err) {
50
+ const e = err;
51
+ if (e?.code === 2 || e?.code === "ENOENT")
52
+ return true;
53
+ return typeof e?.message === "string" && /no such file/i.test(e.message);
54
+ }
55
+ /**
56
+ * 判定错误是否为"目标已存在"(本地独占创建 EEXIST;部分 SFTP 服务端 EXCL 冲突)。
57
+ * @param err 原始错误
58
+ * @returns true 表示已存在
59
+ */
60
+ function isAlreadyExists(err) {
61
+ const e = err;
62
+ return e?.code === "EEXIST" || (typeof e?.message === "string" && /file already exists/i.test(e.message));
63
+ }
64
+ /**
65
+ * 包装 SFTP 回调为 Promise(stat/lstat)。
66
+ * @param invoke 执行回调式调用的函数
67
+ * @returns 属性
68
+ */
69
+ function statP(invoke) {
70
+ return new Promise((resolve, reject) => {
71
+ invoke((err, stats) => (err ? reject(err) : resolve(stats)));
72
+ });
73
+ }
74
+ /**
75
+ * 包装 SFTP 回调为 Promise(无返回值操作)。
76
+ * @param invoke 执行回调式调用的函数
77
+ * @returns 完成 Promise
78
+ */
79
+ function voidP(invoke) {
80
+ return new Promise((resolve, reject) => {
81
+ invoke((err) => (err ? reject(err) : resolve()));
82
+ });
83
+ }
84
+ /**
85
+ * 超时包装:到点调用 onTimeout(中止通道/流)并以 timeout 拒绝;原 Promise 后到结果被忽略。
86
+ * @param work 待等待的 Promise
87
+ * @param timeoutMs 超时毫秒
88
+ * @param onTimeout 超时回调(可抛错,内部吞掉)
89
+ * @returns work 的结果
90
+ */
91
+ function withTimeout(work, timeoutMs, onTimeout) {
92
+ return new Promise((resolve, reject) => {
93
+ let settled = false;
94
+ const timer = setTimeout(() => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ try {
99
+ onTimeout();
100
+ }
101
+ catch {
102
+ /* 中止失败不影响超时报错 */
103
+ }
104
+ reject(new TransferError("timeout", undefined, `timed out after ${timeoutMs}ms`));
105
+ }, Math.max(1, timeoutMs));
106
+ work.then((value) => {
107
+ if (settled)
108
+ return;
109
+ settled = true;
110
+ clearTimeout(timer);
111
+ resolve(value);
112
+ }, (err) => {
113
+ if (settled)
114
+ return;
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ reject(err);
118
+ });
119
+ });
120
+ }
121
+ /**
122
+ * 传输级 deadline:整条传输共用一份剩余时间;超时/外部中止统一中止(SFTP 通道 + 活动流),
123
+ * 并在每次关键提交前用 check() 保证"中止后不再发起新的 rename/commit"。
124
+ * 外部中止(会话层通道 error / 开通道超时)走 abort(),让正在等待的 SFTP 回调立即失败,
125
+ * 避免 job 在已销毁的通道上挂满整条传输超时。
126
+ */
127
+ class TransferDeadline {
128
+ expiresAt;
129
+ timeoutMs;
130
+ timedOut = false;
131
+ abortError = null;
132
+ abortReject = null;
133
+ abortPromise = null;
134
+ aborts = new Set();
135
+ /**
136
+ * @param timeoutMs 传输总超时(毫秒)
137
+ */
138
+ constructor(timeoutMs) {
139
+ this.timeoutMs = Math.max(1, timeoutMs);
140
+ this.expiresAt = Date.now() + this.timeoutMs;
141
+ }
142
+ /** 是否已超时(含已中止或已过截止时刻) */
143
+ get expired() {
144
+ return this.timedOut || Date.now() >= this.expiresAt;
145
+ }
146
+ /**
147
+ * 注册中止动作(通道/流 destroy);返回注销函数。
148
+ * @param fn 中止动作
149
+ * @returns 注销函数
150
+ */
151
+ onAbort(fn) {
152
+ if (this.timedOut) {
153
+ try {
154
+ fn();
155
+ }
156
+ catch { /* 忽略 */ }
157
+ return () => undefined;
158
+ }
159
+ this.aborts.add(fn);
160
+ return () => { this.aborts.delete(fn); };
161
+ }
162
+ /**
163
+ * 中止本次传输(超时与外部通道错误共用):执行全部中止动作,并让进行中的 run() 立即拒绝(幂等)。
164
+ * @param error 中止原因;缺省为 timeout
165
+ */
166
+ abort(error) {
167
+ if (this.timedOut)
168
+ return;
169
+ this.timedOut = true;
170
+ this.abortError = error ?? this.timeoutError();
171
+ for (const fn of [...this.aborts]) {
172
+ try {
173
+ fn();
174
+ }
175
+ catch { /* 单个中止失败不影响其他 */ }
176
+ }
177
+ const reject = this.abortReject;
178
+ this.abortReject = null;
179
+ if (reject) {
180
+ try {
181
+ reject(this.abortError);
182
+ }
183
+ catch { /* 忽略 */ }
184
+ }
185
+ }
186
+ /** 标记超时并执行全部中止动作(幂等) */
187
+ markTimeout() {
188
+ this.abort(this.timeoutError());
189
+ }
190
+ /** 剩余毫秒(至少 1,供 setTimeout 使用) */
191
+ remaining() {
192
+ return Math.max(1, this.expiresAt - Date.now());
193
+ }
194
+ /**
195
+ * 关键点检查:已中止/超时则中止并抛对应错误(用于提交前,确保不再发起新的 rename)。
196
+ * @throws {TransferError} code=timeout 或外部中止原因
197
+ */
198
+ check() {
199
+ if (!this.expired)
200
+ return;
201
+ if (!this.timedOut)
202
+ this.markTimeout();
203
+ throw this.abortError ?? this.timeoutError();
204
+ }
205
+ /**
206
+ * 在剩余时间内执行一个异步操作;超时中止整条传输并抛 timeout;外部中止立即拒绝。
207
+ * @param work 待执行操作
208
+ * @returns 操作结果
209
+ */
210
+ async run(work) {
211
+ this.check();
212
+ return withTimeout(Promise.race([work, this.abortSignal()]), this.remaining(), () => this.markTimeout());
213
+ }
214
+ /** 惰性创建"已被中止"的拒绝 Promise(与 work 竞速,使等待中的回调立即失败) */
215
+ abortSignal() {
216
+ // check() 与本调用之间可能刚好发生中止:直接返回已拒绝的 Promise
217
+ if (this.timedOut)
218
+ return Promise.reject(this.abortError ?? this.timeoutError());
219
+ if (!this.abortPromise) {
220
+ this.abortPromise = new Promise((_resolve, reject) => { this.abortReject = reject; });
221
+ }
222
+ return this.abortPromise;
223
+ }
224
+ /** 构造 timeout 错误(带总超时说明) */
225
+ timeoutError() {
226
+ return new TransferError("timeout", undefined, `timed out after ${this.timeoutMs}ms`);
227
+ }
228
+ }
229
+ /** 远端 stat(跟随符号链接;用于父目录判定) */
230
+ function remoteStat(deadline, sftp, path) {
231
+ return deadline.run(statP((cb) => sftp.stat(path, cb)));
232
+ }
233
+ /** 远端 lstat(不跟随符号链接;用于目标类型判定) */
234
+ function remoteLstat(deadline, sftp, path) {
235
+ return deadline.run(statP((cb) => sftp.lstat(path, cb)));
236
+ }
237
+ /** 远端 lstat:不存在返回 null,其他错误上抛(timeout 不当作不存在) */
238
+ async function remoteLstatOrNull(deadline, sftp, path) {
239
+ try {
240
+ return await remoteLstat(deadline, sftp, path);
241
+ }
242
+ catch (e) {
243
+ if (e instanceof TransferError && e.code === "timeout")
244
+ throw e;
245
+ if (isNoSuchFile(e))
246
+ return null;
247
+ throw e;
248
+ }
249
+ }
250
+ /**
251
+ * 失败路径上的远端 lstat 探测:只受清理预算限制,不占用剩余总 deadline
252
+ * (通道异常/超时后回调可能永不返回,用剩余 10 分钟会把错误路径挂死)。
253
+ * @param sftp SFTP 实例
254
+ * @param path 远端路径
255
+ * @returns 属性;不存在/探测失败/超时均返回 null(调用方据此抛原始错误)
256
+ */
257
+ async function remoteLstatOrNullWithin(sftp, path) {
258
+ try {
259
+ return await withTimeout(statP((cb) => sftp.lstat(path, cb)), TRANSFER_CLEANUP_TIMEOUT_MS, () => undefined);
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ /**
266
+ * 把会话层中止信号接入传输 deadline:通道 error / 开通道超时时立即中止本次传输。
267
+ * @param signal 外部中止信号(可空)
268
+ * @param deadline 传输 deadline
269
+ * @returns 注销函数
270
+ */
271
+ function registerExternalAbort(signal, deadline) {
272
+ if (!signal)
273
+ return () => undefined;
274
+ const onAbort = () => {
275
+ deadline.abort(signal.reason instanceof TransferError ? signal.reason : new TransferError("timeout", undefined, "transfer aborted"));
276
+ };
277
+ if (signal.aborted) {
278
+ onAbort();
279
+ return () => undefined;
280
+ }
281
+ signal.addEventListener("abort", onAbort, { once: true });
282
+ return () => { signal.removeEventListener("abort", onAbort); };
283
+ }
284
+ /** 本地 lstat:不存在返回 null,其他错误上抛 */
285
+ async function localLstatOrNull(deadline, path) {
286
+ try {
287
+ return await deadline.run(lstatLocal(path));
288
+ }
289
+ catch (e) {
290
+ if (e instanceof TransferError && e.code === "timeout")
291
+ throw e;
292
+ if (isNoSuchFile(e))
293
+ return null;
294
+ throw e;
295
+ }
296
+ }
297
+ /**
298
+ * 本地独占创建(flags "wx"):成功即代表文件由本次创建,失败可准确区分 exists。
299
+ * 用 FileHandle 而不是 createWriteStream 的懒 open,避免"失败发生在 open 事件之前"时
300
+ * 无法判断本地部分文件是否由本次创建(清理可靠性)。
301
+ * @param deadline 传输 deadline
302
+ * @param path 本地目标
303
+ * @returns 已打开的 FileHandle
304
+ */
305
+ async function openLocalExclusive(deadline, path) {
306
+ try {
307
+ return await deadline.run(openLocal(path, "wx"));
308
+ }
309
+ catch (e) {
310
+ if (e instanceof TransferError)
311
+ throw e;
312
+ if (isAlreadyExists(e))
313
+ throw new TransferError("exists", path);
314
+ throw e;
315
+ }
316
+ }
317
+ /**
318
+ * 校验为普通文件:符号链接优先识别(lstat 才看得到),目录单独报,其余类型统一 unsupported。
319
+ * @param stats 文件属性
320
+ * @param kind 端(本地/远端,决定错误码)
321
+ * @param path 路径(错误回显)
322
+ */
323
+ function assertRegularFile(stats, kind, path) {
324
+ if (stats.isSymbolicLink())
325
+ throw new TransferError("symlink", path);
326
+ if (stats.isDirectory())
327
+ throw new TransferError(kind === "local" ? "local_is_dir" : "remote_is_dir", path);
328
+ if (!stats.isFile())
329
+ throw new TransferError("unsupported", path);
330
+ }
331
+ /**
332
+ * 远端路径策略校验:Windows 反斜杠盘符路径(C:\a\b)明确拒绝;
333
+ * 正斜杠盘符路径(C:/a/b)按 POSIX 语义处理(parent = C:/a)。
334
+ * @param input 用户传入的远端路径
335
+ * @returns 原路径(便于链式调用)
336
+ */
337
+ function assertRemotePathStyle(input) {
338
+ if (/^[A-Za-z]:\\/.test(input))
339
+ throw new TransferError("remote_path_backslash", input);
340
+ return input;
341
+ }
342
+ /**
343
+ * 展开远端路径的 ~(不经过 shell):
344
+ * - "~" / "~/..." 用 SFTP realpath(".") 得到登录 home 再拼接;
345
+ * - "~user/..." 明确不支持(依赖服务端扩展,v1 不猜);
346
+ * - 绝对/相对路径原样返回(相对路径由 SFTP 服务端按 home 解析)。
347
+ * @param deadline 传输 deadline
348
+ * @param sftp SFTP 实例
349
+ * @param input 用户传入的远端路径
350
+ * @returns 可交给 SFTP 的路径
351
+ */
352
+ async function expandRemoteHome(deadline, sftp, input) {
353
+ assertRemotePathStyle(input);
354
+ if (input === "~")
355
+ return deadline.run(realpathP(sftp, "."));
356
+ if (input.startsWith("~/")) {
357
+ const home = await deadline.run(realpathP(sftp, "."));
358
+ return posix.join(home, input.slice(2));
359
+ }
360
+ if (input.startsWith("~")) {
361
+ throw new TransferError("tilde_user", input, "~user paths are not supported; use ~/ or an absolute path");
362
+ }
363
+ return input;
364
+ }
365
+ /** 远端 realpath(SFTP 回调包装,不单独计超时,由调用方 deadline.run) */
366
+ function realpathP(sftp, path) {
367
+ return new Promise((resolve, reject) => {
368
+ sftp.realpath(path, (err, absPath) => (err ? reject(err) : resolve(absPath)));
369
+ });
370
+ }
371
+ /**
372
+ * 判断远端目录是否视为"已存在的根"(无需 stat/mkdir):
373
+ * 空、"."、"/"、Windows 盘符根(C: / C:/)。
374
+ * @param dir 目录路径
375
+ * @returns true 表示根
376
+ */
377
+ function isRemoteRoot(dir) {
378
+ return dir === "" || dir === "." || dir === "/" || /^[A-Za-z]:[\\/]?$/.test(dir);
379
+ }
380
+ /**
381
+ * 远端 dirname:兼容 Windows 盘符根(C:/a.txt 的父目录是 C:/,不是 C:)。
382
+ * @param target 远端目标路径
383
+ * @returns 父目录
384
+ */
385
+ function remoteDirname(target) {
386
+ if (/^[A-Za-z]:\/[^/]*$/.test(target))
387
+ return target.slice(0, 3);
388
+ return posix.dirname(target);
389
+ }
390
+ /**
391
+ * 上传/下载临时文件路径:与目标同目录(保证 rename 不跨文件系统),文件名带随机串避免并发冲突。
392
+ * @param target 目标路径
393
+ * @param ext 扩展标识(part / bak)
394
+ * @returns 临时文件路径
395
+ */
396
+ function tempRemotePath(target, ext = "part") {
397
+ const dir = remoteDirname(target);
398
+ const name = `.${posix.basename(target)}.opencode-ssh-tool-${randomUUID().slice(0, 8)}.${ext}`;
399
+ if (dir === "." || dir === "")
400
+ return name;
401
+ return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
402
+ }
403
+ /**
404
+ * 本地临时文件路径(与本地目标同目录)。
405
+ * @param target 本地目标
406
+ * @param ext 扩展标识(part / bak)
407
+ * @returns 临时文件本地路径
408
+ */
409
+ function tempLocalPath(target, ext = "part") {
410
+ const dir = dirname(target);
411
+ const name = `.${basename(target)}.opencode-ssh-tool-${randomUUID().slice(0, 8)}.${ext}`;
412
+ return dir === "" ? name : join(dir, name);
413
+ }
414
+ /**
415
+ * 递归创建远端目录(mkdir -p 语义;已存在且是目录则跳过;盘符根视为存在)。
416
+ * @param deadline 传输 deadline
417
+ * @param sftp SFTP 实例
418
+ * @param dir 目标目录
419
+ */
420
+ async function ensureRemoteDir(deadline, sftp, dir) {
421
+ if (isRemoteRoot(dir))
422
+ return;
423
+ try {
424
+ const st = await remoteStat(deadline, sftp, dir);
425
+ if (st.isDirectory())
426
+ return;
427
+ throw new TransferError("parent_missing", dir, "path exists but is not a directory");
428
+ }
429
+ catch (e) {
430
+ if (e instanceof TransferError || !isNoSuchFile(e))
431
+ throw e;
432
+ await ensureRemoteDir(deadline, sftp, posix.dirname(dir));
433
+ try {
434
+ await deadline.run(voidP((cb) => sftp.mkdir(dir, cb)));
435
+ }
436
+ catch (mkdirErr) {
437
+ // 并发/重试下可能已被别人创建:再 stat 确认是目录即可
438
+ const st = await remoteStat(deadline, sftp, dir).catch(() => null);
439
+ if (!st || !st.isDirectory())
440
+ throw mkdirErr;
441
+ }
442
+ }
443
+ }
444
+ /**
445
+ * 上传前准备远端父目录:mkdir=true 递归创建;否则要求父目录已存在且是目录。
446
+ * @param deadline 传输 deadline
447
+ * @param sftp SFTP 实例
448
+ * @param parent 父目录路径(POSIX 语义)
449
+ * @param mkdir 是否创建
450
+ */
451
+ async function prepareRemoteParent(deadline, sftp, parent, mkdir) {
452
+ if (isRemoteRoot(parent))
453
+ return;
454
+ if (mkdir) {
455
+ await ensureRemoteDir(deadline, sftp, parent);
456
+ return;
457
+ }
458
+ try {
459
+ const st = await remoteStat(deadline, sftp, parent);
460
+ if (!st.isDirectory())
461
+ throw new TransferError("parent_missing", parent, "path exists but is not a directory");
462
+ }
463
+ catch (e) {
464
+ if (e instanceof TransferError)
465
+ throw e;
466
+ if (isNoSuchFile(e))
467
+ throw new TransferError("parent_missing", parent);
468
+ throw e;
469
+ }
470
+ }
471
+ /**
472
+ * 下载前准备本地父目录:mkdir=true 递归创建;否则要求父目录已存在且是目录。
473
+ * @param deadline 传输 deadline
474
+ * @param parent 本地父目录
475
+ * @param mkdir 是否创建
476
+ */
477
+ async function prepareLocalParent(deadline, parent, mkdir) {
478
+ if (!parent || parent === ".")
479
+ return;
480
+ if (mkdir) {
481
+ try {
482
+ await deadline.run(mkdirLocal(parent, { recursive: true }));
483
+ }
484
+ catch (e) {
485
+ if (e instanceof TransferError)
486
+ throw e;
487
+ throw new TransferError("io", parent, e instanceof Error ? e.message : String(e));
488
+ }
489
+ return;
490
+ }
491
+ try {
492
+ const st = await deadline.run(statLocal(parent));
493
+ if (!st.isDirectory())
494
+ throw new TransferError("parent_missing", parent, "path exists but is not a directory");
495
+ }
496
+ catch (e) {
497
+ if (e instanceof TransferError)
498
+ throw e;
499
+ if (isNoSuchFile(e))
500
+ throw new TransferError("parent_missing", parent);
501
+ throw new TransferError("io", parent, e instanceof Error ? e.message : String(e));
502
+ }
503
+ }
504
+ /** 尽力删除远端文件(清理临时文件用,不抛错;限时等待,避免死通道把错误路径挂死) */
505
+ async function bestEffortUnlinkRemote(sftp, path) {
506
+ try {
507
+ await withTimeout(voidP((cb) => sftp.unlink(path, cb)), TRANSFER_CLEANUP_TIMEOUT_MS, () => undefined);
508
+ }
509
+ catch {
510
+ /* 清理失败不覆盖原始错误 */
511
+ }
512
+ }
513
+ /** 尽力删除本地文件(不抛错) */
514
+ async function bestEffortUnlinkLocal(path) {
515
+ try {
516
+ await unlinkLocal(path);
517
+ }
518
+ catch {
519
+ /* 清理失败不覆盖原始错误 */
520
+ }
521
+ }
522
+ /**
523
+ * 安全触发进度回调:回调异常不得中断传输。
524
+ * @param cb 进度回调(可空)
525
+ * @param bytes 已传字节数
526
+ * @param total 总字节数
527
+ */
528
+ function reportProgress(cb, bytes, total) {
529
+ if (!cb)
530
+ return;
531
+ try {
532
+ cb(bytes, total);
533
+ }
534
+ catch {
535
+ /* 进度回调只用于展示,任何异常都不得影响传输 */
536
+ }
537
+ }
538
+ /**
539
+ * 流式读写并计数:source → sink,超过 maxBytes 立即中止;任一错误销毁两端。
540
+ * @param source 数据源(本地读流 / 远端读流)
541
+ * @param sink 目标(远端写流 / 本地写流)
542
+ * @param maxBytes 大小上限(运行时强制)
543
+ * @param path 超限错误回显路径
544
+ * @param totalSize 传输前 stat 得到的总字节数(进度回调用)
545
+ * @param onProgress 每累计一块后的进度回调(可空;异常被吞掉)
546
+ * @param onOpen sink 打开成功回调(用于判断独占创建是否真的创建了文件)
547
+ * @returns promise(实际字节数)+ abort(销毁两端)
548
+ */
549
+ function pipeWithLimit(source, sink, maxBytes, path, totalSize, onProgress, onOpen) {
550
+ let bytes = 0;
551
+ let settled = false;
552
+ const destroy = () => {
553
+ try {
554
+ source.destroy();
555
+ }
556
+ catch { /* 忽略 */ }
557
+ try {
558
+ sink.destroy();
559
+ }
560
+ catch { /* 忽略 */ }
561
+ };
562
+ const promise = new Promise((resolve, reject) => {
563
+ const fail = (err) => {
564
+ if (settled)
565
+ return;
566
+ settled = true;
567
+ destroy();
568
+ reject(err instanceof TransferError ? err : new TransferError("io", path, err instanceof Error ? err.message : String(err)));
569
+ };
570
+ sink.once("open", onOpen);
571
+ source.on("data", (chunk) => {
572
+ if (settled)
573
+ return;
574
+ bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
575
+ reportProgress(onProgress, bytes, totalSize);
576
+ if (bytes > maxBytes)
577
+ fail(new TransferError("too_large", path, `${bytes} bytes > ${maxBytes} bytes`));
578
+ });
579
+ source.on("error", fail);
580
+ sink.on("error", fail);
581
+ // ssh2 的 SFTP WriteStream 成功结束时只 emit("close"),不 emit("finish");
582
+ // 本地 fs.WriteStream 通常两者都有。以先到者结算,失败已由 error 分支处理。
583
+ const done = () => {
584
+ if (settled)
585
+ return;
586
+ // 短读/短写兜底:提前 close 或源提前结束时 bytes 可能少于 stat 得到的总大小。
587
+ // 此时绝不能按 done 结算(会把半截文件当成功),统一报 io 让上层清理。
588
+ if (totalSize > 0 && bytes !== totalSize) {
589
+ fail(new TransferError("io", path, `incomplete transfer: ${bytes} of ${totalSize} bytes`));
590
+ return;
591
+ }
592
+ settled = true;
593
+ resolve(bytes);
594
+ };
595
+ sink.once("finish", done);
596
+ sink.once("close", done);
597
+ source.pipe(sink);
598
+ });
599
+ return { promise, abort: destroy };
600
+ }
601
+ /**
602
+ * 上传流:本地文件 → 远端路径(flags "wx" 独占创建 / "w" 写临时文件)。
603
+ * @param deadline 传输 deadline
604
+ * @param sftp SFTP 实例
605
+ * @param localPath 本地文件
606
+ * @param remotePath 远端目标或临时文件
607
+ * @param flags 远端打开标志("wx" / "w")
608
+ * @param maxBytes 大小上限
609
+ * @param state 打开状态(EXCL 时 opened=true 代表目标由本次创建;失败也能读到)
610
+ * @param totalSize 总字节数(进度回调用)
611
+ * @param onProgress 进度回调(可空)
612
+ * @returns 实际字节数
613
+ */
614
+ async function uploadStream(deadline, sftp, localPath, remotePath, flags, maxBytes, state, totalSize, onProgress) {
615
+ deadline.check();
616
+ const source = createLocalReadStream(localPath);
617
+ const sink = sftp.createWriteStream(remotePath, { flags, mode: 0o666 });
618
+ const pipe = pipeWithLimit(source, sink, maxBytes, localPath, totalSize, onProgress, () => { state.opened = true; });
619
+ const unregister = deadline.onAbort(pipe.abort);
620
+ try {
621
+ return await deadline.run(pipe.promise);
622
+ }
623
+ finally {
624
+ unregister();
625
+ }
626
+ }
627
+ /**
628
+ * 下载流:远端路径 → 本地文件(flags "wx" 独占创建 / "w" 写临时文件)。
629
+ * @param deadline 传输 deadline
630
+ * @param sftp SFTP 实例
631
+ * @param remotePath 远端文件
632
+ * @param localPath 本地目标或临时文件
633
+ * @param flags 本地打开标志("wx" / "w")
634
+ * @param maxBytes 大小上限
635
+ * @param state 打开状态(EXCL 时 opened=true 代表本地目标由本次创建;失败也能读到)
636
+ * @param totalSize 总字节数(进度回调用)
637
+ * @param onProgress 进度回调(可空)
638
+ * @returns 实际字节数
639
+ */
640
+ async function downloadStream(deadline, sftp, remotePath, localPath, flags, maxBytes, state, totalSize, onProgress, handle) {
641
+ deadline.check();
642
+ const source = sftp.createReadStream(remotePath);
643
+ const sink = handle
644
+ ? handle.createWriteStream({ autoClose: true })
645
+ : createLocalWriteStream(localPath, { flags, mode: 0o666 });
646
+ const pipe = pipeWithLimit(source, sink, maxBytes, remotePath, totalSize, onProgress, () => { state.opened = true; });
647
+ const unregister = deadline.onAbort(pipe.abort);
648
+ try {
649
+ return await deadline.run(pipe.promise);
650
+ }
651
+ finally {
652
+ unregister();
653
+ }
654
+ }
655
+ /**
656
+ * 远端尽力回滚:把备份移回目标(覆盖提交失败后恢复旧目标)。
657
+ * 不受传输 deadline 限制(deadline 已过也要尝试),但受清理预算限制,通道不可用时快速放弃。
658
+ * @param sftp SFTP 实例
659
+ * @param backup 备份路径
660
+ * @param target 目标路径
661
+ * @param backupConfirmed 备份步骤是否已确认成功;为 true 时备份不存在也算回滚失败
662
+ * @returns true 表示已恢复(或备份本来就不存在且未确认成功);false 表示回滚失败
663
+ */
664
+ async function rollbackRemote(sftp, backup, target, backupConfirmed) {
665
+ try {
666
+ await withTimeout(voidP((cb) => sftp.rename(backup, target, cb)), TRANSFER_CLEANUP_TIMEOUT_MS, () => undefined);
667
+ return true;
668
+ }
669
+ catch (e) {
670
+ // 未确认备份成功时,NO_SUCH_FILE 说明旧目标从未被移走,视为无需回滚;
671
+ // 已确认备份成功却找不到备份,说明回滚失败,不能让调用方误以为原路径已恢复。
672
+ return !backupConfirmed && isNoSuchFile(e);
673
+ }
674
+ }
675
+ /**
676
+ * 本地尽力回滚:把备份移回目标。直接做本地 rename,不受传输 deadline 限制。
677
+ * @param backup 备份路径
678
+ * @param target 目标路径
679
+ * @param backupConfirmed 备份步骤是否已确认成功;为 true 时备份不存在也算回滚失败
680
+ * @returns true 表示已恢复(或备份本来就不存在且未确认成功);false 表示回滚失败
681
+ */
682
+ async function rollbackLocal(backup, target, backupConfirmed) {
683
+ try {
684
+ await withTimeout(renameLocal(backup, target), TRANSFER_CLEANUP_TIMEOUT_MS, () => undefined);
685
+ return true;
686
+ }
687
+ catch (e) {
688
+ return !backupConfirmed && isNoSuchFile(e);
689
+ }
690
+ }
691
+ /**
692
+ * 覆盖提交(远端):临时文件 rename 到目标。
693
+ * - 首次 rename 成功(同目录 rename,同一文件系统内原子)即完成;
694
+ * - 服务端不允许覆盖已存在目标时:确认目标存在 → 先改名目标为备份 → 再改名临时文件;
695
+ * - 已备份但新文件未就位的所有分支都尽力回滚旧目标(不依赖剩余 deadline,只用清理预算);
696
+ * 回滚失败时抛出明确的 io 错误并在 detail 中给出备份路径,避免静默留下空的原始路径。
697
+ * @param deadline 传输 deadline
698
+ * @param sftp SFTP 实例
699
+ * @param temp 临时文件
700
+ * @param target 目标文件
701
+ */
702
+ async function commitRemoteOverwrite(deadline, sftp, temp, target) {
703
+ deadline.check();
704
+ try {
705
+ await deadline.run(voidP((cb) => sftp.rename(temp, target, cb)));
706
+ return;
707
+ }
708
+ catch (e) {
709
+ if (deadline.expired)
710
+ throw e;
711
+ // 失败路径探测只给清理预算,避免通道异常时挂满剩余总超时
712
+ const existing = await remoteLstatOrNullWithin(sftp, target);
713
+ if (!existing)
714
+ throw e;
715
+ // 回退需要"移走旧目标 → 新文件就位"两步;剩余不足时不开始,避免被超时截断
716
+ if (deadline.remaining() < TRANSFER_COMMIT_MIN_REMAINING_MS)
717
+ throw e;
718
+ const backup = tempRemotePath(target, "bak");
719
+ try {
720
+ await deadline.run(voidP((cb) => sftp.rename(target, backup, cb)));
721
+ }
722
+ catch (eBackup) {
723
+ // 旧目标可能已被移走:尽力回滚(通道仍可用时用清理预算完成)
724
+ const restored = await rollbackRemote(sftp, backup, target, false);
725
+ if (restored)
726
+ throw eBackup;
727
+ throw new TransferError("io", target, `backup step failed and rollback failed; previous file may be at ${backup}`);
728
+ }
729
+ // 已备份:后续任何分支都不能让原路径空着——失败一律先尝试回滚
730
+ let commitError = null;
731
+ if (deadline.expired) {
732
+ commitError = new TransferError("timeout", undefined, "timed out before commit");
733
+ }
734
+ else {
735
+ try {
736
+ await deadline.run(voidP((cb) => sftp.rename(temp, target, cb)));
737
+ }
738
+ catch (e2) {
739
+ commitError = e2;
740
+ }
741
+ }
742
+ if (commitError) {
743
+ const restored = await rollbackRemote(sftp, backup, target, true);
744
+ if (restored)
745
+ throw commitError;
746
+ throw new TransferError("io", target, `commit failed and rollback failed; previous file kept at ${backup}`);
747
+ }
748
+ await bestEffortUnlinkRemote(sftp, backup);
749
+ }
750
+ }
751
+ /**
752
+ * 覆盖提交(本地):临时文件 rename 到目标。
753
+ * 同目录 rename 通常可直接覆盖;失败且目标原本存在时(权限/占用/平台差异),
754
+ * 先把旧目标改名为备份 → 新文件就位 → 删备份;失败一律尽力回滚(本地 rename 不受传输 deadline 限制)。
755
+ * @param deadline 传输 deadline
756
+ * @param temp 临时文件
757
+ * @param target 目标文件
758
+ * @param targetExists 传输前目标是否已存在
759
+ */
760
+ async function commitLocalOverwrite(deadline, temp, target, targetExists) {
761
+ deadline.check();
762
+ try {
763
+ await deadline.run(renameLocal(temp, target));
764
+ return;
765
+ }
766
+ catch (e) {
767
+ if (!targetExists || deadline.expired)
768
+ throw e;
769
+ if (deadline.remaining() < TRANSFER_COMMIT_MIN_REMAINING_MS)
770
+ throw e;
771
+ const backup = tempLocalPath(target, "bak");
772
+ try {
773
+ await deadline.run(renameLocal(target, backup));
774
+ }
775
+ catch (eBackup) {
776
+ const restored = await rollbackLocal(backup, target, false);
777
+ if (restored)
778
+ throw eBackup;
779
+ throw new TransferError("io", target, `backup step failed and rollback failed; previous file may be at ${backup}`);
780
+ }
781
+ let commitError = null;
782
+ if (deadline.expired) {
783
+ commitError = new TransferError("timeout", undefined, "timed out before commit");
784
+ }
785
+ else {
786
+ try {
787
+ await deadline.run(renameLocal(temp, target));
788
+ }
789
+ catch (e2) {
790
+ commitError = e2;
791
+ }
792
+ }
793
+ if (commitError) {
794
+ const restored = await rollbackLocal(backup, target, true);
795
+ if (restored)
796
+ throw commitError;
797
+ throw new TransferError("io", target, `commit failed and rollback failed; previous file kept at ${backup}`);
798
+ }
799
+ await bestEffortUnlinkLocal(backup);
800
+ }
801
+ }
802
+ /**
803
+ * 把未知错误归一为 TransferError(已是 TransferError 原样返回)。
804
+ * @param err 原始错误
805
+ * @param path 回显路径
806
+ * @returns TransferError
807
+ */
808
+ function normalizeError(err, path) {
809
+ if (err instanceof TransferError)
810
+ return err;
811
+ return new TransferError("io", path, err instanceof Error ? err.message : String(err));
812
+ }
813
+ /**
814
+ * 上传:插件进程本机普通文件 → SSH 远端(SFTP)。
815
+ * 默认独占创建目标(flags "wx");overwrite 时写同目录临时文件再 rename 提交。
816
+ * 任一步失败清理临时/已创建的部分文件;超时中止通道且不主动发起新的 rename/commit(已发出的 rename 可能仍被执行)。
817
+ * @param opts 上传参数
818
+ * @returns 传输结果(路径/实际字节/耗时,不含内容)
819
+ */
820
+ export async function uploadFile(opts) {
821
+ const started = Date.now();
822
+ const localPath = expandLocalHome(opts.localPath);
823
+ const remoteInput = opts.remotePath;
824
+ const maxBytes = opts.maxBytes ?? MAX_TRANSFER_BYTES;
825
+ const deadline = new TransferDeadline(opts.timeoutMs ?? TRANSFER_TIMEOUT_MS);
826
+ const unregisterAbort = deadline.onAbort(() => {
827
+ try {
828
+ opts.sftp.destroy();
829
+ }
830
+ catch { /* 忽略 */ }
831
+ });
832
+ const unregisterExternal = registerExternalAbort(opts.abortSignal, deadline);
833
+ let remoteTarget = "";
834
+ let temp = null;
835
+ let directAttempted = false;
836
+ const directState = { opened: false };
837
+ try {
838
+ deadline.check();
839
+ const localStat = await localLstatOrNull(deadline, localPath);
840
+ if (!localStat)
841
+ throw new TransferError("local_missing", localPath);
842
+ assertRegularFile(localStat, "local", localPath);
843
+ if (localStat.size > maxBytes) {
844
+ throw new TransferError("too_large", localPath, `${localStat.size} bytes > ${maxBytes} bytes`);
845
+ }
846
+ // 拿到本地大小后先回调 (0,size):空文件也有一次进度回调,Web 端可立即显示总大小
847
+ reportProgress(opts.onProgress, 0, localStat.size);
848
+ remoteTarget = await expandRemoteHome(deadline, opts.sftp, remoteInput);
849
+ const existing = await remoteLstatOrNull(deadline, opts.sftp, remoteTarget);
850
+ if (existing) {
851
+ assertRegularFile(existing, "remote", remoteTarget);
852
+ if (!opts.overwrite)
853
+ throw new TransferError("exists", remoteTarget);
854
+ }
855
+ await prepareRemoteParent(deadline, opts.sftp, posix.dirname(remoteTarget), opts.mkdir === true);
856
+ deadline.check();
857
+ let bytes;
858
+ if (opts.overwrite === true) {
859
+ temp = tempRemotePath(remoteTarget, "part");
860
+ bytes = await uploadStream(deadline, opts.sftp, localPath, temp, "w", maxBytes, { opened: false }, localStat.size, opts.onProgress);
861
+ deadline.check();
862
+ await commitRemoteOverwrite(deadline, opts.sftp, temp, remoteTarget);
863
+ temp = null; // 已 rename 提交,无需再清理
864
+ }
865
+ else {
866
+ directAttempted = true;
867
+ bytes = await uploadStream(deadline, opts.sftp, localPath, remoteTarget, "wx", maxBytes, directState, localStat.size, opts.onProgress);
868
+ }
869
+ return { localPath, remotePath: remoteTarget, bytes, duration: Date.now() - started };
870
+ }
871
+ catch (e) {
872
+ // 失败清理/探测统一用清理预算(TRANSFER_CLEANUP_TIMEOUT_MS),不占用剩余总 deadline:
873
+ // 通道异常/超时后远端回调可能永不返回,否则错误路径会被挂死。
874
+ if (temp)
875
+ await bestEffortUnlinkRemote(opts.sftp, temp);
876
+ else if (directState.opened)
877
+ await bestEffortUnlinkRemote(opts.sftp, remoteTarget);
878
+ else if (directAttempted && !opts.overwrite) {
879
+ if (deadline.expired) {
880
+ // 超时中止通道前可能已在目标名下创建半截文件:尽力清理;
881
+ // 通道已不可用时 3s 内放弃(文档已说明目标路径可能残留,重试会报 exists)
882
+ await bestEffortUnlinkRemote(opts.sftp, remoteTarget);
883
+ }
884
+ else {
885
+ // EXCL 打开失败:若目标此刻已存在(预检后被抢建/竞态)→ exists(原内容未被改动)
886
+ const now = await remoteLstatOrNullWithin(opts.sftp, remoteTarget);
887
+ if (now)
888
+ throw new TransferError("exists", remoteTarget);
889
+ }
890
+ }
891
+ throw normalizeError(e, remoteInput);
892
+ }
893
+ finally {
894
+ unregisterExternal();
895
+ unregisterAbort();
896
+ }
897
+ }
898
+ /**
899
+ * 下载:SSH 远端普通文件(SFTP)→ 插件进程本机。
900
+ * 默认本地独占创建(fs flags "wx");overwrite 时写同目录临时文件再 rename 提交。
901
+ * 任一步失败清理临时/已创建的部分文件;超时中止通道且不主动发起新的 rename/commit(已发出的 rename 可能仍被执行)。
902
+ * @param opts 下载参数
903
+ * @returns 传输结果(路径/实际字节/耗时,不含内容)
904
+ */
905
+ export async function downloadFile(opts) {
906
+ const started = Date.now();
907
+ const localPath = expandLocalHome(opts.localPath);
908
+ const remoteInput = opts.remotePath;
909
+ const maxBytes = opts.maxBytes ?? MAX_TRANSFER_BYTES;
910
+ const deadline = new TransferDeadline(opts.timeoutMs ?? TRANSFER_TIMEOUT_MS);
911
+ const unregisterAbort = deadline.onAbort(() => {
912
+ try {
913
+ opts.sftp.destroy();
914
+ }
915
+ catch { /* 忽略 */ }
916
+ });
917
+ const unregisterExternal = registerExternalAbort(opts.abortSignal, deadline);
918
+ let remotePath = "";
919
+ let temp = null;
920
+ let directCreated = false;
921
+ const directState = { opened: false };
922
+ try {
923
+ deadline.check();
924
+ remotePath = await expandRemoteHome(deadline, opts.sftp, remoteInput);
925
+ const remoteInfo = await remoteLstatOrNull(deadline, opts.sftp, remotePath);
926
+ if (!remoteInfo)
927
+ throw new TransferError("remote_missing", remotePath);
928
+ assertRegularFile(remoteInfo, "remote", remotePath);
929
+ if (remoteInfo.size > maxBytes) {
930
+ throw new TransferError("too_large", remotePath, `${remoteInfo.size} bytes > ${maxBytes} bytes`);
931
+ }
932
+ // 拿到远端大小后先回调 (0,size):空文件也有一次进度回调
933
+ reportProgress(opts.onProgress, 0, remoteInfo.size);
934
+ const localStat = await localLstatOrNull(deadline, localPath);
935
+ if (localStat) {
936
+ assertRegularFile(localStat, "local", localPath);
937
+ if (!opts.overwrite)
938
+ throw new TransferError("exists", localPath);
939
+ }
940
+ await prepareLocalParent(deadline, dirname(localPath), opts.mkdir === true);
941
+ deadline.check();
942
+ let bytes;
943
+ if (opts.overwrite === true) {
944
+ temp = tempLocalPath(localPath, "part");
945
+ bytes = await downloadStream(deadline, opts.sftp, remotePath, temp, "w", maxBytes, { opened: false }, remoteInfo.size, opts.onProgress);
946
+ deadline.check();
947
+ await commitLocalOverwrite(deadline, temp, localPath, localStat !== null);
948
+ temp = null; // 已 rename 提交,无需再清理
949
+ }
950
+ else {
951
+ const handle = await openLocalExclusive(deadline, localPath);
952
+ directCreated = true;
953
+ try {
954
+ bytes = await downloadStream(deadline, opts.sftp, remotePath, localPath, "wx", maxBytes, directState, remoteInfo.size, opts.onProgress, handle);
955
+ }
956
+ catch (e) {
957
+ // downloadStream 在 deadline.check 失败时尚未接管 handle,这里兜底关闭
958
+ await handle.close().catch(() => undefined);
959
+ throw e;
960
+ }
961
+ }
962
+ return { localPath, remotePath, bytes, duration: Date.now() - started };
963
+ }
964
+ catch (e) {
965
+ if (temp)
966
+ await bestEffortUnlinkLocal(temp);
967
+ else if (directCreated)
968
+ await bestEffortUnlinkLocal(localPath);
969
+ throw normalizeError(e, remoteInput);
970
+ }
971
+ finally {
972
+ unregisterExternal();
973
+ unregisterAbort();
974
+ }
975
+ }
976
+ /**
977
+ * 校验 upload/download 的位置参数:必须恰好两个非空路径(多给/少给都报用法)。
978
+ * @param positionals 位置参数
979
+ * @returns 两个路径;不合法时 null
980
+ */
981
+ export function parseTransferPositionals(positionals) {
982
+ if (positionals.length !== 2)
983
+ return null;
984
+ const [first, second] = positionals;
985
+ if (!first || !second)
986
+ return null;
987
+ return { first, second };
988
+ }
989
+ /**
990
+ * 断言会话具备文件传输能力(仅 SSH 会话):LocalSession 等明确拒绝。
991
+ * 用结构判定而非 instanceof,避免 transfer.ts 反向依赖 session.ts(循环导入)。
992
+ * @param session 终端会话
993
+ * @throws {TransferError} code=ssh_only
994
+ */
995
+ export function assertTransferCapable(session) {
996
+ const s = session;
997
+ if (!s || typeof s.uploadFile !== "function" || typeof s.downloadFile !== "function") {
998
+ throw new TransferError("ssh_only", undefined, "file transfer requires an SSH session");
999
+ }
1000
+ }
1001
+ //# sourceMappingURL=transfer.js.map