import { z } from "zod"; import { Unzip, UnzipInflate, strFromU8 } from "fflate"; import { boundsSchema, datasetSchema, faces } from "./protocol"; export const archiveLimit = 256 * 1024 * 1024; export const archiveFileLimit = 50 * 1024 * 1024; export const archiveManifestName = "minegeo-map.json"; const locationSchema = z.strictObject({ id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/), x: z.number().finite(), y: z.number().finite(), z: z.number().finite(), }); export const archiveMetadataSchema = z.strictObject({ format: z.literal("minegeo-map/v1"), minecraftVersion: datasetSchema.shape.minecraftVersion, dimension: z.literal("minecraft:overworld"), north: z.literal("-z"), source: z.enum(["plugin", "fabric"]), name: z.string().min(3).max(80), description: z.string().max(2000), serverName: z.string().min(2).max(80), bounds: boundsSchema, viewpoints: z.array(locationSchema).min(5).max(24), originalMapId: z.string().uuid(), originalOwner: z.string().min(1).max(200), signature: z.string().regex(/^[a-f0-9]{64}$/), }); export const archiveSchema = archiveMetadataSchema .extend({ assets: z .array( z.strictObject({ path: z .string() .regex( /^(?:map\.webp|[a-zA-Z0-9_-]{1,64}\/(?:px|nx|py|ny|pz|nz)\.webp)$/, ), bytes: z.number().int().positive().max(archiveFileLimit), sha256: z.string().regex(/^[a-f0-9]{64}$/), }), ) .min(31) .max(145), }) .superRefine((archive, ctx) => { const paths = new Set(archive.assets.map((a) => a.path)); const expected = [ "map.webp", ...archive.viewpoints.flatMap((p) => faces.map((f) => `${p.id}/${f}.webp`), ), ]; if ( paths.size !== archive.assets.length || paths.size !== expected.length || expected.some((p) => !paths.has(p)) ) ctx.addIssue({ code: "custom", message: "The archive must contain the map and all six faces for each location", }); if ( new Set(archive.viewpoints.map((p) => p.id)).size !== archive.viewpoints.length ) ctx.addIssue({ code: "custom", message: "Viewpoint IDs must be unique" }); if (archive.assets.reduce((n, a) => n + a.bytes, 0) > archiveLimit) ctx.addIssue({ code: "custom", message: "Map archive exceeds the 256 MB limit", }); for (const p of archive.viewpoints) if ( p.x < archive.bounds.minX || p.x > archive.bounds.maxX || p.z < archive.bounds.minZ || p.z > archive.bounds.maxZ ) ctx.addIssue({ code: "custom", message: "Viewpoint outside map bounds", }); }); export type MapArchive = z.infer; export type ArchiveMetadata = z.infer; // Strict portable metadata has no account IDs, original storage paths, server // address, credentials, publication state, or contribution permissions. export function importManifest(value: unknown) { const archive = archiveSchema.parse(value); return datasetSchema.parse({ ...archive, format: "minegeo-dataset/v1", mediaFormat: "webp", }); } export async function sha256(bytes: Uint8Array) { const digest = await crypto.subtle.digest( "SHA-256", new Uint8Array(bytes).buffer, ); return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0"), ).join(""); } export async function readMapArchive(bytes: Uint8Array) { if (!bytes.length || bytes.length > archiveLimit) throw new Error("Map archive exceeds the 256 MB limit"); const files = new Map(); const names = new Set(); let total = 0; let failure: Error | undefined; const unzip = new Unzip((file) => { const valid = file.name === archiveManifestName || /^(?:map\.webp|[a-zA-Z0-9_-]{1,64}\/(?:px|nx|py|ny|pz|nz)\.webp)$/.test( file.name, ); const max = file.name === archiveManifestName ? 128 * 1024 : archiveFileLimit; if ( !valid || names.has(file.name) || names.size >= 146 || (file.originalSize != null && file.originalSize > max) ) throw new Error( "Invalid map ZIP: unexpected, duplicate, or oversized file", ); names.add(file.name); const chunks: Uint8Array[] = []; let size = 0; file.ondata = (error, data, final) => { if (failure) return; if (error) { failure = error; file.terminate(); return; } size += data.length; total += data.length; if (size > max || total > archiveLimit) { failure = new Error("Map archive exceeds the 256 MB limit"); file.terminate(); return; } chunks.push(data); if (final) { const content = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { content.set(chunk, offset); offset += chunk.length; } files.set(file.name, content); } }; file.start(); }); unzip.register(UnzipInflate); try { // Bound inflation per push even when a ZIP lies about its original sizes. for (let offset = 0; offset < bytes.length; offset += 1024) { unzip.push( bytes.subarray(offset, offset + 1024), offset + 1024 >= bytes.length, ); if (failure) throw failure; } } catch (error) { throw new Error(error instanceof Error ? error.message : "Invalid map ZIP"); } if (files.size !== names.size || !files.has(archiveManifestName)) throw new Error("Invalid or incomplete map ZIP"); let archive: MapArchive; try { archive = archiveSchema.parse( JSON.parse(strFromU8(files.get(archiveManifestName)!)), ); } catch { throw new Error("This ZIP is not a supported MineGeo map"); } if (files.size !== archive.assets.length + 1) throw new Error("Map ZIP contains unexpected files"); for (const asset of archive.assets) { const content = files.get(asset.path); if ( !content || content.length !== asset.bytes || (await sha256(content)) !== asset.sha256 ) throw new Error("Map archive checksum or size mismatch"); } return { archive, files }; }