@mrrisega/dsh-remote 0.6.11 → 0.6.13
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/clients/dsh-remote/dsh-bridge.mjs +123 -2
- package/clients/dsh-remote/mobile-adapter.mjs +108 -4
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/dsh-setup.mjs +55 -15
- package/package.json +4 -4
- package/packages/dsh-remote-web/lib/client.js +137 -0
- package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
- package/packages/dsh-remote-web/lib/index.js +748 -18
- package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
- package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
- package/packages/dsh-remote-web/package.json +5 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
- package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 零依赖 tarball 更新通道的回归测试(node:test,零框架)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(真实事故,见 lib/tarball-update.js 顶部注释):
|
|
5
|
+
* Windows 上「一键更新」走的 `npx` 在 Node ≥20.12 起同步 EINVAL,0.6.6 及更早的升级通道
|
|
6
|
+
* 本身是坏的 —— 79 台点过一键更新的 Windows 机器全部 update_failed,且产品内无出路。
|
|
7
|
+
* 本文件就是给替代通道(HTTPS 取 tarball → 内置 zlib + 自解析 tar 落 staging)上锁:
|
|
8
|
+
* 解包正确性、长路径、**路径穿越必须被拒**、链接条目必须跳过、registry URL 编码、
|
|
9
|
+
* 以及 stagePackage 端到端(主包 + ws 依赖都要落地)。
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ 绝不联网:仓库用 scripts/test-net-guard.cjs 拦外部请求(见 package.json 的 test 脚本),
|
|
12
|
+
* 所以这里所有 HTTP 都发生在**注入的假 fetchImpl** 上:registry 元数据与 tarball 都由
|
|
13
|
+
* 本文件用 zlib.gzipSync + 手写最小 tar 打包函数现场构造。
|
|
14
|
+
*/
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import zlib from "node:zlib";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_REGISTRY,
|
|
25
|
+
extractTarGz,
|
|
26
|
+
parseSpec,
|
|
27
|
+
readTarEntries,
|
|
28
|
+
resolveVersion,
|
|
29
|
+
downloadTarball,
|
|
30
|
+
stagePackage,
|
|
31
|
+
} from "../lib/tarball-update.js";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// 最小 tar 打包器(测试自备,避免为了造测试数据引第三方包)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const BLOCK = 512;
|
|
38
|
+
|
|
39
|
+
function octal(value, len) {
|
|
40
|
+
return value.toString(8).padStart(len - 1, "0") + "\0";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function headerBlock({ name = "", size = 0, type = "0", prefix = "", linkname = "" }) {
|
|
44
|
+
const b = Buffer.alloc(BLOCK);
|
|
45
|
+
b.write(name.slice(0, 100), 0, 100, "latin1");
|
|
46
|
+
b.write(octal(0o644, 8), 100, 8, "latin1"); // mode
|
|
47
|
+
b.write(octal(0, 8), 108, 8, "latin1"); // uid
|
|
48
|
+
b.write(octal(0, 8), 116, 8, "latin1"); // gid
|
|
49
|
+
b.write(octal(size, 12), 124, 12, "latin1"); // size
|
|
50
|
+
b.write(octal(0, 12), 136, 12, "latin1"); // mtime
|
|
51
|
+
b.fill(0x20, 148, 156); // chksum 先填空格
|
|
52
|
+
b.write(type, 156, 1, "latin1");
|
|
53
|
+
if (linkname) b.write(linkname.slice(0, 100), 157, 100, "latin1");
|
|
54
|
+
b.write("ustar", 257, 5, "latin1"); // magic "ustar\0"
|
|
55
|
+
b.write("00", 263, 2, "latin1"); // version
|
|
56
|
+
if (prefix) b.write(prefix.slice(0, 155), 345, 155, "latin1");
|
|
57
|
+
let sum = 0;
|
|
58
|
+
for (const byte of b) sum += byte;
|
|
59
|
+
b.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8, "latin1"); // 6 位八进制 + NUL + 空格
|
|
60
|
+
return b;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function padToBlock(buf) {
|
|
64
|
+
const rem = buf.length % BLOCK;
|
|
65
|
+
return rem ? Buffer.concat([buf, Buffer.alloc(BLOCK - rem)]) : buf;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** PaxHeader 记录:`LEN key=value\n`,LEN 含自身位数(迭代到自洽)。 */
|
|
69
|
+
function paxRecord(key, value) {
|
|
70
|
+
const body = ` ${key}=${value}\n`;
|
|
71
|
+
let len = body.length + 1;
|
|
72
|
+
while (String(len).length + body.length !== len) len = String(len).length + body.length;
|
|
73
|
+
return `${len}${body}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 打包成裸 tar。
|
|
78
|
+
* 条目:{ name, data?, type?, prefix?, longName?, paxPath? }
|
|
79
|
+
* · prefix → ustar 的 prefix 字段(长路径写法之一)
|
|
80
|
+
* · longName → GNU long name(typeflag L)
|
|
81
|
+
* · paxPath → PaxHeader(typeflag x,path= 记录)
|
|
82
|
+
*/
|
|
83
|
+
function makeTar(entries) {
|
|
84
|
+
const parts = [];
|
|
85
|
+
for (const e of entries) {
|
|
86
|
+
const data = e.data === undefined ? Buffer.alloc(0) : Buffer.from(e.data);
|
|
87
|
+
const size = e.size === undefined ? data.length : e.size;
|
|
88
|
+
const type = e.type || "0";
|
|
89
|
+
|
|
90
|
+
if (e.longName) {
|
|
91
|
+
const nameBuf = Buffer.from(`${e.longName}\0`, "utf8");
|
|
92
|
+
parts.push(headerBlock({ name: "././@LongLink", size: nameBuf.length, type: "L" }), padToBlock(nameBuf));
|
|
93
|
+
parts.push(headerBlock({ name: e.name, size, type }));
|
|
94
|
+
} else if (e.paxPath) {
|
|
95
|
+
const rec = Buffer.from(paxRecord("path", e.paxPath), "utf8");
|
|
96
|
+
parts.push(headerBlock({ name: "PaxHeader/entry", size: rec.length, type: "x" }), padToBlock(rec));
|
|
97
|
+
parts.push(headerBlock({ name: e.name, size, type }));
|
|
98
|
+
} else {
|
|
99
|
+
parts.push(headerBlock({ name: e.name, size, type, prefix: e.prefix || "", linkname: e.linkname || "" }));
|
|
100
|
+
}
|
|
101
|
+
if (size) parts.push(padToBlock(data));
|
|
102
|
+
}
|
|
103
|
+
parts.push(Buffer.alloc(BLOCK * 2)); // 结束标记
|
|
104
|
+
return Buffer.concat(parts);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const makeTgz = (entries) => zlib.gzipSync(makeTar(entries));
|
|
108
|
+
|
|
109
|
+
async function tmpDir(t) {
|
|
110
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "dsh-tarball-"));
|
|
111
|
+
t.after(() => rm(dir, { recursive: true, force: true }));
|
|
112
|
+
return dir;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 假 fetch:routes 的 key 是完整 URL;值是 { json } 或 { tgz } 或 { status }。 */
|
|
116
|
+
function makeFetch(routes, log = []) {
|
|
117
|
+
return async (url) => {
|
|
118
|
+
log.push(String(url));
|
|
119
|
+
const r = routes[String(url)];
|
|
120
|
+
if (!r) return new Response("not found", { status: 404 });
|
|
121
|
+
if (r.status && r.status !== 200) return new Response(r.body || "error", { status: r.status });
|
|
122
|
+
if (r.json) {
|
|
123
|
+
return new Response(JSON.stringify(r.json), { status: 200, headers: { "content-type": "application/json" } });
|
|
124
|
+
}
|
|
125
|
+
return new Response(r.tgz, { status: 200, headers: { "content-type": "application/octet-stream" } });
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// 解包
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
test("解包:目录/文件/嵌套路径 + stripPrefix 生效 + 返回文件数", async (t) => {
|
|
134
|
+
const root = await tmpDir(t);
|
|
135
|
+
const dest = path.join(root, "staging");
|
|
136
|
+
const tgz = makeTgz([
|
|
137
|
+
{ name: "package/", type: "5" },
|
|
138
|
+
{ name: "package/README.md", data: "# hello" },
|
|
139
|
+
{ name: "package/lib/", type: "5" },
|
|
140
|
+
{ name: "package/lib/a.js", data: "export const a = 1;\n" },
|
|
141
|
+
{ name: "package/lib/nested/deep/b.mjs", data: "export const b = 2;\n" },
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const files = extractTarGz(tgz, dest);
|
|
145
|
+
assert.equal(files, 3, "返回写出的普通文件数(目录不计)");
|
|
146
|
+
assert.equal(await readFile(path.join(dest, "README.md"), "utf8"), "# hello");
|
|
147
|
+
assert.equal(await readFile(path.join(dest, "lib", "a.js"), "utf8"), "export const a = 1;\n");
|
|
148
|
+
assert.equal(await readFile(path.join(dest, "lib", "nested", "deep", "b.mjs"), "utf8"), "export const b = 2;\n");
|
|
149
|
+
assert.ok(!existsSync(path.join(dest, "package")), "默认 stripPrefix='package' 必须把顶层目录去掉");
|
|
150
|
+
|
|
151
|
+
// 诊断用 readTarEntries:条目名是原始(未 strip)的
|
|
152
|
+
const listed = readTarEntries(tgz);
|
|
153
|
+
assert.deepEqual(listed.find((e) => e.name === "package/lib/a.js"), { name: "package/lib/a.js", size: 20, type: "0" });
|
|
154
|
+
assert.equal(listed.filter((e) => e.type === "5").length, 2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("解包:stripPrefix: '' 保留顶层目录", async (t) => {
|
|
158
|
+
const root = await tmpDir(t);
|
|
159
|
+
const dest = path.join(root, "keep");
|
|
160
|
+
const files = extractTarGz(makeTgz([{ name: "package/keep.txt", data: "keep" }]), dest, { stripPrefix: "" });
|
|
161
|
+
assert.equal(files, 1);
|
|
162
|
+
assert.equal(await readFile(path.join(dest, "package", "keep.txt"), "utf8"), "keep");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("解包:超长路径(>100 字符)走 ustar prefix 与 GNU long name 都能正确落盘", async (t) => {
|
|
166
|
+
const root = await tmpDir(t);
|
|
167
|
+
const dest = path.join(root, "long");
|
|
168
|
+
|
|
169
|
+
// ① ustar prefix:prefix + "/" + name 合起来远超 100 字符
|
|
170
|
+
const longDir = "a".repeat(90);
|
|
171
|
+
const fileName = `${"b".repeat(60)}.js`;
|
|
172
|
+
const fullName = `package/${longDir}/${fileName}`;
|
|
173
|
+
assert.ok(fullName.length > 100, "构造的路径必须超过 100 字符才叫长路径");
|
|
174
|
+
const viaPrefix = makeTgz([{ name: fileName, prefix: `package/${longDir}` }].map((e) => ({ ...e, data: "via-prefix" })));
|
|
175
|
+
|
|
176
|
+
// ② GNU long name(typeflag L)
|
|
177
|
+
const segs = Array.from({ length: 12 }, (_, i) => `seg${String(i).padStart(2, "0")}${"x".repeat(6)}`);
|
|
178
|
+
const gnuName = `package/${segs.join("/")}/long.txt`;
|
|
179
|
+
assert.ok(gnuName.length > 100, "GNU long name 用例的路径必须超过 100 字符");
|
|
180
|
+
const viaGnu = makeTgz([
|
|
181
|
+
{ longName: gnuName, name: `${gnuName.slice(0, 90)} (truncated)`, data: "via-gnu" },
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
// ③ PaxHeader(typeflag x,path= 记录)—— 另起一个目录,避免与上面同名
|
|
185
|
+
const paxName = `package/pax/${segs.join("/")}/pax.txt`;
|
|
186
|
+
const viaPax = makeTgz([{ paxPath: paxName, name: "package/pax/placeholder.txt", data: "via-pax" }]);
|
|
187
|
+
|
|
188
|
+
extractTarGz(viaPrefix, path.join(dest, "prefix"));
|
|
189
|
+
extractTarGz(viaGnu, path.join(dest, "gnu"));
|
|
190
|
+
extractTarGz(viaPax, path.join(dest, "pax"));
|
|
191
|
+
|
|
192
|
+
assert.equal(await readFile(path.join(dest, "prefix", longDir, fileName), "utf8"), "via-prefix");
|
|
193
|
+
assert.equal(await readFile(path.join(dest, "gnu", ...segs, "long.txt"), "utf8"), "via-gnu");
|
|
194
|
+
assert.equal(await readFile(path.join(dest, "pax", "pax", ...segs, "pax.txt"), "utf8"), "via-pax");
|
|
195
|
+
|
|
196
|
+
// 解析出的条目名也要是完整长名(不是头里被截断的 100 字符)
|
|
197
|
+
assert.equal(readTarEntries(viaGnu)[0].name, gnuName);
|
|
198
|
+
assert.equal(readTarEntries(viaPax)[0].name, paxName);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("安全红线:路径穿越条目被拒,且目录外不落任何文件", async (t) => {
|
|
202
|
+
const root = await tmpDir(t);
|
|
203
|
+
const evil = [
|
|
204
|
+
{ name: "../evil.txt", data: "pwn" }, // 相对穿越
|
|
205
|
+
{ name: "package/../../evil.txt", data: "pwn" }, // 带合法前缀的穿越
|
|
206
|
+
{ name: "/abs-evil.txt", data: "pwn" }, // 绝对路径
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
for (const entry of evil) {
|
|
210
|
+
const dest = path.join(root, `st-${evil.indexOf(entry)}`);
|
|
211
|
+
assert.throws(
|
|
212
|
+
() => extractTarGz(makeTgz([entry]), dest),
|
|
213
|
+
(err) => err.code === "ETRAVERSAL" && /路径穿越/.test(err.message),
|
|
214
|
+
`条目 ${entry.name} 必须被拒绝`,
|
|
215
|
+
);
|
|
216
|
+
assert.ok(!existsSync(path.join(root, "evil.txt")), "绝不能写出 destDir 之外的文件");
|
|
217
|
+
assert.ok(!existsSync("/abs-evil.txt"), "绝不能写到绝对路径");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 校验在落盘之前整体完成:坏条目 + 合法条目混合时,合法文件也不该被写出来
|
|
221
|
+
const dest = path.join(root, "mixed");
|
|
222
|
+
assert.throws(() => extractTarGz(makeTgz([
|
|
223
|
+
{ name: "package/ok.txt", data: "ok" },
|
|
224
|
+
{ name: "package/../../evil.txt", data: "pwn" },
|
|
225
|
+
]), dest), (err) => err.code === "ETRAVERSAL");
|
|
226
|
+
assert.ok(!existsSync(path.join(dest, "ok.txt")), "坏包不该留下写了一半的目录");
|
|
227
|
+
assert.ok(!existsSync(path.join(root, "evil.txt")));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("安全红线:符号链接/硬链接/设备条目被跳过而不是抛错", async (t) => {
|
|
231
|
+
const root = await tmpDir(t);
|
|
232
|
+
const dest = path.join(root, "links");
|
|
233
|
+
const files = extractTarGz(makeTgz([
|
|
234
|
+
{ name: "package/link-to-passwd", type: "2", linkname: "/etc/passwd" },
|
|
235
|
+
{ name: "package/link-to-escape", type: "2", linkname: "../../../etc/passwd" },
|
|
236
|
+
{ name: "package/hard.txt", type: "1", linkname: "package/real.txt" },
|
|
237
|
+
{ name: "package/dev-null", type: "3" },
|
|
238
|
+
{ name: "package/pipe", type: "6" },
|
|
239
|
+
{ name: "package/real.txt", data: "real" },
|
|
240
|
+
]), dest);
|
|
241
|
+
|
|
242
|
+
assert.equal(files, 1, "只有普通文件计数");
|
|
243
|
+
assert.equal(await readFile(path.join(dest, "real.txt"), "utf8"), "real");
|
|
244
|
+
for (const name of ["link-to-passwd", "link-to-escape", "hard.txt", "dev-null", "pipe"]) {
|
|
245
|
+
assert.ok(!existsSync(path.join(dest, name)), `${name} 必须被跳过(连符号链接本身也不该存在)`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// registry
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
const MAIN_TARBALL_URL = "https://registry.example.test/@mrrisega/dsh-remote/-/dsh-remote-0.6.12.tgz";
|
|
254
|
+
const WS_TARBALL_URL = "https://registry.example.test/ws/-/ws-8.18.2.tgz";
|
|
255
|
+
const SCOPE_TARBALL_URL = "https://registry.example.test/@mrrisega/dsh-util/-/dsh-util-1.0.0.tgz";
|
|
256
|
+
|
|
257
|
+
const MAIN_TGZ = makeTgz([
|
|
258
|
+
{ name: "package/", type: "5" },
|
|
259
|
+
{ name: "package/dsh-setup.mjs", data: "// dsh-setup stub\n" },
|
|
260
|
+
{ name: "package/lib/index.js", data: "export const main = 1;\n" },
|
|
261
|
+
]);
|
|
262
|
+
const WS_TGZ = makeTgz([{ name: "package/index.js", data: "module.exports = require('./lib/ws');\n" }]);
|
|
263
|
+
const SCOPE_TGZ = makeTgz([{ name: "package/index.js", data: "export const util = 1;\n" }]);
|
|
264
|
+
|
|
265
|
+
function packument(version, tarball, distTags) {
|
|
266
|
+
return {
|
|
267
|
+
"dist-tags": distTags,
|
|
268
|
+
versions: { [version]: { name: "x", version, dist: { tarball } } },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
test("resolveVersion:@scope/name 的 URL 编码正确,并解析出版本与 tarball", async () => {
|
|
273
|
+
const log = [];
|
|
274
|
+
const fetchImpl = makeFetch({
|
|
275
|
+
[`${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/latest`]: {
|
|
276
|
+
json: packument("0.6.12", MAIN_TARBALL_URL, { latest: "0.6.12", beta: "0.6.13-beta.1" }),
|
|
277
|
+
},
|
|
278
|
+
[`${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/beta`]: {
|
|
279
|
+
json: packument("0.6.13-beta.1", MAIN_TARBALL_URL, { latest: "0.6.12", beta: "0.6.13-beta.1" }),
|
|
280
|
+
},
|
|
281
|
+
[`${DEFAULT_REGISTRY}/ws/latest`]: { json: packument("8.18.2", WS_TARBALL_URL, { latest: "8.18.2" }) },
|
|
282
|
+
}, log);
|
|
283
|
+
|
|
284
|
+
const main = await resolveVersion({ name: "@mrrisega/dsh-remote", fetchImpl });
|
|
285
|
+
assert.deepEqual(main, {
|
|
286
|
+
version: "0.6.12",
|
|
287
|
+
tarball: MAIN_TARBALL_URL,
|
|
288
|
+
url: `${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/latest`,
|
|
289
|
+
});
|
|
290
|
+
// 作用域包的斜杠必须编码成 %2f(@ 保留),这是 registry 的规范形式
|
|
291
|
+
assert.deepEqual(log, [`${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/latest`]);
|
|
292
|
+
|
|
293
|
+
const beta = await resolveVersion({ name: "@mrrisega/dsh-remote", tag: "beta", fetchImpl });
|
|
294
|
+
assert.equal(beta.version, "0.6.13-beta.1");
|
|
295
|
+
assert.equal(log[1], `${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/beta`);
|
|
296
|
+
|
|
297
|
+
const ws = await resolveVersion({ name: "ws", fetchImpl });
|
|
298
|
+
assert.equal(ws.version, "8.18.2");
|
|
299
|
+
assert.equal(log[2], `${DEFAULT_REGISTRY}/ws/latest`, "非作用域包不加任何多余转义");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("resolveVersion:失败时错误信息带 HTTP 状态与 name/tag(便于遥测归因)", async () => {
|
|
303
|
+
const fetchImpl = makeFetch({});
|
|
304
|
+
await assert.rejects(
|
|
305
|
+
() => resolveVersion({ name: "@mrrisega/dsh-remote", tag: "beta", fetchImpl }),
|
|
306
|
+
(err) => {
|
|
307
|
+
assert.match(err.message, /HTTP 404/);
|
|
308
|
+
assert.match(err.message, /name=@mrrisega\/dsh-remote/);
|
|
309
|
+
assert.match(err.message, /tag=beta/);
|
|
310
|
+
assert.equal(err.status, 404);
|
|
311
|
+
return true;
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// 下载
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
test("downloadTarball:超过 maxBytes 抛错", async () => {
|
|
321
|
+
// 伪随机内容 → gzip 压不掉,才能真的超过 maxBytes(全 0 缓冲会被压到几百字节)
|
|
322
|
+
const big = zlib.gzipSync(Buffer.from(Array.from({ length: 64 * 1024 }, (_, i) => (i * 37 + (i >> 3)) % 251)));
|
|
323
|
+
assert.ok(big.length > 1024);
|
|
324
|
+
await assert.rejects(
|
|
325
|
+
() => downloadTarball({ tarballUrl: "https://registry.example.test/ws/-/ws.tgz", maxBytes: 1024, fetchImpl: async () => new Response(big) }),
|
|
326
|
+
(err) => err.code === "E2BIG" && /过大/.test(err.message),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// content-length 就已经超限 → 连读都不读(用最小的假响应,直接盯住 body 有没有被碰)
|
|
330
|
+
let readStarted = false;
|
|
331
|
+
const fakeRes = {
|
|
332
|
+
ok: true,
|
|
333
|
+
status: 200,
|
|
334
|
+
headers: new Headers({ "content-length": String(10 * 1024 * 1024) }),
|
|
335
|
+
body: { getReader() { readStarted = true; return { read: async () => ({ done: true }) }; } },
|
|
336
|
+
arrayBuffer: async () => { readStarted = true; return new ArrayBuffer(0); },
|
|
337
|
+
};
|
|
338
|
+
await assert.rejects(
|
|
339
|
+
() => downloadTarball({
|
|
340
|
+
tarballUrl: "https://registry.example.test/ws/-/ws.tgz",
|
|
341
|
+
maxBytes: 1024,
|
|
342
|
+
fetchImpl: async () => fakeRes,
|
|
343
|
+
}),
|
|
344
|
+
(err) => err.code === "E2BIG",
|
|
345
|
+
);
|
|
346
|
+
assert.equal(readStarted, false, "content-length 超限时不该继续读 body");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("downloadTarball:返回 gzip Buffer(并拒绝非 gzip 内容)", async () => {
|
|
350
|
+
const buf = await downloadTarball({
|
|
351
|
+
tarballUrl: MAIN_TARBALL_URL,
|
|
352
|
+
fetchImpl: async () => new Response(MAIN_TGZ),
|
|
353
|
+
});
|
|
354
|
+
assert.ok(Buffer.isBuffer(buf));
|
|
355
|
+
assert.equal(buf.length, MAIN_TGZ.length);
|
|
356
|
+
assert.equal(zlib.gunzipSync(buf).length % BLOCK, 0);
|
|
357
|
+
|
|
358
|
+
await assert.rejects(
|
|
359
|
+
() => downloadTarball({ tarballUrl: MAIN_TARBALL_URL, fetchImpl: async () => new Response("<html>404</html>") }),
|
|
360
|
+
/不是 gzip/,
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// stagePackage 端到端
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
test("stagePackage:主包 + ws 依赖端到端落到 staging(含 dist-tag 回退)", async (t) => {
|
|
369
|
+
const root = await tmpDir(t);
|
|
370
|
+
const dest = path.join(root, "staging");
|
|
371
|
+
const log = [];
|
|
372
|
+
const fetchImpl = makeFetch({
|
|
373
|
+
[`${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/latest`]: {
|
|
374
|
+
json: packument("0.6.12", MAIN_TARBALL_URL, { latest: "0.6.12" }),
|
|
375
|
+
},
|
|
376
|
+
[`${DEFAULT_REGISTRY}/ws/%5E8.18.0`]: { status: 404 }, // 版本范围当 tag 请求必然打不到
|
|
377
|
+
[`${DEFAULT_REGISTRY}/ws/latest`]: { json: packument("8.18.2", WS_TARBALL_URL, { latest: "8.18.2" }) },
|
|
378
|
+
[`${DEFAULT_REGISTRY}/@mrrisega%2fdsh-util/1.0.0`]: {
|
|
379
|
+
json: packument("1.0.0", SCOPE_TARBALL_URL, { latest: "1.0.0" }),
|
|
380
|
+
},
|
|
381
|
+
[MAIN_TARBALL_URL]: { tgz: MAIN_TGZ },
|
|
382
|
+
[WS_TARBALL_URL]: { tgz: WS_TGZ },
|
|
383
|
+
[SCOPE_TARBALL_URL]: { tgz: SCOPE_TGZ },
|
|
384
|
+
}, log);
|
|
385
|
+
|
|
386
|
+
const res = await stagePackage({
|
|
387
|
+
spec: "@mrrisega/dsh-remote@latest",
|
|
388
|
+
destDir: dest,
|
|
389
|
+
fetchImpl,
|
|
390
|
+
dependencies: { ws: "^8.18.0", "@mrrisega/dsh-util": "1.0.0" },
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
assert.equal(res.name, "@mrrisega/dsh-remote");
|
|
394
|
+
assert.equal(res.version, "0.6.12", "返回的 version 必须是 registry 解析出来的那个");
|
|
395
|
+
assert.equal(res.dir, dest);
|
|
396
|
+
assert.equal(res.files, 2);
|
|
397
|
+
|
|
398
|
+
// 主包:staging 根目录就是包根(dsh-setup.mjs 在这里)
|
|
399
|
+
assert.equal(await readFile(path.join(dest, "dsh-setup.mjs"), "utf8"), "// dsh-setup stub\n");
|
|
400
|
+
assert.equal(await readFile(path.join(dest, "lib", "index.js"), "utf8"), "export const main = 1;\n");
|
|
401
|
+
|
|
402
|
+
// 依赖:ws 落到 node_modules/ws,作用域依赖落到 node_modules/@scope/x
|
|
403
|
+
const ws = res.deps.find((d) => d.name === "ws");
|
|
404
|
+
assert.equal(await readFile(path.join(dest, "node_modules", "ws", "index.js"), "utf8"), "module.exports = require('./lib/ws');\n");
|
|
405
|
+
assert.equal(ws.version, "8.18.2");
|
|
406
|
+
assert.equal(ws.dir, path.join(dest, "node_modules", "ws"));
|
|
407
|
+
assert.equal(ws.requested, "^8.18.0", "如实记录调用方原本写的范围");
|
|
408
|
+
assert.equal(ws.resolvedBy, "latest-fallback", "范围当 tag 用不了 → 必须如实标注回退到 latest");
|
|
409
|
+
assert.equal(ws.distTag, "latest");
|
|
410
|
+
|
|
411
|
+
const util = res.deps.find((d) => d.name === "@mrrisega/dsh-util");
|
|
412
|
+
assert.equal(await readFile(path.join(dest, "node_modules", "@mrrisega", "dsh-util", "index.js"), "utf8"), "export const util = 1;\n");
|
|
413
|
+
assert.equal(util.version, "1.0.0");
|
|
414
|
+
assert.equal(util.resolvedBy, "requested-tag", "精确版本号能直接命中,不该回退");
|
|
415
|
+
assert.equal(util.dir, path.join(dest, "node_modules", "@mrrisega", "dsh-util"));
|
|
416
|
+
|
|
417
|
+
// 请求序列:作用域名编码正确、范围失败后才请求 latest
|
|
418
|
+
assert.ok(log.includes(`${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/latest`));
|
|
419
|
+
assert.ok(log.indexOf(`${DEFAULT_REGISTRY}/ws/%5E8.18.0`) < log.indexOf(`${DEFAULT_REGISTRY}/ws/latest`));
|
|
420
|
+
assert.ok(!log.some((u) => /^https?:\/\/(?!registry\.npmjs\.org)/.test(u) && !u.includes("example.test")), "绝不碰真实外部地址");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("stagePackage:不带版本、带 @version 的 spec 解析", async (t) => {
|
|
424
|
+
const root = await tmpDir(t);
|
|
425
|
+
const log = [];
|
|
426
|
+
const fetchImpl = makeFetch({
|
|
427
|
+
[`${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/0.6.12`]: {
|
|
428
|
+
json: packument("0.6.12", MAIN_TARBALL_URL, { latest: "0.6.12" }),
|
|
429
|
+
},
|
|
430
|
+
[MAIN_TARBALL_URL]: { tgz: MAIN_TGZ },
|
|
431
|
+
}, log);
|
|
432
|
+
|
|
433
|
+
const res = await stagePackage({ spec: "@mrrisega/dsh-remote@0.6.12", destDir: path.join(root, "s"), fetchImpl });
|
|
434
|
+
assert.equal(res.version, "0.6.12");
|
|
435
|
+
assert.equal(log[0], `${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/0.6.12`, "作用域名里的 @ 不能被当成版本分隔符");
|
|
436
|
+
|
|
437
|
+
assert.deepEqual(parseSpec("@mrrisega/dsh-remote"), { name: "@mrrisega/dsh-remote", tag: "latest" });
|
|
438
|
+
assert.deepEqual(parseSpec("@mrrisega/dsh-remote@0.6.12"), { name: "@mrrisega/dsh-remote", tag: "0.6.12" });
|
|
439
|
+
assert.deepEqual(parseSpec("pkg@beta"), { name: "pkg", tag: "beta" });
|
|
440
|
+
assert.deepEqual(parseSpec("ws"), { name: "ws", tag: "latest" });
|
|
441
|
+
assert.throws(() => parseSpec(""), /不能为空/);
|
|
442
|
+
assert.throws(() => parseSpec("@scope"), /不合法/);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("stagePackage:staging 里能找到 dsh-setup.mjs(调用方要跑的就是它)", async (t) => {
|
|
446
|
+
const root = await tmpDir(t);
|
|
447
|
+
const dest = path.join(root, "staging");
|
|
448
|
+
mkdirSync(dest, { recursive: true }); // 允许调用方先建好空目录
|
|
449
|
+
await stagePackage({
|
|
450
|
+
spec: "@mrrisega/dsh-remote",
|
|
451
|
+
destDir: dest,
|
|
452
|
+
fetchImpl: makeFetch({
|
|
453
|
+
[`${DEFAULT_REGISTRY}/@mrrisega%2fdsh-remote/latest`]: { json: packument("0.6.12", MAIN_TARBALL_URL, { latest: "0.6.12" }) },
|
|
454
|
+
[MAIN_TARBALL_URL]: { tgz: MAIN_TGZ },
|
|
455
|
+
}),
|
|
456
|
+
});
|
|
457
|
+
const setup = path.join(dest, "dsh-setup.mjs");
|
|
458
|
+
assert.ok(existsSync(setup), "staging 根目录必须有 dsh-setup.mjs");
|
|
459
|
+
assert.equal(await readFile(setup, "utf8"), "// dsh-setup stub\n");
|
|
460
|
+
});
|