Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bds-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 3 additions & 15 deletions bds-tools/src/check-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> {
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);
Expand Down
73 changes: 38 additions & 35 deletions bds-tools/src/cli-pack-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,43 +210,46 @@ async function main(): Promise<void> {
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}`);
Expand Down
60 changes: 32 additions & 28 deletions bds-tools/src/world-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string> {
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;
}

Expand Down Expand Up @@ -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({
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
},
};
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions bds-tools/src/zipx.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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);
}
40 changes: 40 additions & 0 deletions bds-tools/world-packs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading
Loading