diff --git a/build-logic/src/main/kotlin/faststats.java-conventions.gradle.kts b/build-logic/src/main/kotlin/faststats.java-conventions.gradle.kts index 445966b8..33ef7adc 100644 --- a/build-logic/src/main/kotlin/faststats.java-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/faststats.java-conventions.gradle.kts @@ -5,6 +5,8 @@ plugins { group = "dev.faststats.metrics" +val libs = rootProject.the().named("libs") + repositories { mavenCentral() } @@ -31,6 +33,12 @@ sourceSets.main { resources.srcDir(generateFastStatsProperties) } +dependencies { + testImplementation(platform(libs.findLibrary("junit-bom").get())) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + tasks.test { dependsOn(tasks.javadoc) useJUnitPlatform() diff --git a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java index 3a87a069..465fbf9d 100644 --- a/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java +++ b/bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java @@ -2,7 +2,7 @@ import dev.faststats.SimpleContext; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.LoggerFactory; import dev.faststats.internal.PlatformLoggerFactory; import org.bukkit.plugin.Plugin; @@ -23,7 +23,7 @@ public final class BukkitContext extends SimpleContext { private final Plugin plugin; private BukkitContext(final Factory factory, final LoggerFactory loggerFactory, final Plugin plugin, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(getConfigPath(plugin), loggerFactory), "bukkit", token); + super(factory, loggerFactory, CustomConfig.builder(getConfigPath(plugin), loggerFactory).build(), "bukkit", token); this.plugin = plugin; initializeServices(factory); } @@ -60,7 +60,7 @@ private static Path getPluginsFolder(final Plugin plugin) { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(this); + return ((CustomConfig) getConfig()).preSubmissionStart(this); } @Override diff --git a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java index bd41cb8e..810e01fd 100644 --- a/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java +++ b/bungeecord/src/main/java/dev/faststats/bungee/BungeeContext.java @@ -4,7 +4,7 @@ import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.LoggerFactory; import dev.faststats.internal.PlatformLoggerFactory; import net.md_5.bungee.api.plugin.Plugin; @@ -25,9 +25,9 @@ public final class BungeeContext extends SimpleContext { private final Plugin plugin; private BungeeContext(final Factory factory, final LoggerFactory loggerFactory, final Plugin plugin, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(plugin.getProxy().getPluginsFolder().toPath() + super(factory, loggerFactory, CustomConfig.builder(plugin.getProxy().getPluginsFolder().toPath() .resolve("faststats").resolve("config.properties"), loggerFactory - ), "bungeecord", token); + ).build(), "bungeecord", token); this.plugin = plugin; initializeServices(factory); } @@ -45,7 +45,7 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(this); + return ((CustomConfig) getConfig()).preSubmissionStart(this); } @Override diff --git a/config/build.gradle.kts b/config/build.gradle.kts index 21598390..16c5d4a5 100644 --- a/config/build.gradle.kts +++ b/config/build.gradle.kts @@ -13,4 +13,6 @@ tasks.compileJava { dependencies { compileOnly(project(":core")) + + testImplementation(project(":core")) } diff --git a/config/src/main/java/dev/faststats/config/CustomConfig.java b/config/src/main/java/dev/faststats/config/CustomConfig.java new file mode 100644 index 00000000..4f5bfcb1 --- /dev/null +++ b/config/src/main/java/dev/faststats/config/CustomConfig.java @@ -0,0 +1,497 @@ +package dev.faststats.config; + +import dev.faststats.Config; +import dev.faststats.SimpleContext; +import dev.faststats.internal.Logger; +import dev.faststats.internal.LoggerFactory; +import org.intellij.lang.annotations.Pattern; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A builder-style metrics configuration. + *

+ * Implementations can override the header comment and the onboarding message, + * and register additional fields that are stored alongside the default ones. + * + *

{@code
+ * public static final CustomConfig.Field ONBOARDING_SCREEN_SEEN =
+ *         CustomConfig.Field.bool("onboarding-screen-seen", false);
+ *
+ * final var config = CustomConfig.builder(file, loggerFactory)
+ *         .comment("My custom header")
+ *         .onboardingMessage(null) // disable the onboarding message
+ *         .field(ONBOARDING_SCREEN_SEEN)
+ *         .build();
+ *
+ * if (!config.get(ONBOARDING_SCREEN_SEEN)) {
+ *     config.set(ONBOARDING_SCREEN_SEEN, true);
+ *     config.persist();
+ * }
+ * }
+ * + * @since 0.32.0 + */ +public final class CustomConfig implements Config { + private static final int CURRENT_CONFIG_VERSION = 3; + + public static final Field ENABLED = Field.bool("enabled", true); + public static final Field SUBMIT_METRICS = Field.bool("submitMetrics", true); + public static final Field SUBMIT_ADDITIONAL_METRICS = Field.bool("submitAdditionalMetrics", true); + public static final Field SUBMIT_ERRORS = Field.bool("submitErrors", true); + public static final Field SERVER_ID = new Field<>("serverId", UUID::randomUUID, + value -> UUID.fromString(value.length() > 36 ? value.substring(0, 36) : value), UUID::toString); + public static final Field DEBUG = Field.bool("debug", false); + private static final Field CONFIG_VERSION = Field.integer("configVersion", CURRENT_CONFIG_VERSION); + + private static final Set> DEFAULT_FIELDS = Set.of( + ENABLED, SUBMIT_METRICS, SUBMIT_ADDITIONAL_METRICS, SUBMIT_ERRORS, SERVER_ID, DEBUG, CONFIG_VERSION + ); + + public static final String DEFAULT_COMMENT = """ + FastStats (https://faststats.dev) collects pseudonymous usage statistics and errors. + # This helps developers understand how their projects are used in the real world. + # + # No IP addresses, player data, or personal information is collected. + # The server ID below is randomly generated and can be regenerated at any time. + # + # Enabling metrics has no noticeable performance impact. + # Keeping FastStats enabled is recommended. + # To disable all FastStats features, set 'enabled=false'. + # To disable only metrics submission, set 'submitMetrics=false'. + # To disable only additional metrics, set 'submitAdditionalMetrics=false'. + # To disable only error tracking, set 'submitErrors=false'. + # + # If you suspect a developer is collecting personal data or bypassing any opt-out option, + # please report it at: https://faststats.dev/abuse + # + # For more information, visit: https://faststats.dev/info + """; + public static final String DEFAULT_ONBOARDING_MESSAGE = """ + This plugin uses FastStats to collect pseudonymous usage statistics and errors. + No personal or identifying information is ever collected. + To opt out, set 'enabled=false' in the metrics configuration file. + Learn more at: https://faststats.dev/info + + Since this is your first start with FastStats, submission will not start + until after a restart, to allow you to opt out if you prefer."""; + + private final Path file; + private final Logger logger; + private final @Nullable String comment; + private final @Nullable String onboardingMessage; + private final Map> fields; + private final Map values = new ConcurrentHashMap<>(); + private final boolean storeEnabled; + private final boolean firstRun; + + private CustomConfig(final Builder builder) { + this.file = builder.file; + this.logger = builder.loggerFactory.getLogger(CustomConfig.class); + this.comment = builder.comment; + this.onboardingMessage = builder.onboardingMessage; + this.fields = Map.copyOf(builder.fields); + this.storeEnabled = builder.enabled == null; + + PropertiesFile.Contents contents; + var broken = false; + try { + contents = PropertiesFile.read(file); + } catch (final IOException | RuntimeException e) { + logger.print(Logger.LogLevel.ERROR, e, "Failed to read FastStats config at " + file + + ", FastStats is disabled until the file is fixed or deleted"); + contents = null; + broken = true; + } + final var properties = contents != null ? contents.properties() : null; + + final var reader = new Reader(properties, fields.values(), logger); + this.firstRun = !broken && reader.empty; + fields.values().forEach(field -> { + if (field != ENABLED || storeEnabled) values.put(field.key(), reader.parse(field)); + }); + if (builder.enabled != null) values.put(ENABLED.key(), builder.enabled); + if (broken) values.put(ENABLED.key(), false); + if (contents != null && contents.foreignEncoding()) reader.save = true; + + final int configVersion = get(CONFIG_VERSION); + if (configVersion < CURRENT_CONFIG_VERSION) { + if (reader.find(CONFIG_VERSION) != null) + logger.info("Updating config version from %s to %s", configVersion, CURRENT_CONFIG_VERSION); + set(CONFIG_VERSION, CURRENT_CONFIG_VERSION); + reader.save = true; + } else if (configVersion > CURRENT_CONFIG_VERSION) reader.save = false; + + if (reader.save && !broken) write(properties != null ? properties : new Properties()); + + final var enabled = get(ENABLED) && enabledFlag(); + set(ENABLED, enabled); + set(SUBMIT_METRICS, enabled && get(SUBMIT_METRICS)); + set(SUBMIT_ERRORS, enabled && get(SUBMIT_ERRORS)); + set(SUBMIT_ADDITIONAL_METRICS, enabled && get(SUBMIT_ADDITIONAL_METRICS)); + } + + @Contract(value = "_, _ -> new", pure = true) + public static Builder builder(final Path file, final LoggerFactory loggerFactory) { + return new Builder(file, loggerFactory); + } + + @Override + public UUID serverId() { + return get(SERVER_ID); + } + + @Override + public boolean enabled() { + return get(ENABLED); + } + + public void enabled(final boolean enabled) { + set(ENABLED, enabled); + } + + @Override + public boolean additionalMetrics() { + return get(SUBMIT_ADDITIONAL_METRICS); + } + + public void additionalMetrics(final boolean additionalMetrics) { + set(SUBMIT_ADDITIONAL_METRICS, additionalMetrics); + } + + @Override + public boolean debug() { + return get(DEBUG) || Boolean.getBoolean("faststats.debug"); + } + + @Override + public boolean submitMetrics() { + return get(SUBMIT_METRICS); + } + + public void submitMetrics(final boolean submitMetrics) { + set(SUBMIT_METRICS, submitMetrics); + } + + @Override + public boolean errorTracking() { + return get(SUBMIT_ERRORS); + } + + public void errorTracking(final boolean errorTracking) { + set(SUBMIT_ERRORS, errorTracking); + } + + public boolean firstRun() { + return firstRun; + } + + /** + * @return {@code false} if FastStats was disabled using {@code -Dfaststats.enabled=false} + */ + public static boolean enabledFlag() { + return Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); + } + + /** + * Whether the given field is registered in this config. + * + * @param field the field + * @return {@code true} if the field is registered, {@code false} otherwise + * @since 0.32.0 + */ + @Contract(pure = true) + public boolean has(final Field field) { + return field.equals(fields.get(field.key())); + } + + /** + * Gets the value of a field. + * + * @param field the field + * @param the value type + * @return the current value + * @throws IllegalArgumentException if the field was not registered with the builder + * @since 0.32.0 + */ + @SuppressWarnings("unchecked") + public T get(final Field field) throws IllegalArgumentException { + requireRegistered(field); + return (T) values.get(field.key()); + } + + /** + * Sets the value of a field. + *

+ * Call {@link #persist()} to write the change to disk. + * + * @param field the field + * @param value the new value + * @param the value type + * @throws IllegalArgumentException if the field was not registered with the builder + * @since 0.32.0 + */ + public void set(final Field field, final T value) throws IllegalArgumentException { + requireRegistered(field); + values.put(field.key(), Objects.requireNonNull(value, "value")); + } + + private void requireRegistered(final Field field) { + if (!has(field)) + throw new IllegalArgumentException("Field '" + field.key() + "' is not registered in this config"); + } + + /** + * Writes the current values to the config file. + *

+ * Entries that are not registered in this config are kept. + * Failures are logged instead of thrown. + */ + @Contract(mutates = "io") + public synchronized void persist() { + PropertiesFile.Contents existing; + try { + existing = PropertiesFile.read(file); + } catch (final IOException | RuntimeException e) { + existing = null; + } + write(existing != null ? existing.properties() : new Properties()); + } + + @SuppressWarnings("unchecked") + private void write(final Properties properties) { + fields.values().forEach(field -> { + if (field == ENABLED && !storeEnabled) return; + final var typed = (Field) field; + try { + properties.setProperty(field.key(), typed.serializer().apply(values.get(field.key()))); + PropertiesFile.removeVariants(properties, field.key()); + } catch (final RuntimeException e) { + logger.print(Logger.LogLevel.WARN, e, "Failed to serialize FastStats config property '" + field.key() + "'"); + } + }); + try { + PropertiesFile.write(file, properties, comment); + } catch (final IOException | RuntimeException e) { + logger.print(Logger.LogLevel.WARN, e, "Failed to save FastStats config at " + file + + ", changes will not persist across restarts"); + } + } + + public boolean preSubmissionStart(final SimpleContext context) { + if (Boolean.getBoolean("faststats.first-run")) return false; + if (!firstRun()) return true; + + if (onboardingMessage != null) { + var separatorLength = 0; + final var split = onboardingMessage.split("\n"); + for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); + + final var logger = context.getLoggerFactory().getLogger(getClass()); + logger.print(Logger.LogLevel.INFO, null, "-".repeat(separatorLength)); + for (final var s : split) logger.print(Logger.LogLevel.INFO, null, s); + logger.print(Logger.LogLevel.INFO, null, "-".repeat(separatorLength)); + } + + System.setProperty("faststats.first-run", "true"); + return false; + } + + @Override + public String toString() { + return "CustomConfig{" + + "firstRun=" + firstRun + + ", values=" + values + + '}'; + } + + private static final class Reader { + private final @Nullable Properties properties; + private final Logger logger; + private final boolean empty; + private boolean save; + + private Reader(@Nullable final Properties properties, final Collection> fields, final Logger logger) { + this.properties = properties; + this.logger = logger; + this.empty = properties == null || fields.stream().allMatch(field -> find(field) == null); + this.save = empty; + } + + private @Nullable String find(final Field field) { + return properties != null ? PropertiesFile.findKey(properties, field.key()) : null; + } + + private T parse(final Field field) { + if (properties == null) return field.defaultValue().get(); + final var key = find(field); + if (key == null) { + if (!empty) logger.print(Logger.LogLevel.WARN, null, "Missing FastStats config property '" + + field.key() + "', using the default value"); + save = true; + return field.defaultValue().get(); + } + if (!key.equals(field.key())) save = true; + final var property = PropertiesFile.clean(properties.getProperty(key)); + try { + final var value = field.parser().apply(property); + if (!property.equals(field.serializer().apply(value))) save = true; + return value; + } catch (final Exception e) { + logger.print(Logger.LogLevel.WARN, null, "Invalid value '" + property + + "' for FastStats config property '" + field.key() + "', using the default value"); + save = true; + return field.defaultValue().get(); + } + } + } + + /** + * Builder for {@link CustomConfig}. + * + * @since 0.32.0 + */ + public static final class Builder { + private final Path file; + private final LoggerFactory loggerFactory; + private final Map> fields = new LinkedHashMap<>(); + private @Nullable String comment = DEFAULT_COMMENT; + private @Nullable String onboardingMessage = DEFAULT_ONBOARDING_MESSAGE; + private @Nullable Boolean enabled; + + private Builder(final Path file, final LoggerFactory loggerFactory) { + this.file = file; + this.loggerFactory = loggerFactory; + DEFAULT_FIELDS.forEach(field -> fields.put(field.key(), field)); + } + + /** + * Sets the header comment of the config file. + *

+ * Lines are prefixed with {@code #} automatically. + * + * @param comment the comment, or {@code null} to omit it + * @return this builder + * @since 0.32.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder comment(@Nullable final String comment) { + this.comment = comment; + return this; + } + + /** + * Sets the message logged on the first run. + * + * @param onboardingMessage the message, or {@code null} to disable it + * @return this builder + * @since 0.32.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder onboardingMessage(@Nullable final String onboardingMessage) { + this.onboardingMessage = onboardingMessage; + return this; + } + + /** + * Overrides the enabled state with one provided by the platform. + *

+ * The {@code enabled} entry is then neither read from nor written to the config file. + * + * @param enabled the enabled state + * @return this builder + * @since 0.32.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder enabled(final boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Registers an additional field. + * + * @param field the field + * @return this builder + * @throws IllegalArgumentException if the key is already registered + * @since 0.32.0 + */ + @Contract(value = "_ -> this", mutates = "this") + public Builder field(final Field field) throws IllegalArgumentException { + final var normalized = PropertiesFile.normalize(field.key()); + if (fields.keySet().stream().map(PropertiesFile::normalize).anyMatch(normalized::equals)) + throw new IllegalArgumentException("Field key '" + field.key() + "' is already registered"); + fields.put(field.key(), field); + return this; + } + + /** + * Reads the config from disk, creating or updating the file if necessary. + * + * @return the config + * @throws RuntimeException if the file could not be read or written + * @since 0.32.0 + */ + @Contract(value = " -> new", mutates = "io") + public CustomConfig build() throws RuntimeException { + return new CustomConfig(this); + } + } + + /** + * A config field. + * + * @param key the property key, ASCII letters and digits optionally separated by single {@code -}, {@code _} or {@code .} + * @param defaultValue the default value supplier + * @param parser converts the stored string into the value + * @param serializer converts the value into the stored string + * @param the value type + * @since 0.32.0 + */ + @SuppressWarnings("PatternValidation") + public record Field( + @Pattern(KEY_PATTERN) String key, + Supplier defaultValue, + Function parser, + Function serializer + ) { + private static final String KEY_PATTERN = "[A-Za-z0-9]+([._-][A-Za-z0-9]+)*"; + private static final java.util.regex.Pattern KEY = java.util.regex.Pattern.compile(KEY_PATTERN); + + /** + * @throws IllegalArgumentException if the key does not match the key pattern + */ + public Field { + if (!KEY.matcher(key).matches()) throw new IllegalArgumentException("Invalid field key '" + key + + "', only ASCII letters and digits optionally separated by single '-', '_' or '.' are allowed"); + } + + @Contract(value = "_, _ -> new", pure = true) + public static Field bool(@Pattern(KEY_PATTERN) final String key, final boolean defaultValue) { + return new Field<>(key, () -> defaultValue, Boolean::parseBoolean, String::valueOf); + } + + @Contract(value = "_, _ -> new", pure = true) + public static Field integer(@Pattern(KEY_PATTERN) final String key, final int defaultValue) { + return new Field<>(key, () -> defaultValue, Integer::parseInt, String::valueOf); + } + + @Contract(value = "_, _ -> new", pure = true) + public static Field string(@Pattern(KEY_PATTERN) final String key, final String defaultValue) { + return new Field<>(key, () -> defaultValue, Function.identity(), Function.identity()); + } + } +} diff --git a/config/src/main/java/dev/faststats/config/PropertiesFile.java b/config/src/main/java/dev/faststats/config/PropertiesFile.java new file mode 100644 index 00000000..6e42445b --- /dev/null +++ b/config/src/main/java/dev/faststats/config/PropertiesFile.java @@ -0,0 +1,225 @@ +package dev.faststats.config; + +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CodingErrorAction; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.Normalizer; +import java.util.Comparator; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +import static java.nio.charset.StandardCharsets.ISO_8859_1; +import static java.nio.charset.StandardCharsets.UTF_16BE; +import static java.nio.charset.StandardCharsets.UTF_16LE; +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Lenient reading and writing of properties files. + */ +final class PropertiesFile { + private static final Charset UTF_32LE = Charset.forName("UTF-32LE"); + private static final Charset UTF_32BE = Charset.forName("UTF-32BE"); + private static final Comparator KEY_ORDER = String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder()); + private static final Pattern MALFORMED_UNICODE_ESCAPE = Pattern.compile("\\\\u(?![0-9a-fA-F]{4})"); + + private PropertiesFile() { + } + + /** + * The contents of a properties file. + * + * @param properties the properties + * @param foreignEncoding whether the file is not plain UTF-8 and should be rewritten + */ + record Contents(Properties properties, boolean foreignEncoding) { + } + + /** + * Reads the properties file, tolerating broken encodings and escapes. + * + * @return the contents, or {@code null} if the file does not exist + * @throws IOException if the file exists but cannot be read + */ + static @Nullable Contents read(final Path file) throws IOException { + if (Files.notExists(file)) return null; + if (!Files.isRegularFile(file)) throw new FileSystemException(file.toString(), null, "Not a regular file"); + + final var decoded = decode(Files.readAllBytes(file)); + try { + return new Contents(load(decoded.content()), decoded.foreignEncoding()); + } catch (final IllegalArgumentException e) { + try { + final var content = MALFORMED_UNICODE_ESCAPE.matcher(decoded.content()).replaceAll("\\\\\\\\u"); + return new Contents(load(content), decoded.foreignEncoding()); + } catch (final IllegalArgumentException again) { + throw new IOException("Malformed config file", again); + } + } + } + + /** + * Writes the properties sorted alphabetically by key. + */ + static void write(final Path file, final Properties properties, @Nullable final String comment) throws IOException { + final var sorted = new Properties() { + @Override + public Set> entrySet() { + final var entries = new TreeSet>(Comparator.comparing( + entry -> (String) entry.getKey(), KEY_ORDER)); + entries.addAll(super.entrySet()); + return entries; + } + }; + sorted.putAll(properties); + + final var parent = file.getParent(); + if (parent != null) Files.createDirectories(parent); + try (final var writer = Files.newBufferedWriter(file, UTF_8)) { + sorted.store(writer, comment); + } + } + + /** + * Finds the key actually used in the file, see {@link #normalize(String)}. + *

+ * If the key is spelled in multiple ways, the exact spelling wins, + * otherwise the alphabetically first one. + * + * @return the key as written in the file, or {@code null} if absent + */ + static @Nullable String findKey(final Properties properties, final String key) { + if (properties.containsKey(key)) return key; + final var normalized = normalize(key); + return properties.stringPropertyNames().stream() + .filter(name -> normalize(name).equals(normalized)) + .min(KEY_ORDER) + .orElse(null); + } + + /** + * Removes all spellings of the key that differ from the given one. + */ + static void removeVariants(final Properties properties, final String key) { + final var normalized = normalize(key); + for (final var name : properties.stringPropertyNames()) + if (!name.equals(key) && normalize(name).equals(normalized)) properties.remove(name); + } + + /** + * Normalizes a key, ignoring case, whitespace, invisible characters, compatibility forms + * like fullwidth letters, and the separators {@code -}, {@code _} and {@code .}. + */ + static String normalize(final String key) { + final var builder = new StringBuilder(key.length()); + Normalizer.normalize(key, Normalizer.Form.NFKC).codePoints() + .filter(c -> !isBlank(c) && c != '-' && c != '_' && c != '.') + .forEach(builder::appendCodePoint); + return builder.toString().toLowerCase(Locale.ROOT); + } + + /** + * Removes leading and trailing whitespace, including non-breaking spaces and invisible characters. + */ + static String clean(final String value) { + var start = 0; + var end = value.length(); + while (start < end && isBlank(value.charAt(start))) start++; + while (end > start && isBlank(value.charAt(end - 1))) end--; + return value.substring(start, end); + } + + private static boolean isBlank(final int c) { + return Character.isWhitespace(c) || Character.isSpaceChar(c) || Character.getType(c) == Character.FORMAT; + } + + private static Properties load(final String content) throws IOException, IllegalArgumentException { + final var properties = new Properties(); + properties.load(new StringReader(content)); + return properties; + } + + private record Decoded(String content, boolean foreignEncoding) { + } + + private static Decoded decode(final byte[] bytes) { + final var charset = detectCharset(bytes); + if (charset != null) { + final var offset = bomLength(bytes, charset); + return new Decoded(stripBom(new String(bytes, offset, bytes.length - offset, charset)), true); + } + try { + final var content = UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + return new Decoded(content, false); + } catch (final CharacterCodingException e) { + return new Decoded(stripBom(new String(bytes, ISO_8859_1)), true); + } + } + + private static String stripBom(final String content) { + return content.startsWith("\uFEFF") ? content.substring(1) : content; + } + + /** + * Detects files that are not BOM-less UTF-8, using the byte order mark or, + * for mostly ASCII content without one, the distribution of zero bytes. + * + * @return the charset, or {@code null} for BOM-less UTF-8 or unknown encodings + */ + private static @Nullable Charset detectCharset(final byte[] bytes) { + if (startsWith(bytes, 0xFF, 0xFE, 0x00, 0x00)) return UTF_32LE; + if (startsWith(bytes, 0x00, 0x00, 0xFE, 0xFF)) return UTF_32BE; + if (startsWith(bytes, 0xEF, 0xBB, 0xBF)) return UTF_8; + if (startsWith(bytes, 0xFE, 0xFF)) return UTF_16BE; + if (startsWith(bytes, 0xFF, 0xFE)) return UTF_16LE; + + final var zeros = new int[4]; + var anyZero = false; + for (var i = 0; i < bytes.length; i++) { + if (bytes[i] != 0) continue; + zeros[i % 4]++; + anyZero = true; + } + if (!anyZero) return null; + + final var quarter = bytes.length / 4 / 2; + if (zeros[0] <= quarter && zeros[1] > quarter && zeros[2] > quarter && zeros[3] > quarter) return UTF_32LE; + if (zeros[0] > quarter && zeros[1] > quarter && zeros[2] > quarter && zeros[3] <= quarter) return UTF_32BE; + + final var half = bytes.length / 2 / 2; + final var even = zeros[0] + zeros[2]; + final var odd = zeros[1] + zeros[3]; + if (odd > half && even <= half / 2) return UTF_16LE; + if (even > half && odd <= half / 2) return UTF_16BE; + return null; + } + + private static int bomLength(final byte[] bytes, final Charset charset) { + if (charset == UTF_32LE) return startsWith(bytes, 0xFF, 0xFE, 0x00, 0x00) ? 4 : 0; + if (charset == UTF_32BE) return startsWith(bytes, 0x00, 0x00, 0xFE, 0xFF) ? 4 : 0; + if (charset == UTF_8) return startsWith(bytes, 0xEF, 0xBB, 0xBF) ? 3 : 0; + if (charset == UTF_16BE) return startsWith(bytes, 0xFE, 0xFF) ? 2 : 0; + return startsWith(bytes, 0xFF, 0xFE) ? 2 : 0; + } + + private static boolean startsWith(final byte[] bytes, final int... prefix) { + if (bytes.length < prefix.length) return false; + for (var i = 0; i < prefix.length; i++) if ((bytes[i] & 0xFF) != prefix[i]) return false; + return true; + } +} diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java deleted file mode 100644 index 0c4f8d29..00000000 --- a/config/src/main/java/dev/faststats/config/SimpleConfig.java +++ /dev/null @@ -1,334 +0,0 @@ -package dev.faststats.config; - -import dev.faststats.Config; -import dev.faststats.SimpleContext; -import dev.faststats.internal.Logger; -import dev.faststats.internal.LoggerFactory; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Properties; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Function; -import java.util.function.Supplier; - -import static java.nio.charset.StandardCharsets.UTF_8; - -@ApiStatus.Internal -public final class SimpleConfig implements Config { - private static final int CONFIG_VERSION = 3; - private static final String CONSENT_SCREEN_SHOWN = "consent-screen-shown"; - - private static final String COMMENT = """ - FastStats (https://faststats.dev) collects pseudonymous usage statistics and errors. - # This helps developers understand how their projects are used in the real world. - # - # No IP addresses, player data, or personal information is collected. - # The server ID below is randomly generated and can be regenerated at any time. - # - # Enabling metrics has no noticeable performance impact. - # Keeping FastStats enabled is recommended. - # To disable all FastStats features, set 'enabled=false'. - # To disable only metrics submission, set 'submitMetrics=false'. - # To disable only additional metrics, set 'submitAdditionalMetrics=false'. - # To disable only error tracking, set 'submitErrors=false'. - # - # If you suspect a developer is collecting personal data or bypassing any opt-out option, - # please report it at: https://faststats.dev/abuse - # - # For more information, visit: https://faststats.dev/info - """; - private static final String ONBOARDING_MESSAGE = """ - This plugin uses FastStats to collect pseudonymous usage statistics and errors. - No personal or identifying information is ever collected. - To opt out, set 'enabled=false' in the metrics configuration file. - Learn more at: https://faststats.dev/info - - Since this is your first start with FastStats, submission will not start - until after a restart, to allow you to opt out if you prefer."""; - - private final Path file; - private final UUID serverId; - private final boolean debug; - private final boolean firstRun; - private final boolean client; - private volatile boolean additionalMetrics; - private volatile boolean enabled; - private volatile boolean errorTracking; - private volatile boolean submitMetrics; - private volatile boolean consentScreenShown; - - public SimpleConfig( - final Path file, - final UUID serverId, - final boolean enabled, - final boolean additionalMetrics, - final boolean debug, - final boolean submitMetrics, - final boolean errorTracking, - final boolean firstRun, - final boolean client, - final boolean consentScreenShown - ) { - this.file = file; - this.serverId = serverId; - this.enabled = enabled; - this.additionalMetrics = additionalMetrics; - this.debug = debug; - this.submitMetrics = submitMetrics; - this.errorTracking = errorTracking; - this.firstRun = firstRun; - this.client = client; - this.consentScreenShown = consentScreenShown; - } - - @Override - public UUID serverId() { - return serverId; - } - - @Override - public boolean enabled() { - return enabled; - } - - public void enabled(final boolean enabled) { - this.enabled = enabled; - } - - @Override - public boolean additionalMetrics() { - return additionalMetrics; - } - - public void additionalMetrics(final boolean additionalMetrics) { - this.additionalMetrics = additionalMetrics; - } - - @Override - public boolean debug() { - return debug; - } - - @Override - public boolean submitMetrics() { - return submitMetrics; - } - - public void submitMetrics(final boolean submitMetrics) { - this.submitMetrics = submitMetrics; - } - - @Override - public boolean errorTracking() { - return errorTracking; - } - - public void errorTracking(final boolean errorTracking) { - this.errorTracking = errorTracking; - } - - public boolean firstRun() { - return firstRun; - } - - public boolean consentScreenShown() { - return consentScreenShown; - } - - public void consentScreenShown(final boolean consentScreenShown) { - this.consentScreenShown = consentScreenShown; - } - - /** - * @return {@code false} if FastStats was disabled using {@code -Dfaststats.enabled=false} - */ - public static boolean enabledFlag() { - return Boolean.parseBoolean(System.getProperty("faststats.enabled", "true")); - } - - @Contract(mutates = "io") - public static SimpleConfig read(final Path file, final LoggerFactory factory) throws RuntimeException { - return read(file, factory, false); - } - - /** - * Reads the config from the given file. - * - * @param client whether the config belongs to a client, only clients store client specific options - */ - @Contract(mutates = "io") - public static SimpleConfig read(final Path file, final LoggerFactory factory, final boolean client) throws RuntimeException { - final var logger = factory.getLogger(SimpleConfig.class); - - final var debugFlag = Boolean.getBoolean("faststats.debug"); - final var enabledFlag = enabledFlag(); - - final var properties = readOrEmpty(file); - final var firstRun = properties == null; - final var saveConfig = new AtomicBoolean(firstRun); - - final var serverId = parse(properties, saveConfig, "serverId", UUID::randomUUID, value -> { - final var corrected = value.length() > 36 ? value.substring(0, 36) : value; - final var uuid = UUID.fromString(corrected); - if (!value.equals(uuid.toString())) saveConfig.set(true); - return uuid; - }, logger); - final var configVersion = parse(properties, saveConfig, "configVersion", null, Integer::parseInt, logger); - final var enabled = parse(properties, saveConfig, "enabled", () -> true, Boolean::parseBoolean, logger); - final var submitMetrics = parse(properties, saveConfig, "submitMetrics", () -> true, Boolean::parseBoolean, logger); - final var errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean, logger); - final var additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean, logger); - final var debug = parse(properties, saveConfig, "debug", () -> false, Boolean::parseBoolean, logger); - final var consentScreenShown = client && parse(properties, saveConfig, CONSENT_SCREEN_SHOWN, () -> false, Boolean::parseBoolean, logger); - - if (configVersion == null || configVersion < CONFIG_VERSION) saveConfig.set(true); - else if (configVersion > CONFIG_VERSION) saveConfig.set(false); - - if (saveConfig.get()) try { - if (configVersion != null && configVersion < CONFIG_VERSION) - logger.info("Updating config version from %s to %s", configVersion, CONFIG_VERSION); - Files.createDirectories(file.getParent()); - try (final var writer = Files.newBufferedWriter(file, UTF_8)) { - final var store = new Properties(); - - store.setProperty("enabled", Boolean.toString(enabled)); - store.setProperty("submitMetrics", Boolean.toString(submitMetrics)); - store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); - store.setProperty("submitErrors", Boolean.toString(errorTracking)); - - store.setProperty("serverId", serverId.toString()); - - store.setProperty("debug", Boolean.toString(debug)); - if (client) store.setProperty(CONSENT_SCREEN_SHOWN, Boolean.toString(consentScreenShown)); - store.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); - - store.store(writer, COMMENT); - } - } catch (final IOException e) { - throw new RuntimeException("Failed to save metrics config", e); - } - - return new SimpleConfig( - file, - serverId, - enabled && enabledFlag, - enabled && enabledFlag && additionalMetrics, - debug || debugFlag, - enabled && enabledFlag && submitMetrics, - enabled && enabledFlag && errorTracking, - firstRun, - client, - consentScreenShown - ); - } - - @Contract(mutates = "io") - public synchronized void persist() throws RuntimeException { - var properties = readOrEmpty(file); - if (properties == null) { - // the file was deleted after it was read, recreate it - properties = new Properties(); - properties.setProperty("serverId", serverId.toString()); - properties.setProperty("debug", Boolean.toString(debug)); - properties.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); - } - - properties.setProperty("enabled", Boolean.toString(enabled())); - properties.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics())); - properties.setProperty("submitErrors", Boolean.toString(errorTracking())); - properties.setProperty("submitMetrics", Boolean.toString(submitMetrics())); - if (client) properties.setProperty(CONSENT_SCREEN_SHOWN, Boolean.toString(consentScreenShown())); - else properties.remove(CONSENT_SCREEN_SHOWN); - - try { - Files.createDirectories(file.getParent()); - } catch (final IOException e) { - throw new RuntimeException("Failed to create metrics config directory", e); - } - try (final var writer = Files.newBufferedWriter(file, UTF_8)) { - properties.store(writer, COMMENT); - } catch (final IOException e) { - throw new RuntimeException("Failed to save metrics config", e); - } - } - - // fixme: this code sucks ass - @Contract(value = "_, _, _, !null, _, _-> !null") - private static @Nullable T parse( - @Nullable final Properties properties, - final AtomicBoolean saveConfig, - final String key, - @Nullable final Supplier defaultValue, - final Function parser, - final Logger logger - ) { - if (properties == null) { - saveConfig.set(true); - return defaultValue != null ? defaultValue.get() : null; - } - final var property = properties.getProperty(key); - if (property == null) { - logger.warn("Missing configuration property: %s", key); - saveConfig.set(true); - return defaultValue != null ? defaultValue.get() : null; - } - try { - return parser.apply(property.trim()); - } catch (final Exception e) { - logger.error("Failed to read property '%s' from config", e, key); - saveConfig.set(true); - return defaultValue != null ? defaultValue.get() : null; - } - } - - private static @Nullable Properties readOrEmpty(final Path file) throws RuntimeException { - if (!Files.isRegularFile(file)) return null; - try (final var reader = Files.newBufferedReader(file, UTF_8)) { - final var properties = new Properties(); - properties.load(reader); - return properties; - } catch (final IOException e) { - throw new RuntimeException("Failed to read metrics config", e); - } - } - - public boolean preSubmissionStart(final SimpleContext context) { - if (Boolean.getBoolean("faststats.first-run")) return false; - - if (firstRun()) { - var separatorLength = 0; - final var split = ONBOARDING_MESSAGE.split("\n"); - for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); - - final var logger = context.getLoggerFactory().getLogger(getClass()); - logger.print(Logger.LogLevel.INFO, null, "-".repeat(separatorLength)); - for (final var s : split) logger.print(Logger.LogLevel.INFO, null, s); - logger.print(Logger.LogLevel.INFO, null, "-".repeat(separatorLength)); - - System.setProperty("faststats.first-run", "true"); - return false; - } - return true; - } - - @Override - public String toString() { - return "SimpleConfig{" + - "serverId=" + serverId + - ", debug=" + debug + - ", firstRun=" + firstRun + - ", client=" + client + - ", consentScreenShown=" + consentScreenShown + - ", additionalMetrics=" + additionalMetrics + - ", enabled=" + enabled + - ", errorTracking=" + errorTracking + - ", submitMetrics=" + submitMetrics + - '}'; - } -} diff --git a/config/src/test/java/dev/faststats/config/CustomConfigTest.java b/config/src/test/java/dev/faststats/config/CustomConfigTest.java new file mode 100644 index 00000000..c9489d43 --- /dev/null +++ b/config/src/test/java/dev/faststats/config/CustomConfigTest.java @@ -0,0 +1,486 @@ +package dev.faststats.config; + +import dev.faststats.internal.PlatformLoggerFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class CustomConfigTest { + private static final CustomConfig.Field SCREEN_SEEN = CustomConfig.Field.bool("onboarding-screen-seen", false); + private static final CustomConfig.Field COUNTER = CustomConfig.Field.integer("counter", 5); + + private final List messages = new CopyOnWriteArrayList<>(); + private final PlatformLoggerFactory loggerFactory = new PlatformLoggerFactory((level, throwable, message) -> messages.add(message)); + + @TempDir + private Path directory; + private Path file; + + @BeforeEach + public void setUp() { + loggerFactory.setDebug(true); + file = directory.resolve("faststats").resolve("config.properties"); + } + + @AfterEach + public void tearDown() { + System.clearProperty("faststats.enabled"); + System.clearProperty("faststats.debug"); + } + + private CustomConfig.Builder builder() { + return CustomConfig.builder(file, loggerFactory); + } + + private Properties load() throws IOException { + try (final var reader = Files.newBufferedReader(file, UTF_8)) { + final var properties = new Properties(); + properties.load(reader); + return properties; + } + } + + private void store(final String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content, UTF_8); + } + + private static String validConfig(final String... extraLines) { + return String.join("\n", List.of( + "enabled=true", + "submitMetrics=true", + "submitAdditionalMetrics=true", + "submitErrors=true", + "serverId=" + UUID.randomUUID(), + "debug=false", + "configVersion=3" + )) + "\n" + String.join("\n", extraLines); + } + + @Test + public void firstRunCreatesFileWithDefaults() throws IOException { + final var config = builder().build(); + + assertTrue(config.firstRun()); + assertTrue(Files.isRegularFile(file)); + assertTrue(config.enabled()); + assertTrue(config.submitMetrics()); + assertTrue(config.additionalMetrics()); + assertTrue(config.errorTracking()); + assertFalse(config.debug()); + + final var properties = load(); + assertEquals("true", properties.getProperty("enabled")); + assertEquals("true", properties.getProperty("submitMetrics")); + assertEquals("true", properties.getProperty("submitAdditionalMetrics")); + assertEquals("true", properties.getProperty("submitErrors")); + assertEquals("false", properties.getProperty("debug")); + assertEquals("3", properties.getProperty("configVersion")); + assertEquals(config.serverId().toString(), properties.getProperty("serverId")); + } + + @Test + public void secondReadKeepsServerIdAndFile() throws IOException { + final var first = builder().build(); + final var content = Files.readString(file, UTF_8); + + final var second = builder().build(); + + assertFalse(second.firstRun()); + assertEquals(first.serverId(), second.serverId()); + assertEquals(content, Files.readString(file, UTF_8)); + } + + @Test + public void defaultCommentIsWritten() throws IOException { + builder().build(); + assertTrue(Files.readString(file, UTF_8).contains("https://faststats.dev/abuse")); + } + + @Test + public void customCommentIsWritten() throws IOException { + builder().comment("My custom header\nSecond line").build(); + + final var lines = Files.readAllLines(file, UTF_8); + assertEquals("#My custom header", lines.get(0)); + assertEquals("#Second line", lines.get(1)); + assertFalse(String.join("\n", lines).contains("https://faststats.dev/abuse")); + } + + @Test + public void nullCommentOmitsHeader() throws IOException { + builder().comment(null).build(); + final var comments = Files.readAllLines(file, UTF_8).stream().filter(line -> line.startsWith("#")).toList(); + assertEquals(1, comments.size()); + } + + @Test + public void customFieldDefaultIsWritten() throws IOException { + final var config = builder().field(SCREEN_SEEN).field(COUNTER).build(); + + assertFalse(config.get(SCREEN_SEEN)); + assertEquals(5, config.get(COUNTER)); + assertEquals("false", load().getProperty("onboarding-screen-seen")); + assertEquals("5", load().getProperty("counter")); + } + + @Test + public void customFieldIsReadFromFile() throws IOException { + store(validConfig("onboarding-screen-seen=true", "counter=42")); + + final var config = builder().field(SCREEN_SEEN).field(COUNTER).build(); + + assertTrue(config.get(SCREEN_SEEN)); + assertEquals(42, config.get(COUNTER)); + } + + @Test + public void customFieldPersists() throws IOException { + final var config = builder().field(SCREEN_SEEN).build(); + config.set(SCREEN_SEEN, true); + config.persist(); + + assertEquals("true", load().getProperty("onboarding-screen-seen")); + assertTrue(builder().field(SCREEN_SEEN).build().get(SCREEN_SEEN)); + } + + @Test + public void missingCustomFieldIsAddedToExistingFile() throws IOException { + store(validConfig()); + + builder().field(SCREEN_SEEN).build(); + + assertEquals("false", load().getProperty("onboarding-screen-seen")); + assertTrue(messages.stream().anyMatch(message -> message.contains("Missing FastStats config property 'onboarding-screen-seen'"))); + } + + @Test + public void customFieldWithCustomType() throws IOException { + final var field = new CustomConfig.Field<>("tags", List::of, + value -> List.of(value.split(",")), value -> String.join(",", value)); + store(validConfig("tags=a,b,c")); + + final var config = builder().field(field).build(); + + assertEquals(List.of("a", "b", "c"), config.get(field)); + } + + @Test + public void duplicateFieldIsRejected() { + final var builder = builder().field(SCREEN_SEEN); + assertThrows(IllegalArgumentException.class, () -> builder.field(CustomConfig.Field.bool("onboarding-screen-seen", true))); + assertThrows(IllegalArgumentException.class, () -> builder.field(CustomConfig.Field.bool("enabled", true))); + assertThrows(IllegalArgumentException.class, () -> builder.field(CustomConfig.Field.integer("configVersion", 1))); + assertThrows(IllegalArgumentException.class, () -> builder.field(CustomConfig.Field.bool("Onboarding-Screen-Seen", true))); + assertThrows(IllegalArgumentException.class, () -> builder.field(CustomConfig.Field.bool("ENABLED", true))); + assertThrows(IllegalArgumentException.class, () -> builder.field(CustomConfig.Field.bool("onboarding_screen_seen", true))); + assertThrows(IllegalArgumentException.class, () -> builder.field(CustomConfig.Field.bool("submit-metrics", true))); + } + + @Test + public void unregisteredFieldIsRejected() { + final var config = builder().build(); + + assertFalse(config.has(SCREEN_SEEN)); + assertThrows(IllegalArgumentException.class, () -> config.get(SCREEN_SEEN)); + assertThrows(IllegalArgumentException.class, () -> config.set(SCREEN_SEEN, true)); + } + + @Test + public void fieldWithSameKeyButDifferentInstanceIsNotRegistered() { + final var config = builder().field(SCREEN_SEEN).build(); + + final var other = CustomConfig.Field.bool("onboarding-screen-seen", false); + assertTrue(config.has(SCREEN_SEEN)); + assertFalse(config.has(other)); + } + + @Test + public void defaultFieldsAreRegistered() { + final var config = builder().build(); + + assertTrue(config.has(CustomConfig.ENABLED)); + assertTrue(config.has(CustomConfig.SUBMIT_METRICS)); + assertTrue(config.has(CustomConfig.SUBMIT_ADDITIONAL_METRICS)); + assertTrue(config.has(CustomConfig.SUBMIT_ERRORS)); + assertTrue(config.has(CustomConfig.SERVER_ID)); + assertTrue(config.has(CustomConfig.DEBUG)); + } + + @Test + public void invalidValueFallsBackToDefaultAndIsRewritten() throws IOException { + store(validConfig("counter=not-a-number")); + + final var config = builder().field(COUNTER).build(); + + assertEquals(5, config.get(COUNTER)); + assertEquals("5", load().getProperty("counter")); + } + + @Test + public void invalidServerIdIsRegenerated() throws IOException { + store(validConfig().replaceFirst("serverId=.*", "serverId=invalid")); + + final var config = builder().build(); + + assertEquals(config.serverId().toString(), load().getProperty("serverId")); + } + + @Test + public void tooLongServerIdIsTruncated() throws IOException { + final var serverId = UUID.randomUUID(); + store(validConfig().replaceFirst("serverId=.*", "serverId=" + serverId + "garbage")); + + final var config = builder().build(); + + assertEquals(serverId, config.serverId()); + assertEquals(serverId.toString(), load().getProperty("serverId")); + } + + @Test + public void nonCanonicalValueIsRewritten() throws IOException { + store(validConfig().replace("enabled=true", "enabled=TRUE")); + + builder().build(); + + assertEquals("true", load().getProperty("enabled")); + } + + @Test + public void valuesAreTrimmed() throws IOException { + store(validConfig("counter= 7 ")); + + assertEquals(7, builder().field(COUNTER).build().get(COUNTER)); + } + + @Test + public void olderConfigVersionIsUpgraded() throws IOException { + store(validConfig().replace("configVersion=3", "configVersion=2")); + + builder().build(); + + assertEquals("3", load().getProperty("configVersion")); + assertTrue(messages.stream().anyMatch(message -> message.contains("Updating config version from 2 to 3"))); + } + + @Test + public void missingConfigVersionIsAdded() throws IOException { + store(validConfig().replace("configVersion=3", "")); + + builder().build(); + + assertEquals("3", load().getProperty("configVersion")); + } + + @Test + public void newerConfigVersionIsNotOverwritten() throws IOException { + final var content = validConfig("counter=invalid").replace("configVersion=3", "configVersion=99"); + store(content); + + final var config = builder().field(COUNTER).build(); + + assertEquals(5, config.get(COUNTER)); + assertEquals(content, Files.readString(file, UTF_8)); + } + + @Test + public void disabledConfigDisablesAllFeatures() throws IOException { + store(validConfig().replace("enabled=true", "enabled=false")); + + final var config = builder().build(); + + assertFalse(config.enabled()); + assertFalse(config.submitMetrics()); + assertFalse(config.additionalMetrics()); + assertFalse(config.errorTracking()); + } + + @Test + public void individualFeaturesCanBeDisabled() throws IOException { + store(validConfig() + .replace("submitMetrics=true", "submitMetrics=false") + .replace("submitErrors=true", "submitErrors=false")); + + final var config = builder().build(); + + assertTrue(config.enabled()); + assertFalse(config.submitMetrics()); + assertTrue(config.additionalMetrics()); + assertFalse(config.errorTracking()); + } + + @Test + public void enabledFlagDisablesFeaturesWithoutChangingFile() throws IOException { + System.setProperty("faststats.enabled", "false"); + + final var config = builder().build(); + + assertFalse(CustomConfig.enabledFlag()); + assertFalse(config.enabled()); + assertFalse(config.submitMetrics()); + assertFalse(config.additionalMetrics()); + assertFalse(config.errorTracking()); + assertEquals("true", load().getProperty("enabled")); + assertEquals("true", load().getProperty("submitMetrics")); + } + + @Test + public void debugFlagIsNotPersisted() throws IOException { + System.setProperty("faststats.debug", "true"); + + final var config = builder().build(); + config.persist(); + + assertTrue(config.debug()); + assertEquals("false", load().getProperty("debug")); + } + + @Test + public void enabledOverrideIsNotStored() throws IOException { + final var config = builder().enabled(false).build(); + + assertFalse(config.enabled()); + assertFalse(config.submitMetrics()); + assertFalse(load().containsKey("enabled")); + + config.persist(); + assertFalse(load().containsKey("enabled")); + } + + @Test + public void enabledOverrideIgnoresFile() throws IOException { + store(validConfig().replace("enabled=true", "enabled=false")); + + final var config = builder().enabled(true).build(); + + assertTrue(config.enabled()); + assertTrue(config.submitMetrics()); + } + + @Test + public void persistWritesChangedSettings() throws IOException { + final var config = builder().build(); + config.submitMetrics(false); + config.errorTracking(false); + config.additionalMetrics(false); + config.persist(); + + final var properties = load(); + assertEquals("false", properties.getProperty("submitMetrics")); + assertEquals("false", properties.getProperty("submitErrors")); + assertEquals("false", properties.getProperty("submitAdditionalMetrics")); + assertEquals("true", properties.getProperty("enabled")); + } + + @Test + public void persistKeepsUnknownKeys() throws IOException { + store(validConfig("unknown-key=value")); + + final var config = builder().build(); + config.persist(); + + assertEquals("value", load().getProperty("unknown-key")); + } + + @Test + public void persistRecreatesDeletedFile() throws IOException { + final var config = builder().field(SCREEN_SEEN).build(); + Files.delete(file); + + config.persist(); + + final var properties = load(); + assertEquals(config.serverId().toString(), properties.getProperty("serverId")); + assertEquals("3", properties.getProperty("configVersion")); + assertEquals("false", properties.getProperty("onboarding-screen-seen")); + } + + @Test + public void persistUsesCustomComment() throws IOException { + final var config = builder().comment("Custom").build(); + config.persist(); + + assertEquals("#Custom", Files.readAllLines(file, UTF_8).get(0)); + } + + @Test + public void setRejectsNull() { + final var config = builder().field(SCREEN_SEEN).build(); + @SuppressWarnings("DataFlowIssue") + final Runnable set = () -> config.set(SCREEN_SEEN, null); + assertThrows(NullPointerException.class, set::run); + } + + @Test + public void differentFilesGetDifferentServerIds() { + final var first = builder().build(); + final var second = CustomConfig.builder(directory.resolve("other.properties"), loggerFactory).build(); + + assertNotEquals(first.serverId(), second.serverId()); + } + + @Test + public void entriesAreSortedAlphabetically() throws IOException { + store(validConfig("zeta=1", "Alpha=2", "beta=3", "counter=4")); + + final var config = builder().field(COUNTER).field(SCREEN_SEEN).build(); + assertKeysSorted(); + + config.persist(); + assertKeysSorted(); + } + + @Test + public void newFileIsSortedAlphabetically() throws IOException { + builder().field(SCREEN_SEEN).field(COUNTER).build(); + + assertKeysSorted(); + } + + private void assertKeysSorted() throws IOException { + final var keys = Files.readAllLines(file, UTF_8).stream() + .filter(line -> !line.isBlank() && !line.startsWith("#")) + .map(line -> line.substring(0, line.indexOf('='))) + .toList(); + final var sorted = keys.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList(); + assertEquals(sorted, keys); + assertFalse(keys.isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { + "", " ", "enabled ", " enabled", "on boarding", "a=b", "a:b", "#comment", "!comment", + "-key", "key-", "_key", "key_", ".key", "key.", "a--b", "a-_b", "a..b", + "k\u00E4se", "\uFF4B\uFF45\uFF59", "key\u00A0", "\u200Bkey", "key\u2060", "key\t", "key\n", "a/b", "a\\b" + }) + @SuppressWarnings("PatternValidation") + public void invalidFieldKeyIsRejected(final String key) { + assertThrows(IllegalArgumentException.class, () -> CustomConfig.Field.bool(key, false)); + assertThrows(IllegalArgumentException.class, () -> CustomConfig.Field.string(key, "")); + assertThrows(IllegalArgumentException.class, () -> new CustomConfig.Field<>(key, () -> "", value -> value, value -> value)); + } + + @ParameterizedTest + @ValueSource(strings = {"enabled", "submitMetrics", "consent-screen-shown", "onboarding_screen_seen", "a.b.c", "ENABLED", "v2", "1", "a-b_c.d"}) + public void validFieldKeyIsAccepted(final String key) { + assertEquals(key, CustomConfig.Field.bool(key, false).key()); + } +} diff --git a/config/src/test/java/dev/faststats/config/MisconfiguredConfigTest.java b/config/src/test/java/dev/faststats/config/MisconfiguredConfigTest.java new file mode 100644 index 00000000..09ed1c15 --- /dev/null +++ b/config/src/test/java/dev/faststats/config/MisconfiguredConfigTest.java @@ -0,0 +1,567 @@ +package dev.faststats.config; + +import dev.faststats.internal.PlatformLoggerFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Properties; +import java.util.Random; +import java.util.UUID; + +import static java.nio.charset.StandardCharsets.ISO_8859_1; +import static java.nio.charset.StandardCharsets.UTF_16LE; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public final class MisconfiguredConfigTest { + private final PlatformLoggerFactory loggerFactory = new PlatformLoggerFactory((level, throwable, message) -> { + }); + + @TempDir + private Path directory; + private Path file; + + @BeforeEach + public void setUp() { + file = directory.resolve("faststats").resolve("config.properties"); + } + + private CustomConfig read() { + return assertDoesNotThrow(() -> CustomConfig.builder(file, loggerFactory).build()); + } + + private void store(final String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content, UTF_8); + } + + private Properties load() throws IOException { + try (final var reader = Files.newBufferedReader(file, UTF_8)) { + final var properties = new Properties(); + properties.load(reader); + return properties; + } + } + + private static void assertDisabled(final CustomConfig config) { + assertFalse(config.enabled(), "enabled"); + assertFalse(config.submitMetrics(), "submitMetrics"); + assertFalse(config.additionalMetrics(), "additionalMetrics"); + assertFalse(config.errorTracking(), "errorTracking"); + } + + @Test + public void onlyEnabledFalse() throws IOException { + store("enabled=false\n"); + + assertDisabled(read()); + final var properties = load(); + assertEquals("false", properties.getProperty("enabled")); + assertEquals("3", properties.getProperty("configVersion")); + assertDisabled(read()); + } + + @Test + public void onlyEnabledFalseWithNewerConfigVersion() throws IOException { + store("enabled=false\nconfigVersion=999\n"); + + assertDisabled(read()); + } + + @ParameterizedTest + @ValueSource(strings = {"false", "FALSE", "False", " false ", "no", "off", "0", "disabled", "DISABLED", "", "null"}) + public void enabledDisabledVariants(final String value) throws IOException { + store("enabled=" + value + "\n"); + + assertDisabled(read()); + } + + @ParameterizedTest + @ValueSource(strings = {"enabled: false", "enabled:false", "enabled : false", "enabled :false", "Enabled : false", " enabled : false ", "enabled false", "enabled = false", " enabled=false"}) + public void alternativeSeparators(final String line) throws IOException { + store(line + "\n"); + + assertDisabled(read()); + } + + @ParameterizedTest + @ValueSource(strings = {"DISABLED", "", "null", "none", "0", "-", "00000000-0000-0000-0000", "\\u0000"}) + public void invalidServerId(final String value) throws IOException { + store("serverId=" + value + "\nenabled=false\n"); + + final var config = read(); + assertDisabled(config); + assertEquals(config.serverId().toString(), load().getProperty("serverId")); + } + + @Test + public void serverIdUnderWrongKey() throws IOException { + store("server-id=DISABLED\nenabled=false\n"); + + assertDisabled(read()); + } + + @Test + public void shortFormServerIdIsNormalized() throws IOException { + store("serverId=1-2-3-4-5\n"); + + final var config = read(); + assertEquals(config.serverId().toString(), load().getProperty("serverId")); + } + + @Test + public void emptyFile() throws IOException { + store(""); + + final var config = read(); + assertTrue(config.firstRun()); + assertEquals(config.serverId().toString(), load().getProperty("serverId")); + } + + @Test + public void onlyComments() throws IOException { + store("# enabled=false\n! comment\n\n \n"); + + assertTrue(read().firstRun()); + } + + @Test + public void duplicateKeysLastOneWins() throws IOException { + store("enabled=true\nenabled=false\n"); + + assertDisabled(read()); + } + + @ParameterizedTest + @ValueSource(strings = {"abc", "", "-5", "99999999999", "3.0", "three"}) + public void invalidConfigVersion(final String value) throws IOException { + store("enabled=false\nconfigVersion=" + value + "\n"); + + assertDisabled(read()); + } + + @Test + public void allValuesEmpty() throws IOException { + store(""" + enabled= + submitMetrics= + submitAdditionalMetrics= + submitErrors= + serverId= + debug= + configVersion= + """); + + final var config = read(); + assertDisabled(config); + assertFalse(config.debug()); + } + + @Test + public void malformedUnicodeEscape() throws IOException { + store("enabled=false\nserverId=\\uZZZZ\n"); + + assertDisabled(read()); + } + + @Test + public void windowsPathBackslashes() throws IOException { + store("enabled=false\nsomething=C:\\users\\name\n"); + + assertDisabled(read()); + } + + @Test + public void latin1EncodedFile() throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, "# Datenschutz: Ä Ö Ü ß\nenabled=false\n", ISO_8859_1); + + assertDisabled(read()); + } + + @Test + public void utf8ByteOrderMark() throws IOException { + store("\uFEFFenabled=false\n"); + + assertDisabled(read()); + } + + @Test + public void binaryGarbage() throws IOException { + Files.createDirectories(file.getParent()); + final var bytes = new byte[1024]; + new Random(42).nextBytes(bytes); + Files.write(file, bytes); + + final var config = read(); + assertTrue(config.firstRun()); + assertEquals(config.serverId().toString(), load().getProperty("serverId")); + } + + @Test + public void readOnlyFileWithMissingKeys() throws IOException { + store("enabled=false\n"); + assertTrue(file.toFile().setReadOnly()); + + assertDisabled(read()); + assertEquals("enabled=false\n", Files.readString(file, UTF_8)); + } + + @Test + public void readOnlyFilePersist() throws IOException { + store("enabled=false\n"); + final var config = read(); + assertTrue(file.toFile().setReadOnly()); + + assertDoesNotThrow(config::persist); + } + + @Test + public void configPathIsDirectory() throws IOException { + Files.createDirectories(file); + + final var config = read(); + assertDisabled(config); + assertFalse(config.firstRun()); + assertTrue(Files.isDirectory(file)); + assertDoesNotThrow(config::persist); + } + + @Test + public void unreadableFileDisablesEvenWithEnabledOverride() throws IOException { + Files.createDirectories(file); + + assertDisabled(assertDoesNotThrow(() -> CustomConfig.builder(file, loggerFactory).enabled(true).build())); + } + + @Test + public void unknownKeysSurviveRewrite() throws IOException { + store("enabled=false\nmy-note=do not delete\n"); + + read(); + + assertEquals("do not delete", load().getProperty("my-note")); + } + + @ParameterizedTest + @ValueSource(strings = {"Enabled=false", "ENABLED=false", "Enabled = false", " Enabled = false ", "eNaBlEd:false", "\tEnabled\t=\tfalse\t"}) + public void keysAreCaseInsensitive(final String line) throws IOException { + store(line + "\n"); + + assertDisabled(read()); + final var properties = load(); + assertEquals("false", properties.getProperty("enabled")); + assertEquals(1, properties.stringPropertyNames().stream().filter("enabled"::equalsIgnoreCase).count()); + assertDisabled(read()); + } + + @Test + public void wrongCaseCustomKey() throws IOException { + final var field = CustomConfig.Field.bool("onboarding-screen-seen", false); + store("enabled=false\nOnboarding-Screen-Seen = true\n"); + + assertTrue(CustomConfig.builder(file, loggerFactory).field(field).build().get(field)); + assertEquals("true", load().getProperty("onboarding-screen-seen")); + assertFalse(load().containsKey("Onboarding-Screen-Seen")); + } + + @Test + public void exactSpellingWins() throws IOException { + store("enabled=false\nENABLED=true\n"); + + assertDisabled(read()); + assertSingleSpelling("enabled", "false"); + } + + @Test + public void exactSpellingWinsRegardlessOfPosition() throws IOException { + store("Enabled=true\nENABLED=true\nenabled=false\n"); + + assertDisabled(read()); + assertSingleSpelling("enabled", "false"); + } + + @Test + public void alphabeticallyFirstSpellingWinsWithoutExactKey() throws IOException { + // "ENABLED" sorts before "Enabled", regardless of the order in the file + store("Enabled=true\nENABLED=false\n"); + assertDisabled(read()); + assertSingleSpelling("enabled", "false"); + + store("ENABLED=false\nEnabled=true\n"); + assertDisabled(read()); + assertSingleSpelling("enabled", "false"); + } + + @ParameterizedTest + @ValueSource(strings = { + "submit_metrics", "submit-metrics", "submit.metrics", "SUBMIT_METRICS", "Submit-Metrics", + "submit\\ metrics", "submitmetrics", "_submitMetrics_", + "submit\u00A0metrics", "submitMetrics\u00A0", "\u00A0submitMetrics", "submit\u200Bmetrics", + "\u200BsubmitMetrics", "submitMetrics\u2060", "\uFF53\uFF55\uFF42\uFF4D\uFF49\uFF54metrics" + }) + public void similarKeysAreMerged(final String key) throws IOException { + store(key + "=false\n"); + + final var config = read(); + assertTrue(config.enabled()); + assertFalse(config.submitMetrics(), key); + assertSingleSpelling("submitMetrics", "false"); + + store("submitMetrics=true\n" + key + "=false\n"); + + assertTrue(read().submitMetrics(), key); + assertSingleSpelling("submitMetrics", "true"); + } + + @ParameterizedTest + @ValueSource(strings = {"false\u00A0", "\u00A0false", "false\u200B", "\u200Bfalse", "\uFEFFfalse", "false\u3000"}) + public void invisibleCharactersInValues(final String value) throws IOException { + store("enabled=" + value + "\nsubmitErrors=true" + value.replace("false", "") + "\n"); + + final var config = read(); + assertDisabled(config); + assertEquals("false", load().getProperty("enabled")); + } + + @Test + public void invisibleCharactersAroundTrueKeepTrue() throws IOException { + store("submitErrors=\u00A0true\u200B\n"); + + assertTrue(read().errorTracking()); + assertEquals("true", load().getProperty("submitErrors")); + } + + @Test + public void invisibleCharactersAroundServerIdKeepServerId() throws IOException { + final var serverId = UUID.randomUUID(); + store("serverId=\u00A0" + serverId + "\u200B\n"); + + assertEquals(serverId, read().serverId()); + assertEquals(serverId.toString(), load().getProperty("serverId")); + } + + @Test + public void everySpellingAtOnce() throws IOException { + store(""" + Enabled=true + ENABLED = true + en_abled=true + enabled=false + \u00A0enabled\u00A0=true + e-n-a-b-l-e-d: true + serverId=DISABLED + server_id=DISABLED + SERVER-ID=DISABLED + submit_metrics=false + submitMetrics=true + """); + + final var config = read(); + assertDisabled(config); + assertSingleSpelling("enabled", "false"); + assertSingleSpelling("serverId", config.serverId().toString()); + assertSingleSpelling("submitMetrics", "true"); + + config.persist(); + assertSingleSpelling("enabled", "false"); + assertSingleSpelling("serverId", config.serverId().toString()); + // persist writes the effective in-memory state, which is disabled + assertSingleSpelling("submitMetrics", "false"); + assertDisabled(read()); + } + + @Test + public void variantsAreRemovedOnPersistWithoutRewriteOnLoad() throws IOException { + // a newer config version prevents the rewrite on load, so the variants survive until persist + store("ENABLED=false\nconfigVersion=99\n"); + final var config = read(); + assertDisabled(config); + assertTrue(load().containsKey("ENABLED")); + + config.persist(); + + assertSingleSpelling("enabled", "false"); + } + + @Test + public void throwingSerializerKeepsOtherSpelling() throws IOException { + store("enabled=false\nBROKEN=keep me\n"); + + CustomConfig.builder(file, loggerFactory).field(BROKEN_SERIALIZER).build().persist(); + + assertEquals("keep me", load().getProperty("BROKEN")); + } + + private void assertSingleSpelling(final String key, final String value) throws IOException { + final var properties = load(); + final var spellings = properties.stringPropertyNames().stream() + .filter(name -> PropertiesFile.normalize(name).equals(PropertiesFile.normalize(key))) + .toList(); + assertEquals(List.of(key), spellings, "spellings of " + key); + assertEquals(value, properties.getProperty(key)); + } + + @Test + public void wrongCaseKeysArePersistedCanonically() throws IOException { + store("Enabled=false\nSUBMITMETRICS=false\n"); + final var config = read(); + + config.persist(); + + final var properties = load(); + assertEquals("false", properties.getProperty("enabled")); + assertEquals("false", properties.getProperty("submitMetrics")); + assertFalse(properties.containsKey("Enabled")); + assertFalse(properties.containsKey("SUBMITMETRICS")); + } + + @Test + public void typoInKeyIsKeptAndTreatedAsFirstRun() throws IOException { + store("enable=false\n"); + + // nothing we know of, so the user gets the onboarding message and a chance to opt out before anything is sent + assertTrue(read().firstRun()); + assertEquals("false", load().getProperty("enable")); + } + + @Test + public void keyFromOtherConfigFormats() throws IOException { + store("enabled = \"false\"\n"); + + assertDisabled(read()); + } + + @Test + public void yamlStyle() throws IOException { + store("enabled: false\nserverId: " + UUID.randomUUID() + "\n"); + + assertDisabled(read()); + } + + private static final CustomConfig.Field BROKEN_SERIALIZER = new CustomConfig.Field<>("broken", () -> "value", + value -> value, value -> { + throw new IllegalStateException("serializer bug"); + }); + + @Test + public void throwingSerializerDoesNotPreventSaving() throws IOException { + store("enabled=false\nbroken=keep me\n"); + + final var config = assertDoesNotThrow(() -> CustomConfig.builder(file, loggerFactory).field(BROKEN_SERIALIZER).build()); + + assertDisabled(config); + final var properties = load(); + assertEquals("false", properties.getProperty("enabled")); + assertEquals("3", properties.getProperty("configVersion")); + assertEquals("keep me", properties.getProperty("broken")); + } + + @Test + public void throwingSerializerDoesNotPreventPersisting() throws IOException { + final var config = CustomConfig.builder(file, loggerFactory).field(BROKEN_SERIALIZER).build(); + config.submitMetrics(false); + + assertDoesNotThrow(config::persist); + assertEquals("false", load().getProperty("submitMetrics")); + } + + @Test + public void nullFromSerializerDoesNotPreventSaving() throws IOException { + final var field = new CustomConfig.Field("null", () -> "value", value -> value, value -> null); + + final var config = assertDoesNotThrow(() -> CustomConfig.builder(file, loggerFactory).field(field).build()); + + assertDoesNotThrow(config::persist); + assertEquals(config.serverId().toString(), load().getProperty("serverId")); + } + + @ParameterizedTest + @ValueSource(strings = {"UTF-16", "UTF-16LE", "UTF-16BE", "UTF-32", "UTF-32LE", "UTF-32BE", "x-UTF-16LE-BOM", "X-UTF-32LE-BOM", "UTF-8-BOM"}) + public void otherUnicodeEncodings(final String encoding) throws IOException { + final var content = "# Datenschutz: \u00E4\u00F6\u00FC \u2014 \uD83D\uDE00\nenabled=false\nmy-note=\u00E4\u00F6\u00FC\n"; + final var bytes = encoding.equals("UTF-8-BOM") + ? ("\uFEFF" + content).getBytes(UTF_8) + : content.getBytes(Charset.forName(encoding)); + Files.createDirectories(file.getParent()); + Files.write(file, bytes); + + final var config = read(); + assertDisabled(config); + assertFalse(config.firstRun()); + assertRewrittenAsUtf8(); + assertEquals("\u00E4\u00F6\u00FC", load().getProperty("my-note")); + assertDisabled(read()); + } + + @ParameterizedTest + @ValueSource(strings = {"UTF-16LE", "UTF-16BE", "UTF-32LE", "UTF-32BE"}) + public void otherUnicodeEncodingsWithoutComments(final String encoding) throws IOException { + Files.createDirectories(file.getParent()); + Files.write(file, "enabled=false\n".getBytes(Charset.forName(encoding))); + + assertDisabled(read()); + assertRewrittenAsUtf8(); + } + + @Test + public void legacyWindowsEncodingIsRewritten() throws IOException { + Files.createDirectories(file.getParent()); + Files.write(file, "# \u201Cquotes\u201D \u20AC\nenabled=false\n".getBytes(Charset.forName("windows-1252"))); + + assertDisabled(read()); + assertRewrittenAsUtf8(); + } + + @Test + public void truncatedUtf16() throws IOException { + final var bytes = "\uFEFFenabled=false\n".getBytes(UTF_16LE); + Files.createDirectories(file.getParent()); + Files.write(file, Arrays.copyOf(bytes, bytes.length - 1)); + + assertDisabled(read()); + } + + @Test + public void foreignEncodingIsKeptWhenRewriteIsBlocked() throws IOException { + final var bytes = "enabled=false\nconfigVersion=99\n".getBytes(UTF_16LE); + Files.createDirectories(file.getParent()); + Files.write(file, bytes); + + assertDisabled(read()); + assertArrayEquals(bytes, Files.readAllBytes(file)); + } + + @Test + public void plainUtf8IsNotRewritten() throws IOException { + final var content = "# \u00E4\u00F6\u00FC\nenabled=false\nsubmitMetrics=true\nsubmitAdditionalMetrics=true\n" + + "submitErrors=true\nserverId=" + UUID.randomUUID() + "\ndebug=false\nconfigVersion=3\n"; + store(content); + + read(); + + assertEquals(content, Files.readString(file, UTF_8)); + } + + private void assertRewrittenAsUtf8() throws IOException { + final var bytes = Files.readAllBytes(file); + assertFalse(bytes.length >= 3 && (bytes[0] & 0xFF) == 0xEF && (bytes[1] & 0xFF) == 0xBB && (bytes[2] & 0xFF) == 0xBF, "BOM"); + for (final var b : bytes) assertNotEquals(0, b, "zero byte"); + assertDoesNotThrow(() -> UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes))); + } +} diff --git a/config/src/test/java/dev/faststats/config/package-info.java b/config/src/test/java/dev/faststats/config/package-info.java new file mode 100644 index 00000000..f2043ece --- /dev/null +++ b/config/src/test/java/dev/faststats/config/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.faststats.config; + +import org.jspecify.annotations.NullMarked; diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 5e45ecfa..b9939329 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -18,7 +18,4 @@ dependencies { testImplementation(project(":config")) testImplementation(libs.gson) - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation(platform(libs.junit.bom)) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 4914456a..859883a7 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -4,9 +4,10 @@ import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.fabric.compat.CompatibilityLayer; import dev.faststats.internal.PlatformLoggerFactory; +import dev.faststats.screen.onboarding.ConsentState; import dev.faststats.screen.registry.FastStatsRegistry; import net.fabricmc.api.EnvType; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; @@ -41,7 +42,7 @@ public final class FabricContext extends SimpleContext { private final boolean consentRequired; private FabricContext(final Factory factory, final dev.faststats.internal.LoggerFactory loggerFactory, final String modId, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(configPath(), loggerFactory, isClient()), "fabric", token); + super(factory, loggerFactory, ConsentState.readConfig(configPath(), loggerFactory, isClient()), "fabric", token); this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); @@ -79,9 +80,9 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - final var config = (SimpleConfig) getConfig(); + final var config = (CustomConfig) getConfig(); if (!config.preSubmissionStart(this)) return false; - if (!consentRequired || config.consentScreenShown()) return true; + if (!consentRequired || ConsentState.consentScreenShown(config)) return true; return !isClient(); } diff --git a/forge/src/main/java/dev/faststats/forge/ForgeContext.java b/forge/src/main/java/dev/faststats/forge/ForgeContext.java index e0324efe..50405278 100644 --- a/forge/src/main/java/dev/faststats/forge/ForgeContext.java +++ b/forge/src/main/java/dev/faststats/forge/ForgeContext.java @@ -1,11 +1,12 @@ package dev.faststats.forge; +import dev.faststats.screen.onboarding.ConsentState; import dev.faststats.screen.registry.FastStatsRegistry; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.forge.compat.CompatibilityLayer; import dev.faststats.internal.LoggerFactory; import dev.faststats.internal.PlatformLoggerFactory; @@ -43,7 +44,7 @@ private ForgeContext(final Factory factory, final LoggerFactory loggerFactory, f } private ForgeContext(final Factory factory, final LoggerFactory loggerFactory, final String modId, @Token final String token, final CompatibilityLayer compatibilityLayer) { - super(factory, loggerFactory, SimpleConfig.read( + super(factory, loggerFactory, ConsentState.readConfig( compatibilityLayer.configDirectory().resolve("faststats").resolve("config.properties"), loggerFactory, compatibilityLayer.getEnvironment() == CompatibilityLayer.Environment.CLIENT), "forge", token); this.modVersion = compatibilityLayer.modVersion(modId).orElseThrow(() -> { @@ -76,9 +77,9 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - final var config = (SimpleConfig) getConfig(); + final var config = (CustomConfig) getConfig(); if (!config.preSubmissionStart(this)) return false; - if (!consentRequired || config.consentScreenShown()) return true; + if (!consentRequired || ConsentState.consentScreenShown(config)) return true; return compatibilityLayer.getEnvironment() != CompatibilityLayer.Environment.CLIENT; } diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java index a4d8cbbf..78021122 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleContext.java @@ -7,7 +7,7 @@ import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.LoggerFactory; import dev.faststats.internal.PlatformLoggerFactory; import org.jetbrains.annotations.Contract; @@ -27,9 +27,9 @@ public final class HytaleContext extends SimpleContext { private final JavaPlugin plugin; private HytaleContext(final Factory factory, final LoggerFactory loggerFactory, final JavaPlugin plugin, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(plugin.getDataDirectory().toAbsolutePath().getParent() + super(factory, loggerFactory, CustomConfig.builder(plugin.getDataDirectory().toAbsolutePath().getParent() .resolve("faststats").resolve("config.properties"), loggerFactory - ), "hytale", token); + ).build(), "hytale", token); this.plugin = plugin; initializeServices(factory); } @@ -48,7 +48,7 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(this); + return ((CustomConfig) getConfig()).preSubmissionStart(this); } @Override diff --git a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java index eec01aa1..9a2dd621 100644 --- a/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java +++ b/minestom/src/main/java/dev/faststats/minestom/MinestomContext.java @@ -4,7 +4,7 @@ import dev.faststats.ErrorTrackerService; import dev.faststats.SimpleContext; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.PlatformLoggerFactory; import net.minestom.server.MinecraftServer; import net.minestom.server.timer.Task; @@ -26,7 +26,7 @@ public final class MinestomContext extends SimpleContext { private final Set tasks = new CopyOnWriteArraySet<>(); MinestomContext(final Factory factory, final dev.faststats.internal.LoggerFactory loggerFactory, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(Path.of("faststats", "config.properties"), loggerFactory), "minestom", token); + super(factory, loggerFactory, CustomConfig.builder(Path.of("faststats", "config.properties"), loggerFactory).build(), "minestom", token); initializeServices(factory); } @@ -51,7 +51,7 @@ protected MinestomMetrics.Factory metricsFactory() { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(this); + return ((CustomConfig) getConfig()).preSubmissionStart(this); } @Override diff --git a/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java b/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java index a6a6100b..6cd61cd8 100644 --- a/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java +++ b/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java @@ -1,11 +1,12 @@ package dev.faststats.neoforge; +import dev.faststats.screen.onboarding.ConsentState; import dev.faststats.screen.registry.FastStatsRegistry; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.LoggerFactory; import dev.faststats.internal.PlatformLoggerFactory; import dev.faststats.neoforge.compat.CompatibilityLayer; @@ -50,7 +51,7 @@ private NeoForgeContext(final Factory factory, final LoggerFactory loggerFactory } private NeoForgeContext(final Factory factory, final LoggerFactory loggerFactory, final String modId, @Token final String token, final CompatibilityLayer compatibilityLayer) { - super(factory, loggerFactory, SimpleConfig.read(configPath(), loggerFactory, + super(factory, loggerFactory, ConsentState.readConfig(configPath(), loggerFactory, compatibilityLayer.getEnvironment() == CompatibilityLayer.Environment.CLIENT), "neoforge", token); this.mod = ModList.get().getModContainerById(modId).map(ModContainer::getModInfo).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); @@ -84,9 +85,9 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - final var config = (SimpleConfig) getConfig(); + final var config = (CustomConfig) getConfig(); if (!config.preSubmissionStart(this)) return false; - if (!consentRequired || config.consentScreenShown()) return true; + if (!consentRequired || ConsentState.consentScreenShown(config)) return true; return compatibilityLayer.getEnvironment() != CompatibilityLayer.Environment.CLIENT; } diff --git a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java index 1fb324fb..488a61e4 100644 --- a/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java +++ b/nukkit/src/main/java/dev/faststats/nukkit/NukkitContext.java @@ -6,7 +6,7 @@ import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.LoggerFactory; import dev.faststats.internal.PlatformLoggerFactory; import org.jetbrains.annotations.Contract; @@ -26,8 +26,8 @@ public final class NukkitContext extends SimpleContext { final PluginBase plugin; private NukkitContext(final Factory factory, final LoggerFactory loggerFactory, final PluginBase plugin, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(Path.of(plugin.getServer().getPluginPath()) - .resolve("faststats").resolve("config.properties"), loggerFactory), "nukkit", token); + super(factory, loggerFactory, CustomConfig.builder(Path.of(plugin.getServer().getPluginPath()) + .resolve("faststats").resolve("config.properties"), loggerFactory).build(), "nukkit", token); this.plugin = plugin; initializeServices(factory); } @@ -45,7 +45,7 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(this); + return ((CustomConfig) getConfig()).preSubmissionStart(this); } @Override diff --git a/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentState.java b/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentState.java index 2c799b94..ab904994 100644 --- a/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentState.java +++ b/onboarding/src/main/java/dev/faststats/screen/onboarding/ConsentState.java @@ -1,13 +1,17 @@ package dev.faststats.screen.onboarding; import dev.faststats.Config; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; +import dev.faststats.internal.LoggerFactory; import dev.faststats.screen.registry.FastStatsRegistry; +import java.nio.file.Path; import java.util.concurrent.Executor; import java.util.concurrent.Executors; public final class ConsentState { + public static final CustomConfig.Field CONSENT_SCREEN_SHOWN = CustomConfig.Field.bool("consent-screen-shown", false); + private static final Executor PERSISTENCE = Executors.newSingleThreadExecutor(runnable -> { final var thread = new Thread(runnable, "faststats-config-writer"); thread.setDaemon(true); @@ -28,6 +32,16 @@ public ConsentState(final Config config) { this.errorTracking = errorTracking; } + public static CustomConfig readConfig(final Path file, final LoggerFactory loggerFactory, final boolean client) { + final var builder = CustomConfig.builder(file, loggerFactory); + if (client) builder.field(CONSENT_SCREEN_SHOWN); + return builder.build(); + } + + public static boolean consentScreenShown(final CustomConfig config) { + return config.has(CONSENT_SCREEN_SHOWN) && config.get(CONSENT_SCREEN_SHOWN); + } + public boolean submitMetrics() { return submitMetrics; } @@ -60,7 +74,7 @@ public void selectAll(final boolean selected) { } public boolean editable() { - return SimpleConfig.enabledFlag(); + return CustomConfig.enabledFlag(); } public synchronized void apply(final FastStatsRegistry registry) { @@ -71,17 +85,17 @@ public synchronized void apply(final FastStatsRegistry registry) { final var enabled = submitMetrics || errorTracking; contexts.forEach(context -> { - final var config = (SimpleConfig) context.getConfig(); + final var config = (CustomConfig) context.getConfig(); config.submitMetrics(submitMetrics); config.additionalMetrics(additionalMetrics()); config.errorTracking(errorTracking); config.enabled(enabled); - config.consentScreenShown(true); + if (config.has(CONSENT_SCREEN_SHOWN)) config.set(CONSENT_SCREEN_SHOWN, true); context.submissionActive = enabled; }); final var context = contexts.get(0); - final var config = (SimpleConfig) context.getConfig(); + final var config = (CustomConfig) context.getConfig(); PERSISTENCE.execute(() -> { try { config.persist(); diff --git a/onboarding/src/main/java/dev/faststats/screen/onboarding/FirstRunOnboarding.java b/onboarding/src/main/java/dev/faststats/screen/onboarding/FirstRunOnboarding.java index b7c7542c..ceec9eae 100644 --- a/onboarding/src/main/java/dev/faststats/screen/onboarding/FirstRunOnboarding.java +++ b/onboarding/src/main/java/dev/faststats/screen/onboarding/FirstRunOnboarding.java @@ -1,6 +1,6 @@ package dev.faststats.screen.onboarding; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.screen.registry.FastStatsRegistry; import java.util.concurrent.atomic.AtomicBoolean; @@ -12,7 +12,7 @@ private FirstRunOnboarding() { } public static boolean shouldOpen() { - if (!SimpleConfig.enabledFlag()) return false; + if (!CustomConfig.enabledFlag()) return false; if (!FastStatsRegistry.instance().consentPending()) return false; return OPENED.compareAndSet(false, true); } diff --git a/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistry.java b/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistry.java index aa399801..8ce67521 100644 --- a/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistry.java +++ b/onboarding/src/main/java/dev/faststats/screen/registry/FastStatsRegistry.java @@ -2,8 +2,9 @@ import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.data.Metric; +import dev.faststats.screen.onboarding.ConsentState; import org.jetbrains.annotations.Contract; import java.util.IdentityHashMap; @@ -60,13 +61,13 @@ public synchronized List contexts() { public synchronized boolean consentPending() { for (final var entry : contexts.entrySet()) { if (!entry.getValue().consentRequired()) continue; - if (!((SimpleConfig) entry.getKey().getConfig()).consentScreenShown()) return true; + if (!ConsentState.consentScreenShown((CustomConfig) entry.getKey().getConfig())) return true; } return false; } - public synchronized SimpleConfig anyConfig() throws IllegalStateException { - for (final var context : contexts.keySet()) return (SimpleConfig) context.getConfig(); + public synchronized CustomConfig anyConfig() throws IllegalStateException { + for (final var context : contexts.keySet()) return (CustomConfig) context.getConfig(); throw new IllegalStateException("Config not found!"); } diff --git a/sponge/build.gradle.kts b/sponge/build.gradle.kts index dd97ec19..ec12d020 100644 --- a/sponge/build.gradle.kts +++ b/sponge/build.gradle.kts @@ -18,5 +18,6 @@ repositories { dependencies { api(project(":core")) + implementation(project(":config")) compileOnly(libs.spongeapi) } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java index c8e8766b..44b87223 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeConfig.java @@ -1,39 +1,16 @@ package dev.faststats.sponge; -import dev.faststats.Config; -import dev.faststats.internal.Logger; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.LoggerFactory; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; -import org.jspecify.annotations.Nullable; import org.spongepowered.api.Sponge; import org.spongepowered.plugin.PluginContainer; -import java.io.IOException; -import java.io.OutputStreamWriter; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.Properties; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Function; -import java.util.function.Supplier; - -import static java.nio.charset.StandardCharsets.UTF_8; @ApiStatus.Internal -public record SpongeConfig( - UUID serverId, - boolean enabled, - boolean additionalMetrics, - boolean debug, - boolean submitMetrics, - boolean errorTracking, - boolean firstRun -) implements Config { - private static volatile boolean loggingEnabled; - private static final int CONFIG_VERSION = 2; - +final class SpongeConfig { private static final String COMMENT = """ FastStats (https://faststats.dev) collects anonymous usage statistics and errors. # This helps developers understand how their projects are used in the real world. @@ -63,120 +40,15 @@ public record SpongeConfig( until you restart the server to allow you to opt out if you prefer. """; - @Contract(mutates = "io") - public static SpongeConfig read(final PluginContainer plugin, final Path file, final LoggerFactory loggerFactory) throws RuntimeException { - final var logger = loggerFactory.getLogger(SpongeConfig.class); - final var debugFlag = Boolean.getBoolean("faststats.debug"); - - final var properties = readOrEmpty(file); - final var firstRun = properties == null; - final var saveConfig = new AtomicBoolean(firstRun); - - final var serverId = parse(properties, saveConfig, "serverId", UUID::randomUUID, value -> { - final var corrected = value.length() > 36 ? value.substring(0, 36) : value; - final var uuid = UUID.fromString(corrected); - if (!value.equals(uuid.toString())) saveConfig.set(true); - return uuid; - }, logger); - final var configVersion = parse(properties, saveConfig, "configVersion", null, Integer::parseInt, logger); - final boolean submitMetrics = parse(properties, saveConfig, "submitMetrics", () -> true, Boolean::parseBoolean, logger); - final boolean errorTracking = parse(properties, saveConfig, "submitErrors", () -> true, Boolean::parseBoolean, logger); - final boolean additionalMetrics = parse(properties, saveConfig, "submitAdditionalMetrics", () -> true, Boolean::parseBoolean, logger); - final boolean debug = parse(properties, saveConfig, "debug", () -> true, Boolean::parseBoolean, logger); - - if (configVersion == null || configVersion < CONFIG_VERSION) saveConfig.set(true); - else if (configVersion > CONFIG_VERSION) saveConfig.set(false); - - if (saveConfig.get()) try { - if (configVersion != null && configVersion < CONFIG_VERSION) - logger.info("Updating config version from %s to %s", configVersion, CONFIG_VERSION); - Files.createDirectories(file.getParent()); - try (final var out = Files.newOutputStream(file); - final var writer = new OutputStreamWriter(out, UTF_8)) { - final var store = new Properties(); - - store.setProperty("submitMetrics", Boolean.toString(submitMetrics)); - store.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics)); - store.setProperty("submitErrors", Boolean.toString(errorTracking)); - - store.setProperty("serverId", serverId.toString()); - - store.setProperty("debug", Boolean.toString(debug)); - store.setProperty("configVersion", Integer.toString(CONFIG_VERSION)); - - store.store(writer, COMMENT); - } - } catch (final IOException e) { - throw new RuntimeException("Failed to save metrics config", e); - } - - final var enabled = Sponge.metricsConfigManager().effectiveCollectionState(plugin).asBoolean(); - return new SpongeConfig( - serverId, - enabled, - enabled && additionalMetrics, - debug || debugFlag, - enabled && submitMetrics, - enabled && errorTracking, - firstRun - ); - } - - @Contract(value = "_, _, _, !null, _, _ -> !null") - private static @Nullable T parse( - @Nullable final Properties properties, - final AtomicBoolean saveConfig, - final String key, - @Nullable final Supplier defaultValue, - final Function parser, - final Logger logger - ) { - if (properties == null) { - saveConfig.set(true); - return defaultValue != null ? defaultValue.get() : null; - } - final var property = properties.getProperty(key); - if (property == null) { - logger.warn("Missing configuration property: %s", key); - saveConfig.set(true); - return defaultValue != null ? defaultValue.get() : null; - } - try { - return parser.apply(property.trim()); - } catch (final Exception e) { - logger.error("Failed to read property '%s' from config", e, key); - saveConfig.set(true); - return defaultValue != null ? defaultValue.get() : null; - } - } - - private static @Nullable Properties readOrEmpty(final Path file) throws RuntimeException { - if (!Files.isRegularFile(file)) return null; - try (final var reader = Files.newBufferedReader(file, UTF_8)) { - final var properties = new Properties(); - properties.load(reader); - return properties; - } catch (final IOException e) { - throw new RuntimeException("Failed to read metrics config", e); - } + private SpongeConfig() { } - public boolean preSubmissionStart(final SpongeContext context) { - if (Boolean.getBoolean("faststats.first-run")) return false; - - if (firstRun()) { - var separatorLength = 0; - final var split = ONBOARDING_MESSAGE.split("\n"); - for (final var s : split) if (s.length() > separatorLength) separatorLength = s.length(); - - final var logger = context.getLoggerFactory().getLogger(getClass()); - logger.print(Logger.LogLevel.INFO, null, "-".repeat(separatorLength)); - for (final var s : split) logger.print(Logger.LogLevel.INFO, null, s); - logger.print(Logger.LogLevel.INFO, null, "-".repeat(separatorLength)); - - System.setProperty("faststats.first-run", "true"); - return false; - } - return true; + @Contract(mutates = "io") + static CustomConfig read(final PluginContainer plugin, final Path file, final LoggerFactory loggerFactory) throws RuntimeException { + return CustomConfig.builder(file, loggerFactory) + .comment(COMMENT) + .onboardingMessage(ONBOARDING_MESSAGE) + .enabled(Sponge.metricsConfigManager().effectiveCollectionState(plugin).asBoolean()) + .build(); } } diff --git a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java index 28cf347a..0550eb85 100644 --- a/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java +++ b/sponge/src/main/java/dev/faststats/sponge/SpongeContext.java @@ -5,6 +5,7 @@ import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.LoggerFactory; import dev.faststats.internal.PlatformLoggerFactory; import org.jetbrains.annotations.Contract; @@ -56,7 +57,7 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - return ((SpongeConfig) getConfig()).preSubmissionStart(this); + return ((CustomConfig) getConfig()).preSubmissionStart(this); } @Override diff --git a/sponge/src/main/java/module-info.java b/sponge/src/main/java/module-info.java index 61c8a824..0eb705a9 100644 --- a/sponge/src/main/java/module-info.java +++ b/sponge/src/main/java/module-info.java @@ -6,6 +6,7 @@ requires com.google.gson; requires com.google.guice; + requires dev.faststats.config; requires dev.faststats; requires java.logging; diff --git a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java index dfeac39f..563012d0 100644 --- a/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java +++ b/velocity/src/main/java/dev/faststats/velocity/VelocityContext.java @@ -9,7 +9,7 @@ import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; -import dev.faststats.config.SimpleConfig; +import dev.faststats.config.CustomConfig; import dev.faststats.internal.LoggerFactory; import dev.faststats.internal.PlatformLoggerFactory; import org.jetbrains.annotations.Contract; @@ -40,9 +40,9 @@ private VelocityContext( @DataDirectory final Path dataDirectory, @Token final String token ) { - super(factory, loggerFactory, SimpleConfig.read(dataDirectory.resolveSibling("faststats") + super(factory, loggerFactory, CustomConfig.builder(dataDirectory.resolveSibling("faststats") .resolve("config.properties"), loggerFactory - ), "velocity", token); + ).build(), "velocity", token); this.plugin = plugin; this.server = server; initializeServices(factory); @@ -61,7 +61,7 @@ public Metrics create() throws IllegalStateException { @Override protected boolean preSubmissionStart() { - return ((SimpleConfig) getConfig()).preSubmissionStart(this); + return ((CustomConfig) getConfig()).preSubmissionStart(this); } @Override