@1e0zj/dsh-plugin-mall 0.1.17 → 0.2.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/README.md +92 -6
- package/package.json +6 -2
- package/src/cli.js +1638 -0
- package/src/client.js +213 -57
- package/src/github.js +121 -26
- package/src/guard.js +2413 -0
- package/src/index.js +1650 -88
- package/src/installer.js +1585 -77
package/src/github.js
CHANGED
|
@@ -26,31 +26,58 @@ function apiUrl(apiBase, path) {
|
|
|
26
26
|
return `${base}${path.replace(/^\//, "")}`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* GET with bounded retries. The GitHub search API 504s under load — a plain
|
|
31
|
+
* transient that a retry clears — and its cold responses can take 8s+ while
|
|
32
|
+
* warm ones take 300ms. So: up to 3 attempts, 500ms/1500ms backoff, retrying
|
|
33
|
+
* only 5xx statuses, network errors, and our own per-attempt timeout. Never
|
|
34
|
+
* retried: 4xx (deterministic — the 422 "first 1000 results" contract in
|
|
35
|
+
* searchPlugins depends on failing fast) and caller cancellation, which
|
|
36
|
+
* propagates immediately.
|
|
37
|
+
*/
|
|
38
|
+
const REQUEST_TIMEOUT = 12000;
|
|
39
|
+
const RETRY_DELAYS = [500, 1500];
|
|
40
|
+
|
|
29
41
|
async function requestJson(path, { apiBase, token, signal }) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
const url = apiUrl(apiBase, path);
|
|
43
|
+
let lastError;
|
|
44
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
|
|
45
|
+
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt - 1]));
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT);
|
|
49
|
+
response = await fetch(url, {
|
|
50
|
+
headers: buildHeaders(token),
|
|
51
|
+
signal: signal === undefined ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]),
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error?.name === "AbortError" && signal?.aborted) throw error; // caller cancelled
|
|
55
|
+
// 网络错误或单次超时——都值得重试,错误文本留给最后一轮。
|
|
56
|
+
lastError = new Error(error?.name === "AbortError"
|
|
57
|
+
? `GitHub API request timed out after ${REQUEST_TIMEOUT / 1000}s (attempt ${attempt + 1})`
|
|
58
|
+
: `GitHub API request failed: ${error?.message ?? String(error)}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (response.status >= 500 && attempt < RETRY_DELAYS.length) {
|
|
62
|
+
lastError = new Error(`GitHub API ${response.status}: ${response.statusText}`);
|
|
63
|
+
continue; // 5xx 瞬时故障,退避后重试
|
|
64
|
+
}
|
|
65
|
+
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
66
|
+
const resetAt = response.headers.get("x-ratelimit-reset");
|
|
67
|
+
const body = await response.json().catch(() => undefined);
|
|
68
|
+
if (response.status === 403 && remaining === "0" && resetAt !== null) {
|
|
69
|
+
const reset = new Date(Number(resetAt) * 1000).toISOString();
|
|
70
|
+
throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
|
|
71
|
+
}
|
|
72
|
+
if (response.status === 404) {
|
|
73
|
+
throw new Error(`GitHub API 404: ${body?.message ?? "not found"}`);
|
|
74
|
+
}
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`GitHub API ${response.status}: ${body?.message ?? response.statusText}`);
|
|
77
|
+
}
|
|
78
|
+
return body;
|
|
52
79
|
}
|
|
53
|
-
|
|
80
|
+
throw lastError ?? new Error("GitHub API request failed");
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
/** Pick the stable, compact fields the tools render. */
|
|
@@ -346,7 +373,7 @@ export function compareVersions(a, b) {
|
|
|
346
373
|
|
|
347
374
|
/** Cap on concurrent outbound requests — shared by verification and update checks. */
|
|
348
375
|
export const NETWORK_CONCURRENCY = 8;
|
|
349
|
-
// repo -> {kind, name, version, hostDeps, ts}
|
|
376
|
+
// repo -> {kind, name, version, hostDeps, manifest, ts}
|
|
350
377
|
const verifyCache = new Map();
|
|
351
378
|
// Verdicts expire. Caching a success for the process lifetime freezes the badge
|
|
352
379
|
// on whatever the repo looked like the first time it was seen: when dsh-TUI
|
|
@@ -415,6 +442,44 @@ async function fetchRawPackageJson(repo, signal, sources) {
|
|
|
415
442
|
throw lastError ?? new Error("no raw source reachable");
|
|
416
443
|
}
|
|
417
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Fetch an arbitrary repo file as text from the same raw sources. The path
|
|
447
|
+
* comes from a plugin manifest's `dsh.bundle.patch`, i.e. untrusted input:
|
|
448
|
+
* anything absolute, parent-traversing, or backslashed is rejected before a
|
|
449
|
+
* URL is ever built. Source templates carry `package.json` as their path; the
|
|
450
|
+
* tail is swapped for the requested file so custom `rawSources` keep working.
|
|
451
|
+
* Returns undefined when every reachable source 404s.
|
|
452
|
+
*/
|
|
453
|
+
export async function fetchRawFile(repo, filePath, { signal, sources } = {}) {
|
|
454
|
+
const parts = String(filePath ?? "").split("/").filter((part) => part.length > 0 && part !== ".");
|
|
455
|
+
if (parts.length === 0 || parts.some((part) => part === ".." || part.includes("\\") || part.includes(":"))) {
|
|
456
|
+
throw new Error(`unsafe repo file path: ${JSON.stringify(filePath)}`);
|
|
457
|
+
}
|
|
458
|
+
const tail = parts.map(encodeURIComponent).join("/");
|
|
459
|
+
let saw404 = false;
|
|
460
|
+
let lastError;
|
|
461
|
+
for (const template of rawSourcesOf(sources)) {
|
|
462
|
+
const base = template.replace("{repo}", repo);
|
|
463
|
+
const url = base.includes("{path}")
|
|
464
|
+
? base.replace("{path}", tail)
|
|
465
|
+
: base.replace(/package\.json$/, tail);
|
|
466
|
+
try {
|
|
467
|
+
const response = await fetch(url, {
|
|
468
|
+
headers: { "User-Agent": "dsh-plugin-mall", Accept: "text/plain, */*" },
|
|
469
|
+
signal,
|
|
470
|
+
});
|
|
471
|
+
if (response.status === 404) { saw404 = true; continue; }
|
|
472
|
+
if (!response.ok) throw new Error(`source returned ${response.status}`);
|
|
473
|
+
return await response.text();
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (error?.name === "AbortError") throw error;
|
|
476
|
+
lastError = error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (saw404) return undefined;
|
|
480
|
+
throw lastError ?? new Error("no raw source reachable");
|
|
481
|
+
}
|
|
482
|
+
|
|
418
483
|
/**
|
|
419
484
|
* Framework packages the host provides via the profiles/node_modules fallback.
|
|
420
485
|
* A plugin declaring any of these as `dependencies` (instead of
|
|
@@ -468,13 +533,13 @@ export async function verifyPlugins({ repos, signal, sources }) {
|
|
|
468
533
|
: typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
|
|
469
534
|
: pkg.dsh?.client !== undefined ? "client"
|
|
470
535
|
: "plain";
|
|
471
|
-
verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg), ts: Date.now() });
|
|
536
|
+
verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg), manifest: pkg, ts: Date.now() });
|
|
472
537
|
} catch (error) {
|
|
473
538
|
if (error?.name === "AbortError") throw error;
|
|
474
539
|
verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
|
|
475
540
|
}
|
|
476
541
|
});
|
|
477
|
-
// 一律重建对象:内部的 ts 不该出现在发给浏览器的响应里。
|
|
542
|
+
// 一律重建对象:内部的 ts 与完整 manifest 不该出现在发给浏览器的响应里。
|
|
478
543
|
const results = {};
|
|
479
544
|
for (const repo of wanted) {
|
|
480
545
|
const cached = verifyCache.get(repo);
|
|
@@ -483,6 +548,15 @@ export async function verifyPlugins({ repos, signal, sources }) {
|
|
|
483
548
|
return { results };
|
|
484
549
|
}
|
|
485
550
|
|
|
551
|
+
/**
|
|
552
|
+
* The cached full manifest for a repo already seen by verifyPlugins, or
|
|
553
|
+
* undefined. Server-side only — the browsing-time compat scan needs the whole
|
|
554
|
+
* package.json (peers, engines, dsh.conflicts), not just the badge metadata.
|
|
555
|
+
*/
|
|
556
|
+
export function cachedRepoManifest(repo) {
|
|
557
|
+
return verifyCache.get(String(repo ?? ""))?.manifest;
|
|
558
|
+
}
|
|
559
|
+
|
|
486
560
|
/**
|
|
487
561
|
* Fetch one repository's metadata plus its package.json (base64-decoded),
|
|
488
562
|
* which is what tells us whether it declares a dsh bundle patch.
|
|
@@ -585,6 +659,27 @@ if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test
|
|
|
585
659
|
const failed = runHostShadowFixtures();
|
|
586
660
|
console.log(`${HOST_SHADOW_FIXTURES.length - failed}/${HOST_SHADOW_FIXTURES.length} passed\n`);
|
|
587
661
|
if (failed > 0) process.exit(1);
|
|
662
|
+
// fetchRawFile 路径钳制:来自插件清单的 dsh.bundle.patch 是不可信输入,
|
|
663
|
+
// 越界/绝对/反斜杠路径必须在拼 URL 之前被拒绝(这些用例零网络)。
|
|
664
|
+
{
|
|
665
|
+
const deadSource = ["http://127.0.0.1:1/{repo}/package.json"];
|
|
666
|
+
let clampFailed = 0;
|
|
667
|
+
for (const bad of ["../secret", "a/../../b", "dir\\win.yml", "c:/abs.yml", ""]) {
|
|
668
|
+
let rejected = false;
|
|
669
|
+
try { await fetchRawFile("owner/repo", bad, { sources: deadSource }); } catch { rejected = true; }
|
|
670
|
+
if (!rejected) { clampFailed++; console.log(` FAIL 危险路径应拒绝: ${JSON.stringify(bad)}`); }
|
|
671
|
+
else console.log(` PASS 危险路径拒绝: ${JSON.stringify(bad)}`);
|
|
672
|
+
}
|
|
673
|
+
// 合法相对路径要通过钳制、真正走到网络(dead source 必失败,但报错不能是钳制错误)。
|
|
674
|
+
{
|
|
675
|
+
let reached = false;
|
|
676
|
+
try { await fetchRawFile("owner/repo", "subdir/patch file.yml", { sources: deadSource }); }
|
|
677
|
+
catch (error) { reached = !/unsafe repo file path/.test(error?.message ?? ""); }
|
|
678
|
+
if (!reached) { clampFailed++; console.log(" FAIL 合法路径应通过钳制并尝试网络"); }
|
|
679
|
+
else console.log(" PASS 合法路径通过钳制(网络不可达按预期失败)");
|
|
680
|
+
}
|
|
681
|
+
if (clampFailed > 0) process.exit(1);
|
|
682
|
+
}
|
|
588
683
|
if (process.argv.includes("--offline")) process.exit(0);
|
|
589
684
|
const apiBase = "https://api.github.com";
|
|
590
685
|
const result = await searchPlugins({ query: "", perPage: 3, apiBase });
|