@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/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
- let response;
31
- try {
32
- response = await fetch(apiUrl(apiBase, path), {
33
- headers: buildHeaders(token),
34
- signal,
35
- });
36
- } catch (error) {
37
- if (error?.name === "AbortError") throw error;
38
- throw new Error(`GitHub API request failed: ${error?.message ?? String(error)}`);
39
- }
40
- const remaining = response.headers.get("x-ratelimit-remaining");
41
- const resetAt = response.headers.get("x-ratelimit-reset");
42
- const body = await response.json().catch(() => undefined);
43
- if (response.status === 403 && remaining === "0" && resetAt !== null) {
44
- const reset = new Date(Number(resetAt) * 1000).toISOString();
45
- throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
46
- }
47
- if (response.status === 404) {
48
- throw new Error(`GitHub API 404: ${body?.message ?? "not found"}`);
49
- }
50
- if (!response.ok) {
51
- throw new Error(`GitHub API ${response.status}: ${body?.message ?? response.statusText}`);
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
- return body;
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 });