diff --git a/src/main/java/me/glaremasters/guilds/database/CorruptDataBackup.java b/src/main/java/me/glaremasters/guilds/database/CorruptDataBackup.java new file mode 100644 index 000000000..be64f11e9 --- /dev/null +++ b/src/main/java/me/glaremasters/guilds/database/CorruptDataBackup.java @@ -0,0 +1,270 @@ +package me.glaremasters.guilds.database; + +import me.glaremasters.guilds.utils.LoggingUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * Keeps a copy of data the plugin could not read. + * + *
A file the plugin cannot parse is often the only copy of that record. The plugin has no + * in-memory version of it, so anything it wrote in its place would be a guess. Rather than + * discarding the file or writing over it, the file is copied to a backup directory and the + * original is left exactly where the operator put it. + * + *
The backups live outside every data folder the plugin reads, under
+ * {@code plugins/Guilds/backups/corrupt/ The timestamp comes from the file's own last-modified time rather than from the moment the
+ * backup was taken. An unchanged file therefore always lands in the same directory, and a second
+ * read of the same corruption finds the copy that is already there instead of making another one.
+ * The same holds across a restart.
+ */
+public final class CorruptDataBackup {
+
+ /**
+ * The directory under the plugin's data folder that holds the backups.
+ */
+ public static final String BACKUP_DIRECTORY = "backups/corrupt";
+
+ /**
+ * The timestamp format used for a backup directory.
+ */
+ private static final DateTimeFormatter TIMESTAMP =
+ DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").withZone(ZoneId.systemDefault());
+
+ private CorruptDataBackup() {
+ throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
+ }
+
+ /**
+ * What happened when a backup was attempted.
+ */
+ public static final class Result {
+
+ private final boolean created;
+ private final boolean skipped;
+ private final boolean failed;
+ private final File backupFile;
+ private final String failure;
+
+ private Result(boolean created, boolean skipped, boolean failed, File backupFile, String failure) {
+ this.created = created;
+ this.skipped = skipped;
+ this.failed = failed;
+ this.backupFile = backupFile;
+ this.failure = failure;
+ }
+
+ /**
+ * Whether a new copy was written.
+ *
+ * @return true when a backup file was created
+ */
+ public boolean isCreated() {
+ return created;
+ }
+
+ /**
+ * Whether an identical backup was already there.
+ *
+ * @return true when the corruption had already been backed up
+ */
+ public boolean isSkipped() {
+ return skipped;
+ }
+
+ /**
+ * Whether the backup could not be written.
+ *
+ * @return true when the backup failed
+ */
+ public boolean isFailed() {
+ return failed;
+ }
+
+ /**
+ * Where the backup is.
+ *
+ * @return the backup file, or null when the backup failed
+ */
+ public File getBackupFile() {
+ return backupFile;
+ }
+
+ /**
+ * Why the backup failed.
+ *
+ * @return the reason, or null when the backup succeeded
+ */
+ public String getFailure() {
+ return failure;
+ }
+ }
+
+ /**
+ * Copies a file the plugin could not read into the backup directory.
+ *
+ * The copy is byte for byte. The original file is not moved, not truncated and not
+ * overwritten: it is left exactly as it was, and a backup is a safeguard rather than
+ * permission to replace it.
+ *
+ * @param pluginDataFolder the plugin's data folder, {@code plugins/Guilds}
+ * @param sourceFile the file that could not be read
+ * @param dataType the kind of record the file holds, used as a directory name
+ * @return what happened
+ */
+ public static Result backup(File pluginDataFolder, File sourceFile, String dataType) {
+ if (pluginDataFolder == null || sourceFile == null || !sourceFile.isFile()) {
+ return new Result(false, false, true, null,
+ "there was no file to copy: " + (sourceFile == null ? "no file was given" : sourceFile.getAbsolutePath()));
+ }
+
+ final File directory = backupDirectory(pluginDataFolder, sourceFile, dataType);
+ final File original = new File(directory, sourceFile.getName());
+
+ // The same corruption, read again, must not produce a second copy.
+ if (original.isFile() && sameContent(original, sourceFile)) {
+ return new Result(false, true, false, original, null);
+ }
+
+ // Something with that name is already there and holds different data, so the copy needs a
+ // name of its own rather than the one that is taken.
+ File target = original;
+ if (target.exists()) {
+ target = freeName(directory, sourceFile.getName());
+ if (target == null) {
+ return new Result(false, false, true, null,
+ "the backup directory holds a file with that name and it could not be replaced: " + directory.getAbsolutePath());
+ }
+ }
+
+ try {
+ final Path source = sourceFile.toPath();
+ final Path destination = target.toPath();
+ Files.createDirectories(destination.getParent());
+ Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
+ return new Result(true, false, false, target, null);
+ } catch (IOException | RuntimeException ex) {
+ final String reason = ex.getMessage() == null ? ex.toString() : ex.getMessage();
+ LoggingUtils.severe("The backup of " + sourceFile.getAbsolutePath() + " could not be written to "
+ + directory.getAbsolutePath() + ". The file has been left untouched: " + reason, ex);
+ return new Result(false, false, true, null, reason);
+ }
+ }
+
+ /**
+ * Whether a backup file is a byte-for-byte copy of its source.
+ *
+ * @param sourceFile the original file
+ * @param backupFile the proposed backup
+ * @return true when both files exist and have the same contents
+ */
+ public static boolean isVerifiedCopy(File sourceFile, File backupFile) {
+ return sourceFile != null && sourceFile.isFile() && backupFile != null && backupFile.isFile()
+ && sameContent(sourceFile, backupFile);
+ }
+
+ /**
+ * The directory a backup of a file goes in.
+ *
+ * The timestamp is the file's own last-modified time, so an unchanged file always maps to
+ * the same directory and is only ever backed up once.
+ *
+ * @param pluginDataFolder the plugin's data folder
+ * @param sourceFile the file being backed up
+ * @param dataType the kind of record the file holds
+ * @return the directory
+ */
+ private static File backupDirectory(File pluginDataFolder, File sourceFile, String dataType) {
+ final long modified = sourceFile.lastModified();
+ final String timestamp = modified <= 0L
+ ? TIMESTAMP.format(Instant.now())
+ : TIMESTAMP.format(Instant.ofEpochMilli(modified));
+ return new File(new File(new File(pluginDataFolder, BACKUP_DIRECTORY), timestamp), safeName(dataType));
+ }
+
+ /**
+ * A name that can be used as a directory name.
+ *
+ * @param dataType the kind of record
+ * @return the name
+ */
+ private static String safeName(String dataType) {
+ if (dataType == null || dataType.trim().isEmpty()) {
+ return "unknown";
+ }
+ return dataType.replaceAll("[^A-Za-z0-9._-]", "_");
+ }
+
+ /**
+ * A free name for a backup inside its directory.
+ *
+ * The original name is kept, so the operator can tell what the file was. When something with
+ * that name is already there, a number is added until the name is free, so a backup never
+ * overwrites another one.
+ *
+ * @param directory the backup directory
+ * @param name the original file name
+ * @return the name to write, or null when none could be found
+ */
+ private static File freeName(File directory, String name) {
+ for (int suffix = 1; suffix < 1000; suffix++) {
+ final File candidate = new File(directory, name + "." + suffix);
+ if (!candidate.exists()) {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Whether two files hold the same bytes.
+ *
+ * @param first the first file
+ * @param second the second file
+ * @return true when their contents are identical
+ */
+ private static boolean sameContent(File first, File second) {
+ try {
+ return digest(first).equals(digest(second));
+ } catch (IOException | RuntimeException ex) {
+ return false;
+ }
+ }
+
+ /**
+ * The SHA-256 digest of a file's contents.
+ *
+ * @param file the file
+ * @return the digest, as hex
+ * @throws IOException if the file cannot be read
+ */
+ private static String digest(File file) throws IOException {
+ final MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IOException("SHA-256 is not available", ex);
+ }
+
+ final byte[] hash = digest.digest(Files.readAllBytes(file.toPath()));
+ final StringBuilder hex = new StringBuilder(hash.length * 2);
+ for (byte value : hash) {
+ hex.append(Character.forDigit((value >> 4) & 0xF, 16));
+ hex.append(Character.forDigit(value & 0xF, 16));
+ }
+ return hex.toString();
+ }
+}
diff --git a/src/main/java/me/glaremasters/guilds/database/JsonFileUtils.java b/src/main/java/me/glaremasters/guilds/database/JsonFileUtils.java
index 63f161fa5..e49c71eab 100644
--- a/src/main/java/me/glaremasters/guilds/database/JsonFileUtils.java
+++ b/src/main/java/me/glaremasters/guilds/database/JsonFileUtils.java
@@ -1,6 +1,8 @@
package me.glaremasters.guilds.database;
import com.google.gson.Gson;
+import me.glaremasters.guilds.utils.LoggingUtils;
+import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
@@ -11,16 +13,245 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
/**
* Utility methods for JSON persistence files.
*/
public final class JsonFileUtils {
+ /**
+ * The extension every data file the plugin writes carries.
+ *
+ * The writers produce the record id and this extension: {@code createGuild} and
+ * {@code updateGuild} write {@code id + ".json"}, and the arena and challenge providers do the
+ * same. Nothing else in a data folder is a record.
+ */
+ public static final String DATA_FILE_EXTENSION = ".json";
+
private JsonFileUtils() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
+ /**
+ * Lists the files in a folder that hold a record.
+ *
+ * A data folder belongs to the plugin, but the operator can see it and can put things in
+ * it. It can therefore hold more than the files the plugin wrote: the temporary file a write
+ * that was interrupted left behind, a note, a backup folder. Treating every entry as a record
+ * made a stray file stand in for a guild that did not exist, and made the plugin complain
+ * about files it had never written.
+ *
+ * The rule is narrow on purpose. A file is a record when it is a regular file whose name
+ * ends in {@code .json}. The temporary file that {@link #writeAtomically(File, String)} creates
+ * next to its target ends in {@code .tmp}, so an interrupted write is ignored. A file the
+ * operator put there is ignored as well.
+ *
+ * Ignored files are left alone. They are not the plugin's to remove.
+ *
+ * @param folder the folder to look in
+ * @return the data files in the folder, in no particular order
+ * @throws IOException if the folder cannot be listed. A folder that cannot be read is a problem
+ * with the storage rather than with one record, and it has to say so: a folder that
+ * looks empty is a server that comes up with no guilds and no explanation.
+ */
+ public static List The id is the name of the file without its extension. That is what the writers use, and
+ * it is the key the SQL providers use for the same records, so the two backends have to agree
+ * on it.
+ *
+ * @param file the file to read the id from
+ * @return the id of the record
+ */
+ public static String getRecordId(File file) {
+ final String name = file.getName();
+ final int dotIndex = name.lastIndexOf('.');
+ return dotIndex == -1 ? name : name.substring(0, dotIndex);
+ }
+
+ /**
+ * Warns when the plugin's data folder is not the parent of a data folder.
+ *
+ * The data folders sit inside the plugin's own folder: {@code plugins/Guilds/data},
+ * {@code plugins/Guilds/arenas}, {@code plugins/Guilds/challenges} and
+ * {@code plugins/Guilds/cooldowns}. The copies of the files the plugin cannot read go into
+ * {@code plugins/Guilds/backups/corrupt}, so they have to be taken from the plugin's folder
+ * rather than from the data folder.
+ *
+ * Both are {@link File} arguments, so the two can be given the wrong way round without the
+ * compiler noticing. That would put the copies inside the folder the plugin enumerates, where
+ * they would be read as records. A loud message at startup is the right weight for it: the
+ * constructor is public, and another layout is a legitimate thing for a third-party caller to
+ * want.
+ *
+ * @param pluginDataFolder the plugin's data folder
+ * @param dataFolder the folder the records live in
+ * @param dataType the kind of record the folder holds
+ */
+ public static void warnIfFoldersAreSwapped(File pluginDataFolder, File dataFolder, String dataType) {
+ if (pluginDataFolder == null || dataFolder == null) {
+ return;
+ }
+
+ final File parent = dataFolder.getParentFile();
+
+ if (parent == null) {
+ return;
+ }
+
+ if (parent.getAbsoluteFile().equals(pluginDataFolder.getAbsoluteFile())) {
+ return;
+ }
+
+ LoggingUtils.severe("The " + dataType + " data folder (" + dataFolder.getAbsolutePath() + ") is not a folder inside the "
+ + "plugin's data folder (" + pluginDataFolder.getAbsolutePath() + "), so the copies of the files the plugin cannot read "
+ + "will not land where they should. The two folders may have been given the wrong way round.");
+ }
+
+ /**
+ * Keeps a copy of a file the plugin could not read and says where it is.
+ *
+ * The copy is byte for byte. The original is left exactly as it was: a backup is a
+ * safeguard, not permission to replace the file.
+ *
+ * The log line names the file that could not be read, why it could not be read and where
+ * the copy is, so an operator has all three in one place.
+ *
+ * @param pluginDataFolder the plugin's data folder, {@code plugins/Guilds}
+ * @param file the file that could not be read
+ * @param dataType the kind of record the file holds
+ * @param cause what went wrong while reading the file
+ */
+ public static void backupCorruptFile(File pluginDataFolder, File file, String dataType, Throwable cause) {
+ backupCorruptFile(pluginDataFolder, file, dataType, describe(cause));
+ }
+
+ /**
+ * Keeps a copy of a file the plugin could not read and says where it is.
+ *
+ * The copy is byte for byte. The original is left exactly as it was: a backup is a
+ * safeguard, not permission to replace the file.
+ *
+ * The log line names the file that could not be read, why it could not be read and where
+ * the copy is, so an operator has all three in one place.
+ *
+ * @param pluginDataFolder the plugin's data folder, {@code plugins/Guilds}
+ * @param file the file that could not be read
+ * @param dataType the kind of record the file holds
+ * @param reason why the file could not be read
+ */
+ public static void backupCorruptFile(File pluginDataFolder, File file, String dataType, String reason) {
+ final CorruptDataBackup.Result result = CorruptDataBackup.backup(pluginDataFolder, file, dataType);
+
+ if (result.isFailed()) {
+ LoggingUtils.severe("The " + dataType + " file " + file.getAbsolutePath() + " could not be read (" + reason + "), "
+ + "and its backup could not be written: " + result.getFailure() + ". The file has been left where it is.");
+ return;
+ }
+
+ if (result.isSkipped()) {
+ LoggingUtils.severe("The " + dataType + " file " + file.getAbsolutePath() + " could not be read (" + reason + "). "
+ + "A copy of it is already at " + result.getBackupFile().getAbsolutePath() + ".");
+ return;
+ }
+
+ LoggingUtils.severe("The " + dataType + " file " + file.getAbsolutePath() + " could not be read (" + reason + "). "
+ + "A copy of it was written to " + result.getBackupFile().getAbsolutePath() + ". "
+ + "The file itself has been left where it is.");
+ }
+
+ /**
+ * Keeps a copy of a corrupt file and reports whether the copy exists and matches its source.
+ *
+ * @param pluginDataFolder the plugin's data folder
+ * @param file the corrupt file
+ * @param dataType the kind of data the file holds
+ * @param reason why the file could not be read
+ * @return true when a byte-for-byte backup is available
+ */
+ public static boolean backupCorruptFileAndVerify(File pluginDataFolder, File file, String dataType, String reason) {
+ final CorruptDataBackup.Result result = CorruptDataBackup.backup(pluginDataFolder, file, dataType);
+
+ if (result.isFailed()) {
+ LoggingUtils.severe("The " + dataType + " file " + file.getAbsolutePath() + " could not be read (" + reason + "), "
+ + "and its backup could not be written: " + result.getFailure() + ". The file has been left where it is.");
+ return false;
+ }
+
+ if (!CorruptDataBackup.isVerifiedCopy(file, result.getBackupFile())) {
+ LoggingUtils.severe("The " + dataType + " file " + file.getAbsolutePath() + " could not be read (" + reason + "), "
+ + "and its backup could not be verified. The file has been left where it is.");
+ return false;
+ }
+
+ if (result.isSkipped()) {
+ LoggingUtils.severe("The " + dataType + " file " + file.getAbsolutePath() + " could not be read (" + reason + "). "
+ + "A verified copy of it is already at " + result.getBackupFile().getAbsolutePath() + ".");
+ } else {
+ LoggingUtils.severe("The " + dataType + " file " + file.getAbsolutePath() + " could not be read (" + reason + "). "
+ + "A verified copy of it was written to " + result.getBackupFile().getAbsolutePath() + ". "
+ + "The file itself has been left where it is.");
+ }
+
+ return true;
+ }
+
+ /**
+ * Describes what went wrong, in a form that fits in a log line.
+ *
+ * @param cause what went wrong
+ * @return the message of the cause, or the cause itself when it has no message
+ */
+ private static String describe(Throwable cause) {
+ if (cause == null) {
+ return "the reason was not reported";
+ }
+
+ return cause.getMessage() == null ? cause.toString() : cause.getMessage();
+ }
+
public static A file in here holds an arena the plugin could not use: one it could not parse, or one
+ * whose record has no id. The arena storage deletes the files it has no record for, so a file
+ * it could not read has to be kept out of that pass. The file is the only copy of that
+ * arena's data.
+ */
+ private final Set That file is the only copy of the arena it holds. The plugin has no arena for it in
+ * memory, so anything written there would replace data the plugin never read with data it
+ * never had.
+ *
+ * @param file the file that is about to be written
+ * @throws IOException if the file is one the plugin could not read
+ */
+ private void refuseToOverwriteUnreadableFile(File file) throws IOException {
+ if (!unreadableFiles.contains(file.getName())) {
+ return;
+ }
+
+ throw new IOException("The arena file " + file.getAbsolutePath() + " could not be read when the plugin started, "
+ + "so it has not been changed. Fix the file and restart the server.");
+ }
+
@Override
public void deleteArena(@Nullable String tablePrefix, @NotNull String id) throws IOException {
- deleteArena(new File(dataFolder, id + ".json"));
+ deleteArena(new File(dataFolder, id + JsonFileUtils.DATA_FILE_EXTENSION));
}
- private void deleteArena(File file) {
- if (file.exists()) file.delete();
+ /**
+ * Deletes an arena file, unless it is one the plugin could not read.
+ *
+ * The arena storage deletes the files it has no record for, one after the other, so a file
+ * that could not be read is skipped rather than refused: it is the only copy of that arena,
+ * and one unreadable file must not stop the rest of the batch from being cleaned up. The
+ * load already said which files could not be read.
+ *
+ * A delete that did not happen is a different matter, and it is raised. The caller is told
+ * which file is still there, so it can carry on with the rest of the batch and say what it
+ * could not remove.
+ *
+ * @param file the file to delete
+ * @throws IOException if the file could not be deleted
+ */
+ private void deleteArena(File file) throws IOException {
+ if (unreadableFiles.contains(file.getName())) {
+ return;
+ }
+
+ if (!file.exists()) return;
+
+ file.delete();
+
+ if (file.exists()) {
+ throw new IOException("The arena file " + file.getAbsolutePath() + " could not be deleted.");
+ }
}
}
diff --git a/src/main/java/me/glaremasters/guilds/database/challenges/ChallengeAdapter.java b/src/main/java/me/glaremasters/guilds/database/challenges/ChallengeAdapter.java
index 2fbf938d9..45f9ff0aa 100644
--- a/src/main/java/me/glaremasters/guilds/database/challenges/ChallengeAdapter.java
+++ b/src/main/java/me/glaremasters/guilds/database/challenges/ChallengeAdapter.java
@@ -22,7 +22,7 @@ public ChallengeAdapter(Guilds guilds, DatabaseAdapter adapter) {
default:
case JSON:
File fileDataFolder = new File(guilds.getDataFolder(), "challenges");
- provider = new ChallengeJsonProvider(fileDataFolder);
+ provider = new ChallengeJsonProvider(fileDataFolder, guilds.getDataFolder());
break;
case MYSQL:
case SQLITE:
diff --git a/src/main/java/me/glaremasters/guilds/database/challenges/provider/ChallengeJsonProvider.java b/src/main/java/me/glaremasters/guilds/database/challenges/provider/ChallengeJsonProvider.java
index fa7c2db5d..f7ad8b7e5 100644
--- a/src/main/java/me/glaremasters/guilds/database/challenges/provider/ChallengeJsonProvider.java
+++ b/src/main/java/me/glaremasters/guilds/database/challenges/provider/ChallengeJsonProvider.java
@@ -12,18 +12,42 @@
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
-import java.util.Arrays;
import java.util.HashSet;
-import java.util.Objects;
import java.util.Set;
public class ChallengeJsonProvider implements ChallengeProvider {
+ /**
+ * The kind of record the challenge files hold, used as the name of the backup folder.
+ */
+ private static final String DATA_TYPE = "challenges";
+
private final File dataFolder;
+ private final File pluginDataFolder;
private final Gson gson;
+ /**
+ * The challenge files that could not be read when the challenges were loaded.
+ *
+ * A file in here holds a challenge the plugin could not use: one it could not parse, or one
+ * whose record has no id. The file is the only copy of that challenge's data, so it is never
+ * written to or deleted.
+ */
+ private final Set That file is the only copy of the challenge it holds. The plugin has no challenge for it
+ * in memory, so anything written there would replace data the plugin never read with data it
+ * never had.
+ *
+ * @param file the file that is about to be written or deleted
+ * @throws IOException if the file is one the plugin could not read
+ */
+ private void refuseToOverwriteUnreadableFile(File file) throws IOException {
+ if (!unreadableFiles.contains(file.getName())) {
+ return;
+ }
+
+ throw new IOException("The challenge file " + file.getAbsolutePath() + " could not be read when the plugin started, "
+ + "so it has not been changed. Fix the file and restart the server.");
+ }
+
+ @Override
+ public void deleteChallenge(@Nullable String tablePrefix, @NotNull String id) throws IOException {
+ deleteChallenge(new File(dataFolder, id + JsonFileUtils.DATA_FILE_EXTENSION));
+ }
+
+ private void deleteChallenge(File file) throws IOException {
+ refuseToOverwriteUnreadableFile(file);
+
+ if (!file.exists()) return;
+
+ file.delete();
+
+ /*
+ * A delete that did not happen has to be reported. The challenge is gone from the plugin
+ * once the delete is asked for, so nothing would try again, and the next restart would
+ * load the challenge that was supposed to be gone.
+ */
+ if (file.exists()) {
+ throw new IOException("The challenge file " + file.getAbsolutePath() + " could not be deleted.");
+ }
}
}
diff --git a/src/main/java/me/glaremasters/guilds/database/cooldowns/CooldownAdapter.java b/src/main/java/me/glaremasters/guilds/database/cooldowns/CooldownAdapter.java
index 0cee3e053..cc89cadc5 100644
--- a/src/main/java/me/glaremasters/guilds/database/cooldowns/CooldownAdapter.java
+++ b/src/main/java/me/glaremasters/guilds/database/cooldowns/CooldownAdapter.java
@@ -24,7 +24,7 @@ public CooldownAdapter(Guilds guilds, DatabaseAdapter adapter) {
default:
case JSON:
File fileDataFolder = new File(guilds.getDataFolder(), "cooldowns");
- provider = new CooldownJsonProvider(fileDataFolder);
+ provider = new CooldownJsonProvider(fileDataFolder, guilds.getDataFolder());
break;
case MYSQL:
case SQLITE:
diff --git a/src/main/java/me/glaremasters/guilds/database/cooldowns/provider/CooldownJsonProvider.java b/src/main/java/me/glaremasters/guilds/database/cooldowns/provider/CooldownJsonProvider.java
index d0328d927..b567f9120 100644
--- a/src/main/java/me/glaremasters/guilds/database/cooldowns/provider/CooldownJsonProvider.java
+++ b/src/main/java/me/glaremasters/guilds/database/cooldowns/provider/CooldownJsonProvider.java
@@ -1,17 +1,21 @@
package me.glaremasters.guilds.database.cooldowns.provider;
import com.google.gson.Gson;
-import com.google.gson.reflect.TypeToken;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
import me.glaremasters.guilds.Guilds;
import me.glaremasters.guilds.cooldowns.Cooldown;
import me.glaremasters.guilds.database.JsonFileUtils;
import me.glaremasters.guilds.database.cooldowns.CooldownProvider;
+import me.glaremasters.guilds.utils.LoggingUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
-import java.lang.reflect.Type;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.sql.Timestamp;
import java.util.ArrayList;
@@ -19,16 +23,31 @@
import java.util.UUID;
public class CooldownJsonProvider implements CooldownProvider {
+ /**
+ * The kind of record the cooldown file holds, used as the name of the backup folder.
+ */
+ private static final String DATA_TYPE = "cooldowns";
+
private final File dataFolder;
+ private final File pluginDataFolder;
private final File cooldownFile;
private Gson gson;
- private Type cooldownCollectionType;
public CooldownJsonProvider(File dataFolder) {
+ this(dataFolder, dataFolder.getParentFile());
+ }
+
+ /**
+ * @param dataFolder the folder the cooldown file lives in
+ * @param pluginDataFolder the plugin's data folder, where the backups are kept
+ */
+ public CooldownJsonProvider(File dataFolder, File pluginDataFolder) {
this.dataFolder = dataFolder;
+ this.pluginDataFolder = pluginDataFolder;
this.cooldownFile = new File(dataFolder, "cooldowns.json");
this.gson = Guilds.getGson();
- this.cooldownCollectionType = new TypeToken One file holds a list of cooldowns, so one broken entry used to take the whole list with
+ * it: the file could not be parsed, every cooldown in it was lost for the session, and the
+ * plugin logged a single error with no way to tell which entry was at fault.
+ *
+ * The entries that cannot be read are kept as they are written, so a later write puts them
+ * back unchanged instead of quietly dropping them.
+ */
+ private static final class CooldownFile {
+ private final List An entry that cannot be read is skipped and logged, and the entries around it are still
+ * returned. The whole file being unreadable is still an error: there is nothing to salvage
+ * then, and the caller has to know that no cooldowns were loaded.
+ *
+ * @return the cooldowns in the file, and the entries that could not be read
+ * @throws IOException if the file cannot be read or does not hold a list of cooldowns
+ */
+ private CooldownFile readCooldownFile() throws IOException {
+ final CooldownFile contents = new CooldownFile();
+
+ if (!this.cooldownFile.exists()) {
+ return contents;
+ }
+
+ final JsonElement root;
+ try (Reader reader = Files.newBufferedReader(this.cooldownFile.toPath(), StandardCharsets.UTF_8)) {
+ root = new JsonParser().parse(reader);
+ } catch (RuntimeException ex) {
+ // The file cannot be read at all, so it is kept aside before the caller is told.
+ JsonFileUtils.backupCorruptFile(pluginDataFolder, this.cooldownFile, DATA_TYPE, ex);
+ throw new IOException("Failed to parse cooldowns JSON file: " + this.cooldownFile.getAbsolutePath(), ex);
+ }
+
+ // An empty file is an empty list of cooldowns, which is what the container starts as.
+ if (root == null || root.isJsonNull()) {
+ return contents;
+ }
+
+ if (!root.isJsonArray()) {
+ JsonFileUtils.backupCorruptFile(pluginDataFolder, this.cooldownFile, DATA_TYPE, "the file does not hold a list of cooldowns");
+ throw new IOException("The cooldowns JSON file does not hold a list of cooldowns: " + this.cooldownFile.getAbsolutePath());
+ }
+
+ final JsonArray entries = root.getAsJsonArray();
+
+ for (int i = 0; i < entries.size(); i++) {
+ final JsonElement entry = entries.get(i);
+
+ try {
+ final Cooldown cooldown = gson.fromJson(entry, Cooldown.class);
+
+ // A cooldown without a type or an owner cannot be used, and asking for one used to
+ // throw where the cooldowns are used.
+ if (cooldown == null || cooldown.getCooldownType() == null || cooldown.getCooldownOwner() == null) {
+ contents.unreadable.add(entry);
+ LoggingUtils.severe("There was an error loading a Cooldown from the cooldowns file: " + this.cooldownFile.getAbsolutePath());
+ LoggingUtils.severe("The entry at position " + i + " could not be read. To prevent data loss in the plugin, this Cooldown has been prevented from loading. The entry has been left in the file as it is.");
+ continue;
+ }
+
+ contents.cooldowns.add(cooldown);
+ } catch (RuntimeException ex) {
+ contents.unreadable.add(entry);
+ LoggingUtils.severe("There was an error loading a Cooldown from the cooldowns file: " + this.cooldownFile.getAbsolutePath(), ex);
+ LoggingUtils.severe("The entry at position " + i + " could not be read. To prevent data loss in the plugin, this Cooldown has been prevented from loading. The entry has been left in the file as it is.");
+ }
+ }
+
+ /*
+ * One entry that cannot be read costs every entry in the file, because the file is the
+ * only place the cooldowns are. The whole file is kept aside, and the entries that could
+ * be read are still loaded.
+ */
+ if (!contents.unreadable.isEmpty()) {
+ final String reason = contents.unreadable.size() + " of the " + entries.size() + " entries could not be read";
+ if (!JsonFileUtils.backupCorruptFileAndVerify(pluginDataFolder, this.cooldownFile, DATA_TYPE, reason)) {
+ throw new IOException("The cooldowns JSON file could not be backed up and verified: "
+ + this.cooldownFile.getAbsolutePath());
+ }
+ }
+
+ return contents;
}
- private void writeCooldownFile(File file, List The file stays a JSON list of cooldowns, which is what it always was. The entries that
+ * could not be read are written back unchanged.
+ *
+ * @param contents the cooldowns to write, and the entries to keep
+ * @throws IOException if the file cannot be written
+ */
+ private void writeCooldownFile(CooldownFile contents) throws IOException {
+ final JsonArray entries = new JsonArray();
+
+ for (Cooldown cooldown : contents.cooldowns) {
+ entries.add(gson.toJsonTree(cooldown, Cooldown.class));
+ }
+
+ for (JsonElement unreadable : contents.unreadable) {
+ entries.add(unreadable);
+ }
+
+ JsonFileUtils.writeAtomically(this.cooldownFile, gson.toJson(entries));
}
private boolean cooldownExists(@NotNull String cooldownType, @NotNull String cooldownOwner, @NotNull List A file in here holds a guild the plugin could not use: one it could not parse, or one
+ * whose record has no id. Either way the file is the only copy of that guild's data, so the
+ * save must never write over it. The plugin does not have the guild in memory, so anything it
+ * wrote would be a guess at data it never read.
+ */
+ private final Set That file is the only copy of the guild it holds. The plugin has no guild for it in
+ * memory, so anything written there would replace data the plugin never read with data it
+ * never had.
+ *
+ * @param file the file that is about to be written or deleted
+ * @throws IOException if the file is one the plugin could not read
+ */
+ private void refuseToOverwriteUnreadableFile(File file) throws IOException {
+ if (!unreadableFiles.contains(file.getName())) {
+ return;
+ }
+
+ throw new IOException("The guild file " + file.getAbsolutePath() + " could not be read when the plugin started, "
+ + "so it has not been changed. Fix the file and restart the server.");
+ }
+
@Override
- public void deleteGuild(@Nullable String tablePrefix, @NotNull String id) {
- deleteGuild(new File(dataFolder, id + ".json"));
+ public void deleteGuild(@Nullable String tablePrefix, @NotNull String id) throws IOException {
+ deleteGuild(new File(dataFolder, id + JsonFileUtils.DATA_FILE_EXTENSION));
}
- private void deleteGuild(File file) {
- if (file.exists()) file.delete();
+ private void deleteGuild(File file) throws IOException {
+ refuseToOverwriteUnreadableFile(file);
+
+ if (!file.exists()) return;
+
+ file.delete();
+
+ /*
+ * A delete that did not happen has to be reported. The caller has already taken the guild
+ * out of the plugin, so the autosave will not touch the file again, and the next restart
+ * would load the guild the player was told was deleted.
+ */
+ if (file.exists()) {
+ throw new IOException("The guild file " + file.getAbsolutePath() + " could not be deleted.");
+ }
}
}
diff --git a/src/test/java/me/glaremasters/guilds/database/CorruptBackups.java b/src/test/java/me/glaremasters/guilds/database/CorruptBackups.java
new file mode 100644
index 000000000..def63ba6f
--- /dev/null
+++ b/src/test/java/me/glaremasters/guilds/database/CorruptBackups.java
@@ -0,0 +1,70 @@
+package me.glaremasters.guilds.database;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Finds the backups the plugin writes for the data it cannot read.
+ *
+ * The backups live under {@code plugins/Guilds/backups/corrupt/ The backups live outside the four data folders, so a backup is never loaded as a record,
+ * however it is named. Two of them with the same name must not overwrite each other either.
+ */
+class JsonStorageBackupTest {
+
+ private static final String GUILD_ID = "11111111-1111-1111-1111-111111111111";
+ private static final String ARENA_ID = "33333333-3333-3333-3333-333333333333";
+ private static final String CHALLENGE_ID = "44444444-4444-4444-4444-444444444444";
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeAll
+ static void installGson() throws ReflectiveOperationException {
+ TestGson.install();
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ Files.createDirectories(dataFolder());
+ Files.createDirectories(arenaFolder());
+ Files.createDirectories(challengeFolder());
+ Files.createDirectories(cooldownFolder());
+
+ Files.write(dataFolder().resolve(GUILD_ID + ".json"), ("{ \"id\": \"" + GUILD_ID + "\", \"name\": \"A guild\" }")
+ .getBytes(StandardCharsets.UTF_8));
+ Files.write(arenaFolder().resolve(ARENA_ID + ".json"), ("{ \"id\": \"" + ARENA_ID + "\", \"name\": \"An arena\" }")
+ .getBytes(StandardCharsets.UTF_8));
+ Files.write(challengeFolder().resolve(CHALLENGE_ID + ".json"), ("{ \"id\": \"" + CHALLENGE_ID + "\", \"isCompleted\": false }")
+ .getBytes(StandardCharsets.UTF_8));
+ Files.write(cooldownFolder().resolve("cooldowns.json"), "[]".getBytes(StandardCharsets.UTF_8));
+ }
+
+ private Path pluginFolder() {
+ return tempDir;
+ }
+
+ private Path dataFolder() {
+ return tempDir.resolve("data");
+ }
+
+ private Path arenaFolder() {
+ return tempDir.resolve("arenas");
+ }
+
+ private Path challengeFolder() {
+ return tempDir.resolve("challenges");
+ }
+
+ private Path cooldownFolder() {
+ return tempDir.resolve("cooldowns");
+ }
+
+ @Test
+ @DisplayName("a backup is never enumerated as a record by any of the four providers")
+ void aBackupIsNeverEnumeratedAsARecord() throws IOException {
+ // A backup, where the helper puts it, and a copy of one an operator left in the backup
+ // folder by hand.
+ final Path backup = tempDir.resolve("backups").resolve("corrupt").resolve("20200101-000000").resolve("guilds")
+ .resolve(GUILD_ID + ".json");
+ Files.createDirectories(backup.getParent());
+ Files.write(backup, ("{ \"id\": \"" + GUILD_ID + "\", \"name\": \"A guild\" }").getBytes(StandardCharsets.UTF_8));
+ Files.write(tempDir.resolve("backups").resolve(GUILD_ID + ".json"),
+ ("{ \"id\": \"" + GUILD_ID + "\", \"name\": \"A guild\" }").getBytes(StandardCharsets.UTF_8));
+
+ final GuildJsonProvider guilds = new GuildJsonProvider(dataFolder().toFile(), pluginFolder().toFile());
+ final ArenaJsonProvider arenas = new ArenaJsonProvider(arenaFolder().toFile(), pluginFolder().toFile());
+ final ChallengeJsonProvider challenges = new ChallengeJsonProvider(challengeFolder().toFile(), pluginFolder().toFile());
+ final CooldownJsonProvider cooldowns = new CooldownJsonProvider(cooldownFolder().toFile(), pluginFolder().toFile());
+
+ assertEquals(List.of(GUILD_ID), guilds.getAllGuildIds(null));
+ assertEquals(List.of(ARENA_ID), arenas.getAllArenaIds(null));
+
+ final Set The plugin logs through {@code java.util.logging} under the logger name {@code Guilds}, so a
+ * handler on that logger is enough to see what it said. The tests use this to check that the
+ * plugin did not report a problem about a file it was supposed to ignore.
+ */
+public final class LogCapture implements AutoCloseable {
+
+ private final Logger logger = Logger.getLogger("Guilds");
+ private final Handler handler;
+ private final List The providers read their Gson from {@link Guilds#getGson()}, which is a static field filled in
+ * during plugin startup. A unit test has no plugin, so the field is set directly. The Gson built
+ * here is the same one the plugin builds, so the files the tests write are the files the plugin
+ * would write.
+ */
+public final class TestGson {
+
+ private TestGson() {
+ throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
+ }
+
+ /**
+ * Installs the plugin's Gson into the plugin's static field.
+ *
+ * @throws ReflectiveOperationException if the field cannot be reached
+ */
+ public static void install() throws ReflectiveOperationException {
+ final Field field = Guilds.class.getDeclaredField("gson");
+ field.setAccessible(true);
+ field.set(null, new GsonBuilder().setPrettyPrinting().create());
+ }
+
+ /**
+ * @return a Gson identical to the one the plugin uses
+ */
+ public static Gson gson() {
+ return new GsonBuilder().setPrettyPrinting().create();
+ }
+}
diff --git a/src/test/java/me/glaremasters/guilds/database/UnwritableFolders.java b/src/test/java/me/glaremasters/guilds/database/UnwritableFolders.java
new file mode 100644
index 000000000..ef892122b
--- /dev/null
+++ b/src/test/java/me/glaremasters/guilds/database/UnwritableFolders.java
@@ -0,0 +1,39 @@
+package me.glaremasters.guilds.database;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/**
+ * Locks a folder so the files in it cannot be removed.
+ *
+ * A file in a folder that cannot be written to cannot be unlinked, which is how a delete
+ * fails in the field: the folder was made read-only, or the file system ran out of space.
+ */
+public final class UnwritableFolders {
+
+ private UnwritableFolders() {
+ throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
+ }
+
+ /**
+ * Takes the write permission off a folder.
+ *
+ * @param folder the folder to lock
+ * @return true when the folder really cannot be written to. A process that can write to it
+ * anyway, such as one running as root, can still remove the files in it.
+ * @throws IOException if the permissions cannot be changed
+ */
+ public static boolean lock(Path folder) throws IOException {
+ folder.toFile().setWritable(false, false);
+ return !folder.toFile().canWrite();
+ }
+
+ /**
+ * Gives a folder its write permission back, so the folder can be cleaned up afterwards.
+ *
+ * @param folder the folder to unlock
+ */
+ public static void unlock(Path folder) {
+ folder.toFile().setWritable(true, false);
+ }
+}
diff --git a/src/test/java/me/glaremasters/guilds/database/arenas/provider/ArenaJsonProviderTest.java b/src/test/java/me/glaremasters/guilds/database/arenas/provider/ArenaJsonProviderTest.java
new file mode 100644
index 000000000..69f3a85f9
--- /dev/null
+++ b/src/test/java/me/glaremasters/guilds/database/arenas/provider/ArenaJsonProviderTest.java
@@ -0,0 +1,426 @@
+package me.glaremasters.guilds.database.arenas.provider;
+
+import me.glaremasters.guilds.Guilds;
+import me.glaremasters.guilds.arena.Arena;
+import me.glaremasters.guilds.database.CorruptBackups;
+import me.glaremasters.guilds.database.DatabaseAdapter;
+import me.glaremasters.guilds.database.DatabaseBackend;
+import me.glaremasters.guilds.database.LogCapture;
+import me.glaremasters.guilds.database.TestGson;
+import me.glaremasters.guilds.database.UnwritableFolders;
+import me.glaremasters.guilds.database.arenas.ArenaAdapter;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Covers which files the JSON arena storage treats as arenas, and what happens to the ones it
+ * cannot read.
+ *
+ * The arena folder has the same problem the guild folder had: everything in it was taken to be
+ * an arena. See {@code GuildJsonProviderTest} for the full story.
+ */
+class ArenaJsonProviderTest {
+
+ private static final String ARENA_ID = "33333333-3333-3333-3333-333333333333";
+
+ @TempDir
+ Path tempDir;
+
+ private ArenaJsonProvider provider;
+
+ @BeforeAll
+ static void installGson() throws ReflectiveOperationException {
+ TestGson.install();
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ Files.createDirectories(dataFolder());
+ provider = new ArenaJsonProvider(dataFolder().toFile(), pluginFolder().toFile());
+
+ write(ARENA_ID + ".json", arenaJson(ARENA_ID, "The Real Arena"));
+ write(ARENA_ID + ".json" + "1234567890" + ".tmp", arenaJson(ARENA_ID, "The Real Arena"));
+ write("notes.txt", "this is not an arena");
+ write("README", "nothing to see here");
+
+ Files.createDirectory(dataFolder().resolve("backup"));
+ write(dataFolder().resolve("backup").resolve("old-arena.json"), arenaJson(ARENA_ID, "Old Arena"));
+ }
+
+ /**
+ * @return the plugin's data folder, the one the backups are kept in
+ */
+ private Path pluginFolder() {
+ return tempDir;
+ }
+
+ /**
+ * @return the folder the arena files live in
+ */
+ private Path dataFolder() {
+ return tempDir.resolve("arenas");
+ }
+
+ private void write(String name, String content) throws IOException {
+ write(dataFolder().resolve(name), content);
+ }
+
+ private void write(Path path, String content) throws IOException {
+ Files.write(path, content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String arenaJson(String id, String name) {
+ return "{\n"
+ + " \"id\": \"" + id + "\",\n"
+ + " \"name\": \"" + name + "\",\n"
+ + " \"challenger\": null,\n"
+ + " \"defender\": null\n"
+ + "}\n";
+ }
+
+ @Test
+ @DisplayName("only the arena data files are enumerated")
+ void onlyDataFilesAreEnumerated() throws IOException {
+ assertEquals(List.of(ARENA_ID), provider.getAllArenaIds(null));
+ }
+
+ @Test
+ @DisplayName("only the arena data files are loaded as arenas")
+ void onlyDataFilesAreLoaded() throws IOException {
+ final List The challenge folder has the same problem the guild folder had: everything in it was taken to
+ * be a challenge. See {@code GuildJsonProviderTest} for the full story.
+ */
+class ChallengeJsonProviderTest {
+
+ private static final String CHALLENGE_ID = "44444444-4444-4444-4444-444444444444";
+
+ @TempDir
+ Path tempDir;
+
+ private ChallengeJsonProvider provider;
+
+ @BeforeAll
+ static void installGson() throws ReflectiveOperationException {
+ TestGson.install();
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ Files.createDirectories(dataFolder());
+ provider = new ChallengeJsonProvider(dataFolder().toFile(), pluginFolder().toFile());
+
+ write(CHALLENGE_ID + ".json", challengeJson(CHALLENGE_ID));
+ write(CHALLENGE_ID + ".json" + "1234567890" + ".tmp", challengeJson(CHALLENGE_ID));
+ write("notes.txt", "this is not a challenge");
+ write("README", "nothing to see here");
+
+ Files.createDirectory(dataFolder().resolve("backup"));
+ write(dataFolder().resolve("backup").resolve("old-challenge.json"), challengeJson(CHALLENGE_ID));
+ }
+
+ /**
+ * @return the plugin's data folder, the one the backups are kept in
+ */
+ private Path pluginFolder() {
+ return tempDir;
+ }
+
+ /**
+ * @return the folder the challenge files live in
+ */
+ private Path dataFolder() {
+ return tempDir.resolve("challenges");
+ }
+
+ private void write(String name, String content) throws IOException {
+ write(dataFolder().resolve(name), content);
+ }
+
+ private void write(Path path, String content) throws IOException {
+ Files.write(path, content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String challengeJson(String id) {
+ return "{\n"
+ + " \"id\": \"" + id + "\",\n"
+ + " \"initiateTime\": 0,\n"
+ + " \"isCompleted\": false,\n"
+ + " \"challengePlayers\": [],\n"
+ + " \"defendPlayers\": []\n"
+ + "}\n";
+ }
+
+ @Test
+ @DisplayName("only the challenge data files are loaded as challenges")
+ void onlyDataFilesAreLoaded() throws IOException {
+ final Set The cooldown file is the one data file that holds a list rather than a single record, so it
+ * is the one place where a single broken entry used to cost every other entry as well: the file
+ * could not be parsed, no cooldown was loaded, and the plugin logged one error that did not say
+ * which entry was at fault.
+ */
+class CooldownJsonProviderTest {
+
+ private static final UUID COOLDOWN_ID = UUID.fromString("55555555-5555-5555-5555-555555555555");
+ private static final UUID OWNER_ID = UUID.fromString("66666666-6666-6666-6666-666666666666");
+
+ @TempDir
+ Path tempDir;
+
+ private CooldownJsonProvider provider;
+
+ @BeforeAll
+ static void installGson() throws ReflectiveOperationException {
+ TestGson.install();
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ Files.createDirectories(dataFolder());
+ provider = new CooldownJsonProvider(dataFolder().toFile(), pluginFolder().toFile());
+ provider.createContainer(null);
+ }
+
+ /**
+ * @return the plugin's data folder, the one the backups are kept in
+ */
+ private Path pluginFolder() {
+ return tempDir;
+ }
+
+ /**
+ * @return the folder the cooldown file lives in
+ */
+ private Path dataFolder() {
+ return tempDir.resolve("cooldowns");
+ }
+
+ private Path cooldownFile() {
+ return dataFolder().resolve("cooldowns.json");
+ }
+
+ private void writeCooldowns(String content) throws IOException {
+ Files.write(cooldownFile(), content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String cooldownEntry(UUID id, Cooldown.Type type, UUID owner, long expiry) {
+ return " {\n"
+ + " \"cooldownId\": \"" + id + "\",\n"
+ + " \"cooldownType\": \"" + type.name() + "\",\n"
+ + " \"cooldownOwner\": \"" + owner + "\",\n"
+ + " \"cooldownExpiry\": " + expiry + "\n"
+ + " }";
+ }
+
+ @Test
+ @DisplayName("a cooldown file with one good entry is read")
+ void aGoodFileIsRead() throws IOException {
+ writeCooldowns("[\n" + cooldownEntry(COOLDOWN_ID, Cooldown.Type.Request, OWNER_ID, 9999999999999L) + "\n]\n");
+
+ final List The guild data folder is a folder an operator can see and touch. Anything can end up in it:
+ * a leftover temporary file from a write that was interrupted, a note, a backup folder. The
+ * provider used to take every entry it found and call it a guild, which let a stray file stand in
+ * for a real guild and made the plugin report a guild that did not exist.
+ *
+ * A file the plugin cannot read is a different matter. It is the only copy of that guild's
+ * data, so it is copied aside and left exactly where it was.
+ */
+class GuildJsonProviderTest {
+
+ private static final String GUILD_ID = "11111111-1111-1111-1111-111111111111";
+ private static final String OTHER_GUILD_ID = "22222222-2222-2222-2222-222222222222";
+
+ @TempDir
+ Path tempDir;
+
+ private GuildJsonProvider provider;
+
+ @BeforeAll
+ static void installGson() throws ReflectiveOperationException {
+ TestGson.install();
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ Files.createDirectories(dataFolder());
+ provider = new GuildJsonProvider(dataFolder().toFile(), pluginFolder().toFile());
+
+ // The one real guild.
+ write(GUILD_ID + ".json", guildJson(GUILD_ID, "The Real Guild"));
+
+ // What a write that was killed half way leaves behind: the plugin writes to a temporary
+ // file next to the target and moves it into place afterwards.
+ write(GUILD_ID + ".json" + "1234567890" + ".tmp", guildJson(GUILD_ID, "The Real Guild"));
+
+ // Files an operator dropped in.
+ write("notes.txt", "this is not a guild");
+ write("README", "nothing to see here");
+
+ // A folder, because people make backups that way.
+ Files.createDirectory(dataFolder().resolve("backup"));
+ write(dataFolder().resolve("backup").resolve("old-guild.json"), guildJson(OTHER_GUILD_ID, "Old Guild"));
+ }
+
+ /**
+ * @return the plugin's data folder, the one the backups are kept in
+ */
+ private Path pluginFolder() {
+ return tempDir;
+ }
+
+ /**
+ * @return the folder the guild files live in
+ */
+ private Path dataFolder() {
+ return tempDir.resolve("data");
+ }
+
+ private void write(String name, String content) throws IOException {
+ write(dataFolder().resolve(name), content);
+ }
+
+ private void write(Path path, String content) throws IOException {
+ Files.write(path, content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String guildJson(String id, String name) {
+ return "{\n"
+ + " \"id\": \"" + id + "\",\n"
+ + " \"name\": \"" + name + "\",\n"
+ + " \"prefix\": \"TG\",\n"
+ + " \"motd\": \"A guild\",\n"
+ + " \"status\": \"Public\",\n"
+ + " \"balance\": 0.0,\n"
+ + " \"members\": [],\n"
+ + " \"invitedMembers\": [],\n"
+ + " \"allies\": [],\n"
+ + " \"pendingAllies\": [],\n"
+ + " \"codes\": [],\n"
+ + " \"vaults\": [],\n"
+ + " \"lastDefended\": 0,\n"
+ + " \"creationDate\": 0\n"
+ + "}\n";
+ }
+
+ @Test
+ @DisplayName("only the guild data files are enumerated")
+ void onlyDataFilesAreEnumerated() throws IOException {
+ assertEquals(List.of(GUILD_ID), provider.getAllGuildIds(null));
+ }
+
+ @Test
+ @DisplayName("only the guild data files are loaded as guilds")
+ void onlyDataFilesAreLoaded() throws IOException {
+ final List>(){}.getType(); // needed to serialize a list properly
+
+ JsonFileUtils.warnIfFoldersAreSwapped(pluginDataFolder, dataFolder, DATA_TYPE);
}
@Override
@@ -47,29 +66,142 @@ public boolean cooldownExists(@Nullable String tablePrefix, @NotNull String cool
@Override
public List
>(){}.getType();
+ final String expected = gson.toJson(Arrays.asList(cooldown), listType);
+
+ assertEquals(expected, new String(Files.readAllBytes(cooldownFile()), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ @DisplayName("an empty cooldown file holds no cooldowns")
+ void anEmptyFileHoldsNoCooldowns() throws IOException {
+ assertTrue(provider.getAllCooldowns(null).isEmpty());
+ }
+
+ @Test
+ @DisplayName("a cooldown file that cannot be read is backed up byte for byte")
+ void aBrokenCooldownFileIsBackedUpByteForByte() throws IOException {
+ final String corrupt = "{ this is not json";
+ writeCooldowns(corrupt);
+
+ assertThrows(IOException.class, () -> provider.getAllCooldowns(null));
+
+ final Path backup = CorruptBackups.onlyBackup(pluginFolder(), "cooldowns");
+ assertArrayEquals(corrupt.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(backup));
+
+ // The original is still there, with the same bytes.
+ assertTrue(Files.exists(cooldownFile()));
+ assertArrayEquals(corrupt.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(cooldownFile()));
+ }
+
+ @Test
+ @DisplayName("a cooldown file that does not hold a list is backed up")
+ void aCooldownFileThatIsNotAListIsBackedUp() throws IOException {
+ final String unusable = "{ \"not\": \"a list\" }";
+ writeCooldowns(unusable);
+
+ assertThrows(IOException.class, () -> provider.getAllCooldowns(null));
+
+ final Path backup = CorruptBackups.onlyBackup(pluginFolder(), "cooldowns");
+ assertArrayEquals(unusable.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(backup));
+ assertArrayEquals(unusable.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(cooldownFile()));
+ }
+
+ @Test
+ @DisplayName("a cooldown file with a broken entry is backed up")
+ void aCooldownFileWithABrokenEntryIsBackedUp() throws IOException {
+ final String broken = "[\n"
+ + " \"this is not a cooldown\",\n"
+ + cooldownEntry(COOLDOWN_ID, Cooldown.Type.Request, OWNER_ID, 9999999999999L) + "\n"
+ + "]\n";
+ writeCooldowns(broken);
+
+ provider.getAllCooldowns(null);
+
+ final Path backup = CorruptBackups.onlyBackup(pluginFolder(), "cooldowns");
+ assertArrayEquals(broken.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(backup));
+ assertArrayEquals(broken.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(cooldownFile()));
+ }
+
+ @Test
+ @DisplayName("the cooldown backup lands outside the data folder and keeps the file name")
+ void theBackupLandsOutsideTheDataFolder() throws IOException {
+ writeCooldowns("{ this is not json");
+
+ assertThrows(IOException.class, () -> provider.getAllCooldowns(null));
+
+ final Path backup = CorruptBackups.onlyBackup(pluginFolder(), "cooldowns");
+
+ assertEquals("cooldowns.json", backup.getFileName().toString());
+ assertEquals("cooldowns", backup.getParent().getFileName().toString());
+ assertTrue(backup.getParent().getParent().getFileName().toString().matches("\\d{8}-\\d{6}"),
+ "the backup folder is not named after a timestamp: " + backup);
+ assertEquals("corrupt", backup.getParent().getParent().getParent().getFileName().toString());
+ assertEquals("backups", backup.getParent().getParent().getParent().getParent().getFileName().toString());
+ assertEquals(pluginFolder(), backup.getParent().getParent().getParent().getParent().getParent());
+ assertTrue(backup.startsWith(pluginFolder()));
+ }
+
+ @Test
+ @DisplayName("the log names the cooldown file, the reason and the backup")
+ void theLogNamesTheFileTheReasonAndTheBackup() throws IOException {
+ writeCooldowns("{ this is not json");
+
+ try (LogCapture log = new LogCapture()) {
+ assertThrows(IOException.class, () -> provider.getAllCooldowns(null));
+
+ assertTrue(log.contains("cooldowns.json"), "the log did not name the file");
+ assertTrue(log.contains("could not be read"), "the log did not say what went wrong");
+ assertTrue(log.contains("backups"), "the log did not say where the backup is");
+ }
+ }
+
+ @Test
+ @DisplayName("the log says which entry of the cooldown file could not be read")
+ void theLogSaysWhichEntryCouldNotBeRead() throws IOException {
+ writeCooldowns("[\n"
+ + " \"this is not a cooldown\",\n"
+ + cooldownEntry(COOLDOWN_ID, Cooldown.Type.Request, OWNER_ID, 9999999999999L) + "\n"
+ + "]\n");
+
+ try (LogCapture log = new LogCapture()) {
+ provider.getAllCooldowns(null);
+
+ assertTrue(log.contains("position 0"), "the log did not say which entry was at fault");
+ }
+ }
+
+ @Test
+ @DisplayName("a cooldown backup that cannot be written is reported and nothing is lost")
+ void aBackupThatCannotBeWrittenIsReportedAndNothingIsLost() throws IOException {
+ final String corrupt = "{ this is not json";
+ writeCooldowns(corrupt);
+
+ Files.createFile(pluginFolder().resolve("backups"));
+
+ try (LogCapture log = new LogCapture()) {
+ assertThrows(IOException.class, () -> provider.getAllCooldowns(null));
+
+ assertTrue(log.contains("could not be written"), "the failure to back the file up was not reported");
+ assertTrue(log.contains("cooldowns.json"), "the log did not name the file");
+ }
+
+ assertTrue(Files.exists(cooldownFile()));
+ assertArrayEquals(corrupt.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(cooldownFile()));
+ }
+
+ @Test
+ @DisplayName("creating a cooldown is refused when a partial-corruption backup cannot be written")
+ void createIsRefusedWhenPartialCorruptionCannotBeBackedUp() throws IOException {
+ final String corrupt = "[\n"
+ + " \"this is not a cooldown\",\n"
+ + cooldownEntry(COOLDOWN_ID, Cooldown.Type.Request, OWNER_ID, 9999999999999L) + "\n"
+ + "]\n";
+ writeCooldowns(corrupt);
+ Files.createFile(pluginFolder().resolve("backups"));
+
+ assertThrows(IOException.class, () -> provider.createCooldown(null, UUID.randomUUID().toString(),
+ Cooldown.Type.Home.name(), OWNER_ID.toString(), new Timestamp(9999999999999L)));
+
+ assertArrayEquals(corrupt.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(cooldownFile()));
+ }
+
+ @Test
+ @DisplayName("deleting a cooldown is refused when a partial-corruption backup cannot be written")
+ void deleteIsRefusedWhenPartialCorruptionCannotBeBackedUp() throws IOException {
+ final String corrupt = "[\n"
+ + " \"this is not a cooldown\",\n"
+ + cooldownEntry(COOLDOWN_ID, Cooldown.Type.Request, OWNER_ID, 9999999999999L) + "\n"
+ + "]\n";
+ writeCooldowns(corrupt);
+ Files.createFile(pluginFolder().resolve("backups"));
+
+ assertThrows(IOException.class, () -> provider.deleteCooldown(null, Cooldown.Type.Request.name(), OWNER_ID.toString()));
+
+ assertArrayEquals(corrupt.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(cooldownFile()));
+ }
+
+ @Test
+ @DisplayName("the same cooldown corruption is backed up once, not once per read")
+ void theSameCorruptionIsBackedUpOnce() throws IOException {
+ writeCooldowns("{ this is not json");
+
+ assertThrows(IOException.class, () -> provider.getAllCooldowns(null));
+ assertEquals(1, CorruptBackups.backupFiles(pluginFolder(), "cooldowns").size());
+
+ assertThrows(IOException.class, () -> provider.getAllCooldowns(null));
+ assertEquals(1, CorruptBackups.backupFiles(pluginFolder(), "cooldowns").size());
+
+ // A restart: a new provider over the same folder.
+ final CooldownJsonProvider restarted = new CooldownJsonProvider(dataFolder().toFile(), pluginFolder().toFile());
+ assertThrows(IOException.class, () -> restarted.getAllCooldowns(null));
+ assertEquals(1, CorruptBackups.backupFiles(pluginFolder(), "cooldowns").size());
+ }
+}
diff --git a/src/test/java/me/glaremasters/guilds/database/guild/provider/GuildJsonProviderTest.java b/src/test/java/me/glaremasters/guilds/database/guild/provider/GuildJsonProviderTest.java
new file mode 100644
index 000000000..0655dfa31
--- /dev/null
+++ b/src/test/java/me/glaremasters/guilds/database/guild/provider/GuildJsonProviderTest.java
@@ -0,0 +1,437 @@
+package me.glaremasters.guilds.database.guild.provider;
+
+import me.glaremasters.guilds.database.CorruptBackups;
+import me.glaremasters.guilds.database.LogCapture;
+import me.glaremasters.guilds.database.TestGson;
+import me.glaremasters.guilds.database.UnwritableFolders;
+import me.glaremasters.guilds.guild.Guild;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Covers which files the JSON guild storage treats as guilds, and what happens to the ones it
+ * cannot read.
+ *
+ *