diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java index 305fe1f21..1ca092beb 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java @@ -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 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 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 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()); } } diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java index 9ab8e18b9..2d40db8e0 100644 --- a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java @@ -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 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 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 directoryNames(Path root) throws IOException { + try (Stream entries = Files.list(root)) { + return entries.map((Path entry) -> entry.getFileName().toString()).sorted().toList(); + } + } + private static Map snapshot(Path root) throws IOException { Map files = new TreeMap<>(); try (Stream walk = Files.walk(root)) {