Guarded ingest pack replacement

- Mod ingest compares source and target by real path and file identity, so a differently cased target on a case-insensitive filesystem can no longer resolve to and delete the source pack
- replace=true only replaces a target that contains ingest/manifest.json
- Ingest builds into a hidden staging directory and swaps it in atomically, deleting the previous output only after the new pack is complete
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-14 21:02:29 -04:00
1 parent 3ba6d5b9c1
commit 191d9e88e6
2 files changed
+103 -20

No files matched your search

@@ -42,6 +42,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;
public final class ModIngester {
private static final Set<String> RESERVED_NAMESPACES = Set.of("minecraft", "c", "iris");
@@ -58,18 +59,17 @@ public final class ModIngester {
throw new IOException("Source pack " + source + " was written by mod ingest; ingest from its original pack instead.");
}
Set<String> namespaces = selectNamespaces(request);
prepareTarget(target, request.replaceTarget());
requireReplaceableTarget(target, request.replaceTarget());
String targetKey = target.getFileName().toString();
IrisProjectCopier.copyProject(source.toFile(), target.getParent().toFile(), request.dimensionKey(), targetKey);
Files.createDirectories(target.getParent());
Path workspace = Files.createTempDirectory(target.getParent(), "." + targetKey + ".ingest-");
try {
return populate(request, target, targetKey, namespaces);
} catch (IOException | RuntimeException e) {
try {
AtomicDirectoryPublisher.deleteTree(target);
} catch (IOException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
throw e;
IrisProjectCopier.copyProject(source.toFile(), workspace.toFile(), request.dimensionKey(), targetKey);
ModIngestReport built = populate(request, workspace.resolve(targetKey), targetKey, namespaces);
publish(workspace.resolve(targetKey), target, request.replaceTarget());
return new ModIngestReport(target, built.items(), built.summaryLines());
} finally {
AtomicDirectoryPublisher.deleteTree(workspace);
}
}
@@ -134,24 +134,53 @@ public final class ModIngester {
}
private static void requireSeparate(Path source, Path target) throws IOException {
Path realSource = Files.exists(source) ? source.toRealPath() : source;
if (target.getParent() == null) {
throw new IOException("Target pack needs a parent directory: " + target);
}
Path realSource = Files.exists(source) ? source.toRealPath() : source;
List<Path> targets = new ArrayList<>(List.of(target));
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
targets.add(target.toRealPath());
if (Files.exists(source) && Files.isSameFile(source, target)) {
throw new IOException("Target pack " + target + " is the source pack " + realSource);
}
} else if (Files.exists(target.getParent())) {
targets.add(target.getParent().toRealPath().resolve(target.getFileName()));
}
for (Path candidate : List.of(source, realSource)) {
if (candidate.equals(target) || target.startsWith(candidate) || candidate.startsWith(target)) {
throw new IOException("Target pack " + target + " overlaps the source pack " + candidate);
for (Path resolvedTarget : targets) {
if (candidate.equals(resolvedTarget) || resolvedTarget.startsWith(candidate) || candidate.startsWith(resolvedTarget)) {
throw new IOException("Target pack " + target + " overlaps the source pack " + candidate);
}
}
}
}
private static void prepareTarget(Path target, boolean replace) throws IOException {
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
if (!replace) {
throw new FileAlreadyExistsException(target.toString());
}
AtomicDirectoryPublisher.deleteTree(target);
private static void requireReplaceableTarget(Path target, boolean replace) throws IOException {
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(target)) {
return;
}
if (!replace) {
throw new FileAlreadyExistsException(target.toString());
}
if (Files.isSymbolicLink(target)
|| !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)
|| !Files.isRegularFile(target.resolve(IngestManifest.PATH), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Iris only replaces packs written by mod ingest, and " + target + " has no "
+ IngestManifest.PATH + "; choose another target pack name.");
}
}
private static void publish(Path built, Path target, boolean replace) throws IOException {
Path staged = target.resolveSibling("." + target.getFileName() + ".staged-" + UUID.randomUUID());
Files.move(built, staged);
try (AtomicDirectoryPublisher.Publication publication = replace
? AtomicDirectoryPublisher.publish(staged, target)
: AtomicDirectoryPublisher.publishAbsent(staged, target)) {
publication.commit();
publication.cleanupBackup();
} finally {
AtomicDirectoryPublisher.deleteTree(staged);
}
Files.createDirectories(target.getParent());
}
}
@@ -111,6 +111,54 @@ public class ModIngesterPackTest {
ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), true));
assertEquals(first, snapshot(target));
assertEquals(List.of("modded"), directoryNames(root.resolve("packs")));
}
@Test
public void replaceRefusesAPackThatWasNotWrittenByIngest() throws Exception {
Path root = folder.getRoot().toPath();
Path source = ModIngestFixture.writeSourcePack(root);
Path handmade = root.resolve("packs").resolve("handmade");
ModIngestFixture.write(handmade, "dimensions/handmade.json", "{\"name\": \"Handmade\"}");
IOException refused = assertThrows(IOException.class, () -> ModIngester.ingest(
ModIngestFixture.request(source, handmade, Set.of(ModIngestFixture.MOD), true)));
assertTrue(refused.getMessage(), refused.getMessage().contains("ingest/manifest.json"));
assertEquals(Map.of("dimensions/handmade.json", "{\"name\": \"Handmade\"}"), snapshot(handmade));
}
@Test
public void replaceNeverDeletesTheSourcePackThroughAnotherSpelling() throws Exception {
Path root = folder.getRoot().toPath();
Path source = ModIngestFixture.writeSourcePack(root);
Map<String, String> before = snapshot(source);
Path respelled = root.resolve("SOURCE");
boolean caseInsensitive = Files.isDirectory(respelled);
if (caseInsensitive) {
assertThrows(IOException.class, () -> ModIngester.ingest(
ModIngestFixture.request(source, respelled, Set.of(ModIngestFixture.MOD), true)));
} else {
ModIngester.ingest(ModIngestFixture.request(source, respelled, Set.of(ModIngestFixture.MOD), true));
}
assertEquals(before, snapshot(source));
}
@Test
public void failedReplaceKeepsThePreviousOutput() throws Exception {
Path root = folder.getRoot().toPath();
Path source = ModIngestFixture.writeSourcePack(root);
Path target = root.resolve("packs").resolve("modded");
ModIngester.ingest(ModIngestFixture.request(source, target, Set.of(ModIngestFixture.MOD), false));
Map<String, String> first = snapshot(target);
assertThrows(IOException.class, () -> ModIngester.ingest(new ModIngestRequest(
source, target, "missing", ModIngestFixture.catalog(), Set.of(ModIngestFixture.MOD), true)));
assertEquals(first, snapshot(target));
assertEquals(List.of("modded"), directoryNames(root.resolve("packs")));
}
@Test
@@ -155,6 +203,12 @@ public class ModIngesterPackTest {
assertTrue(report.items().stream().noneMatch(item -> item.namespace().equals("minecraft")));
}
private static List<String> directoryNames(Path root) throws IOException {
try (Stream<Path> entries = Files.list(root)) {
return entries.map((Path entry) -> entry.getFileName().toString()).sorted().toList();
}
}
private static Map<String, String> snapshot(Path root) throws IOException {
Map<String, String> files = new TreeMap<>();
try (Stream<Path> walk = Files.walk(root)) {