diff --git a/core/src/main/java/dev/felnull/itts/core/dict/DictionaryManager.java b/core/src/main/java/dev/felnull/itts/core/dict/DictionaryManager.java index 64ad2b6..3efd810 100644 --- a/core/src/main/java/dev/felnull/itts/core/dict/DictionaryManager.java +++ b/core/src/main/java/dev/felnull/itts/core/dict/DictionaryManager.java @@ -8,6 +8,7 @@ import dev.felnull.itts.core.savedata.legacy.LegacyDictData; import dev.felnull.itts.core.savedata.legacy.LegacySaveDataLayer; import dev.felnull.itts.core.util.JsonUtils; +import dev.felnull.itts.core.util.PatternValidator; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; @@ -28,6 +29,16 @@ public class DictionaryManager implements ITTSRuntimeUse { */ private static final int FILE_VERSION = 0; + /** + * アップロード時の最大エントリ数 + */ + private static final int MAX_DICT_ENTRIES = 1000; + + /** + * エントリの最大文字数 + */ + private static final int MAX_TEXT_LENGTH = 1000; + /** * グローバル辞書 */ @@ -254,35 +265,55 @@ public List serverDictLoadFromJson(@NotNull JsonObject jo, long int version = JsonUtils.getInt(jo, "version", -1); if (version != FILE_VERSION) { - throw new RuntimeException("Unsupported dictionary file version."); + throw new RuntimeException("Unsupported dictionary file version"); + } + + JsonElement entryElement = jo.get("entry"); + if (entryElement == null || !entryElement.isJsonObject()) { + throw new RuntimeException("Invalid dictionary file format"); + } + + JsonObject entry = jo.getAsJsonObject("entry"); + + if (entry.size() > MAX_DICT_ENTRIES) { + throw new RuntimeException("Dictionary entry count exceeds limit. Maximum is " + MAX_DICT_ENTRIES); } - if (jo.get("entry").isJsonObject()) { - JsonObject entry = jo.getAsJsonObject("entry"); - LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); + LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); + + for (Map.Entry en : entry.entrySet()) { + String target = en.getKey(); + + if (!en.getValue().isJsonPrimitive() || !en.getValue().getAsJsonPrimitive().isString()) { + continue; + } + + String read = en.getValue().getAsString(); - for (Map.Entry en : entry.entrySet()) { - String target = en.getKey(); + if (target.length() > MAX_TEXT_LENGTH || read.length() > MAX_TEXT_LENGTH) { + continue; + } - if (!en.getValue().isJsonPrimitive() || !en.getValue().getAsJsonPrimitive().isString()) { - continue; - } + if (target.isBlank() || read.isBlank()) { + continue; + } - String read = en.getValue().getAsString(); + if (!PatternValidator.validate(target).valid()) { + continue; + } - LegacyDictData pre = legacySaveDataLayer.getServerDictData(guildId, target); + LegacyDictData pre = legacySaveDataLayer.getServerDictData(guildId, target); - if (!overwrite && pre != null) { - continue; - } + if (!overwrite && pre != null) { + continue; + } - legacySaveDataLayer.addServerDictData(guildId, target, read); + legacySaveDataLayer.addServerDictData(guildId, target, read); - LegacyDictData ndata = Objects.requireNonNull(legacySaveDataLayer.getServerDictData(guildId, target)); + LegacyDictData ndata = Objects.requireNonNull(legacySaveDataLayer.getServerDictData(guildId, target)); - if (!ndata.equals(pre)) { - ret.add(ndata); - } + if (!ndata.equals(pre)) { + ret.add(ndata); } } diff --git a/core/src/main/java/dev/felnull/itts/core/dict/GlobalDictionary.java b/core/src/main/java/dev/felnull/itts/core/dict/GlobalDictionary.java index 833b012..e8fbcd3 100644 --- a/core/src/main/java/dev/felnull/itts/core/dict/GlobalDictionary.java +++ b/core/src/main/java/dev/felnull/itts/core/dict/GlobalDictionary.java @@ -11,7 +11,9 @@ import java.util.Map; import java.util.function.Function; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * グローバル辞書 @@ -50,7 +52,15 @@ public int getDefaultPriority() { protected @NotNull Map> getReplaces(long guildId) { LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); return legacySaveDataLayer.getAllGlobalDictData().stream() - .map(n -> Pair.of(Pattern.compile(n.getTarget()), n.getRead())) + .flatMap(n -> { + try { + Pattern pattern = Pattern.compile(n.getTarget()); + return Stream.of(Pair.of(pattern, n.getRead())); + } catch (PatternSyntaxException e) { + getITTSLogger().warn("Invalid regex pattern in global dict: {}", n.getTarget()); + return Stream.empty(); + } + }) .collect(Collectors.toMap(Pair::getLeft, patternStringPair -> n -> patternStringPair.getRight())); } } diff --git a/core/src/main/java/dev/felnull/itts/core/dict/ServerDictionary.java b/core/src/main/java/dev/felnull/itts/core/dict/ServerDictionary.java index cbd01ed..5f2b79c 100644 --- a/core/src/main/java/dev/felnull/itts/core/dict/ServerDictionary.java +++ b/core/src/main/java/dev/felnull/itts/core/dict/ServerDictionary.java @@ -11,7 +11,9 @@ import java.util.Map; import java.util.function.Function; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * サーバー辞書 @@ -50,7 +52,15 @@ public int getDefaultPriority() { protected @NotNull Map> getReplaces(long guildId) { LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); return legacySaveDataLayer.getAllServerDictData(guildId).stream() - .map(n -> Pair.of(Pattern.compile(n.getTarget()), n.getRead())) + .flatMap(n -> { + try { + Pattern pattern = Pattern.compile(n.getTarget()); + return Stream.of(Pair.of(pattern, n.getRead())); + } catch (PatternSyntaxException e) { + getITTSLogger().warn("Invalid regex pattern in server dict: {}", n.getTarget()); + return Stream.empty(); + } + }) .collect(Collectors.toMap(Pair::getLeft, patternStringPair -> n -> patternStringPair.getRight())); } } diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/AdminCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/AdminCommand.java index eef3812..5be6950 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/AdminCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/AdminCommand.java @@ -79,6 +79,8 @@ private void vnick(SlashCommandInteractionEvent event) { if (name == null) { sud.setNickName(null); event.reply(DiscordUtils.getEscapedName(event.getGuild(), user) + "の読み上げユーザ名をリセットしました。").queue(); + } else if (name.isBlank()) { + event.reply("名前を空にすることはできません。リセットするには名前を指定せずに実行してください。").setEphemeral(true).queue(); } else { sud.setNickName(name); event.reply(DiscordUtils.getEscapedName(event.getGuild(), user) + "の読み上げユーザ名を変更しました。").queue(); diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/ConfigCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/ConfigCommand.java index 3ae0e01..d1e0415 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/ConfigCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/ConfigCommand.java @@ -5,6 +5,7 @@ import dev.felnull.itts.core.savedata.legacy.LegacySaveDataLayer; import dev.felnull.itts.core.savedata.legacy.LegacyServerData; import dev.felnull.itts.core.savedata.repository.ServerData; +import dev.felnull.itts.core.util.PatternValidator; import dev.felnull.itts.core.voice.VoiceCategory; import dev.felnull.itts.core.voice.VoiceManager; import dev.felnull.itts.core.voice.VoiceType; @@ -171,6 +172,13 @@ private void readIgnore(SlashCommandInteractionEvent event) { Guild guild = Objects.requireNonNull(event.getGuild()); String op = Objects.requireNonNull(event.getOption("regex", OptionMapping::getAsString)); + + PatternValidator.ValidationResult validationResult = PatternValidator.validate(op); + if (!validationResult.valid()) { + event.reply(validationResult.error()).setEphemeral(true).queue(); + return; + } + LegacySaveDataLayer legacySaveDataLayer = SaveDataManager.getInstance().getLegacySaveDataLayer(); LegacyServerData sd = legacySaveDataLayer.getServerData(guild.getIdLong()); diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/DenyCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/DenyCommand.java index 5c2f9e8..68a9061 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/DenyCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/DenyCommand.java @@ -92,7 +92,6 @@ private void show(SlashCommandInteractionEvent event) { return; } - MessageCreateBuilder msg = new MessageCreateBuilder().addContent("読み上げ拒否されたユーザ一覧\n"); StringBuilder sb = new StringBuilder(); JDA jda = event.getJDA(); @@ -103,7 +102,15 @@ private void show(SlashCommandInteractionEvent event) { sb.append(DiscordUtils.getEscapedName(guild, user)).append("\n"); } } - msg.addContent("``" + sb + "``"); + + if (sb.isEmpty()) { + event.reply("読み上げ拒否されたユーザの情報を取得できませんでした。").setEphemeral(true).queue(); + return; + } + + MessageCreateBuilder msg = new MessageCreateBuilder() + .addContent("読み上げ拒否されたユーザ一覧\n") + .addContent("```\n" + sb + "```"); event.reply(msg.build()).setEphemeral(true).queue(); } diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/DictCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/DictCommand.java index 142fc76..52230db 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/DictCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/DictCommand.java @@ -3,12 +3,14 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; import dev.felnull.itts.core.dict.Dictionary; import dev.felnull.itts.core.dict.DictionaryManager; import dev.felnull.itts.core.dict.ServerDictionary; import dev.felnull.itts.core.savedata.SaveDataManager; import dev.felnull.itts.core.savedata.legacy.LegacyDictData; import dev.felnull.itts.core.savedata.legacy.LegacySaveDataLayer; +import dev.felnull.itts.core.util.PatternValidator; import dev.felnull.itts.core.util.StringUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; @@ -54,6 +56,11 @@ public class DictCommand extends BaseCommand { */ private static final int MAX_FIELD_TEXT_LENGTH = 125; + /** + * アップロードファイルの最大サイズ (1MB) + */ + private static final int MAX_UPLOAD_FILE_SIZE = 1024 * 1024; + /** * GSOUN */ @@ -175,11 +182,24 @@ private void upload(SlashCommandInteractionEvent event) { Message.Attachment file = Objects.requireNonNull(event.getOption("file", OptionMapping::getAsAttachment)); boolean overwrite = Objects.requireNonNull(event.getOption("overwrite", OptionMapping::getAsBoolean)); + if (file.getSize() > MAX_UPLOAD_FILE_SIZE) { + int maxSizeMB = MAX_UPLOAD_FILE_SIZE / (1024 * 1024); + event.reply(String.format("ファイルサイズが大きすぎます。最大%dMBまでです。", maxSizeMB)).setEphemeral(true).queue(); + return; + } + event.deferReply().queue(); file.getProxy().download().thenApplyAsync(stream -> { - try (InputStream st = new BufferedInputStream(stream); Reader reader = new InputStreamReader(st)) { - return GSON.fromJson(reader, JsonObject.class); + try (InputStream st = new BufferedInputStream(stream); + Reader reader = new InputStreamReader(st, StandardCharsets.UTF_8)) { + JsonObject result = GSON.fromJson(reader, JsonObject.class); + if (result == null) { + throw new RuntimeException("Invalid JSON file"); + } + return result; + } catch (JsonSyntaxException e) { + throw new RuntimeException("Invalid JSON format: " + e.getMessage()); } catch (IOException e) { throw new RuntimeException(e); } @@ -204,7 +224,8 @@ private void upload(SlashCommandInteractionEvent event) { event.getHook().sendMessageEmbeds(replayEmbedBuilder.build()).addContent(overwrite ? "以下の単語の読みを上書き登録しました" : "以下の単語の読みを登録しました").queue(); } else { getITTSLogger().error("Dictionary registration failure", error); - event.getHook().sendMessage("辞書ファイルの読み込み中にエラーが発生しました").queue(); + String errorMessage = error.getCause() != null ? error.getCause().getMessage() : error.getMessage(); + event.getHook().sendMessage("辞書ファイルの読み込み中にエラーが発生しました: " + errorMessage).queue(); } }, getAsyncExecutor()); @@ -266,8 +287,19 @@ private void add(SlashCommandInteractionEvent event) { String word = Objects.requireNonNull(event.getOption("word", OptionMapping::getAsString)); String reading = Objects.requireNonNull(event.getOption("reading", OptionMapping::getAsString)); + if (word.isBlank() || reading.isBlank()) { + event.reply("単語と読みを空にすることはできません。").setEphemeral(true).queue(); + return; + } + if (word.length() > MAX_TEXT_LENGTH || reading.length() > MAX_TEXT_LENGTH) { - event.reply(String.format("登録可能な最大文字数は%d文字です", MAX_TEXT_LENGTH)).queue(); + event.reply(String.format("登録可能な最大文字数は%d文字です", MAX_TEXT_LENGTH)).setEphemeral(true).queue(); + return; + } + + PatternValidator.ValidationResult validationResult = PatternValidator.validate(word); + if (!validationResult.valid()) { + event.reply(validationResult.error()).setEphemeral(true).queue(); return; } diff --git a/core/src/main/java/dev/felnull/itts/core/discord/command/VnickCommand.java b/core/src/main/java/dev/felnull/itts/core/discord/command/VnickCommand.java index c5aa5e0..9788909 100644 --- a/core/src/main/java/dev/felnull/itts/core/discord/command/VnickCommand.java +++ b/core/src/main/java/dev/felnull/itts/core/discord/command/VnickCommand.java @@ -51,6 +51,8 @@ public void commandInteraction(SlashCommandInteractionEvent event) { if (name == null) { sud.setNickName(null); event.reply("自分の読み上げユーザ名をリセットしました。").setEphemeral(true).queue(); + } else if (name.isBlank()) { + event.reply("名前を空にすることはできません。リセットするには名前を指定せずに実行してください。").setEphemeral(true).queue(); } else { sud.setNickName(name); event.reply("自分の読み上げユーザ名を変更しました。").setEphemeral(true).queue(); diff --git a/core/src/main/java/dev/felnull/itts/core/tts/TTSManager.java b/core/src/main/java/dev/felnull/itts/core/tts/TTSManager.java index 3f7bc75..0228610 100644 --- a/core/src/main/java/dev/felnull/itts/core/tts/TTSManager.java +++ b/core/src/main/java/dev/felnull/itts/core/tts/TTSManager.java @@ -24,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; /** * TTS管理 @@ -186,9 +187,13 @@ public void sayChat(@NotNull Guild guild, @NotNull MessageChannel messageChannel String ignoreRegex = legacySaveDataLayer.getServerData(guild.getIdLong()).getIgnoreRegex(); if (ignoreRegex != null) { - Pattern ignorePattern = Pattern.compile(ignoreRegex); - if (ignorePattern.matcher(message.getContentDisplay()).matches()) { - return; + try { + Pattern ignorePattern = Pattern.compile(ignoreRegex); + if (ignorePattern.matcher(message.getContentDisplay()).matches()) { + return; + } + } catch (PatternSyntaxException e) { + getITTSLogger().warn("Invalid ignore regex for guild {}: {}", guild.getIdLong(), ignoreRegex); } } diff --git a/core/src/main/java/dev/felnull/itts/core/util/PatternValidator.java b/core/src/main/java/dev/felnull/itts/core/util/PatternValidator.java new file mode 100644 index 0000000..75bde2f --- /dev/null +++ b/core/src/main/java/dev/felnull/itts/core/util/PatternValidator.java @@ -0,0 +1,94 @@ +package dev.felnull.itts.core.util; + +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * 正規表現バリデーションユーティリティ + */ +public final class PatternValidator { + + /** + * ReDoS脆弱性を持つ可能性のあるパターン + * ネストされた量子 (例: (a+)+, (a*)*) を検出 + */ + private static final Pattern REDOS_PATTERN = Pattern.compile( + "\\([^)]*[+*][^)]*\\)[+*]|\\([^)]*\\|[^)]*\\)[+*]" + ); + + private PatternValidator() { + } + + /** + * 正規表現の構文を検証する + * + * @param regex 検証する正規表現 + * @return 検証結果 + */ + @NotNull + public static ValidationResult validate(@NotNull String regex) { + try { + Pattern pattern = Pattern.compile(regex); + + if (isPotentiallyDangerous(regex)) { + return ValidationResult.failure("この正規表現はパフォーマンス上の問題を引き起こす可能性があります"); + } + + return ValidationResult.success(pattern); + } catch (PatternSyntaxException e) { + return ValidationResult.failure("無効な正規表現です: " + e.getDescription()); + } + } + + /** + * 正規表現がReDoS脆弱性を持つ可能性があるかチェックする + * + * @param regex チェックする正規表現 + * @return 危険な可能性がある場合はtrue + */ + public static boolean isPotentiallyDangerous(@NotNull String regex) { + return REDOS_PATTERN.matcher(regex).find(); + } + + /** + * 正規表現バリデーションの結果 + * + * @param valid 有効かどうか + * @param pattern コンパイル済みPattern (有効な場合のみ) + * @param error エラーメッセージ (無効な場合のみ) + */ + public record ValidationResult(boolean valid, Pattern pattern, String error) { + + /** + * 成功結果を作成 + * + * @param pattern コンパイル済みPattern + * @return 成功結果 + */ + public static ValidationResult success(@NotNull Pattern pattern) { + return new ValidationResult(true, pattern, null); + } + + /** + * 失敗結果を作成 + * + * @param error エラーメッセージ + * @return 失敗結果 + */ + public static ValidationResult failure(@NotNull String error) { + return new ValidationResult(false, null, error); + } + + /** + * Patternを取得 (成功時のみ) + * + * @return コンパイル済みPattern + */ + public Optional getPattern() { + return Optional.ofNullable(pattern); + } + } +} diff --git a/core/src/main/java/dev/felnull/itts/core/voice/coeiroink/CoeiroinkManager.java b/core/src/main/java/dev/felnull/itts/core/voice/coeiroink/CoeiroinkManager.java index a9c5aae..ad948e1 100644 --- a/core/src/main/java/dev/felnull/itts/core/voice/coeiroink/CoeiroinkManager.java +++ b/core/src/main/java/dev/felnull/itts/core/voice/coeiroink/CoeiroinkManager.java @@ -115,12 +115,22 @@ protected List requestSpeakers(CIURL ciurl) throws IOException .timeout(Duration.of(3000, ChronoUnit.MILLIS)) .build(); HttpResponse rep = hc.send(req, HttpResponse.BodyHandlers.ofInputStream()); + + int statusCode = rep.statusCode(); + if (statusCode != 200) { + throw new IOException(name + " API error: HTTP " + statusCode); + } + JsonArray ja; try (InputStream stream = new BufferedInputStream(rep.body()); Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { ja = GSON.fromJson(reader, JsonArray.class); } + if (ja == null) { + throw new IOException(name + " API returned invalid JSON response"); + } + ImmutableList.Builder speakerBuilder = new ImmutableList.Builder<>(); for (JsonElement je : ja) { diff --git a/core/src/main/java/dev/felnull/itts/core/voice/voicevox/VoicevoxManager.java b/core/src/main/java/dev/felnull/itts/core/voice/voicevox/VoicevoxManager.java index 1fc846b..27bc76c 100644 --- a/core/src/main/java/dev/felnull/itts/core/voice/voicevox/VoicevoxManager.java +++ b/core/src/main/java/dev/felnull/itts/core/voice/voicevox/VoicevoxManager.java @@ -116,12 +116,22 @@ protected List requestSpeakers(VVURL vvurl) throws IOException, .timeout(Duration.of(3000, ChronoUnit.MILLIS)) .build(); HttpResponse rep = hc.send(req, HttpResponse.BodyHandlers.ofInputStream()); + + int statusCode = rep.statusCode(); + if (statusCode != 200) { + throw new IOException(name + " API error: HTTP " + statusCode); + } + JsonArray ja; try (InputStream stream = new BufferedInputStream(rep.body()); Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { ja = GSON.fromJson(reader, JsonArray.class); } + if (ja == null) { + throw new IOException(name + " API returned invalid JSON response"); + } + ImmutableList.Builder speakerBuilder = new ImmutableList.Builder<>(); for (JsonElement je : ja) { @@ -142,9 +152,21 @@ private JsonObject getQuery(String text, int speakerId) { .build(); HttpResponse rep = hc.send(req, HttpResponse.BodyHandlers.ofInputStream()); + int statusCode = rep.statusCode(); + if (statusCode != 200) { + throw new IOException(name + " audio_query API error: HTTP " + statusCode); + } + + JsonObject result; try (InputStream stream = new BufferedInputStream(rep.body()); Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { - return GSON.fromJson(reader, JsonObject.class); + result = GSON.fromJson(reader, JsonObject.class); } + + if (result == null) { + throw new IOException(name + " audio_query API returned invalid JSON response"); + } + + return result; } catch (Exception e) { throw new RuntimeException(e); } diff --git a/core/src/test/java/dev/felnull/itts/core/dict/DictionaryManagerTest.java b/core/src/test/java/dev/felnull/itts/core/dict/DictionaryManagerTest.java new file mode 100644 index 0000000..497bc2e --- /dev/null +++ b/core/src/test/java/dev/felnull/itts/core/dict/DictionaryManagerTest.java @@ -0,0 +1,104 @@ +package dev.felnull.itts.core.dict; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * DictionaryManagerのバリデーションテスト + */ +public class DictionaryManagerTest { + + private final DictionaryManager dictionaryManager = new DictionaryManager(); + + @Nested + @DisplayName("serverDictLoadFromJson()のバリデーションテスト") + class ServerDictLoadFromJsonTests { + + @Test + @DisplayName("バージョンが異なる場合は例外をスロー") + void invalidVersionThrowsException() { + JsonObject jo = new JsonObject(); + jo.addProperty("version", 999); + JsonObject entry = new JsonObject(); + entry.addProperty("test", "テスト"); + jo.add("entry", entry); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Unsupported dictionary file version", exception.getMessage()); + } + + @Test + @DisplayName("versionフィールドがない場合は例外をスロー") + void missingVersionThrowsException() { + JsonObject jo = new JsonObject(); + JsonObject entry = new JsonObject(); + entry.addProperty("test", "テスト"); + jo.add("entry", entry); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Unsupported dictionary file version", exception.getMessage()); + } + + @Test + @DisplayName("entryフィールドがない場合は例外をスロー") + void missingEntryFieldThrowsException() { + JsonObject jo = new JsonObject(); + jo.addProperty("version", 0); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Invalid dictionary file format", exception.getMessage()); + } + + @Test + @DisplayName("entryがオブジェクトでない場合は例外をスロー") + void entryNotObjectThrowsException() { + JsonObject jo = new JsonObject(); + jo.addProperty("version", 0); + jo.addProperty("entry", "not an object"); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Invalid dictionary file format", exception.getMessage()); + } + + @Test + @DisplayName("エントリ数が上限を超える場合は例外をスロー") + void tooManyEntriesThrowsException() { + JsonObject jo = new JsonObject(); + jo.addProperty("version", 0); + JsonObject entry = new JsonObject(); + + for (int i = 0; i <= 1000; i++) { + entry.addProperty("word" + i, "読み" + i); + } + jo.add("entry", entry); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertTrue(exception.getMessage().contains("Dictionary entry count exceeds limit")); + } + + @ParameterizedTest + @DisplayName("負のバージョン番号でも例外をスロー") + @ValueSource(ints = {-1, -100, Integer.MIN_VALUE}) + void negativeVersionThrowsException(int version) { + JsonObject jo = new JsonObject(); + jo.addProperty("version", version); + JsonObject entry = new JsonObject(); + entry.addProperty("test", "テスト"); + jo.add("entry", entry); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> + dictionaryManager.serverDictLoadFromJson(jo, 123456L, false)); + Assertions.assertEquals("Unsupported dictionary file version", exception.getMessage()); + } + } +} diff --git a/core/src/test/java/dev/felnull/itts/core/util/PatternValidatorTest.java b/core/src/test/java/dev/felnull/itts/core/util/PatternValidatorTest.java new file mode 100644 index 0000000..984e3f9 --- /dev/null +++ b/core/src/test/java/dev/felnull/itts/core/util/PatternValidatorTest.java @@ -0,0 +1,157 @@ +package dev.felnull.itts.core.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.stream.Stream; + +/** + * PatternValidatorのテストクラス + */ +public class PatternValidatorTest { + + @Nested + @DisplayName("validate()メソッドのテスト") + class ValidateTests { + + @ParameterizedTest + @DisplayName("有効な正規表現が成功を返す") + @ValueSource(strings = { + "^[a-z]+$", + "[0-9]{3}-[0-9]{4}", + "hello", + "\\d+", + "a*b+c?", + "^(foo|bar)$", + "[A-Za-z0-9_]+", + "\\w+@\\w+\\.\\w+", + "(?:abc)+", + "\\p{L}+" + }) + void validPatternsReturnSuccess(String regex) { + PatternValidator.ValidationResult result = PatternValidator.validate(regex); + Assertions.assertTrue(result.valid()); + Assertions.assertNotNull(result.pattern()); + Assertions.assertNull(result.error()); + Assertions.assertTrue(result.getPattern().isPresent()); + } + + @ParameterizedTest + @DisplayName("無効な構文がエラーを返す") + @MethodSource("invalidPatterns") + void invalidSyntaxReturnsError(String regex, String expectedErrorContains) { + PatternValidator.ValidationResult result = PatternValidator.validate(regex); + Assertions.assertFalse(result.valid()); + Assertions.assertNull(result.pattern()); + Assertions.assertNotNull(result.error()); + Assertions.assertTrue(result.error().contains("無効な正規表現です")); + Assertions.assertTrue(result.getPattern().isEmpty()); + } + + private static Stream invalidPatterns() { + return Stream.of( + Arguments.of("[a-z", "Unclosed character class"), + Arguments.of("(abc", "Unclosed group"), + Arguments.of("(?P", "Unknown inline modifier"), + Arguments.of("*abc", "Dangling meta character"), + Arguments.of("+abc", "Dangling meta character"), + Arguments.of("?abc", "Dangling meta character"), + Arguments.of("\\", "Unexpected internal error"), + Arguments.of("[z-a]", "Illegal character range") + ); + } + + @ParameterizedTest + @DisplayName("ReDoS脆弱性パターンがエラーを返す") + @ValueSource(strings = { + "(a+)+", + "(a*)*", + "(a+)*", + "(a*)+", + "(x+)+b", + "(a|b)+", + "(a|b)*", + "(x|y)+z" + }) + void redosPatternsReturnError(String regex) { + PatternValidator.ValidationResult result = PatternValidator.validate(regex); + Assertions.assertFalse(result.valid()); + Assertions.assertNull(result.pattern()); + Assertions.assertNotNull(result.error()); + Assertions.assertTrue(result.error().contains("パフォーマンス上の問題")); + } + } + + @Nested + @DisplayName("isPotentiallyDangerous()メソッドのテスト") + class IsPotentiallyDangerousTests { + + @ParameterizedTest + @DisplayName("危険なパターンを検出する") + @ValueSource(strings = { + "(a+)+", + "(a*)*", + "(a+)*", + "(a*)+", + "(x+)+b", + "(a|b)+", + "(a|b)*", + "(foo|bar)*" + }) + void detectsDangerousPatterns(String regex) { + Assertions.assertTrue(PatternValidator.isPotentiallyDangerous(regex)); + } + + @ParameterizedTest + @DisplayName("安全なパターンは検出しない") + @ValueSource(strings = { + "^[a-z]+$", + "[0-9]{3}-[0-9]{4}", + "hello", + "\\d+", + "a+b+c+", + "^(foo|bar)$", + "[A-Za-z0-9_]+", + "\\w+@\\w+\\.\\w+", + "(?:abc)", + "a{1,10}" + }) + void safePatternNotDetected(String regex) { + Assertions.assertFalse(PatternValidator.isPotentiallyDangerous(regex)); + } + } + + @Nested + @DisplayName("ValidationResultのテスト") + class ValidationResultTests { + + @ParameterizedTest + @DisplayName("成功結果が正しく作成される") + @ValueSource(strings = {"abc", "\\d+", "[a-z]+"}) + void successResultCreatedCorrectly(String regex) { + PatternValidator.ValidationResult result = PatternValidator.ValidationResult.success( + java.util.regex.Pattern.compile(regex) + ); + Assertions.assertTrue(result.valid()); + Assertions.assertNotNull(result.pattern()); + Assertions.assertNull(result.error()); + Assertions.assertEquals(regex, result.pattern().pattern()); + } + + @ParameterizedTest + @DisplayName("失敗結果が正しく作成される") + @ValueSource(strings = {"エラー1", "エラー2", "無効な正規表現です"}) + void failureResultCreatedCorrectly(String errorMessage) { + PatternValidator.ValidationResult result = PatternValidator.ValidationResult.failure(errorMessage); + Assertions.assertFalse(result.valid()); + Assertions.assertNull(result.pattern()); + Assertions.assertEquals(errorMessage, result.error()); + Assertions.assertTrue(result.getPattern().isEmpty()); + } + } +}