@zhangfengshun/dsh-remote-ssh 2.2.0 → 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 CHANGED
@@ -2,6 +2,23 @@
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
+
18
+ ## [2.2.1] — 去掉设置导航「远程连接」左侧的齿轮图标
19
+ ### 修复
20
+ - DSH 壳层对未知 `settings.section` 渲染默认齿轮图标,且导航类名是构建期哈希(每版都会变),旧版硬编码的 CSS 选择器(`VOzbGW_*`)已随 DSH 更新失效。改为按栏目标题文本定位设置导航按钮并隐藏其图标 svg(MutationObserver + 语言切换自动重跑),跨 DSH 构建版本稳定。
21
+
5
22
  ## [2.2.0] — 远程命令连接复用(实测 ≈15× 提速)+ 协议/安全加固
6
23
  ### 性能
7
24
  - **`remote_ssh_exec` 走持久会话池**:不再每次调用做完整 SSH 握手。真实 HPC(ssh.cn-zhongwei-1.paracloud.com,2222 端口)实测:一次性连接单命令平均 **1292ms**,连接复用后单命令平均 **84.3ms**,**约 15.3× 提速**;`stdin` 参数经 heredoc 走同一条复用通道。
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.1.7
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.1.7
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/client.js CHANGED
@@ -55,9 +55,7 @@ window.__ModuleLoader__.load({
55
55
  ".rssh-seg-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}" +
56
56
  ".rssh-seg-btn.active{background:var(--dsw-alias-state-business-primary);color:#fff}" +
57
57
  ".rssh-pick-tree{border:1px solid var(--dsw-alias-border-l1);border-radius:4px;max-height:240px;overflow-y:auto;margin-top:4px;background:var(--dsw-alias-bg-layer-2)}" +
58
- ".rssh-pick-empty{padding:12px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:11px}" +
59
- // 设置面板导航:隐藏「远程连接」栏目左侧壳层硬编码的齿轮图标(本栏目 order=200 位于末尾)。
60
- ".VOzbGW_navList .VOzbGW_navCell:last-child .VOzbGW_navIcon{display:none}";
58
+ ".rssh-pick-empty{padding:12px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:11px}";
61
59
 
62
60
  // ---- 国际化(跟随 DSH 设置中的语言自动切换)----
63
61
  var NS = "dsh-remote-ssh";
@@ -1111,6 +1109,46 @@ window.__ModuleLoader__.load({
1111
1109
  return function () { if (el.parentNode) el.parentNode.removeChild(el); };
1112
1110
  });
1113
1111
 
1112
+ // ---- 去掉设置导航「远程连接」条目左侧的默认齿轮图标 ----
1113
+ // 壳层对未知 settings.section 一律渲染齿轮图标(IconSettingsOutline16 兜底),
1114
+ // 且导航 CSS 类名是构建期哈希(VOzbGW_*、zOa2rq_* 每版都会变),CSS 选择器跨版本
1115
+ // 不可靠。这里改为按条目文本(settings.nav)定位设置导航按钮,隐藏其 svg 图标;
1116
+ // 设置面板按需挂载、语言会切换,用 MutationObserver + 语言订阅自动重跑。
1117
+ if (slots) {
1118
+ function hideRemoteGearIcon() {
1119
+ try {
1120
+ var label = i18n.t("settings.nav");
1121
+ var buttons = document.querySelectorAll("button");
1122
+ for (var i = 0; i < buttons.length; i++) {
1123
+ var btn = buttons[i];
1124
+ var text = (btn.textContent || "").trim();
1125
+ if (text === label.trim() && text !== "") {
1126
+ var icon = btn.querySelector("svg");
1127
+ if (icon) { icon.style.display = "none"; return true; }
1128
+ }
1129
+ }
1130
+ } catch (e) {}
1131
+ return false;
1132
+ }
1133
+ ctx.effect(function () {
1134
+ if (typeof document === "undefined") return;
1135
+ hideRemoteGearIcon();
1136
+ var timer = null;
1137
+ var schedule = function () {
1138
+ if (timer) return;
1139
+ timer = setTimeout(function () { timer = null; hideRemoteGearIcon(); }, 120);
1140
+ };
1141
+ var mo = new MutationObserver(schedule);
1142
+ mo.observe(document.body, { childList: true, subtree: true });
1143
+ var off = i18n.subscribe(schedule);
1144
+ return function () {
1145
+ mo.disconnect();
1146
+ if (off) off();
1147
+ if (timer) clearTimeout(timer);
1148
+ };
1149
+ }, "dsh-remote-ssh: hide settings nav gear icon");
1150
+ }
1151
+
1114
1152
  // better-sidebar 0.15+ 的文件上传 UI 把原始字节流 POST 到 /sidebar/upload。
1115
1153
  // 远程工作区会话的文件树显示的是本地镜像目录,better-sidebar 会把上传写进
1116
1154
  // 本地镜像而不会到达远程。这里包装 fetch:目标目录位于某个远程工作区镜像
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 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";
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
- // stat 拿大小:超过 MAX_BYTES 的大文件只读前 4MB(head -c 截断),
434
- // 避免把持久会话的 stdout 缓冲撑爆(会话层另有 8MB 硬上限兜底)。
435
- // 读路径走 2>&1 合并协议(热路径,省掉一次子 shell 与 stderr 重定向)。
436
- const st = await runner(p, "stat -c%s " + shellQuotePath(path));
437
- let size = null;
438
- if (st.ok) {
439
- const n = parseInt(String(st.stdout || "").trim(), 10);
440
- if (!isNaN(n)) size = n;
441
- }
442
- let cmd = "base64 -w0 " + shellQuotePath(path);
443
- let truncated = false;
444
- if (size !== null && size > MAX_BYTES) {
445
- cmd = "head -c " + MAX_BYTES + " " + shellQuotePath(path) + " | base64 -w0";
446
- truncated = true;
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 r = await runner(p, cmd, undefined, MAX_BYTES);
449
- if (!r.ok) return { ok: false, error: String(r.error || r.stderr || "").trim() || "读取失败" };
450
- let text = "";
451
- let binary = false;
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
- const bytes = new Uint8Array(Buffer.from(String(r.stdout).trim(), "base64"));
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
- return { ok: true, path: path, content: text, binary: binary, truncated: truncated || r.truncated };
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
- return await runner(p, "cat > " + shellQuotePath(path), c, MAX_BYTES, true);
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) return { ok: true, remotePath: remotePath };
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
- function profileKey(p) {
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
- return await runPooled(p, args.command, args.stdin, undefined, true);
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
- return await runPooled(p, "mkdir -p " + shellQuotePath(args.path), undefined, undefined);
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
- return await runPooled(p, "rm -rf " + shellQuotePath(args.path), undefined, undefined);
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
- return await runPooled(p, "mv " + shellQuotePath(args.src) + " " + shellQuotePath(args.dst), undefined, undefined);
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
- return normExec(await runPooled(tc.profile, cmd, args.stdin, undefined, true));
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
- let cmd = "mkdir -p " + shellQuotePath(resolveRemotePath(args.path, tc.remotePath));
1439
- return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
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
- let cmd = "rm -rf " + shellQuotePath(resolveRemotePath(args.path, tc.remotePath));
1454
- return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
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
- let cmd = "mv " + shellQuotePath(resolveRemotePath(args.src, tc.remotePath)) + " " + shellQuotePath(resolveRemotePath(args.dst, tc.remotePath));
1472
- return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
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.2.0",
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",