@zhangfengshun/dsh-remote-ssh 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +11 -1
- package/README_EN.md +11 -1
- package/lib/index.js +349 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
|
|
4
4
|
|
|
5
|
+
## [2.3.0] — 远程文件打开提速:单往返合并读 + 结果缓存(实测 ≈1.3×~5×)
|
|
6
|
+
### 性能
|
|
7
|
+
- **fs.read 单往返合并读**:size/mtime 与文件内容合并为一条池化命令(`__DSH_ST__` 帧,`_` 不在 base64 字母表故标记天然抗噪),去掉每文件一次的 stat 往返。真实 HPC 实测(100KB 文件,各 10 次 p50):raw 直传 396.6→301.8ms、base64 443.8→337.9ms,约 **1.31×**;1MB 大文件同步受益。>4MB 仍 `head -c 4MiB` 截断,二进制探测与解码语义不变。
|
|
8
|
+
- **raw 文本快路径**:文本扩展名白名单(.py/.js/.md/… 等)免 base64 直传(传输段再省 ≈24-27%);字节长度 + U+FFFD 双校验,不符自动回退 base64 重读——结果与旧实现逐字节一致,仅多 1 RTT。
|
|
9
|
+
- **主机侧结果缓存**:读结果与目录列举各 LRU(32/64 条)+ TTL 5s。TTL 内重复打开/切回页签 **0 RTT**(实测 ≈0.02ms);过期后 1 次轻量 mtime+size 复验,未变免重传(实测 84.9ms,约 **5×**);目录列举 TTL 内 0ms。写/删/移/建目录/上传/推送/远端 exec/git 自动失效,并引入每 profile 缓存代(epoch)兜底「同秒同 size 写」与 `~/x` vs `/home/u/x` 路径字符串空间差。
|
|
10
|
+
- **目录列举 `cd` 包进子 shell**:修复列举后 `cd` 残留污染共享会话 cwd 的既有问题(旧实现会使会话内后续相对路径命令落空)。
|
|
11
|
+
### 修复
|
|
12
|
+
- **写路径字节精确**:`remote_ssh_write` / 文件页签保存改走 `base64 -d` 管道写入,修复旧 `cat > file` heredoc 帧每次保存尾随多 1 字节的问题(实测 3/3/4B,旧实现 4/4/5B),且对 NUL/二进制内容安全。
|
|
13
|
+
### 已知限制
|
|
14
|
+
- 集成终端(`ssh -tt`)与远端外部进程改动的文件,插件缓存最多 **5s** 陈旧;外部进程「同秒内同 size 写」且此后不再变化时,mtime 复验无法察觉(极窄窗口;插件自身写/exec/git 路径经失效与 epoch 兜底,不受此限)。
|
|
15
|
+
- `/sidebar/file` 下载池化路径有效上限 ≈**6.29MB**(会话 8MB 输出熔断 ÷ base64 4/3 膨胀);更大文件自动退回一次性连接下载(可成功,但多付一次废传输与重连),2.4.0 候选修复。
|
|
16
|
+
- 二进制内容伪装文本扩展名时,raw 校验拒绝后自动回退 base64 重读(多 1 RTT,结果正确)。
|
|
17
|
+
|
|
5
18
|
## [2.2.1] — 去掉设置导航「远程连接」左侧的齿轮图标
|
|
6
19
|
### 修复
|
|
7
20
|
- DSH 壳层对未知 `settings.section` 渲染默认齿轮图标,且导航类名是构建期哈希(每版都会变),旧版硬编码的 CSS 选择器(`VOzbGW_*`)已随 DSH 更新失效。改为按栏目标题文本定位设置导航按钮并隐藏其图标 svg(MutationObserver + 语言切换自动重跑),跨 DSH 构建版本稳定。
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
## 安装
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.
|
|
20
|
+
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
> 安装后需**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
|
|
@@ -54,6 +54,16 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.7
|
|
|
54
54
|
|
|
55
55
|
Shell wrapper(`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`)检测工作区 `.remote-ssh.json`,自动 `ssh -tt` 连接远程,使内置「终端」页签透明接入。
|
|
56
56
|
|
|
57
|
+
## 缓存与一致性
|
|
58
|
+
|
|
59
|
+
远程读取与目录列举结果在主机侧缓存(LRU 32/64 条,TTL 5 秒):TTL 内重复打开或切回页签 0 网络往返;过期后先做一次轻量 mtime+size 复验,未变化则免重传。写、删除、移动、建目录、上传、推送(push)、远端 exec 与 git 面板操作会自动失效相关缓存。
|
|
60
|
+
|
|
61
|
+
已知限制:
|
|
62
|
+
|
|
63
|
+
- 集成终端(`ssh -tt`)与远端其它进程改动的文件依赖 TTL + 复验兜底,最多 **5 秒**陈旧;
|
|
64
|
+
- `/sidebar/file` 下载池化路径有效上限约 **6.29MB**,更大文件自动退回一次性连接下载(可成功,多一次重连开销);
|
|
65
|
+
- 二进制内容伪装成文本扩展名时会多一次 base64 回退往返(结果正确)。
|
|
66
|
+
|
|
57
67
|
## ❤️ 七夕快乐
|
|
58
68
|
|
|
59
69
|
本项目是送给 **zhangyi** 的七夕礼物。
|
package/README_EN.md
CHANGED
|
@@ -17,7 +17,7 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
|
|
|
17
17
|
## Installation
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.
|
|
20
|
+
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.3.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
> **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
|
|
@@ -54,6 +54,16 @@ The plugin registers 4 exact routes (`/sidebar/api/fs.tree`, `fs.read`, `fs.writ
|
|
|
54
54
|
|
|
55
55
|
A shell wrapper (`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`) detects the workspace's `.remote-ssh.json` and auto-launches `ssh -tt`, making the built-in **Terminal** tab transparently connect to remote.
|
|
56
56
|
|
|
57
|
+
## Caching & Consistency
|
|
58
|
+
|
|
59
|
+
Remote reads and directory listings are cached host-side (LRU 32/64 entries, TTL 5s): re-opening or switching back to a tab within the TTL costs 0 network round-trips; after expiry a lightweight mtime+size revalidation runs first, and unchanged files are served without re-transfer. Write, delete, move, mkdir, upload, push (syncUp), remote exec and git-panel operations automatically invalidate the affected cache entries.
|
|
60
|
+
|
|
61
|
+
Known limitations:
|
|
62
|
+
|
|
63
|
+
- Files changed from the integrated terminal (`ssh -tt`) or by other remote processes rely on TTL + revalidation and may be stale for up to **5 seconds**;
|
|
64
|
+
- The pooled `/sidebar/file` download path has an effective limit of ≈**6.29MB**; larger files automatically fall back to a one-shot connection download (succeeds, with one extra reconnect);
|
|
65
|
+
- Binary content masquerading with a text extension costs one extra base64 fallback round-trip (results are still correct).
|
|
66
|
+
|
|
57
67
|
## ❤️ Happy Qixi
|
|
58
68
|
|
|
59
69
|
This project is a Qixi Festival gift for **zhangyi**.
|
package/lib/index.js
CHANGED
|
@@ -408,7 +408,17 @@ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes) {
|
|
|
408
408
|
|
|
409
409
|
async function remoteListDir(runner, p, path) {
|
|
410
410
|
const target = path || p.remoteRoot || "~";
|
|
411
|
-
const
|
|
411
|
+
const pk = profileKey(p);
|
|
412
|
+
const key = pk + "|" + String(target);
|
|
413
|
+
const epoch = cacheEpoch(pk);
|
|
414
|
+
const hit = lruGet(treeCache, key);
|
|
415
|
+
if (hit && hit.epoch === epoch && Date.now() - hit.at <= TREE_CACHE_TTL_MS) {
|
|
416
|
+
return { ok: true, path: target, entries: hit.entries.slice() }; // TTL 内 0 RTT
|
|
417
|
+
}
|
|
418
|
+
if (hit && hit.epoch !== epoch) treeCache.delete(key);
|
|
419
|
+
// cd 包进子 shell:列举完成后不污染共享会话 cwd(findings §4.4 —— 旧实现 cd 持久生效,
|
|
420
|
+
// 会话内后续相对路径命令全部落空)。exit 只退出子 shell,哨兵协议不受影响。
|
|
421
|
+
const script = "( cd " + shellQuotePath(target) + " 2>/dev/null || { echo '__DSH_ERR__ cannot cd'; exit 1; }; find . -maxdepth 1 -mindepth 1 -printf '%Y\\t%f\\t%s\\n' 2>/dev/null | sort )";
|
|
412
422
|
const r = await runner(p, script, undefined, MAX_BYTES);
|
|
413
423
|
if (!r.ok) return { ok: false, error: (r.error || r.stderr || "").trim() || "读取目录失败" };
|
|
414
424
|
const entries = [];
|
|
@@ -425,39 +435,305 @@ async function remoteListDir(runner, p, path) {
|
|
|
425
435
|
entries.sort(function (a, b) {
|
|
426
436
|
return a.type === b.type ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : (a.type === "directory" ? -1 : 1);
|
|
427
437
|
});
|
|
438
|
+
lruPut(treeCache, key, { entries: entries, epoch: epoch, at: Date.now() }, TREE_CACHE_MAX);
|
|
428
439
|
return { ok: true, path: target, entries: entries };
|
|
429
440
|
}
|
|
430
441
|
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// 远程读取提速(t2):单往返合并读 + raw 文本快路径 + mtime/size LRU 结果缓存
|
|
444
|
+
//
|
|
445
|
+
// 帧协议(沿用既有非 split 快速路径 "{ cmd ; } 2>&1" + 退出码哨兵;路径一律先
|
|
446
|
+
// f=<shellQuotePath> 再以 "$f" 引用,无任何 cwd 依赖):
|
|
447
|
+
// stdout 首行固定为 __DSH_ST__<size>:<mtime> (stat 成功,均为十进制)
|
|
448
|
+
// 或 __DSH_ST__ERR:<单行摘要> (stat 失败:摘要压成单行 ≤200 字符,
|
|
449
|
+
// 整条命令以内建 false 收尾保证非零
|
|
450
|
+
// 退出码 —— 绝不空内容假成功/挂起;
|
|
451
|
+
// 不用 exit,因非 split 帧无子 shell 隔离)
|
|
452
|
+
// 载荷 = 首行 \n 之后:base64 模式 `base64 -w0`(size>4MB 先 `head -c 4MB` 截断),
|
|
453
|
+
// raw 模式为文件原始字节(仅限文本扩展名白名单)。
|
|
454
|
+
// `_` 不在 base64 字母表内 → base64 载荷不可能出现 `__DSH_ST__`,标记定位天然抗噪;
|
|
455
|
+
// 协议层固定在哨兵前补一个 \n(exitMark),raw 解析按“恰好去掉末尾一个 \n”还原。
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
const DSH_ST_MARK = "__DSH_ST__";
|
|
459
|
+
/** raw 快路径扩展名白名单(findings §3②):只对按 UTF-8 文本处理的文件免 base64。
|
|
460
|
+
* 字节长度或 \uFFFD 校验失败时自动回退 base64 重读,不劣于现状。 */
|
|
461
|
+
const RAW_TEXT_EXTS = new Set([
|
|
462
|
+
".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".tsv",
|
|
463
|
+
".py", ".pyw", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".vue", ".svelte",
|
|
464
|
+
".json", ".jsonc", ".json5", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
|
|
465
|
+
".sh", ".bash", ".zsh", ".fish", ".ps1", ".psm1", ".bat", ".cmd", ".sql",
|
|
466
|
+
".c", ".h", ".cpp", ".hpp", ".cc", ".hh", ".cxx", ".hxx", ".f", ".for", ".f90", ".f95",
|
|
467
|
+
".java", ".go", ".rs", ".rb", ".pl", ".pm", ".php", ".lua", ".r", ".jl", ".dart", ".kt",
|
|
468
|
+
".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".svg", ".xsl", ".proto",
|
|
469
|
+
".tex", ".bib", ".properties", ".gradle", ".cmake", ".mk", ".diff", ".patch", ".ipynb"
|
|
470
|
+
]);
|
|
471
|
+
/** 无扩展名 / 点开头的常读文本文件(按整名匹配,小写)。 */
|
|
472
|
+
const RAW_TEXT_NAMES = new Set([
|
|
473
|
+
"makefile", "dockerfile", "license", "readme", "changelog", "authors", "contributors",
|
|
474
|
+
".gitignore", ".gitattributes", ".gitmodules", ".gitconfig", ".gitkeep", ".editorconfig",
|
|
475
|
+
".dockerignore", ".npmrc", ".nvmrc", ".bashrc", ".bash_profile", ".profile", ".vimrc", ".tmux.conf"
|
|
476
|
+
]);
|
|
477
|
+
/** 读结果 LRU:key = profileKey|path,TTL ≤5s;过期后 1 裸 RTT stat 复验 mtime+size。 */
|
|
478
|
+
const READ_CACHE_MAX = 32;
|
|
479
|
+
const READ_CACHE_TTL_MS = 5000;
|
|
480
|
+
/** 目录列举 LRU:key = profileKey|dir,TTL 5s。 */
|
|
481
|
+
const TREE_CACHE_MAX = 64;
|
|
482
|
+
const TREE_CACHE_TTL_MS = 5000;
|
|
483
|
+
/** 每个 profile 的缓存代(epoch):插件侧任何变更(写/删/移/建目录/上传/syncUp/
|
|
484
|
+
* 远程 exec/git)都 +1,条目按填充时代号校验 —— 兜底 mtime 秒级粒度的“同秒同 size”
|
|
485
|
+
* 写、~/x 与 /home/u/x 路径字符串空间不一致、以及漏钩子场景(findings §4.3)。 */
|
|
486
|
+
const cacheEpochs = new Map();
|
|
487
|
+
const readCache = new Map();
|
|
488
|
+
const treeCache = new Map();
|
|
489
|
+
|
|
490
|
+
function profileKey(p) {
|
|
491
|
+
// 认证信息变化时应重建会话/缓存:修改 keyPath/authMethod 后旧身份不能继续复用。
|
|
492
|
+
return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "") + "|" + String(p.authMethod || "key") + "|" + String(p.keyPath || "");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function cacheEpoch(pk) {
|
|
496
|
+
let e = cacheEpochs.get(pk);
|
|
497
|
+
if (!e) { e = 1; cacheEpochs.set(pk, e); }
|
|
498
|
+
return e;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** 只递增 epoch 不删条目:远程 exec / git 这类“可能改了任意文件”的入口调用。 */
|
|
502
|
+
function bumpCacheEpoch(p) {
|
|
503
|
+
const pk = profileKey(p);
|
|
504
|
+
cacheEpochs.set(pk, cacheEpoch(pk) + 1);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function lruGet(map, key) {
|
|
508
|
+
const v = map.get(key);
|
|
509
|
+
if (v === undefined) return undefined;
|
|
510
|
+
map.delete(key);
|
|
511
|
+
map.set(key, v); // LRU touch
|
|
512
|
+
return v;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function lruPut(map, key, val, max) {
|
|
516
|
+
map.delete(key);
|
|
517
|
+
map.set(key, val);
|
|
518
|
+
while (map.size > max) map.delete(map.keys().next().value);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function normalizeCachePath(path) {
|
|
522
|
+
let s = String(path || "");
|
|
523
|
+
while (s.length > 1 && s.charCodeAt(s.length - 1) === 47) s = s.slice(0, -1);
|
|
524
|
+
return s;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function posixDirname(path) {
|
|
528
|
+
const s = normalizeCachePath(path);
|
|
529
|
+
const i = s.lastIndexOf("/");
|
|
530
|
+
if (i < 0) return ".";
|
|
531
|
+
if (i === 0) return "/";
|
|
532
|
+
return s.slice(0, i);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** 文件读取缓存失效(统一入口之一;pk 为 profileKey 字符串)。
|
|
536
|
+
* subtree=true 同时失效 path 子树(rm -rf / mv 目录场景)。 */
|
|
537
|
+
function invalidateReadCache(pk, path, opts) {
|
|
538
|
+
const sub = !!(opts && opts.subtree);
|
|
539
|
+
const target = pk + "|" + normalizeCachePath(path);
|
|
540
|
+
for (const k of Array.from(readCache.keys())) {
|
|
541
|
+
if (k === target || (sub && k.startsWith(target + "/"))) readCache.delete(k);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** 目录列举缓存失效(同语义,作用于目录键)。 */
|
|
546
|
+
function invalidateTreeCache(pk, dir, opts) {
|
|
547
|
+
const sub = !!(opts && opts.subtree);
|
|
548
|
+
const target = pk + "|" + normalizeCachePath(dir);
|
|
549
|
+
for (const k of Array.from(treeCache.keys())) {
|
|
550
|
+
if (k === target || (sub && k.startsWith(target + "/"))) treeCache.delete(k);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** 收敛失效入口:所有可能改变远端文件/目录的插件入口都调用它 ——
|
|
555
|
+
* 文件缓存(exact + 可选子树)+ 列举缓存(被改目录本身 + 其父目录)+ 整代 epoch+1。 */
|
|
556
|
+
function invalidateRemoteCaches(p, path, opts) {
|
|
557
|
+
bumpCacheEpoch(p);
|
|
558
|
+
const pk = profileKey(p);
|
|
559
|
+
const target = normalizeCachePath(path);
|
|
560
|
+
if (!target) return;
|
|
561
|
+
invalidateReadCache(pk, target, opts);
|
|
562
|
+
invalidateTreeCache(pk, target, opts);
|
|
563
|
+
invalidateTreeCache(pk, posixDirname(target), { subtree: false });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** 整个 profile 的缓存清空(syncUp 批量覆盖远端后调用)。 */
|
|
567
|
+
function clearCachesForProfile(p) {
|
|
568
|
+
const pk = profileKey(p);
|
|
569
|
+
cacheEpochs.set(pk, cacheEpoch(pk) + 1);
|
|
570
|
+
for (const k of Array.from(readCache.keys())) { if (k.startsWith(pk + "|")) readCache.delete(k); }
|
|
571
|
+
for (const k of Array.from(treeCache.keys())) { if (k.startsWith(pk + "|")) treeCache.delete(k); }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function isRawTextPath(path) {
|
|
575
|
+
const base = String(path || "").split(/[\\/]/).pop() || "";
|
|
576
|
+
const lower = base.toLowerCase();
|
|
577
|
+
const dot = lower.lastIndexOf(".");
|
|
578
|
+
if (dot > 0 && RAW_TEXT_EXTS.has(lower.slice(dot))) return true;
|
|
579
|
+
if (RAW_TEXT_NAMES.has(lower)) return true;
|
|
580
|
+
if (dot === 0 && RAW_TEXT_EXTS.has(lower)) return true; // .gitignore 这类整名即后缀
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** 远端错误摘要:压成单行、去重空白、≤300 字符(远端侧已 cut -c1-200,这里再兜底)。 */
|
|
585
|
+
function sanitizeErrLine(s) {
|
|
586
|
+
const t = String(s || "").replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ").trim();
|
|
587
|
+
return t.slice(0, 300);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* 解析 __DSH_ST__ 帧。stdout 首行必须严格以标记开头(登录噪声场景按旧行为降级,
|
|
592
|
+
* framed=false → 整段按旧协议处理,绝不误切)。stat 值异常(非数字/缺冒号)一律走
|
|
593
|
+
* err 分支,禁止 NaN 继续传播。首行之后即载荷(文件内容不会出现在标记行之前:
|
|
594
|
+
* 命令先 printf 标记,head/base64/cat 在其后输出)。
|
|
595
|
+
*/
|
|
596
|
+
function parseStatFrame(stdout) {
|
|
597
|
+
const out = String(stdout || "");
|
|
598
|
+
const nl = out.indexOf("\n");
|
|
599
|
+
const first = nl >= 0 ? out.slice(0, nl) : out;
|
|
600
|
+
const rest = nl >= 0 ? out.slice(nl + 1) : "";
|
|
601
|
+
if (!first.startsWith(DSH_ST_MARK)) {
|
|
602
|
+
return { framed: false, err: null, size: null, mtime: null, rest: out };
|
|
603
|
+
}
|
|
604
|
+
const body = first.slice(DSH_ST_MARK.length);
|
|
605
|
+
if (body.startsWith("ERR:")) {
|
|
606
|
+
return { framed: true, err: sanitizeErrLine(body.slice(4)) || "远端读取失败", size: null, mtime: null, rest: rest };
|
|
607
|
+
}
|
|
608
|
+
const ci = body.indexOf(":");
|
|
609
|
+
if (ci < 0) return { framed: true, err: "stat 标记解析异常", size: null, mtime: null, rest: rest };
|
|
610
|
+
const sizeStr = body.slice(0, ci);
|
|
611
|
+
const mtimeStr = body.slice(ci + 1);
|
|
612
|
+
if (!/^\d*$/.test(sizeStr) || !/^\d*$/.test(mtimeStr) || (sizeStr === "" && mtimeStr === "")) {
|
|
613
|
+
return { framed: true, err: "stat 标记数值异常", size: null, mtime: null, rest: rest };
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
framed: true,
|
|
617
|
+
err: null,
|
|
618
|
+
size: sizeStr === "" ? null : parseInt(sizeStr, 10),
|
|
619
|
+
mtime: mtimeStr === "" ? null : parseInt(mtimeStr, 10),
|
|
620
|
+
rest: rest
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** 帧命令共享段:stat(size mtime) + 标记行。stat 失败 → ERR 标记 + false(非零收尾)。 */
|
|
625
|
+
function statFrameSnippet() {
|
|
626
|
+
return "if st=$(stat -c'%s %Y' -- \"$f\" 2>&1); then printf '" + DSH_ST_MARK + "%s:%s\\n' \"${st%% *}\" \"${st#* }\"; ";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function errSnippet() {
|
|
630
|
+
return "else printf '" + DSH_ST_MARK + "ERR:%s\\n' \"$(printf '%s' \"$st\" | tr '\\r\\n' ' ' | cut -c1-200)\"; false; fi";
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** 复验命令(过期缓存条目的 mtime+size 校验):1 裸 RTT,无内容传输。 */
|
|
634
|
+
function buildStatCommand(path) {
|
|
635
|
+
return "f=" + shellQuotePath(path) + "; " + statFrameSnippet() + errSnippet();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** 合并读命令:一次往返同时拿 size/mtime 与内容;>4MB 保留 head -c 截断语义。
|
|
639
|
+
* raw=true 载荷为原始字节(仅白名单文本),否则 base64 -w0。 */
|
|
640
|
+
function buildReadCommand(path, raw) {
|
|
641
|
+
const readPart = raw
|
|
642
|
+
? "if [ \"${st%% *}\" -gt " + MAX_BYTES + " ] 2>/dev/null; then head -c " + MAX_BYTES + " -- \"$f\"; else cat -- \"$f\"; fi"
|
|
643
|
+
: "if [ \"${st%% *}\" -gt " + MAX_BYTES + " ] 2>/dev/null; then head -c " + MAX_BYTES + " -- \"$f\" | base64 -w0; else base64 -w0 -- \"$f\"; fi";
|
|
644
|
+
return "f=" + shellQuotePath(path) + "; " + statFrameSnippet() + readPart + "; " + errSnippet();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function decodeBase64Text(b64) {
|
|
648
|
+
const bytes = new Uint8Array(Buffer.from(String(b64).trim(), "base64"));
|
|
649
|
+
let binary = false;
|
|
650
|
+
const first = bytes.subarray(0, 8000);
|
|
651
|
+
for (let i = 0; i < first.length; i++) { if (first[i] === 0) { binary = true; break; } }
|
|
652
|
+
return { text: new TextDecoder().decode(bytes), binary: binary };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** raw 载荷字节级校验:去掉协议层补的末尾 \n 后,字节数必须等于 size,且不含
|
|
656
|
+
* \uFFFD(会话层 chunk.toString("utf8") 有损解码的痕迹)。任一不符 → 返回 null,
|
|
657
|
+
* 由调用方回退 base64 重读。二进制探测语义与旧实现一致(前 8000 含 NUL → binary)。 */
|
|
658
|
+
function decodeRawText(rest, size) {
|
|
659
|
+
if (size === null || size === undefined) return null;
|
|
660
|
+
if (!rest.endsWith("\n")) return null; // 协议保证哨兵前有一个分隔 \n
|
|
661
|
+
const rawText = rest.slice(0, -1);
|
|
662
|
+
const expected = Math.min(size, MAX_BYTES);
|
|
663
|
+
if (Buffer.byteLength(rawText, "utf8") !== expected) return null;
|
|
664
|
+
if (rawText.indexOf("\uFFFD") >= 0) return null;
|
|
665
|
+
return { text: rawText, binary: rawText.slice(0, 8000).indexOf("\u0000") >= 0 };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function readResultOk(path, text, binary, truncated) {
|
|
669
|
+
return { ok: true, path: path, content: text, binary: binary, truncated: truncated };
|
|
670
|
+
}
|
|
671
|
+
|
|
431
672
|
async function remoteReadFile(runner, p, path) {
|
|
432
673
|
if (!path) return { ok: false, error: "path 为必填项" };
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
674
|
+
const pk = profileKey(p);
|
|
675
|
+
const key = pk + "|" + String(path);
|
|
676
|
+
const epoch = cacheEpoch(pk);
|
|
677
|
+
const hit = lruGet(readCache, key);
|
|
678
|
+
if (hit && hit.epoch === epoch) {
|
|
679
|
+
if (Date.now() - hit.at <= READ_CACHE_TTL_MS) {
|
|
680
|
+
return readResultOk(path, hit.content, hit.binary, hit.truncated); // TTL 内 0 RTT
|
|
681
|
+
}
|
|
682
|
+
// TTL 过期:1 裸 RTT 复验 mtime+size,未变则免内容重传(findings §3③)
|
|
683
|
+
const rv = await runner(p, buildStatCommand(path), undefined, MAX_BYTES);
|
|
684
|
+
if (rv.ok) {
|
|
685
|
+
const rst = parseStatFrame(rv.stdout);
|
|
686
|
+
if (rst.framed && rst.err !== null) {
|
|
687
|
+
readCache.delete(key); // 文件已消失/不可 stat:全量读也只会得到同样错误
|
|
688
|
+
return { ok: false, error: rst.err };
|
|
689
|
+
}
|
|
690
|
+
if (rst.framed && rst.size === hit.size && rst.mtime === hit.mtime) {
|
|
691
|
+
hit.at = Date.now();
|
|
692
|
+
lruPut(readCache, key, hit, READ_CACHE_MAX);
|
|
693
|
+
return readResultOk(path, hit.content, hit.binary, hit.truncated);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// 复验失败/文件已变更 → 落到全量合并读
|
|
697
|
+
} else if (hit) {
|
|
698
|
+
readCache.delete(key); // 旧代条目(epoch 已推进)直接丢弃
|
|
447
699
|
}
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
let
|
|
451
|
-
|
|
700
|
+
const isRaw = isRawTextPath(path);
|
|
701
|
+
let r = await runner(p, buildReadCommand(path, isRaw), undefined, MAX_BYTES);
|
|
702
|
+
let st = parseStatFrame(r.stdout);
|
|
703
|
+
if (isRaw && r.ok && st.framed && st.err === null) {
|
|
704
|
+
const rawOk = decodeRawText(st.rest, st.size);
|
|
705
|
+
if (rawOk) {
|
|
706
|
+
const truncated = st.size !== null && st.size > MAX_BYTES;
|
|
707
|
+
if (!r.truncated) {
|
|
708
|
+
lruPut(readCache, key, { content: rawOk.text, binary: rawOk.binary, truncated: truncated, size: st.size, mtime: st.mtime, epoch: epoch, at: Date.now() }, READ_CACHE_MAX);
|
|
709
|
+
}
|
|
710
|
+
return readResultOk(path, rawOk.text, rawOk.binary, truncated || !!r.truncated);
|
|
711
|
+
}
|
|
712
|
+
// raw 校验失败(二进制冒充文本/截断断字/异常)→ base64 重读,不劣于现状
|
|
713
|
+
r = await runner(p, buildReadCommand(path, false), undefined, MAX_BYTES);
|
|
714
|
+
st = parseStatFrame(r.stdout);
|
|
715
|
+
}
|
|
716
|
+
if (!r.ok) {
|
|
717
|
+
readCache.delete(key);
|
|
718
|
+
let msg = String(r.error || r.stderr || "").trim();
|
|
719
|
+
if (st.framed) msg = st.err !== null ? st.err : sanitizeErrLine(st.rest);
|
|
720
|
+
return { ok: false, error: msg || "读取失败" };
|
|
721
|
+
}
|
|
722
|
+
if (st.framed && st.err !== null) {
|
|
723
|
+
// 帧内错误但退出码 0(异常远端):按错误处理,绝不返回空内容假成功
|
|
724
|
+
return { ok: false, error: st.err };
|
|
725
|
+
}
|
|
726
|
+
let decoded;
|
|
452
727
|
try {
|
|
453
|
-
|
|
454
|
-
const first = bytes.subarray(0, 8000);
|
|
455
|
-
for (let i = 0; i < first.length; i++) { if (first[i] === 0) { binary = true; break; } }
|
|
456
|
-
text = new TextDecoder().decode(bytes);
|
|
728
|
+
decoded = decodeBase64Text(st.framed ? st.rest : r.stdout);
|
|
457
729
|
} catch (e) {
|
|
458
730
|
return { ok: false, error: "解码失败: " + String(e) };
|
|
459
731
|
}
|
|
460
|
-
|
|
732
|
+
const truncated = st.framed && st.size !== null && st.size > MAX_BYTES;
|
|
733
|
+
if (st.framed && !r.truncated) {
|
|
734
|
+
lruPut(readCache, key, { content: decoded.text, binary: decoded.binary, truncated: truncated, size: st.size, mtime: st.mtime, epoch: epoch, at: Date.now() }, READ_CACHE_MAX);
|
|
735
|
+
}
|
|
736
|
+
return readResultOk(path, decoded.text, decoded.binary, truncated || !!r.truncated);
|
|
461
737
|
}
|
|
462
738
|
|
|
463
739
|
async function remoteWriteFile(runner, p, path, content) {
|
|
@@ -466,7 +742,13 @@ async function remoteWriteFile(runner, p, path, content) {
|
|
|
466
742
|
if (c.length > MAX_BYTES) {
|
|
467
743
|
return { ok: false, exitCode: -1, stdout: "", stderr: "", error: "写入内容超过 " + MAX_BYTES + " 字节上限", truncated: false };
|
|
468
744
|
}
|
|
469
|
-
|
|
745
|
+
// base64 -d 写入(heredoc stdin):heredoc 帧固定补一个尾随 \n,base64 解码会忽略它,
|
|
746
|
+
// 从而字节级精确 —— 修复旧 `cat > file` 写法“每次保存尾随 +1 字节”的 quirk
|
|
747
|
+
// (findings §3⑦),且对 NUL/二进制安全。内容 ≤4MB → base64 ≤5.6MB,内存可控。
|
|
748
|
+
const r = await runner(p, "base64 -d > " + shellQuotePath(path), Buffer.from(c, "utf8").toString("base64"), MAX_BYTES, true);
|
|
749
|
+
// 写后失效:成功与否都失效(失败的写也可能已截断旧文件)。
|
|
750
|
+
invalidateRemoteCaches(p, path, { subtree: false });
|
|
751
|
+
return r;
|
|
470
752
|
}
|
|
471
753
|
|
|
472
754
|
/**
|
|
@@ -607,7 +889,11 @@ async function remoteSyncUp(subprocess, p, remotePath, mirrorPath) {
|
|
|
607
889
|
try { sshOut = await sshH.done; } catch (e) { sshOut = { exitCode: -1 }; }
|
|
608
890
|
const tarErr = readCollected(tarH, "stderr");
|
|
609
891
|
const sshErr = readCollected(sshH, "stderr");
|
|
610
|
-
if (tarOut.exitCode === 0 && sshOut.exitCode === 0)
|
|
892
|
+
if (tarOut.exitCode === 0 && sshOut.exitCode === 0) {
|
|
893
|
+
// 批量覆盖远端文件:清空该 profile 的读/列举缓存(findings §4.3a)
|
|
894
|
+
clearCachesForProfile(p);
|
|
895
|
+
return { ok: true, remotePath: remotePath };
|
|
896
|
+
}
|
|
611
897
|
const parts = [];
|
|
612
898
|
if (pipeErr) parts.push("管道: " + pipeErr);
|
|
613
899
|
if (tarOut.exitCode !== 0) parts.push("tar 退出码 " + tarOut.exitCode + (tarErr ? ": " + tarErr.slice(0, 500) : ""));
|
|
@@ -743,10 +1029,7 @@ function apply(ctx, config) {
|
|
|
743
1029
|
// ---- 持久 SSH 会话池(连接复用,避免每次操作都做完整 SSH 握手)----
|
|
744
1030
|
const sessions = new Map(); // profileKey -> CommandSession
|
|
745
1031
|
const SESSION_IDLE_MS = 10 * 60 * 1000;
|
|
746
|
-
|
|
747
|
-
// 认证信息变化时应重建会话:修改 keyPath/authMethod 后旧连接不能继续复用。
|
|
748
|
-
return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "") + "|" + String(p.authMethod || "key") + "|" + String(p.keyPath || "");
|
|
749
|
-
}
|
|
1032
|
+
// profileKey 已上移模块层(读缓存/列举缓存与失效逻辑共用同一身份键,定义见 remoteReadFile 上方)。
|
|
750
1033
|
function getSession(p) {
|
|
751
1034
|
const key = profileKey(p);
|
|
752
1035
|
let s = sessions.get(key);
|
|
@@ -1023,7 +1306,10 @@ function apply(ctx, config) {
|
|
|
1023
1306
|
if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
|
|
1024
1307
|
if (!args || !args.command) return { ok: false, error: "command 为必填项" };
|
|
1025
1308
|
// 走持久会话池(stderr 分离):首调用建立连接后,后续调用毫秒级返回。
|
|
1026
|
-
|
|
1309
|
+
// 远程命令可能改任意文件(插件不可见)→ 整代失效读/列举缓存(findings §4.3a)。
|
|
1310
|
+
const r = await runPooled(p, args.command, args.stdin, undefined, true);
|
|
1311
|
+
bumpCacheEpoch(p);
|
|
1312
|
+
return r;
|
|
1027
1313
|
},
|
|
1028
1314
|
listDir: async (args) => {
|
|
1029
1315
|
const p = resolveProfile(args);
|
|
@@ -1054,19 +1340,26 @@ function apply(ctx, config) {
|
|
|
1054
1340
|
const p = resolveProfile(args);
|
|
1055
1341
|
if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
|
|
1056
1342
|
if (!args || !args.path) return { ok: false, error: "path 为必填项" };
|
|
1057
|
-
|
|
1343
|
+
const r = await runPooled(p, "mkdir -p " + shellQuotePath(args.path), undefined, undefined);
|
|
1344
|
+
invalidateRemoteCaches(p, args.path, { subtree: false }); // 父目录列举变化
|
|
1345
|
+
return r;
|
|
1058
1346
|
},
|
|
1059
1347
|
deleteFile: async (args) => {
|
|
1060
1348
|
const p = resolveProfile(args);
|
|
1061
1349
|
if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
|
|
1062
1350
|
if (!args || !args.path) return { ok: false, error: "path 为必填项" };
|
|
1063
|
-
|
|
1351
|
+
const r = await runPooled(p, "rm -rf " + shellQuotePath(args.path), undefined, undefined);
|
|
1352
|
+
invalidateRemoteCaches(p, args.path, { subtree: true });
|
|
1353
|
+
return r;
|
|
1064
1354
|
},
|
|
1065
1355
|
move: async (args) => {
|
|
1066
1356
|
const p = resolveProfile(args);
|
|
1067
1357
|
if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
|
|
1068
1358
|
if (!args || !args.src || !args.dst) return { ok: false, error: "src 和 dst 为必填项" };
|
|
1069
|
-
|
|
1359
|
+
const r = await runPooled(p, "mv " + shellQuotePath(args.src) + " " + shellQuotePath(args.dst), undefined, undefined);
|
|
1360
|
+
invalidateRemoteCaches(p, args.src, { subtree: true });
|
|
1361
|
+
invalidateRemoteCaches(p, args.dst, { subtree: true });
|
|
1362
|
+
return r;
|
|
1070
1363
|
},
|
|
1071
1364
|
/** 远端 → 本地镜像(按工作区)。 */
|
|
1072
1365
|
syncDown: async (args) => {
|
|
@@ -1222,7 +1515,10 @@ function apply(ctx, config) {
|
|
|
1222
1515
|
// 走持久会话池(stderr 分离,双哨兵协议):首次调用完成建连后,
|
|
1223
1516
|
// 后续调用不再重复 TCP 握手 + 认证,单次耗时从秒级降到毫秒级;
|
|
1224
1517
|
// 会话异常时 runPooled 自动重建并降级一次性连接。
|
|
1225
|
-
|
|
1518
|
+
// 远程命令可能改任意文件(插件不可见)→ 整代失效读/列举缓存(findings §4.3a)。
|
|
1519
|
+
const r = await runPooled(tc.profile, cmd, args.stdin, undefined, true);
|
|
1520
|
+
bumpCacheEpoch(tc.profile);
|
|
1521
|
+
return normExec(r);
|
|
1226
1522
|
}
|
|
1227
1523
|
});
|
|
1228
1524
|
|
|
@@ -1435,8 +1731,10 @@ function apply(ctx, config) {
|
|
|
1435
1731
|
const tc = toolContext(args, exec);
|
|
1436
1732
|
if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
|
|
1437
1733
|
if (!args.path) return normExec({ ok: false, error: "path 为必填项" });
|
|
1438
|
-
|
|
1439
|
-
|
|
1734
|
+
const rp = resolveRemotePath(args.path, tc.remotePath);
|
|
1735
|
+
const r = await runPooled(tc.profile, "mkdir -p " + shellQuotePath(rp), undefined, undefined);
|
|
1736
|
+
invalidateRemoteCaches(tc.profile, rp, { subtree: false }); // 父目录列举变化
|
|
1737
|
+
return normExec(r);
|
|
1440
1738
|
}
|
|
1441
1739
|
});
|
|
1442
1740
|
|
|
@@ -1450,8 +1748,10 @@ function apply(ctx, config) {
|
|
|
1450
1748
|
const tc = toolContext(args, exec);
|
|
1451
1749
|
if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
|
|
1452
1750
|
if (!args.path) return normExec({ ok: false, error: "path 为必填项" });
|
|
1453
|
-
|
|
1454
|
-
|
|
1751
|
+
const rp = resolveRemotePath(args.path, tc.remotePath);
|
|
1752
|
+
const r = await runPooled(tc.profile, "rm -rf " + shellQuotePath(rp), undefined, undefined);
|
|
1753
|
+
invalidateRemoteCaches(tc.profile, rp, { subtree: true });
|
|
1754
|
+
return normExec(r);
|
|
1455
1755
|
}
|
|
1456
1756
|
});
|
|
1457
1757
|
|
|
@@ -1468,8 +1768,12 @@ function apply(ctx, config) {
|
|
|
1468
1768
|
const tc = toolContext(args, exec);
|
|
1469
1769
|
if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
|
|
1470
1770
|
if (!args.src || !args.dst) return normExec({ ok: false, error: "src 和 dst 为必填项" });
|
|
1471
|
-
|
|
1472
|
-
|
|
1771
|
+
const src = resolveRemotePath(args.src, tc.remotePath);
|
|
1772
|
+
const dst = resolveRemotePath(args.dst, tc.remotePath);
|
|
1773
|
+
const r = await runPooled(tc.profile, "mv " + shellQuotePath(src) + " " + shellQuotePath(dst), undefined, undefined);
|
|
1774
|
+
invalidateRemoteCaches(tc.profile, src, { subtree: true });
|
|
1775
|
+
invalidateRemoteCaches(tc.profile, dst, { subtree: true }); // dst 子树:mv file dir/ 会落入既有目录
|
|
1776
|
+
return normExec(r);
|
|
1473
1777
|
}
|
|
1474
1778
|
});
|
|
1475
1779
|
|
|
@@ -1857,6 +2161,8 @@ function apply(ctx, config) {
|
|
|
1857
2161
|
const quoted = args.map((a) => shellQuote(a)).join(" ");
|
|
1858
2162
|
const r = await runPooled(profile, "git -C " + shellQuotePath(remoteDir) + " --no-pager -c color.ui=false " + quoted, undefined, 8 * 1024 * 1024);
|
|
1859
2163
|
if (!r.ok) throw { code: "git-error", message: String(r.stdout || r.error || "").trim() || "remote git exited with " + r.exitCode };
|
|
2164
|
+
// commit/checkout/revert/cherry-pick 等会改工作区文件 → 整代失效读/列举缓存
|
|
2165
|
+
bumpCacheEpoch(profile);
|
|
1860
2166
|
return r.stdout;
|
|
1861
2167
|
}
|
|
1862
2168
|
|
|
@@ -2137,6 +2443,8 @@ function apply(ctx, config) {
|
|
|
2137
2443
|
writeJson(res, status, { ok: false, error: { code: r.code, message: r.message } });
|
|
2138
2444
|
return;
|
|
2139
2445
|
}
|
|
2446
|
+
// 上传落盘:目标文件 + 父目录列举失效
|
|
2447
|
+
invalidateRemoteCaches(profile, target, { subtree: false });
|
|
2140
2448
|
writeOk(res, { path: dir.replace(/\\/g, "/").replace(/\/+$/, "") + "/" + rel, size: r.size });
|
|
2141
2449
|
} catch (e) {
|
|
2142
2450
|
writeJson(res, 400, { ok: false, error: { code: "fs-error", message: String(e && e.message ? e.message : e) } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhangfengshun/dsh-remote-ssh",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|