171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { randomUUID } from "node:crypto";
|
|
import sharp from "sharp";
|
|
import { zipSync, strToU8 } from "fflate";
|
|
import {
|
|
archiveSchema,
|
|
importManifest,
|
|
readMapArchive,
|
|
sha256,
|
|
archiveManifestName,
|
|
} from "../shared/map-archive";
|
|
import { datasetSchema, faces, parseUploadManifest } from "../shared/protocol";
|
|
import {
|
|
archiveSignature,
|
|
verifyArchiveSignature,
|
|
} from "../lib/map-archive-signature";
|
|
import { processFabricImages } from "../lib/fabric-images";
|
|
|
|
async function fixture(source: "fabric" | "plugin" = "plugin") {
|
|
const bytes = await sharp({
|
|
create: { width: 256, height: 256, channels: 3, background: "#78ab23" },
|
|
})
|
|
.webp()
|
|
.toBuffer();
|
|
const viewpoints = Array.from({ length: 5 }, (_, i) => ({
|
|
id: `p${i}`,
|
|
x: i,
|
|
y: 70,
|
|
z: i,
|
|
}));
|
|
const files: Record<string, Uint8Array> = { "map.webp": bytes };
|
|
for (const p of viewpoints)
|
|
for (const f of faces) files[`${p.id}/${f}.webp`] = bytes;
|
|
const archive = archiveSchema.parse({
|
|
format: "minegeo-map/v1",
|
|
name: "Shared world",
|
|
description: "Map for friends",
|
|
serverName: "Test server",
|
|
source,
|
|
dimension: "minecraft:overworld",
|
|
north: "-z",
|
|
minecraftVersion: "26.2",
|
|
bounds: { minX: 0, minZ: 0, maxX: 32, maxZ: 32 },
|
|
viewpoints,
|
|
originalMapId: randomUUID(),
|
|
originalOwner: "Original explorer",
|
|
signature: "0".repeat(64),
|
|
assets: await Promise.all(
|
|
Object.entries(files).map(async ([path, content]) => ({
|
|
path,
|
|
bytes: content.length,
|
|
sha256: await sha256(content),
|
|
})),
|
|
),
|
|
});
|
|
archive.signature = archiveSignature(archive, "test-key");
|
|
files[archiveManifestName] = strToU8(JSON.stringify(archive));
|
|
return { archive, files };
|
|
}
|
|
|
|
test("ZIP roundtrip preserves source, six faces, coordinates, and signed original owner", async () => {
|
|
for (const source of ["plugin", "fabric"] as const) {
|
|
const { archive, files } = await fixture(source);
|
|
for (const level of [0, 6] as const) {
|
|
const restored = await readMapArchive(zipSync(files, { level }));
|
|
assert.deepEqual(restored.archive, archive);
|
|
const manifest = importManifest(restored.archive);
|
|
assert.equal(manifest.source, source);
|
|
assert.equal(manifest.mediaFormat, "webp");
|
|
assert.equal(manifest.assets.length, 31);
|
|
assert.equal(manifest.contributionTo, undefined);
|
|
assert.equal(manifest.mapPath, undefined);
|
|
assert.equal(manifest.serverAddress, undefined);
|
|
verifyArchiveSignature(restored.archive, "test-key");
|
|
assert.throws(() => parseUploadManifest(manifest), /Use map import/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("ZIP import rejects corruption, missing faces, unsafe files, and false ownership", async () => {
|
|
const { archive, files } = await fixture();
|
|
await assert.rejects(
|
|
readMapArchive(zipSync({ ...files, "p0/px.webp": strToU8("corrupt") })),
|
|
/checksum or size/,
|
|
);
|
|
const missing = { ...files };
|
|
delete missing["p0/px.webp"];
|
|
await assert.rejects(
|
|
readMapArchive(zipSync(missing)),
|
|
/unexpected files|checksum/,
|
|
);
|
|
await assert.rejects(
|
|
readMapArchive(zipSync({ ...files, "../secret": strToU8("file") })),
|
|
/unexpected/,
|
|
);
|
|
await assert.rejects(readMapArchive(strToU8("not a zip")), /ZIP/);
|
|
assert.throws(
|
|
() =>
|
|
verifyArchiveSignature(
|
|
{ ...archive, originalOwner: "Impersonator" },
|
|
"test-key",
|
|
),
|
|
/ownership/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
verifyArchiveSignature({ ...archive, name: "Changed world" }, "test-key"),
|
|
/ownership/,
|
|
);
|
|
assert.throws(() =>
|
|
importManifest({ ...archive, contributionTo: randomUUID() }),
|
|
);
|
|
assert.throws(() =>
|
|
importManifest({
|
|
...archive,
|
|
viewpoints: archive.viewpoints.map((p) => ({
|
|
...p,
|
|
assetRoot: randomUUID(),
|
|
})),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("ZIP extraction rejects oversized entries before inflating them", async () => {
|
|
const { files } = await fixture();
|
|
const zip = zipSync(files, { level: 0 });
|
|
// The first local header advertises an enormous uncompressed map.
|
|
new DataView(zip.buffer).setUint32(22, 60 * 1024 * 1024, true);
|
|
await assert.rejects(readMapArchive(zip), /oversized/);
|
|
});
|
|
|
|
test("rendered server map imports are validated and processed without the terrain renderer", async () => {
|
|
const { archive, files } = await fixture();
|
|
const manifest = importManifest(archive);
|
|
const images = new Map<string, Buffer>();
|
|
await processFabricImages(manifest, {
|
|
signal: new AbortController().signal,
|
|
download: async (path) => Buffer.from(files[path]),
|
|
save: async (path, bytes) => {
|
|
images.set(path, bytes);
|
|
},
|
|
progress: async () => {},
|
|
});
|
|
assert.equal(images.size, 36);
|
|
assert.equal((await sharp(images.get("map.webp")).metadata()).format, "webp");
|
|
assert.equal(
|
|
(await sharp(images.get("p0/preview.webp")).metadata()).width,
|
|
512,
|
|
);
|
|
assert.equal(datasetSchema.parse(manifest).viewpoints.length, 5);
|
|
const corrupt = Buffer.from("not an image");
|
|
const bad = datasetSchema.parse({
|
|
...manifest,
|
|
assets: manifest.assets.map((a) =>
|
|
a.path === "map.webp"
|
|
? { ...a, bytes: corrupt.length, sha256: "0".repeat(64) }
|
|
: a,
|
|
),
|
|
});
|
|
await assert.rejects(
|
|
processFabricImages(bad, {
|
|
signal: new AbortController().signal,
|
|
download: async () => corrupt,
|
|
save: async () => {},
|
|
progress: async () => {},
|
|
}),
|
|
/checksum/,
|
|
);
|
|
});
|