@shendeguize/remote-dsh-center 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +197 -0
- package/README.md +174 -0
- package/package.json +48 -0
- package/scripts/install.mjs +208 -0
- package/src/api.js +725 -0
- package/src/cli.js +1445 -0
- package/src/config-sync.js +157 -0
- package/src/daemon.js +362 -0
- package/src/defaults.js +89 -0
- package/src/dsh-workspace.js +467 -0
- package/src/launcher.js +627 -0
- package/src/lib/bundle.js +82 -0
- package/src/lib/bus.js +109 -0
- package/src/lib/capture.js +53 -0
- package/src/lib/clock.js +18 -0
- package/src/lib/entry.js +27 -0
- package/src/lib/errors.js +88 -0
- package/src/lib/logfile.js +65 -0
- package/src/lib/machine.js +63 -0
- package/src/lib/origin-guard.js +64 -0
- package/src/lib/pool.js +88 -0
- package/src/lib/proto.js +457 -0
- package/src/lib/semver.js +103 -0
- package/src/lib/shq.js +112 -0
- package/src/lib/ssh.js +647 -0
- package/src/lib/validate.js +363 -0
- package/src/monitor.js +145 -0
- package/src/patchsync.js +310 -0
- package/src/ports.js +93 -0
- package/src/prober.js +185 -0
- package/src/server.js +449 -0
- package/src/settings-file.js +550 -0
- package/src/ssh-config.js +152 -0
- package/src/store.js +772 -0
- package/src/tunnel.js +589 -0
- package/src/updater.js +450 -0
- package/src/web/actions.js +409 -0
- package/src/web/api.js +262 -0
- package/src/web/app.js +347 -0
- package/src/web/components/config-sync-dialog.js +469 -0
- package/src/web/components/confirm-dialog.js +61 -0
- package/src/web/components/defaults-card.js +216 -0
- package/src/web/components/event-panel.js +98 -0
- package/src/web/components/host-drawer.js +1039 -0
- package/src/web/components/host-table.js +317 -0
- package/src/web/components/hub.js +143 -0
- package/src/web/components/iframe-pane.js +377 -0
- package/src/web/components/manager-card.js +65 -0
- package/src/web/components/setup-wizard.js +726 -0
- package/src/web/components/tabbar.js +577 -0
- package/src/web/components/toast-region.js +107 -0
- package/src/web/favicon.svg +7 -0
- package/src/web/form.js +220 -0
- package/src/web/host-presentation.js +73 -0
- package/src/web/host-rules.js +76 -0
- package/src/web/index.html +17 -0
- package/src/web/router.js +118 -0
- package/src/web/setup-schema.js +203 -0
- package/src/web/sse.js +118 -0
- package/src/web/store.js +405 -0
- package/src/web/style.css +813 -0
- package/src/web/utils.js +210 -0
package/src/lib/proto.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远端协议模板构建 + 输出解析(规格 = 12 文档 §1、§3、§7)。
|
|
3
|
+
*
|
|
4
|
+
* 统一包装约定(12 §0):最终 argv 为 spawn('ssh', [...COMMON_SSH_OPTS, host, 'sh -c ' + shq(body)])。
|
|
5
|
+
* body 为单行 POSIX 脚本,语句以 '; ' 连接;唯一后台化语句 `nohup … &` 必须以 '; ' 与
|
|
6
|
+
* 前文隔离——若用 && 链接,`A && B &` 会把整个 AND 列表放入后台子壳,$! 拿到的是子壳 PID。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DshError } from './errors.js';
|
|
10
|
+
import { assertEnvKey, assertInt, assertSafeName, shq, workdirToken } from './shq.js';
|
|
11
|
+
import { REMOTE_DIR } from '../defaults.js';
|
|
12
|
+
|
|
13
|
+
/** 超时与轮询参数(12 §3 汇总)——集中此处便于调优。 */
|
|
14
|
+
export const PROTO_TIMING = Object.freeze({
|
|
15
|
+
onceTimeoutMs: 15_000, // 单条一次性 ssh 命令(02 §5.3)
|
|
16
|
+
scpTimeoutMs: 60_000, // 每个 patch 文件
|
|
17
|
+
startBudgetMs: 90_000, // start 队列任务总预算
|
|
18
|
+
pollFirstDelayMs: 1_000, // POLL 首拍 T+1s
|
|
19
|
+
pollIntervalMs: 2_000, // 此后每 2s 一拍
|
|
20
|
+
pollMaxAttempts: 5, // 最多 5 拍
|
|
21
|
+
stopGraceSec: 3, // STOP 脚本内 TERM→KILL 间隔
|
|
22
|
+
logTailLines: 50, // FAIL 收尾取日志尾行数
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const remotePath = (rel) => `$HOME/${REMOTE_DIR}/${rel}`;
|
|
26
|
+
|
|
27
|
+
// ── §1.1 探测协议 ────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** 无注入值,模板为常量。 */
|
|
30
|
+
export function buildProbeScript() {
|
|
31
|
+
return [
|
|
32
|
+
'echo "DSH_BIN=$(command -v dsh || echo MISSING)"',
|
|
33
|
+
'if command -v dsh >/dev/null 2>&1; then echo "DSH_VERSION=$(dsh --version 2>/dev/null | head -n 1)"; fi',
|
|
34
|
+
'H="${DSH_HOME:-$HOME/.dsh}"',
|
|
35
|
+
"printf 'DSH_HOME=%s\\n' \"$H\"",
|
|
36
|
+
'if [ -d "$H/profiles/web" ]; then echo "PROFILE_WEB=yes"; else echo "PROFILE_WEB=no"; fi',
|
|
37
|
+
'echo "RUNNING_DSH_WEB<<EOF"',
|
|
38
|
+
// `[d]sh` 只躲过 grep 自己;执行本脚本的那层 sh -c 仍会被命中——它的命令行里
|
|
39
|
+
// 既有 command -v dsh 又有 profiles/web。故再按 $$(本 shell 的 pid,子 shell 中
|
|
40
|
+
// 不变)排掉自身那行,否则每次探测都凭空多出一个「手动实例」。
|
|
41
|
+
'ps -eo pid,args | grep "[d]sh.*web" | grep -v "^ *$$ " || true',
|
|
42
|
+
'echo "EOF"',
|
|
43
|
+
'echo "PROBE_DONE=yes"',
|
|
44
|
+
].join('; ');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── §1.2 拉起协议 ────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{logName:string, port:number|'0', env?:Record<string,string>,
|
|
51
|
+
* patchRemoteNames?:string[], extraArgs?:string[], workdir?:string|null}} p
|
|
52
|
+
*/
|
|
53
|
+
export function buildLaunchScript({
|
|
54
|
+
logName, port, env = {}, patchRemoteNames = [], extraArgs = [], workdir = null,
|
|
55
|
+
}) {
|
|
56
|
+
assertSafeName(logName);
|
|
57
|
+
const portTok = assertInt(port, { min: 1, max: 65535, allowZero: true });
|
|
58
|
+
|
|
59
|
+
let envp = '';
|
|
60
|
+
const envEntries = Object.entries(env);
|
|
61
|
+
if (envEntries.length > 0) {
|
|
62
|
+
envp = `env ${envEntries.map(([k, v]) => `${assertEnvKey(k)}=${shq(String(v))}`).join(' ')} `;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const patchArgs = patchRemoteNames
|
|
66
|
+
.map((n) => ` --patch "${remotePath(`patches/${assertSafeName(n)}`)}"`)
|
|
67
|
+
.join('');
|
|
68
|
+
const extra = extraArgs.map((a) => ` ${shq(String(a))}`).join('');
|
|
69
|
+
|
|
70
|
+
// 前置语句以 '; ' 连接到 nohup(12 §0:若用 '&&',`A && B &` 会把整个 AND 列表放入
|
|
71
|
+
// 后台子壳,$! 拿到的是子壳 PID)。'&' 本身即后台化语句与 echo 之间的分隔符——
|
|
72
|
+
// 其后不能再跟 ';'(POSIX 语法错误)。
|
|
73
|
+
// workdir=null 时整段不生成,模板逐字退回补丁 01 之前的形态(零回归面)。
|
|
74
|
+
// 落地物路径(日志、patches、--patch 参数)全是 $HOME 绝对形态,cd 影响不到它们。
|
|
75
|
+
const cdStmt = workdir === null
|
|
76
|
+
? null
|
|
77
|
+
: (() => {
|
|
78
|
+
const wd = workdirToken(workdir);
|
|
79
|
+
return `cd -- ${wd} || { echo "ERR=workdir"; printf 'WD=%s\\n' ${wd}; exit 8; }`;
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
const prelude = [
|
|
83
|
+
`mkdir -p "${remotePath('patches')}" || { echo "ERR=mkdir"; exit 9; }`,
|
|
84
|
+
`LOG="${remotePath(logName)}"`,
|
|
85
|
+
': > "$LOG"',
|
|
86
|
+
...(cdStmt ? [cdStmt] : []),
|
|
87
|
+
].join('; ');
|
|
88
|
+
// --patch 是 dsh 启动器自己的旗标,必须紧跟 `web` 排在 web app 旗标之前:真机
|
|
89
|
+
// (dsh 0.1.0-rc.7)上 `dsh web --no-open ... --patch P` 会被 web app 判为
|
|
90
|
+
// unknown option '--patch' 而直接退出。extraArgs 反过来是 app 参数,仍留在尾部。
|
|
91
|
+
const launch = `nohup ${envp}dsh web${patchArgs} --no-open --host 127.0.0.1 --port ${portTok}${extra} > "$LOG" 2>&1 < /dev/null &`;
|
|
92
|
+
return `${prelude}; ${launch} echo "PID=$!"`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 一次往返同时取「URL / 进程存活 / 绑定错误」,驱动 §3 状态机少一半 ssh 轮次。 */
|
|
96
|
+
export function buildLaunchPollScript({ logName, pid }) {
|
|
97
|
+
assertSafeName(logName);
|
|
98
|
+
const pidTok = assertInt(pid, { min: 1, max: 4_294_967_295 });
|
|
99
|
+
return [
|
|
100
|
+
`LOG="${remotePath(logName)}"`,
|
|
101
|
+
'U=$(grep -o "dsh web: http://127\\.0\\.0\\.1:[0-9][0-9]*" "$LOG" 2>/dev/null | head -n 1)',
|
|
102
|
+
"if [ -n \"$U\" ]; then printf 'URL=%s\\n' \"$U\"; fi",
|
|
103
|
+
`if kill -0 ${pidTok} 2>/dev/null; then echo "ALIVE=yes"; else echo "ALIVE=no"; fi`,
|
|
104
|
+
'if grep -qiE "EADDRINUSE|address already in use" "$LOG" 2>/dev/null; then echo "BIND_ERR=yes"; else echo "BIND_ERR=no"; fi',
|
|
105
|
+
'echo "POLL_DONE=yes"',
|
|
106
|
+
].join('; ');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** manager 精析:对 kv.URL 再验并抽取 actualPort(12 §1.2 正则终稿)。 */
|
|
110
|
+
const URL_RE = /^dsh web: http:\/\/127\.0\.0\.1:(\d{1,5})$/;
|
|
111
|
+
|
|
112
|
+
export function parseLaunchUrl(url) {
|
|
113
|
+
const m = URL_RE.exec(String(url).trim());
|
|
114
|
+
if (!m) return null;
|
|
115
|
+
const p = Number(m[1]);
|
|
116
|
+
return p >= 1 && p <= 65535 ? p : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── §1.3 复核与停止协议 ──────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* ALIVE(PID 存活)+ ARGS 块(指纹,manager 侧全等比对)+ LISTEN 三态
|
|
123
|
+
* (ss 非必然存在,unknown 不作否定证据)+ CWD(进程实际工作目录)。
|
|
124
|
+
*
|
|
125
|
+
* CWD 是 best-effort 的**展示与诊断**字段:/proc 不存在或无权读时回 unknown。
|
|
126
|
+
* 它绝不进不误杀判据集——判据只有 PID 存活与 ARGS 逐字全等(12 §1.3)。
|
|
127
|
+
*/
|
|
128
|
+
export function buildVerifyScript({ pid, port }) {
|
|
129
|
+
const pidTok = assertInt(pid, { min: 1, max: 4_294_967_295 });
|
|
130
|
+
const portTok = assertInt(port, { min: 1, max: 65535 });
|
|
131
|
+
return [
|
|
132
|
+
`A=$(ps -p ${pidTok} -o args= 2>/dev/null)`,
|
|
133
|
+
'if [ -n "$A" ]; then echo "ALIVE=yes"; echo "ARGS<<EOF"; printf \'%s\\n\' "$A"; echo "EOF"; else echo "ALIVE=no"; fi',
|
|
134
|
+
`if command -v ss >/dev/null 2>&1; then if ss -ltn 2>/dev/null | grep -q ":${portTok} "; then echo "LISTEN=yes"; else echo "LISTEN=no"; fi; else echo "LISTEN=unknown"; fi`,
|
|
135
|
+
`if [ -r /proc/${pidTok}/cwd ]; then printf 'CWD=%s\\n' "$(readlink /proc/${pidTok}/cwd 2>/dev/null || echo unknown)"; else echo "CWD=unknown"; fi`,
|
|
136
|
+
'echo "VERIFY_DONE=yes"',
|
|
137
|
+
].join('; ');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 校验与 kill 在同一条命令内(03 §4.2 反竞态原则),匹配为与记录指纹**逐字全等**
|
|
142
|
+
* (契约疑议 1 的落地口径)——全等成立必然蕴含「含 dsh」。
|
|
143
|
+
*/
|
|
144
|
+
export function buildStopScript({ pid, fingerprint }) {
|
|
145
|
+
const pidTok = assertInt(pid, { min: 1, max: 4_294_967_295 });
|
|
146
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
|
|
147
|
+
throw new DshError('VALIDATION', '停止协议缺少命令行指纹,拒绝拼装(防误杀)');
|
|
148
|
+
}
|
|
149
|
+
return [
|
|
150
|
+
`A=$(ps -p ${pidTok} -o args= 2>/dev/null)`,
|
|
151
|
+
'if [ -z "$A" ]; then echo "KILLED=already-dead"',
|
|
152
|
+
`elif [ "$A" = ${shq(fingerprint)} ]; then kill ${pidTok} 2>/dev/null; sleep ${PROTO_TIMING.stopGraceSec}; if kill -0 ${pidTok} 2>/dev/null; then kill -9 ${pidTok} 2>/dev/null; echo "KILLED=force"; else echo "KILLED=term"; fi`,
|
|
153
|
+
'else echo "KILLED=no"; echo "REASON=fingerprint-mismatch"; echo "ARGS<<EOF"; printf \'%s\\n\' "$A"; echo "EOF"; fi',
|
|
154
|
+
'echo "STOP_DONE=yes"',
|
|
155
|
+
].join('; ');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── §1.4 日志获取 ────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/** 输出为裸日志文本,不走 KEY=VALUE 解析。 */
|
|
161
|
+
export function buildLogTailScript({ logName, lines }) {
|
|
162
|
+
assertSafeName(logName);
|
|
163
|
+
const n = assertInt(lines, { min: 1, max: 10_000 });
|
|
164
|
+
return `tail -n ${n} "${remotePath(logName)}" 2>/dev/null || echo "(no log)"`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── §1.5 patch 目录清理 ──────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 兼职保证 patches/ 目录存在(scp 无 mkdir -p 能力),因此**清理协议先于 scp 执行**。
|
|
171
|
+
* 文件名是 manager 自造 [name] 字符集(无空格无引号),空格包裹匹配法安全。
|
|
172
|
+
*/
|
|
173
|
+
export function buildPatchCleanupScript({ keepNames = [] }) {
|
|
174
|
+
const keep = keepNames.map((n) => assertSafeName(n)).join(' ');
|
|
175
|
+
return [
|
|
176
|
+
`mkdir -p "${remotePath('patches')}" || { echo "ERR=mkdir"; exit 9; }`,
|
|
177
|
+
`cd "${remotePath('patches')}" || exit 9`,
|
|
178
|
+
`for f in *; do [ -e "$f" ] || continue; case " ${keep} " in *" $f "*) ;; *) rm -f -- "$f" ;; esac; done`,
|
|
179
|
+
'echo "CLEAN_DONE=yes"',
|
|
180
|
+
].join('; ');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── settings.yaml 固定路径读写协议 ───────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
const SETTINGS_TXN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
186
|
+
const SETTINGS_CHECKSUM_RE = /^cksum-v1:(0|[1-9][0-9]{0,9}):(0|[1-9][0-9]{0,6})$/;
|
|
187
|
+
|
|
188
|
+
function settingsTxnToken(txn) {
|
|
189
|
+
if (typeof txn !== 'string' || !SETTINGS_TXN_RE.test(txn)) {
|
|
190
|
+
throw new DshError('VALIDATION', `非法 settings 事务号:${JSON.stringify(txn)}`, {
|
|
191
|
+
detail: `事务号须匹配 ${SETTINGS_TXN_RE}`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return shq(txn);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function settingsBase(baseChecksum) {
|
|
198
|
+
if (baseChecksum === null) {
|
|
199
|
+
return { expect: shq('no'), crc: shq(''), size: shq('') };
|
|
200
|
+
}
|
|
201
|
+
if (typeof baseChecksum !== 'string') {
|
|
202
|
+
throw new DshError('VALIDATION', 'settings baseChecksum 必须是 cksum-v1 token 或 null');
|
|
203
|
+
}
|
|
204
|
+
const match = SETTINGS_CHECKSUM_RE.exec(baseChecksum);
|
|
205
|
+
if (!match || Number(match[1]) > 4_294_967_295 || Number(match[2]) > 524_288) {
|
|
206
|
+
throw new DshError('VALIDATION', `非法 settings checksum:${JSON.stringify(baseChecksum)}`, {
|
|
207
|
+
detail: 'checksum 须为 cksum-v1:<0..4294967295>:<0..524288>,且十进制数不得有前导零',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return { expect: shq('yes'), crc: shq(match[1]), size: shq(match[2]) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 固定读取 `${DSH_HOME:-$HOME/.dsh}/settings.yaml`。
|
|
215
|
+
* 内容经 POSIX od 输出 hex;txn 只派生管理目录内的短命快照名。
|
|
216
|
+
*/
|
|
217
|
+
export function buildSettingsReadScript({ txn } = {}) {
|
|
218
|
+
const txnTok = settingsTxnToken(txn);
|
|
219
|
+
return [
|
|
220
|
+
'LC_ALL=C',
|
|
221
|
+
'export LC_ALL',
|
|
222
|
+
'umask 077',
|
|
223
|
+
'set -f',
|
|
224
|
+
`T=${txnTok}`,
|
|
225
|
+
'H="${DSH_HOME:-$HOME/.dsh}"',
|
|
226
|
+
'P="$H/settings.yaml"',
|
|
227
|
+
'R="$HOME/.dsh_center_remote"',
|
|
228
|
+
'S="$R/settings-staging"',
|
|
229
|
+
'SNAP="$S/read-$T.data"',
|
|
230
|
+
'HEX_RAW="$S/read-$T.hex-raw"',
|
|
231
|
+
'HEX="$S/read-$T.hex"',
|
|
232
|
+
'unsupported() { echo "ERR=settings-unsupported"; exit 1; }',
|
|
233
|
+
'read_fail() { echo "ERR=settings-read"; exit 1; }',
|
|
234
|
+
'is_uint() { case "$1" in ""|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }',
|
|
235
|
+
'parse_cksum() { set -- $1; [ "$#" -eq 2 ] || return 1; is_uint "$1" || return 1; is_uint "$2" || return 1; CK_CRC=$1; CK_SIZE=$2; }',
|
|
236
|
+
'ensure_dir() { D=$1; if [ -L "$D" ]; then return 1; elif [ -e "$D" ]; then [ -d "$D" ] || return 1; else mkdir "$D" || return 1; fi; chmod 700 "$D" || return 1; }',
|
|
237
|
+
'cleanup_staging() { set +f; rm -f "$S"/read-*.data "$S"/read-*.hex "$S"/read-*.hex-raw "$S"/write-*.data; RC=$?; set -f; return "$RC"; }',
|
|
238
|
+
'cleanup_commit() { set +f; rm -f "$H"/.settings.yaml.dshc-*.tmp; RC=$?; set -f; return "$RC"; }',
|
|
239
|
+
'emit_path() { printf \'%s\' "$P" | od -A n -v -t x1 > "$HEX_RAW" 2>/dev/null || read_fail; tr -d \'[:space:]\' < "$HEX_RAW" > "$HEX" || read_fail; echo "PATH_HEX<<DSHC_PATH"; cat "$HEX" || { echo; echo "DSHC_PATH"; read_fail; }; echo; echo "DSHC_PATH"; }',
|
|
240
|
+
'echo "SETTINGS_PROTO=1"',
|
|
241
|
+
'printf \'SETTINGS_TXN=%s\\n\' "$T"',
|
|
242
|
+
'case "$HOME" in /*) ;; *) unsupported ;; esac',
|
|
243
|
+
'case "$H" in /*) ;; *) unsupported ;; esac',
|
|
244
|
+
'for C in command test printf mkdir chmod rm dd wc cksum od tr cat mv; do command -v "$C" >/dev/null 2>&1 || unsupported; done',
|
|
245
|
+
'OD_PROBE=$(printf \'\\001\\377\' | od -A n -v -t x1 2>/dev/null | tr -d \'[:space:]\') || unsupported',
|
|
246
|
+
'[ "$OD_PROBE" = 01ff ] || unsupported',
|
|
247
|
+
'CK_OUT=$(printf x | cksum 2>/dev/null) || unsupported',
|
|
248
|
+
'parse_cksum "$CK_OUT" || unsupported',
|
|
249
|
+
'[ "$CK_CRC" = 12738659 ] && [ "$CK_SIZE" = 1 ] || unsupported',
|
|
250
|
+
'ensure_dir "$R" || read_fail',
|
|
251
|
+
'ensure_dir "$S" || read_fail',
|
|
252
|
+
'cleanup_staging || read_fail',
|
|
253
|
+
'if [ -d "$H" ]; then cleanup_commit || read_fail; fi',
|
|
254
|
+
'trap \'rm -f "$SNAP" "$HEX_RAW" "$HEX"\' 0',
|
|
255
|
+
'trap \'read_fail\' 1 2 3 15',
|
|
256
|
+
'if [ -L "$P" ]; then read_fail; fi',
|
|
257
|
+
'if [ ! -e "$P" ]; then echo "EXISTS=no"; echo "SIZE=0"; emit_path; echo "CONTENT_HEX<<DSHC_CONTENT"; echo "DSHC_CONTENT"; echo "SETTINGS_READ_DONE=yes"; exit 0; fi',
|
|
258
|
+
'[ -f "$P" ] || read_fail',
|
|
259
|
+
'dd if="$P" of="$SNAP" bs=524289 count=1 2>/dev/null || read_fail',
|
|
260
|
+
'chmod 600 "$SNAP" || read_fail',
|
|
261
|
+
'SIZE_RAW=$(wc -c < "$SNAP" 2>/dev/null) || read_fail',
|
|
262
|
+
'SIZE=$(printf \'%s\' "$SIZE_RAW" | tr -d \'[:space:]\') || read_fail',
|
|
263
|
+
'is_uint "$SIZE" || read_fail',
|
|
264
|
+
'[ "$SIZE" -le 524288 ] || { echo "ERR=settings-too-large"; exit 10; }',
|
|
265
|
+
'CK_OUT=$(cksum < "$SNAP" 2>/dev/null) || read_fail',
|
|
266
|
+
'parse_cksum "$CK_OUT" || read_fail',
|
|
267
|
+
'[ "$CK_SIZE" = "$SIZE" ] || read_fail',
|
|
268
|
+
'echo "EXISTS=yes"',
|
|
269
|
+
'printf \'SIZE=%s\\n\' "$SIZE"',
|
|
270
|
+
'printf \'CRC=%s\\n\' "$CK_CRC"',
|
|
271
|
+
'emit_path',
|
|
272
|
+
'od -A n -v -t x1 "$SNAP" > "$HEX_RAW" 2>/dev/null || read_fail',
|
|
273
|
+
'tr -d \'[:space:]\' < "$HEX_RAW" > "$HEX" || read_fail',
|
|
274
|
+
'HEX_SIZE_RAW=$(wc -c < "$HEX" 2>/dev/null) || read_fail',
|
|
275
|
+
'HEX_SIZE=$(printf \'%s\' "$HEX_SIZE_RAW" | tr -d \'[:space:]\') || read_fail',
|
|
276
|
+
'[ "$HEX_SIZE" -eq "$((SIZE * 2))" ] || read_fail',
|
|
277
|
+
'echo "CONTENT_HEX<<DSHC_CONTENT"',
|
|
278
|
+
'cat "$HEX" || { echo; echo "DSHC_CONTENT"; read_fail; }',
|
|
279
|
+
'echo',
|
|
280
|
+
'echo "DSHC_CONTENT"',
|
|
281
|
+
'echo "SETTINGS_READ_DONE=yes"',
|
|
282
|
+
].join('; ');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 从 stdin 接收新内容并以 cksum-v1 双复核 CAS 提交到固定 settings 路径。
|
|
287
|
+
* baseChecksum=null 表示只允许创建;string 表示只允许替换逐字相同的基线 token。
|
|
288
|
+
*/
|
|
289
|
+
export function buildSettingsWriteScript({ txn, baseChecksum } = {}) {
|
|
290
|
+
const txnTok = settingsTxnToken(txn);
|
|
291
|
+
const base = settingsBase(baseChecksum);
|
|
292
|
+
return [
|
|
293
|
+
'LC_ALL=C',
|
|
294
|
+
'export LC_ALL',
|
|
295
|
+
'umask 077',
|
|
296
|
+
'set -f',
|
|
297
|
+
`T=${txnTok}`,
|
|
298
|
+
`EXPECT=${base.expect}`,
|
|
299
|
+
`BASE_CRC=${base.crc}`,
|
|
300
|
+
`BASE_SIZE=${base.size}`,
|
|
301
|
+
'H="${DSH_HOME:-$HOME/.dsh}"',
|
|
302
|
+
'P="$H/settings.yaml"',
|
|
303
|
+
'R="$HOME/.dsh_center_remote"',
|
|
304
|
+
'S="$R/settings-staging"',
|
|
305
|
+
'B="$R/settings-backup"',
|
|
306
|
+
'STAGE="$S/write-$T.data"',
|
|
307
|
+
'TEMP="$H/.settings.yaml.dshc-$T.tmp"',
|
|
308
|
+
'PREV="$B/previous.yaml"',
|
|
309
|
+
'ABSENT="$B/previous.absent"',
|
|
310
|
+
'BTMP="$B/previous-$T.tmp"',
|
|
311
|
+
'ATMP="$B/absent-$T.tmp"',
|
|
312
|
+
'COMMITTED=no',
|
|
313
|
+
'commit_state() { if [ "$COMMITTED" = no ]; then echo "COMMIT_STATE=not-committed"; else echo "COMMIT_STATE=unknown"; fi; }',
|
|
314
|
+
'unsupported() { echo "ERR=settings-unsupported"; exit 1; }',
|
|
315
|
+
'write_fail() { echo "ERR=settings-write"; commit_state; exit 12; }',
|
|
316
|
+
'stale() { echo "ERR=settings-stale"; commit_state; exit 11; }',
|
|
317
|
+
'too_large() { echo "ERR=settings-too-large"; commit_state; exit 10; }',
|
|
318
|
+
'is_uint() { case "$1" in ""|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }',
|
|
319
|
+
'parse_cksum() { set -- $1; [ "$#" -eq 2 ] || return 1; is_uint "$1" || return 1; is_uint "$2" || return 1; CK_CRC=$1; CK_SIZE=$2; }',
|
|
320
|
+
'ensure_dir() { D=$1; if [ -L "$D" ]; then return 1; elif [ -e "$D" ]; then [ -d "$D" ] || return 1; else mkdir "$D" || return 1; fi; chmod 700 "$D" || return 1; }',
|
|
321
|
+
'cleanup_managed() { set +f; rm -f "$S"/read-*.data "$S"/read-*.hex "$S"/read-*.hex-raw "$S"/write-*.data "$B"/previous-*.tmp "$B"/absent-*.tmp; RC=$?; set -f; return "$RC"; }',
|
|
322
|
+
'cleanup_commit() { set +f; rm -f "$H"/.settings.yaml.dshc-*.tmp; RC=$?; set -f; return "$RC"; }',
|
|
323
|
+
'emit_path() { echo "PATH_HEX<<DSHC_PATH"; if printf \'%s\' "$P" | od -A n -v -t x1; then :; else echo "DSHC_PATH"; write_fail; fi; echo "DSHC_PATH"; }',
|
|
324
|
+
'read_current() { CUR_EXISTS=no; CUR_CRC=; CUR_SIZE=; if [ -L "$P" ]; then return 2; fi; if [ ! -e "$P" ]; then return 0; fi; [ -f "$P" ] || return 2; CUR_RAW=$(wc -c < "$P" 2>/dev/null) || return 3; CUR_SIZE=$(printf \'%s\' "$CUR_RAW" | tr -d \'[:space:]\') || return 3; is_uint "$CUR_SIZE" || return 3; [ "$CUR_SIZE" -le 524288 ] || return 4; CUR_OUT=$(cksum < "$P" 2>/dev/null) || return 3; parse_cksum "$CUR_OUT" || return 3; [ "$CK_SIZE" = "$CUR_SIZE" ] || return 5; CUR_EXISTS=yes; CUR_CRC=$CK_CRC; CUR_SIZE=$CK_SIZE; return 0; }',
|
|
325
|
+
'load_current() { read_current; RC=$?; case "$RC" in 0) return 0 ;; 4) too_large ;; 5) stale ;; *) write_fail ;; esac; }',
|
|
326
|
+
'match_base() { if [ "$EXPECT" = no ]; then [ "$CUR_EXISTS" = no ]; else [ "$CUR_EXISTS" = yes ] && [ "$CUR_CRC" = "$BASE_CRC" ] && [ "$CUR_SIZE" = "$BASE_SIZE" ]; fi; }',
|
|
327
|
+
'echo "SETTINGS_PROTO=1"',
|
|
328
|
+
'printf \'SETTINGS_TXN=%s\\n\' "$T"',
|
|
329
|
+
'case "$HOME" in /*) ;; *) unsupported ;; esac',
|
|
330
|
+
'case "$H" in /*) ;; *) unsupported ;; esac',
|
|
331
|
+
'for C in command test printf mkdir chmod rm dd wc cksum od tr cat mv; do command -v "$C" >/dev/null 2>&1 || unsupported; done',
|
|
332
|
+
'OD_PROBE=$(printf \'\\001\\377\' | od -A n -v -t x1 2>/dev/null | tr -d \'[:space:]\') || unsupported',
|
|
333
|
+
'[ "$OD_PROBE" = 01ff ] || unsupported',
|
|
334
|
+
'CK_OUT=$(printf x | cksum 2>/dev/null) || unsupported',
|
|
335
|
+
'parse_cksum "$CK_OUT" || unsupported',
|
|
336
|
+
'[ "$CK_CRC" = 12738659 ] && [ "$CK_SIZE" = 1 ] || unsupported',
|
|
337
|
+
'ensure_dir "$R" || write_fail',
|
|
338
|
+
'ensure_dir "$S" || write_fail',
|
|
339
|
+
'ensure_dir "$B" || write_fail',
|
|
340
|
+
'for F in "$PREV" "$ABSENT"; do if [ -L "$F" ]; then write_fail; fi; if [ -e "$F" ] && [ ! -f "$F" ]; then write_fail; fi; done',
|
|
341
|
+
'cleanup_managed || write_fail',
|
|
342
|
+
'trap \'rm -f "$STAGE" "$TEMP" "$BTMP" "$ATMP"\' 0',
|
|
343
|
+
'trap \'write_fail\' 1 2 3 15',
|
|
344
|
+
'cat > "$STAGE" || write_fail',
|
|
345
|
+
'chmod 600 "$STAGE" || write_fail',
|
|
346
|
+
'NEW_RAW=$(wc -c < "$STAGE" 2>/dev/null) || write_fail',
|
|
347
|
+
'NEW_SIZE=$(printf \'%s\' "$NEW_RAW" | tr -d \'[:space:]\') || write_fail',
|
|
348
|
+
'is_uint "$NEW_SIZE" || write_fail',
|
|
349
|
+
'[ "$NEW_SIZE" -le 524288 ] || too_large',
|
|
350
|
+
'NEW_OUT=$(cksum < "$STAGE" 2>/dev/null) || write_fail',
|
|
351
|
+
'parse_cksum "$NEW_OUT" || write_fail',
|
|
352
|
+
'[ "$CK_SIZE" = "$NEW_SIZE" ] || write_fail',
|
|
353
|
+
'NEW_CRC=$CK_CRC',
|
|
354
|
+
'NEW_SIZE=$CK_SIZE',
|
|
355
|
+
'load_current',
|
|
356
|
+
'match_base || stale',
|
|
357
|
+
'[ -d "$H" ] || write_fail',
|
|
358
|
+
'cd "$H" || write_fail',
|
|
359
|
+
'cleanup_commit || write_fail',
|
|
360
|
+
'cat "$STAGE" > "$TEMP" || write_fail',
|
|
361
|
+
'chmod 600 "$TEMP" || write_fail',
|
|
362
|
+
'TEMP_OUT=$(cksum < "$TEMP" 2>/dev/null) || write_fail',
|
|
363
|
+
'parse_cksum "$TEMP_OUT" || write_fail',
|
|
364
|
+
'[ "$CK_CRC" = "$NEW_CRC" ] && [ "$CK_SIZE" = "$NEW_SIZE" ] || write_fail',
|
|
365
|
+
'if [ "$CUR_EXISTS" = yes ]; then if [ -L "$P" ] || [ ! -f "$P" ]; then write_fail; fi; cat "$P" > "$BTMP" || write_fail; chmod 600 "$BTMP" || write_fail; BACK_OUT=$(cksum < "$BTMP" 2>/dev/null) || write_fail; parse_cksum "$BACK_OUT" || write_fail; [ "$CK_CRC" = "$BASE_CRC" ] && [ "$CK_SIZE" = "$BASE_SIZE" ] || stale; mv -f "$BTMP" "$PREV" || write_fail; rm -f "$ABSENT" || write_fail; else : > "$ATMP" || write_fail; chmod 600 "$ATMP" || write_fail; mv -f "$ATMP" "$ABSENT" || write_fail; rm -f "$PREV" || write_fail; fi',
|
|
366
|
+
'load_current',
|
|
367
|
+
'match_base || stale',
|
|
368
|
+
'COMMITTED=unknown',
|
|
369
|
+
'mv -f "$TEMP" "$P" || write_fail',
|
|
370
|
+
'COMMITTED=yes',
|
|
371
|
+
'load_current',
|
|
372
|
+
'[ "$CUR_EXISTS" = yes ] || write_fail',
|
|
373
|
+
'[ "$CUR_CRC" = "$NEW_CRC" ] && [ "$CUR_SIZE" = "$NEW_SIZE" ] || stale',
|
|
374
|
+
'emit_path',
|
|
375
|
+
'printf \'NEW_SIZE=%s\\n\' "$NEW_SIZE"',
|
|
376
|
+
'printf \'NEW_CRC=%s\\n\' "$NEW_CRC"',
|
|
377
|
+
'echo "SETTINGS_WRITE_DONE=yes"',
|
|
378
|
+
].join('; ');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── §7 协议输出解析器 ────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
const KV_RE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
|
|
384
|
+
const BLOCK_OPEN_RE = /^([A-Z][A-Z0-9_]*)<<([A-Za-z_]+)$/;
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @typedef {{kv: Record<string, string[]>, blocks: Record<string, string>, stray: string[]}} ProtoOutput
|
|
388
|
+
* @param {string} stdout
|
|
389
|
+
* @param {{requireDone?:string}} [opts] 如 'PROBE_DONE'——缺哨兵抛 PROTO_PARSE
|
|
390
|
+
* @returns {ProtoOutput}
|
|
391
|
+
*/
|
|
392
|
+
export function parseProtoOutput(stdout, { requireDone } = {}) {
|
|
393
|
+
const text = String(stdout ?? '').replace(/\r/g, '');
|
|
394
|
+
const lines = text.split('\n');
|
|
395
|
+
|
|
396
|
+
/** @type {Record<string,string[]>} */
|
|
397
|
+
const kv = {};
|
|
398
|
+
/** @type {Record<string,string>} */
|
|
399
|
+
const blocks = {};
|
|
400
|
+
const stray = [];
|
|
401
|
+
|
|
402
|
+
let blockKey = null;
|
|
403
|
+
let blockDelim = null;
|
|
404
|
+
let blockLines = [];
|
|
405
|
+
|
|
406
|
+
for (const line of lines) {
|
|
407
|
+
if (blockKey !== null) {
|
|
408
|
+
if (line === blockDelim) {
|
|
409
|
+
blocks[blockKey] = blockLines.join('\n');
|
|
410
|
+
blockKey = null;
|
|
411
|
+
blockDelim = null;
|
|
412
|
+
blockLines = [];
|
|
413
|
+
} else {
|
|
414
|
+
blockLines.push(line);
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (line === '') continue;
|
|
420
|
+
|
|
421
|
+
const open = BLOCK_OPEN_RE.exec(line);
|
|
422
|
+
if (open) {
|
|
423
|
+
blockKey = open[1];
|
|
424
|
+
blockDelim = open[2];
|
|
425
|
+
blockLines = [];
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const pair = KV_RE.exec(line);
|
|
430
|
+
if (pair) {
|
|
431
|
+
(kv[pair[1]] ??= []).push(pair[2]);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
stray.push(line);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (blockKey !== null) {
|
|
439
|
+
throw new DshError('PROTO_PARSE', `远端输出的 ${blockKey} 块未闭合(缺 ${blockDelim} 界符行)`, {
|
|
440
|
+
detail: text,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (requireDone && kv[requireDone]?.[0] !== 'yes') {
|
|
445
|
+
throw new DshError('PROTO_PARSE', `远端输出缺少 ${requireDone} 哨兵(输出被截断或连接中断)`, {
|
|
446
|
+
detail: text,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return { kv, blocks, stray };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 取单值(重复键取最后一个,缺失返回 fallback)。 */
|
|
454
|
+
export function kvOne(out, key, fallback = null) {
|
|
455
|
+
const arr = out.kv[key];
|
|
456
|
+
return arr && arr.length > 0 ? arr[arr.length - 1] : fallback;
|
|
457
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SemVer 解析与比较(含 pre-release 优先级)—— 纯内核,零 import。
|
|
3
|
+
*
|
|
4
|
+
* 自己写而不抄一份三方实现,是因为零依赖底线;只写更新逻辑真正需要的部分:
|
|
5
|
+
* 解析、比较、从一堆版本里挑最新。唯一不能凭直觉写对的是 pre-release 的优先级
|
|
6
|
+
* (数字段按数值比、字母段按字典序、数字段永远小于字母段、字段少的更小、
|
|
7
|
+
* 正式版大于同核心号的任何预发布),所以每条规则都在 tests/lib/semver.test.js
|
|
8
|
+
* 里有对应用例。build 元数据(`+xxx`)按规范不参与比较。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SHAPE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
|
12
|
+
|
|
13
|
+
/** 数字段不许有前导零——否则 `1.0.01` 与 `1.0.1` 会各自成立,比较结果说不清。 */
|
|
14
|
+
const hasLeadingZero = (id) => /^\d+$/.test(id) && id.length > 1 && id.startsWith('0');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {unknown} input 形如 `0.2.0` / `v0.2.0-rc.1` / `1.0.0+build.5`
|
|
18
|
+
* @returns {{major:number, minor:number, patch:number, prerelease:readonly string[],
|
|
19
|
+
* build:string|null, version:string}|null} 形状不对给 null(调用方自己决定怎么报错)
|
|
20
|
+
*/
|
|
21
|
+
export function parseVersion(input) {
|
|
22
|
+
const raw = String(input ?? '').trim();
|
|
23
|
+
const m = SHAPE.exec(raw);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
|
|
26
|
+
const [, major, minor, patch, pre, build] = m;
|
|
27
|
+
if ([major, minor, patch].some(hasLeadingZero)) return null;
|
|
28
|
+
|
|
29
|
+
const prerelease = pre === undefined ? [] : pre.split('.');
|
|
30
|
+
if (prerelease.some((id) => id === '' || hasLeadingZero(id))) return null;
|
|
31
|
+
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
major: Number(major),
|
|
34
|
+
minor: Number(minor),
|
|
35
|
+
patch: Number(patch),
|
|
36
|
+
prerelease: Object.freeze(prerelease),
|
|
37
|
+
build: build ?? null,
|
|
38
|
+
// 规整形态(去掉 v 前缀与 build 元数据),比较与展示都用它
|
|
39
|
+
version: `${Number(major)}.${Number(minor)}.${Number(patch)}${pre ? `-${pre}` : ''}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @returns {boolean} 合法且带 pre-release 后缀 */
|
|
44
|
+
export function isPrerelease(input) {
|
|
45
|
+
const parsed = parseVersion(input);
|
|
46
|
+
return Boolean(parsed) && parsed.prerelease.length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareIdentifiers(a, b) {
|
|
50
|
+
const aNum = /^\d+$/.test(a);
|
|
51
|
+
const bNum = /^\d+$/.test(b);
|
|
52
|
+
if (aNum && bNum) return Math.sign(Number(a) - Number(b));
|
|
53
|
+
if (aNum !== bNum) return aNum ? -1 : 1; // 数字段永远小于字母段
|
|
54
|
+
if (a === b) return 0;
|
|
55
|
+
return a < b ? -1 : 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} a
|
|
60
|
+
* @param {string} b
|
|
61
|
+
* @returns {-1|0|1}
|
|
62
|
+
* @throws {TypeError} 任一侧不是合法版本号
|
|
63
|
+
*/
|
|
64
|
+
export function compareVersions(a, b) {
|
|
65
|
+
const pa = parseVersion(a);
|
|
66
|
+
const pb = parseVersion(b);
|
|
67
|
+
if (!pa || !pb) throw new TypeError(`不是合法版本号:${pa ? b : a}`);
|
|
68
|
+
|
|
69
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
70
|
+
if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const na = pa.prerelease.length;
|
|
74
|
+
const nb = pb.prerelease.length;
|
|
75
|
+
if (na === 0 && nb === 0) return 0;
|
|
76
|
+
if (na === 0 || nb === 0) return na === 0 ? 1 : -1; // 正式版 > 预发布
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < Math.max(na, nb); i += 1) {
|
|
79
|
+
const x = pa.prerelease[i];
|
|
80
|
+
const y = pb.prerelease[i];
|
|
81
|
+
if (x === undefined || y === undefined) return x === undefined ? -1 : 1; // 字段少的更小
|
|
82
|
+
const verdict = compareIdentifiers(x, y);
|
|
83
|
+
if (verdict !== 0) return verdict;
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 从候选里挑最新的一个。非法版本号一律忽略(Release 页面上难免有杂项 tag)。
|
|
90
|
+
* @param {Iterable<string>} versions
|
|
91
|
+
* @param {{includePrerelease?:boolean}} [opts] 默认只看正式版——「稳定用户不该被动吃到 rc」
|
|
92
|
+
* @returns {string|null} 原样返回入选的那个字符串;无可用候选给 null
|
|
93
|
+
*/
|
|
94
|
+
export function pickLatest(versions, { includePrerelease = false } = {}) {
|
|
95
|
+
let best = null;
|
|
96
|
+
for (const candidate of versions) {
|
|
97
|
+
const parsed = parseVersion(candidate);
|
|
98
|
+
if (!parsed) continue;
|
|
99
|
+
if (!includePrerelease && parsed.prerelease.length > 0) continue;
|
|
100
|
+
if (best === null || compareVersions(candidate, best) > 0) best = candidate;
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
package/src/lib/shq.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX 单引号转义与命名校验(12 §2)。
|
|
3
|
+
*
|
|
4
|
+
* 转义防不了的两件事(12 §2.4,设计边界):
|
|
5
|
+
* 1. 参数语义注入:extraArgs 里的 '--port 9999' 转义后仍是合法参数,会与 manager 的
|
|
6
|
+
* --port 冲突。不禁止(用户自担),launcher 拼装时发 warn;actualPort 以日志解析为准。
|
|
7
|
+
* 2. ssh 参数位注入:Host 名以 '-' 开头会被 ssh 当选项。由 assertSafeHost 统一把关。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { DshError } from './errors.js';
|
|
11
|
+
|
|
12
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
|
+
const SAFE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
14
|
+
const SAFE_HOST_RE = /^[A-Za-z0-9._-]+$/;
|
|
15
|
+
const INT_RE = /^[1-9][0-9]{0,9}$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 任意字符串 → 可作为单个 shell 单词安全嵌入。
|
|
19
|
+
* 算法:两端加 ',内部每个 ' 替换为 '\''(关引-转义引-开引)。
|
|
20
|
+
* @param {string} s 任意 JS 字符串(含空串、换行、任意 Unicode)
|
|
21
|
+
* @returns {string} 保证经任何 POSIX sh 解析后逐字还原 s
|
|
22
|
+
*/
|
|
23
|
+
export function shq(s) {
|
|
24
|
+
if (typeof s !== 'string') throw new DshError('VALIDATION', `shq 只接受字符串,收到 ${typeof s}`);
|
|
25
|
+
return `'${s.split("'").join("'\\''")}'`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* env 键白名单校验。键不做转义——转义救不了不合法键,因为 `env K='v'` 的 `K=` 段在引号外。
|
|
30
|
+
* @throws {DshError} VALIDATION
|
|
31
|
+
*/
|
|
32
|
+
export function assertEnvKey(k) {
|
|
33
|
+
if (typeof k !== 'string' || !ENV_KEY_RE.test(k)) {
|
|
34
|
+
throw new DshError('VALIDATION', `非法环境变量名:${JSON.stringify(k)}`, {
|
|
35
|
+
detail: `环境变量名须匹配 ${ENV_KEY_RE}`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return k;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** manager 自造文件名校验(logName / patch 远端名)。 */
|
|
42
|
+
export function assertSafeName(n) {
|
|
43
|
+
if (typeof n !== 'string' || !SAFE_NAME_RE.test(n) || n.startsWith('-') || n.startsWith('.')) {
|
|
44
|
+
throw new DshError('VALIDATION', `非法远端文件名:${JSON.stringify(n)}`, {
|
|
45
|
+
detail: `文件名须匹配 ${SAFE_NAME_RE} 且不以 - 或 . 开头`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return n;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** ssh Host 名校验(12 §2.4 第 2 条)。 */
|
|
52
|
+
export function assertSafeHost(h) {
|
|
53
|
+
if (typeof h !== 'string' || !SAFE_HOST_RE.test(h) || h.startsWith('-')) {
|
|
54
|
+
throw new DshError('VALIDATION', `主机名不适合作为 ssh 参数:${JSON.stringify(h)}`, {
|
|
55
|
+
detail: `主机名须匹配 ${SAFE_HOST_RE} 且不以 - 开头(改 ~/.ssh/config 里的 Host 名)`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return h;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 远端启动目录(工作区根)的形态判定。
|
|
63
|
+
* 只认绝对路径与 `~` 前缀:相对路径「相对于什么」在 sshd 侧无从定义,拦在此处比
|
|
64
|
+
* 让用户对着 `ERR=workdir` 猜要省心。其余字符 shq 都能安全引用,故不再多设字符集限制。
|
|
65
|
+
*/
|
|
66
|
+
export function isWorkdirPath(p) {
|
|
67
|
+
if (typeof p !== 'string' || p.length === 0) return false;
|
|
68
|
+
if (/[\0\n\r]/.test(p)) return false;
|
|
69
|
+
return p.startsWith('/') || p === '~' || p.startsWith('~/');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @throws {DshError} VALIDATION */
|
|
73
|
+
export function assertWorkdir(p) {
|
|
74
|
+
if (!isWorkdirPath(p)) {
|
|
75
|
+
throw new DshError('VALIDATION', `非法远端启动目录:${JSON.stringify(p)}`, {
|
|
76
|
+
detail: '须为绝对路径(/ 开头)或 ~、~/… 形态;不接受相对路径、空串与含换行/NUL 的值',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return p;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* workdir → 可嵌入 `cd -- <TOK>` 的单个 shell 词。
|
|
84
|
+
* `~` 不能进单引号(引号内不展开),改用 `"$HOME"` 与 shq 段相邻拼接——
|
|
85
|
+
* POSIX 相邻字符串自然连成一个词,展开永远发生在远端。
|
|
86
|
+
*/
|
|
87
|
+
export function workdirToken(p) {
|
|
88
|
+
assertWorkdir(p);
|
|
89
|
+
if (p === '~') return '"$HOME"';
|
|
90
|
+
if (p.startsWith('~/')) return `"$HOME"${shq(p.slice(1))}`;
|
|
91
|
+
return shq(p);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 整数校验后原样拼接(12 §1 的 [int] 标注)。数字不需转义,校验杜绝一切非数字。
|
|
96
|
+
* @param {number|string} v
|
|
97
|
+
* @param {{min?:number, max?:number, allowZero?:boolean}} [opts]
|
|
98
|
+
*/
|
|
99
|
+
export function assertInt(v, { min = 1, max = 65535, allowZero = false } = {}) {
|
|
100
|
+
const s = String(v);
|
|
101
|
+
if (allowZero && s === '0') return '0';
|
|
102
|
+
if (!INT_RE.test(s)) {
|
|
103
|
+
throw new DshError('VALIDATION', `期望整数,收到 ${JSON.stringify(v)}`);
|
|
104
|
+
}
|
|
105
|
+
const n = Number(s);
|
|
106
|
+
if (n < min || n > max) {
|
|
107
|
+
throw new DshError('VALIDATION', `整数越界:${n} 不在 ${min}..${max}`);
|
|
108
|
+
}
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export { ENV_KEY_RE, SAFE_NAME_RE, SAFE_HOST_RE };
|