From 6e890ec2bac4ba08d7c78f30b6847f0b8dd4bb2e Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 23 Sep 2024 09:01:07 -0500 Subject: [PATCH] Add an origin checker to warn when World Host was downloaded from an unofficial source The warning only appears once per installation of World Host --- .../github/gaming32/worldhost/WorldHost.java | 25 +- .../worldhost/origincheck/OriginChecker.java | 13 + .../worldhost/origincheck/OriginCheckers.java | 70 +++++ .../checkers/AbstractOriginChecker.java | 82 ++++++ .../checkers/GioOriginChecker.java | 108 ++++++++ .../checkers/MacOriginChecker.java | 31 +++ .../checkers/NoopOriginChecker.java | 23 ++ .../checkers/WindowsOriginChecker.java | 48 ++++ .../parser/SimpleBplistParser.java | 250 ++++++++++++++++++ .../origincheck/parser/SimpleIniParser.java | 30 +++ .../assets/world-host/lang/en_us.json | 4 +- version.gradle.kts | 5 +- 12 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/OriginChecker.java create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/OriginCheckers.java create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/checkers/AbstractOriginChecker.java create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/checkers/GioOriginChecker.java create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/checkers/MacOriginChecker.java create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/checkers/NoopOriginChecker.java create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/checkers/WindowsOriginChecker.java create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/parser/SimpleBplistParser.java create mode 100644 src/main/java/io/github/gaming32/worldhost/origincheck/parser/SimpleIniParser.java diff --git a/src/main/java/io/github/gaming32/worldhost/WorldHost.java b/src/main/java/io/github/gaming32/worldhost/WorldHost.java index 9f37fc5..46a7108 100644 --- a/src/main/java/io/github/gaming32/worldhost/WorldHost.java +++ b/src/main/java/io/github/gaming32/worldhost/WorldHost.java @@ -15,6 +15,7 @@ import io.github.gaming32.worldhost.gui.screen.FriendsScreen; import io.github.gaming32.worldhost.gui.screen.JoiningWorldHostScreen; import io.github.gaming32.worldhost.gui.screen.OnlineFriendsScreen; +import io.github.gaming32.worldhost.origincheck.OriginCheckers; import io.github.gaming32.worldhost.plugin.FriendAdder; import io.github.gaming32.worldhost.plugin.InfoTextsCategory; import io.github.gaming32.worldhost.plugin.OnlineFriend; @@ -239,7 +240,10 @@ public class WorldHost @Override public void onInitializeClient() { final var container = FabricLoader.getInstance().getModContainer(MOD_ID).orElseThrow(); - init(path -> container.findPath(path).orElseThrow(() -> new NoSuchFileException(path))); + init( + path -> container.findPath(path).orElseThrow(() -> new NoSuchFileException(path)), + container.getOrigin().getPaths().getFirst() + ); } //#else //$$ public WorldHost( @@ -251,7 +255,7 @@ public void onInitializeClient() { //$$ final ModContainer container = ModLoadingContext.get().getActiveContainer(); //#endif //$$ final var modFile = container.getModInfo().getOwningFile().getFile(); - //$$ init(path -> modFile.findResource(path.split("/"))); + //$$ init(path -> modFile.findResource(path.split("/")), modFile.getFilePath()); //$$ container.registerExtensionPoint( //#if MC >= 1.20.5 //$$ IConfigScreenFactory.class, (ignored, screen) -> new WorldHostConfigScreen(screen) @@ -266,7 +270,7 @@ public void onInitializeClient() { //$$ } //#endif - private static void init(IOFunction assetGetter) { + private static void init(IOFunction assetGetter, Path modPath) { try (BufferedReader reader = Files.newBufferedReader( assetGetter.apply("16k.txt"), StandardCharsets.US_ASCII )) { @@ -288,6 +292,21 @@ private static void init(IOFunction assetGetter) { loadConfig(); prepareFileWatcher(); + final var nonstandardOrigins = OriginCheckers.getNonstandardOriginsOnce(OriginCheckers.NATIVE_CHECKER, modPath); + if (!nonstandardOrigins.isEmpty()) { + LOGGER.warn("Found nonstandard download origins: {}", nonstandardOrigins); + WHToast.builder("world-host.nonstandard_origin") + .description(Components.translatable( + "world-host.nonstandard_origin.desc", + nonstandardOrigins.stream() + .map(URI::getHost) + .collect(Collectors.joining(", ")) + )) + .important() + .ticks(200) + .show(); + } + try { Files.createDirectories(CACHE_DIR); } catch (IOException e) { diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/OriginChecker.java b/src/main/java/io/github/gaming32/worldhost/origincheck/OriginChecker.java new file mode 100644 index 0000000..39c473f --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/OriginChecker.java @@ -0,0 +1,13 @@ +package io.github.gaming32.worldhost.origincheck; + +import java.net.URI; +import java.nio.file.Path; +import java.util.List; + +public interface OriginChecker { + boolean needsChecking(Path file); + + List findOrigins(Path file); + + void markChecked(Path file); +} diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/OriginCheckers.java b/src/main/java/io/github/gaming32/worldhost/origincheck/OriginCheckers.java new file mode 100644 index 0000000..6759664 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/OriginCheckers.java @@ -0,0 +1,70 @@ +package io.github.gaming32.worldhost.origincheck; + +import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.origincheck.checkers.GioOriginChecker; +import io.github.gaming32.worldhost.origincheck.checkers.MacOriginChecker; +import io.github.gaming32.worldhost.origincheck.checkers.NoopOriginChecker; +import io.github.gaming32.worldhost.origincheck.checkers.WindowsOriginChecker; +import net.minecraft.Util; + +import java.net.URI; +import java.nio.file.Path; +import java.util.List; +import java.util.function.Predicate; + +public class OriginCheckers { + public static final OriginChecker NATIVE_CHECKER = switch (Util.getPlatform()) { + case WINDOWS -> new WindowsOriginChecker(); + case OSX -> new MacOriginChecker(); + default -> { + try { + yield new GioOriginChecker(); + } catch (IllegalStateException e) { + WorldHost.LOGGER.info("GIO origin checker not available", e); + yield new NoopOriginChecker(); + } + } + }; + public static final List STANDARD_ORIGINS = List.of( + "modrinth.com", + "github.com", + "githubusercontent.com", + "maven.jemnetworks.com" + ); + + public static boolean isStandardHost(String host) { + final var hostLength = host.length(); + for (final var origin : STANDARD_ORIGINS) { + if (host.equals(origin)) { + return true; + } + final var originLength = origin.length(); + if (hostLength <= originLength) continue; + if (host.endsWith(origin) && host.charAt(hostLength - originLength - 1) == '.') { + return true; + } + } + return false; + } + + public static boolean hasStandardOrigin(URI uri) { + return isStandardHost(uri.getHost()); + } + + public static List getNonstandardOrigins(List uris) { + return uris.stream().filter(Predicate.not(OriginCheckers::hasStandardOrigin)).toList(); + } + + public static List getNonstandardOrigins(OriginChecker checker, Path file) { + return getNonstandardOrigins(checker.findOrigins(file)); + } + + public static List getNonstandardOriginsOnce(OriginChecker checker, Path file) { + if (!checker.needsChecking(file)) { + return List.of(); + } + final var result = getNonstandardOrigins(checker, file); + checker.markChecked(file); + return result; + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/AbstractOriginChecker.java b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/AbstractOriginChecker.java new file mode 100644 index 0000000..e47cbb7 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/AbstractOriginChecker.java @@ -0,0 +1,82 @@ +package io.github.gaming32.worldhost.origincheck.checkers; + +import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.origincheck.OriginChecker; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.UserDefinedFileAttributeView; +import java.util.ArrayList; +import java.util.List; + +abstract class AbstractOriginChecker implements OriginChecker { + abstract String getCheckedMarker(); + + abstract String getOriginAttributeName(); + + abstract List<@Nullable String> parseOrigins(ByteBuffer bb) throws Exception; + + @Override + public boolean needsChecking(Path file) { + final var attributes = Files.getFileAttributeView(file, UserDefinedFileAttributeView.class); + if (attributes == null) { + return false; + } + try { + return !attributes.list().contains(getCheckedMarker()); + } catch (IOException e) { + WorldHost.LOGGER.error("Failed to check for {} on {}", getCheckedMarker(), file, e); + return false; + } + } + + @Override + public List findOrigins(Path file) { + final var attributes = Files.getFileAttributeView(file, UserDefinedFileAttributeView.class); + if (attributes == null) { + return List.of(); + } + try { + if (!attributes.list().contains(getOriginAttributeName())) { + return List.of(); + } + final var bb = ByteBuffer.allocate(attributes.size(getOriginAttributeName())); + attributes.read(getOriginAttributeName(), bb); + bb.flip(); + return toUriList(parseOrigins(bb)); + } catch (Exception e) { + WorldHost.LOGGER.error("Failed to read {} on {}", getOriginAttributeName(), file, e); + return List.of(); + } + } + + private List toUriList(List<@Nullable String> uris) { + final var result = new ArrayList(uris.size()); + for (final var url : uris) { + if (url != null) { + try { + result.add(new URI(url)); + } catch (URISyntaxException e) { + WorldHost.LOGGER.warn("Failed to parse {} URL {}", getOriginAttributeName(), url, e); + } + } + } + return result; + } + + @Override + public void markChecked(Path file) { + final var attributes = Files.getFileAttributeView(file, UserDefinedFileAttributeView.class); + if (attributes == null) return; + try { + attributes.write(getCheckedMarker(), ByteBuffer.allocate(0)); + } catch (IOException e) { + WorldHost.LOGGER.error("Failed to write {} on {}", getCheckedMarker(), file, e); + } + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/GioOriginChecker.java b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/GioOriginChecker.java new file mode 100644 index 0000000..912381e --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/GioOriginChecker.java @@ -0,0 +1,108 @@ +package io.github.gaming32.worldhost.origincheck.checkers; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.origincheck.OriginChecker; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; + +// This is more spotty, as not all web browsers implement this (for example, Firefox does, but Chromium doesn't). +// Furthermore, most things won't copy the metadata with the file, which means this metadata often won't be available +// from the mods directory, unless the file was directly saved there and not copied. When I wrote this, I only knew +// about the browser limitation, not the copying limitation, otherwise I wouldn't have written it. +public class GioOriginChecker implements OriginChecker { + private static final String CHECKED_MARKER = "metadata::world-host-origin-checked"; + private static final String DOWNLOAD_URI = "metadata::download-uri"; + + private final Gio gio; + + public GioOriginChecker() throws IllegalStateException { + try { + gio = Native.load("gio-2.0", Gio.class); + } catch (UnsatisfiedLinkError e) { + throw new IllegalStateException("Gio library not available", e); + } + } + + @Override + public boolean needsChecking(Path file) { + return doWithInfo( + file, CHECKED_MARKER, false, + (gioFile, info) -> gio.g_file_info_get_attribute_string(info, CHECKED_MARKER) == null + ); + } + + @Override + public List findOrigins(Path file) { + final var uri = doWithInfo( + file, DOWNLOAD_URI, null, + (gioFile, info) -> gio.g_file_info_get_attribute_string(info, DOWNLOAD_URI) + ); + if (uri == null) { + return List.of(); + } + try { + return List.of(new URI(uri)); + } catch (URISyntaxException e) { + WorldHost.LOGGER.warn("Failed to parse {} URI {}", DOWNLOAD_URI, uri, e); + return List.of(); + } + } + + @Override + public void markChecked(Path file) { + doWithInfo(file, "", (gioFile, info) -> { + gio.g_file_info_set_attribute_string(info, CHECKED_MARKER, ""); + if (!gio.g_file_set_attributes_from_info(gioFile, info, 0, null, null)) { + WorldHost.LOGGER.warn("Failed to set {} on {}", CHECKED_MARKER, file); + } + }); + } + + private void doWithInfo(Path file, String attributes, BiConsumer action) { + doWithInfo(file, attributes, null, (gioFile, info) -> { + action.accept(gioFile, info); + return null; + }); + } + + private T doWithInfo(Path file, String attributes, T defaultValue, BiFunction action) { + if (file.getFileSystem() != FileSystems.getDefault()) { + return defaultValue; + } + final var gioFile = gio.g_file_new_for_path(file.toString()); + try { + final var info = gio.g_file_query_info(gioFile, attributes, 0, null, null); + if (info == null) { + return defaultValue; + } + try { + return action.apply(gioFile, info); + } finally { + Native.free(Pointer.nativeValue(info)); + } + } finally { + Native.free(Pointer.nativeValue(gioFile)); + } + } + + private interface Gio extends Library { + Pointer g_file_new_for_path(String path); + + Pointer g_file_query_info(Pointer file, String attributes, int flags, Pointer cancellable, Pointer error); + + String g_file_info_get_attribute_string(Pointer info, String attribute); + + void g_file_info_set_attribute_string(Pointer info, String attribute, String attr_value); + + boolean g_file_set_attributes_from_info(Pointer file, Pointer info, int flags, Pointer cancellable, Pointer error); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/MacOriginChecker.java b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/MacOriginChecker.java new file mode 100644 index 0000000..9fa3c64 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/MacOriginChecker.java @@ -0,0 +1,31 @@ +package io.github.gaming32.worldhost.origincheck.checkers; + +import io.github.gaming32.worldhost.origincheck.parser.SimpleBplistParser; + +import java.nio.ByteBuffer; +import java.util.Collection; +import java.util.List; + +public class MacOriginChecker extends AbstractOriginChecker { + @Override + String getCheckedMarker() { + return "io.github.gaming32.worldhost:originChecked"; + } + + @Override + String getOriginAttributeName() { + return "com.apple.metadata:kMDItemWhereFroms"; + } + + @Override + List parseOrigins(ByteBuffer bb) { + final var bplist = SimpleBplistParser.parseBplist(bb); + if (!(bplist instanceof Collection collection)) { + return List.of(); + } + return collection.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/NoopOriginChecker.java b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/NoopOriginChecker.java new file mode 100644 index 0000000..9589f25 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/NoopOriginChecker.java @@ -0,0 +1,23 @@ +package io.github.gaming32.worldhost.origincheck.checkers; + +import io.github.gaming32.worldhost.origincheck.OriginChecker; + +import java.net.URI; +import java.nio.file.Path; +import java.util.List; + +public class NoopOriginChecker implements OriginChecker { + @Override + public boolean needsChecking(Path file) { + return false; + } + + @Override + public List findOrigins(Path file) { + return List.of(); + } + + @Override + public void markChecked(Path file) { + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/WindowsOriginChecker.java b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/WindowsOriginChecker.java new file mode 100644 index 0000000..3f04637 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/checkers/WindowsOriginChecker.java @@ -0,0 +1,48 @@ +package io.github.gaming32.worldhost.origincheck.checkers; + +import io.github.gaming32.worldhost.origincheck.parser.SimpleIniParser; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.io.CharArrayReader; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class WindowsOriginChecker extends AbstractOriginChecker { + @Override + String getCheckedMarker() { + return "WorldHostOriginChecked"; + } + + @Override + String getOriginAttributeName() { + return "Zone.Identifier"; + } + + @Override + List<@Nullable String> parseOrigins(ByteBuffer bb) throws IOException { + final var zoneIdentifier = parseZoneIdentifier(bb); + final var zoneTransfer = zoneIdentifier.get("ZoneTransfer"); + if (zoneTransfer == null) { + return List.of(); + } + return Arrays.asList(zoneTransfer.get("ReferrerUrl"), zoneTransfer.get("HostUrl")); + } + + private static Map> parseZoneIdentifier(ByteBuffer bb) throws IOException { + final var cb = getWindowsCharset().decode(bb); + try (var reader = new BufferedReader(new CharArrayReader(cb.array(), cb.arrayOffset() + cb.position(), cb.remaining()))) { + return SimpleIniParser.parse(reader); + } + } + + // TODO: Replace with direct native.encoding usage when Java 18+ becomes the minimum + private static Charset getWindowsCharset() { + final var nativeCharset = System.getProperty("native.encoding"); + return nativeCharset == null ? Charset.defaultCharset() : Charset.forName(nativeCharset); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/parser/SimpleBplistParser.java b/src/main/java/io/github/gaming32/worldhost/origincheck/parser/SimpleBplistParser.java new file mode 100644 index 0000000..d2b363d --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/parser/SimpleBplistParser.java @@ -0,0 +1,250 @@ +package io.github.gaming32.worldhost.origincheck.parser; + +import it.unimi.dsi.fastutil.bytes.Byte2LongFunction; +import net.minecraft.Util; + +import java.io.Serial; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; +import java.util.UUID; +import java.util.function.IntFunction; + +public class SimpleBplistParser { + private static final long CORE_DATA_EPOCH = Util.make(() -> { + final var calender = Calendar.getInstance(TimeZone.getTimeZone("UTC"), Locale.ROOT); + calender.set(2001, Calendar.JANUARY, 1, 0, 0, 0); + return calender.getTimeInMillis() / 1000L; + }); + + private static final int TYPE_SIMPLE = 0b0000; + private static final int TYPE_INT = 0b0001; + private static final int TYPE_REAL = 0b0010; + private static final int TYPE_DATE = 0b0011; + private static final int TYPE_DATA = 0b0100; + private static final int TYPE_ASCII = 0b0101; + private static final int TYPE_UNICODE = 0b0110; + private static final int TYPE_UTF8 = 0b0111; + private static final int TYPE_UID = 0b1000; + private static final int TYPE_ARRAY = 0b1010; + private static final int TYPE_ORDSET = 0b1011; + private static final int TYPE_SET = 0b1100; + private static final int TYPE_DICT = 0b1101; + + private static final int SIMPLE_NULL = 0b0000; + private static final int SIMPLE_FALSE = 0b1000; + private static final int SIMPLE_TRUE = 0b1001; + private static final int SIMPLE_URL = 0b1100; + private static final int SIMPLE_URL_WITH_BASE = 0b1101; + private static final int SIMPLE_UUID = 0b1110; + private static final int SIMPLE_FILL = 0b1111; + + private static final byte[] MAGIC = "bplist".getBytes(StandardCharsets.US_ASCII); + + private final ByteBuffer data; + private final int[] offsets; + private final int refSize; + private final int rootObject; + private final Map decoderCache = HashMap.newHashMap(3); + + private SimpleBplistParser(ByteBuffer data) { + this.data = data; + data.position(data.limit() - 32 + 6); + final var offsetSize = data.get() & 0xff; + refSize = data.get() & 0xff; + offsets = new int[(int)data.getLong()]; + rootObject = (int)data.getLong(); + final var offsetTableStart = (int)data.getLong(); + readOffsetTable(offsetTableStart, offsetSize); + } + + public static Object parseBplist(ByteBuffer data) { + data.order(ByteOrder.BIG_ENDIAN); + data.position(0); + final var magic = new byte[MAGIC.length]; + data.get(magic); + if (!Arrays.equals(magic, MAGIC)) { + throw new IllegalArgumentException("Not a bplist file"); + } + data.get(); // Major + data.get(); // Minor + return new SimpleBplistParser(data).readRoot(); + } + + private Object readRoot() { + return readObjectByIndex(rootObject); + } + + private Object readObjectByIndex(int index) { + data.position(offsets[index]); + return readObject(); + } + + private Object readObject() { + final var marker = data.get() & 0xff; + final var type = (marker & 0xf0) >> 4; + final var typeExtra = marker & 0x0f; + return switch (type) { + case TYPE_SIMPLE -> switch (typeExtra) { + case SIMPLE_NULL -> null; + case SIMPLE_FALSE -> false; + case SIMPLE_TRUE -> true; + case SIMPLE_URL -> { + final var urlObj = readObject(); + if (!(urlObj instanceof String url)) { + throw new BplistParsingFailure("Expected url to be string"); + } + try { + yield new URI(url).toURL(); + } catch (URISyntaxException | MalformedURLException e) { + throw new BplistParsingFailure("Invalid URL " + url, e); + } + } + case SIMPLE_URL_WITH_BASE -> throw new BplistParsingFailure("Extended url not supported"); + case SIMPLE_UUID -> new UUID(data.getLong(), data.getLong()); + case SIMPLE_FILL -> throw new BplistParsingFailure("Should never parse fill byte"); + default -> throw new BplistParsingFailure("Unknown marker byte 0x" + Integer.toHexString(marker)); + }; + case TYPE_INT -> readIntObject(typeExtra); + case TYPE_REAL -> readReal(typeExtra); + case TYPE_DATE -> new Date((data.getLong() - CORE_DATA_EPOCH) * 1000L); + case TYPE_DATA -> { + final var result = new byte[readSize(typeExtra)]; + data.get(result); + yield result; + } + case TYPE_ASCII -> readString(StandardCharsets.US_ASCII, typeExtra); + case TYPE_UNICODE -> readString(StandardCharsets.UTF_16BE, typeExtra); + case TYPE_UTF8 -> readString(StandardCharsets.UTF_8, typeExtra); + case TYPE_UID -> throw new BplistParsingFailure("Unsupported object type uid"); + case TYPE_ARRAY -> readCollection(ArrayList::new, typeExtra); + case TYPE_ORDSET -> readCollection(LinkedHashSet::newLinkedHashSet, typeExtra); + case TYPE_SET -> readCollection(HashSet::newHashSet, typeExtra); + case TYPE_DICT -> readDict(typeExtra); + default -> throw new BplistParsingFailure("Unknown marker byte 0x" + Integer.toHexString(marker)); + }; + } + + private long readIntObject(int extra) { + return readIntSigned(1 << extra); + } + + private Number readReal(int extra) { + final var bits = readIntObject(extra); + return switch (extra) { + case 2 -> Float.intBitsToFloat((int)bits); + case 3 -> Double.longBitsToDouble(bits); + default -> throw new BplistParsingFailure("Unsupported real size 2^" + extra); + }; + } + + private String readString(Charset charset, int extra) { + final var decoder = decoderCache.computeIfAbsent( + charset, c -> c.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + ).reset(); + final var result = CharBuffer.allocate(readSize(extra)); + decoder.decode(data, result, true); + if (result.remaining() > 0) { + throw new BplistParsingFailure("Failed to read entire string (" + result.remaining() + " unread)"); + } + return result.flip().toString(); + } + + private > C readCollection(IntFunction factory, int extra) { + final var size = readSize(extra); + final var result = factory.apply(size); + var pos = data.position(); + for (var i = 0; i < size; i++) { + result.add(readObjectByIndex((int)readIntUnsigned(refSize))); + data.position(pos += refSize); + } + return result; + } + + private Map readDict(int extra) { + final var size = readSize(extra); + final var keys = new Object[size]; + final var values = new Object[size]; + var pos = data.position(); + for (var i = 0; i < size; i++) { + keys[i] = readObjectByIndex((int)readIntUnsigned(refSize)); + data.position(pos += refSize); + } + for (var i = 0; i < size; i++) { + values[i] = readObjectByIndex((int)readIntUnsigned(refSize)); + data.position(pos += refSize); + } + + final var result = HashMap.newHashMap(size); + for (var i = 0; i < size; i++) { + result.put(keys[i], values[i]); + } + return result; + } + + private int readSize(int extra) { + if (extra != 0xf) { + return extra; + } + return (int)readIntObject(data.get() & 0x0f); + } + + private void readOffsetTable(int offsetTableStart, int offsetSize) { + data.position(offsetTableStart); + for (var i = 0; i < offsets.length; i++) { + offsets[i] = (int)readIntUnsigned(offsetSize); + } + } + + private long readIntUnsigned(int size) { + return readInt(size, x -> x & 0xffL); + } + + private long readIntSigned(int size) { + return readInt(size, x -> (long)x); + } + + private long readInt(int size, Byte2LongFunction starter) { + if (size < 1) { + throw new IllegalArgumentException("Size < 1"); + } + var result = starter.get(data.get()); + for (var i = 1; i < size; i++) { + result = (result << 8) | (data.get() & 0xff); + } + return result; + } + + public static class BplistParsingFailure extends RuntimeException { + @Serial + private static final long serialVersionUID = -5280726282273772182L; + + public BplistParsingFailure(String message) { + super(message); + } + + public BplistParsingFailure(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/origincheck/parser/SimpleIniParser.java b/src/main/java/io/github/gaming32/worldhost/origincheck/parser/SimpleIniParser.java new file mode 100644 index 0000000..19e7ee2 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/origincheck/parser/SimpleIniParser.java @@ -0,0 +1,30 @@ +package io.github.gaming32.worldhost.origincheck.parser; + +import org.apache.commons.lang3.StringUtils; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +public class SimpleIniParser { + public static Map> parse(BufferedReader reader) throws IOException { + final var result = new LinkedHashMap>(); + Map section = new LinkedHashMap<>(); + result.put("", section); + + String line; + while ((line = reader.readLine()) != null) { + line = StringUtils.substringBefore(line, ';').trim(); + if (line.startsWith("[") && line.endsWith("]")) { + section = result.computeIfAbsent(line.substring(1, line.length() - 1), k -> new LinkedHashMap<>()); + continue; + } + final var split = line.split("=", 2); + if (split.length != 2) continue; + section.put(split[0].trim(), split[1].trim()); + } + + return result; + } +} diff --git a/src/main/resources/assets/world-host/lang/en_us.json b/src/main/resources/assets/world-host/lang/en_us.json index 22d7424..b76e473 100644 --- a/src/main/resources/assets/world-host/lang/en_us.json +++ b/src/main/resources/assets/world-host/lang/en_us.json @@ -71,5 +71,7 @@ "world-host.share_world": "Share World", "world-host.share_world.failed": "Failed to share world", "world-host.create_world": "Create World", - "world-host.world_with_security": "%s \u2022 %s" + "world-host.world_with_security": "%s \u2022 %s", + "world-host.nonstandard_origin": "Unofficial World Host download", + "world-host.nonstandard_origin.desc": "World Host was downloaded from an unofficial source (%s). The only official source is Modrinth. This message will not be shown again." } \ No newline at end of file diff --git a/version.gradle.kts b/version.gradle.kts index 76f4deb..7c8243b 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -76,7 +76,10 @@ unimined.minecraft { if (mcVersion <= 1_19_00) { searge() } - mojmap() + mojmap { + dependsOn("intermediary") + onlyExistingSrc() + } when { mcVersion >= 1_21_00 -> "1.21:2024.07.28" mcVersion >= 1_20_05 -> "1.20.6:2024.06.16"