diff --git a/src/main/java/com/vinurl/exe/Executable.java b/src/main/java/com/vinurl/exe/Executable.java index 3d57be8..a74a5ab 100644 --- a/src/main/java/com/vinurl/exe/Executable.java +++ b/src/main/java/com/vinurl/exe/Executable.java @@ -6,11 +6,14 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.net.URI; -import java.net.URISyntaxException; +import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -22,6 +25,7 @@ import java.util.zip.ZipInputStream; import static com.vinurl.client.VinURLClient.CONFIG; +import static com.vinurl.exe.GitHub.ReleaseInfo; import static com.vinurl.util.Constants.LOGGER; import static com.vinurl.util.Constants.VINURLPATH; @@ -92,7 +96,8 @@ public void killAllProcesses() { public boolean checkForExecutable() { if (DIRECTORY.toFile().exists() || DIRECTORY.toFile().mkdirs()) { if (!FILE_PATH.toFile().exists()) { - return downloadExecutable(); + ReleaseInfo release = GitHub.fetchLatestRelease(REPOSITORY_NAME, REPOSITORY_FILE); + return !release.isEmpty() && downloadExecutable(release); } else if (CONFIG.updatesOnStartup()) { checkForUpdates(); } @@ -102,34 +107,73 @@ public boolean checkForExecutable() { } public boolean checkForUpdates() { - return !currentVersion().equals(latestVersion()) && downloadExecutable(); + ReleaseInfo release = GitHub.fetchLatestRelease(REPOSITORY_NAME, REPOSITORY_FILE); + if (release.isEmpty() || release.version().equals(currentVersion())) { + return false; + } + return downloadExecutable(release); } - private boolean downloadExecutable() { - try (InputStream inputStream = getDownloadInputStream()) { + private boolean downloadExecutable(ReleaseInfo release) { + Path tempFile = null; + try { + tempFile = Files.createTempFile(DIRECTORY, FILE_NAME, ".tmp"); + tempFile.toFile().deleteOnExit(); + + String actualDigest = downloadToFile(GitHub.openAssetStream(REPOSITORY_NAME, REPOSITORY_FILE), tempFile); + + if (release.digest() != null && !release.digest().equals(actualDigest)) { + LOGGER.error("Digest verification failed for {} (expected {}, got {})", + REPOSITORY_FILE, release.digest(), actualDigest); + return false; + } + if (REPOSITORY_FILE.endsWith(".zip")) { - try (ZipInputStream zipInput = new ZipInputStream(inputStream)) { - ZipEntry zipEntry = zipInput.getNextEntry(); - while (zipEntry != null) { - if (zipEntry.getName().endsWith(FILE_NAME + (SystemUtils.IS_OS_WINDOWS ? ".exe" : ""))) { - Files.copy(zipInput, FILE_PATH, StandardCopyOption.REPLACE_EXISTING); - break; - } - zipEntry = zipInput.getNextEntry(); - } - } + extractFromZip(tempFile, FILE_PATH); } else { - Files.copy(inputStream, FILE_PATH, StandardCopyOption.REPLACE_EXISTING); + Files.move(tempFile, FILE_PATH, StandardCopyOption.REPLACE_EXISTING); + tempFile = null; } + if (SystemUtils.IS_OS_UNIX) { Runtime.getRuntime().exec(new String[] {"chmod", "+x", FILE_PATH.toString()}); } - return createVersionFile(latestVersion()); + return createVersionFile(release.version()); } catch (Exception e) { + LOGGER.error("Failed to download {}", REPOSITORY_FILE, e); return false; + } finally { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException ignored) {} + } } } + private String downloadToFile(InputStream input, Path target) throws IOException, NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + try (DigestInputStream digestInput = new DigestInputStream(input, md); + OutputStream output = Files.newOutputStream(target)) { + digestInput.transferTo(output); + } + return "sha256:" + HexFormat.of().formatHex(md.digest()); + } + + private void extractFromZip(Path zipFile, Path target) throws IOException { + String targetName = FILE_NAME + (SystemUtils.IS_OS_WINDOWS ? ".exe" : ""); + try (ZipInputStream zipInput = new ZipInputStream(Files.newInputStream(zipFile))) { + ZipEntry zipEntry; + while ((zipEntry = zipInput.getNextEntry()) != null) { + if (zipEntry.getName().endsWith(targetName)) { + Files.copy(zipInput, target, StandardCopyOption.REPLACE_EXISTING); + return; + } + } + } + throw new IOException("Entry not found in zip: " + targetName); + } + private boolean createVersionFile(String version) { try { Files.writeString(VERSION_PATH, version); @@ -147,20 +191,6 @@ public String currentVersion() { } } - private String latestVersion() { - String url = "https://api.github.com/repos/%s/releases/latest".formatted(REPOSITORY_NAME); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(new URI(url).toURL().openStream()))) { - return reader.readLine().split("\"tag_name\":\"")[1].split("\",\"target_commitish\"")[0]; - } catch (IOException | ArrayIndexOutOfBoundsException | URISyntaxException e) { - return ""; - } - } - - private InputStream getDownloadInputStream() throws IOException, URISyntaxException { - String url = "https://github.com/%s/releases/latest/download/%s".formatted(REPOSITORY_NAME, REPOSITORY_FILE); - return new URI(url).toURL().openStream(); - } - public ProcessStream executeCommand(String id, String... arguments) { return new ProcessStream(id, arguments); } diff --git a/src/main/java/com/vinurl/exe/GitHub.java b/src/main/java/com/vinurl/exe/GitHub.java new file mode 100644 index 0000000..3cce79a --- /dev/null +++ b/src/main/java/com/vinurl/exe/GitHub.java @@ -0,0 +1,84 @@ +package com.vinurl.exe; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.client.Minecraft; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; + +import static com.vinurl.util.Constants.MOD_ID; +import static com.vinurl.util.Constants.MOD_VERSION; + +public class GitHub { + private static final String USER_AGENT = "Java/%s %s/%s".formatted(System.getProperty("java.version"), MOD_ID, MOD_VERSION); + private static final String API_VERSION = "2022-11-28"; + + public record ReleaseInfo(String version, @Nullable String digest) { + public static final ReleaseInfo EMPTY = new ReleaseInfo("", null); + + public boolean isEmpty() { + return version.isEmpty(); + } + } + + public static ReleaseInfo fetchLatestRelease(String repository, String assetName) { + String url = "https://api.github.com/repos/%s/releases/latest".formatted(repository); + try (InputStream stream = openApiStream(url); + InputStreamReader reader = new InputStreamReader(stream)) { + JsonObject release = JsonParser.parseReader(reader).getAsJsonObject(); + + String version = release.get("tag_name").getAsString(); + + String digest = null; + JsonArray assets = release.getAsJsonArray("assets"); + for (JsonElement asset : assets) { + JsonObject assetObj = asset.getAsJsonObject(); + if (assetName.equals(assetObj.get("name").getAsString())) { + JsonElement digestElement = assetObj.get("digest"); + if (digestElement != null && !digestElement.isJsonNull()) { + digest = digestElement.getAsString(); + } + break; + } + } + + return new ReleaseInfo(version, digest); + } catch (Exception e) { + return ReleaseInfo.EMPTY; + } + } + + public static InputStream openAssetStream(String repository, String assetName) throws IOException { + String url = "https://github.com/%s/releases/latest/download/%s".formatted(repository, assetName); + return openStream(url); + } + + private static InputStream openApiStream(String url) throws IOException { + HttpURLConnection conn = openConnection(url); + conn.setRequestProperty("Accept", "application/vnd.github+json"); + conn.setRequestProperty("X-GitHub-Api-Version", API_VERSION); + return conn.getInputStream(); + } + + private static InputStream openStream(String url) throws IOException { + return openConnection(url).getInputStream(); + } + + private static HttpURLConnection openConnection(String url) throws IOException { + try { + HttpURLConnection conn = (HttpURLConnection) new URI(url).toURL().openConnection(Minecraft.getInstance().getProxy()); + conn.setRequestProperty("User-Agent", USER_AGENT); + return conn; + } catch (URISyntaxException e) { + throw new IOException("Invalid URL: " + url, e); + } + } +} diff --git a/src/main/java/com/vinurl/util/Constants.java b/src/main/java/com/vinurl/util/Constants.java index 8e07021..da213d3 100644 --- a/src/main/java/com/vinurl/util/Constants.java +++ b/src/main/java/com/vinurl/util/Constants.java @@ -16,6 +16,8 @@ public class Constants { //general public static final String MOD_ID = "vinurl"; + public static final String MOD_VERSION = FabricLoader.getInstance().getModContainer(MOD_ID) + .map(c -> c.getMetadata().getVersion().getFriendlyString()).orElse("unknown"); public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); public static final Path VINURLPATH = FabricLoader.getInstance().getGameDir().resolve(MOD_ID);