From a5fc62e40bd7241fca05505792bb5940c703e5cf Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Wed, 5 Aug 2026 12:36:51 +0200 Subject: [PATCH 1/8] Add the full-pack manifest contract --- build.gradle.kts | 2 + .../gtnhgradle/fullpack/FullPackManifest.java | 37 +++++ .../fullpack/FullPackManifestParser.java | 127 ++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifest.java create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifestParser.java diff --git a/build.gradle.kts b/build.gradle.kts index d23bf07b..41545edd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,6 +43,8 @@ dependencies { implementation("org.jdom:jdom2:2.0.6.1") // Maven artifact for version comparison implementation("org.apache.maven:maven-artifact:3.9.9") + // Full-pack manifest parsing + implementation("com.google.code.gson:gson:2.13.2") // All these plugins will be present in the classpath of the project using our plugin, but not activated until explicitly applied api(pluginDep("com.gtnewhorizons.retrofuturagradle","2.0.2")) diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifest.java b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifest.java new file mode 100644 index 00000000..1ee6e045 --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifest.java @@ -0,0 +1,37 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +import com.google.gson.annotations.SerializedName; + +/** An installation plan for a complete GTNH client. */ +public record FullPackManifest(String digest, List files, + List archives, Map textFiles) { + + public sealed interface Asset permits File, Archive { + + URI url(); + + Authentication authentication(); + } + + /** One file copied directly into the runtime. */ + public record File(String owner, String path, URI url, MavenModule maven, Authentication authentication) + implements Asset {} + + /** One ZIP archive extracted into the runtime root. */ + public record Archive(URI url, List exclude, boolean keepExisting, Authentication authentication) + implements Asset {} + + /** Maven coordinates of a mod in the pack. */ + public record MavenModule(String group, String name, String version) {} + + public enum Authentication { + @SerializedName("none") + NONE, + @SerializedName("github") + GITHUB + } +} diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifestParser.java b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifestParser.java new file mode 100644 index 00000000..177c125e --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifestParser.java @@ -0,0 +1,127 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; + +/** Parses full-pack installation plans. */ +public final class FullPackManifestParser { + + private static final int SUPPORTED_VERSION = 1; + private static final Gson GSON = new Gson(); + + private FullPackManifestParser() {} + + public static FullPackManifest parse(String json) { + final ManifestJson parsed; + try { + parsed = GSON.fromJson(json, ManifestJson.class); + } catch (JsonParseException e) { + throw new IllegalArgumentException("Full-pack manifest is not valid JSON", e); + } + if (parsed == null) { + throw new IllegalArgumentException("Full-pack manifest is empty"); + } + if (parsed.version() != SUPPORTED_VERSION) { + throw new IllegalArgumentException("Unsupported full-pack manifest version: " + parsed.version()); + } + + final List files = new ArrayList<>(); + final List parsedFiles = orEmpty(parsed.files()); + for (int i = 0; i < parsedFiles.size(); i++) { + final FileJson file = parsedFiles.get(i); + final String path = normalizePath(file.path(), "files[" + i + "].path"); + final URI url = URI.create(file.url()); + final FullPackManifest.Authentication authentication = authentication(file.authentication()); + validateAuthenticationUrl(authentication, url, "file " + i); + files.add(new FullPackManifest.File(file.owner(), path, url, parseMaven(file.maven(), i), authentication)); + } + + final List archives = new ArrayList<>(); + final List parsedArchives = orEmpty(parsed.archives()); + for (int i = 0; i < parsedArchives.size(); i++) { + final ArchiveJson archive = parsedArchives.get(i); + final URI url = URI.create(archive.url()); + final FullPackManifest.Authentication authentication = authentication(archive.authentication()); + validateAuthenticationUrl(authentication, url, "archive " + i); + final List excludes = new ArrayList<>(); + final List parsedExcludes = orEmpty(archive.exclude()); + for (int j = 0; j < parsedExcludes.size(); j++) { + excludes.add(normalizePath(parsedExcludes.get(j), "archives[" + i + "].exclude[" + j + "]")); + } + archives.add(new FullPackManifest.Archive(url, excludes, archive.keepExisting(), authentication)); + } + + final Map textFiles = new LinkedHashMap<>(); + if (parsed.textFiles() != null) { + parsed.textFiles() + .forEach((path, content) -> textFiles.put(normalizePath(path, "textFiles path"), content)); + } + return new FullPackManifest( + FullPackAssetCache.sha256(json), + List.copyOf(files), + List.copyOf(archives), + Map.copyOf(textFiles)); + } + + private static List orEmpty(List values) { + return values == null ? List.of() : values; + } + + private static FullPackManifest.MavenModule parseMaven(String value, int fileIndex) { + if (value == null) { + return null; + } + final String[] parts = value.split(":", -1); + if (parts.length != 3 || parts[0].isBlank() || parts[1].isBlank() || parts[2].isBlank()) { + throw new IllegalArgumentException( + "Invalid Maven coordinates in full-pack file " + fileIndex + ": " + value); + } + return new FullPackManifest.MavenModule(parts[0], parts[1], parts[2]); + } + + private static FullPackManifest.Authentication authentication(FullPackManifest.Authentication authentication) { + return authentication == null ? FullPackManifest.Authentication.NONE : authentication; + } + + private static void validateAuthenticationUrl(FullPackManifest.Authentication authentication, URI url, + String asset) { + if (authentication == FullPackManifest.Authentication.NONE) { + return; + } + final boolean githubAssetApi = "https".equalsIgnoreCase(url.getScheme()) + && "api.github.com".equalsIgnoreCase(url.getHost()) + && url.getPath() + .matches("/repos/[^/]+/[^/]+/releases/assets/[0-9]+"); + if (!githubAssetApi) { + throw new IllegalArgumentException( + "Full-pack manifest " + asset + " can only use GitHub authentication with the GitHub Assets API"); + } + } + + static String normalizePath(String value, String field) { + if (value == null || value.isBlank() || value.startsWith("/") || value.contains("\\")) { + throw new IllegalArgumentException("Full-pack manifest field " + field + " contains an unsafe path"); + } + for (String segment : value.split("/", -1)) { + if (segment.isEmpty() || segment.equals(".") || segment.equals("..")) { + throw new IllegalArgumentException("Full-pack manifest field " + field + " contains an unsafe path"); + } + } + return value; + } + + private record ManifestJson(int version, List files, List archives, + Map textFiles) {} + + private record FileJson(String owner, String path, String url, String maven, + FullPackManifest.Authentication authentication) {} + + private record ArchiveJson(String url, List exclude, boolean keepExisting, + FullPackManifest.Authentication authentication) {} +} From 8033c2005be762dba52c41aba74ea2acc5988d31 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Wed, 5 Aug 2026 12:36:51 +0200 Subject: [PATCH 2/8] Add content-addressed full-pack asset cache --- .../fullpack/FullPackAssetCache.java | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackAssetCache.java diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackAssetCache.java b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackAssetCache.java new file mode 100644 index 00000000..c0d7933c --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackAssetCache.java @@ -0,0 +1,154 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import de.undercouch.gradle.tasks.download.DownloadAction; + +/** A shared cache keyed by the asset URL. */ +public final class FullPackAssetCache { + + private final Path root; + private final String githubToken; + private final DownloadAction publicDownload; + private final DownloadAction githubDownload; + + public FullPackAssetCache(Path root, String githubToken, DownloadAction publicDownload, + DownloadAction githubDownload) { + this.root = root; + this.githubToken = githubToken == null ? "" : githubToken.trim(); + this.publicDownload = publicDownload; + this.githubDownload = githubDownload; + } + + public Path resolve(FullPackManifest.Asset asset) { + return resolveAll(List.of(asset)).get(0); + } + + public List resolveAll(List assets) { + if (assets.isEmpty()) { + return List.of(); + } + + final List missingAssets = assets.stream() + .filter( + asset -> !Files.isRegularFile( + objectPath( + asset.url() + .toString()))) + .toList(); + final List githubAssets = missingAssets.stream() + .filter(asset -> asset.authentication() == FullPackManifest.Authentication.GITHUB) + .toList(); + if (!githubAssets.isEmpty() && githubToken.isBlank()) { + throw new IllegalStateException( + "GitHub authentication required for full-pack asset " + githubAssets.get(0) + .url() + "; configure fullPack.githubToken or GITHUB_TOKEN"); + } + + download( + missingAssets.stream() + .filter(asset -> asset.authentication() == FullPackManifest.Authentication.NONE) + .toList(), + Map.of(), + publicDownload); + download( + githubAssets, + Map.of( + "Accept", + "application/octet-stream", + "Authorization", + "Bearer " + githubToken, + "X-GitHub-Api-Version", + "2022-11-28"), + githubDownload); + return assets.stream() + .map( + asset -> objectPath( + asset.url() + .toString())) + .toList(); + } + + private void download(List assets, Map headers, + DownloadAction download) { + final List urls = assets.stream() + .map(FullPackManifest.Asset::url) + .distinct() + .toList(); + if (urls.isEmpty()) { + return; + } + + final Path objects = root.resolve("objects") + .resolve("sha256"); + try { + Files.createDirectories(objects); + download.src(urls); + if (urls.size() == 1) { + download.dest( + objectPath( + urls.get(0) + .toString()) + .toFile()); + } else { + download.dest(objects.toFile()); + download.eachFile( + details -> details.setPath( + objectRelativePath( + details.getSourceURL() + .toString()))); + } + download.header("User-Agent", "GTNHGradle-fullpack"); + download.headers(headers); + download.connectTimeout(30_000); + download.readTimeout(15 * 60_000); + download.overwrite(false); + download.tempAndMove(true); + download.execute() + .get(); + } catch (InterruptedException e) { + Thread.currentThread() + .interrupt(); + throw new IllegalStateException("Interrupted while downloading full-pack assets", e); + } catch (ExecutionException e) { + if (e.getCause() instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Failed to download full-pack assets", e.getCause()); + } catch (IOException e) { + throw new UncheckedIOException("Failed to cache full-pack assets", e); + } + } + + Path objectPath(String url) { + return root.resolve("objects") + .resolve("sha256") + .resolve(objectRelativePath(url)); + } + + private static String objectRelativePath(String url) { + final String key = sha256(url); + return key.substring(0, 2) + "/" + key.substring(2); + } + + static String sha256(String value) { + try { + return java.util.HexFormat.of() + .formatHex( + MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } +} From 49aad5f8f0fe0a13ecedc5ec75d92730f6fc3ae4 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Wed, 5 Aug 2026 12:36:51 +0200 Subject: [PATCH 3/8] Resolve local full-pack dependency overlays --- .../FullPackDependencyOverlayPlanner.java | 85 +++++++++++++ .../MavenLocalProductionArtifactLocator.java | 113 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackDependencyOverlayPlanner.java create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/MavenLocalProductionArtifactLocator.java diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackDependencyOverlayPlanner.java b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackDependencyOverlayPlanner.java new file mode 100644 index 00000000..0d00f17a --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackDependencyOverlayPlanner.java @@ -0,0 +1,85 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.apache.maven.artifact.versioning.ComparableVersion; + +/** Selects production dependency JARs which should replace entries from the daily pack. */ +public final class FullPackDependencyOverlayPlanner { + + public enum Source { + REMOTE, + MAVEN_LOCAL + } + + public record Artifact(FullPackManifest.MavenModule module, Path file, Source source) {} + + public record Overlay(String manifestPath, Path source) {} + + private FullPackDependencyOverlayPlanner() {} + + public static List plan(FullPackManifest manifest, List artifacts, + List requestedModules, boolean preferMavenLocal) { + final List overlays = new ArrayList<>(); + for (FullPackManifest.File file : manifest.files()) { + if (file.maven() == null) { + continue; + } + final Artifact selected = artifacts.stream() + .filter(artifact -> sameModule(file.maven(), artifact.module())) + .filter( + artifact -> artifact.source() == Source.MAVEN_LOCAL ? preferMavenLocal + : isSameOrNewer(artifact.module(), file.maven())) + .max( + Comparator.comparing((Artifact artifact) -> artifact.source() == Source.MAVEN_LOCAL) + .thenComparing( + artifact -> new ComparableVersion( + artifact.module() + .version()))) + .orElse(null); + if (selected != null) { + overlays.add(new Overlay(file.path(), selected.file())); + continue; + } + + final FullPackManifest.MavenModule newestRequested = requestedModules.stream() + .filter(module -> sameModule(file.maven(), module)) + .max( + (first, second) -> new ComparableVersion(first.version()) + .compareTo(new ComparableVersion(second.version()))) + .orElse(null); + if (newestRequested != null && isNewer(newestRequested, file.maven())) { + throw new IllegalStateException( + "Could not resolve a production SRG JAR for " + coordinates(newestRequested)); + } + } + return List.copyOf(overlays); + } + + private static boolean sameModule(FullPackManifest.MavenModule first, FullPackManifest.MavenModule second) { + return first.group() + .equals(second.group()) + && first.name() + .equals(second.name()); + } + + private static boolean isSameOrNewer(FullPackManifest.MavenModule candidate, + FullPackManifest.MavenModule baseline) { + return new ComparableVersion(candidate.version()).compareTo(new ComparableVersion(baseline.version())) >= 0; + } + + private static boolean isNewer(FullPackManifest.MavenModule candidate, FullPackManifest.MavenModule baseline) { + return new ComparableVersion(candidate.version()).compareTo(new ComparableVersion(baseline.version())) > 0; + } + + private static String moduleKey(FullPackManifest.MavenModule module) { + return module.group() + ":" + module.name(); + } + + private static String coordinates(FullPackManifest.MavenModule module) { + return moduleKey(module) + ":" + module.version(); + } +} diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/MavenLocalProductionArtifactLocator.java b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/MavenLocalProductionArtifactLocator.java new file mode 100644 index 00000000..f7e1220c --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/MavenLocalProductionArtifactLocator.java @@ -0,0 +1,113 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** Locates exact production SRG artifacts published by another local GTNH checkout. */ +public final class MavenLocalProductionArtifactLocator { + + private static final String OBFUSCATION_ATTRIBUTE = "com.gtnewhorizons.retrofuturagradle.obfuscation"; + + private MavenLocalProductionArtifactLocator() {} + + public static List find(Path repository, + List requestedModules) { + final List artifacts = new ArrayList<>(); + for (FullPackManifest.MavenModule module : requestedModules) { + final Path versionDirectory = moduleDirectory(repository, module); + final String artifactName = module.name() + "-" + module.version(); + final Path moduleMetadata = versionDirectory.resolve(artifactName + ".module"); + final Path productionJar = versionDirectory.resolve(artifactName + ".jar"); + if (!Files.isRegularFile(moduleMetadata) || !Files.isRegularFile(productionJar)) { + continue; + } + if (declaresSrgProductionJar( + moduleMetadata, + productionJar.getFileName() + .toString())) { + artifacts.add( + new FullPackDependencyOverlayPlanner.Artifact( + module, + productionJar, + FullPackDependencyOverlayPlanner.Source.MAVEN_LOCAL)); + } + } + return List.copyOf(artifacts); + } + + private static Path moduleDirectory(Path repository, FullPackManifest.MavenModule module) { + Path result = repository.toAbsolutePath() + .normalize(); + for (String groupSegment : module.group() + .split("\\.")) { + result = resolveCoordinateSegment(result, groupSegment, "group"); + } + result = resolveCoordinateSegment(result, module.name(), "name"); + return resolveCoordinateSegment(result, module.version(), "version"); + } + + private static Path resolveCoordinateSegment(Path parent, String segment, String field) { + if (segment == null || segment.isBlank() + || segment.equals(".") + || segment.equals("..") + || segment.contains("/") + || segment.contains("\\")) { + throw new IllegalArgumentException("Invalid Maven module " + field + ": " + segment); + } + return parent.resolve(segment); + } + + private static boolean declaresSrgProductionJar(Path moduleMetadata, String productionJarName) { + final JsonObject metadata; + try { + metadata = JsonParser.parseString(Files.readString(moduleMetadata, StandardCharsets.UTF_8)) + .getAsJsonObject(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read Maven Local module metadata: " + moduleMetadata, e); + } catch (RuntimeException e) { + throw new IllegalArgumentException("Invalid Maven Local module metadata: " + moduleMetadata, e); + } + + final JsonElement variants = metadata.get("variants"); + if (variants == null || !variants.isJsonArray()) { + return false; + } + for (JsonElement variantElement : variants.getAsJsonArray()) { + if (!variantElement.isJsonObject()) { + continue; + } + final JsonObject variant = variantElement.getAsJsonObject(); + if (!"reobfElements".equals(text(variant, "name"))) { + continue; + } + final JsonObject attributes = variant.getAsJsonObject("attributes"); + if (attributes == null || !"srg".equals(text(attributes, OBFUSCATION_ATTRIBUTE))) { + continue; + } + final JsonElement files = variant.get("files"); + if (files != null && files.isJsonArray()) { + for (JsonElement fileElement : files.getAsJsonArray()) { + if (fileElement.isJsonObject() + && productionJarName.equals(text(fileElement.getAsJsonObject(), "name"))) { + return true; + } + } + } + } + return false; + } + + private static String text(JsonObject object, String field) { + final JsonElement value = object.get(field); + return value == null || !value.isJsonPrimitive() ? null : value.getAsString(); + } +} From d630ec66c807ce969a82f399a1b8a70f3b048b60 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Wed, 5 Aug 2026 12:36:51 +0200 Subject: [PATCH 4/8] Materialize isolated full-pack runtimes --- .../fullpack/FullPackInstaller.java | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackInstaller.java diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackInstaller.java b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackInstaller.java new file mode 100644 index 00000000..0372a197 --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackInstaller.java @@ -0,0 +1,198 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import de.undercouch.gradle.tasks.download.DownloadAction; + +/** Downloads and materializes a resolved full-pack client runtime. */ +public final class FullPackInstaller { + + private final Path root; + private final FullPackAssetCache assetCache; + + public FullPackInstaller(Path root, String githubToken, DownloadAction publicDownload, + DownloadAction githubDownload) { + this.root = root; + this.assetCache = new FullPackAssetCache(root, githubToken, publicDownload, githubDownload); + } + + public Path prepare(FullPackManifest manifest, String currentOwner, Path currentModJar) { + return prepare(manifest, currentOwner, currentModJar, List.of()); + } + + public Path prepare(FullPackManifest manifest, String currentOwner, Path currentModJar, + List dependencyOverlays) { + if (currentOwner == null || currentOwner.isBlank()) { + throw new IllegalArgumentException("Current full-pack asset owner is required"); + } + if (!Files.isRegularFile(currentModJar)) { + throw new IllegalArgumentException("Current mod JAR does not exist: " + currentModJar); + } + + final Path runsRoot = root.resolve("runs") + .toAbsolutePath() + .normalize(); + final Path runtime = resolveInside( + runsRoot, + sanitize( + currentOwner) + "/" + checkoutKey(currentModJar) + "/" + "client" + "/" + sanitize(manifest.digest())); + try { + Files.createDirectories(runtime); + final String localJarPath = manifest.files() + .stream() + .filter(file -> currentOwner.equalsIgnoreCase(file.owner())) + .map(FullPackManifest.File::path) + .findFirst() + .orElse("mods/" + sanitize(currentOwner) + ".jar"); + final Set overlayPaths = new HashSet<>(); + dependencyOverlays.forEach(overlay -> overlayPaths.add(overlay.manifestPath())); + final List files = manifest.files() + .stream() + .filter( + file -> file.owner() == null || !file.owner() + .equalsIgnoreCase(currentOwner)) + .filter(file -> !overlayPaths.contains(file.path())) + .toList(); + final List assets = new ArrayList<>(files); + assets.addAll(manifest.archives()); + final List sources = assetCache.resolveAll(assets); + for (int i = 0; i < files.size(); i++) { + final FullPackManifest.File file = files.get(i); + final Path source = sources.get(i); + final Path destination = resolveInside(runtime, file.path()); + Files.createDirectories(destination.getParent()); + installImmutableFile(source, destination); + } + for (int i = 0; i < manifest.archives() + .size(); i++) { + final FullPackManifest.Archive archive = manifest.archives() + .get(i); + extractZip(sources.get(files.size() + i), runtime, archive.exclude(), archive.keepExisting()); + } + + installDependencyOverlays(runtime, dependencyOverlays); + installTextFiles(runtime, manifest.textFiles()); + + final Path localJarDestination = resolveInside(runtime, localJarPath); + Files.createDirectories(localJarDestination.getParent()); + Files.copy(currentModJar, localJarDestination, StandardCopyOption.REPLACE_EXISTING); + return runtime; + } catch (IOException e) { + throw new UncheckedIOException("Failed to materialize full-pack runtime", e); + } + } + + private static void installDependencyOverlays(Path runtime, + List dependencyOverlays) throws IOException { + for (FullPackDependencyOverlayPlanner.Overlay overlay : dependencyOverlays) { + if (!Files.isRegularFile(overlay.source())) { + throw new IllegalArgumentException( + "Full-pack dependency overlay JAR does not exist: " + overlay.source()); + } + final Path destination = resolveInside(runtime, overlay.manifestPath()); + Files.createDirectories(destination.getParent()); + Files.copy(overlay.source(), destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void installTextFiles(Path runtime, Map textFiles) throws IOException { + for (Map.Entry textFile : textFiles.entrySet()) { + final Path destination = resolveInside(runtime, textFile.getKey()); + Files.createDirectories(destination.getParent()); + Files.writeString(destination, textFile.getValue(), StandardCharsets.UTF_8); + } + } + + private static void installImmutableFile(Path source, Path destination) throws IOException { + Files.deleteIfExists(destination); + try { + Files.createLink(destination, source); + } catch (UnsupportedOperationException | IOException e) { + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void extractZip(Path source, Path destinationRoot, Iterable exclusions, boolean keepExisting) + throws IOException { + Files.createDirectories(destinationRoot); + final Set excluded = new HashSet<>(); + exclusions.forEach(excluded::add); + try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(source))) { + ZipEntry zipEntry; + while ((zipEntry = zip.getNextEntry()) != null) { + final String name = zipEntry.getName(); + final Path destination = resolveZipEntry(destinationRoot, name); + final String canonicalName = destinationRoot.relativize(destination) + .toString() + .replace('\\', '/'); + if (isExcluded(canonicalName, excluded) || keepExisting && Files.exists(destination)) { + continue; + } + if (zipEntry.isDirectory()) { + Files.createDirectories(destination); + } else { + Files.createDirectories(destination.getParent()); + Files.copy(zip, destination, StandardCopyOption.REPLACE_EXISTING); + } + zip.closeEntry(); + } + } + } + + private static boolean isExcluded(String path, Set exclusions) { + for (String excluded : exclusions) { + if (path.equals(excluded) || path.startsWith(excluded + "/")) { + return true; + } + } + return false; + } + + private static Path resolveZipEntry(Path destinationRoot, String name) { + if (name == null || name.isBlank() || name.startsWith("/") || name.contains("\\")) { + throw new IllegalArgumentException("Full-pack ZIP contains an unsafe path: " + name); + } + final Path destination = destinationRoot.resolve(name) + .normalize(); + if (!destination.startsWith(destinationRoot)) { + throw new IllegalArgumentException("Full-pack ZIP entry escapes the runtime: " + name); + } + return destination; + } + + private static Path resolveInside(Path root, String relativePath) { + final Path resolved = root.resolve(relativePath) + .normalize(); + if (!resolved.startsWith(root)) { + throw new IllegalArgumentException("Full-pack destination escapes the runtime: " + relativePath); + } + return resolved; + } + + private static String sanitize(String value) { + final String sanitized = value.replaceAll("[^A-Za-z0-9._-]", "_"); + if (sanitized.isBlank() || sanitized.equals(".") || sanitized.equals("..")) { + throw new IllegalArgumentException("Current full-pack asset owner cannot identify a runtime directory"); + } + return sanitized; + } + + private static String checkoutKey(Path currentModJar) { + final Path checkoutOutput = currentModJar.toAbsolutePath() + .normalize() + .getParent(); + return FullPackAssetCache.sha256(checkoutOutput.toString()); + } +} From bc1351df816c24d89deded6522d633147236c861 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Wed, 5 Aug 2026 12:36:51 +0200 Subject: [PATCH 5/8] Prepare full-pack client runtimes --- .../fullpack/FullPackExtension.java | 18 ++ .../fullpack/PrepareFullPackClientTask.java | 204 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackExtension.java create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/PrepareFullPackClientTask.java diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackExtension.java b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackExtension.java new file mode 100644 index 00000000..0796e5ca --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackExtension.java @@ -0,0 +1,18 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.Property; + +/** Configuration for assembling and running a complete GTNH client. */ +public abstract class FullPackExtension { + + public abstract Property getManifestUrl(); + + public abstract Property getOwner(); + + public abstract Property getGitHubToken(); + + public abstract DirectoryProperty getCacheDirectory(); + + public abstract Property getPreferMavenLocal(); +} diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/PrepareFullPackClientTask.java b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/PrepareFullPackClientTask.java new file mode 100644 index 00000000..99f4643b --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/fullpack/PrepareFullPackClientTask.java @@ -0,0 +1,204 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import javax.inject.Inject; + +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; +import org.gradle.work.DisableCachingByDefault; + +import de.undercouch.gradle.tasks.download.DownloadAction; + +/** Resolves the full-pack manifest and materializes a production client runtime. */ +@DisableCachingByDefault(because = "The remote manifest may change without its URL changing") +public abstract class PrepareFullPackClientTask extends DefaultTask { + + private final DownloadAction manifestDownload; + private final DownloadAction publicAssetsDownload; + private final DownloadAction githubAssetsDownload; + + @Inject + public PrepareFullPackClientTask() { + manifestDownload = new DownloadAction(getProject(), this); + publicAssetsDownload = new DownloadAction(getProject(), this); + githubAssetsDownload = new DownloadAction(getProject(), this); + } + + @Input + public abstract Property getManifestUrl(); + + @Input + public abstract Property getOwner(); + + @Internal + public abstract Property getGitHubToken(); + + @InputFile + @PathSensitive(PathSensitivity.NONE) + public abstract RegularFileProperty getLocalModJar(); + + @Internal + public abstract DirectoryProperty getCacheDirectory(); + + @Input + public abstract Property getPreferMavenLocal(); + + @Internal + public abstract DirectoryProperty getMavenLocalRepository(); + + @InputFiles + @PathSensitive(PathSensitivity.NONE) + public abstract ConfigurableFileCollection getProductionOverlayFiles(); + + @Input + public abstract MapProperty getProductionOverlayArtifacts(); + + @Input + public abstract ListProperty getRequestedProductionModules(); + + @OutputFile + public abstract RegularFileProperty getRuntimePathFile(); + + @OutputFile + public abstract RegularFileProperty getLauncherPatchFile(); + + @TaskAction + public void prepareClient() { + final FullPackManifest manifest = FullPackManifestParser.parse(downloadManifest()); + final List requestedModules = getRequestedProductionModules().getOrElse(List.of()) + .stream() + .map(PrepareFullPackClientTask::parseModule) + .toList(); + final List artifacts = productionArtifacts(requestedModules); + final List overlays = FullPackDependencyOverlayPlanner + .plan(manifest, artifacts, requestedModules, getPreferMavenLocal().get()); + final Path runtime = new FullPackInstaller( + getCacheDirectory().getAsFile() + .get() + .toPath(), + getGitHubToken().getOrElse(""), + publicAssetsDownload, + githubAssetsDownload).prepare( + manifest, + getOwner().get(), + getLocalModJar().getAsFile() + .get() + .toPath(), + overlays); + copyLauncherPatch(runtime); + writeRuntimePath(runtime); + getLogger().lifecycle("Prepared GTNH client at {}", runtime); + } + + private List productionArtifacts( + List requestedModules) { + final List artifacts = new ArrayList<>(); + for (Map.Entry entry : getProductionOverlayArtifacts().getOrElse(Map.of()) + .entrySet()) { + artifacts.add( + new FullPackDependencyOverlayPlanner.Artifact( + parseModule(entry.getKey()), + Path.of(entry.getValue()), + FullPackDependencyOverlayPlanner.Source.REMOTE)); + } + if (getPreferMavenLocal().get()) { + artifacts.addAll( + MavenLocalProductionArtifactLocator.find( + getMavenLocalRepository().getAsFile() + .get() + .toPath(), + requestedModules)); + } + return List.copyOf(artifacts); + } + + private static FullPackManifest.MavenModule parseModule(String coordinates) { + final String[] parts = coordinates.split(":", -1); + if (parts.length != 3 || parts[0].isBlank() || parts[1].isBlank() || parts[2].isBlank()) { + throw new IllegalArgumentException("Invalid full-pack Maven module coordinates: " + coordinates); + } + return new FullPackManifest.MavenModule(parts[0], parts[1], parts[2]); + } + + private void copyLauncherPatch(Path runtime) { + final Path source = runtime.resolve(".gtnh/launcher/lwjgl3ify-forgePatches.jar"); + final Path destination = getLauncherPatchFile().getAsFile() + .get() + .toPath(); + if (!Files.isRegularFile(source)) { + throw new GradleException("Prepared full-pack runtime is missing " + source); + } + try { + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("Failed to stage the full-pack launcher patch", e); + } + } + + private String downloadManifest() { + final Path destination = getTemporaryDir().toPath() + .resolve("manifest.json"); + manifestDownload.src(getManifestUrl().get()); + manifestDownload.dest(destination.toFile()); + manifestDownload.header("User-Agent", "GTNHGradle-fullpack"); + manifestDownload.connectTimeout(30_000); + manifestDownload.readTimeout(2 * 60_000); + manifestDownload.overwrite(true); + manifestDownload.tempAndMove(true); + try { + manifestDownload.execute(true) + .get(); + return Files.readString(destination, StandardCharsets.UTF_8); + } catch (InterruptedException e) { + Thread.currentThread() + .interrupt(); + throw new GradleException("Interrupted while downloading the full-pack manifest", e); + } catch (ExecutionException e) { + throw new GradleException("Failed to download the full-pack manifest", e.getCause()); + } catch (IOException e) { + throw new UncheckedIOException("Failed to download the full-pack manifest", e); + } + } + + private void writeRuntimePath(Path runtime) { + final Path destination = getRuntimePathFile().getAsFile() + .get() + .toPath(); + try { + Files.createDirectories(destination.getParent()); + Files.writeString( + destination, + runtime.toAbsolutePath() + .normalize() + .toString(), + StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Failed to record the prepared full-pack runtime", e); + } + } +} From 0ef675ada391c3cb2a1327c8adb0c8409099f7e2 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Wed, 5 Aug 2026 12:36:51 +0200 Subject: [PATCH 6/8] Wire full-pack production launches --- .../gtnhgradle/GTNHGradlePlugin.java | 2 + .../gtnhgradle/modules/FullPackModule.java | 294 ++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 src/main/java/com/gtnewhorizons/gtnhgradle/modules/FullPackModule.java diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/GTNHGradlePlugin.java b/src/main/java/com/gtnewhorizons/gtnhgradle/GTNHGradlePlugin.java index b157b3b2..24016e6e 100644 --- a/src/main/java/com/gtnewhorizons/gtnhgradle/GTNHGradlePlugin.java +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/GTNHGradlePlugin.java @@ -7,6 +7,7 @@ import com.gtnewhorizons.retrofuturagradle.shadow.com.google.common.collect.ImmutableMap; import com.gtnewhorizons.gtnhgradle.modules.AccessTransformerModule; import com.gtnewhorizons.gtnhgradle.modules.CodeStyleModule; +import com.gtnewhorizons.gtnhgradle.modules.FullPackModule; import com.gtnewhorizons.gtnhgradle.modules.GitVersionModule; import com.gtnewhorizons.gtnhgradle.modules.IdeIntegrationModule; import com.gtnewhorizons.gtnhgradle.modules.JVMDowngraderModule; @@ -101,6 +102,7 @@ public static abstract class GTNHExtension implements ExtensionAware { GitVersionModule.class, CodeStyleModule.class, ToolchainModule.class, + FullPackModule.class, ScalaModule.class, StructureCheckModule.class, AccessTransformerModule.class, diff --git a/src/main/java/com/gtnewhorizons/gtnhgradle/modules/FullPackModule.java b/src/main/java/com/gtnewhorizons/gtnhgradle/modules/FullPackModule.java new file mode 100644 index 00000000..a92f570e --- /dev/null +++ b/src/main/java/com/gtnewhorizons/gtnhgradle/modules/FullPackModule.java @@ -0,0 +1,294 @@ +package com.gtnewhorizons.gtnhgradle.modules; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.gradle.api.Project; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.Dependency; +import org.gradle.api.artifacts.DependencyArtifact; +import org.gradle.api.artifacts.ExternalModuleDependency; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.attributes.Category; +import org.gradle.api.attributes.LibraryElements; +import org.gradle.api.attributes.Usage; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.TaskContainer; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.jvm.toolchain.JavaLanguageVersion; +import org.jetbrains.annotations.NotNull; + +import com.gtnewhorizons.gtnhgradle.GTNHGradlePlugin; +import com.gtnewhorizons.gtnhgradle.GTNHModule; +import com.gtnewhorizons.gtnhgradle.PropertiesConfiguration; +import com.gtnewhorizons.gtnhgradle.fullpack.FullPackExtension; +import com.gtnewhorizons.gtnhgradle.fullpack.PrepareFullPackClientTask; +import com.gtnewhorizons.retrofuturagradle.MinecraftExtension; +import com.gtnewhorizons.retrofuturagradle.ObfuscationAttribute; +import com.gtnewhorizons.retrofuturagradle.mcp.MCPTasks; +import com.gtnewhorizons.retrofuturagradle.mcp.ReobfuscatedJar; +import com.gtnewhorizons.retrofuturagradle.minecraft.MinecraftTasks; +import com.gtnewhorizons.retrofuturagradle.minecraft.RunMinecraftTask; +import com.gtnewhorizons.retrofuturagradle.util.Distribution; + +/** Adds tasks which run the locally built mod inside a complete GTNH client. */ +public class FullPackModule implements GTNHModule { + + public static final String DEFAULT_MANIFEST_URL = "https://raw.githubusercontent.com/GTNewHorizons/" + + "DreamAssemblerXXL/master/releases/manifests/fullpack/daily.json"; + + @Override + public boolean isEnabled(@NotNull PropertiesConfiguration configuration) { + return true; + } + + @Override + public void apply(GTNHGradlePlugin.@NotNull GTNHExtension gtnh, @NotNull Project project) throws Throwable { + final FullPackExtension extension = project.getExtensions() + .create("fullPack", FullPackExtension.class); + extension.getManifestUrl() + .convention(DEFAULT_MANIFEST_URL); + extension.getOwner() + .convention(project.getName()); + extension.getGitHubToken() + .convention( + project.getProviders() + .environmentVariable("GITHUB_TOKEN")); + extension.getPreferMavenLocal() + .convention(false); + extension.getCacheDirectory() + .convention( + project.getLayout() + .dir( + project.provider( + () -> new File( + project.getGradle() + .getGradleUserHomeDir(), + "caches/gtnh/fullpack")))); + + final Configuration productionMods = project.getConfigurations() + .create("fullPackProductionMods", configuration -> { + configuration.setDescription("Production SRG variants considered for full-pack dependency overlays"); + configuration.setCanBeConsumed(false); + configuration.setCanBeResolved(true); + configuration.setVisible(false); + configuration.getAttributes() + .attribute( + Usage.USAGE_ATTRIBUTE, + project.getObjects() + .named(Usage.class, Usage.JAVA_RUNTIME)); + configuration.getAttributes() + .attribute( + Category.CATEGORY_ATTRIBUTE, + project.getObjects() + .named(Category.class, Category.LIBRARY)); + configuration.getAttributes() + .attribute( + LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, + project.getObjects() + .named(LibraryElements.class, LibraryElements.JAR)); + configuration.getAttributes() + .attribute( + ObfuscationAttribute.OBFUSCATION_ATTRIBUTE, + ObfuscationAttribute.getSrg(project.getObjects())); + }); + final Set requestedProductionModules = new LinkedHashSet<>(); + for (String sourceName : List.of( + "api", + "implementation", + "compileOnly", + "compileOnlyApi", + "runtimeOnly", + "runtimeOnlyNonPublishable", + "devOnlyNonPublishable")) { + final Configuration source = project.getConfigurations() + .findByName(sourceName); + if (source != null) { + source.getDependencies() + .all( + dependency -> mirrorProductionDependency( + dependency, + productionMods, + requestedProductionModules)); + } + } + final var productionArtifacts = productionMods.getIncoming() + .artifactView(view -> view.lenient(true)); + final Provider> resolvedProductionArtifacts = project.provider( + () -> describeResolvedArtifacts( + productionArtifacts.getArtifacts() + .getArtifacts())); + + final TaskContainer tasks = project.getTasks(); + final File runtimePathFile = project.getLayout() + .getBuildDirectory() + .file("fullpack/client-runtime.path") + .get() + .getAsFile(); + final File launcherPatch = project.getLayout() + .getBuildDirectory() + .file("fullpack/lwjgl3ify-forgePatches.jar") + .get() + .getAsFile(); + final TaskProvider reobfJar = tasks.named("reobfJar", ReobfuscatedJar.class); + final TaskProvider prepare = tasks + .register("prepareFullPackClient", PrepareFullPackClientTask.class, task -> { + task.setGroup("GTNH Buildscript"); + task.setDescription("Downloads and assembles a complete GTNH client with the locally built mod"); + task.getManifestUrl() + .set(extension.getManifestUrl()); + task.getOwner() + .set(extension.getOwner()); + task.getGitHubToken() + .set(extension.getGitHubToken()); + task.getPreferMavenLocal() + .set(extension.getPreferMavenLocal()); + task.getCacheDirectory() + .set(extension.getCacheDirectory()); + task.getMavenLocalRepository() + .set(new File(System.getProperty("user.home"), ".m2/repository")); + task.getProductionOverlayFiles() + .from(productionArtifacts.getFiles()); + task.getProductionOverlayArtifacts() + .set(resolvedProductionArtifacts); + task.getRequestedProductionModules() + .set(project.provider(() -> List.copyOf(requestedProductionModules))); + task.getLocalModJar() + .set(reobfJar.flatMap(ReobfuscatedJar::getArchiveFile)); + task.getRuntimePathFile() + .fileValue(runtimePathFile); + task.getLauncherPatchFile() + .fileValue(launcherPatch); + task.getOutputs() + .upToDateWhen(ignored -> false); + }); + + final MinecraftExtension minecraft = project.getExtensions() + .getByType(MinecraftExtension.class); + final MinecraftTasks minecraftTasks = project.getExtensions() + .getByType(MinecraftTasks.class); + final MCPTasks mcpTasks = project.getExtensions() + .getByType(MCPTasks.class); + + tasks.register("runFullPack", RunMinecraftTask.class, Distribution.CLIENT) + .configure(task -> { + task.getLwjglVersion() + .set(3); + task.setup(project); + task.getMcExtExtraRunJvmArguments() + .set( + minecraft.getExtraRunJvmArguments() + .map( + arguments -> arguments.stream() + .filter(argument -> !argument.equals("-Dmixin.debug.countInjections=true")) + .toList())); + task.setGroup("GTNH Buildscript"); + task.setDescription("Runs the complete GTNH client with the locally built mod"); + task.dependsOn( + minecraftTasks.getTaskDownloadVanillaJars(), + minecraftTasks.getTaskDownloadVanillaAssets(), + prepare); + + task.getJavaLauncher() + .set( + gtnh.getToolchainService() + .launcherFor( + toolchain -> toolchain.getLanguageVersion() + .set(JavaLanguageVersion.of(17)))); + @SuppressWarnings("unchecked") + final List modernJvmArgs = (List) project.property("modernJvmArgs"); + task.getExtraJvmArgs() + .addAll(modernJvmArgs); + task.classpath(mcpTasks.getForgeUniversalConfiguration()); + task.classpath(minecraftTasks.getVanillaClientLocation()); + task.classpath(mcpTasks.getPatchedConfiguration()); + task.setClasspath( + project.files(launcherPatch) + .plus(task.getClasspath())); + task.getMainClass() + .set("com.gtnewhorizons.retrofuturabootstrap.MainStartOnFirstThread"); + task.getTweakClasses() + .add( + minecraft.getMinorMcVersion() + .map( + version -> version <= 7 ? "cpw.mods.fml.common.launcher.FMLTweaker" + : "net.minecraftforge.fml.common.launcher.FMLTweaker")); + task.doFirst("select prepared full-pack runtime", currentTask -> { + final RunMinecraftTask runTask = (RunMinecraftTask) currentTask; + final File preparedRuntime = readRuntimeDirectory(runtimePathFile); + if (!launcherPatch.isFile()) { + throw new GradleException("Prepared full-pack runtime is missing " + launcherPatch); + } + runTask.setWorkingDir(preparedRuntime); + }); + }); + } + + private static File readRuntimeDirectory(File runtimePathFile) { + try { + return new File( + Files.readString(runtimePathFile.toPath(), StandardCharsets.UTF_8) + .trim()); + } catch (IOException e) { + throw new UncheckedIOException("Failed to locate the prepared full-pack runtime", e); + } + } + + private static void mirrorProductionDependency(Dependency dependency, Configuration destination, + Set requestedModules) { + if (!(dependency instanceof ExternalModuleDependency module) || module.getGroup() == null + || module.getVersion() == null + || !hasSupportedClassifier(module)) { + return; + } + final String coordinates = module.getGroup() + ":" + module.getName() + ":" + module.getVersion(); + if (!requestedModules.add(coordinates)) { + return; + } + final ExternalModuleDependency production = module.copy(); + production.setTransitive(false); + production.getArtifacts() + .clear(); + destination.getDependencies() + .add(production); + } + + private static boolean hasSupportedClassifier(ExternalModuleDependency dependency) { + for (DependencyArtifact artifact : dependency.getArtifacts()) { + final String classifier = artifact.getClassifier(); + if (classifier != null && !classifier.isBlank() && !classifier.equals("dev") && !classifier.equals("api")) { + return false; + } + } + return true; + } + + private static Map describeResolvedArtifacts(Set artifacts) { + final Map resolved = new LinkedHashMap<>(); + for (ResolvedArtifactResult artifact : artifacts) { + if (!(artifact.getId() + .getComponentIdentifier() instanceof ModuleComponentIdentifier module)) { + continue; + } + final String coordinates = module.getGroup() + ":" + module.getModule() + ":" + module.getVersion(); + final String previous = resolved.put( + coordinates, + artifact.getFile() + .getAbsolutePath()); + if (previous != null) { + throw new IllegalStateException("Multiple production artifacts resolved for " + coordinates); + } + } + return Map.copyOf(resolved); + } +} From 1f803e7eec1a639d352d23329f9f08eecfb45c29 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Wed, 5 Aug 2026 12:36:52 +0200 Subject: [PATCH 7/8] Document full-pack runtime workflow --- README.MD | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.MD b/README.MD index 3069dfe4..8c49249f 100644 --- a/README.MD +++ b/README.MD @@ -14,6 +14,41 @@ and then registers the `gtnhGradle` extension object which can be used to activa `test` tests make sure that applying the plugin works correctly in a simple Gradle setup. Bulk of the testing is done in `functionalTest` which uses [Gradle TestKit](https://docs.gradle.org/8.1.1/userguide/test_kit.html) to test entire workflows in sandboxed Gradle environments. +## Running against the complete modpack + +Build and run the current mod in a full GTNH client with: + +```shell +./gradlew runFullPack +``` + +`runFullPack` runs `prepareFullPackClient` automatically. Downloaded files are shared between projects under +`/caches/gtnh/fullpack`. + +Private GitHub assets use the token from the `GITHUB_TOKEN` environment variable. + +To test a local dependency, publish it to Maven Local: + +```shell +./gradlew publishToMavenLocal +``` + +Then use that version in the project and enable Maven Local overrides: + +```kotlin +repositories { + mavenLocal() +} + +dependencies { + api("com.github.GTNewHorizons:OtherMod:1.2.3-local:dev") +} + +fullPack { + preferMavenLocal.set(true) +} +``` + ## Updating from the previous buildscript 0. Make sure you're on the latest `master` commit and you pulled the recent repository changes ;-) From 805b643bfe1d4e944de189ea5b3009ef3eac9104 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Wed, 5 Aug 2026 12:36:52 +0200 Subject: [PATCH 8/8] Test full-pack runtime workflow --- .../FullPackModuleFunctionalTest.java | 182 +++++++++++++++++ .../fullpack/FullPackAssetCacheTest.java | 73 +++++++ .../FullPackDependencyOverlayPlannerTest.java | 104 ++++++++++ .../fullpack/FullPackInstallerTest.java | 189 ++++++++++++++++++ .../fullpack/FullPackManifestParserTest.java | 138 +++++++++++++ ...venLocalProductionArtifactLocatorTest.java | 52 +++++ 6 files changed, 738 insertions(+) create mode 100644 src/functionalTest/java/com/gtnewhorizons/gtnhgradle/FullPackModuleFunctionalTest.java create mode 100644 src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackAssetCacheTest.java create mode 100644 src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackDependencyOverlayPlannerTest.java create mode 100644 src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackInstallerTest.java create mode 100644 src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifestParserTest.java create mode 100644 src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/MavenLocalProductionArtifactLocatorTest.java diff --git a/src/functionalTest/java/com/gtnewhorizons/gtnhgradle/FullPackModuleFunctionalTest.java b/src/functionalTest/java/com/gtnewhorizons/gtnhgradle/FullPackModuleFunctionalTest.java new file mode 100644 index 00000000..06cf50be --- /dev/null +++ b/src/functionalTest/java/com/gtnewhorizons/gtnhgradle/FullPackModuleFunctionalTest.java @@ -0,0 +1,182 @@ +package com.gtnewhorizons.gtnhgradle; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.gtnewhorizons.retrofuturagradle.shadow.com.google.common.collect.ImmutableMap; + +class FullPackModuleFunctionalTest { + + @TempDir + Path projectDirectory; + + @Test + void conventionPluginRegistersFullPackTasksWithoutProjectConfiguration() throws IOException { + setupProject(); + + BuildResult result = createRunner("tasks", "--all").build(); + + assertTrue( + result.getOutput() + .contains("prepareFullPackClient")); + assertTrue( + result.getOutput() + .contains("runFullPack")); + } + + @Test + void runFullPackBuildsAndPreparesTheLocalProductionJar() throws IOException { + setupProject(); + + BuildResult result = createRunner("runFullPack", "--dry-run", "--configuration-cache").build(); + + assertTrue( + result.getOutput() + .contains(":reobfJar SKIPPED")); + assertTrue( + result.getOutput() + .contains(":prepareFullPackClient SKIPPED")); + assertTrue( + result.getOutput() + .contains(":runFullPack SKIPPED")); + } + + @Test + void runFullPackUsesProductionLauncherWithoutDevelopmentMixinValidation() throws IOException { + setupProject(); + Files.writeString(projectDirectory.resolve("gradle.properties"), """ + usesMixins = true + usesMixinDebug = true + mixinsPackage = mixin.mixins + """, StandardOpenOption.APPEND); + Files.createDirectories(projectDirectory.resolve("src/main/java/com/myname/mymodid/mixin/mixins")); + Path launcherPatch = projectDirectory.resolve("build/fullpack/lwjgl3ify-forgePatches.jar"); + Files.createDirectories(launcherPatch.getParent()); + Files.writeString(launcherPatch, "launcher"); + Path runtimePathFile = projectDirectory.resolve("build/fullpack/client-runtime.path"); + Files.createDirectories(runtimePathFile.getParent()); + Files.writeString( + runtimePathFile, + projectDirectory.resolve("fake-runtime") + .toString()); + Files.writeString(projectDirectory.resolve("build.gradle.kts"), """ + + tasks.register("verifyRunFullPackLauncher") { + doLast { + val run = tasks.named( + "runFullPack" + ).get() + check(run.lwjglVersion.get() == 3) { "runFullPack must use LWJGL 3" } + check(run.javaLauncher.get().metadata.languageVersion.asInt() == 17) { + "runFullPack must use Java 17" + } + check( + run.mainClass.get() == + "com.gtnewhorizons.retrofuturabootstrap.MainStartOnFirstThread" + ) { "runFullPack must enter through RetroFuturaBootstrap" } + check( + run.extraJvmArgs.get().contains( + "-Djava.system.class.loader=" + + "com.gtnewhorizons.retrofuturabootstrap.RfbSystemClassLoader" + ) + ) { "runFullPack must install the RFB system class loader" } + check( + !run.calculateJvmArgs().contains("-Dmixin.debug.countInjections=true") + ) { "runFullPack must not enable development-only Mixin injection validation" } + check( + run.classpath.files.first().canonicalFile == + file("build/fullpack/lwjgl3ify-forgePatches.jar").canonicalFile + ) { "lwjgl3ify forgePatches must be first on the launch classpath" } + } + } + """, StandardOpenOption.APPEND); + + BuildResult result = createRunner("verifyRunFullPackLauncher").build(); + + assertTrue( + result.getOutput() + .contains("BUILD SUCCESSFUL")); + } + + @Test + void devDependencyIsMirroredAsAnSrgProductionArtifactWithoutAClassifier() throws IOException { + setupProject(); + Files.writeString(projectDirectory.resolve("build.gradle.kts"), """ + + dependencies { + api("com.github.GTNewHorizons:ModularUI2:2.3.85-1.7.10:dev") + } + + tasks.register("verifyFullPackProductionDependencies") { + doLast { + val production = configurations.getByName("fullPackProductionMods") + val dependency = production.dependencies.single { + it.group == "com.github.GTNewHorizons" && it.name == "ModularUI2" + } as ExternalModuleDependency + check(dependency.artifacts.isEmpty()) + check( + production.attributes.getAttribute( + com.gtnewhorizons.retrofuturagradle.ObfuscationAttribute.OBFUSCATION_ATTRIBUTE + )?.name == "srg" + ) + } + } + """, StandardOpenOption.APPEND); + + BuildResult result = createRunner("verifyFullPackProductionDependencies").build(); + + assertTrue( + result.getOutput() + .contains("BUILD SUCCESSFUL")); + } + + private void setupProject() throws IOException { + Files.writeString(projectDirectory.resolve("settings.gradle.kts"), """ + pluginManagement { + repositories { + maven { + name = "GTNH Maven" + url = uri("https://nexus.gtnewhorizons.com/repository/public/") + } + gradlePluginPortal() + mavenCentral() + mavenLocal() + } + } + plugins { + id("com.gtnewhorizons.gtnhsettingsconvention") + } + """); + Files.writeString(projectDirectory.resolve("build.gradle.kts"), """ + plugins { + id("com.gtnewhorizons.gtnhconvention") + } + """); + Files.writeString(projectDirectory.resolve("gradle.properties"), """ + modName = MyMod + modId = mymodid + modGroup = com.myname.mymodid + enableModernJavaSyntax = true + enableGenericInjection = true + """); + Files.createDirectories(projectDirectory.resolve("src/main/java/com/myname/mymodid")); + } + + private GradleRunner createRunner(String... arguments) { + return GradleRunner.create() + .withEnvironment(ImmutableMap.of("VERSION", "1.0.0")) + .withArguments(arguments) + .forwardOutput() + .withPluginClasspath() + .withProjectDir(projectDirectory.toFile()); + } +} diff --git a/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackAssetCacheTest.java b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackAssetCacheTest.java new file mode 100644 index 00000000..05a93f64 --- /dev/null +++ b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackAssetCacheTest.java @@ -0,0 +1,73 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.gradle.testfixtures.ProjectBuilder; + +import de.undercouch.gradle.tasks.download.DownloadAction; + +class FullPackAssetCacheTest { + + @TempDir + Path temporaryDirectory; + + @Test + void cachedAssetsAreReturnedInManifestOrder() throws Exception { + FullPackAssetCache cache = cache(""); + FullPackManifest.File first = file("https://example.invalid/first.jar", FullPackManifest.Authentication.NONE); + FullPackManifest.File second = file("https://example.invalid/second.jar", FullPackManifest.Authentication.NONE); + Path firstObject = cache.objectPath( + first.url() + .toString()); + Path secondObject = cache.objectPath( + second.url() + .toString()); + Files.createDirectories(firstObject.getParent()); + Files.createDirectories(secondObject.getParent()); + Files.writeString(firstObject, "first"); + Files.writeString(secondObject, "second"); + + List resolved = cache.resolveAll(List.of(second, first)); + + assertEquals(List.of(secondObject, firstObject), resolved); + assertNotEquals(firstObject, secondObject); + } + + @Test + void authenticatedAssetWithoutATokenFailsBeforeDownloading() { + FullPackManifest.File file = file( + "https://api.github.com/repos/GTNewHorizons/Test/releases/assets/1", + FullPackManifest.Authentication.GITHUB); + + IllegalStateException error = assertThrows(IllegalStateException.class, () -> cache("").resolve(file)); + + assertTrue( + error.getMessage() + .contains("configure fullPack.githubToken or GITHUB_TOKEN")); + } + + private FullPackAssetCache cache(String githubToken) { + var project = ProjectBuilder.builder() + .build(); + return new FullPackAssetCache( + temporaryDirectory, + githubToken, + new DownloadAction(project), + new DownloadAction(project)); + } + + private static FullPackManifest.File file(String url, FullPackManifest.Authentication authentication) { + return new FullPackManifest.File(null, "mods/asset.jar", URI.create(url), null, authentication); + } +} diff --git a/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackDependencyOverlayPlannerTest.java b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackDependencyOverlayPlannerTest.java new file mode 100644 index 00000000..cc62553d --- /dev/null +++ b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackDependencyOverlayPlannerTest.java @@ -0,0 +1,104 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class FullPackDependencyOverlayPlannerTest { + + @Test + void explicitlyPreferredMavenLocalArtifactOverridesANewerDailyVersion() { + FullPackManifest.MavenModule dailyModule = module("2.3.85-1.7.10"); + FullPackManifest manifest = manifest(dailyModule); + Path localJar = Path.of("ModularUI2-2.3.79-1.7.10-local-power-goggles.jar"); + FullPackDependencyOverlayPlanner.Artifact localArtifact = new FullPackDependencyOverlayPlanner.Artifact( + module("2.3.79-1.7.10-local-power-goggles"), + localJar, + FullPackDependencyOverlayPlanner.Source.MAVEN_LOCAL); + + assertEquals( + List.of(new FullPackDependencyOverlayPlanner.Overlay("mods/modularui2-daily.jar", localJar)), + FullPackDependencyOverlayPlanner + .plan(manifest, List.of(localArtifact), List.of(localArtifact.module()), true)); + } + + @Test + void newerResolvedProductionArtifactOverridesTheDailyVersion() { + FullPackManifest manifest = manifest(module("2.3.85-1.7.10")); + Path resolvedJar = Path.of("ModularUI2-2.3.86-1.7.10.jar"); + FullPackDependencyOverlayPlanner.Artifact resolvedArtifact = new FullPackDependencyOverlayPlanner.Artifact( + module("2.3.86-1.7.10"), + resolvedJar, + FullPackDependencyOverlayPlanner.Source.REMOTE); + + assertEquals( + List.of(new FullPackDependencyOverlayPlanner.Overlay("mods/modularui2-daily.jar", resolvedJar)), + FullPackDependencyOverlayPlanner + .plan(manifest, List.of(resolvedArtifact), List.of(resolvedArtifact.module()), false)); + } + + @Test + void olderRemoteArtifactDoesNotDowngradeTheDailyVersion() { + FullPackDependencyOverlayPlanner.Artifact olderArtifact = new FullPackDependencyOverlayPlanner.Artifact( + module("2.3.79-1.7.10"), + Path.of("ModularUI2-2.3.79-1.7.10.jar"), + FullPackDependencyOverlayPlanner.Source.REMOTE); + + assertEquals( + List.of(), + FullPackDependencyOverlayPlanner.plan( + manifest(module("2.3.85-1.7.10")), + List.of(olderArtifact), + List.of(olderArtifact.module()), + false)); + } + + @Test + void MavenLocalArtifactIsIgnoredWithoutExplicitOptIn() { + FullPackDependencyOverlayPlanner.Artifact localArtifact = new FullPackDependencyOverlayPlanner.Artifact( + module("2.3.79-1.7.10-local"), + Path.of("ModularUI2-2.3.79-1.7.10-local.jar"), + FullPackDependencyOverlayPlanner.Source.MAVEN_LOCAL); + + assertEquals( + List.of(), + FullPackDependencyOverlayPlanner.plan( + manifest(module("2.3.85-1.7.10")), + List.of(localArtifact), + List.of(localArtifact.module()), + false)); + } + + @Test + void newerDeclaredModuleWithoutAProductionArtifactFailsInsteadOfSilentlyUsingDaily() { + FullPackManifest.MavenModule requested = module("2.3.86-1.7.10"); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> FullPackDependencyOverlayPlanner + .plan(manifest(module("2.3.85-1.7.10")), List.of(), List.of(requested), false)); + + assertEquals( + "Could not resolve a production SRG JAR for com.github.GTNewHorizons:ModularUI2:2.3.86-1.7.10", + error.getMessage()); + } + + private static FullPackManifest manifest(FullPackManifest.MavenModule module) { + FullPackManifest.File file = new FullPackManifest.File( + "ModularUI2", + "mods/modularui2-daily.jar", + URI.create("https://example.invalid/modularui2.jar"), + module, + FullPackManifest.Authentication.NONE); + return new FullPackManifest("digest", List.of(file), List.of(), java.util.Map.of()); + } + + private static FullPackManifest.MavenModule module(String version) { + return new FullPackManifest.MavenModule("com.github.GTNewHorizons", "ModularUI2", version); + } +} diff --git a/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackInstallerTest.java b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackInstallerTest.java new file mode 100644 index 00000000..66c7c3a5 --- /dev/null +++ b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackInstallerTest.java @@ -0,0 +1,189 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.gradle.api.Project; +import org.gradle.testfixtures.ProjectBuilder; + +import de.undercouch.gradle.tasks.download.DownloadAction; + +class FullPackInstallerTest { + + private final Project project = ProjectBuilder.builder() + .build(); + + @TempDir + Path temporaryDirectory; + + @Test + void localModAndDependencyOverlaysReplaceReleasedFilesBeforeTextFilesAreWritten() throws Exception { + FullPackManifest.MavenModule module = new FullPackManifest.MavenModule("g", "dependency", "1.0"); + FullPackManifest manifest = manifest( + "digest", + List.of( + file("CurrentMod", "/current.jar", "mods/current-release.jar", null), + file("DependencyMod", "/dependency.jar", "mods/dependency-release.jar", module)), + List.of(), + Map.of("config/generated.cfg", "generated", "mods/current-release.jar", "temporary")); + Path currentJar = Files.write(temporaryDirectory.resolve("current-local.jar"), bytes("local-current")); + Path dependencyJar = Files.write(temporaryDirectory.resolve("dependency-local.jar"), bytes("local-dependency")); + FullPackDependencyOverlayPlanner.Overlay overlay = new FullPackDependencyOverlayPlanner.Overlay( + "mods/dependency-release.jar", + dependencyJar); + + Path runtime = installer(temporaryDirectory.resolve("fullpack")) + .prepare(manifest, "CurrentMod", currentJar, List.of(overlay)); + + assertFalse(Files.exists(runtime.resolve("mods/current-local.jar"))); + assertArrayEquals(bytes("local-current"), Files.readAllBytes(runtime.resolve("mods/current-release.jar"))); + assertArrayEquals( + bytes("local-dependency"), + Files.readAllBytes(runtime.resolve("mods/dependency-release.jar"))); + assertEquals("generated", Files.readString(runtime.resolve("config/generated.cfg"))); + assertTrue( + runtime.toString() + .contains("client")); + } + + @Test + void archivesExtractInOrderWithDirectoryExclusionsAndKeepExisting() throws Exception { + byte[] config = zip( + Map.of( + "config/client.cfg", + bytes("config"), + "config/server/secret.cfg", + bytes("server"), + "generated.txt", + bytes("archive"), + "shared.txt", + bytes("first"))); + byte[] translations = zip(Map.of("shared.txt", bytes("second"), "lang/en_US.lang", bytes("translation"))); + FullPackManifest.Archive configArchive = archive("/config.zip", List.of("config/server"), false); + FullPackManifest.Archive translationsArchive = archive("/translations.zip", List.of(), true); + FullPackManifest manifest = manifest( + "archives", + List.of(), + List.of(configArchive, translationsArchive), + Map.of("generated.txt", "text")); + Path cacheRoot = temporaryDirectory.resolve("fullpack"); + cache(cacheRoot, configArchive, config); + cache(cacheRoot, translationsArchive, translations); + Path localJar = Files.write(temporaryDirectory.resolve("mod.jar"), bytes("local")); + + Path runtime = installer(cacheRoot).prepare(manifest, "CurrentMod", localJar); + + assertEquals("config", Files.readString(runtime.resolve("config/client.cfg"))); + assertFalse(Files.exists(runtime.resolve("config/server/secret.cfg"))); + assertEquals("first", Files.readString(runtime.resolve("shared.txt"))); + assertEquals("translation", Files.readString(runtime.resolve("lang/en_US.lang"))); + assertEquals("text", Files.readString(runtime.resolve("generated.txt"))); + } + + @Test + void zipEntryCannotEscapeTheRuntime() throws Exception { + FullPackManifest.Archive archive = archive("/unsafe.zip", List.of(), false); + FullPackManifest manifest = manifest("unsafe", List.of(), List.of(archive), Map.of()); + Path cacheRoot = temporaryDirectory.resolve("fullpack"); + cache(cacheRoot, archive, zip(Map.of("../escaped.txt", bytes("escaped")))); + Path localJar = Files.write(temporaryDirectory.resolve("mod.jar"), bytes("local")); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> installer(cacheRoot).prepare(manifest, "CurrentMod", localJar)); + + assertTrue( + error.getMessage() + .contains("escapes the runtime")); + assertFalse(Files.exists(temporaryDirectory.resolve("escaped.txt"))); + } + + @Test + void differentCheckoutsUseIsolatedRuntimeDirectories() throws Exception { + FullPackManifest manifest = manifest("same-digest", List.of(), List.of(), Map.of()); + Path firstJar = temporaryDirectory.resolve("checkout-one/build/libs/mod.jar"); + Path secondJar = temporaryDirectory.resolve("checkout-two/build/libs/mod.jar"); + Files.createDirectories(firstJar.getParent()); + Files.createDirectories(secondJar.getParent()); + Files.write(firstJar, bytes("first")); + Files.write(secondJar, bytes("second")); + FullPackInstaller installer = installer(temporaryDirectory.resolve("fullpack")); + + Path firstRuntime = installer.prepare(manifest, "CurrentMod", firstJar); + Path secondRuntime = installer.prepare(manifest, "CurrentMod", secondJar); + + assertNotEquals(firstRuntime, secondRuntime); + assertEquals("first", Files.readString(firstRuntime.resolve("mods/CurrentMod.jar"))); + assertEquals("second", Files.readString(secondRuntime.resolve("mods/CurrentMod.jar"))); + } + + private static FullPackManifest manifest(String digest, List files, + List archives, Map textFiles) { + return new FullPackManifest(digest, files, archives, textFiles); + } + + private static FullPackManifest.File file(String owner, String source, String path, + FullPackManifest.MavenModule maven) { + return new FullPackManifest.File( + owner, + path, + URI.create("https://example.invalid" + source), + maven, + FullPackManifest.Authentication.NONE); + } + + private static FullPackManifest.Archive archive(String source, List excludes, boolean keepExisting) { + return new FullPackManifest.Archive( + URI.create("https://example.invalid" + source), + excludes, + keepExisting, + FullPackManifest.Authentication.NONE); + } + + private void cache(Path root, FullPackManifest.Asset asset, byte[] content) throws IOException { + Path object = new FullPackAssetCache(root, "", new DownloadAction(project), new DownloadAction(project)) + .objectPath( + asset.url() + .toString()); + Files.createDirectories(object.getParent()); + Files.write(object, content); + } + + private FullPackInstaller installer(Path root) { + return new FullPackInstaller(root, "", new DownloadAction(project), new DownloadAction(project)); + } + + private static byte[] zip(Map entries) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + for (Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue()); + zip.closeEntry(); + } + } + return output.toByteArray(); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifestParserTest.java b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifestParserTest.java new file mode 100644 index 00000000..ae55fd93 --- /dev/null +++ b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/FullPackManifestParserTest.java @@ -0,0 +1,138 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class FullPackManifestParserTest { + + private static final String VALID_PLAN = """ + { + "version": 1, + "files": [ + { + "owner": "GT5-Unofficial", + "path": "mods/gregtech.jar", + "url": "https://example.invalid/gregtech.jar", + "maven": "com.github.GTNewHorizons:GT5-Unofficial:5.09.54.73" + }, + { + "path": ".gtnh/launcher/lwjgl3ify-forgePatches.jar", + "url": "https://example.invalid/lwjgl3ify-forgePatches.jar" + } + ], + "archives": [ + { + "url": "https://example.invalid/config.zip", + "exclude": ["server.properties", "journeymap/data"] + }, + { + "url": "https://example.invalid/translations.zip", + "keepExisting": true + } + ], + "textFiles": { + "config/txloader/load/mainmenu/version.txt": "GT New Horizons 2.8.0\n" + } + } + """; + + @Test + void compactPlanPreservesInstallationOrderAndOptionalMetadata() { + FullPackManifest manifest = FullPackManifestParser.parse(VALID_PLAN); + + assertEquals( + 2, + manifest.files() + .size()); + assertEquals( + "GT5-Unofficial", + manifest.files() + .getFirst() + .owner()); + assertEquals( + "mods/gregtech.jar", + manifest.files() + .getFirst() + .path()); + assertEquals( + new FullPackManifest.MavenModule("com.github.GTNewHorizons", "GT5-Unofficial", "5.09.54.73"), + manifest.files() + .getFirst() + .maven()); + assertEquals( + FullPackManifest.Authentication.NONE, + manifest.files() + .getFirst() + .authentication()); + assertEquals( + List.of("server.properties", "journeymap/data"), + manifest.archives() + .getFirst() + .exclude()); + assertEquals( + true, + manifest.archives() + .get(1) + .keepExisting()); + assertEquals( + Map.of("config/txloader/load/mainmenu/version.txt", "GT New Horizons 2.8.0\n"), + manifest.textFiles()); + } + + @Test + void unsupportedVersionIsRejected() { + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> FullPackManifestParser.parse(VALID_PLAN.replace("\"version\": 1", "\"version\": 2"))); + + assertEquals("Unsupported full-pack manifest version: 2", error.getMessage()); + } + + @Test + void pathsCannotEscapeTheRuntime() { + IllegalArgumentException file = assertThrows( + IllegalArgumentException.class, + () -> FullPackManifestParser.parse(VALID_PLAN.replace("mods/gregtech.jar", "../gregtech.jar"))); + IllegalArgumentException text = assertThrows( + IllegalArgumentException.class, + () -> FullPackManifestParser + .parse(VALID_PLAN.replace("config/txloader/load/mainmenu/version.txt", "../version.txt"))); + IllegalArgumentException exclusion = assertThrows( + IllegalArgumentException.class, + () -> FullPackManifestParser.parse(VALID_PLAN.replace("journeymap/data", "../data"))); + + assertEquals("Full-pack manifest field files[0].path contains an unsafe path", file.getMessage()); + assertEquals("Full-pack manifest field textFiles path contains an unsafe path", text.getMessage()); + assertEquals("Full-pack manifest field archives[0].exclude[1] contains an unsafe path", exclusion.getMessage()); + } + + @Test + void githubAuthenticationCannotSendATokenToAnotherHost() { + String authenticated = VALID_PLAN.replace( + "\"url\": \"https://example.invalid/gregtech.jar\"", + "\"url\": \"https://example.invalid/gregtech.jar\", \"authentication\": \"github\""); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> FullPackManifestParser.parse(authenticated)); + + assertEquals( + "Full-pack manifest file 0 can only use GitHub authentication with the GitHub Assets API", + error.getMessage()); + } + + @Test + void malformedMavenCoordinatesAreRejected() { + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> FullPackManifestParser.parse( + VALID_PLAN.replace("com.github.GTNewHorizons:GT5-Unofficial:5.09.54.73", "GT5-Unofficial:5.09.54.73"))); + + assertEquals("Invalid Maven coordinates in full-pack file 0: GT5-Unofficial:5.09.54.73", error.getMessage()); + } +} diff --git a/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/MavenLocalProductionArtifactLocatorTest.java b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/MavenLocalProductionArtifactLocatorTest.java new file mode 100644 index 00000000..60478746 --- /dev/null +++ b/src/test/java/com/gtnewhorizons/gtnhgradle/fullpack/MavenLocalProductionArtifactLocatorTest.java @@ -0,0 +1,52 @@ +package com.gtnewhorizons.gtnhgradle.fullpack; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MavenLocalProductionArtifactLocatorTest { + + @TempDir + Path temporaryDirectory; + + @Test + void exactSrgReobfVariantSelectsTheProductionJarInsteadOfTheDevJar() throws Exception { + FullPackManifest.MavenModule module = new FullPackManifest.MavenModule( + "com.github.GTNewHorizons", + "ModularUI2", + "2.3.79-1.7.10-local"); + Path versionDirectory = temporaryDirectory.resolve("com/github/GTNewHorizons/ModularUI2/2.3.79-1.7.10-local"); + Files.createDirectories(versionDirectory); + Path productionJar = Files.writeString(versionDirectory.resolve("ModularUI2-2.3.79-1.7.10-local.jar"), "srg"); + Files.writeString(versionDirectory.resolve("ModularUI2-2.3.79-1.7.10-local-dev.jar"), "mcp"); + Files.writeString(versionDirectory.resolve("ModularUI2-2.3.79-1.7.10-local.module"), """ + { + "variants": [ + { + "name": "runtimeElements", + "attributes": {"com.gtnewhorizons.retrofuturagradle.obfuscation": "mcp"}, + "files": [{"name": "ModularUI2-2.3.79-1.7.10-local-dev.jar"}] + }, + { + "name": "reobfElements", + "attributes": {"com.gtnewhorizons.retrofuturagradle.obfuscation": "srg"}, + "files": [{"name": "ModularUI2-2.3.79-1.7.10-local.jar"}] + } + ] + } + """); + + assertEquals( + List.of( + new FullPackDependencyOverlayPlanner.Artifact( + module, + productionJar, + FullPackDependencyOverlayPlanner.Source.MAVEN_LOCAL)), + MavenLocalProductionArtifactLocator.find(temporaryDirectory, List.of(module))); + } +}