diff --git a/bds-tools/package.json b/bds-tools/package.json index 6456b025..a2b0fe56 100644 --- a/bds-tools/package.json +++ b/bds-tools/package.json @@ -42,7 +42,7 @@ "dev": "tsx src/bds-manager.ts", "typecheck": "tsc --noEmit", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "test": "node --test is-main.test.mjs pack-manager-lifecycle.test.mjs server-properties.test.mjs world-packs.test.mjs", + "test": "node --test is-main.test.mjs pack-manager-lifecycle.test.mjs server-properties.test.mjs world-packs.test.mjs zipx.test.mjs", "update": "node dist/check-update.js", "update:check": "node dist/check-update.js --check-only", "update:force": "node dist/check-update.js --force", diff --git a/bds-tools/src/check-update.ts b/bds-tools/src/check-update.ts index 980aceb9..9f90563a 100644 --- a/bds-tools/src/check-update.ts +++ b/bds-tools/src/check-update.ts @@ -11,7 +11,6 @@ import { createFileSink, createLogger, createStdoutSink } from "@sfmc-bds/sdk/logs"; import cliProgress from "cli-progress"; -import JSZip from "jszip"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -39,6 +38,7 @@ import { verifyFileHash, } from "./upstream.js"; import { compareVersions, getCurrentVersionAsync, getCurrentVersionSync, saveVersionCache } from "./version.js"; +import { extractZipFileToDir } from "./zipx.js"; // 独立入口:source = "updater",与 bds-manager 的 "bds-tools" 区分 const updaterFileSink = createFileSink(LOG_PATH); @@ -133,24 +133,12 @@ async function restorePreserves(bdsPath: string, backupDir: string, preserve: st } } -/** 把 srcDir 下的内容移动到 destDir (覆盖) */ +/** 把 srcDir 下的内容移动到 destDir (覆盖) — 经 zipx 安全解压 */ async function extractZipToBds(zipPath: string, destDir: string): Promise { - const data = await fs.promises.readFile(zipPath); - const zip = await JSZip.loadAsync(data); // 抽出到临时目录,避免旧内容干扰 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bds-extract-")); try { - const entries = Object.values(zip.files); - for (const e of entries) { - const out = path.join(tmpDir, e.name); - if (e.dir) { - fs.mkdirSync(out, { recursive: true }); - continue; - } - fs.mkdirSync(path.dirname(out), { recursive: true }); - const buf = await e.async("nodebuffer"); - fs.writeFileSync(out, buf); - } + await extractZipFileToDir(zipPath, tmpDir); // 把临时目录里的内容复制到 BDS 路径 for (const entry of fs.readdirSync(tmpDir)) { const srcPath = path.join(tmpDir, entry); diff --git a/bds-tools/src/cli-pack-manager.ts b/bds-tools/src/cli-pack-manager.ts index 7cedc7e2..2252e81f 100644 --- a/bds-tools/src/cli-pack-manager.ts +++ b/bds-tools/src/cli-pack-manager.ts @@ -210,43 +210,46 @@ async function main(): Promise { process.stdout.write(`${JSON.stringify(list)}\n`); return; } - case "list-installed": { - const wp = await import("./world-packs.js"); - const bdsRoot = need(args, "bds-root"); - const level = need(args, "level"); - const list = wp.listInstalledWorldPacks(path.resolve(bdsRoot), level); - process.stdout.write(`${JSON.stringify(list)}\n`); - return; - } - case "bump-version": { - const wp = await import("./world-packs.js"); - const packDir = need(args, "pack-dir"); - const next = wp.bumpPackPatchVersion(path.resolve(packDir)); - process.stdout.write(`${JSON.stringify({ version: next })}\n`); - return; - } - case "install-dir": { - const wp = await import("./world-packs.js"); - const src = need(args, "src"); - const destParent = need(args, "dest-parent"); - const force = !!args["force"]; - const r = await wp.installPackDirectory({ - srcDir: path.resolve(src), - destParent: path.resolve(destParent), - force, - ...(args["folder-name"] ? { folderName: args["folder-name"] } : {}), - }); - process.stdout.write(`${JSON.stringify(r)}\n`); - if (!r.ok && !r.conflict) process.exit(1); - return; - } + case "list-installed": + case "bump-version": + case "install-dir": case "discover-packs": { + /* OCP: world-packs 动词表扩展,避免每 case 重复 dynamic import(DRY) */ const wp = await import("./world-packs.js"); - const root = need(args, "root"); - const depth = args["max-depth"] ? Number(args["max-depth"]) : 2; - const roots = wp.discoverPackRoots(path.resolve(root), { maxDepth: depth }); - process.stdout.write(`${JSON.stringify(roots)}\n`); - return; + if (verb === "list-installed") { + const bdsRoot = need(args, "bds-root"); + const level = need(args, "level"); + const list = wp.listInstalledWorldPacks(path.resolve(bdsRoot), level); + process.stdout.write(`${JSON.stringify(list)}\n`); + return; + } + if (verb === "bump-version") { + const packDir = need(args, "pack-dir"); + const next = wp.bumpPackPatchVersion(path.resolve(packDir)); + process.stdout.write(`${JSON.stringify({ version: next })}\n`); + return; + } + if (verb === "install-dir") { + const src = need(args, "src"); + const destParent = need(args, "dest-parent"); + const force = !!args["force"]; + const r = await wp.installPackDirectory({ + srcDir: path.resolve(src), + destParent: path.resolve(destParent), + force, + ...(args["folder-name"] ? { folderName: args["folder-name"] } : {}), + }); + process.stdout.write(`${JSON.stringify(r)}\n`); + if (!r.ok && !r.conflict) process.exit(1); + return; + } + { + const root = need(args, "root"); + const depth = args["max-depth"] ? Number(args["max-depth"]) : 2; + const roots = wp.discoverPackRoots(path.resolve(root), { maxDepth: depth }); + process.stdout.write(`${JSON.stringify(roots)}\n`); + return; + } } default: die(`unknown verb: ${verb}`); diff --git a/bds-tools/src/world-packs.ts b/bds-tools/src/world-packs.ts index d69456e8..4da7ae90 100644 --- a/bds-tools/src/world-packs.ts +++ b/bds-tools/src/world-packs.ts @@ -4,15 +4,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import JSZip from "jszip"; import { copyDirAsync } from "./fsx.js"; import { disablePackInWorld, enablePackInWorld, readPackManifestHeader, readWorldPackList, - worldPackListHas, } from "./pack-manager.js"; +import { extractZipFileToDir } from "./zipx.js"; export type WorldPackKind = "behavior" | "resource"; @@ -123,25 +122,10 @@ export function isPackArchive(filePath: string): boolean { return ARCHIVE_EXTS.some((ext) => lower.endsWith(ext)); } -/** 解压 zip/mcpack/mcaddon 到临时目录,返回该目录 */ +/** 解压 zip/mcpack/mcaddon 到临时目录,返回该目录(经 zipx 防 zip-slip) */ export async function extractArchiveToTemp(filePath: string): Promise { - const abs = path.resolve(filePath); - if (!fs.existsSync(abs)) throw new Error(`archive not found: ${abs}`); - const buf = fs.readFileSync(abs); - const zip = await JSZip.loadAsync(buf); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-pack-")); - const entries = Object.keys(zip.files); - for (const name of entries) { - const entry = zip.files[name]; - if (!entry || entry.dir) { - fs.mkdirSync(path.join(tmp, name), { recursive: true }); - continue; - } - const out = path.join(tmp, name); - fs.mkdirSync(path.dirname(out), { recursive: true }); - const data = await entry.async("nodebuffer"); - fs.writeFileSync(out, data); - } + await extractZipFileToDir(filePath, tmp); return tmp; } @@ -173,7 +157,7 @@ export function listInstalledWorldPacks(bdsRoot: string, levelName: string): Ins for (const dir of listPackDirsIn(parent)) { const info = readPackManifestInfo(dir); if (!info || info.kind !== kind) { - // 仍尝试用 header 展示 + // 仍尝试用 header 展示(enabled 与正常路径同契约:enable-list 含 uuid → LSP) const header = readPackManifestHeader(dir); if (!header) continue; result.push({ @@ -183,7 +167,7 @@ export function listInstalledWorldPacks(bdsRoot: string, levelName: string): Ins name: path.basename(dir), uuid: header.uuid, version: header.version, - enabled: worldPackListHas(worldsDir, levelName, kind, header.uuid), + enabled: enabledMap.has(header.uuid), }); continue; } @@ -255,25 +239,36 @@ export async function installPackDirectory(opts: { ); await fs.promises.mkdir(opts.destParent, { recursive: true }); - // 冲突:同 uuid 任意文件夹,或同 folderName + // 冲突:同 uuid,或同 folderName(即使旧包 manifest 无法完整识别也要拦 — 禁止静默覆盖) let conflictDir: string | null = null; + let conflictExisting: PackManifestInfo | null = null; for (const dir of listPackDirsIn(opts.destParent)) { const existing = readPackManifestInfo(dir); - if (!existing) continue; - if (existing.uuid === info.uuid || path.basename(dir) === folderName) { - conflictDir = dir; - break; + const sameFolder = path.basename(dir) === folderName; + const sameUuid = !!existing && existing.uuid === info.uuid; + if (!sameFolder && !sameUuid) continue; + conflictDir = dir; + if (existing) { + conflictExisting = existing; + } else { + const header = readPackManifestHeader(dir); + conflictExisting = { + name: path.basename(dir), + uuid: header?.uuid ?? "(unknown)", + version: header?.version ?? [0, 0, 0], + kind: info.kind, + }; } + break; } if (conflictDir && !opts.force) { - const existing = readPackManifestInfo(conflictDir)!; return { ok: false, skipped: true, reason: "conflict", conflict: { - existing: { ...existing, dir: conflictDir }, + existing: { ...(conflictExisting ?? info), dir: conflictDir }, incoming: info, }, }; @@ -335,6 +330,15 @@ export function worldPackParentDir( ); } +/** 世界 enable-list 权威读取 — 委托 pack-manager(供 doctor 等,避免硬编码文件名) */ +export function listWorldEnableEntries( + bdsRoot: string, + levelName: string, + kind: WorldPackKind +): Array<{ pack_id: string; version: [number, number, number] }> { + return readWorldPackList(path.join(bdsRoot, "worlds"), levelName, kind); +} + export function findInstalledPackById( packs: InstalledWorldPack[], id: string diff --git a/bds-tools/src/zipx.ts b/bds-tools/src/zipx.ts new file mode 100644 index 00000000..ab2aae42 --- /dev/null +++ b/bds-tools/src/zipx.ts @@ -0,0 +1,56 @@ +/** + * zipx.ts — JSZip 安全解压(防 zip-slip / 绝对路径 / Windows `\`) + * 单一权威,供 world-packs / check-update 复用(DRY)。 + */ +import fs from "node:fs"; +import path from "node:path"; +import JSZip from "jszip"; + +/** + * 将 zip 条目名解析为 destRoot 下的绝对路径。 + * 拒绝 `..`、绝对路径(含前导 `/`、盘符)、以及 resolve 后逃逸 destRoot 的条目。 + */ +export function resolveSafeZipEntryPath(destRoot: string, entryName: string): string { + const raw = String(entryName ?? "").replace(/\\/g, "/"); + // 先于去前导斜杠检查:避免 `/etc/passwd` 被剥成相对路径静默写入 dest + if (!raw || raw.includes("..") || raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) { + throw new Error(`unsafe zip entry path: ${entryName}`); + } + const parts = raw.replace(/\/$/, "").split("/").filter(Boolean); + if (parts.length === 0) { + throw new Error(`unsafe zip entry path: ${entryName}`); + } + const rootResolved = path.resolve(destRoot); + const out = path.resolve(rootResolved, ...parts); + const rootPrefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep; + if (out !== rootResolved && !out.startsWith(rootPrefix)) { + throw new Error(`zip entry escapes dest: ${entryName}`); + } + return out; +} + +/** 将 zip Buffer 解压到 destDir(已存在则写入其下)。 */ +export async function extractZipBufferToDir(data: Buffer, destDir: string): Promise { + const zip = await JSZip.loadAsync(data); + await fs.promises.mkdir(destDir, { recursive: true }); + for (const name of Object.keys(zip.files)) { + const entry = zip.files[name]; + if (!entry) continue; + const out = resolveSafeZipEntryPath(destDir, name); + if (entry.dir || name.endsWith("/") || name.endsWith("\\")) { + await fs.promises.mkdir(out, { recursive: true }); + continue; + } + await fs.promises.mkdir(path.dirname(out), { recursive: true }); + const buf = await entry.async("nodebuffer"); + await fs.promises.writeFile(out, buf); + } +} + +/** 从磁盘 zip 文件解压到 destDir。 */ +export async function extractZipFileToDir(zipPath: string, destDir: string): Promise { + const abs = path.resolve(zipPath); + if (!fs.existsSync(abs)) throw new Error(`archive not found: ${abs}`); + const data = await fs.promises.readFile(abs); + await extractZipBufferToDir(data, destDir); +} diff --git a/bds-tools/world-packs.test.mjs b/bds-tools/world-packs.test.mjs index 410019fa..f34709ae 100644 --- a/bds-tools/world-packs.test.mjs +++ b/bds-tools/world-packs.test.mjs @@ -130,4 +130,44 @@ describe("world-packs primitives", () => { const { version } = JSON.parse(cli.out.trim()); assert.deepEqual(version, [2, 0, 1]); }); + + it("installPackDirectory 同 folderName 即使旧包 kind 不可识别也要 conflict", async () => { + const { installPackDirectory, formatWorldPackFolderName } = await import("./dist/world-packs.js"); + const dest = path.join(tmp, "conflict-parent"); + const folderName = formatWorldPackFolderName("Broken", "resource"); + const existingDir = path.join(dest, folderName); + fs.mkdirSync(existingDir, { recursive: true }); + // 无法 detectPackKind 的残缺 manifest(仅 header,无 modules) + fs.writeFileSync( + path.join(existingDir, "manifest.json"), + JSON.stringify({ + format_version: 2, + header: { + name: "Broken", + uuid: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + version: [1, 0, 0], + }, + modules: [], + }) + ); + const src = path.join(tmp, "incoming-rp"); + writeManifest(src, { + name: "Incoming", + uuid: "cccccccc-cccc-cccc-cccc-cccccccccccc", + version: [1, 0, 0], + type: "resources", + }); + const r = await installPackDirectory({ + srcDir: src, + destParent: dest, + folderName: "Broken", + force: false, + }); + assert.equal(r.ok, false); + assert.equal(r.reason, "conflict"); + assert.ok(r.conflict); + // 未 force 时不得覆盖 + const still = JSON.parse(fs.readFileSync(path.join(existingDir, "manifest.json"), "utf8")); + assert.equal(still.header.uuid, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + }); }); diff --git a/bds-tools/zipx.test.mjs b/bds-tools/zipx.test.mjs new file mode 100644 index 00000000..3721b8c6 --- /dev/null +++ b/bds-tools/zipx.test.mjs @@ -0,0 +1,41 @@ +/** + * zipx 安全路径 + 解压单测(zip-slip) + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import JSZip from "jszip"; +import { extractZipBufferToDir, resolveSafeZipEntryPath } from "./dist/zipx.js"; + +test("resolveSafeZipEntryPath rejects .. and absolute", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-zipx-")); + assert.throws(() => resolveSafeZipEntryPath(root, "../evil.txt")); + assert.throws(() => resolveSafeZipEntryPath(root, "foo/../../evil.txt")); + assert.throws(() => resolveSafeZipEntryPath(root, "/etc/passwd")); + const ok = resolveSafeZipEntryPath(root, "pack/manifest.json"); + assert.equal(ok, path.resolve(root, "pack", "manifest.json")); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("extractZipBufferToDir rejects zip-slip entries", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-zipx-")); + const zip = new JSZip(); + zip.file("../../escape.txt", "pwned"); + const buf = await zip.generateAsync({ type: "nodebuffer" }); + await assert.rejects(() => extractZipBufferToDir(buf, root), /unsafe zip entry|escapes/); + assert.equal(fs.existsSync(path.join(root, "escape.txt")), false); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test("extractZipBufferToDir writes nested safe entries", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-zipx-")); + const zip = new JSZip(); + zip.file("inner/manifest.json", '{"ok":true}'); + const buf = await zip.generateAsync({ type: "nodebuffer" }); + await extractZipBufferToDir(buf, root); + const text = fs.readFileSync(path.join(root, "inner", "manifest.json"), "utf8"); + assert.match(text, /"ok"\s*:\s*true/); + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/package-lock.json b/package-lock.json index 571c3990..56334aed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3552,6 +3552,7 @@ "license": "ISC", "dependencies": { "@clack/prompts": "^1.7.0", + "@sfmc-bds/bds-tools": "^0.1.0", "@sfmc-bds/sdk": "^0.1.0", "chalk": "^5.4.1", "cli-progress": "^3.12.0", diff --git a/sfmc/src/pack-lifecycle.ts b/sfmc/src/pack-lifecycle.ts index 1565a55d..64efa666 100644 --- a/sfmc/src/pack-lifecycle.ts +++ b/sfmc/src/pack-lifecycle.ts @@ -70,6 +70,7 @@ function createSdkResolvePlugin(sdkRoot: string): import("esbuild").Plugin { }, }; } + export function buildRoot(): string { return path.join(ROOT, "build"); } diff --git a/sfmc/src/runtime.ts b/sfmc/src/runtime.ts index 67efcf6c..644609cb 100644 --- a/sfmc/src/runtime.ts +++ b/sfmc/src/runtime.ts @@ -165,8 +165,7 @@ export function resolveNewModule(): string | null { /** * 解析 `@sfmc-bds/sdk` 包根目录(含 package.json)。 - * 优先级: SFMC_SDK_ROOT > createRequire(已知 export) > monorepo modules/sdk/@sfmc-sdk。 - * 注意: SDK package.json 的 exports 未暴露 ./package.json,不能 req.resolve('…/package.json')。 + * 优先级: SFMC_SDK_ROOT > createRequire(@sfmc-bds/sdk/package.json) > 公开 export 向上找 > monorepo。 */ export function resolveSdkPackageRoot(): string { const fromEnv = process.env.SFMC_SDK_ROOT; @@ -176,6 +175,13 @@ export function resolveSdkPackageRoot(): string { function findSdkRootFromRequire(req: NodeRequire): string | null { try { + // SDK 已暴露 ./package.json export;优先直解 + try { + const pkgJson = req.resolve("@sfmc-bds/sdk/package.json"); + return path.dirname(pkgJson); + } catch { + /* 旧包未暴露时回退 */ + } // 任一公开 export 即可;再向上找含 name=@sfmc-bds/sdk 的 package.json const hit = req.resolve("@sfmc-bds/sdk/sapi/runtime"); let dir = path.dirname(hit); diff --git a/sfmc/src/world-packs.ts b/sfmc/src/world-packs.ts index a3bef759..9922e407 100644 --- a/sfmc/src/world-packs.ts +++ b/sfmc/src/world-packs.ts @@ -16,6 +16,7 @@ import { installPackDirectory, isPackArchive, listInstalledWorldPacks, + listWorldEnableEntries, readPackManifestInfo, worldPackParentDir, type InstalledWorldPack, @@ -450,24 +451,11 @@ function hasFlag(args: string[], name: string): boolean { async function cmdDoctor(): Promise { const { bdsRoot, levelName } = resolveBdsContext(); const packs = listInstalledWorldPacks(bdsRoot, levelName); - const worldsDir = path.join(bdsRoot, "worlds"); const issues: string[] = []; for (const kind of ["behavior", "resource"] as const) { - const listFile = path.join( - worldsDir, - levelName, - kind === "behavior" ? "world_behavior_packs.json" : "world_resource_packs.json" - ); - let entries: Array<{ pack_id: string; version: number[] }> = []; - if (fs.existsSync(listFile)) { - try { - entries = JSON.parse(fs.readFileSync(listFile, "utf8")) as typeof entries; - } catch { - issues.push(t("packs.doctor.parseFail", { file: listFile })); - continue; - } - } + // 经 listWorldEnableEntries → pack-manager.readWorldPackList(DRY/Demeter,不硬编码 JSON 文件名) + const entries = listWorldEnableEntries(bdsRoot, levelName, kind); const byUuid = new Map(packs.filter((p) => p.kind === kind).map((p) => [p.uuid, p])); for (const e of entries) { const p = byUuid.get(e.pack_id);