From 2a8b4749d55c4cc22d5dc31e30ca263c2c5d45d8 Mon Sep 17 00:00:00 2001 From: xt0x Date: Sat, 18 Apr 2026 09:09:08 +0900 Subject: [PATCH] feat(parser): implement StrictMnemonicParser for mnemonic validation and parsing --- .../bip39/api/DefaultBip39Service.java | 22 +++++ .../example/bip39/parser/ParsedMnemonic.java | 17 ++++ .../bip39/parser/StrictMnemonicParser.java | 74 ++++++++++++++++ .../bip39/api/DefaultBip39ServiceTest.java | 42 +++++++-- .../parser/StrictMnemonicParserTest.java | 86 +++++++++++++++++++ 5 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/bip39/parser/ParsedMnemonic.java create mode 100644 src/main/java/com/example/bip39/parser/StrictMnemonicParser.java create mode 100644 src/test/java/com/example/bip39/parser/StrictMnemonicParserTest.java diff --git a/src/main/java/com/example/bip39/api/DefaultBip39Service.java b/src/main/java/com/example/bip39/api/DefaultBip39Service.java index 25183d1..d2404ce 100644 --- a/src/main/java/com/example/bip39/api/DefaultBip39Service.java +++ b/src/main/java/com/example/bip39/api/DefaultBip39Service.java @@ -1,6 +1,9 @@ package com.example.bip39.api; +import com.example.bip39.error.Bip39ErrorCode; +import com.example.bip39.error.Bip39Exception; import com.example.bip39.model.ValidationResult; +import com.example.bip39.parser.StrictMnemonicParser; import com.example.bip39.wordlist.Bip39WordList; import com.example.bip39.wordlist.EnglishWordList; import java.util.List; @@ -29,21 +32,33 @@ public String entropyToMnemonic(byte[] entropy) { @Override public byte[] mnemonicToEntropy(String mnemonic) { + StrictMnemonicParser.parse(mnemonic); throw new UnsupportedOperationException("mnemonicToEntropy is not implemented yet"); } @Override public byte[] mnemonicToEntropy(List words) { + StrictMnemonicParser.parse(words); throw new UnsupportedOperationException("mnemonicToEntropy is not implemented yet"); } @Override public ValidationResult validateMnemonic(String mnemonic) { + try { + StrictMnemonicParser.parse(mnemonic); + } catch (Bip39Exception exception) { + return invalidFormatResult(exception); + } throw new UnsupportedOperationException("validateMnemonic is not implemented yet"); } @Override public ValidationResult validateMnemonic(List words) { + try { + StrictMnemonicParser.parse(words); + } catch (Bip39Exception exception) { + return invalidFormatResult(exception); + } throw new UnsupportedOperationException("validateMnemonic is not implemented yet"); } @@ -56,4 +71,11 @@ public byte[] mnemonicToSeed(String mnemonic, String passphrase) { public byte[] mnemonicToSeed(List words, String passphrase) { throw new UnsupportedOperationException("mnemonicToSeed is not implemented yet"); } + + private static ValidationResult invalidFormatResult(Bip39Exception exception) { + if (exception.getErrorCode() != Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT) { + throw exception; + } + return ValidationResult.failure(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, null, null, null); + } } diff --git a/src/main/java/com/example/bip39/parser/ParsedMnemonic.java b/src/main/java/com/example/bip39/parser/ParsedMnemonic.java new file mode 100644 index 0000000..d3098e8 --- /dev/null +++ b/src/main/java/com/example/bip39/parser/ParsedMnemonic.java @@ -0,0 +1,17 @@ +package com.example.bip39.parser; + +import java.util.List; +import java.util.Objects; + +public record ParsedMnemonic(List words, String normalizedMnemonic) { + + public ParsedMnemonic { + words = List.copyOf(Objects.requireNonNull(words, "words must not be null")); + normalizedMnemonic = + Objects.requireNonNull(normalizedMnemonic, "normalizedMnemonic must not be null"); + } + + public int wordCount() { + return words.size(); + } +} diff --git a/src/main/java/com/example/bip39/parser/StrictMnemonicParser.java b/src/main/java/com/example/bip39/parser/StrictMnemonicParser.java new file mode 100644 index 0000000..7ea1691 --- /dev/null +++ b/src/main/java/com/example/bip39/parser/StrictMnemonicParser.java @@ -0,0 +1,74 @@ +package com.example.bip39.parser; + +import com.example.bip39.error.Bip39ErrorCode; +import com.example.bip39.error.Bip39Exception; +import java.text.Normalizer; +import java.text.Normalizer.Form; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public final class StrictMnemonicParser { + + private static final Pattern NORMALIZED_ENGLISH_MNEMONIC = Pattern.compile("[a-z]+(?: [a-z]+)*"); + + private StrictMnemonicParser() {} + + public static ParsedMnemonic parse(String mnemonic) { + if (mnemonic == null || !isNormalizedEnglishMnemonic(mnemonic)) { + throw invalidMnemonicFormat(); + } + return new ParsedMnemonic(splitNormalizedMnemonic(mnemonic), mnemonic); + } + + public static ParsedMnemonic parse(List words) { + if (words == null) { + throw invalidMnemonicFormat(); + } + + for (String word : words) { + if (word == null || word.isEmpty() || containsWhitespace(word)) { + throw invalidMnemonicFormat(); + } + } + + String normalizedMnemonic = String.join(" ", words); + if (!isNormalizedEnglishMnemonic(normalizedMnemonic)) { + throw invalidMnemonicFormat(); + } + + return new ParsedMnemonic(words, normalizedMnemonic); + } + + static boolean isNormalizedEnglishMnemonic(String mnemonic) { + return Normalizer.isNormalized(mnemonic, Form.NFKD) + && NORMALIZED_ENGLISH_MNEMONIC.matcher(mnemonic).matches(); + } + + private static List splitNormalizedMnemonic(String mnemonic) { + List words = new ArrayList<>(); + int wordStart = 0; + for (int index = 0; index <= mnemonic.length(); index++) { + if (index == mnemonic.length() || mnemonic.charAt(index) == ' ') { + words.add(mnemonic.substring(wordStart, index)); + wordStart = index + 1; + } + } + return words; + } + + private static boolean containsWhitespace(String word) { + for (int index = 0; index < word.length(); index++) { + char character = word.charAt(index); + if (Character.isWhitespace(character) || Character.isSpaceChar(character)) { + return true; + } + } + return false; + } + + private static Bip39Exception invalidMnemonicFormat() { + return new Bip39Exception( + Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, "Mnemonic must already be normalized"); + } +} diff --git a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java index 5ea42d0..4527a2c 100644 --- a/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java +++ b/src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java @@ -1,8 +1,14 @@ package com.example.bip39.api; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.example.bip39.error.Bip39ErrorCode; +import com.example.bip39.error.Bip39Exception; +import com.example.bip39.model.ValidationResult; import com.example.bip39.wordlist.EnglishWordList; import java.util.List; import org.junit.jupiter.api.Test; @@ -25,21 +31,23 @@ void servicesShareSingleLoadedWordListInstance() { } @Test - void coreApiMethodsRemainExplicitlyUnimplemented() { + void methodsBeyondParsingRemainExplicitlyUnimplementedForNormalizedInputs() { DefaultBip39Service service = new DefaultBip39Service(); assertThrows( UnsupportedOperationException.class, () -> service.entropyToMnemonic(new byte[16])); assertThrows( - UnsupportedOperationException.class, () -> service.mnemonicToEntropy("abandon ability")); + UnsupportedOperationException.class, + () -> service.mnemonicToEntropy("abandon ability able")); assertThrows( UnsupportedOperationException.class, - () -> service.mnemonicToEntropy(List.of("abandon", "ability"))); + () -> service.mnemonicToEntropy(List.of("abandon", "ability", "able"))); assertThrows( - UnsupportedOperationException.class, () -> service.validateMnemonic("abandon ability")); + UnsupportedOperationException.class, + () -> service.validateMnemonic("abandon ability able")); assertThrows( UnsupportedOperationException.class, - () -> service.validateMnemonic(List.of("abandon", "ability"))); + () -> service.validateMnemonic(List.of("abandon", "ability", "able"))); assertThrows( UnsupportedOperationException.class, () -> service.mnemonicToSeed("abandon ability", "TREZOR")); @@ -47,4 +55,28 @@ void coreApiMethodsRemainExplicitlyUnimplemented() { UnsupportedOperationException.class, () -> service.mnemonicToSeed(List.of("abandon", "ability"), "TREZOR")); } + + @Test + void mnemonicToEntropyRejectsInvalidFormatBeforeCoreLogic() { + DefaultBip39Service service = new DefaultBip39Service(); + + Bip39Exception exception = + assertThrows( + Bip39Exception.class, () -> service.mnemonicToEntropy(" abandon ability able")); + + assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode()); + } + + @Test + void validateMnemonicReturnsFormatFailureBeforeValidationLogic() { + DefaultBip39Service service = new DefaultBip39Service(); + + ValidationResult result = service.validateMnemonic("abandon ability able"); + + assertFalse(result.ok()); + assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, result.errorCode()); + assertNull(result.normalizedMnemonic()); + assertNull(result.wordCount()); + assertNull(result.invalidWord()); + } } diff --git a/src/test/java/com/example/bip39/parser/StrictMnemonicParserTest.java b/src/test/java/com/example/bip39/parser/StrictMnemonicParserTest.java new file mode 100644 index 0000000..38de546 --- /dev/null +++ b/src/test/java/com/example/bip39/parser/StrictMnemonicParserTest.java @@ -0,0 +1,86 @@ +package com.example.bip39.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.example.bip39.error.Bip39ErrorCode; +import com.example.bip39.error.Bip39Exception; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class StrictMnemonicParserTest { + + @Test + void parsesNormalizedMnemonicStringIntoWords() { + ParsedMnemonic parsed = StrictMnemonicParser.parse("abandon ability able"); + + assertEquals(List.of("abandon", "ability", "able"), parsed.words()); + assertEquals("abandon ability able", parsed.normalizedMnemonic()); + assertEquals(3, parsed.wordCount()); + } + + @Test + void parsesWordListIntoNormalizedMnemonic() { + ParsedMnemonic parsed = StrictMnemonicParser.parse(List.of("abandon", "ability", "able")); + + assertEquals(List.of("abandon", "ability", "able"), parsed.words()); + assertEquals("abandon ability able", parsed.normalizedMnemonic()); + assertEquals(3, parsed.wordCount()); + } + + @Test + void rejectsNullMnemonicString() { + assertInvalidFormat((String) null); + } + + @Test + void rejectsNullWordList() { + assertInvalidFormat((List) null); + } + + @Test + void rejectsStringWithLeadingTrailingOrRepeatedSpaces() { + assertInvalidFormat(" abandon ability"); + assertInvalidFormat("abandon ability "); + assertInvalidFormat("abandon ability"); + } + + @Test + void rejectsStringWithTabsNewlinesOrUppercaseLetters() { + assertInvalidFormat("abandon" + '\t' + "ability"); + assertInvalidFormat("abandon" + '\n' + "ability"); + assertInvalidFormat("Abandon ability"); + } + + @Test + void rejectsStringContainingNonAsciiOrNonNormalizedCharacters() { + assertInvalidFormat("abandon café"); + assertInvalidFormat("abandon" + '\u000b' + "ability"); + } + + @Test + void rejectsWordListsContainingNullEmptyOrWhitespace() { + assertInvalidFormat(List.of("abandon", "", "able")); + assertInvalidFormat(List.of("abandon", "ab ility", "able")); + assertInvalidFormat(List.of("abandon", "ab" + '\t' + "ility", "able")); + assertInvalidFormat(List.of("abandon", "Ability", "able")); + } + + @Test + void rejectsNullWordEntries() { + assertInvalidFormat(Arrays.asList("abandon", null, "able")); + } + + private static void assertInvalidFormat(String mnemonic) { + Bip39Exception exception = + assertThrows(Bip39Exception.class, () -> StrictMnemonicParser.parse(mnemonic)); + assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode()); + } + + private static void assertInvalidFormat(List words) { + Bip39Exception exception = + assertThrows(Bip39Exception.class, () -> StrictMnemonicParser.parse(words)); + assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode()); + } +}