mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-18 14:34:05 +00:00
Failed closed on unreadable takeovers
- Modded config reports unparseable files, non-object takeover sections, and invalid takeover entries instead of returning silent defaults - Takeover reconciliation refuses startup when takeovers are unreadable and the world has an applied takeover state file - Config writes are refused while takeovers are unreadable so fallback defaults never overwrite the operator's file
This commit is contained in:
@@ -24,8 +24,11 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public final class ModdedModConfig {
|
||||
private static final Object LOCK = new Object();
|
||||
@@ -39,17 +42,19 @@ public final class ModdedModConfig {
|
||||
private final long mainWorldSeed;
|
||||
private final boolean mainWorldAutoRestart;
|
||||
private final Map<String, String> dimensionTakeovers;
|
||||
private final List<String> takeoverProblems;
|
||||
|
||||
private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld,
|
||||
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart,
|
||||
Map<String, String> dimensionTakeovers) {
|
||||
Takeovers takeovers) {
|
||||
this.defaultPack = defaultPack;
|
||||
this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim();
|
||||
this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld;
|
||||
this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim();
|
||||
this.mainWorldSeed = mainWorldSeed;
|
||||
this.mainWorldAutoRestart = mainWorldAutoRestart;
|
||||
this.dimensionTakeovers = Map.copyOf(dimensionTakeovers);
|
||||
this.dimensionTakeovers = Map.copyOf(takeovers.entries());
|
||||
this.takeoverProblems = List.copyOf(takeovers.problems());
|
||||
}
|
||||
|
||||
public static ModdedModConfig get() {
|
||||
@@ -68,9 +73,9 @@ public final class ModdedModConfig {
|
||||
|
||||
public static void setPrimaryWorld(String dimensionId) {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = get();
|
||||
ModdedModConfig current = writable();
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld,
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.dimensionTakeovers);
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.takeovers());
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
}
|
||||
@@ -78,9 +83,9 @@ public final class ModdedModConfig {
|
||||
|
||||
public static void setMainWorld(String packRef, long seed) {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = get();
|
||||
ModdedModConfig current = writable();
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
|
||||
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.dimensionTakeovers);
|
||||
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.takeovers());
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
}
|
||||
@@ -122,16 +127,59 @@ public final class ModdedModConfig {
|
||||
return dimensionTakeovers;
|
||||
}
|
||||
|
||||
public List<String> takeoverProblems() {
|
||||
return takeoverProblems;
|
||||
}
|
||||
|
||||
static ModdedModConfig read(Path file) {
|
||||
ModdedModConfig defaults = defaults();
|
||||
JSONObject json;
|
||||
try {
|
||||
json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException | IOException unreadable) {
|
||||
ModdedIrisLog.error("Iris modded config at {} is unreadable; using defaults", file, unreadable);
|
||||
return new ModdedModConfig(defaults.defaultPack, defaults.primaryWorld, defaults.routePlayersToPrimaryWorld,
|
||||
defaults.mainWorldPack, defaults.mainWorldSeed, defaults.mainWorldAutoRestart,
|
||||
new Takeovers(Map.of(), List.of("Iris could not read " + file + ": " + unreadable.getMessage())));
|
||||
}
|
||||
return new ModdedModConfig(
|
||||
json.optString("defaultPack", defaults.defaultPack),
|
||||
json.optString("primaryWorld", defaults.primaryWorld),
|
||||
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
|
||||
json.optString("mainWorldPack", defaults.mainWorldPack),
|
||||
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
|
||||
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
|
||||
readDimensionTakeovers(json, file));
|
||||
}
|
||||
|
||||
private Takeovers takeovers() {
|
||||
return new Takeovers(dimensionTakeovers, takeoverProblems);
|
||||
}
|
||||
|
||||
private static ModdedModConfig writable() {
|
||||
ModdedModConfig current = get();
|
||||
if (!current.takeoverProblems.isEmpty()) {
|
||||
throw new IllegalStateException("Iris will not rewrite " + configFile()
|
||||
+ " while its dimension takeovers are unreadable: " + String.join("; ", current.takeoverProblems)
|
||||
+ ". Fix the file and restart first.");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static ModdedModConfig defaults() {
|
||||
return new ModdedModConfig("overworld", "", true, "", 0L, false, new Takeovers(Map.of(), List.of()));
|
||||
}
|
||||
|
||||
private static boolean updateDimensionTakeover(String levelKey, String packRef) {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = get();
|
||||
ModdedModConfig current = writable();
|
||||
Map<String, String> takeovers = new TreeMap<>(current.dimensionTakeovers);
|
||||
String previous = packRef == null ? takeovers.remove(levelKey) : takeovers.put(levelKey, packRef);
|
||||
if (packRef == null ? previous == null : packRef.equals(previous)) {
|
||||
return false;
|
||||
}
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, takeovers);
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Takeovers(takeovers, List.of()));
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
return true;
|
||||
@@ -144,41 +192,41 @@ public final class ModdedModConfig {
|
||||
|
||||
private static ModdedModConfig load() {
|
||||
Path file = configFile();
|
||||
ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false, Map.of());
|
||||
if (!Files.isRegularFile(file)) {
|
||||
ModdedModConfig defaults = defaults();
|
||||
write(file, defaults);
|
||||
return defaults;
|
||||
}
|
||||
try {
|
||||
JSONObject json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
|
||||
return new ModdedModConfig(
|
||||
json.optString("defaultPack", defaults.defaultPack),
|
||||
json.optString("primaryWorld", defaults.primaryWorld),
|
||||
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
|
||||
json.optString("mainWorldPack", defaults.mainWorldPack),
|
||||
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
|
||||
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
|
||||
readDimensionTakeovers(json.optJSONObject(DIMENSION_TAKEOVERS), file));
|
||||
} catch (RuntimeException | IOException e) {
|
||||
ModdedIrisLog.error("Iris modded config at {} is invalid; using defaults", file, e);
|
||||
return defaults;
|
||||
}
|
||||
return read(file);
|
||||
}
|
||||
|
||||
private static Map<String, String> readDimensionTakeovers(JSONObject json, Path file) {
|
||||
if (json == null) {
|
||||
return Map.of();
|
||||
private static Takeovers readDimensionTakeovers(JSONObject json, Path file) {
|
||||
if (!json.has(DIMENSION_TAKEOVERS) || json.isNull(DIMENSION_TAKEOVERS)) {
|
||||
return new Takeovers(Map.of(), List.of());
|
||||
}
|
||||
JSONObject section = json.optJSONObject(DIMENSION_TAKEOVERS);
|
||||
if (section == null) {
|
||||
return new Takeovers(Map.of(), List.of("Iris could not read " + DIMENSION_TAKEOVERS + " in " + file
|
||||
+ ": it must be an object mapping dimension keys to pack references"));
|
||||
}
|
||||
Map<String, String> takeovers = new TreeMap<>();
|
||||
for (String levelKey : json.keySet()) {
|
||||
List<String> problems = new ArrayList<>();
|
||||
for (String levelKey : new TreeSet<>(section.keySet())) {
|
||||
Object value = section.opt(levelKey);
|
||||
try {
|
||||
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, json.optString(levelKey, ""));
|
||||
if (!(value instanceof String packRef)) {
|
||||
throw new IllegalArgumentException("the pack reference must be a string");
|
||||
}
|
||||
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, packRef);
|
||||
takeovers.put(takeover.levelKey(), takeover.packRef());
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
ModdedIrisLog.error("Iris ignored dimension takeover '{}' in {}: {}", levelKey, file, invalid.getMessage());
|
||||
problems.add("Iris could not read dimension takeover '" + levelKey + "' in " + file + ": " + invalid.getMessage());
|
||||
}
|
||||
}
|
||||
return takeovers;
|
||||
for (String problem : problems) {
|
||||
ModdedIrisLog.error(problem);
|
||||
}
|
||||
return new Takeovers(takeovers, problems);
|
||||
}
|
||||
|
||||
private static void write(Path file, ModdedModConfig config) {
|
||||
@@ -201,4 +249,7 @@ public final class ModdedModConfig {
|
||||
ModdedIrisLog.error("Iris failed to write modded config at {}", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private record Takeovers(Map<String, String> entries, List<String> problems) {
|
||||
}
|
||||
}
|
||||
|
||||
+19
-3
@@ -44,7 +44,8 @@ public final class ModdedTakeoverReconciler {
|
||||
|
||||
public static void reconcileEarly() {
|
||||
try {
|
||||
Map<String, String> configured = ModdedModConfig.get().dimensionTakeovers();
|
||||
ModdedModConfig config = ModdedModConfig.get();
|
||||
Map<String, String> configured = config.dimensionTakeovers();
|
||||
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
|
||||
if (worldRoot == null) {
|
||||
if (!configured.isEmpty()) {
|
||||
@@ -54,7 +55,7 @@ public final class ModdedTakeoverReconciler {
|
||||
return;
|
||||
}
|
||||
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
|
||||
Result result = reconcile(worldRoot, configured, recoveryParent);
|
||||
Result result = reconcile(worldRoot, configured, config.takeoverProblems(), recoveryParent);
|
||||
if (result.changedLevels().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -85,7 +86,9 @@ public final class ModdedTakeoverReconciler {
|
||||
}
|
||||
}
|
||||
|
||||
static Result reconcile(Path worldRoot, Map<String, String> configured, Path recoveryParent) throws IOException {
|
||||
static Result reconcile(Path worldRoot, Map<String, String> configured, List<String> configProblems,
|
||||
Path recoveryParent) throws IOException {
|
||||
requireReadableConfig(worldRoot, configProblems);
|
||||
Map<String, String> applied = readApplied(worldRoot);
|
||||
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, configured);
|
||||
if (changed.isEmpty()) {
|
||||
@@ -104,6 +107,19 @@ public final class ModdedTakeoverReconciler {
|
||||
return new Result(changed, moved.isEmpty() ? null : recovery);
|
||||
}
|
||||
|
||||
private static void requireReadableConfig(Path worldRoot, List<String> configProblems) throws IOException {
|
||||
if (configProblems.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Path state = worldRoot.resolve(STATE_FILE);
|
||||
if (!Files.exists(state)) {
|
||||
return;
|
||||
}
|
||||
throw new IOException("Iris refused to reconcile dimension takeovers because the configured takeovers are unreadable while "
|
||||
+ state + " records applied takeovers: " + String.join("; ", configProblems)
|
||||
+ ". Fix the modded config so every takeover entry is valid; Iris never treats an unreadable takeover as removed.");
|
||||
}
|
||||
|
||||
static Map<String, String> readApplied(Path worldRoot) throws IOException {
|
||||
Path state = worldRoot.resolve(STATE_FILE);
|
||||
if (!Files.isRegularFile(state)) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 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.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedModConfigTest {
|
||||
@Test
|
||||
public void validTakeoversLoadWithoutProblems() throws IOException {
|
||||
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld:sky\"}}");
|
||||
try {
|
||||
ModdedModConfig config = ModdedModConfig.read(file);
|
||||
|
||||
assertEquals(Map.of("aether:the_aether", "overworld:sky"), config.dimensionTakeovers());
|
||||
assertEquals(List.of(), config.takeoverProblems());
|
||||
} finally {
|
||||
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unparseableConfigReportsAProblemNamingTheFile() throws IOException {
|
||||
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": ");
|
||||
try {
|
||||
ModdedModConfig config = ModdedModConfig.read(file);
|
||||
|
||||
assertEquals(Map.of(), config.dimensionTakeovers());
|
||||
assertEquals(1, config.takeoverProblems().size());
|
||||
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains(file.toString()));
|
||||
} finally {
|
||||
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidTakeoverEntryIsReportedInsteadOfSilentlyDropped() throws IOException {
|
||||
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld\", \"Broken Key\": \"overworld\"}}");
|
||||
try {
|
||||
ModdedModConfig config = ModdedModConfig.read(file);
|
||||
|
||||
assertEquals(Map.of("aether:the_aether", "overworld"), config.dimensionTakeovers());
|
||||
assertEquals(1, config.takeoverProblems().size());
|
||||
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains("Broken Key"));
|
||||
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains(file.toString()));
|
||||
} finally {
|
||||
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void takeoverSectionThatIsNotAnObjectIsReported() throws IOException {
|
||||
Path file = config("{\"dimensionTakeovers\": [\"aether:the_aether\"]}");
|
||||
try {
|
||||
ModdedModConfig config = ModdedModConfig.read(file);
|
||||
|
||||
assertEquals(Map.of(), config.dimensionTakeovers());
|
||||
assertEquals(1, config.takeoverProblems().size());
|
||||
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains("dimensionTakeovers"));
|
||||
} finally {
|
||||
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
|
||||
}
|
||||
}
|
||||
|
||||
private static Path config(String json) throws IOException {
|
||||
Path file = Files.createTempDirectory("iris-modded-config").resolve("modded.json");
|
||||
Files.writeString(file, json, StandardCharsets.UTF_8);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
+58
-4
@@ -33,6 +33,7 @@ import java.util.stream.Stream;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedTakeoverReconcilerTest {
|
||||
@@ -64,7 +65,7 @@ public class ModdedTakeoverReconcilerTest {
|
||||
touch(worldRoot.resolve("players/data/player.dat"));
|
||||
|
||||
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent);
|
||||
worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent);
|
||||
|
||||
assertEquals(Set.of("iristest:skylands"), result.changedLevels());
|
||||
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) {
|
||||
@@ -92,14 +93,14 @@ public class ModdedTakeoverReconcilerTest {
|
||||
touch(region);
|
||||
|
||||
ModdedTakeoverReconciler.Result unchanged = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent);
|
||||
worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent);
|
||||
|
||||
assertEquals(Set.of(), unchanged.changedLevels());
|
||||
assertNull(unchanged.recovery());
|
||||
assertTrue(Files.isRegularFile(region));
|
||||
|
||||
ModdedTakeoverReconciler.Result removed = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of(), recoveryParent);
|
||||
worldRoot, Map.of(), List.of(), recoveryParent);
|
||||
|
||||
assertEquals(Set.of("iristest:skylands"), removed.changedLevels());
|
||||
assertFalse(Files.exists(region));
|
||||
@@ -119,7 +120,7 @@ public class ModdedTakeoverReconcilerTest {
|
||||
Files.createDirectories(worldRoot);
|
||||
|
||||
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of("aether:the_aether", "overworld"), recoveryParent);
|
||||
worldRoot, Map.of("aether:the_aether", "overworld"), List.of(), recoveryParent);
|
||||
|
||||
assertEquals(Set.of("aether:the_aether"), result.changedLevels());
|
||||
assertNull(result.recovery());
|
||||
@@ -130,6 +131,59 @@ public class ModdedTakeoverReconcilerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unreadableConfigWithRecordedTakeoversRefusesWithoutMovingAnything() throws IOException {
|
||||
Path temporary = Files.createTempDirectory("iris-takeover-unreadable");
|
||||
Path worldRoot = temporary.resolve("world");
|
||||
Path recoveryParent = temporary.resolve("config");
|
||||
try {
|
||||
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
|
||||
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld"));
|
||||
touch(region);
|
||||
List<String> problems = List.of("Iris could not read /srv/config/irisworldgen/modded.json: Unterminated object");
|
||||
|
||||
IOException refused = assertThrows(IOException.class, () -> ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of(), problems, recoveryParent));
|
||||
|
||||
assertTrue(refused.getMessage(), refused.getMessage().contains("modded.json"));
|
||||
assertTrue(Files.isRegularFile(region));
|
||||
assertFalse(Files.exists(recoveryParent));
|
||||
assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
|
||||
} finally {
|
||||
deleteTree(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unreadableConfigWithAnEmptyStateFileStillRefuses() throws IOException {
|
||||
Path temporary = Files.createTempDirectory("iris-takeover-unreadable-empty");
|
||||
Path worldRoot = temporary.resolve("world");
|
||||
try {
|
||||
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of());
|
||||
|
||||
assertThrows(IOException.class, () -> ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config")));
|
||||
} finally {
|
||||
deleteTree(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unreadableConfigWithoutRecordedStateProceeds() throws IOException {
|
||||
Path temporary = Files.createTempDirectory("iris-takeover-unreadable-fresh");
|
||||
Path worldRoot = temporary.resolve("world");
|
||||
try {
|
||||
Files.createDirectories(worldRoot);
|
||||
|
||||
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config"));
|
||||
|
||||
assertEquals(Set.of(), result.changedLevels());
|
||||
} finally {
|
||||
deleteTree(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void touch(Path file) throws IOException {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, "x", StandardCharsets.UTF_8);
|
||||
|
||||
Reference in New Issue
Block a user