From 333b766d2d7054af6b9bc15d9d7345080cf95795 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 2 Sep 2026 03:00:10 -0400 Subject: [PATCH] Datapack imports from Modrinth, HTTP(S) and local file ZIPs Work in progress parked from the working tree so the hydrology merge could land; amend or squash as needed. --- README.md | 4 +- .../iris/core/commands/CommandDatapack.java | 2 +- .../iris/core/commands/CommandFind.java | 2 +- .../service/DatapackStructureScopeSVC.java | 2 +- ...risStructureLocateCommandContractTest.java | 2 +- .../core/datapack/DatapackIngestService.java | 261 ++++++++++++++++-- .../iris/core/datapack/ModrinthResolver.java | 36 ++- .../localization/BukkitCommandMessages.java | 2 +- .../localization/DirectorCommandMessages.java | 2 +- .../iris/engine/object/IrisDimension.java | 2 +- .../datapack/DatapackIngestServiceTest.java | 91 ++++++ .../core/datapack/ModrinthResolverTest.java | 22 ++ 12 files changed, 378 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index bf193a837..658f78d4a 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ the server. First boot performs no pack download. Run `/iris download pack=overw each download to finish before starting another. The current built-in packs declare no external datapack imports. Restart after download so Minecraft loads their dimension types and biomes. Custom packs that declare `datapackImports` must complete their external-datapack installation and -registry restart first. Plain Spigot supports ordinary managed `/iris create`, but not +registry restart first. Bukkit accepts Modrinth, direct HTTP(S), and absolute local `file:` ZIP URLs; +ZIPs under `plugins/Iris/datapacks/imports/` are discovered for every Iris dimension. Plain Spigot +supports ordinary managed `/iris create`, but not the early-bootstrap `/iris replace` path for canonical Overworld, Nether, or End slots. **Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDatapack.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDatapack.java index 41a62cbc2..306f9a7ef 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDatapack.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDatapack.java @@ -32,7 +32,7 @@ import java.util.List; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.BukkitCommandMessages; import art.arcane.volmlib.util.localization.MessageArgument; -@Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports (Modrinth)", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth") +@Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth") public class CommandDatapack implements DirectorExecutor { @Director(description = "Download/update every datapack listed in a pack dimension's 'datapackImports' and install it into the world so its structures register like vanilla", descriptionKey = "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", aliases = {"pull"}) public void ingest( diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java index a3577b6d8..1222f8ea6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java @@ -334,7 +334,7 @@ public class CommandFind implements DirectorExecutor { } Set configuredImportUrls = normalizedImportUrls( - engine.getDimension().getDatapackImports()); + DatapackIngestService.configuredImports(engine.getDimension())); return new StructureExclusionReport( List.copyOf(exclusions), Set.copyOf(registeredKeys.keySet()), configuredImportUrls, registeredExcluded, configuredUnregistered, diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java index cf86b2587..403fd01c2 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java @@ -116,6 +116,6 @@ public final class DatapackStructureScopeSVC implements IrisService { return Set.of(); } return scopeIndex.declaredSources( - generator.getTarget().getDimension().getDatapackImports()); + DatapackIngestService.configuredImports(generator.getTarget().getDimension())); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java index 01d9eb4ea..9bddbe3ff 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java @@ -205,7 +205,7 @@ public class IrisStructureLocateCommandContractTest { assertTrue(method.contains("non-generating structure candidate(s) and their reasons to the server console")); assertTrue(method.contains("no chunks were searched")); assertTrue(method.indexOf("DatapackIngestService.installed()") > method.indexOf("J.a(() ->")); - assertTrue(source.contains("engine.getDimension().getDatapackImports()")); + assertTrue(source.contains("DatapackIngestService.configuredImports(engine.getDimension())")); assertTrue(source.contains("snapshot.configuredImportUrls().contains(importUrl)")); assertTrue(source.contains("engine.getData().getStructureLoader().getPossibleKeys()")); assertFalse(method.contains("commandSender.sendMessage(exclusion")); diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index e785084d8..5cbca3872 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -102,6 +102,7 @@ public final class DatapackIngestService { private static final String TRANSACTION_JOURNAL = "journal.json"; private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json"; private static final String STARTUP_VALIDATION_CACHE = "startup-validation.json"; + private static final String LOCAL_IMPORT_DIRECTORY = "imports"; private static final int OWNERSHIP_SCHEMA = 1; private static final int TRANSACTION_SCHEMA = 2; private static final int STARTUP_VALIDATION_SCHEMA = 1; @@ -158,7 +159,7 @@ public final class DatapackIngestService { StartupValidationCache cached = readStartupValidationCache(cacheFile); if (startupValidationContextMatches( cached, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) { - String localFingerprint = startupValidationFingerprint(root, worldFolders); + String localFingerprint = startupValidationFingerprint(root, worldFolders, urls); if (startupValidationCacheMatches( cached, mcVersion, @@ -243,7 +244,7 @@ public final class DatapackIngestService { autoIngest, stripOverrides, urls, - startupValidationFingerprint(root, worldFolders)); + startupValidationFingerprint(root, worldFolders, urls)); writeStartupValidationCache(cacheFile, cache); return cache; } catch (IOException | RuntimeException exception) { @@ -301,7 +302,7 @@ public final class DatapackIngestService { validated.autoIngest, validated.stripOverrides, validated.urls, - startupValidationFingerprint(root, worldFolders)); + startupValidationFingerprint(root, worldFolders, validated.urls)); } private static StartupValidationCache createStartupValidationCache( @@ -411,6 +412,14 @@ public final class DatapackIngestService { } static String startupValidationFingerprint(File root, KList worldFolders) throws IOException { + return startupValidationFingerprint(root, worldFolders, List.of()); + } + + static String startupValidationFingerprint( + File root, + KList worldFolders, + Iterable sources + ) throws IOException { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); Path manifestPath = new File(root, "manifest.json").toPath(); @@ -456,12 +465,61 @@ public final class DatapackIngestService { new File(worldFolder, entry.id)); } } + updateLocalSourceFingerprint(digest, sources); return hex(digest.digest()); } catch (NoSuchAlgorithmException exception) { throw new IOException("SHA-256 algorithm unavailable", exception); } } + private static void updateLocalSourceFingerprint( + MessageDigest digest, + Iterable sources + ) throws IOException { + if (sources == null) { + return; + } + List localSources = new ArrayList<>(); + for (String source : sources) { + URI uri = parseSourceUri(source); + if (uri != null && "file".equalsIgnoreCase(uri.getScheme())) { + localSources.add(uri.normalize().toASCIIString()); + } + } + localSources.sort(String::compareTo); + byte[] buffer = new byte[HASH_BUFFER_BYTES]; + for (String source : localSources) { + Path path = requireLocalDatapackPath(parseSourceUri(source)); + updateFingerprintValue(digest, "local-source:" + source); + try (InputStream input = Files.newInputStream( + path, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS)) { + long bytes = 0; + int length; + while ((length = input.read(buffer)) > 0) { + bytes += length; + if (bytes > MAX_DOWNLOAD_BYTES) { + throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path); + } + digest.update(buffer, 0, length); + } + updateDigestLong(digest, bytes); + } + } + } + + private static URI parseSourceUri(String source) throws IOException { + if (source == null || source.isBlank()) { + return null; + } + try { + return new URI(source.trim()); + } catch (URISyntaxException exception) { + throw new IOException("Invalid datapack URL " + source, exception); + } + } + private static void updateDirectoryFingerprint( MessageDigest digest, String identity, @@ -574,7 +632,7 @@ public final class DatapackIngestService { private static Report ingestLocked(VolmitSender sender, KList urls, boolean restart) { Report report = new Report(); if (urls == null || urls.isEmpty()) { - message(sender, C.YELLOW + "No datapackImports configured in any loaded pack. Add Modrinth URLs to a dimension's 'datapackImports' list, then run /iris datapack ingest."); + message(sender, C.YELLOW + "No external datapacks found. Add an HTTP(S) or file URL to a dimension's 'datapackImports', or place a ZIP in Iris/datapacks/imports, then run /iris datapack ingest."); return report; } @@ -676,7 +734,7 @@ public final class DatapackIngestService { if (report.changed()) { message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate."); - message(sender, C.GRAY + "After the restart they generate natively only in Iris dimensions that declare their source URL - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import , or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures."); + message(sender, C.GRAY + "After the restart they generate natively in Iris dimensions that declare their source URL; ZIPs from Iris/datapacks/imports are enabled for every Iris dimension. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import , or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures."); message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structure families with importedStructures.disabled or complete keys with importedStructures.disabledExact."); if (!restart) { message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list to see the new keys."); @@ -1434,15 +1492,74 @@ public final class DatapackIngestService { } public static KList collectConfiguredImports() { - LinkedHashSet urls = new LinkedHashSet<>(); + LinkedHashSet sources = new LinkedHashSet<>(); try (Stream stream = ServerConfigurator.allPacks()) { - stream.forEach(data -> collectImports(data, urls)); + stream.forEach(data -> collectImports(data, sources)); } + sources.addAll(localDatapackImports()); KList result = new KList<>(); - result.addAll(urls); + result.addAll(sources); return result; } + public static Set configuredImports(IrisDimension dimension) { + Iterable explicit = dimension == null ? List.of() : dimension.getDatapackImports(); + return mergeConfiguredImports(explicit, localDatapackImports()); + } + + static Set mergeConfiguredImports( + Iterable explicit, + Iterable discovered + ) { + LinkedHashSet sources = new LinkedHashSet<>(); + if (explicit != null) { + addImports(explicit, sources); + } + if (discovered != null) { + addImports(discovered, sources); + } + return sources.isEmpty() ? Set.of() : Set.copyOf(sources); + } + + static List discoverLocalDatapackImports(File directory) throws IOException { + Path root = directory.toPath().toAbsolutePath().normalize(); + if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(root); + } + if (Files.isSymbolicLink(root) + || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Local datapack import path is not a safe directory: " + root); + } + try (Stream stream = Files.list(root)) { + return stream + .filter(path -> !Files.isSymbolicLink(path)) + .filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) + .filter(DatapackIngestService::isLocalDatapackArchive) + .sorted(Comparator.comparing( + (Path path) -> path.getFileName().toString(), + String.CASE_INSENSITIVE_ORDER) + .thenComparing((Path path) -> path.getFileName().toString())) + .map(path -> path.toAbsolutePath().normalize().toUri().toASCIIString()) + .toList(); + } + } + + private static boolean isLocalDatapackArchive(Path path) { + String name = path.getFileName().toString(); + return !name.startsWith(".") && name.toLowerCase(Locale.ROOT).endsWith(".zip"); + } + + private static List localDatapackImports() { + File directory = IrisPlatforms.get().dataFolder("datapacks", LOCAL_IMPORT_DIRECTORY); + try { + return discoverLocalDatapackImports(directory); + } catch (IOException exception) { + IrisLogging.reportError("Could not discover local datapack imports under " + + directory.getPath() + ".", exception); + return List.of(); + } + } + public static List installed() { TRANSACTION_LOCK.lock(); try { @@ -1883,34 +2000,39 @@ public final class DatapackIngestService { if (imports == null) { continue; } - for (String url : imports) { - if (url != null && !url.isBlank()) { - urls.add(url.trim()); - } + addImports(imports, urls); + } + } + + private static void addImports(Iterable imports, LinkedHashSet sources) { + for (String source : imports) { + if (source != null && !source.isBlank()) { + sources.add(normalizeConfiguredSource(source)); } } } + static String normalizeConfiguredSource(String source) { + String normalized = source.trim(); + try { + URI uri = new URI(normalized); + if ("file".equalsIgnoreCase(uri.getScheme())) { + return Path.of(uri).toAbsolutePath().normalize().toUri().toASCIIString(); + } + } catch (IllegalArgumentException | URISyntaxException ignored) { + } + return normalized; + } + private static Set configuredImports(IrisData data) { LinkedHashSet urls = new LinkedHashSet<>(); collectImports(data, urls); + urls.addAll(localDatapackImports()); return urls; } private static boolean hasImports(IrisData data) { - if (data.getDimensionLoader() == null) { - return false; - } - for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) { - if (dimension == null) { - continue; - } - KList imports = dimension.getDatapackImports(); - if (imports != null && !imports.isEmpty()) { - return true; - } - } - return false; + return !configuredImports(data).isEmpty(); } static InstallResult install(File stagedDir, KList worldFolders, Entry entry, boolean stripOverrides) throws IOException { @@ -3532,11 +3654,12 @@ public final class DatapackIngestService { } static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException { - URI current; - try { - current = new URI(url); - } catch (URISyntaxException e) { - throw new IOException("Invalid datapack URL " + url, e); + URI current = parseSourceUri(url); + if (current == null) { + throw new IOException("Empty datapack URL"); + } + if ("file".equalsIgnoreCase(current.getScheme())) { + return copyLocalDatapack(current, dest); } for (int attempt = 0; attempt < MAX_REDIRECTS; attempt++) { if (!"http".equalsIgnoreCase(current.getScheme()) && !"https".equalsIgnoreCase(current.getScheme())) { @@ -3614,6 +3737,84 @@ public final class DatapackIngestService { throw new IOException("Too many redirects downloading " + url); } + private static DownloadResult copyLocalDatapack(URI source, File destination) throws IOException { + Path sourcePath = requireLocalDatapackPath(source); + Path destinationPath = destination.toPath().toAbsolutePath().normalize(); + if (Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS) + && Files.isSameFile(sourcePath, destinationPath)) { + throw new IOException("Local datapack source and cache destination are the same file: " + sourcePath); + } + Path parent = destinationPath.getParent(); + if (parent == null) { + throw new IOException("Local datapack cache destination has no parent: " + destinationPath); + } + ensureScratchDirectory(parent.toFile(), "datapack download cache"); + BasicFileAttributes before = Files.readAttributes( + sourcePath, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS); + Path temporary = Files.createTempFile(parent, destination.getName() + "-", ".part"); + try { + long copied = 0; + try (InputStream input = Files.newInputStream( + sourcePath, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS); + OutputStream output = Files.newOutputStream(temporary)) { + byte[] buffer = new byte[HASH_BUFFER_BYTES]; + int length; + while ((length = input.read(buffer)) > 0) { + copied += length; + if (copied > MAX_DOWNLOAD_BYTES) { + throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + sourcePath); + } + output.write(buffer, 0, length); + } + } + BasicFileAttributes after = Files.readAttributes( + sourcePath, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS); + if (!after.isRegularFile() + || before.size() != after.size() + || !before.lastModifiedTime().equals(after.lastModifiedTime()) + || !Objects.equals(before.fileKey(), after.fileKey()) + || copied != after.size()) { + throw new IOException("Local datapack changed while Iris was copying it: " + sourcePath); + } + move(temporary, destinationPath); + return new DownloadResult(false, null, null); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static Path requireLocalDatapackPath(URI source) throws IOException { + if (source == null + || !"file".equalsIgnoreCase(source.getScheme()) + || source.isOpaque() + || source.getRawAuthority() != null + || source.getRawQuery() != null + || source.getRawFragment() != null) { + throw new IOException("Datapack file URL must be an absolute local file URI without authority, query, or fragment: " + source); + } + Path path; + try { + path = Path.of(source).toAbsolutePath().normalize(); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid local datapack file URL: " + source, exception); + } + if (Files.isSymbolicLink(path) + || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Local datapack is not a regular non-symbolic-link file: " + path); + } + long size = Files.size(path); + if (size > MAX_DOWNLOAD_BYTES) { + throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path); + } + return path; + } + private static String headerOrFallback(HttpURLConnection connection, String name, String fallback) { String value = connection.getHeaderField(name); return value == null || value.isBlank() ? fallback : value; diff --git a/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java b/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java index 55c102f4e..0b998ca41 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java @@ -32,6 +32,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; @@ -228,22 +229,33 @@ public final class ModrinthResolver { } private static ResolvedDatapack directResolve(String url) { - String filename = url; - int slash = filename.lastIndexOf('/'); - if (slash >= 0) { - filename = filename.substring(slash + 1); - } - int query = filename.indexOf('?'); - if (query >= 0) { - filename = filename.substring(0, query); - } - if (filename.isBlank()) { - filename = "datapack.zip"; - } + String filename = directFilename(url); String identity = directIdentity(url); return new ResolvedDatapack(url, filename, null, "direct-" + identity, "direct", null, true); } + private static String directFilename(String url) { + try { + URI uri = new URI(url); + if ("file".equalsIgnoreCase(uri.getScheme())) { + Path fileName = Path.of(uri).getFileName(); + return fileName == null || fileName.toString().isBlank() + ? "datapack.zip" + : fileName.toString(); + } + String path = uri.getPath(); + if (path != null && !path.isBlank()) { + int slash = path.lastIndexOf('/'); + String fileName = slash >= 0 ? path.substring(slash + 1) : path; + if (!fileName.isBlank()) { + return fileName; + } + } + } catch (IllegalArgumentException | URISyntaxException ignored) { + } + return "datapack.zip"; + } + static String directIdentity(String url) { String normalized = url; try { diff --git a/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessages.java index 176c79262..5eb36360e 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessages.java @@ -29,7 +29,7 @@ public final class BukkitCommandMessages { ); public static final TextKey COMMAND_DATAPACK_ADD_MODRINTH_URLS_DIMENSION_S_DATAPACKIMPORTS_LIST_THEN_RUN_IRIS = TextKey.of( "iris.bukkit.commanddatapack.add_modrinth_urls_dimension_s_datapackimports_list_then_run_iris", - C.YELLOW + "Add Modrinth URLs to a dimension's 'datapackImports' list, then run /iris datapack ingest." + C.YELLOW + "Add source URLs to a dimension's 'datapackImports' list or use the local import folder, then run /iris datapack ingest." ); public static final TextKey COMMAND_DEVELOPER_GENHASH_STARTED_CHUNKS = TextKey.of( "iris.bukkit.commanddeveloper.genhash_started_chunks", diff --git a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java index 6f1dd4393..08a2f8f6d 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java @@ -8,7 +8,7 @@ import java.util.List; public final class DirectorCommandMessages { public static final TextKey COMMAND_DATAPACK_DIRECTOR_DOWNLOAD_MANAGE_EXTERNAL_DATAPACK_IMPORTS_MODRINTH = TextKey.of( "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth", - "Download & manage external datapack imports (Modrinth)" + "Download & manage external datapack imports" ); public static final TextKey COMMAND_DATAPACK_DIRECTOR_DOWNLOAD_UPDATE_EVERY_DATAPACK_LISTED_PACK_DIMENSION_S_DATAPACKIMPORTS_INSTALL_IT_INTO = TextKey.of( "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index d3679f7de..b7220f0d0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -276,7 +276,7 @@ public class IrisDimension extends IrisRegistrant { @Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation (ores, trees, plants, springs, geodes) for this dimension. Disabled by default: leaving this out generates exactly the terrain Iris always has. Set 'enabled' true to run the vanilla decoration feature pass over Iris terrain. Carvers are never imported.") private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl(); @ArrayType(type = String.class, min = 1) - @Desc("External datapack sources owned by this dimension. Bukkit registers their resources server-wide, but Iris excludes their structure sets and definitions from generation and locate state in vanilla worlds and Iris dimensions that do not declare the same source. Editable auto-import is limited to resources supplied by the URLs declared by each Iris pack. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.") + @Desc("External HTTP(S) or file URL datapack sources owned by this dimension. Bukkit registers their resources server-wide, but Iris excludes their structure sets and definitions from generation and locate state in vanilla worlds and Iris dimensions that do not declare the same source. Local ZIPs under Iris/datapacks/imports are discovered automatically and enabled for every Iris dimension. Editable auto-import is limited to resources supplied by configured or discovered sources. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.") private KList datapackImports = new KList<>(); @MinNumber(0) @MaxNumber(318) diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java index c9056b181..eebb01096 100644 --- a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java @@ -357,6 +357,97 @@ public class DatapackIngestServiceTest { } } + @Test + public void downloadCopiesLocalFileUrl() throws Exception { + Path source = temporaryFolder.newFile("local source.zip").toPath(); + Files.writeString(source, "local-archive", StandardCharsets.UTF_8); + File destination = new File(temporaryFolder.newFolder("local-download"), "pack.zip"); + + DatapackIngestService.DownloadResult result = DatapackIngestService.download( + source.toUri().toASCIIString(), + destination, + null, + null + ); + + assertFalse(result.notModified()); + assertEquals("local-archive", Files.readString(destination.toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void failedLocalDownloadPreservesExistingDestination() throws Exception { + Path missing = temporaryFolder.getRoot().toPath().resolve("missing.zip"); + File destination = new File(temporaryFolder.newFolder("local-download-existing"), "pack.zip"); + Files.writeString(destination.toPath(), "existing", StandardCharsets.UTF_8); + + try { + DatapackIngestService.download( + missing.toUri().toASCIIString(), + destination, + null, + null + ); + fail("Expected missing local source rejection"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("regular non-symbolic-link file")); + } + + assertEquals("existing", Files.readString(destination.toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void localDropFolderDiscoversOnlyTopLevelZipFilesInStableOrder() throws Exception { + File imports = temporaryFolder.newFolder("local-imports"); + Path first = new File(imports, "Alpha Pack.ZIP").toPath(); + Path second = new File(imports, "bravo.zip").toPath(); + Files.writeString(first, "alpha", StandardCharsets.UTF_8); + Files.writeString(second, "bravo", StandardCharsets.UTF_8); + Files.writeString(new File(imports, "ignored.txt").toPath(), "ignored", StandardCharsets.UTF_8); + Path nested = new File(imports, "nested/hidden.zip").toPath(); + Files.createDirectories(nested.getParent()); + Files.writeString(nested, "nested", StandardCharsets.UTF_8); + + List discovered = DatapackIngestService.discoverLocalDatapackImports(imports); + + assertEquals(List.of( + first.toAbsolutePath().normalize().toUri().toASCIIString(), + second.toAbsolutePath().normalize().toUri().toASCIIString() + ), discovered); + } + + @Test + public void configuredFileSourceNormalizesToTheDiscoveredArchiveUri() throws Exception { + Path source = temporaryFolder.newFile("normalized-local.zip").toPath(); + String discovered = source.toAbsolutePath().normalize().toUri().toASCIIString(); + String alternate = discovered.replace("file:///", "file:/"); + + assertEquals(discovered, DatapackIngestService.normalizeConfiguredSource(alternate)); + assertEquals( + Set.of("https://example.test/dimension.zip", discovered), + DatapackIngestService.mergeConfiguredImports( + List.of("https://example.test/dimension.zip", alternate), + List.of(discovered))); + } + + @Test + public void startupValidationFingerprintChangesWithLocalSourceBytes() throws Exception { + File root = temporaryFolder.newFolder("startup-local-source-fingerprint"); + Path source = temporaryFolder.newFile("startup-local.zip").toPath(); + KList worldFolders = new KList<>(); + String sourceUrl = source.toUri().toASCIIString(); + Files.writeString(source, "alpha", StandardCharsets.UTF_8); + String before = DatapackIngestService.startupValidationFingerprint( + root, worldFolders, List.of(sourceUrl)); + + FileTime originalTime = Files.getLastModifiedTime(source); + Files.writeString(source, "bravo", StandardCharsets.UTF_8); + Files.setLastModifiedTime(source, originalTime); + String after = DatapackIngestService.startupValidationFingerprint( + root, worldFolders, List.of(sourceUrl)); + + assertFalse(before.equals(after)); + } + @Test public void removalRequiresMatchingIrisOwnership() throws Exception { File unmanaged = datapackDirectory("unmanaged"); diff --git a/core/src/test/java/art/arcane/iris/core/datapack/ModrinthResolverTest.java b/core/src/test/java/art/arcane/iris/core/datapack/ModrinthResolverTest.java index 857059b5c..b49badc17 100644 --- a/core/src/test/java/art/arcane/iris/core/datapack/ModrinthResolverTest.java +++ b/core/src/test/java/art/arcane/iris/core/datapack/ModrinthResolverTest.java @@ -8,6 +8,9 @@ import org.junit.Test; import java.io.IOException; import java.io.Reader; import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import static org.junit.Assert.assertEquals; @@ -104,6 +107,25 @@ public class ModrinthResolverTest { assertFalse(resolved.getVersionId().isBlank()); } + @Test + public void fileUrlResolvesAsDirectArchiveWithDecodedFilename() throws Exception { + Path directory = Files.createTempDirectory("iris-local-resolver"); + Path archive = directory.resolve("local pack.zip"); + try { + Files.writeString(archive, "archive", StandardCharsets.UTF_8); + + ModrinthResolver.ResolvedDatapack resolved = ModrinthResolver.resolve( + archive.toUri().toASCIIString(), "26.2"); + + assertTrue(resolved.isDirect()); + assertEquals("local pack.zip", resolved.getFileName()); + assertFalse(resolved.getVersionId().isBlank()); + } finally { + Files.deleteIfExists(archive); + Files.deleteIfExists(directory); + } + } + @Test public void boundedApiReaderAcceptsTheExactCharacterLimit() throws Exception { String response = "1234\n5678";