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

@@ -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)) {