Split datapack ingest service

- Keep ingest orchestration and the public entry points in DatapackIngestService and move startup validation, staging reapply, removal, install planning, ownership, manifests, transactions and recovery into dedicated package classes
- Promote the shared ingest data types to top level so every collaborator addresses them directly
- Report the managed metadata IO failures on the reapply path instead of logging them at debug
- Requalify DatapackIngestServiceTest call sites onto the new owners without changing any test
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-08 05:57:18 -04:00
1 parent 9775a50b26
commit 422618282e
42 files changed
+7338 -6079

No files matched your search

@@ -0,0 +1,37 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
final class CoordinatorDirectory {
String target;
String pending;
String backup;
boolean hadTarget;
String originalHash;
String desiredHash;
String originalMarkerHash;
String desiredMarkerHash;
String originalIdentity;
String desiredIdentity;
String targetRoot;
String scratchRoot;
String targetRootIdentity;
String scratchRootIdentity;
}
@@ -0,0 +1,26 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
final class CoordinatorEditable {
String packRoot;
String transactionId;
String claimId;
}
@@ -0,0 +1,37 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.util.ArrayList;
import java.util.List;
final class CoordinatorJournal {
int schemaVersion;
String transactionId;
CoordinatorOperation operation;
CoordinatorPhase phase;
String id;
String url;
String versionId;
String versionNumber;
String sha1;
boolean manifestAlreadyMatched;
List<CoordinatorDirectory> directories = new ArrayList<>();
List<CoordinatorEditable> editables = new ArrayList<>();
}
@@ -0,0 +1,25 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
enum CoordinatorOperation {
INSTALL,
REMOVE
}
@@ -0,0 +1,31 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
enum CoordinatorPhase {
PREPARED,
PUBLISHING,
PUBLISHED,
COMMITTED;
boolean published() {
return this == PUBLISHED || this == COMMITTED;
}
}
@@ -0,0 +1,415 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.datapack.ModrinthResolver.ResolvedDatapack;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.io.ZipUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackArchive {
static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)";
static final int MAX_REDIRECTS = 5;
static final int MAX_ARCHIVE_ENTRIES = 100_000;
static final int MAX_CACHE_FILES = 32;
static final long MAX_DOWNLOAD_BYTES = 256L * 1024L * 1024L;
static final long MAX_EXPANDED_BYTES = 1024L * 1024L * 1024L;
static final long MAX_ENTRY_BYTES = 256L * 1024L * 1024L;
static final long MAX_CACHE_BYTES = 1024L * 1024L * 1024L;
static final Set<String> RESERVED_IDS = Set.of("iris");
private DatapackArchive() {
}
static File extractArchive(File zip, File stagingRoot, Entry entry) throws IOException {
DatapackSupport.ensureScratchDirectory(stagingRoot, "datapack staging");
File pending = new File(stagingRoot, ".pending-" + entry.id + "-" + UUID.randomUUID());
try {
if (!pending.mkdirs() && !pending.isDirectory()) {
throw new IOException("Couldn't create datapack extraction directory " + pending.getPath());
}
ZipUtils.unzipFile(zip, pending, MAX_ARCHIVE_ENTRIES, MAX_EXPANDED_BYTES, MAX_ENTRY_BYTES);
flattenIfWrapped(pending);
DatapackPackMetadata.validatePackMetadata(pending);
PackResources resources = DatapackOwnership.scanPackResources(pending);
entry.structureKeys = resources.structureKeys();
entry.templateKeys = resources.templateKeys();
DatapackOwnership.writeOwnership(pending, entry);
return pending;
} catch (UncheckedIOException e) {
IOException failure = e.getCause();
cleanupFailedExtraction(pending, failure);
throw failure;
} catch (IOException | RuntimeException e) {
cleanupFailedExtraction(pending, e);
throw e;
}
}
static void cleanupFailedExtraction(File pending, Throwable failure) {
try {
DatapackInstall.deleteInstallScratch(pending, "failed datapack extraction");
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
static void cleanupExtractedStaging(File extractedDir) {
try {
DatapackInstall.deleteInstallScratch(extractedDir, "extracted datapack staging");
} catch (IOException e) {
IrisLogging.warn("Preserving extracted datapack staging for restart cleanup: "
+ extractedDir.getPath() + " (" + e.getMessage() + ")");
}
}
static void flattenIfWrapped(File dir) throws IOException {
if (new File(dir, "pack.mcmeta").isFile()) {
return;
}
File[] children = dir.listFiles();
if (children == null) {
return;
}
File singleDir = null;
int dirCount = 0;
int fileCount = 0;
for (File child : children) {
if (child.isDirectory()) {
dirCount++;
singleDir = child;
} else {
fileCount++;
}
}
if (dirCount != 1 || fileCount != 0 || singleDir == null || !new File(singleDir, "pack.mcmeta").isFile()) {
return;
}
File[] inner = singleDir.listFiles();
if (inner != null) {
for (File item : inner) {
File moved = new File(dir, item.getName());
if (item.renameTo(moved)) {
continue;
}
if (item.isDirectory()) {
IO.copyDirectory(item.toPath(), moved.toPath());
} else {
Files.copy(item.toPath(), moved.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
}
DatapackInstall.deleteInstallScratch(singleDir, "wrapped datapack extraction");
}
static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException {
URI current = DatapackStartupValidation.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())) {
throw new IOException("Datapack URL must use HTTP or HTTPS: " + current);
}
URL target = current.toURL();
HttpURLConnection connection = (HttpURLConnection) target.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", USER_AGENT);
if (etag != null && !etag.isBlank()) {
connection.setRequestProperty("If-None-Match", etag);
}
if (lastModified != null && !lastModified.isBlank()) {
connection.setRequestProperty("If-Modified-Since", lastModified);
}
connection.setConnectTimeout(20000);
connection.setReadTimeout(60000);
connection.setInstanceFollowRedirects(false);
try {
int code = connection.getResponseCode();
if (code == HttpURLConnection.HTTP_NOT_MODIFIED) {
String responseEtag = headerOrFallback(connection, "ETag", etag);
String responseLastModified = headerOrFallback(connection, "Last-Modified", lastModified);
return new DownloadResult(true, responseEtag, responseLastModified);
}
if (code / 100 == 3) {
String location = connection.getHeaderField("Location");
if (location == null || location.isBlank()) {
throw new IOException("Redirect without a location header from " + current);
}
try {
current = current.resolve(new URI(location));
} catch (URISyntaxException e) {
throw new IOException("Invalid redirect location from " + current + ": " + location, e);
}
continue;
}
if (code != 200) {
throw new IOException("HTTP " + code + " downloading " + current);
}
long declaredLength = connection.getContentLengthLong();
if (declaredLength > MAX_DOWNLOAD_BYTES) {
throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes");
}
File parent = dest.getParentFile();
Path parentPath = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent.toPath();
DatapackSupport.ensureScratchDirectory(parentPath.toFile(), "datapack download cache");
Path temporary = Files.createTempFile(parentPath, dest.getName() + "-", ".part");
try {
long downloaded = 0;
String responseEtag = connection.getHeaderField("ETag");
String responseLastModified = connection.getHeaderField("Last-Modified");
try (InputStream in = connection.getInputStream();
OutputStream out = Files.newOutputStream(temporary)) {
byte[] buffer = new byte[8192];
int length;
while ((length = in.read(buffer)) > 0) {
downloaded += length;
if (downloaded > MAX_DOWNLOAD_BYTES) {
throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes");
}
out.write(buffer, 0, length);
}
}
DatapackSupport.move(temporary, dest.toPath());
return new DownloadResult(false, responseEtag, responseLastModified);
} finally {
Files.deleteIfExists(temporary);
}
} finally {
connection.disconnect();
}
}
throw new IOException("Too many redirects downloading " + url);
}
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);
}
DatapackSupport.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[DatapackOwnership.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);
}
DatapackSupport.move(temporary, destinationPath);
return new DownloadResult(false, null, null);
} finally {
Files.deleteIfExists(temporary);
}
}
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;
}
static String headerOrFallback(HttpURLConnection connection, String name, String fallback) {
String value = connection.getHeaderField(name);
return value == null || value.isBlank() ? fallback : value;
}
static String sha1(File file) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
try (InputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[8192];
int length;
while ((length = in.read(buffer)) > 0) {
digest.update(buffer, 0, length);
}
}
byte[] hash = digest.digest();
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-1 algorithm unavailable", e);
}
}
static String deriveId(ResolvedDatapack resolved) {
String base = resolved.getProjectSlug();
if (base == null || base.isBlank()) {
base = resolved.getFileName();
int dot = base.lastIndexOf('.');
if (dot > 0) {
base = base.substring(0, dot);
}
}
String id = sanitizeId(base);
if (resolved.isDirect()) {
return id + "-" + ModrinthResolver.directIdentity(resolved.getDownloadUrl());
}
return id;
}
static String sanitizeId(String value) {
if (value == null) {
return "datapack";
}
String lower = value.toLowerCase(Locale.ROOT).trim();
StringBuilder builder = new StringBuilder(lower.length());
for (int i = 0; i < lower.length(); i++) {
char c = lower.charAt(i);
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') {
builder.append(c);
} else if (c == ' ' || c == '/' || c == '\\') {
builder.append('-');
}
}
String cleaned = builder.toString().replaceAll("-+", "-");
cleaned = cleaned.replaceAll("^[-_.]+", "").replaceAll("[-_.]+$", "");
return cleaned.isBlank() ? "datapack" : cleaned;
}
static boolean isValidManagedId(String value) {
return value != null && !value.isBlank() && value.equals(sanitizeId(value))
&& !RESERVED_IDS.contains(value);
}
static void pruneCache(File cacheDir) {
File[] files = cacheDir.listFiles(File::isFile);
if (files == null) {
return;
}
List<File> archives = new ArrayList<>();
long totalBytes = 0;
for (File file : files) {
if (file.getName().endsWith(".part")) {
IO.delete(file);
continue;
}
if (file.getName().endsWith(".zip")) {
archives.add(file);
totalBytes += Math.max(0, file.length());
}
}
archives.sort(Comparator.comparingLong(File::lastModified));
int remaining = archives.size();
for (File archive : archives) {
if (remaining <= MAX_CACHE_FILES && totalBytes <= MAX_CACHE_BYTES) {
break;
}
long size = Math.max(0, archive.length());
IO.delete(archive);
if (!archive.exists()) {
remaining--;
totalBytes -= size;
}
}
}
}
@@ -0,0 +1,41 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.io.IOException;
import java.nio.file.Path;
final class DatapackCoordinator {
private final Path transactionRoot;
final CoordinatorJournal journal;
DatapackCoordinator(Path transactionRoot, CoordinatorJournal journal) {
this.transactionRoot = transactionRoot;
this.journal = journal;
}
void phase(CoordinatorPhase phase) throws IOException {
journal.phase = phase;
DatapackTransactions.writeCoordinatorJournal(transactionRoot, journal);
}
void finish() throws IOException {
DatapackTransactions.deleteCoordinatorTransaction(transactionRoot);
}
}
@@ -0,0 +1,438 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackCoordinatorResolution {
private DatapackCoordinatorResolution() {
}
static Set<Path> authoritativeEditableRoots(
Manifest committedManifest,
CoordinatorJournal journal
) throws IOException {
Set<Path> roots = new HashSet<>();
Entry committed = committedManifest.findById(journal.id);
if (committed != null) {
if (!Objects.equals(committed.url, journal.url)) {
throw new IOException("Datapack transaction conflicts with the committed editable pack owner");
}
addExistingPackRoots(roots, committed.importedTargets.keySet());
addExistingPackRoots(roots, committed.importAttempts.keySet());
addExistingPackRoots(roots, committed.importedBundles.keySet());
return roots;
}
if (journal.operation != CoordinatorOperation.REMOVE || !journal.phase.published()) {
throw new IOException("Datapack transaction has no committed editable pack authority");
}
for (CoordinatorEditable editable : journal.editables) {
Path packRoot = DatapackTransactions.coordinatorPath(editable.packRoot, "editable pack root");
if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot);
}
Path realPackRoot = packRoot.toRealPath();
if (!packRoot.equals(realPackRoot)) {
throw new IOException("Editable pack root changed before datapack recovery: " + packRoot);
}
roots.add(realPackRoot);
}
return roots;
}
static void addExistingPackRoots(Set<Path> roots, Set<String> paths) throws IOException {
for (String path : paths) {
try {
Path root = Path.of(path).toAbsolutePath().normalize();
if (Files.isDirectory(root)) {
roots.add(root.toRealPath());
}
} catch (RuntimeException e) {
throw new IOException("Invalid editable pack root in the datapack manifest: " + path, e);
}
}
}
static void validateCoordinatorDirectory(
CoordinatorDirectory directory,
CoordinatorOperation operation,
Set<Path> allowedTargets,
Set<Path> seenTargets,
Set<Path> seenTargetIdentities
) throws IOException {
if (directory == null || directory.target == null || directory.backup == null
|| directory.originalHash == null || directory.desiredHash == null
|| directory.originalMarkerHash == null || directory.desiredMarkerHash == null
|| directory.originalIdentity == null || directory.desiredIdentity == null
|| directory.targetRoot == null || directory.scratchRoot == null
|| directory.targetRootIdentity == null || directory.scratchRootIdentity == null) {
throw new IOException("Incomplete directory participant in datapack transaction");
}
Path target = DatapackTransactions.coordinatorPath(directory.target, "target");
if (!allowedTargets.contains(target) || !seenTargets.add(target)) {
throw new IOException("Datapack transaction target is outside its configured roots: " + target);
}
File targetFile = target.toFile();
File targetParent = targetFile.getParentFile();
Path targetRootIdentity = DatapackTransactions.coordinatorPath(directory.targetRoot, "target root");
if (!seenTargetIdentities.add(targetRootIdentity.resolve(target.getFileName()).normalize())) {
throw new IOException("Aliased datapack transaction target " + target);
}
validateRecoveryContainer(
targetParent.toPath(),
targetRootIdentity,
true,
"datapack target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
targetParent, directory.targetRoot, directory.targetRootIdentity, "datapack target root");
File scratchRoot = new File(
targetParent.getParentFile() == null ? targetParent : targetParent.getParentFile(),
operation == CoordinatorOperation.INSTALL ? ".iris-datapack-install" : ".iris-datapack-remove"
);
Path expectedScratch = scratchRoot.toPath().toAbsolutePath().normalize();
Path backup = DatapackTransactions.coordinatorPath(directory.backup, "backup");
if (!Objects.equals(backup.getParent(), expectedScratch) || Files.isSymbolicLink(backup)) {
throw new IOException("Invalid datapack transaction backup path " + backup);
}
Path pending = null;
if (operation == CoordinatorOperation.INSTALL) {
if (directory.pending == null || directory.pending.isBlank()) {
throw new IOException("Install transaction is missing its pending directory");
}
pending = DatapackTransactions.coordinatorPath(directory.pending, "pending directory");
if (!Objects.equals(pending.getParent(), expectedScratch) || Files.isSymbolicLink(pending)) {
throw new IOException("Invalid datapack transaction pending path " + pending);
}
} else if (directory.pending != null && !directory.pending.isBlank()) {
throw new IOException("Removal transaction unexpectedly contains a pending directory");
}
boolean scratchRequired = Files.exists(backup, LinkOption.NOFOLLOW_LINKS)
|| pending != null && Files.exists(pending, LinkOption.NOFOLLOW_LINKS);
validateRecoveryContainer(
expectedScratch,
DatapackTransactions.coordinatorPath(directory.scratchRoot, "scratch root"),
scratchRequired,
"datapack transaction scratch root"
);
if (scratchRequired) {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
expectedScratch.toFile(), directory.scratchRoot,
directory.scratchRootIdentity, "datapack transaction scratch root");
}
}
static void validateRecoveryContainer(
Path container,
Path expectedRealPath,
boolean required,
String purpose
) throws IOException {
Path normalized = container.toAbsolutePath().normalize();
Path normalizedExpected = expectedRealPath.toAbsolutePath().normalize();
if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) {
if (required) {
throw new IOException("Missing " + purpose + " " + normalized);
}
return;
}
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), normalizedExpected)) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
static boolean coordinatorCommitDecision(Manifest manifest, CoordinatorJournal journal) throws IOException {
Entry committed = manifest.findById(journal.id);
if (journal.operation == CoordinatorOperation.REMOVE) {
if (committed == null) {
if (!journal.phase.published()) {
throw new IOException("Datapack removal manifest changed before every participant was published");
}
return true;
}
if (!Objects.equals(committed.url, journal.url) || journal.phase == CoordinatorPhase.COMMITTED) {
throw new IOException("Datapack removal journal conflicts with the committed manifest");
}
return false;
}
boolean matches = committed != null
&& Objects.equals(committed.url, journal.url)
&& Objects.equals(committed.versionId, journal.versionId)
&& Objects.equals(committed.versionNumber, journal.versionNumber)
&& Objects.equals(committed.sha1, journal.sha1);
if (journal.manifestAlreadyMatched) {
if (!matches) {
throw new IOException("Committed datapack changed while an install transaction was incomplete");
}
return journal.phase.published();
}
if (matches) {
if (!journal.phase.published()) {
throw new IOException("Datapack install manifest changed before every target was published");
}
return true;
}
if (journal.phase == CoordinatorPhase.COMMITTED) {
throw new IOException("Committed datapack install journal conflicts with the manifest");
}
return false;
}
static void resolveCoordinatorDirectories(CoordinatorJournal journal, boolean commit) throws IOException {
List<CoordinatorDirectory> directories = new ArrayList<>(journal.directories);
if (!commit) {
Collections.reverse(directories);
}
IOException failure = null;
for (CoordinatorDirectory directory : directories) {
try {
if (journal.operation == CoordinatorOperation.INSTALL) {
resolveInstallDirectory(journal, directory, commit);
} else {
resolveRemovalDirectory(directory, commit);
}
} catch (IOException | RuntimeException e) {
IOException participantFailure = e instanceof IOException ioFailure
? ioFailure : new IOException("Failed resolving datapack directory participant", e);
if (failure == null) {
failure = participantFailure;
} else {
failure.addSuppressed(participantFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
static void resolveInstallDirectory(
CoordinatorJournal journal,
CoordinatorDirectory directory,
boolean commit
) throws IOException {
File target = new File(directory.target);
File pending = new File(directory.pending);
File backup = new File(directory.backup);
File targetRoot = new File(directory.targetRoot);
if (commit) {
verifyDesiredDirectory(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
deleteOriginalBackupIfPresent(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity);
deleteDesiredDirectoryIfPresent(
pending, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
cleanupScratchParent(backup);
return;
}
if (directory.hadTarget) {
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"datapack install backup");
deleteDesiredDirectoryIfPresent(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
DatapackSupport.moveNew(backup.toPath(), target.toPath());
DatapackSupport.forceDirectoryIfSupported(target.getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(backup.getParentFile().toPath());
} else {
DatapackInstallPlanner.verifyDirectorySnapshot(
target, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"original datapack target");
}
} else {
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unexpected backup for newly-installed datapack " + backup.getPath());
}
deleteDesiredDirectoryIfPresent(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
}
deleteDesiredDirectoryIfPresent(
pending, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
cleanupScratchParent(backup);
}
static void resolveRemovalDirectory(CoordinatorDirectory directory, boolean commit) throws IOException {
File target = new File(directory.target);
File backup = new File(directory.backup);
File targetRoot = new File(directory.targetRoot);
if (commit) {
if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Removed datapack target reappeared before transaction cleanup: " + target.getPath());
}
deleteOriginalBackupIfPresent(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity);
cleanupScratchParent(backup);
return;
}
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"datapack removal backup");
if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack removal target was concurrently recreated: " + target.getPath());
}
DatapackSupport.moveNew(backup.toPath(), target.toPath());
DatapackSupport.forceDirectoryIfSupported(target.getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(backup.getParentFile().toPath());
} else {
DatapackInstallPlanner.verifyDirectorySnapshot(
target, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"original datapack target");
}
cleanupScratchParent(backup);
}
static void resolveCoordinatorEditables(
CoordinatorJournal journal,
Path transactionRoot,
boolean commit
) throws IOException {
IOException failure = null;
for (CoordinatorEditable editable : journal.editables) {
try {
Path packRoot = DatapackTransactions.coordinatorPath(editable.packRoot, "editable pack root");
StructureTransactionWriter writer = new StructureTransactionWriter(packRoot);
writer.resolvePreparedRemoval(
new StructureTransactionWriter.PreparedRemovalToken(
packRoot,
UUID.fromString(editable.transactionId)
),
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
),
commit
);
IrisData.invalidateLoadedStructureResources(packRoot.toFile());
} catch (IOException | RuntimeException e) {
IOException participantFailure = e instanceof IOException ioFailure
? ioFailure : new IOException("Failed resolving editable structure participant", e);
if (failure == null) {
failure = participantFailure;
} else {
failure.addSuppressed(participantFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
static void verifyDesiredDirectory(
File directory,
File targetRoot,
CoordinatorJournal journal,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
DatapackInstallPlanner.verifyDirectorySnapshot(
directory, targetRoot, expectedHash, expectedMarkerHash,
expectedIdentity, "installed datapack target");
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.id, journal.id) || !Objects.equals(ownership.url, journal.url)
|| !Objects.equals(ownership.contentHash, expectedHash)) {
throw new IOException("Installed datapack ownership does not match its transaction at " + directory.getPath());
}
}
static void deleteDesiredDirectoryIfPresent(
File directory,
File targetRoot,
CoordinatorJournal journal,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
verifyDesiredDirectory(
directory, targetRoot, journal, expectedHash,
expectedMarkerHash, expectedIdentity);
deleteVerifiedDirectory(directory);
}
static void deleteOriginalBackupIfPresent(
File backup,
File targetRoot,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
if (!Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, expectedHash, expectedMarkerHash,
expectedIdentity, "datapack transaction backup");
deleteVerifiedDirectory(backup);
}
static void deleteVerifiedDirectory(File directory) throws IOException {
File parent = Objects.requireNonNull(directory.getParentFile(), "datapack transaction parent");
DatapackInstall.validateInstallTree(directory, parent, "Datapack transaction directory");
IO.delete(directory);
if (Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not delete datapack transaction directory " + directory.getPath());
}
}
static void cleanupScratchParent(File participant) {
File parent = participant.getParentFile();
if (parent != null) {
parent.delete();
}
}
}
@@ -0,0 +1,146 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
final class DatapackImportSources {
static final String LOCAL_IMPORT_DIRECTORY = "imports";
private DatapackImportSources() {
}
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(DatapackImportSources::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();
}
}
static boolean isLocalDatapackArchive(Path path) {
String name = path.getFileName().toString();
return !name.startsWith(".") && name.toLowerCase(Locale.ROOT).endsWith(".zip");
}
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();
}
}
static void collectImports(IrisData data, LinkedHashSet<String> urls) {
if (data == null || data.getDimensionLoader() == null) {
return;
}
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
if (dimension == null) {
continue;
}
KList<String> imports = dimension.getDatapackImports();
if (imports == null) {
continue;
}
addImports(imports, urls);
}
}
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;
}
static Set<String> configuredImports(IrisData data) {
LinkedHashSet<String> urls = new LinkedHashSet<>();
collectImports(data, urls);
urls.addAll(localDatapackImports());
return urls;
}
static boolean hasImports(IrisData data) {
return !configuredImports(data).isEmpty();
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,331 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
final class DatapackInstall {
static final int MAX_SCRATCH_DELETE_ATTEMPTS = 3;
private DatapackInstall() {
}
static InstallResult install(File stagedDir, KList<File> worldFolders, Entry entry, boolean stripOverrides) throws IOException {
File root = inferDatapackRoot(stagedDir);
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
InstallExecution execution = prepareInstallExecution(
stagedDir,
worldFolders,
entry,
stripOverrides,
root
);
try {
verifyInstallExecution(execution);
} catch (IOException failure) {
rollbackInstallExecutions(List.of(execution), failure);
throw failure;
}
finishInstallExecution(execution);
return execution.result();
}
static InstallExecution prepareInstallExecution(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides,
File root
) throws IOException {
return prepareInstallExecution(
stagedDir, worldFolders, entry, stripOverrides, root, null);
}
static InstallExecution prepareInstallExecution(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides,
File root,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
DatapackOwnership.validateManagedDirectory(stagedDir, entry.id);
Ownership stagedOwnership = DatapackOwnership.readOwnership(stagedDir);
String stagedHash = DatapackOwnership.directoryHash(stagedDir);
if (!ownershipMetadataMatches(stagedOwnership, entry, stagedHash)) {
throw new IOException("Iris datapack staging does not match the committed manifest entry for " + entry.id);
}
DatapackStagingGuard.validateInstallParticipants(worldFolders, verifiedStagingInstall, entry);
List<InstallPlan> plans = new ArrayList<>();
try {
for (File worldFolder : worldFolders) {
plans.add(DatapackInstallPlanner.prepareInstall(
stagedDir, worldFolder, entry, stagedHash, stripOverrides, verifiedStagingInstall));
}
if (verifiedStagingInstall != null) {
plans.add(DatapackInstallPlanner.prepareInstall(
stagedDir,
verifiedStagingInstall.stagingRoot().toFile(),
entry,
stagedHash,
false,
verifiedStagingInstall));
}
} catch (IOException | RuntimeException preparationFailure) {
for (InstallPlan plan : plans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException cleanupFailure) {
preparationFailure.addSuppressed(cleanupFailure);
}
}
if (preparationFailure instanceof UncheckedIOException unchecked) {
throw unchecked.getCause();
}
if (preparationFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw preparationFailure;
}
boolean changed = false;
List<InstallPlan> publishPlans = new ArrayList<>();
List<InstallPlan> unchangedPlans = new ArrayList<>();
try {
for (InstallPlan plan : plans) {
changed |= plan.contentChanged();
if (plan.publishRequired()) {
publishPlans.add(plan);
} else {
unchangedPlans.add(plan);
DatapackInstallPlanner.cleanupInstallPlan(plan, true);
}
}
} catch (IOException cleanupFailure) {
for (InstallPlan plan : plans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException additionalFailure) {
cleanupFailure.addSuppressed(additionalFailure);
}
}
throw cleanupFailure;
}
if (publishPlans.isEmpty()) {
return new InstallExecution(
new InstallResult(changed),
null,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
boolean manifestAlreadyMatched = manifestEntryMatches(committedManifest.findById(entry.id), entry);
DatapackCoordinator coordinator;
try {
coordinator = DatapackTransactions.createInstallCoordinator(root, entry, publishPlans, manifestAlreadyMatched);
} catch (IOException | RuntimeException coordinatorFailure) {
for (InstallPlan plan : publishPlans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException cleanupFailure) {
coordinatorFailure.addSuppressed(cleanupFailure);
}
}
if (coordinatorFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw coordinatorFailure;
}
try {
coordinator.phase(CoordinatorPhase.PUBLISHING);
for (InstallPlan plan : publishPlans) {
DatapackInstallPlanner.publishInstallPlan(plan);
}
coordinator.phase(CoordinatorPhase.PUBLISHED);
} catch (IOException | RuntimeException publishFailure) {
try {
DatapackCoordinatorResolution.resolveCoordinatorDirectories(coordinator.journal, false);
coordinator.finish();
} catch (IOException rollbackFailure) {
publishFailure.addSuppressed(rollbackFailure);
}
if (publishFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw publishFailure;
}
return new InstallExecution(
new InstallResult(changed),
coordinator,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
static void verifyInstallExecution(InstallExecution execution) throws IOException {
if (execution.verified()) {
return;
}
if (execution.verifyStagedSource()) {
DatapackOwnership.validateManagedDirectory(execution.stagedDir(), execution.entry().id);
Ownership stagedOwnership = DatapackOwnership.readOwnership(execution.stagedDir());
String stagedHash = DatapackOwnership.directoryHash(execution.stagedDir());
if (!Objects.equals(stagedHash, execution.stagedHash())
|| !ownershipMetadataMatches(stagedOwnership, execution.entry(), stagedHash)) {
throw new IOException("Iris datapack staging changed before installation commit for "
+ execution.entry().id);
}
}
for (InstallPlan plan : execution.publishedPlans()) {
DatapackInstallPlanner.verifyDesiredInstallSnapshot(plan.target(), plan, "published datapack target");
}
for (InstallPlan plan : execution.unchangedPlans()) {
DatapackInstallPlanner.verifyOriginalInstallSnapshot(plan.target(), plan, "unchanged datapack target");
}
execution.markVerified();
}
static void finishInstallExecution(InstallExecution execution) throws IOException {
if (!execution.verified()) {
throw new IOException("Datapack install cannot commit before final verification");
}
if (execution.coordinator() == null) {
return;
}
execution.coordinator().phase(CoordinatorPhase.COMMITTED);
DatapackCoordinatorResolution.resolveCoordinatorDirectories(execution.coordinator().journal, true);
execution.coordinator().finish();
}
static void rollbackInstallExecutions(List<InstallExecution> executions, Throwable failure) {
List<InstallExecution> reversed = new ArrayList<>(executions);
Collections.reverse(reversed);
for (InstallExecution execution : reversed) {
if (execution.coordinator() == null) {
continue;
}
try {
DatapackCoordinatorResolution.resolveCoordinatorDirectories(execution.coordinator().journal, false);
execution.coordinator().finish();
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
}
static File inferDatapackRoot(File stagedDir) {
File parent = stagedDir.getParentFile();
if (parent != null && "staging".equals(parent.getName()) && parent.getParentFile() != null) {
return parent.getParentFile();
}
return parent == null ? stagedDir : parent;
}
static boolean manifestEntryMatches(Entry committed, Entry desired) {
return committed != null
&& Objects.equals(committed.id, desired.id)
&& Objects.equals(committed.url, desired.url)
&& Objects.equals(committed.versionId, desired.versionId)
&& Objects.equals(committed.versionNumber, desired.versionNumber)
&& Objects.equals(committed.sha1, desired.sha1);
}
static void validateInstallTree(File directory, File storeAnchor, String purpose) throws IOException {
DatapackScratchRecovery.validateScratchTree(directory.toPath());
if (!DatapackScratchRecovery.sameScratchVolume(directory.toPath(), storeAnchor.toPath())) {
throw new IOException(purpose + " crosses a filesystem boundary at " + directory.getPath());
}
}
static File installScratchRoot(File targetFolder) {
File parent = targetFolder.getParentFile();
return new File(parent == null ? targetFolder : parent, ".iris-datapack-install");
}
static void cleanupPreparedInstall(File pending, File pendingRoot, Throwable failure) {
try {
deleteInstallScratch(pending, "prepared datapack install");
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
pendingRoot.delete();
}
static boolean ownershipMetadataMatches(Ownership ownership, Entry entry, String contentHash) {
return ownership.schemaVersion == DatapackOwnership.OWNERSHIP_SCHEMA
&& Objects.equals(ownership.id, entry.id)
&& Objects.equals(ownership.url, entry.url)
&& Objects.equals(ownership.versionId, entry.versionId)
&& Objects.equals(ownership.versionNumber, entry.versionNumber)
&& Objects.equals(ownership.sha1, entry.sha1)
&& Objects.equals(ownership.contentHash, contentHash)
&& DatapackManifestStore.copyList(ownership.structureKeys).equals(DatapackManifestStore.copyList(entry.structureKeys))
&& DatapackManifestStore.copyList(ownership.templateKeys).equals(DatapackManifestStore.copyList(entry.templateKeys));
}
static boolean ownershipSourceMatches(Ownership ownership, Entry entry) {
return ownership.schemaVersion == DatapackOwnership.OWNERSHIP_SCHEMA
&& Objects.equals(ownership.id, entry.id)
&& Objects.equals(ownership.url, entry.url);
}
static void deleteInstallScratch(File scratch, String purpose) throws IOException {
Path scratchPath = scratch.toPath();
File parent = Objects.requireNonNull(scratch.getParentFile(), "datapack scratch parent");
for (int attempt = 0; attempt < MAX_SCRATCH_DELETE_ATTEMPTS; attempt++) {
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (!Files.exists(scratchPath, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(scratchPath)
|| !Files.isDirectory(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to remove unsafe " + purpose + " " + scratch.getPath());
}
validateInstallTree(scratch, parent, purpose);
DatapackOwnership.removeFinderMetadata(scratch);
IO.delete(scratch);
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
}
throw new IOException("Could not remove " + purpose + " " + scratch.getPath());
}
}
@@ -0,0 +1,394 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.UUID;
final class DatapackInstallPlanner {
private DatapackInstallPlanner() {
}
static InstallPlan prepareInstall(
File stagedDir,
File worldFolder,
Entry entry,
String stagedHash,
boolean stripOverrides,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
DatapackStagingGuard.ensureInstallTargetRoot(worldFolder);
File target = new File(worldFolder, entry.id);
InstallPlan unchanged = tryPrepareUnchangedManagedInstall(
stagedDir,
worldFolder,
target,
entry,
stagedHash,
stripOverrides,
verifiedStagingInstall);
if (unchanged != null) {
return unchanged;
}
boolean canonicalStagingInstall = verifiedStagingInstall != null
&& verifiedStagingInstall.isCanonicalInstall(worldFolder, target);
boolean legacyReplacementAuthorized = canonicalStagingInstall
&& verifiedStagingInstall.consume(stagedDir, worldFolder, target, entry, stagedHash);
VerifiedStagingInstall legacyWorldAuthorization = null;
File pendingRoot = DatapackInstall.installScratchRoot(worldFolder);
File pending = new File(pendingRoot, entry.id + "-" + UUID.randomUUID());
File backup = new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID());
try {
DatapackSupport.ensureScratchDirectory(pendingRoot, "datapack install staging");
String targetRootIdentity = DatapackSupport.realDirectoryPath(worldFolder, "datapack target root");
String scratchRootIdentity = DatapackSupport.realDirectoryPath(pendingRoot, "datapack install scratch root");
String targetRootFileIdentity = DatapackSupport.directoryIdentity(worldFolder);
String scratchRootFileIdentity = DatapackSupport.directoryIdentity(pendingRoot);
IO.copyDirectory(stagedDir.toPath(), pending.toPath());
Files.deleteIfExists(new File(pending, DatapackOwnership.OWNERSHIP_MARKER).toPath());
String copiedHash = DatapackOwnership.directoryHash(pending);
if (!Objects.equals(stagedHash, copiedHash)) {
throw new IOException("Datapack staging changed or copied incompletely while preparing " + entry.id);
}
boolean removedOverrideMarker = Files.deleteIfExists(
new File(pending, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).toPath());
if (stripOverrides) {
DatapackPackMetadata.stripVanillaStructureOverrides(pending);
DatapackPackMetadata.writeMarker(new File(pending, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER));
}
DatapackPackMetadata.validatePackMetadata(pending);
if (!stripOverrides && !removedOverrideMarker) {
DatapackOwnership.writeOwnership(pending, entry, copiedHash);
} else {
DatapackOwnership.writeOwnership(pending, entry);
}
DatapackInstall.validateInstallTree(pending, worldFolder, "Prepared datapack install");
Ownership desiredOwnership = DatapackOwnership.readOwnership(pending);
String desiredHash = desiredOwnership.contentHash;
String desiredMarkerHash = DatapackOwnership.ownershipMarkerFingerprint(pending);
String desiredIdentity = DatapackSupport.directoryIdentity(pending);
boolean hadTarget = DatapackSupport.pathExists(target.toPath(), "datapack install target");
String originalHash = "";
String originalMarkerHash = "absent";
String originalIdentity = "";
boolean contentChanged = !hadTarget;
boolean publishRequired = !hadTarget;
if (hadTarget) {
if (Files.isSymbolicLink(target.toPath())
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
}
DatapackInstall.validateInstallTree(target, worldFolder, "Existing datapack install");
Ownership ownership = DatapackOwnership.readOwnershipOrNull(target);
if (ownership != null) {
DatapackOwnership.removeFinderMetadata(target);
}
String currentHash = DatapackOwnership.directoryHash(target);
originalHash = currentHash;
originalMarkerHash = DatapackOwnership.ownershipMarkerFingerprint(target);
originalIdentity = DatapackSupport.directoryIdentity(target);
if (ownership == null) {
boolean differingLegacyTarget = !Objects.equals(currentHash, desiredHash);
if (differingLegacyTarget && !legacyReplacementAuthorized
&& verifiedStagingInstall != null
&& verifiedStagingInstall.authorizeLegacyWorldReplacement(
stagedDir,
worldFolder,
target,
entry,
stagedHash,
currentHash,
originalMarkerHash)) {
legacyReplacementAuthorized = true;
legacyWorldAuthorization = verifiedStagingInstall;
}
if (differingLegacyTarget && !legacyReplacementAuthorized) {
throw new IOException("Refusing to replace unmanaged datapack at " + target.getPath());
}
if (differingLegacyTarget && (originalIdentity.isEmpty()
|| targetRootFileIdentity.isEmpty()
|| scratchRootFileIdentity.isEmpty()
|| verifiedStagingInstall == null
|| !verifiedStagingInstall.hasStablePathIdentities())) {
throw new IOException("Cannot safely identify legacy datapack staging at " + target.getPath());
}
publishRequired = true;
} else {
if (!entry.id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + target.getPath());
}
publishRequired = !Objects.equals(ownership.contentHash, currentHash)
|| !DatapackInstall.ownershipMetadataMatches(ownership, entry, desiredHash);
}
contentChanged = !Objects.equals(currentHash, desiredHash);
publishRequired |= contentChanged;
if (ownership != null && !Objects.equals(ownership.contentHash, currentHash)) {
IrisLogging.warn("Repairing modified or corrupt Iris-managed datapack at " + target.getPath());
}
}
return new InstallPlan(
target,
pending,
backup,
pendingRoot,
hadTarget,
publishRequired,
contentChanged,
originalHash,
desiredHash,
originalMarkerHash,
desiredMarkerHash,
originalIdentity,
desiredIdentity,
targetRootIdentity,
scratchRootIdentity,
targetRootFileIdentity,
scratchRootFileIdentity,
entry.id,
entry.url,
legacyWorldAuthorization
);
} catch (UncheckedIOException e) {
IOException cause = e.getCause();
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, cause);
throw cause;
} catch (IOException e) {
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, e);
throw e;
} catch (RuntimeException e) {
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, e);
throw e;
}
}
static InstallPlan tryPrepareUnchangedManagedInstall(
File stagedDir,
File worldFolder,
File target,
Entry entry,
String stagedHash,
boolean stripOverrides,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
if (verifiedStagingInstall != null
|| stripOverrides
|| DatapackSupport.pathExists(new File(stagedDir, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).toPath(),
"staged datapack override marker")
|| !DatapackSupport.pathExists(target.toPath(), "datapack install target")) {
return null;
}
if (Files.isSymbolicLink(target.toPath())
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
}
Ownership ownership = DatapackOwnership.readOwnershipOrNull(target);
if (ownership == null) {
return null;
}
if (!entry.id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + target.getPath());
}
if (!DatapackInstall.ownershipMetadataMatches(ownership, entry, stagedHash)) {
return null;
}
DatapackInstall.validateInstallTree(target, worldFolder, "Existing datapack install");
DatapackOwnership.removeFinderMetadata(target);
String currentHash = DatapackOwnership.directoryHash(target);
if (!Objects.equals(currentHash, stagedHash)) {
return null;
}
String markerHash = DatapackOwnership.ownershipMarkerFingerprint(target);
String identity = DatapackSupport.directoryIdentity(target);
File pendingRoot = DatapackInstall.installScratchRoot(worldFolder);
return new InstallPlan(
target,
new File(pendingRoot, entry.id + "-" + UUID.randomUUID()),
new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID()),
pendingRoot,
true,
false,
false,
currentHash,
currentHash,
markerHash,
markerHash,
identity,
identity,
DatapackSupport.realDirectoryPath(worldFolder, "datapack target root"),
"",
DatapackSupport.directoryIdentity(worldFolder),
"",
entry.id,
entry.url,
null
);
}
static void publishInstallPlan(InstallPlan plan) throws IOException {
verifyDirectoryContainerIdentity(
plan.target().getParentFile(), plan.targetRootIdentity(),
plan.targetRootFileIdentity(), "datapack target root");
verifyDirectoryContainerIdentity(
plan.pendingRoot(), plan.scratchRootIdentity(),
plan.scratchRootFileIdentity(), "datapack install scratch root");
verifyDesiredInstallSnapshot(plan.pending(), plan, "prepared datapack install");
if (plan.hadTarget()) {
verifyOriginalInstallSnapshot(plan.target(), plan, "original datapack target");
} else if (DatapackSupport.pathExists(plan.target().toPath(), "new datapack target")) {
throw new IOException("Datapack install target was concurrently created at " + plan.target().getPath());
}
if (plan.legacyWorldAuthorization() != null) {
plan.legacyWorldAuthorization().verifyLegacyWorldSnapshot();
}
try {
if (plan.hadTarget()) {
DatapackSupport.moveNew(plan.target().toPath(), plan.backup().toPath());
verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup");
if (DatapackSupport.pathExists(plan.target().toPath(), "moved datapack target")) {
throw new IOException("Datapack install target reappeared after backup at " + plan.target().getPath());
}
forceInstallMoveDirectories(plan);
}
DatapackSupport.moveNew(plan.pending().toPath(), plan.target().toPath());
verifyDesiredInstallSnapshot(plan.target(), plan, "installed datapack target");
forceInstallMoveDirectories(plan);
} catch (IOException publishFailure) {
if (plan.hadTarget()
&& Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS)
&& Files.exists(plan.pending().toPath(), LinkOption.NOFOLLOW_LINKS)
&& Files.notExists(plan.target().toPath(), LinkOption.NOFOLLOW_LINKS)) {
try {
verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup");
DatapackSupport.moveNew(plan.backup().toPath(), plan.target().toPath());
forceInstallMoveDirectories(plan);
} catch (IOException restoreFailure) {
publishFailure.addSuppressed(restoreFailure);
}
}
throw publishFailure;
}
}
static void forceInstallMoveDirectories(InstallPlan plan) throws IOException {
DatapackSupport.forceDirectoryIfSupported(plan.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(plan.pendingRoot().toPath());
}
static void verifyDirectoryContainerIdentity(
File directory,
String expectedRealPath,
String expectedFileIdentity,
String purpose
) throws IOException {
Path normalized = directory.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), Path.of(expectedRealPath))
|| (!expectedFileIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(directory), expectedFileIdentity))) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
static void verifyOriginalInstallSnapshot(
File directory,
InstallPlan plan,
String purpose
) throws IOException {
verifyDirectorySnapshot(
directory,
plan.target().getParentFile(),
plan.originalHash(),
plan.originalMarkerHash(),
plan.originalIdentity(),
purpose
);
}
static void verifyDesiredInstallSnapshot(
File directory,
InstallPlan plan,
String purpose
) throws IOException {
verifyDirectorySnapshot(
directory,
plan.target().getParentFile(),
plan.desiredHash(),
plan.desiredMarkerHash(),
plan.desiredIdentity(),
purpose
);
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.id, plan.id())
|| !Objects.equals(ownership.url, plan.url())
|| !Objects.equals(ownership.contentHash, plan.desiredHash())) {
throw new IOException("Datapack ownership changed in " + purpose + " at " + directory.getPath());
}
}
static void verifyDirectorySnapshot(
File directory,
File storeAnchor,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity,
String purpose
) throws IOException {
if (!DatapackSupport.pathExists(directory.toPath(), purpose)
|| Files.isSymbolicLink(directory.toPath())
|| !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Missing or unsafe " + purpose + " at " + directory.getPath());
}
DatapackInstall.validateInstallTree(directory, storeAnchor, purpose);
if (Files.exists(new File(directory, DatapackOwnership.OWNERSHIP_MARKER).toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackOwnership.removeFinderMetadata(directory);
}
if (!expectedIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(directory), expectedIdentity)) {
throw new IOException("Datapack directory identity changed in " + purpose + " at " + directory.getPath());
}
if (!Objects.equals(expectedHash, DatapackOwnership.directoryHash(directory))
|| !Objects.equals(expectedMarkerHash, DatapackOwnership.ownershipMarkerFingerprint(directory))) {
throw new IOException("Datapack content changed in " + purpose + " at " + directory.getPath());
}
}
static void cleanupInstallPlan(InstallPlan plan, boolean committed) throws IOException {
DatapackInstall.deleteInstallScratch(plan.pending(), "datapack install pending directory");
if (committed) {
DatapackInstall.deleteInstallScratch(plan.backup(), "datapack install backup");
}
if (Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Preserving prior datapack backup after incomplete install: "
+ plan.backup().getPath());
}
plan.pendingRoot().delete();
}
}
@@ -0,0 +1,316 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
final class DatapackManifestStore {
static final long MAX_MANIFEST_BYTES = 16L * 1024L * 1024L;
private DatapackManifestStore() {
}
static List<String> copyList(List<String> values) {
return values == null ? List.of() : List.copyOf(values);
}
static Entry copyEntry(Entry source) {
Entry resolved = Objects.requireNonNull(source, "Datapack manifest entry must not be null");
Entry copy = new Entry();
copy.url = resolved.url;
copy.id = resolved.id;
copy.versionId = resolved.versionId;
copy.versionNumber = resolved.versionNumber;
copy.sha1 = resolved.sha1;
copy.filename = resolved.filename;
copy.etag = resolved.etag;
copy.lastModified = resolved.lastModified;
copy.installedEpoch = resolved.installedEpoch;
copy.structuresImported = resolved.structuresImported;
copy.stagingMetadata = resolved.stagingMetadata;
copy.structureKeys = new ArrayList<>(copyList(resolved.structureKeys));
copy.templateKeys = new ArrayList<>(copyList(resolved.templateKeys));
copy.installMetadata = new HashMap<>(Objects.requireNonNullElseGet(
resolved.installMetadata, Map::of));
copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importedTargets, Map::of));
copy.importAttempts = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importAttempts, Map::of));
copy.importedBundles = new HashMap<>();
if (resolved.importedBundles != null) {
for (Map.Entry<String, Map<String, String>> bundle : resolved.importedBundles.entrySet()) {
copy.importedBundles.put(bundle.getKey(), new HashMap<>(bundle.getValue()));
}
}
return copy;
}
static Manifest readManifest(File root) {
File file = new File(root, "manifest.json");
Manifest manifest = null;
boolean recoverFromStaging = false;
Path path = file.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
manifest = new Manifest();
} else if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
IrisLogging.error("Unreadable datapack manifest " + file.getPath()
+ "; moving the non-regular path aside before recovering Iris-managed staging");
recoverFromStaging = quarantine(path);
} else {
try {
String json = DatapackSupport.readBoundedUtf8(path, MAX_MANIFEST_BYTES, "Datapack manifest");
manifest = DatapackSupport.GSON.fromJson(json, Manifest.class);
if (manifest == null) {
throw new IOException("Datapack manifest is empty");
}
} catch (Exception e) {
IrisLogging.reportError("Unreadable datapack manifest " + file.getPath()
+ "; moving it aside before recovering Iris-managed staging", e);
recoverFromStaging = quarantine(file.toPath());
}
}
if (manifest == null) {
manifest = new Manifest();
}
normalizeManifest(manifest);
if (recoverFromStaging) {
recoverManifestFromStaging(root, manifest);
}
return manifest;
}
static boolean quarantine(Path file) {
try {
DatapackSupport.move(file, file.resolveSibling(file.getFileName().toString() + ".corrupt-" + System.currentTimeMillis()));
return true;
} catch (IOException e) {
IrisLogging.reportError("Failed to move aside corrupt datapack manifest " + file, e);
return false;
}
}
static void normalizeManifest(Manifest manifest) {
if (manifest.entries == null) {
manifest.entries = new ArrayList<>();
return;
}
List<Entry> normalized = new ArrayList<>();
Set<String> urls = new HashSet<>();
Set<String> ids = new HashSet<>();
for (Entry entry : manifest.entries) {
if (entry == null || entry.url == null || entry.url.isBlank() || entry.id == null || entry.id.isBlank()) {
continue;
}
entry.url = entry.url.trim();
if (!DatapackArchive.isValidManagedId(entry.id)) {
IrisLogging.warn("Ignoring datapack manifest entry with invalid id '" + entry.id + "'");
continue;
}
entry.structureKeys = normalizeKeys(entry.structureKeys);
entry.templateKeys = normalizeKeys(entry.templateKeys);
entry.stagingMetadata = entry.stagingMetadata == null ? "" : entry.stagingMetadata.trim();
entry.installMetadata = normalizeImportedTargets(entry.installMetadata);
entry.importedTargets = normalizeImportedTargets(entry.importedTargets);
entry.importAttempts = normalizeImportedTargets(entry.importAttempts);
entry.importedBundles = normalizeImportedBundles(entry.importedBundles);
if (!urls.add(entry.url) || !ids.add(entry.id)) {
IrisLogging.warn("Ignoring duplicate datapack manifest entry for id '" + entry.id + "' and url " + entry.url);
continue;
}
normalized.add(entry);
}
manifest.entries = normalized;
}
static void recoverManifestFromStaging(File root, Manifest manifest) {
File staging = new File(root, "staging");
File[] directories = staging.listFiles(File::isDirectory);
if (directories == null) {
return;
}
for (File directory : directories) {
try {
Ownership ownership = DatapackOwnership.readOwnershipOrNull(directory);
if (ownership == null || !directory.getName().equals(ownership.id)
|| manifest.find(ownership.url) != null || manifest.findById(ownership.id) != null) {
continue;
}
DatapackOwnership.validateManagedDirectory(directory, ownership.id);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
IrisLogging.warn("Ignoring corrupt Iris-managed datapack staging at " + directory.getPath());
continue;
}
Entry recovered = ownership.toEntry();
recovered.installedEpoch = directory.lastModified();
manifest.put(recovered);
IrisLogging.warn("Recovered Iris-managed datapack manifest entry '" + recovered.id + "' from staging.");
} catch (IOException e) {
IrisLogging.warn("Ignoring orphan datapack staging at " + directory.getPath() + ": " + e.getMessage());
}
}
}
static List<String> normalizeKeys(List<String> keys) {
TreeSet<String> normalized = new TreeSet<>();
if (keys != null) {
for (String key : keys) {
if (key != null && !key.isBlank()) {
normalized.add(key.trim().toLowerCase(Locale.ROOT));
}
}
}
return new ArrayList<>(normalized);
}
static Map<String, String> normalizeImportedTargets(Map<String, String> targets) {
Map<String, String> normalized = new HashMap<>();
if (targets == null) {
return normalized;
}
for (Map.Entry<String, String> target : targets.entrySet()) {
if (target.getKey() != null && !target.getKey().isBlank()
&& target.getValue() != null && !target.getValue().isBlank()) {
normalized.put(target.getKey(), target.getValue());
}
}
return normalized;
}
static Map<String, Map<String, String>> normalizeImportedBundles(
Map<String, Map<String, String>> targets
) {
Map<String, Map<String, String>> normalized = new HashMap<>();
if (targets == null) {
return normalized;
}
for (Map.Entry<String, Map<String, String>> target : targets.entrySet()) {
if (target.getKey() == null || target.getKey().isBlank() || target.getValue() == null) {
continue;
}
Map<String, String> bundles = new TreeMap<>();
for (Map.Entry<String, String> bundle : target.getValue().entrySet()) {
if (bundle.getKey() != null && !bundle.getKey().isBlank()
&& bundle.getValue() != null && !bundle.getValue().isBlank()) {
bundles.put(bundle.getKey(), bundle.getValue());
}
}
normalized.put(target.getKey(), bundles);
}
return normalized;
}
static void writeManifest(File root, Manifest manifest) {
try {
writeManifestChecked(root, manifest);
} catch (IOException e) {
IrisLogging.reportError("Failed to write datapack manifest "
+ new File(root, "manifest.json").toPath(), e);
}
}
static void writeManifestChecked(File root, Manifest manifest) throws IOException {
try (ManifestWrite write = prepareManifestWrite(root, manifest)) {
write.publish();
}
}
static ManifestWrite prepareManifestWrite(File root, Manifest manifest) throws IOException {
Path file = new File(root, "manifest.json").toPath();
Path parent = file.getParent();
Files.createDirectories(parent);
Path temp = Files.createTempFile(parent, "manifest", ".json.tmp");
Path rollback = null;
try {
byte[] content = DatapackSupport.GSON.toJson(manifest).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_MANIFEST_BYTES) {
throw new IOException("Datapack manifest exceeds " + MAX_MANIFEST_BYTES + " bytes");
}
Files.write(temp, content, StandardOpenOption.TRUNCATE_EXISTING);
DatapackSupport.forceFile(temp);
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
throw new IOException("Datapack manifest is not a regular file: " + file);
}
rollback = Files.createTempFile(parent, "manifest-rollback", ".json.tmp");
Files.copy(file, rollback, StandardCopyOption.REPLACE_EXISTING);
DatapackSupport.forceFile(rollback);
}
return new ManifestWrite(temp, file, rollback);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(temp);
} catch (IOException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
if (rollback != null) {
try {
Files.deleteIfExists(rollback);
} catch (IOException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
}
throw e;
}
}
static Manifest readCommittedManifest(File root) throws IOException {
Path manifestPath = new File(root, "manifest.json").toPath();
if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
return new Manifest();
}
if (Files.isSymbolicLink(manifestPath)
|| !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack manifest is not a regular file: " + manifestPath);
}
Manifest manifest;
try {
manifest = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(manifestPath, MAX_MANIFEST_BYTES, "Datapack manifest"),
Manifest.class
);
} catch (RuntimeException e) {
throw new IOException("Invalid datapack manifest " + manifestPath, e);
}
if (manifest == null) {
throw new IOException("Empty datapack manifest " + manifestPath);
}
normalizeManifest(manifest);
return manifest;
}
}
@@ -0,0 +1,379 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileStore;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Stream;
final class DatapackOwnership {
static final String FINDER_METADATA = ".DS_Store";
static final String OWNERSHIP_MARKER = ".iris-managed.json";
static final int OWNERSHIP_SCHEMA = 1;
static final int MAX_MANAGED_PATHS = DatapackArchive.MAX_ARCHIVE_ENTRIES + 16;
static final long MAX_OWNERSHIP_BYTES = 1024L * 1024L;
static final int HASH_BUFFER_BYTES = 64 * 1024;
private DatapackOwnership() {
}
static void writeOwnership(File directory, Entry entry) throws IOException {
writeOwnership(directory, entry, directoryHash(directory));
}
static void writeOwnership(File directory, Entry entry, String contentHash) throws IOException {
if (!DatapackArchive.isValidManagedId(entry.id) || entry.url == null || entry.url.isBlank()) {
throw new IOException("Invalid Iris datapack ownership identity for " + directory.getPath());
}
if (contentHash == null || contentHash.isBlank()) {
throw new IOException("Missing Iris datapack ownership content hash for " + directory.getPath());
}
Ownership ownership = new Ownership(
OWNERSHIP_SCHEMA,
entry.id,
entry.url,
entry.versionId,
entry.versionNumber,
entry.sha1,
contentHash,
DatapackManifestStore.copyList(entry.structureKeys),
DatapackManifestStore.copyList(entry.templateKeys)
);
Path marker = new File(directory, OWNERSHIP_MARKER).toPath();
Path temporary = Files.createTempFile(directory.toPath(), OWNERSHIP_MARKER, ".tmp");
try {
Files.writeString(
temporary,
DatapackSupport.GSON.toJson(ownership),
StandardCharsets.UTF_8,
StandardOpenOption.TRUNCATE_EXISTING
);
DatapackSupport.move(temporary, marker);
} finally {
Files.deleteIfExists(temporary);
}
}
static Ownership readOwnership(File directory) throws IOException {
Ownership ownership = readOwnershipOrNull(directory);
if (ownership == null) {
throw new IOException("Missing Iris datapack ownership marker in " + directory.getPath());
}
return ownership;
}
static Ownership readOwnershipOrNull(File directory) throws IOException {
File marker = new File(directory, OWNERSHIP_MARKER);
Path markerPath = marker.toPath();
if (Files.notExists(markerPath, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
if (!Files.exists(markerPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath());
}
if (Files.isSymbolicLink(markerPath)
|| !Files.isRegularFile(markerPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
if (marker.length() > MAX_OWNERSHIP_BYTES) {
throw new IOException("Oversized Iris datapack ownership marker in " + directory.getPath());
}
try {
Ownership ownership = DatapackSupport.GSON.fromJson(DatapackSupport.readBoundedUtf8(
marker.toPath(), MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker"), Ownership.class);
if (ownership == null || ownership.schemaVersion != OWNERSHIP_SCHEMA
|| !DatapackArchive.isValidManagedId(ownership.id) || ownership.url == null || ownership.url.isBlank()
|| ownership.contentHash == null || ownership.contentHash.isBlank()) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
return ownership;
} catch (RuntimeException e) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath(), e);
}
}
static String ownershipMarkerFingerprint(File directory) throws IOException {
Path marker = new File(directory, OWNERSHIP_MARKER).toPath();
if (Files.notExists(marker, LinkOption.NOFOLLOW_LINKS)) {
return "absent";
}
if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath());
}
if (Files.isSymbolicLink(marker)
|| !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return "sha256:" + DatapackSupport.hex(digest.digest(DatapackSupport.readBoundedBytes(
marker, MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker")));
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static void validateManagedDirectory(File directory, String id) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Missing datapack directory " + directory.getPath());
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Refusing symbolic-link datapack directory " + directory.getPath());
}
DatapackPackMetadata.validatePackMetadata(directory);
rejectSymbolicLinks(directory);
Ownership ownership = readOwnershipOrNull(directory);
if (ownership == null) {
throw new IOException("Datapack is not Iris-managed: " + directory.getPath());
}
if (!id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + directory.getPath());
}
removeFinderMetadata(directory);
}
static void rejectSymbolicLinks(File root) throws IOException {
try (Stream<Path> paths = Files.walk(root.toPath())) {
Path symbolicLink = paths.filter(Files::isSymbolicLink).findFirst().orElse(null);
if (symbolicLink != null) {
throw new IOException("Datapack contains a symbolic link: " + symbolicLink);
}
}
}
static boolean sameDatapackVolume(
Path root,
FileStore rootStore,
Path entry
) throws IOException {
return DatapackScratchRecovery.sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry));
}
static String directoryHash(File root) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
Path rootMarker = rootPath.resolve(OWNERSHIP_MARKER);
FileStore rootStore = Files.getFileStore(rootPath);
List<Path> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
int pathCount = 0;
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(rootPath) || path.equals(rootMarker)) {
continue;
}
if (isFinderMetadata(path)) {
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious Finder metadata in datapack: " + path);
}
continue;
}
pathCount++;
if (pathCount > MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths");
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Datapack contains a symbolic link: " + path);
}
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack contains an unsupported filesystem entry: " + path);
}
entries.add(path);
}
}
entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString()));
byte[] buffer = new byte[HASH_BUFFER_BYTES];
long totalBytes = 0;
for (Path entry : entries) {
String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/');
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
BasicFileAttributes attributes = Files.readAttributes(
entry,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| !attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack entry changed while hashing: " + relative);
}
if (!DatapackScratchRecovery.sameScratchVolume(rootPath, rootStore, entry, Files.getFileStore(entry))) {
throw new IOException("Datapack entry crosses a filesystem boundary: " + entry);
}
boolean directory = attributes.isDirectory();
digest.update((byte) (directory ? 1 : 2));
DatapackSupport.updateDigestInt(digest, relativeBytes.length);
digest.update(relativeBytes);
if (!directory) {
long expectedBytes = attributes.size();
if (expectedBytes > DatapackArchive.MAX_ENTRY_BYTES) {
throw new IOException("Datapack file exceeds " + DatapackArchive.MAX_ENTRY_BYTES + " bytes: " + relative);
}
totalBytes += expectedBytes;
if (totalBytes > DatapackArchive.MAX_EXPANDED_BYTES) {
throw new IOException("Datapack contents exceed " + DatapackArchive.MAX_EXPANDED_BYTES + " bytes");
}
DatapackSupport.updateDigestLong(digest, expectedBytes);
long entryBytes = 0;
try (InputStream input = Files.newInputStream(
entry,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS
)) {
int length;
while ((length = input.read(buffer)) > 0) {
entryBytes += length;
if (entryBytes > expectedBytes) {
throw new IOException("Datapack file changed while hashing: " + relative);
}
digest.update(buffer, 0, length);
}
}
if (entryBytes != expectedBytes) {
throw new IOException("Datapack file changed while hashing: " + relative);
}
}
}
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static void removeFinderMetadata(File root) throws IOException {
List<Path> entries;
try (Stream<Path> paths = Files.walk(root.toPath())) {
entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList();
}
if (entries.size() > MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths");
}
for (Path path : entries) {
if (!isFinderMetadata(path)) {
continue;
}
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious Finder metadata in datapack: " + path);
}
Files.delete(path);
}
}
static boolean isFinderMetadata(Path path) {
Path fileName = path.getFileName();
return fileName != null && FINDER_METADATA.equals(fileName.toString());
}
static PackResources scanPackResources(File root) throws IOException {
TreeSet<String> structureKeys = new TreeSet<>();
TreeSet<String> structureSetKeys = new TreeSet<>();
TreeSet<String> templateKeys = new TreeSet<>();
Path dataRoot = new File(root, "data").toPath();
if (!Files.isDirectory(dataRoot, LinkOption.NOFOLLOW_LINKS)) {
return new PackResources(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
}
try (Stream<Path> paths = Files.walk(dataRoot)) {
for (Path path : paths.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)).toList()) {
Path relative = dataRoot.relativize(path);
if (relative.getNameCount() < 3) {
continue;
}
String namespace = relative.getName(0).toString().toLowerCase(Locale.ROOT);
String normalized = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/');
addResourceKey(structureKeys, namespace, normalized, "worldgen/structure/", ".json");
addResourceKey(structureKeys, namespace, normalized, "worldgen/structures/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_set/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_sets/", ".json");
addResourceKey(templateKeys, namespace, normalized, "structure/", ".nbt");
addResourceKey(templateKeys, namespace, normalized, "structures/", ".nbt");
}
}
return new PackResources(
new ArrayList<>(structureKeys),
new ArrayList<>(structureSetKeys),
new ArrayList<>(templateKeys));
}
static void addResourceKey(Set<String> keys, String namespace, String path, String prefix, String suffix) {
if (!path.startsWith(prefix) || !path.endsWith(suffix)) {
return;
}
String resourcePath = path.substring(prefix.length(), path.length() - suffix.length());
if (!resourcePath.isBlank()) {
keys.add(namespace + ":" + resourcePath);
}
}
static boolean deleteOwnedDirectory(File directory, String id) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath());
}
Ownership ownership = readOwnership(directory);
if (!id.equals(ownership.id)) {
throw new IOException("Ownership marker belongs to '" + ownership.id + "'");
}
removeFinderMetadata(directory);
if (!Objects.equals(ownership.contentHash, directoryHash(directory))) {
throw new IOException("Refusing to delete modified or corrupt Iris-managed datapack " + directory.getPath());
}
File parent = Objects.requireNonNull(directory.getParentFile(), "managed datapack parent");
DatapackInstall.validateInstallTree(directory, parent, "Managed datapack deletion");
IO.delete(directory);
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not delete " + directory.getPath());
}
return true;
}
}
@@ -0,0 +1,155 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.volmlib.util.io.IO;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.util.stream.Stream;
final class DatapackPackMetadata {
static final String OVERRIDES_STRIPPED_MARKER = ".iris-overrides-stripped";
static final long MAX_METADATA_BYTES = 1024L * 1024L;
private DatapackPackMetadata() {
}
static boolean resolveStripOverrides() {
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
return stream.anyMatch(DatapackPackMetadata::packDisablesOverrides);
}
}
static boolean packDisablesOverrides(IrisData data) {
if (data == null || data.getDimensionLoader() == null) {
return false;
}
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
if (dimension == null) {
continue;
}
IrisImportedStructureControl control = dimension.getImportedStructures();
if (control != null && !control.isDatapackOverrides()) {
return true;
}
}
return false;
}
static void stripVanillaStructureOverrides(File datapackRoot) throws IOException {
stripVanillaStructureOverrides(datapackRoot, IO::delete);
}
static void stripVanillaStructureOverrides(
File datapackRoot,
DirectoryDeleter deleter
) throws IOException {
File minecraftData = new File(new File(datapackRoot, "data"), "minecraft");
if (!minecraftData.isDirectory()) {
return;
}
String[] relativeTrees = {
"worldgen" + File.separator + "structure_set",
"worldgen" + File.separator + "structure",
"worldgen" + File.separator + "template_pool",
"structure"
};
for (String tree : relativeTrees) {
File dir = new File(minecraftData, tree);
if (dir.exists()) {
deleter.delete(dir);
if (Files.exists(dir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not remove vanilla structure override tree " + dir.getPath());
}
}
}
}
static void writeMarker(File marker) throws IOException {
Files.writeString(marker.toPath(), "stripped", StandardCharsets.UTF_8);
}
static void validatePackMetadata(File datapackRoot) throws IOException {
File metadata = new File(datapackRoot, "pack.mcmeta");
if (!metadata.isFile() || Files.isSymbolicLink(metadata.toPath())) {
throw new IOException("Datapack is missing a regular pack.mcmeta");
}
if (metadata.length() > MAX_METADATA_BYTES) {
throw new IOException("Datapack pack.mcmeta exceeds 1 MiB");
}
JsonElement parsed;
try {
parsed = JsonParser.parseString(DatapackSupport.readBoundedUtf8(
metadata.toPath(), MAX_METADATA_BYTES, "Datapack pack.mcmeta"));
} catch (RuntimeException e) {
throw new IOException("Datapack pack.mcmeta is not valid JSON", e);
}
if (!parsed.isJsonObject()) {
throw new IOException("Datapack pack.mcmeta root must be an object");
}
JsonObject root = parsed.getAsJsonObject();
if (!root.has("pack") || !root.get("pack").isJsonObject()) {
throw new IOException("Datapack pack.mcmeta must contain a pack object");
}
JsonObject pack = root.getAsJsonObject("pack");
boolean hasPackFormat = pack.has("pack_format") && pack.get("pack_format").isJsonPrimitive()
&& pack.getAsJsonPrimitive("pack_format").isNumber();
boolean hasRange = validFormatValue(pack.get("min_format")) && validFormatValue(pack.get("max_format"));
if (!hasPackFormat && !hasRange) {
throw new IOException("Datapack pack.mcmeta must declare numeric pack_format or valid min_format/max_format");
}
if (!pack.has("description") || pack.get("description").isJsonNull()) {
throw new IOException("Datapack pack.mcmeta must declare a description");
}
File data = new File(datapackRoot, "data");
if (data.exists() && (!data.isDirectory() || Files.isSymbolicLink(data.toPath()))) {
throw new IOException("Datapack data path is not a regular directory");
}
}
static boolean validFormatValue(JsonElement value) {
if (value == null || value.isJsonNull()) {
return false;
}
if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) {
return true;
}
if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) {
return false;
}
for (JsonElement component : value.getAsJsonArray()) {
if (!component.isJsonPrimitive() || !component.getAsJsonPrimitive().isNumber()) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,468 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.UUID;
final class DatapackRemoval {
private DatapackRemoval() {
}
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id) {
File root = IrisPlatforms.get().dataFolder("datapacks");
return removeOutcomeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder());
}
static boolean removeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
return removeOutcomeLocked(sender, id, root, worldFolders) == RemoveOutcome.REMOVED;
}
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
String requested = id == null ? "" : id.trim().toLowerCase(Locale.ROOT);
String cleaned = DatapackArchive.sanitizeId(id);
if (requested.isBlank() || !requested.equals(cleaned) || DatapackArchive.RESERVED_IDS.contains(cleaned)) {
DatapackSupport.message(sender, C.RED + "Invalid Iris-managed datapack id '" + requested + "'. Run /iris datapack list and use the exact listed id.");
return RemoveOutcome.REJECTED;
}
try {
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
} catch (IOException e) {
DatapackSupport.message(sender, C.RED + "Datapack removal blocked by incomplete transaction recovery: " + e.getMessage());
IrisLogging.reportError(e);
return RemoveOutcome.REJECTED;
}
Manifest manifest = DatapackManifestStore.readManifest(root);
Entry ownedEntry = manifest.findById(cleaned);
if (ownedEntry == null) {
DatapackSupport.message(sender, C.YELLOW + "No Iris-managed datapack named '" + cleaned + "'. Unmanaged world datapacks are never removed by Iris.");
return RemoveOutcome.REJECTED;
}
List<File> targets;
try {
targets = preflightRemovalTargets(root, worldFolders, ownedEntry);
} catch (IOException e) {
DatapackSupport.message(sender, C.RED + "Refused to remove datapack '" + cleaned + "': " + e.getMessage());
IrisLogging.reportError(e);
return RemoveOutcome.REJECTED;
}
EditableImportRemoval editableRemoval = null;
DirectoryRemoval directoryRemoval = null;
ManifestWrite manifestWrite = null;
DatapackCoordinator coordinator = null;
try {
editableRemoval = prepareEntryImportRemoval(ownedEntry, manifest.entries);
directoryRemoval = prepareOwnedDirectories(targets);
if (!manifest.removeById(cleaned)) {
throw new IOException("Datapack manifest entry disappeared during removal");
}
manifestWrite = DatapackManifestStore.prepareManifestWrite(root, manifest);
coordinator = DatapackTransactions.createRemovalCoordinator(root, ownedEntry, directoryRemoval, editableRemoval);
coordinator.phase(CoordinatorPhase.PUBLISHING);
directoryRemoval.prepare();
coordinator.phase(CoordinatorPhase.PUBLISHED);
manifestWrite.publish();
} catch (IOException | RuntimeException removalFailure) {
boolean manifestCommitted = manifestWrite != null && manifestWrite.published();
if (manifestCommitted) {
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator,
removalFailure);
DatapackSupport.message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return RemoveOutcome.REMOVED;
}
boolean restored = rollbackRemoval(manifestWrite, directoryRemoval, editableRemoval, removalFailure);
if (restored && coordinator != null) {
try {
coordinator.finish();
} catch (IOException cleanupFailure) {
removalFailure.addSuppressed(cleanupFailure);
}
}
DatapackSupport.message(sender, C.RED + "Failed to remove datapack '" + cleaned
+ "'; Iris attempted to restore every prior location: " + removalFailure.getMessage());
IrisLogging.reportError(removalFailure);
// A mutation was attempted; even a successful rollback is not provably identical
// (the fingerprint does not cover datapacks/), so stay conservatively invalidated.
return RemoveOutcome.FAILED_AFTER_MUTATION;
}
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, null);
DatapackSupport.message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return RemoveOutcome.REMOVED;
}
static void finishCommittedRemoval(
String id,
ManifestWrite manifestWrite,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval,
DatapackCoordinator coordinator,
Throwable priorFailure
) {
if (priorFailure != null) {
IOException recoveryFailure = new IOException(
"Datapack manifest committed before publication durability was confirmed",
priorFailure
);
if (editableRemoval != null) {
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
recoveryFailure.addSuppressed(releaseFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
recoveryFailure.addSuppressed(cleanupFailure);
}
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", recoveryFailure);
return;
}
IOException failure = null;
if (coordinator != null) {
try {
coordinator.phase(CoordinatorPhase.COMMITTED);
} catch (IOException phaseFailure) {
failure = DatapackSupport.appendIOException(failure, phaseFailure);
if (editableRemoval != null) {
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
failure = DatapackSupport.appendIOException(failure, releaseFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", failure);
return;
}
}
if (editableRemoval != null) {
try {
editableRemoval.markCommitted();
editableRemoval.finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
failure = DatapackSupport.appendIOException(failure, releaseFailure);
}
}
}
if (directoryRemoval != null) {
try {
directoryRemoval.finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
if (failure == null && coordinator != null) {
try {
coordinator.finish();
} catch (IOException cleanupFailure) {
failure = cleanupFailure;
}
}
if (failure != null) {
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", failure);
}
}
static List<File> preflightRemovalTargets(File root, List<File> worldFolders, Entry entry) throws IOException {
List<File> targets = new ArrayList<>();
Set<Path> seen = new HashSet<>();
File staging = new File(root, "staging");
DatapackSupport.verifyDirectoryContainerIfPresent(staging, "datapack staging");
File stagedDir = new File(staging, entry.id);
verifyAndCollectRemovalTarget(stagedDir, entry, targets, seen);
for (File worldFolder : worldFolders) {
DatapackSupport.verifyDirectoryContainerIfPresent(worldFolder, "world datapacks");
verifyAndCollectRemovalTarget(new File(worldFolder, entry.id), entry, targets, seen);
}
return targets;
}
static void verifyAndCollectRemovalTarget(
File target,
Entry entry,
List<File> targets,
Set<Path> seen
) throws IOException {
verifyOwnedDirectoryIfPresent(target, entry);
Path normalized = target.toPath().toAbsolutePath().normalize();
if (DatapackSupport.pathExists(normalized, "datapack removal target")) {
Path identity = normalized.toRealPath();
if (!seen.add(identity)) {
throw new IOException("Aliased datapack removal target " + normalized);
}
targets.add(target);
}
}
static EditableImportRemoval prepareEntryImportRemoval(
Entry entry,
List<Entry> manifestEntries
) throws IOException {
List<PreparedEditableImport> prepared = new ArrayList<>();
Set<String> targetIdSet = new TreeSet<>(entry.importedBundles.keySet());
targetIdSet.addAll(entry.importedTargets.keySet());
targetIdSet.addAll(entry.importAttempts.keySet());
List<String> targetIds = new ArrayList<>(targetIdSet);
targetIds.sort(String::compareTo);
try {
for (String targetId : targetIds) {
Set<String> retainedKeys = invalidateRetainedImportClaims(
entry, targetId, manifestEntries);
File dataFolder = new File(targetId);
if (!dataFolder.isDirectory()) {
continue;
}
StructureTransactionWriter writer = new StructureTransactionWriter(dataFolder.toPath());
List<StructureTransactionWriter.OwnedRemoval> removals = ownedImportRemovals(
writer,
entry,
targetId,
retainedKeys
);
StructureTransactionWriter.PreparedRemoval removal =
writer.prepareMatchingOwnedRemovals(removals);
prepared.add(new PreparedEditableImport(dataFolder, removal));
}
return new EditableImportRemoval(prepared);
} catch (IOException | RuntimeException preparationFailure) {
IOException failure = preparationFailure instanceof IOException ioFailure
? ioFailure
: new IOException("Failed preparing editable datapack import cleanup", preparationFailure);
for (int i = prepared.size() - 1; i >= 0; i--) {
try {
prepared.get(i).removal().rollback();
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
}
}
static List<StructureTransactionWriter.OwnedRemoval> ownedImportRemovals(
StructureTransactionWriter writer,
Entry removingEntry,
String targetId,
Set<String> retainedKeys
) throws IOException {
List<StructureTransactionWriter.OwnedRemoval> removals = new ArrayList<>();
Map<String, Set<String>> removingClaims = importBundleClaims(removingEntry, targetId);
for (Map.Entry<String, Set<String>> bundle : removingClaims.entrySet()) {
try {
StructureKey targetKey = StructureKey.parse(bundle.getKey());
if (retainedKeys.contains(bundle.getKey())) {
continue;
}
Optional<StructureSource> ownedSource = writer.ownedSource(targetKey);
if (ownedSource.isEmpty() || !sourceClaimsContain(bundle.getValue(), ownedSource.get())) {
continue;
}
removals.add(StructureTransactionWriter.OwnedRemoval.managedDatapack(
targetKey,
ownedSource.get().kind(),
ownedSource.get().key()
));
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure import inventory entry '"
+ bundle.getKey() + "' -> '" + bundle.getValue() + "'", e);
}
}
return List.copyOf(removals);
}
static Set<String> invalidateRetainedImportClaims(
Entry removingEntry,
String targetId,
List<Entry> manifestEntries
) {
Set<String> removingKeys = importBundleClaims(removingEntry, targetId).keySet();
Set<String> retainedKeys = new TreeSet<>();
for (Entry candidate : manifestEntries) {
if (candidate == removingEntry) {
continue;
}
Set<String> candidateKeys = importBundleClaims(candidate, targetId).keySet();
boolean candidateRetained = false;
for (String candidateKey : candidateKeys) {
if (removingKeys.contains(candidateKey)) {
retainedKeys.add(candidateKey);
candidateRetained = true;
}
}
if (candidateRetained) {
candidate.importedTargets.remove(targetId);
candidate.importAttempts.remove(targetId);
candidate.structuresImported = false;
}
}
return Set.copyOf(retainedKeys);
}
static Map<String, Set<String>> importBundleClaims(Entry entry, String targetId) {
Map<String, Set<String>> claims = new TreeMap<>();
addImportBundleClaims(claims, entry.importedBundles.getOrDefault(targetId, Map.of()));
if (entry.importedBundles.containsKey(targetId) || entry.importedTargets.containsKey(targetId)) {
addImportBundleClaims(claims, DatapackStructureImports.importBundleInventory(entry));
}
return claims;
}
static void addImportBundleClaims(
Map<String, Set<String>> claims,
Map<String, String> inventory
) {
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
claims.computeIfAbsent(bundle.getKey(), ignored -> new TreeSet<>()).add(bundle.getValue());
}
}
static boolean sourceClaimsContain(Set<String> claims, StructureSource source) throws IOException {
for (String claimedKey : claims) {
try {
StructureKey sourceKey = StructureKey.parse(claimedKey);
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
if (source.kind() == sourceKind && source.key().equals(sourceKey)) {
return true;
}
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure source key '" + claimedKey + "'", e);
}
}
return false;
}
static DirectoryRemoval prepareOwnedDirectories(List<File> targets) throws IOException {
List<DirectoryMove> planned = new ArrayList<>();
for (File target : targets) {
File parent = target.getParentFile();
File backupRoot = new File(parent.getParentFile() == null ? parent : parent.getParentFile(), ".iris-datapack-remove");
File backup = new File(backupRoot, target.getName() + "-" + UUID.randomUUID());
DatapackSupport.ensureScratchDirectory(backupRoot, "datapack removal backup");
DatapackInstall.validateInstallTree(target, parent, "Datapack removal target");
planned.add(new DirectoryMove(
target,
backup,
DatapackOwnership.directoryHash(target),
DatapackOwnership.ownershipMarkerFingerprint(target),
DatapackSupport.directoryIdentity(target),
DatapackSupport.realDirectoryPath(parent, "datapack target root"),
DatapackSupport.realDirectoryPath(backupRoot, "datapack removal scratch root"),
DatapackSupport.directoryIdentity(parent),
DatapackSupport.directoryIdentity(backupRoot)
));
}
return new DirectoryRemoval(planned);
}
static boolean rollbackRemoval(
ManifestWrite manifestWrite,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval,
Throwable removalFailure
) {
if (manifestWrite != null) {
try {
manifestWrite.discard();
} catch (IOException discardFailure) {
removalFailure.addSuppressed(discardFailure);
}
}
if (directoryRemoval != null) {
try {
directoryRemoval.rollback();
} catch (IOException rollbackFailure) {
removalFailure.addSuppressed(rollbackFailure);
}
}
if (editableRemoval != null) {
try {
editableRemoval.rollback();
} catch (IOException rollbackFailure) {
removalFailure.addSuppressed(rollbackFailure);
}
}
return removalFailure.getSuppressed().length == 0;
}
static void verifyOwnedDirectoryIfPresent(File directory, Entry entry) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (Files.isSymbolicLink(directory.toPath())
|| !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath());
}
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!DatapackInstall.ownershipSourceMatches(ownership, entry)) {
throw new IOException("Ownership marker at " + directory.getPath() + " belongs to '" + ownership.id + "'");
}
DatapackOwnership.removeFinderMetadata(directory);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
throw new IOException("Refusing to remove modified or corrupt Iris-managed datapack " + directory.getPath());
}
}
}
@@ -0,0 +1,436 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileStore;
import java.nio.file.FileSystems;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Stream;
final class DatapackScratchRecovery {
static final int WINDOWS_LEGACY_PATH_LIMIT = 247;
private DatapackScratchRecovery() {
}
static boolean recoverTransactions(File root, List<File> worldFolders) throws IOException {
boolean changed = recoverStagingScratch(new File(root, "staging"));
File transactionDirectory = new File(root, DatapackTransactions.TRANSACTION_DIRECTORY);
Path transactionPath = transactionDirectory.toPath();
if (!Files.exists(transactionPath, LinkOption.NOFOLLOW_LINKS)) {
return recoverInstallScratch(root, worldFolders) | changed;
}
DatapackSupport.verifyDirectoryContainerIfPresent(transactionDirectory, "datapack transaction");
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
List<Path> transactionRoots;
try (Stream<Path> paths = Files.list(transactionPath)) {
transactionRoots = paths.limit(DatapackTransactions.MAX_TRANSACTION_COUNT + 2L).sorted().toList();
}
if (transactionRoots.size() == DatapackTransactions.MAX_TRANSACTION_COUNT + 2) {
throw new IOException("Datapack transaction directory contains too many entries");
}
int transactionCount = 0;
for (Path transactionRoot : transactionRoots) {
if (isHarmlessRecoveryArtifact(transactionRoot)) {
continue;
}
transactionCount++;
if (transactionCount > DatapackTransactions.MAX_TRANSACTION_COUNT) {
throw new IOException("Datapack transaction count exceeds " + DatapackTransactions.MAX_TRANSACTION_COUNT);
}
}
for (Path transactionRoot : transactionRoots) {
if (isHarmlessRecoveryArtifact(transactionRoot)) {
changed |= Files.deleteIfExists(transactionRoot);
continue;
}
recoverTransaction(root, worldFolders, committedManifest, transactionPath, transactionRoot);
changed = true;
}
changed |= transactionDirectory.delete();
return recoverInstallScratch(root, worldFolders) | changed;
}
static boolean recoverInstallScratch(File root, List<File> worldFolders) throws IOException {
Set<Path> scratchRoots = new TreeSet<>();
scratchRoots.add(DatapackInstall.installScratchRoot(new File(root, "staging")).toPath().toAbsolutePath().normalize());
for (File worldFolder : worldFolders) {
scratchRoots.add(DatapackInstall.installScratchRoot(worldFolder).toPath().toAbsolutePath().normalize());
}
boolean changed = false;
for (Path scratchRoot : scratchRoots) {
changed |= recoverInstallScratchRoot(scratchRoot);
}
return changed;
}
static boolean recoverInstallScratchRoot(Path scratchRoot) throws IOException {
if (!Files.exists(scratchRoot, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
DatapackSupport.verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch");
List<Path> children;
try (Stream<Path> paths = Files.list(scratchRoot)) {
children = paths.limit(DatapackOwnership.MAX_MANAGED_PATHS + 2L).sorted().toList();
}
int managedEntries = 0;
for (Path child : children) {
if (!isHarmlessRecoveryArtifact(child)) {
managedEntries++;
}
if (managedEntries > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack install scratch contains too many entries");
}
}
List<StagingScratch> pending = new ArrayList<>();
List<StagingScratch> backups = new ArrayList<>();
boolean changed = false;
for (Path child : children) {
if (isHarmlessRecoveryArtifact(child)) {
changed |= Files.deleteIfExists(child);
continue;
}
StagingScratch scratch = parseInstallScratch(scratchRoot, child);
if (scratch == null) {
throw new IOException("Unexpected datapack install scratch artifact " + child);
}
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack install scratch artifact " + child);
}
validateScratchTree(child);
if (scratch.kind() == StagingScratchKind.BACKUP) {
backups.add(scratch);
} else {
pending.add(scratch);
}
}
if (!backups.isEmpty()) {
throw new IOException("Preserving unjournaled datapack install backup "
+ backups.getFirst().path());
}
for (StagingScratch scratch : pending) {
DatapackInstall.deleteInstallScratch(scratch.path().toFile(), "orphan datapack install pending directory");
changed = true;
}
changed |= scratchRoot.toFile().delete();
return changed;
}
static StagingScratch parseInstallScratch(Path scratchRoot, Path child) throws IOException {
Path normalized = child.toAbsolutePath().normalize();
if (!Objects.equals(normalized.getParent(), scratchRoot)) {
throw new IOException("Datapack install scratch artifact escapes its root: " + child);
}
String name = normalized.getFileName().toString();
int uuidStart = name.length() - 36;
if (uuidStart <= 1 || name.charAt(uuidStart - 1) != '-') {
return null;
}
try {
UUID.fromString(name.substring(uuidStart));
} catch (IllegalArgumentException e) {
return null;
}
String stem = name.substring(0, uuidStart - 1);
StagingScratchKind kind = stem.endsWith("-backup")
? StagingScratchKind.BACKUP : StagingScratchKind.PENDING;
String id = kind == StagingScratchKind.BACKUP
? stem.substring(0, stem.length() - "-backup".length()) : stem;
if (id.isBlank() || !id.equals(DatapackArchive.sanitizeId(id)) || DatapackArchive.RESERVED_IDS.contains(id)) {
return null;
}
return new StagingScratch(kind, id, normalized);
}
static boolean recoverStagingScratch(File stagingDirectory) throws IOException {
Path stagingRoot = stagingDirectory.toPath().toAbsolutePath().normalize();
if (!Files.exists(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
DatapackSupport.verifyDirectoryContainerIfPresent(stagingDirectory, "datapack staging");
List<Path> children;
try (Stream<Path> paths = Files.list(stagingRoot)) {
children = paths.limit(DatapackOwnership.MAX_MANAGED_PATHS + 1L).sorted().toList();
}
if (children.size() > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack staging contains too many entries");
}
List<StagingScratch> pending = new ArrayList<>();
Map<String, List<StagingScratch>> backups = new TreeMap<>();
for (Path child : children) {
StagingScratch scratch = parseStagingScratch(stagingRoot, child);
if (scratch == null) {
continue;
}
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack staging scratch artifact " + child);
}
if (scratch.kind() == StagingScratchKind.PENDING) {
validateScratchTree(child);
pending.add(scratch);
} else {
backups.computeIfAbsent(scratch.id(), ignored -> new ArrayList<>()).add(scratch);
}
}
for (Map.Entry<String, List<StagingScratch>> entry : backups.entrySet()) {
if (entry.getValue().size() != 1) {
throw new IOException("Ambiguous datapack staging backups for '" + entry.getKey() + "'");
}
StagingScratch backup = entry.getValue().getFirst();
Ownership backupOwnership = verifyManagedScratchDirectory(backup.path().toFile(), backup.id());
Path target = stagingRoot.resolve(backup.id()).normalize();
if (!Objects.equals(target.getParent(), stagingRoot)) {
throw new IOException("Datapack staging backup target escapes its root");
}
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
Ownership targetOwnership = verifyManagedScratchDirectory(target.toFile(), backup.id());
if (!Objects.equals(backupOwnership.url, targetOwnership.url)) {
throw new IOException("Datapack staging backup source does not match its target: " + target);
}
}
}
boolean changed = false;
for (List<StagingScratch> matches : backups.values()) {
StagingScratch backup = matches.getFirst();
Path target = stagingRoot.resolve(backup.id()).normalize();
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
DatapackCoordinatorResolution.deleteVerifiedDirectory(backup.path().toFile());
} else {
DatapackSupport.moveNew(backup.path(), target);
}
changed = true;
}
for (StagingScratch scratch : pending) {
DatapackCoordinatorResolution.deleteVerifiedDirectory(scratch.path().toFile());
changed = true;
}
DatapackSupport.forceDirectoryIfSupported(stagingRoot);
return changed;
}
static StagingScratch parseStagingScratch(Path stagingRoot, Path child) throws IOException {
Path normalized = child.toAbsolutePath().normalize();
if (!Objects.equals(normalized.getParent(), stagingRoot)) {
throw new IOException("Datapack staging artifact escapes its root: " + child);
}
String name = normalized.getFileName().toString();
StagingScratchKind kind;
String prefix;
if (name.startsWith(".pending-")) {
kind = StagingScratchKind.PENDING;
prefix = ".pending-";
} else if (name.startsWith(".backup-")) {
kind = StagingScratchKind.BACKUP;
prefix = ".backup-";
} else {
return null;
}
int uuidStart = name.length() - 36;
if (uuidStart <= prefix.length() || name.charAt(uuidStart - 1) != '-') {
return null;
}
String id = name.substring(prefix.length(), uuidStart - 1);
if (!id.equals(DatapackArchive.sanitizeId(id)) || DatapackArchive.RESERVED_IDS.contains(id)) {
return null;
}
try {
UUID.fromString(name.substring(uuidStart));
} catch (IllegalArgumentException e) {
return null;
}
return new StagingScratch(kind, id, normalized);
}
static void validateScratchTree(Path root) throws IOException {
FileStore rootStore = Files.getFileStore(root);
int[] pathCount = new int[]{0};
Files.walkFileTree(root, new SimpleFileVisitor<>() {
private FileVisitResult inspect(Path entry, BasicFileAttributes attributes) throws IOException {
pathCount[0]++;
if (pathCount[0] > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack scratch contains too many paths: " + root);
}
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| (!attributes.isDirectory() && !attributes.isRegularFile())) {
throw new IOException("Datapack scratch contains an unsupported file: " + entry);
}
if (!DatapackOwnership.sameDatapackVolume(root, rootStore, entry)) {
throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) throws IOException {
return inspect(directory, attributes);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
return inspect(file, attributes);
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect datapack scratch entry: " + file, failure);
}
});
}
static boolean sameScratchVolume(Path first, Path second) throws IOException {
return sameScratchVolume(first, Files.getFileStore(first), second, Files.getFileStore(second));
}
static boolean sameScratchVolume(
Path first,
FileStore firstStore,
Path second,
FileStore secondStore
) {
if (Objects.equals(firstStore, secondStore)) {
return true;
}
if (!isDefaultWindowsPath(first) || !isDefaultWindowsPath(second)) {
return false;
}
Path firstAbsolute = first.toAbsolutePath().normalize();
Path secondAbsolute = second.toAbsolutePath().normalize();
if ((firstAbsolute.toString().length() > WINDOWS_LEGACY_PATH_LIMIT)
== (secondAbsolute.toString().length() > WINDOWS_LEGACY_PATH_LIMIT)) {
return false;
}
Path firstRoot = firstAbsolute.getRoot();
Path secondRoot = secondAbsolute.getRoot();
if (firstRoot == null || secondRoot == null) {
return false;
}
return sameWindowsVolume(
firstStore, firstRoot.toString(), secondStore, secondRoot.toString());
}
static boolean sameWindowsVolume(
FileStore firstStore,
String firstRoot,
FileStore secondStore,
String secondRoot
) {
if (firstRoot == null || secondRoot == null || !firstRoot.equalsIgnoreCase(secondRoot)) {
return false;
}
try {
Object firstSerial = firstStore.getAttribute("volume:vsn");
Object secondSerial = secondStore.getAttribute("volume:vsn");
return firstSerial != null && firstSerial.equals(secondSerial);
} catch (IOException | RuntimeException exception) {
return false;
}
}
static boolean isSupportedScratchDirectory(BasicFileAttributes attributes) {
return attributes != null
&& attributes.isDirectory()
&& !attributes.isSymbolicLink()
&& !attributes.isOther();
}
static boolean isDefaultWindowsPath(Path path) {
return File.separatorChar == '\\'
&& path.getFileSystem().equals(FileSystems.getDefault());
}
static Ownership verifyManagedScratchDirectory(File directory, String id) throws IOException {
DatapackOwnership.validateManagedDirectory(directory, id);
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
throw new IOException("Datapack staging backup is modified or corrupt: " + directory.getPath());
}
return ownership;
}
static boolean isHarmlessRecoveryArtifact(Path path) throws IOException {
if (!DatapackOwnership.isFinderMetadata(path)) {
return false;
}
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious datapack recovery artifact " + path);
}
return true;
}
static void recoverTransaction(
File root,
List<File> worldFolders,
Manifest committedManifest,
Path transactionDirectory,
Path transactionRoot
) throws IOException {
Path normalizedRoot = transactionRoot.toAbsolutePath().normalize();
if (!Objects.equals(normalizedRoot.getParent(), transactionDirectory.toAbsolutePath().normalize())
|| Files.isSymbolicLink(normalizedRoot)
|| !Files.isDirectory(normalizedRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack transaction directory " + transactionRoot);
}
try {
UUID.fromString(normalizedRoot.getFileName().toString());
} catch (IllegalArgumentException e) {
throw new IOException("Invalid datapack transaction directory name " + transactionRoot, e);
}
CoordinatorJournal journal = DatapackTransactions.readCoordinatorJournal(normalizedRoot);
if (journal == null) {
DatapackTransactions.deleteCoordinatorTransaction(normalizedRoot);
return;
}
DatapackTransactions.validateCoordinatorJournal(root, worldFolders, committedManifest, normalizedRoot, journal);
boolean commit = DatapackCoordinatorResolution.coordinatorCommitDecision(committedManifest, journal);
DatapackCoordinatorResolution.resolveCoordinatorDirectories(journal, commit);
DatapackCoordinatorResolution.resolveCoordinatorEditables(journal, normalizedRoot, commit);
DatapackTransactions.deleteCoordinatorTransaction(normalizedRoot);
}
enum StagingScratchKind {
PENDING,
BACKUP
}
record StagingScratch(StagingScratchKind kind, String id, Path path) {
}
}
@@ -0,0 +1,256 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Report;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackStagingGuard {
private DatapackStagingGuard() {
}
static VerifiedStagingInstall authorizeVerifiedStagingInstall(
File root,
File stagingRoot,
File verifiedSource,
Entry entry
) throws IOException {
Path normalizedRoot = DatapackSupport.requireDirectoryIdentity(root, "datapack storage root");
Path normalizedStagingRoot = DatapackSupport.requireDirectoryIdentity(stagingRoot, "datapack staging root");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedRoot, "datapack storage root");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedStagingRoot, "datapack staging root");
Path expectedStagingRoot = normalizedRoot.resolve("staging").normalize();
if (!normalizedStagingRoot.equals(expectedStagingRoot)
|| !Files.isSameFile(normalizedStagingRoot, expectedStagingRoot)) {
throw new IOException("Verified datapack staging root is not Iris's canonical staging directory");
}
Path normalizedSource = DatapackSupport.requireDirectoryIdentity(verifiedSource, "verified datapack extraction");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedSource, "verified datapack extraction");
if (!Objects.equals(normalizedSource.getParent(), normalizedStagingRoot)
|| !Files.isSameFile(normalizedSource.getParent(), normalizedStagingRoot)) {
throw new IOException("Verified datapack extraction is outside Iris's canonical staging directory");
}
verifyPendingExtractionName(normalizedSource.getFileName().toString(), entry.id);
DatapackOwnership.validateManagedDirectory(verifiedSource, entry.id);
DatapackScratchRecovery.validateScratchTree(normalizedSource);
if (!DatapackScratchRecovery.sameScratchVolume(normalizedSource, normalizedStagingRoot)) {
throw new IOException("Verified datapack extraction crosses a filesystem boundary");
}
String desiredHash = DatapackOwnership.directoryHash(verifiedSource);
Ownership ownership = DatapackOwnership.readOwnership(verifiedSource);
if (!DatapackInstall.ownershipMetadataMatches(ownership, entry, desiredHash)) {
throw new IOException("Verified datapack extraction does not match the resolved archive metadata");
}
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
Entry committed = committedManifest.findById(entry.id);
boolean legacyReplacementAuthorized = committed != null
&& Objects.equals(committed.id, entry.id)
&& Objects.equals(committed.url, entry.url);
LegacyStagingSnapshot legacyStagingSnapshot = captureLegacyStagingSnapshot(
normalizedStagingRoot, entry.id, legacyReplacementAuthorized);
return new VerifiedStagingInstall(
normalizedRoot,
normalizedStagingRoot,
normalizedSource,
entry,
desiredHash,
committed,
legacyReplacementAuthorized,
legacyStagingSnapshot
);
}
static LegacyStagingSnapshot captureLegacyStagingSnapshot(
Path normalizedStagingRoot,
String id,
boolean legacyReplacementAuthorized
) throws IOException {
if (!legacyReplacementAuthorized) {
return null;
}
Path target = normalizedStagingRoot.resolve(id).normalize();
if (!DatapackSupport.pathExists(target, "legacy datapack staging target")) {
return null;
}
if (Files.isSymbolicLink(target)
|| !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid legacy datapack staging target " + target);
}
DatapackInstall.validateInstallTree(target.toFile(), normalizedStagingRoot.toFile(), "Legacy datapack staging");
String markerHash = DatapackOwnership.ownershipMarkerFingerprint(target.toFile());
if (!"absent".equals(markerHash)) {
return null;
}
return new LegacyStagingSnapshot(
target,
target.toRealPath(),
DatapackSupport.directoryIdentity(target.toFile()),
DatapackOwnership.directoryHash(target.toFile()),
markerHash
);
}
static void verifyPendingExtractionName(String name, String id) throws IOException {
String prefix = ".pending-" + id + "-";
if (!name.startsWith(prefix)) {
throw new IOException("Verified datapack extraction has an invalid staging name " + name);
}
try {
UUID.fromString(name.substring(prefix.length()));
} catch (IllegalArgumentException e) {
throw new IOException("Verified datapack extraction has an invalid staging identity " + name, e);
}
}
static void validateInstallParticipants(
List<File> worldFolders,
VerifiedStagingInstall verifiedStagingInstall,
Entry entry
) throws IOException {
List<File> roots = new ArrayList<>(worldFolders);
if (verifiedStagingInstall != null) {
roots.add(verifiedStagingInstall.stagingRoot().toFile());
}
Set<Path> normalizedTargets = new HashSet<>();
Set<Path> realRoots = new HashSet<>();
Set<Path> targetIdentities = new HashSet<>();
List<Path> existingTargets = new ArrayList<>();
for (File folder : roots) {
ensureInstallTargetRoot(folder);
Path normalizedRoot = folder.toPath().toAbsolutePath().normalize();
Path realRoot = normalizedRoot.toRealPath();
Path normalizedTarget = normalizedRoot.resolve(entry.id).normalize();
if (!realRoots.add(realRoot) || !normalizedTargets.add(normalizedTarget)) {
throw new IOException("Duplicate datapack install target " + normalizedTarget);
}
Path targetIdentity = realRoot.resolve(entry.id).normalize();
if (Files.exists(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(normalizedTarget)
|| !Files.isDirectory(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid datapack install target " + normalizedTarget);
}
targetIdentity = normalizedTarget.toRealPath();
for (Path existingTarget : existingTargets) {
if (Files.isSameFile(normalizedTarget, existingTarget)) {
throw new IOException("Aliased datapack install target " + normalizedTarget);
}
}
existingTargets.add(normalizedTarget);
}
if (!targetIdentities.add(targetIdentity)) {
throw new IOException("Aliased datapack install target " + normalizedTarget);
}
}
if (verifiedStagingInstall != null) {
verifiedStagingInstall.verifyStagingRoot();
}
}
static void ensureInstallTargetRoot(File directory) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(directory.toPath());
}
DatapackSupport.requireDirectoryIdentity(directory, "datapack install root");
}
static void recordInstallResult(
VolmitSender sender,
Report report,
File stagedDir,
KList<File> worldFolders,
Entry entry,
InstallResult result,
String versionNumber
) {
DatapackStagingReapply.recordInstallMetadata(stagedDir, worldFolders, entry);
if (result.changed()) {
report.updated.add(entry.id + " (" + DatapackSupport.safe(versionNumber) + ")");
report.requiresRestart = true;
DatapackSupport.message(sender, C.GREEN + " Repaired " + C.WHITE + entry.id + C.GREEN + " " + DatapackSupport.safe(versionNumber));
return;
}
report.upToDate.add(entry.id + " (" + DatapackSupport.safe(versionNumber) + ")");
DatapackSupport.message(sender, C.GRAY + " Up to date: " + C.WHITE + entry.id + C.GRAY + " " + DatapackSupport.safe(versionNumber));
}
static boolean isUsableStaging(File stagedDir, Entry entry) {
return inspectUsableStaging(stagedDir, entry).usable();
}
static StagingInspection inspectUsableStaging(File stagedDir, Entry entry) {
try {
DatapackOwnership.validateManagedDirectory(stagedDir, entry.id);
Ownership ownership = DatapackOwnership.readOwnership(stagedDir);
String contentHash = DatapackOwnership.directoryHash(stagedDir);
if (!DatapackInstall.ownershipSourceMatches(ownership, entry)
|| !Objects.equals(ownership.versionId, entry.versionId)
|| !Objects.equals(ownership.versionNumber, entry.versionNumber)
|| !Objects.equals(ownership.sha1, entry.sha1)
|| !Objects.equals(ownership.contentHash, contentHash)) {
return new StagingInspection(false, false, false);
}
PackResources resources = DatapackOwnership.scanPackResources(stagedDir);
List<String> previousStructureKeys = DatapackManifestStore.copyList(entry.structureKeys);
List<String> previousTemplateKeys = DatapackManifestStore.copyList(entry.templateKeys);
boolean ownershipCorrected = false;
if (!DatapackManifestStore.copyList(resources.structureKeys()).equals(DatapackManifestStore.copyList(ownership.structureKeys))
|| !DatapackManifestStore.copyList(resources.templateKeys()).equals(DatapackManifestStore.copyList(ownership.templateKeys))) {
Entry corrected = DatapackManifestStore.copyEntry(entry);
corrected.structureKeys = resources.structureKeys();
corrected.templateKeys = resources.templateKeys();
DatapackOwnership.writeOwnership(stagedDir, corrected);
ownershipCorrected = true;
}
entry.structureKeys = resources.structureKeys();
entry.templateKeys = resources.templateKeys();
boolean manifestChanged = !previousStructureKeys.equals(DatapackManifestStore.copyList(entry.structureKeys))
|| !previousTemplateKeys.equals(DatapackManifestStore.copyList(entry.templateKeys));
return new StagingInspection(true, ownershipCorrected, manifestChanged);
} catch (IOException e) {
IrisLogging.warn("Ignoring unusable Iris datapack staging at " + stagedDir.getPath() + ": " + e.getMessage());
return new StagingInspection(false, false, false);
}
}
record LegacyStagingSnapshot(
Path normalizedTarget,
Path realTarget,
String targetIdentity,
String contentHash,
String markerHash
) {
}
}
@@ -0,0 +1,312 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.ReapplyOutcome;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
final class DatapackStagingReapply {
private DatapackStagingReapply() {
}
static ReapplyOutcome reapplyFromStagingLocked(KList<File> worldFolders) {
File root = IrisPlatforms.get().dataFolder("datapacks");
ReapplyOutcome recovery = recoverBeforeReapplyOutcome(root, worldFolders);
if (!recovery.succeeded()) {
return reportReapplyFailure(recovery);
}
File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging");
ReapplyOutcome repair = reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
DatapackPackMetadata.resolveStripOverrides());
if (!repair.succeeded()) {
return reportReapplyFailure(repair);
}
return ReapplyOutcome.success(recovery.recovered(), repair.repaired());
}
static boolean reapplyStagingRoot(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
return reportReapplyFailure(reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
stripOverrides)).succeeded();
}
static ReapplyOutcome reapplyStagingRootOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
Manifest manifest = DatapackManifestStore.readManifest(root);
if (stagingDir == null
|| !Files.exists(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
if (manifest.entries.isEmpty()) {
return ReapplyOutcome.success(false, false);
}
File missing = stagingDir == null ? new File(root, "staging") : stagingDir;
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is missing at " + missing.getPath()));
}
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at " + stagingDir.getPath()));
}
return reapplyStagedDirectoriesOutcome(
root, stagingDir, worldFolders, stripOverrides, manifest);
}
static boolean reapplyStagedDirectories(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return reportReapplyFailure(ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at "
+ stagingDir.getPath()))).succeeded();
}
return reportReapplyFailure(reapplyStagedDirectoriesOutcome(
root,
stagingDir,
worldFolders,
stripOverrides,
DatapackManifestStore.readManifest(root))).succeeded();
}
static ReapplyOutcome reapplyStagedDirectoriesOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides,
Manifest manifest
) {
File[] staged = stagingDir.listFiles(File::isDirectory);
if (staged == null) {
return ReapplyOutcome.failed(new IOException(
"Unable to enumerate managed datapack staging at " + stagingDir.getPath()));
}
boolean repaired = false;
IOException failure = null;
for (Entry entry : manifest.entries) {
File stagedDir = new File(stagingDir, entry.id);
if (isRecordedUnchangedInstall(stagedDir, worldFolders, entry, stripOverrides)) {
continue;
}
if (!DatapackStagingGuard.isUsableStaging(stagedDir, entry)) {
forgetInstallMetadata(entry);
failure = DatapackSupport.appendFailure(failure, new IOException(
"Managed datapack staging is unusable for '" + entry.id
+ "' at " + stagedDir.getPath()));
continue;
}
try {
InstallResult result = DatapackInstall.install(stagedDir, worldFolders, entry, stripOverrides);
if (result.changed()) {
repaired = true;
IrisLogging.warn("Repaired installed datapack '" + entry.id
+ "' from Iris staging before datapack compilation.");
}
recordInstallMetadata(stagedDir, worldFolders, entry);
} catch (IOException e) {
forgetInstallMetadata(entry);
failure = DatapackSupport.appendFailure(failure, e);
}
}
DatapackManifestStore.writeManifest(root, manifest);
return failure == null
? ReapplyOutcome.success(false, repaired)
: ReapplyOutcome.failed(failure);
}
static ReapplyOutcome reportReapplyFailure(ReapplyOutcome outcome) {
if (!outcome.succeeded()) {
IrisLogging.reportError(
"External datapack recovery or staging repair failed.",
outcome.failure().orElseThrow());
}
return outcome;
}
static boolean isRecordedUnchangedInstall(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides
) {
if (entry.stagingMetadata == null || entry.stagingMetadata.isBlank()
|| entry.installMetadata == null || entry.installMetadata.size() != worldFolders.size()) {
return false;
}
try {
if (!isRecordedManagedDirectory(stagedDir, entry)
|| !entry.stagingMetadata.equals(metadataDigest(stagedDir))) {
return false;
}
for (File worldFolder : worldFolders) {
File target = new File(worldFolder, entry.id);
if (!isRecordedManagedDirectory(target, entry)
|| new File(target, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).isFile() != stripOverrides) {
return false;
}
String recorded = entry.installMetadata.get(installMetadataKey(target));
if (recorded == null || !recorded.equals(metadataDigest(target))) {
return false;
}
}
return true;
} catch (IOException e) {
IrisLogging.reportError("Managed datapack '" + entry.id
+ "' requires full verification", e);
return false;
}
}
static boolean isRecordedManagedDirectory(File directory, Entry entry) throws IOException {
Path path = directory.toPath();
if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) {
return false;
}
if (!new File(directory, "pack.mcmeta").isFile()) {
return false;
}
Ownership ownership = DatapackOwnership.readOwnershipOrNull(directory);
return ownership != null
&& DatapackInstall.ownershipSourceMatches(ownership, entry)
&& Objects.equals(ownership.versionId, entry.versionId)
&& Objects.equals(ownership.versionNumber, entry.versionNumber)
&& Objects.equals(ownership.sha1, entry.sha1);
}
static void recordInstallMetadata(File stagedDir, KList<File> worldFolders, Entry entry) {
try {
Map<String, String> recorded = new HashMap<>();
for (File worldFolder : worldFolders) {
File target = new File(worldFolder, entry.id);
recorded.put(installMetadataKey(target), metadataDigest(target));
}
entry.stagingMetadata = metadataDigest(stagedDir);
entry.installMetadata = recorded;
} catch (IOException e) {
IrisLogging.reportError("Unable to record managed datapack metadata for '" + entry.id + "'", e);
forgetInstallMetadata(entry);
}
}
static void forgetInstallMetadata(Entry entry) {
entry.stagingMetadata = "";
entry.installMetadata = new HashMap<>();
}
static String installMetadataKey(File target) {
return target.toPath().toAbsolutePath().normalize().toString();
}
static String metadataDigest(File root) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
List<MetadataEntry> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
int pathCount = 0;
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(rootPath) || DatapackOwnership.isFinderMetadata(path)) {
continue;
}
pathCount++;
if (pathCount > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + DatapackOwnership.MAX_MANAGED_PATHS + " paths");
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Datapack contains a symbolic link: " + path);
}
String relativePath = rootPath.relativize(path).toString();
entries.add(new MetadataEntry(path, relativePath));
}
}
entries.sort(Comparator.comparing(MetadataEntry::relativePath));
for (MetadataEntry entry : entries) {
String relative = entry.relativePath().replace(File.separatorChar, '/');
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
BasicFileAttributes attributes = Files.readAttributes(
entry.path(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack contains an unsupported filesystem entry: " + entry.path());
}
digest.update((byte) (attributes.isDirectory() ? 1 : 2));
DatapackSupport.updateDigestInt(digest, relativeBytes.length);
digest.update(relativeBytes);
if (!attributes.isDirectory()) {
DatapackSupport.updateDigestLong(digest, attributes.size());
DatapackSupport.updateDigestLong(digest, attributes.lastModifiedTime().toMillis());
}
}
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static boolean recoverBeforeReapply(File root, List<File> worldFolders) {
return reportReapplyFailure(recoverBeforeReapplyOutcome(root, worldFolders)).succeeded();
}
static ReapplyOutcome recoverBeforeReapplyOutcome(File root, List<File> worldFolders) {
try {
return ReapplyOutcome.success(DatapackScratchRecovery.recoverTransactions(root, worldFolders), false);
} catch (IOException e) {
return ReapplyOutcome.failed(e);
}
}
}
@@ -0,0 +1,385 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
final class DatapackStartupValidation {
static final String STARTUP_VALIDATION_CACHE = "startup-validation.json";
static final int STARTUP_VALIDATION_SCHEMA = 1;
static volatile StartupValidationCache activeStartupValidation;
private DatapackStartupValidation() {
}
static StartupValidationCache cacheStartupValidation(
File root,
KList<File> worldFolders,
Path cacheFile,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls
) {
try {
StartupValidationCache cache = createStartupValidationCache(
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
startupValidationFingerprint(root, worldFolders, urls));
writeStartupValidationCache(cacheFile, cache);
return cache;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not persist external datapack startup validation: "
+ DatapackSupport.failureMessage(exception));
return null;
}
}
static void refreshStartupValidationAfterMaintenance(boolean maintenanceChanged) {
if (!maintenanceChanged) {
return;
}
StartupValidationCache validated = activeStartupValidation;
if (validated == null || !IrisStartupValidation.isReady()) {
return;
}
KList<String> configured = DatapackIngestService.collectConfiguredImports();
List<String> urls = configured.stream().sorted().toList();
boolean autoIngest = IrisSettings.get().getGeneral().autoIngestDatapacks;
boolean stripOverrides = DatapackPackMetadata.resolveStripOverrides();
String mcVersion = DatapackSupport.serverMcVersion();
int irisVersion = IrisPlatforms.get().irisVersionNumber();
if (!startupValidationContextMatches(
validated, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
return;
}
File root = IrisPlatforms.get().dataFolder("datapacks");
KList<File> worldFolders = ServerConfigurator.getDatapacksFolder();
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
DatapackTransactions.TRANSACTION_LOCK.lock();
try {
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
StartupValidationCache refreshed = refreshStartupValidationCache(
validated, root, worldFolders);
writeStartupValidationCache(cacheFile, refreshed);
activeStartupValidation = refreshed;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not refresh external datapack validation after startup maintenance: "
+ DatapackSupport.failureMessage(exception));
} finally {
DatapackTransactions.TRANSACTION_LOCK.unlock();
}
}
static StartupValidationCache refreshStartupValidationCache(
StartupValidationCache validated,
File root,
KList<File> worldFolders
) throws IOException {
Objects.requireNonNull(validated, "Validated external datapack startup state");
return createStartupValidationCache(
validated.minecraftVersion,
validated.irisVersion,
validated.autoIngest,
validated.stripOverrides,
validated.urls,
startupValidationFingerprint(root, worldFolders, validated.urls));
}
static StartupValidationCache createStartupValidationCache(
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls,
String localFingerprint
) {
StartupValidationCache cache = new StartupValidationCache();
cache.schemaVersion = STARTUP_VALIDATION_SCHEMA;
cache.minecraftVersion = Objects.requireNonNullElse(mcVersion, "");
cache.irisVersion = irisVersion;
cache.autoIngest = autoIngest;
cache.stripOverrides = stripOverrides;
cache.urls = List.copyOf(urls);
cache.localFingerprint = localFingerprint;
return cache;
}
static boolean startupValidationContextMatches(
StartupValidationCache cache,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls
) {
return cache != null
&& cache.schemaVersion == STARTUP_VALIDATION_SCHEMA
&& Objects.equals(cache.minecraftVersion, Objects.requireNonNullElse(mcVersion, ""))
&& cache.irisVersion == irisVersion
&& cache.autoIngest == autoIngest
&& cache.stripOverrides == stripOverrides
&& Objects.equals(cache.urls, urls);
}
static boolean startupValidationCacheMatches(
StartupValidationCache cache,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls,
String localFingerprint
) {
return startupValidationContextMatches(
cache, mcVersion, irisVersion, autoIngest, stripOverrides, urls)
&& localFingerprint != null && !localFingerprint.isBlank()
&& Objects.equals(cache.localFingerprint, localFingerprint);
}
static StartupValidationCache readStartupValidationCache(Path cacheFile) {
if (cacheFile == null
|| Files.isSymbolicLink(cacheFile)
|| !Files.isRegularFile(cacheFile, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
try {
if (Files.size(cacheFile) > DatapackPackMetadata.MAX_METADATA_BYTES) {
return null;
}
StartupValidationCache cache = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(cacheFile, DatapackPackMetadata.MAX_METADATA_BYTES, "External datapack startup validation"),
StartupValidationCache.class);
if (cache == null || cache.urls == null) {
return null;
}
List<String> sortedUrls = cache.urls.stream()
.filter(Objects::nonNull)
.sorted()
.toList();
if (sortedUrls.size() != cache.urls.size()
|| new HashSet<>(sortedUrls).size() != sortedUrls.size()
|| !sortedUrls.equals(cache.urls)) {
return null;
}
cache.urls = sortedUrls;
return cache;
} catch (IOException | RuntimeException exception) {
return null;
}
}
static void writeStartupValidationCache(Path cacheFile, StartupValidationCache cache) throws IOException {
Path absolute = cacheFile.toAbsolutePath().normalize();
Path parent = Objects.requireNonNull(absolute.getParent(), "External datapack startup validation parent");
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".startup-validation-", ".tmp");
try {
byte[] content = DatapackSupport.GSON.toJson(cache).getBytes(StandardCharsets.UTF_8);
if (content.length > DatapackPackMetadata.MAX_METADATA_BYTES) {
throw new IOException("External datapack startup validation exceeds " + DatapackPackMetadata.MAX_METADATA_BYTES + " bytes");
}
Files.write(staged, content, StandardOpenOption.TRUNCATE_EXISTING);
DatapackSupport.forceFile(staged);
try {
Files.move(staged, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absolute, StandardCopyOption.REPLACE_EXISTING);
}
DatapackSupport.forceDirectoryIfSupported(parent);
} finally {
Files.deleteIfExists(staged);
}
}
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();
updateFingerprintValue(digest, "manifest");
if (Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(manifestPath)
|| !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack manifest path " + manifestPath);
}
byte[] manifest = DatapackSupport.readBoundedBytes(
manifestPath, DatapackManifestStore.MAX_MANIFEST_BYTES, "Datapack manifest fingerprint");
DatapackSupport.updateDigestLong(digest, manifest.length);
digest.update(manifest);
} else if (Files.notExists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
DatapackSupport.updateDigestLong(digest, -1L);
} else {
throw new IOException("Cannot determine datapack manifest state at " + manifestPath);
}
Manifest manifest = DatapackManifestStore.readCommittedManifest(root);
updateDirectoryFingerprint(digest, "staging", new File(root, "staging"));
updateDirectoryFingerprint(digest, "transactions", new File(root, DatapackTransactions.TRANSACTION_DIRECTORY));
updateDirectoryFingerprint(
digest,
"storage-install-scratch",
DatapackInstall.installScratchRoot(new File(root, "staging")));
List<Entry> entries = new ArrayList<>(manifest.entries);
entries.sort(Comparator.comparing(entry -> entry.id));
List<File> targets = new ArrayList<>(worldFolders == null ? List.of() : worldFolders);
targets.sort(Comparator.comparing(file -> file.toPath().toAbsolutePath().normalize().toString()));
for (File worldFolder : targets) {
String worldIdentity = worldFolder.toPath().toAbsolutePath().normalize().toString();
updateFingerprintValue(digest, "world:" + worldIdentity);
updateDirectoryFingerprint(
digest,
"world-install-scratch:" + worldIdentity,
DatapackInstall.installScratchRoot(worldFolder));
for (Entry entry : entries) {
updateDirectoryFingerprint(
digest,
"world-pack:" + worldIdentity + ":" + entry.id,
new File(worldFolder, entry.id));
}
}
updateLocalSourceFingerprint(digest, sources);
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IOException("SHA-256 algorithm unavailable", exception);
}
}
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[DatapackOwnership.HASH_BUFFER_BYTES];
for (String source : localSources) {
Path path = DatapackArchive.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 > DatapackArchive.MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + DatapackArchive.MAX_DOWNLOAD_BYTES + " bytes: " + path);
}
digest.update(buffer, 0, length);
}
DatapackSupport.updateDigestLong(digest, bytes);
}
}
}
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);
}
}
static void updateDirectoryFingerprint(
MessageDigest digest,
String identity,
File directory
) throws IOException {
updateFingerprintValue(digest, identity);
Path path = directory.toPath();
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
updateFingerprintValue(digest, "missing");
return;
}
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(path)
|| !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid external datapack validation directory " + path);
}
updateFingerprintValue(digest, DatapackOwnership.directoryHash(directory));
Path ownership = new File(directory, DatapackOwnership.OWNERSHIP_MARKER).toPath();
if (Files.isRegularFile(ownership, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(ownership)) {
byte[] marker = DatapackSupport.readBoundedBytes(
ownership, DatapackOwnership.MAX_OWNERSHIP_BYTES, "External datapack ownership fingerprint");
DatapackSupport.updateDigestLong(digest, marker.length);
digest.update(marker);
} else {
DatapackSupport.updateDigestLong(digest, -1L);
}
}
static void updateFingerprintValue(MessageDigest digest, String value) {
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
DatapackSupport.updateDigestInt(digest, bytes.length);
digest.update(bytes);
}
}
@@ -0,0 +1,489 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.BulkStructureImporter;
import art.arcane.iris.core.structure.StructureImporter;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.stream.Stream;
final class DatapackStructureImports {
static final int STRUCTURE_IMPORT_FORMAT_REVISION = 3;
private DatapackStructureImports() {
}
static boolean autoImportDatapackStructures() {
DatapackTransactions.TRANSACTION_LOCK.lock();
try {
return autoImportDatapackStructuresLocked();
} finally {
DatapackTransactions.TRANSACTION_LOCK.unlock();
}
}
static boolean autoImportDatapackStructuresLocked() {
boolean autoImportEnabled = IrisSettings.get().getGeneral().autoImportDatapackStructures;
File root = IrisPlatforms.get().dataFolder("datapacks");
boolean recovered;
try {
recovered = DatapackScratchRecovery.recoverTransactions(root, ServerConfigurator.getDatapacksFolder());
} catch (IOException e) {
IrisLogging.reportError("Automatic datapack structure import blocked by incomplete transaction recovery.", e);
return false;
}
Manifest manifest = DatapackManifestStore.readManifest(root);
if (manifest.entries.isEmpty()) {
return recovered;
}
Map<String, Entry> manifestEntriesByUrl = new HashMap<>();
for (Entry entry : manifest.entries) {
if (entry.url != null) {
manifestEntriesByUrl.put(entry.url, entry);
}
}
List<IrisData> packs;
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
packs = stream.filter(Objects::nonNull).toList();
}
if (!autoImportEnabled && !hasRemovedImportState(packs, manifest.entries)) {
return recovered;
}
Map<String, Entry> entriesByUrl = new HashMap<>();
File stagingRoot = new File(root, "staging");
boolean stagingStateChanged = false;
boolean manifestStagingMetadataChanged = false;
for (Entry entry : manifest.entries) {
if (entry.url == null) {
continue;
}
StagingInspection inspection = DatapackStagingGuard.inspectUsableStaging(new File(stagingRoot, entry.id), entry);
stagingStateChanged |= inspection.ownershipCorrected();
manifestStagingMetadataChanged |= inspection.manifestChanged();
if (inspection.usable()) {
entriesByUrl.put(entry.url, entry);
}
}
int attemptedPacks = 0;
int completedPacks = 0;
int cleanupTargets = 0;
Set<String> completedUrls = new HashSet<>();
Set<String> failedUrls = new HashSet<>();
for (IrisData data : packs) {
Set<String> configured = DatapackImportSources.configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
cleanupTargets++;
}
}
if (!cleanupRemovedImports(data, targetId, configured, manifest.entries, manifestEntriesByUrl)) {
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
failedUrls.add(entry.url);
}
}
}
if (!autoImportEnabled) {
continue;
}
Set<String> pendingUrls = new HashSet<>();
for (String url : configured) {
Entry entry = entriesByUrl.get(url);
if (entry != null && importPending(entry, targetId)) {
pendingUrls.add(url);
}
}
if (pendingUrls.isEmpty()) {
continue;
}
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
prepareImportRecoveryInventory(entry, targetId);
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
} catch (IOException e) {
failedUrls.addAll(pendingUrls);
IrisLogging.reportError("Datapack structure import for pack '"
+ data.getDataFolder().getPath()
+ "' was blocked because its recovery inventory could not be persisted.", e);
continue;
}
Set<String> structureKeys = new TreeSet<>();
Set<String> templateKeys = new TreeSet<>();
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url)) {
continue;
}
structureKeys.addAll(DatapackManifestStore.copyList(entry.structureKeys));
templateKeys.addAll(DatapackManifestStore.copyList(entry.templateKeys));
}
attemptedPacks++;
try {
BulkStructureImporter.Report report = BulkStructureImporter.importManagedDatapackStructures(
data,
StructureImporter.Mode.OVERWRITE,
BukkitPlatform.console(),
structureKeys,
templateKeys
);
Set<String> successfulPendingUrls = new HashSet<>(pendingUrls);
Set<String> incompleteUrls = new HashSet<>();
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
Map<String, String> desired = importBundleInventory(entry);
if (!report.successfulBundles().entrySet().containsAll(desired.entrySet())) {
successfulPendingUrls.remove(pendingUrl);
incompleteUrls.add(pendingUrl);
failedUrls.add(pendingUrl);
}
}
if (report.failed() > 0 && incompleteUrls.isEmpty()) {
successfulPendingUrls.clear();
incompleteUrls.addAll(pendingUrls);
failedUrls.addAll(pendingUrls);
}
if (!incompleteUrls.isEmpty()) {
boolean reconciled = reconcileFailedImportInventories(
root, manifest, data, targetId, incompleteUrls, entriesByUrl);
if (report.retryRequired() || !reconciled) {
IrisLogging.error("Datapack structure import for pack '%s' reported %d incomplete source(s) and remains pending because a retryable runtime failure occurred.",
data.getDataFolder().getPath(), incompleteUrls.size());
} else if (recordDeterministicImportAttempts(
root, manifest, targetId, incompleteUrls, entriesByUrl)) {
IrisLogging.warn("Datapack structure import for pack '"
+ data.getDataFolder().getPath() + "' left " + incompleteUrls.size()
+ " source(s) incomplete after deterministic validation failures. Iris will retain the partial editable imports without retrying until the datapack source, importer format, or target pack changes.");
}
}
Map<String, String> sharedBundles = desiredBundles(configured, entriesByUrl);
boolean packCompleted = false;
for (String pendingUrl : successfulPendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
Map<String, String> desired = importBundleInventory(entry);
Map<String, String> previous = entry.importedBundles.getOrDefault(targetId, Map.of());
Map<String, String> stale = new TreeMap<>(previous);
stale.keySet().removeAll(desired.keySet());
stale.keySet().removeAll(sharedBundles.keySet());
Map<String, String> remaining = cleanupImportedBundles(data, stale);
if (!remaining.isEmpty()) {
failedUrls.add(pendingUrl);
continue;
}
entry.importedBundles.put(targetId, desired);
recordSuccessfulImport(entry, targetId);
completedUrls.add(pendingUrl);
packCompleted = true;
}
if (packCompleted) {
completedPacks++;
}
} catch (RuntimeException e) {
failedUrls.addAll(pendingUrls);
IrisLogging.reportError("Datapack structure import failed for pack '"
+ data.getDataFolder().getPath() + "'; the manifest remains pending for retry.", e);
reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl);
}
}
for (Entry entry : manifest.entries) {
if (failedUrls.contains(entry.url)) {
entry.structuresImported = false;
} else if (completedUrls.contains(entry.url)) {
entry.structuresImported = true;
}
}
if (attemptedPacks == 0 && cleanupTargets == 0) {
if (manifestStagingMetadataChanged) {
DatapackManifestStore.writeManifest(root, manifest);
}
return recovered || stagingStateChanged || manifestStagingMetadataChanged;
}
DatapackManifestStore.writeManifest(root, manifest);
if (attemptedPacks == 0) {
IrisLogging.info("Datapack editable-import cleanup reconciled " + cleanupTargets + " removed source target(s).");
return true;
}
IrisLogging.info("Datapack structure import refreshed " + completedUrls.size() + " source(s) across "
+ completedPacks + "/" + attemptedPacks
+ " pack(s). Reference the imported keys from a 'structures' placement to position them manually.");
return true;
}
static boolean hasRemovedImportState(List<IrisData> packs, List<Entry> entries) {
for (IrisData data : packs) {
Set<String> configured = DatapackImportSources.configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
return true;
}
}
}
return false;
}
static String importRevision(Entry entry) {
return importRevision(entry, STRUCTURE_IMPORT_FORMAT_REVISION);
}
static String importRevision(Entry entry, int importerFormatRevision) {
return "v" + importerFormatRevision + ":" + DatapackSupport.safe(entry.versionId) + ":" + DatapackSupport.safe(entry.sha1);
}
static boolean importPending(Entry entry, String targetId) {
String revision = importRevision(entry);
return !revision.equals(entry.importedTargets.get(targetId))
&& !revision.equals(entry.importAttempts.get(targetId));
}
static void recordDeterministicImportAttempt(Entry entry, String targetId) {
entry.importedTargets.remove(targetId);
entry.importAttempts.put(targetId, importRevision(entry));
entry.structuresImported = false;
}
static void recordSuccessfulImport(Entry entry, String targetId) {
entry.importedTargets.put(targetId, importRevision(entry));
entry.importAttempts.remove(targetId);
}
static void prepareImportRecoveryInventory(Entry entry, String targetId) {
Map<String, String> desired = importBundleInventory(entry);
Map<String, String> recovery = new TreeMap<>(desired);
recovery.putAll(entry.importedBundles.getOrDefault(targetId, Map.of()));
entry.importedBundles.put(targetId, recovery);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
static boolean reconcileFailedImportInventories(
File root,
Manifest manifest,
IrisData data,
String targetId,
Set<String> pendingUrls,
Map<String, Entry> entriesByUrl
) {
boolean reconciled = true;
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
try {
reconcileFailedImportInventory(data, entry, targetId);
} catch (IOException | RuntimeException e) {
reconciled = false;
IrisLogging.reportError("Could not reconcile partial editable structure imports for '"
+ pendingUrl + "' in pack '" + data.getDataFolder().getPath()
+ "'; the conservative recovery inventory remains pending.", e);
}
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
} catch (IOException e) {
reconciled = false;
IrisLogging.reportError("Could not persist reconciled partial editable structure imports for pack '"
+ data.getDataFolder().getPath() + "'; the earlier recovery inventory remains durable.", e);
}
return reconciled;
}
static boolean recordDeterministicImportAttempts(
File root,
Manifest manifest,
String targetId,
Set<String> incompleteUrls,
Map<String, Entry> entriesByUrl
) {
for (String incompleteUrl : incompleteUrls) {
recordDeterministicImportAttempt(entriesByUrl.get(incompleteUrl), targetId);
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
return true;
} catch (IOException e) {
for (String incompleteUrl : incompleteUrls) {
entriesByUrl.get(incompleteUrl).importAttempts.remove(targetId);
}
IrisLogging.reportError("Could not persist deterministic editable structure import attempts; the sources remain pending for retry.", e);
return false;
}
}
static void reconcileFailedImportInventory(
IrisData data,
Entry entry,
String targetId
) throws IOException {
Map<String, Set<String>> claims = DatapackRemoval.importBundleClaims(entry, targetId);
Map<String, String> reconciled = new TreeMap<>();
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
for (Map.Entry<String, Set<String>> bundle : claims.entrySet()) {
StructureKey targetKey;
try {
targetKey = StructureKey.parse(bundle.getKey());
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure target key '" + bundle.getKey() + "'", e);
}
Optional<StructureSource> source = writer.ownedSource(targetKey);
if (source.isPresent() && DatapackRemoval.sourceClaimsContain(bundle.getValue(), source.get())) {
reconciled.put(bundle.getKey(), source.get().key().value());
}
}
if (reconciled.isEmpty()) {
entry.importedBundles.remove(targetId);
} else {
entry.importedBundles.put(targetId, reconciled);
}
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
static boolean cleanupRemovedImports(
IrisData data,
String targetId,
Set<String> configured,
List<Entry> entries,
Map<String, Entry> entriesByUrl
) {
boolean successful = true;
Map<String, String> retainedBundles = desiredBundles(configured, entriesByUrl);
for (Entry entry : entries) {
if (configured.contains(entry.url)) {
continue;
}
Map<String, String> inventory = entry.importedBundles.get(targetId);
if (inventory == null && !hasImportState(entry, targetId)) {
continue;
}
Map<String, String> resolvedInventory = inventory == null ? Map.of() : inventory;
for (Entry retainedEntry : entries) {
if (configured.contains(retainedEntry.url)) {
retainedEntry.importedTargets.remove(targetId);
retainedEntry.importAttempts.remove(targetId);
retainedEntry.structuresImported = false;
}
}
Map<String, String> removable = new TreeMap<>(resolvedInventory);
removable.keySet().removeAll(retainedBundles.keySet());
Map<String, String> remaining = cleanupImportedBundles(data, removable);
if (!remaining.isEmpty()) {
Map<String, String> retained = new TreeMap<>();
for (Map.Entry<String, String> bundle : resolvedInventory.entrySet()) {
if (retainedBundles.containsKey(bundle.getKey()) || remaining.containsKey(bundle.getKey())) {
retained.put(bundle.getKey(), bundle.getValue());
}
}
entry.importedBundles.put(targetId, retained);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
successful = false;
continue;
}
entry.importedBundles.remove(targetId);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
}
return successful;
}
static boolean hasImportState(Entry entry, String targetId) {
return entry.importedBundles.containsKey(targetId)
|| entry.importedTargets.containsKey(targetId)
|| entry.importAttempts.containsKey(targetId);
}
static Map<String, String> cleanupImportedBundles(IrisData data, Map<String, String> inventory) {
Map<String, String> remaining = new TreeMap<>();
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
boolean removed = false;
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
StructureKey sourceKey;
try {
sourceKey = StructureKey.parse(bundle.getValue());
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
removed |= writer.removeManagedDatapackOwned(
StructureKey.parse(bundle.getKey()),
sourceKind,
sourceKey
);
} catch (IOException | RuntimeException e) {
remaining.put(bundle.getKey(), bundle.getValue());
IrisLogging.reportError("Preserving imported structure bundle '" + bundle.getKey()
+ "' in pack '" + data.getDataFolder().getPath()
+ "' because ownership-safe cleanup failed.", e);
}
}
if (removed) {
data.invalidateStructureResources();
}
return remaining;
}
static Map<String, String> desiredBundles(Set<String> configured, Map<String, Entry> entriesByUrl) {
Map<String, String> bundles = new TreeMap<>();
for (String url : configured) {
Entry entry = entriesByUrl.get(url);
if (entry != null) {
bundles.putAll(importBundleInventory(entry));
}
}
return bundles;
}
static Map<String, String> importBundleInventory(Entry entry) {
Map<String, String> bundles = new TreeMap<>();
for (String structureKey : DatapackManifestStore.copyList(entry.structureKeys)) {
bundles.put("iris:" + StructureImporter.deriveName(structureKey), structureKey);
}
for (String templateKey : DatapackManifestStore.copyList(entry.templateKeys)) {
bundles.put("iris:" + BulkStructureImporter.templateNameFor(templateKey), templateKey);
}
return bundles;
}
}
@@ -0,0 +1,277 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.nms.MinecraftVersion;
import art.arcane.iris.util.common.io.Durability;
import art.arcane.iris.util.common.plugin.VolmitSender;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.bukkit.Bukkit;
import org.bukkit.Server;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
final class DatapackSupport {
static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private DatapackSupport() {
}
static String failureMessage(Throwable exception) {
if (exception == null) {
return "unknown failure";
}
String message = exception.getMessage();
return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
}
static IOException appendFailure(IOException current, IOException additional) {
if (current == null) {
return additional;
}
current.addSuppressed(additional);
return current;
}
static IOException appendIOException(IOException current, Throwable failure) {
IOException next = failure instanceof IOException ioFailure
? ioFailure : new IOException("Datapack transaction participant failed", failure);
if (current == null) {
return next;
}
current.addSuppressed(next);
return current;
}
static Path requireDirectoryIdentity(File directory, String purpose) throws IOException {
Path normalized = directory.toPath().toAbsolutePath().normalize();
BasicFileAttributes attributes = Files.readAttributes(
normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!DatapackScratchRecovery.isSupportedScratchDirectory(attributes)) {
throw new IOException("Invalid " + purpose + " " + normalized);
}
normalized.toRealPath();
return normalized;
}
static void requireNoSymbolicLinkComponents(Path normalized, String purpose) throws IOException {
Path current = normalized.getRoot();
for (Path component : normalized) {
current = current == null ? component : current.resolve(component);
BasicFileAttributes attributes = Files.readAttributes(
current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (attributes.isSymbolicLink()) {
throw new IOException("Refusing symbolic-link component in " + purpose + " " + normalized);
}
if (attributes.isOther()) {
throw new IOException("Refusing special filesystem component in " + purpose + " " + normalized);
}
if (!DatapackScratchRecovery.isSupportedScratchDirectory(attributes)) {
throw new IOException("Refusing unsupported component in " + purpose + " " + normalized);
}
}
}
static String directoryIdentity(File directory) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(
directory.toPath(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
throw new IOException("Invalid datapack directory identity at " + directory.getPath());
}
Object fileKey = attributes.fileKey();
if (fileKey != null) {
return "key:" + fileKey;
}
// Windows exposes no inode-equivalent for a directory, and returning nothing here fails
// the identity gate closed, which disables legacy staging replacement on that platform
// outright. Creation time is the one stable discriminator the filesystem still offers: a
// directory swapped in at the same path brings its own, so a swap is still caught. It is
// a weaker guarantee than an inode, so it is only ever the fallback.
return "created:" + attributes.creationTime();
}
static boolean pathExists(Path path, String purpose) throws IOException {
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return true;
}
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
throw new IOException("Cannot determine " + purpose + " state at " + path);
}
static String serverMcVersion() {
return serverMcVersion(Bukkit.getServer());
}
static String serverMcVersion(Server server) {
MinecraftVersion detected = MinecraftVersion.detect(server);
return detected == null ? null : detected.value();
}
static String safe(String value) {
return value == null || value.isBlank() ? "?" : value;
}
static String safeFile(String value) {
return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^a-zA-Z0-9._-]", "_");
}
static String hex(byte[] hash) {
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
}
static void updateDigestInt(MessageDigest digest, int value) {
digest.update((byte) (value >>> 24));
digest.update((byte) (value >>> 16));
digest.update((byte) (value >>> 8));
digest.update((byte) value);
}
static void updateDigestLong(MessageDigest digest, long value) {
digest.update((byte) (value >>> 56));
digest.update((byte) (value >>> 48));
digest.update((byte) (value >>> 40));
digest.update((byte) (value >>> 32));
digest.update((byte) (value >>> 24));
digest.update((byte) (value >>> 16));
digest.update((byte) (value >>> 8));
digest.update((byte) value);
}
static void message(VolmitSender sender, String text) {
if (sender != null) {
sender.sendMessage(text);
return;
}
IrisLogging.info(text);
}
static void move(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}
static void moveNew(Path source, Path target) throws IOException {
if (pathExists(target, "move target")) {
throw new IOException("Refusing to replace concurrently-created path " + target);
}
Files.move(source, target);
}
static void ensureScratchDirectory(File directory, String purpose) throws IOException {
Path path = directory.toPath();
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath());
}
return;
}
Files.createDirectories(path);
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not create a safe " + purpose + " directory " + directory.getPath());
}
}
static void verifyDirectoryContainerIfPresent(File directory, String purpose) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath());
}
}
static String normalizedPath(File file) {
return file.toPath().toAbsolutePath().normalize().toString();
}
static String realDirectoryPath(File directory, String purpose) throws IOException {
Path path = directory.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid " + purpose + " " + path);
}
return path.toRealPath().toString();
}
static String readBoundedUtf8(Path path, long maxBytes, String purpose) throws IOException {
return new String(readBoundedBytes(path, maxBytes, purpose), StandardCharsets.UTF_8);
}
static byte[] readBoundedBytes(Path path, long maxBytes, String purpose) throws IOException {
int readLimit = Math.toIntExact(maxBytes + 1);
byte[] content;
try (InputStream input = Files.newInputStream(
path,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS
)) {
content = input.readNBytes(readLimit);
}
if (content.length > maxBytes) {
throw new IOException(purpose + " exceeds " + maxBytes + " bytes");
}
return content;
}
static void forceFile(Path file) throws IOException {
if (!Durability.enabled()) {
return;
}
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) {
Durability.force(channel);
}
}
static void forceDirectoryIfSupported(Path directory) throws IOException {
if (!Durability.enabled()) {
return;
}
if (!Files.getFileStore(directory).supportsFileAttributeView("posix")) {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
Durability.force(channel);
}
}
}
@@ -0,0 +1,342 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Stream;
final class DatapackTransactions {
static final String TRANSACTION_DIRECTORY = ".iris-datapack-transactions";
static final String TRANSACTION_JOURNAL = "journal.json";
static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
static final int TRANSACTION_SCHEMA = 2;
static final long MAX_TRANSACTION_JOURNAL_BYTES = 4L * 1024L * 1024L;
static final int MAX_TRANSACTION_COUNT = 1_024;
static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock();
private DatapackTransactions() {
}
static DatapackCoordinator createInstallCoordinator(
File root,
Entry entry,
List<InstallPlan> plans,
boolean manifestAlreadyMatched
) throws IOException {
CoordinatorJournal journal = newCoordinatorJournal(
CoordinatorOperation.INSTALL,
entry,
manifestAlreadyMatched
);
for (InstallPlan plan : plans) {
if (!plan.publishRequired()) {
continue;
}
CoordinatorDirectory directory = new CoordinatorDirectory();
directory.target = DatapackSupport.normalizedPath(plan.target());
directory.pending = DatapackSupport.normalizedPath(plan.pending());
directory.backup = DatapackSupport.normalizedPath(plan.backup());
directory.hadTarget = plan.hadTarget();
directory.originalHash = plan.originalHash();
directory.desiredHash = plan.desiredHash();
directory.originalMarkerHash = plan.originalMarkerHash();
directory.desiredMarkerHash = plan.desiredMarkerHash();
directory.originalIdentity = plan.originalIdentity();
directory.desiredIdentity = plan.desiredIdentity();
directory.targetRoot = plan.targetRootIdentity();
directory.scratchRoot = plan.scratchRootIdentity();
directory.targetRootIdentity = plan.targetRootFileIdentity();
directory.scratchRootIdentity = plan.scratchRootFileIdentity();
journal.directories.add(directory);
}
return createCoordinator(root, journal);
}
static DatapackCoordinator createRemovalCoordinator(
File root,
Entry entry,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval
) throws IOException {
CoordinatorJournal journal = newCoordinatorJournal(CoordinatorOperation.REMOVE, entry, true);
for (DirectoryMove move : directoryRemoval.moves()) {
CoordinatorDirectory directory = new CoordinatorDirectory();
directory.target = DatapackSupport.normalizedPath(move.target());
directory.pending = "";
directory.backup = DatapackSupport.normalizedPath(move.backup());
directory.hadTarget = true;
directory.originalHash = move.originalHash();
directory.desiredHash = "";
directory.originalMarkerHash = move.originalMarkerHash();
directory.desiredMarkerHash = "";
directory.originalIdentity = move.originalIdentity();
directory.desiredIdentity = "";
directory.targetRoot = move.targetRootIdentity();
directory.scratchRoot = move.scratchRootIdentity();
directory.targetRootIdentity = move.targetRootFileIdentity();
directory.scratchRootIdentity = move.scratchRootFileIdentity();
journal.directories.add(directory);
}
for (StructureTransactionWriter.PreparedRemovalToken token : editableRemoval.recoveryTokens()) {
CoordinatorEditable editable = new CoordinatorEditable();
editable.packRoot = token.packRoot().toString();
editable.transactionId = token.transactionId().toString();
editable.claimId = UUID.randomUUID().toString();
journal.editables.add(editable);
}
Path transactionRoot = coordinatorTransactionRoot(root, journal);
try {
editableRemoval.claimRecoveryOwners(transactionRoot, journal);
writeCoordinatorJournal(transactionRoot, journal);
return new DatapackCoordinator(transactionRoot, journal);
} catch (IOException | RuntimeException creationFailure) {
try {
DatapackInstall.deleteInstallScratch(transactionRoot.toFile(), "incomplete datapack transaction");
} catch (IOException cleanupFailure) {
creationFailure.addSuppressed(cleanupFailure);
}
if (creationFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw creationFailure;
}
}
static CoordinatorJournal newCoordinatorJournal(
CoordinatorOperation operation,
Entry entry,
boolean manifestAlreadyMatched
) {
CoordinatorJournal journal = new CoordinatorJournal();
journal.schemaVersion = TRANSACTION_SCHEMA;
journal.transactionId = UUID.randomUUID().toString();
journal.operation = operation;
journal.phase = CoordinatorPhase.PREPARED;
journal.id = entry.id;
journal.url = entry.url;
journal.versionId = entry.versionId;
journal.versionNumber = entry.versionNumber;
journal.sha1 = entry.sha1;
journal.manifestAlreadyMatched = manifestAlreadyMatched;
return journal;
}
static DatapackCoordinator createCoordinator(File root, CoordinatorJournal journal) throws IOException {
Path transactionRoot = coordinatorTransactionRoot(root, journal);
writeCoordinatorJournal(transactionRoot, journal);
return new DatapackCoordinator(transactionRoot, journal);
}
static Path coordinatorTransactionRoot(File root, CoordinatorJournal journal) throws IOException {
File transactionDirectory = new File(root, TRANSACTION_DIRECTORY);
DatapackSupport.ensureScratchDirectory(transactionDirectory, "datapack transaction");
Path transactionRoot = new File(transactionDirectory, journal.transactionId).toPath().toAbsolutePath().normalize();
if (Files.exists(transactionRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack transaction id already exists: " + journal.transactionId);
}
return transactionRoot;
}
static CoordinatorJournal readCoordinatorJournal(Path transactionRoot) throws IOException {
Path committed = transactionRoot.resolve(TRANSACTION_JOURNAL);
Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT);
if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
return parseCoordinatorJournal(committed);
}
if (!Files.exists(next, LinkOption.NOFOLLOW_LINKS)) {
try (Stream<Path> entries = Files.list(transactionRoot)) {
if (entries.findAny().isEmpty()) {
return null;
}
}
throw new IOException("Missing datapack transaction journal " + committed);
}
try {
return parseCoordinatorJournal(next);
} catch (IOException firstWriteFailure) {
try (Stream<Path> entries = Files.list(transactionRoot)) {
List<Path> contents = entries.limit(2).toList();
if (contents.size() == 1 && Objects.equals(contents.getFirst(), next)
&& !Files.isSymbolicLink(next)) {
return null;
}
}
throw firstWriteFailure;
}
}
static CoordinatorJournal parseCoordinatorJournal(Path journalPath) throws IOException {
if (Files.isSymbolicLink(journalPath)
|| !Files.isRegularFile(journalPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack transaction journal " + journalPath);
}
try {
CoordinatorJournal journal = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(
journalPath,
MAX_TRANSACTION_JOURNAL_BYTES,
"Datapack transaction journal"
),
CoordinatorJournal.class
);
if (journal == null) {
throw new IOException("Empty datapack transaction journal " + journalPath);
}
return journal;
} catch (RuntimeException e) {
throw new IOException("Invalid datapack transaction journal " + journalPath, e);
}
}
static void validateCoordinatorJournal(
File root,
List<File> worldFolders,
Manifest committedManifest,
Path transactionRoot,
CoordinatorJournal journal
) throws IOException {
if (journal.schemaVersion != TRANSACTION_SCHEMA || journal.transactionId == null
|| journal.operation == null || journal.phase == null || journal.id == null
|| journal.url == null || journal.directories == null || journal.editables == null) {
throw new IOException("Incomplete datapack transaction journal at " + transactionRoot);
}
UUID transactionId;
try {
transactionId = UUID.fromString(journal.transactionId);
} catch (IllegalArgumentException e) {
throw new IOException("Invalid datapack transaction id " + journal.transactionId, e);
}
if (!transactionId.toString().equals(transactionRoot.getFileName().toString())) {
throw new IOException("Datapack transaction id does not match its directory");
}
if (!journal.id.equals(DatapackArchive.sanitizeId(journal.id)) || DatapackArchive.RESERVED_IDS.contains(journal.id)) {
throw new IOException("Invalid datapack transaction entry id " + journal.id);
}
if (journal.directories.size() > worldFolders.size() + 1 || journal.editables.size() > 10_000) {
throw new IOException("Datapack transaction journal contains too many participants");
}
if (journal.operation == CoordinatorOperation.INSTALL && !journal.editables.isEmpty()) {
throw new IOException("Datapack install transaction unexpectedly contains editable participants");
}
Set<Path> allowedTargets = new HashSet<>();
allowedTargets.add(new File(new File(root, "staging"), journal.id).toPath().toAbsolutePath().normalize());
for (File worldFolder : worldFolders) {
allowedTargets.add(new File(worldFolder, journal.id).toPath().toAbsolutePath().normalize());
}
Set<Path> seenTargets = new HashSet<>();
Set<Path> seenTargetIdentities = new HashSet<>();
for (CoordinatorDirectory directory : journal.directories) {
DatapackCoordinatorResolution.validateCoordinatorDirectory(
directory, journal.operation, allowedTargets, seenTargets, seenTargetIdentities);
}
Set<String> seenEditables = new HashSet<>();
Set<Path> allowedEditableRoots = journal.editables.isEmpty()
? Set.of() : DatapackCoordinatorResolution.authoritativeEditableRoots(committedManifest, journal);
for (CoordinatorEditable editable : journal.editables) {
if (editable == null || editable.packRoot == null || editable.transactionId == null
|| editable.claimId == null
|| !seenEditables.add(editable.packRoot)) {
throw new IOException("Invalid duplicate editable participant in datapack transaction");
}
Path packRoot = coordinatorPath(editable.packRoot, "editable pack root");
Path realPackRoot = Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)
? packRoot.toRealPath() : packRoot;
if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)
|| !packRoot.equals(realPackRoot) || !allowedEditableRoots.contains(realPackRoot)) {
throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot);
}
try {
UUID.fromString(editable.transactionId);
UUID.fromString(editable.claimId);
} catch (IllegalArgumentException e) {
throw new IOException("Invalid editable transaction recovery identity", e);
}
StructureTransactionWriter writer = new StructureTransactionWriter(packRoot);
boolean recoveryDataPresent = writer.verifyRecoveryOwner(
new StructureTransactionWriter.PreparedRemovalToken(
packRoot,
UUID.fromString(editable.transactionId)
),
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
),
committedManifest.findById(journal.id) == null
&& journal.phase == CoordinatorPhase.PUBLISHED
);
if (!recoveryDataPresent && journal.phase != CoordinatorPhase.COMMITTED) {
throw new IOException("Editable structure recovery data disappeared before commit");
}
}
}
static void deleteCoordinatorTransaction(Path transactionRoot) throws IOException {
DatapackInstall.deleteInstallScratch(transactionRoot.toFile(), "completed datapack transaction");
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent"));
}
static void writeCoordinatorJournal(Path transactionRoot, CoordinatorJournal journal) throws IOException {
byte[] content = DatapackSupport.GSON.toJson(journal).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_TRANSACTION_JOURNAL_BYTES) {
throw new IOException("Datapack transaction journal exceeds " + MAX_TRANSACTION_JOURNAL_BYTES + " bytes");
}
Files.createDirectories(transactionRoot);
Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT);
if (Files.isSymbolicLink(next)) {
throw new IOException("Datapack transaction journal cannot be a symbolic link: " + next);
}
Files.deleteIfExists(next);
Files.write(next, content, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
DatapackSupport.forceFile(next);
DatapackSupport.move(next, transactionRoot.resolve(TRANSACTION_JOURNAL));
DatapackSupport.forceDirectoryIfSupported(transactionRoot);
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent"));
}
static Path coordinatorPath(String value, String purpose) throws IOException {
try {
return Path.of(value).toAbsolutePath().normalize();
} catch (RuntimeException e) {
throw new IOException("Invalid datapack transaction " + purpose + " path", e);
}
}
}
@@ -0,0 +1,26 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.io.File;
@FunctionalInterface
interface DirectoryDeleter {
void delete(File directory);
}
@@ -0,0 +1,34 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.io.File;
record DirectoryMove(
File target,
File backup,
String originalHash,
String originalMarkerHash,
String originalIdentity,
String targetRootIdentity,
String scratchRootIdentity,
String targetRootFileIdentity,
String scratchRootFileIdentity
) {
}
@@ -0,0 +1,179 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.util.List;
final class DirectoryRemoval {
private final List<DirectoryMove> moved;
private int prepared;
private boolean closed;
DirectoryRemoval(List<DirectoryMove> moved) {
this.moved = List.copyOf(moved);
}
List<DirectoryMove> moves() {
return moved;
}
void prepare() throws IOException {
if (closed || prepared > 0) {
throw new IllegalStateException("Datapack directory removal transaction is not new");
}
try {
for (DirectoryMove directoryMove : moved) {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.target().getParentFile(),
directoryMove.targetRootIdentity(),
directoryMove.targetRootFileIdentity(),
"datapack removal target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.target(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal target"
);
DatapackSupport.moveNew(directoryMove.target().toPath(), directoryMove.backup().toPath());
prepared++;
DatapackSupport.forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath());
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal backup"
);
}
} catch (IOException removalFailure) {
try {
rollback();
} catch (IOException restoreFailure) {
removalFailure.addSuppressed(restoreFailure);
}
throw removalFailure;
}
}
void rollback() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = prepared - 1; i >= 0; i--) {
DirectoryMove directoryMove = moved.get(i);
if (!Files.exists(directoryMove.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) {
continue;
}
try {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.target().getParentFile(),
directoryMove.targetRootIdentity(),
directoryMove.targetRootFileIdentity(),
"datapack removal target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal backup"
);
if (DatapackSupport.pathExists(directoryMove.target().toPath(), "datapack removal target")) {
throw new IOException("Datapack removal target was concurrently recreated at "
+ directoryMove.target().getPath());
}
DatapackSupport.moveNew(
directoryMove.backup().toPath(),
directoryMove.target().toPath()
);
DatapackSupport.forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath());
} catch (IOException restoreFailure) {
if (failure == null) {
failure = restoreFailure;
} else {
failure.addSuppressed(restoreFailure);
}
}
}
for (int i = 0; i < prepared; i++) {
DirectoryMove directoryMove = moved.get(i);
directoryMove.backup().getParentFile().delete();
}
closed = true;
if (failure != null) {
throw failure;
}
}
void finishCommit() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = 0; i < prepared; i++) {
DirectoryMove directoryMove = moved.get(i);
try {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackCoordinatorResolution.deleteOriginalBackupIfPresent(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity()
);
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
directoryMove.backup().getParentFile().delete();
}
closed = true;
if (failure != null) {
throw failure;
}
}
}
@@ -0,0 +1,23 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
record DownloadResult(boolean notModified, String etag, String lastModified) {
}
@@ -0,0 +1,171 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
final class EditableImportRemoval {
private final List<PreparedEditableImport> prepared;
private boolean closed;
EditableImportRemoval(List<PreparedEditableImport> prepared) {
this.prepared = List.copyOf(prepared);
}
List<StructureTransactionWriter.PreparedRemovalToken> recoveryTokens() {
List<StructureTransactionWriter.PreparedRemovalToken> tokens = new ArrayList<>();
for (PreparedEditableImport editableImport : prepared) {
editableImport.removal().recoveryToken().ifPresent(tokens::add);
}
return List.copyOf(tokens);
}
void claimRecoveryOwners(Path transactionRoot, CoordinatorJournal journal) throws IOException {
int ownerIndex = 0;
for (PreparedEditableImport editableImport : prepared) {
Optional<StructureTransactionWriter.PreparedRemovalToken> token =
editableImport.removal().recoveryToken();
if (token.isEmpty()) {
continue;
}
if (ownerIndex >= journal.editables.size()) {
throw new IOException("Missing datapack coordinator claim for editable structure removal");
}
CoordinatorEditable editable = journal.editables.get(ownerIndex++);
StructureTransactionWriter.PreparedRemovalToken recoveryToken = token.get();
if (!Objects.equals(recoveryToken.packRoot().toString(), editable.packRoot)
|| !Objects.equals(recoveryToken.transactionId().toString(), editable.transactionId)) {
throw new IOException("Datapack coordinator editable claim order changed during preparation");
}
editableImport.removal().claimRecoveryOwner(
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
)
);
}
if (ownerIndex != journal.editables.size()) {
throw new IOException("Unexpected datapack coordinator editable recovery claim");
}
}
void markCommitted() throws IOException {
if (closed) {
throw new IllegalStateException("Editable import removal transaction is closed");
}
for (PreparedEditableImport editableImport : prepared) {
editableImport.removal().markCommitted();
}
}
void finishCommit() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (PreparedEditableImport editableImport : prepared) {
try {
editableImport.removal().finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
IOException transactionFailure = cleanupFailure instanceof IOException ioFailure
? ioFailure
: new IOException("Failed finalizing editable import removal", cleanupFailure);
if (failure == null) {
failure = transactionFailure;
} else {
failure.addSuppressed(transactionFailure);
}
}
if (editableImport.removal().changed()) {
try {
IrisData.getLoaded(editableImport.dataFolder())
.ifPresent(IrisData::invalidateStructureResources);
} catch (RuntimeException invalidationFailure) {
IOException transactionFailure = new IOException(
"Failed invalidating editable import structure resources",
invalidationFailure
);
if (failure == null) {
failure = transactionFailure;
} else {
failure.addSuppressed(transactionFailure);
}
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
void rollback() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = prepared.size() - 1; i >= 0; i--) {
try {
prepared.get(i).removal().rollback();
} catch (IOException rollbackFailure) {
if (failure == null) {
failure = rollbackFailure;
} else {
failure.addSuppressed(rollbackFailure);
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
void leaveForRecovery() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (PreparedEditableImport editableImport : prepared) {
try {
editableImport.removal().leaveForRecovery();
} catch (IOException releaseFailure) {
if (failure == null) {
failure = releaseFailure;
} else {
failure.addSuppressed(releaseFailure);
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
}
@@ -0,0 +1,96 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.io.File;
import java.util.List;
final class InstallExecution {
private final InstallResult result;
private final DatapackCoordinator coordinator;
private final File stagedDir;
private final Entry entry;
private final String stagedHash;
private final boolean verifyStagedSource;
private final List<InstallPlan> publishedPlans;
private final List<InstallPlan> unchangedPlans;
private boolean verified;
InstallExecution(
InstallResult result,
DatapackCoordinator coordinator,
File stagedDir,
Entry entry,
String stagedHash,
boolean verifyStagedSource,
List<InstallPlan> publishedPlans,
List<InstallPlan> unchangedPlans
) {
this.result = result;
this.coordinator = coordinator;
this.stagedDir = stagedDir;
this.entry = DatapackManifestStore.copyEntry(entry);
this.stagedHash = stagedHash;
this.verifyStagedSource = verifyStagedSource;
this.publishedPlans = List.copyOf(publishedPlans);
this.unchangedPlans = List.copyOf(unchangedPlans);
}
InstallResult result() {
return result;
}
DatapackCoordinator coordinator() {
return coordinator;
}
File stagedDir() {
return stagedDir;
}
Entry entry() {
return entry;
}
String stagedHash() {
return stagedHash;
}
boolean verifyStagedSource() {
return verifyStagedSource;
}
List<InstallPlan> publishedPlans() {
return publishedPlans;
}
List<InstallPlan> unchangedPlans() {
return unchangedPlans;
}
boolean verified() {
return verified;
}
void markVerified() {
verified = true;
}
}
@@ -0,0 +1,45 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.io.File;
record InstallPlan(
File target,
File pending,
File backup,
File pendingRoot,
boolean hadTarget,
boolean publishRequired,
boolean contentChanged,
String originalHash,
String desiredHash,
String originalMarkerHash,
String desiredMarkerHash,
String originalIdentity,
String desiredIdentity,
String targetRootIdentity,
String scratchRootIdentity,
String targetRootFileIdentity,
String scratchRootFileIdentity,
String id,
String url,
VerifiedStagingInstall legacyWorldAuthorization
) {
}
@@ -0,0 +1,23 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
record InstallResult(boolean changed) {
}
@@ -0,0 +1,61 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.util.ArrayList;
import java.util.List;
final class Manifest {
List<Entry> entries = new ArrayList<>();
Entry find(String url) {
for (Entry entry : entries) {
if (entry.url != null && entry.url.equals(url)) {
return entry;
}
}
return null;
}
Entry findById(String id) {
for (Entry entry : entries) {
if (entry.id != null && entry.id.equals(id)) {
return entry;
}
}
return null;
}
void put(Entry entry) {
for (int i = 0; i < entries.size(); i++) {
Entry current = entries.get(i);
if (current.url != null && current.url.equals(entry.url)) {
entries.set(i, entry);
return;
}
}
entries.add(entry);
}
boolean removeById(String id) {
return entries.removeIf(entry -> id.equals(entry.id));
}
}
@@ -0,0 +1,96 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
final class ManifestWrite implements AutoCloseable {
private final Path staged;
private final Path target;
private final Path rollback;
private boolean published;
ManifestWrite(Path staged, Path target, Path rollback) {
this.staged = staged;
this.target = target;
this.rollback = rollback;
}
void publish() throws IOException {
if (published) {
throw new IllegalStateException("Datapack manifest write was already published");
}
try {
DatapackSupport.move(staged, target);
published = true;
} catch (IOException publishFailure) {
try {
restoreOriginal();
} catch (IOException restoreFailure) {
publishFailure.addSuppressed(restoreFailure);
}
throw publishFailure;
}
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent"));
}
boolean published() {
return published;
}
void discard() throws IOException {
IOException failure = null;
try {
Files.deleteIfExists(staged);
} catch (IOException cleanupFailure) {
failure = cleanupFailure;
}
if (rollback != null) {
try {
Files.deleteIfExists(rollback);
} catch (IOException cleanupFailure) {
if (failure == null) {
failure = cleanupFailure;
} else {
failure.addSuppressed(cleanupFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
@Override
public void close() throws IOException {
discard();
}
private void restoreOriginal() throws IOException {
if (rollback == null) {
Files.deleteIfExists(target);
} else {
DatapackSupport.move(rollback, target);
}
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent"));
}
}
@@ -0,0 +1,24 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.nio.file.Path;
record MetadataEntry(Path path, String relativePath) {
}
@@ -0,0 +1,69 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.util.List;
final class Ownership {
final int schemaVersion;
final String id;
final String url;
final String versionId;
final String versionNumber;
final String sha1;
final String contentHash;
final List<String> structureKeys;
final List<String> templateKeys;
Ownership(
int schemaVersion,
String id,
String url,
String versionId,
String versionNumber,
String sha1,
String contentHash,
List<String> structureKeys,
List<String> templateKeys
) {
this.schemaVersion = schemaVersion;
this.id = id;
this.url = url;
this.versionId = versionId;
this.versionNumber = versionNumber;
this.sha1 = sha1;
this.contentHash = contentHash;
this.structureKeys = List.copyOf(structureKeys);
this.templateKeys = List.copyOf(templateKeys);
}
Entry toEntry() {
Entry entry = new Entry();
entry.id = id;
entry.url = url;
entry.versionId = versionId;
entry.versionNumber = versionNumber;
entry.sha1 = sha1;
entry.structureKeys = DatapackManifestStore.normalizeKeys(structureKeys);
entry.templateKeys = DatapackManifestStore.normalizeKeys(templateKeys);
return entry;
}
}
@@ -0,0 +1,28 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.util.List;
record PackResources(
List<String> structureKeys,
List<String> structureSetKeys,
List<String> templateKeys
) {
}
@@ -0,0 +1,29 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
record PreparedEditableImport(
File dataFolder,
StructureTransactionWriter.PreparedRemoval removal
) {
}
@@ -0,0 +1,26 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
enum RemoveOutcome {
REMOVED,
REJECTED,
FAILED_AFTER_MUTATION
}
@@ -0,0 +1,27 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
record StagingInspection(
boolean usable,
boolean ownershipCorrected,
boolean manifestChanged
) {
}
@@ -0,0 +1,31 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import java.util.List;
final class StartupValidationCache {
int schemaVersion;
String minecraftVersion;
int irisVersion;
boolean autoIngest;
boolean stripOverrides;
List<String> urls;
String localFingerprint;
}
@@ -0,0 +1,248 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
final class VerifiedStagingInstall {
private final Path normalizedRoot;
private final Path realRoot;
private final String rootIdentity;
private final Path normalizedStagingRoot;
private final Path realStagingRoot;
private final String stagingRootIdentity;
private final Path normalizedSource;
private final Path realSource;
private final String sourceIdentity;
private final String id;
private final String url;
private final String versionId;
private final String versionNumber;
private final String sha1;
private final String desiredHash;
private final Entry committedEntrySnapshot;
private final boolean legacyReplacementAuthorized;
private final DatapackStagingGuard.LegacyStagingSnapshot legacyStagingSnapshot;
private boolean consumed;
VerifiedStagingInstall(
Path normalizedRoot,
Path normalizedStagingRoot,
Path normalizedSource,
Entry entry,
String desiredHash,
Entry committedEntrySnapshot,
boolean legacyReplacementAuthorized,
DatapackStagingGuard.LegacyStagingSnapshot legacyStagingSnapshot
) throws IOException {
this.normalizedRoot = normalizedRoot;
this.realRoot = normalizedRoot.toRealPath();
this.rootIdentity = DatapackSupport.directoryIdentity(normalizedRoot.toFile());
this.normalizedStagingRoot = normalizedStagingRoot;
this.realStagingRoot = normalizedStagingRoot.toRealPath();
this.stagingRootIdentity = DatapackSupport.directoryIdentity(normalizedStagingRoot.toFile());
this.normalizedSource = normalizedSource;
this.realSource = normalizedSource.toRealPath();
this.sourceIdentity = DatapackSupport.directoryIdentity(normalizedSource.toFile());
this.id = entry.id;
this.url = entry.url;
this.versionId = entry.versionId;
this.versionNumber = entry.versionNumber;
this.sha1 = entry.sha1;
this.desiredHash = desiredHash;
this.committedEntrySnapshot = committedEntrySnapshot == null ? null : DatapackManifestStore.copyEntry(committedEntrySnapshot);
this.legacyReplacementAuthorized = legacyReplacementAuthorized;
this.legacyStagingSnapshot = legacyStagingSnapshot;
}
Path stagingRoot() {
return normalizedStagingRoot;
}
boolean hasStablePathIdentities() {
return !rootIdentity.isEmpty()
&& !stagingRootIdentity.isEmpty()
&& !sourceIdentity.isEmpty();
}
boolean isCanonicalInstall(File installRoot, File target) {
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
return suppliedRoot.equals(normalizedStagingRoot)
&& suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize());
}
void verifyStagingRoot() throws IOException {
verifyPathIdentity(normalizedRoot, realRoot, rootIdentity, "datapack storage root");
verifyPathIdentity(
normalizedStagingRoot, realStagingRoot, stagingRootIdentity, "datapack staging root");
if (!Objects.equals(normalizedStagingRoot.getParent(), normalizedRoot)
|| !Files.isSameFile(normalizedStagingRoot.getParent(), normalizedRoot)) {
throw new IOException("Verified datapack staging root changed identity");
}
}
boolean consume(
File source,
File installRoot,
File target,
Entry entry,
String stagedHash
) throws IOException {
if (consumed) {
throw new IOException("Verified datapack staging authorization was already consumed");
}
consumed = true;
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
if (!suppliedRoot.equals(normalizedStagingRoot)
|| !suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize())) {
throw new IOException("Verified datapack staging authorization was paired with a different path");
}
verifyAuthority(source, entry, stagedHash);
return legacyReplacementAuthorized;
}
boolean authorizeLegacyWorldReplacement(
File source,
File installRoot,
File target,
Entry entry,
String stagedHash,
String currentHash,
String currentMarkerHash
) throws IOException {
if (legacyStagingSnapshot == null
|| !"absent".equals(currentMarkerHash)
|| !Objects.equals(currentHash, legacyStagingSnapshot.contentHash())
|| legacyStagingSnapshot.targetIdentity().isEmpty()) {
return false;
}
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
if (!Objects.equals(suppliedTarget.getParent(), suppliedRoot)
|| !Objects.equals(suppliedTarget.getFileName().toString(), id)
|| Files.isSameFile(suppliedRoot, normalizedStagingRoot)
|| Files.isSameFile(suppliedTarget, legacyStagingSnapshot.normalizedTarget())) {
return false;
}
verifyAuthority(source, entry, stagedHash);
verifyLegacyWorldSnapshot();
return true;
}
private void verifyAuthority(File source, Entry entry, String stagedHash) throws IOException {
verifyAuthorityState();
Path suppliedSource = source.toPath().toAbsolutePath().normalize();
if (!suppliedSource.equals(normalizedSource)) {
throw new IOException("Verified datapack staging authorization was paired with a different path");
}
if (!Objects.equals(entry.id, id)
|| !Objects.equals(entry.url, url)
|| !Objects.equals(entry.versionId, versionId)
|| !Objects.equals(entry.versionNumber, versionNumber)
|| !Objects.equals(entry.sha1, sha1)
|| !Objects.equals(stagedHash, desiredHash)) {
throw new IOException("Verified datapack staging authorization was paired with different metadata");
}
}
private void verifyAuthorityState() throws IOException {
verifyStagingRoot();
verifyPathIdentity(normalizedSource, realSource, sourceIdentity, "verified datapack extraction");
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(normalizedRoot.toFile());
Entry committed = committedManifest.findById(id);
if (!manifestEntrySnapshotMatches(committed, committedEntrySnapshot)) {
throw new IOException("Committed datapack staging authority changed before installation");
}
if (committedEntrySnapshot != null && !legacyReplacementAuthorized) {
throw new IOException("Committed datapack staging authority conflicts with " + id);
}
}
private static boolean manifestEntrySnapshotMatches(Entry current, Entry expected) {
if (current == null || expected == null) {
return current == expected;
}
return Objects.equals(current.id, expected.id)
&& Objects.equals(current.url, expected.url)
&& Objects.equals(current.versionId, expected.versionId)
&& Objects.equals(current.versionNumber, expected.versionNumber)
&& Objects.equals(current.sha1, expected.sha1)
&& Objects.equals(current.filename, expected.filename)
&& Objects.equals(current.etag, expected.etag)
&& Objects.equals(current.lastModified, expected.lastModified)
&& current.installedEpoch == expected.installedEpoch
&& current.structuresImported == expected.structuresImported
&& DatapackManifestStore.copyList(current.structureKeys).equals(DatapackManifestStore.copyList(expected.structureKeys))
&& DatapackManifestStore.copyList(current.templateKeys).equals(DatapackManifestStore.copyList(expected.templateKeys))
&& Objects.equals(current.importedTargets, expected.importedTargets)
&& Objects.equals(current.importAttempts, expected.importAttempts)
&& Objects.equals(current.importedBundles, expected.importedBundles);
}
void verifyLegacyWorldSnapshot() throws IOException {
verifyAuthorityState();
if (legacyStagingSnapshot == null || legacyStagingSnapshot.targetIdentity().isEmpty()) {
throw new IOException("Missing stable canonical legacy datapack staging snapshot for " + id);
}
if (!Objects.equals(legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !Files.isSameFile(
legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !DatapackScratchRecovery.sameScratchVolume(
legacyStagingSnapshot.normalizedTarget(), normalizedStagingRoot)) {
throw new IOException("Changed or unsafe canonical legacy datapack staging target for " + id);
}
DatapackInstallPlanner.verifyDirectorySnapshot(
legacyStagingSnapshot.normalizedTarget().toFile(),
normalizedStagingRoot.toFile(),
legacyStagingSnapshot.contentHash(),
legacyStagingSnapshot.markerHash(),
legacyStagingSnapshot.targetIdentity(),
"canonical legacy datapack staging target"
);
if (!Objects.equals(
legacyStagingSnapshot.normalizedTarget().toRealPath(),
legacyStagingSnapshot.realTarget())) {
throw new IOException("Changed canonical legacy datapack staging target identity for " + id);
}
}
private static void verifyPathIdentity(
Path normalized,
Path expectedReal,
String expectedIdentity,
String purpose
) throws IOException {
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), expectedReal)
|| (!expectedIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(normalized.toFile()), expectedIdentity))) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
}
File diff suppressed because it is too large. Load diff