@1e0zj/dsh-plugin-mall 0.1.18 → 0.2.1

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
@@ -373,7 +373,7 @@ export function compareVersions(a, b) {
373
373
 
374
374
  /** Cap on concurrent outbound requests — shared by verification and update checks. */
375
375
  export const NETWORK_CONCURRENCY = 8;
376
- // repo -> {kind, name, version, hostDeps, ts}
376
+ // repo -> {kind, name, version, hostDeps, manifest, ts}
377
377
  const verifyCache = new Map();
378
378
  // Verdicts expire. Caching a success for the process lifetime freezes the badge
379
379
  // on whatever the repo looked like the first time it was seen: when dsh-TUI
@@ -442,6 +442,44 @@ async function fetchRawPackageJson(repo, signal, sources) {
442
442
  throw lastError ?? new Error("no raw source reachable");
443
443
  }
444
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
+
445
483
  /**
446
484
  * Framework packages the host provides via the profiles/node_modules fallback.
447
485
  * A plugin declaring any of these as `dependencies` (instead of
@@ -495,13 +533,13 @@ export async function verifyPlugins({ repos, signal, sources }) {
495
533
  : typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
496
534
  : pkg.dsh?.client !== undefined ? "client"
497
535
  : "plain";
498
- 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() });
499
537
  } catch (error) {
500
538
  if (error?.name === "AbortError") throw error;
501
539
  verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
502
540
  }
503
541
  });
504
- // 一律重建对象:内部的 ts 不该出现在发给浏览器的响应里。
542
+ // 一律重建对象:内部的 ts 与完整 manifest 不该出现在发给浏览器的响应里。
505
543
  const results = {};
506
544
  for (const repo of wanted) {
507
545
  const cached = verifyCache.get(repo);
@@ -510,6 +548,15 @@ export async function verifyPlugins({ repos, signal, sources }) {
510
548
  return { results };
511
549
  }
512
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
+
513
560
  /**
514
561
  * Fetch one repository's metadata plus its package.json (base64-decoded),
515
562
  * which is what tells us whether it declares a dsh bundle patch.
@@ -612,6 +659,27 @@ if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test
612
659
  const failed = runHostShadowFixtures();
613
660
  console.log(`${HOST_SHADOW_FIXTURES.length - failed}/${HOST_SHADOW_FIXTURES.length} passed\n`);
614
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
+ }
615
683
  if (process.argv.includes("--offline")) process.exit(0);
616
684
  const apiBase = "https://api.github.com";
617
685
  const result = await searchPlugins({ query: "", perPage: 3, apiBase });