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.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-02 03:00:10 -04:00
parent 15bc52e9b5
commit 333b766d2d
12 changed files with 378 additions and 50 deletions
+3 -1
View File
@@ -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
@@ -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(
@@ -334,7 +334,7 @@ public class CommandFind implements DirectorExecutor {
}
Set<String> configuredImportUrls = normalizedImportUrls(
engine.getDimension().getDatapackImports());
DatapackIngestService.configuredImports(engine.getDimension()));
return new StructureExclusionReport(
List.copyOf(exclusions), Set.copyOf(registeredKeys.keySet()),
configuredImportUrls, registeredExcluded, configuredUnregistered,
@@ -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()));
}
}
@@ -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"));
@@ -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<File> worldFolders) throws IOException {
return startupValidationFingerprint(root, worldFolders, List.of());
}
static String startupValidationFingerprint(
File root,
KList<File> worldFolders,
Iterable<String> 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<String> sources
) throws IOException {
if (sources == null) {
return;
}
List<String> 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<String> 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 <dimension>, 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 <dimension>, 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 <dimension> to see the new keys.");
@@ -1434,15 +1492,74 @@ public final class DatapackIngestService {
}
public static KList<String> collectConfiguredImports() {
LinkedHashSet<String> urls = new LinkedHashSet<>();
LinkedHashSet<String> sources = new LinkedHashSet<>();
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
stream.forEach(data -> collectImports(data, urls));
stream.forEach(data -> collectImports(data, sources));
}
sources.addAll(localDatapackImports());
KList<String> result = new KList<>();
result.addAll(urls);
result.addAll(sources);
return result;
}
public static Set<String> configuredImports(IrisDimension dimension) {
Iterable<String> explicit = dimension == null ? List.of() : dimension.getDatapackImports();
return mergeConfiguredImports(explicit, localDatapackImports());
}
static Set<String> mergeConfiguredImports(
Iterable<String> explicit,
Iterable<String> discovered
) {
LinkedHashSet<String> 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<String> 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<Path> 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<String> 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<Entry> 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<String> imports, LinkedHashSet<String> 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<String> configuredImports(IrisData data) {
LinkedHashSet<String> 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<String> imports = dimension.getDatapackImports();
if (imports != null && !imports.isEmpty()) {
return true;
}
}
return false;
return !configuredImports(data).isEmpty();
}
static InstallResult install(File stagedDir, KList<File> 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;
@@ -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 {
@@ -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",
@@ -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",
@@ -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<String> datapackImports = new KList<>();
@MinNumber(0)
@MaxNumber(318)
@@ -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<String> 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<File> 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");
@@ -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";