Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/main/java/com/example/bip39/api/DefaultBip39Service.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String> 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<String> words) {
try {
StrictMnemonicParser.parse(words);
} catch (Bip39Exception exception) {
return invalidFormatResult(exception);
}
throw new UnsupportedOperationException("validateMnemonic is not implemented yet");
}

Expand All @@ -56,4 +71,11 @@ public byte[] mnemonicToSeed(String mnemonic, String passphrase) {
public byte[] mnemonicToSeed(List<String> 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);
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/example/bip39/parser/ParsedMnemonic.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.example.bip39.parser;

import java.util.List;
import java.util.Objects;

public record ParsedMnemonic(List<String> 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();
}
}
74 changes: 74 additions & 0 deletions src/main/java/com/example/bip39/parser/StrictMnemonicParser.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> splitNormalizedMnemonic(String mnemonic) {
List<String> 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");
}
}
42 changes: 37 additions & 5 deletions src/test/java/com/example/bip39/api/DefaultBip39ServiceTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,26 +31,52 @@ 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"));
assertThrows(
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());
}
}
Original file line number Diff line number Diff line change
@@ -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<String>) 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<String> words) {
Bip39Exception exception =
assertThrows(Bip39Exception.class, () -> StrictMnemonicParser.parse(words));
assertEquals(Bip39ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, exception.getErrorCode());
}
}
Loading