|
| 1 | +package pl.syntaxdevteam.syntaxlauncher.core.loader |
| 2 | + |
| 3 | +import java.nio.file.Files |
| 4 | +import java.nio.file.LinkOption |
| 5 | +import java.nio.file.Path |
| 6 | +import java.util.zip.ZipFile |
| 7 | +import kotlinx.serialization.json.Json |
| 8 | +import kotlinx.serialization.json.JsonArray |
| 9 | +import kotlinx.serialization.json.JsonObject |
| 10 | +import kotlinx.serialization.json.JsonPrimitive |
| 11 | +import kotlinx.serialization.json.contentOrNull |
| 12 | + |
| 13 | +/** |
| 14 | + * Read-only preflight for modern Forge/NeoForge client installers. |
| 15 | + * This never executes downloaded code or pretends that a processor list is a launch profile. |
| 16 | + */ |
| 17 | +data class InstallerProcessorDescriptor( |
| 18 | + val artifact: String, |
| 19 | + val classpath: List<String>, |
| 20 | + val args: List<String>, |
| 21 | + val outputs: Map<String, String>, |
| 22 | +) |
| 23 | + |
| 24 | +data class InstallerProfileInspection( |
| 25 | + val gameVersion: String, |
| 26 | + val launcherVersionId: String, |
| 27 | + val processors: List<InstallerProcessorDescriptor>, |
| 28 | + val installerLibraries: List<String>, |
| 29 | + val versionLibraries: List<String>, |
| 30 | + val embeddedDataNames: Set<String>, |
| 31 | +) |
| 32 | + |
| 33 | +/** |
| 34 | + * Version 2 install_profile.json + version.json. Legacy v1 profiles and absent |
| 35 | + * processors are rejected rather than silently yielding incomplete clients. |
| 36 | + */ |
| 37 | +class InstallerProfileInspector { |
| 38 | + private val json = Json { ignoreUnknownKeys = true } |
| 39 | + private val coordinate = Regex("[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[A-Za-z0-9._+~-]+(:[A-Za-z0-9_.-]+)?") |
| 40 | + private val safeEntry = Regex("[A-Za-z0-9_./+-]{1,200}") |
| 41 | + private val safeLiteral = Regex("[A-Za-z0-9_:\\-./+{}\\[\\]'\",=;$ ]{1,500}") |
| 42 | + |
| 43 | + fun inspect(jar: Path, requestedGameVersion: String): InstallerProfileInspection { |
| 44 | + require(Files.isRegularFile(jar, LinkOption.NOFOLLOW_LINKS) && |
| 45 | + Files.size(jar) in 1..(512L * 1024 * 1024)) { "Invalid installer archive." } |
| 46 | + require(requestedGameVersion.matches(Regex("[A-Za-z0-9._-]{1,80}"))) |
| 47 | + ZipFile(jar.toFile()).use { zip -> |
| 48 | + fun read(name: String): JsonObject { |
| 49 | + val entry = zip.getEntry(name) ?: throw ModLoaderException( |
| 50 | + "Installer is missing " + name + "; legacy/installless layouts are unsupported.") |
| 51 | + require(!entry.isDirectory && entry.size in 1..(2L * 1024 * 1024)) { |
| 52 | + "Oversized or empty installer metadata: " + name |
| 53 | + } |
| 54 | + val bytes = zip.getInputStream(entry).use { it.readNBytes(2 * 1024 * 1024 + 1) } |
| 55 | + require(bytes.size <= 2 * 1024 * 1024) { "Installer metadata too large." } |
| 56 | + return json.parseToJsonElement(bytes.toString(Charsets.UTF_8)) as? JsonObject |
| 57 | + ?: throw ModLoaderException("Invalid installer metadata: " + name) |
| 58 | + } |
| 59 | + val install = read("install_profile.json") |
| 60 | + val version = read("version.json") |
| 61 | + val game = install.text("minecraft") ?: throw ModLoaderException( |
| 62 | + "Installer has no Minecraft target.") |
| 63 | + require(game == requestedGameVersion) { "Installer targets " + game + ", not " + requestedGameVersion } |
| 64 | + val inherits = version.text("inheritsFrom") |
| 65 | + require(inherits == requestedGameVersion) { |
| 66 | + "Generated version profile targets another Minecraft version." |
| 67 | + } |
| 68 | + val id = version.text("id") ?: error("Generated version id is missing.") |
| 69 | + require(id.matches(Regex("[A-Za-z0-9._+~-]{1,120}"))) { "Unsafe installer version id." } |
| 70 | + val rawProcessors = install.array("processors") |
| 71 | + require(rawProcessors.isNotEmpty() && rawProcessors.size <= 128) { |
| 72 | + "Installer lacks a supported processor list." |
| 73 | + } |
| 74 | + val processors = rawProcessors.mapNotNull { element -> |
| 75 | + val processor = element as? JsonObject ?: error("Malformed installer processor.") |
| 76 | + val sides = processor.array("sides").mapNotNull { (it as? JsonPrimitive)?.contentOrNull } |
| 77 | + if (sides.isNotEmpty() && "client" !in sides) return@mapNotNull null |
| 78 | + val artifact = processor.text("jar") ?: error("Processor JAR missing.") |
| 79 | + require(validArtifact(artifact)) { "Unsafe processor artifact." } |
| 80 | + val classpath = processor.array("classpath").map { |
| 81 | + val value = (it as? JsonPrimitive)?.contentOrNull ?: error("Invalid processor classpath.") |
| 82 | + require(validArtifact(value)) { "Unsafe processor classpath." } |
| 83 | + value |
| 84 | + } |
| 85 | + val args = processor.array("args").map { |
| 86 | + val value = (it as? JsonPrimitive)?.contentOrNull ?: error("Invalid processor argument.") |
| 87 | + require(value.length in 1..500 && value.none { c -> c.code < 32 }) { |
| 88 | + "Unsafe processor argument." |
| 89 | + } |
| 90 | + value |
| 91 | + } |
| 92 | + val outputs = (processor["outputs"] as? JsonObject)?.mapValues { (_, value) -> |
| 93 | + (value as? JsonPrimitive)?.contentOrNull ?: error("Invalid processor output.") |
| 94 | + }.orEmpty() |
| 95 | + require(outputs.size <= 128 && outputs.all { (key, value) -> |
| 96 | + key.length <= 500 && value.length <= 500 |
| 97 | + }) { "Invalid processor outputs." } |
| 98 | + InstallerProcessorDescriptor(artifact, classpath, args, outputs) |
| 99 | + } |
| 100 | + require(processors.isNotEmpty()) { "Installer has no client-side processors." } |
| 101 | + fun libraries(objectValue: JsonObject): List<String> = |
| 102 | + objectValue.array("libraries").map { |
| 103 | + val name = (it as? JsonObject)?.text("name") ?: error("Unnamed installer library.") |
| 104 | + require(coordinate.matches(name)) { "Unsafe library coordinate: " + name } |
| 105 | + name |
| 106 | + } |
| 107 | + val embedded = (install["data"] as? JsonObject)?.keys.orEmpty() |
| 108 | + require(embedded.size <= 128 && embedded.all { safeEntry.matches(it) && ".." !in it }) { |
| 109 | + "Invalid installer data names." |
| 110 | + } |
| 111 | + return InstallerProfileInspection( |
| 112 | + game, id, processors, libraries(install), libraries(version), embedded, |
| 113 | + ) |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + private fun validArtifact(s: String): Boolean { |
| 118 | + val coordinateValue = s.removePrefix("[").removeSuffix("]") |
| 119 | + return coordinate.matches(coordinateValue) && |
| 120 | + (!s.startsWith("[") || s.endsWith("]")) |
| 121 | + } |
| 122 | + |
| 123 | + private fun JsonObject.text(name: String): String? = |
| 124 | + (this[name] as? JsonPrimitive)?.contentOrNull |
| 125 | + |
| 126 | + private fun JsonObject.array(name: String): List<kotlinx.serialization.json.JsonElement> = |
| 127 | + (this[name] as? JsonArray)?.toList().orEmpty() |
| 128 | +} |
0 commit comments