diff --git a/main/pom.xml b/main/pom.xml index 5d98886e9..98916b6b7 100644 --- a/main/pom.xml +++ b/main/pom.xml @@ -221,6 +221,12 @@ hamcrest-all ${hamcrest.version} + + org.openjfx + javafx-swing + ${javafx.version} + test + diff --git a/main/ui/pom.xml b/main/ui/pom.xml index 710fe7c3f..1465683f0 100644 --- a/main/ui/pom.xml +++ b/main/ui/pom.xml @@ -109,5 +109,10 @@ 1.1 test + + org.openjfx + javafx-swing + test + diff --git a/main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java b/main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java index 8a6d0539e..960a7220a 100644 --- a/main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java +++ b/main/ui/src/main/java/org/cryptomator/ui/controls/SecPasswordField.java @@ -15,6 +15,8 @@ import javafx.scene.input.Dragboard; import javafx.scene.input.TransferMode; import java.nio.CharBuffer; +import java.text.Normalizer; +import java.text.Normalizer.Form; import java.util.Arrays; /** @@ -53,15 +55,38 @@ public class SecPasswordField extends PasswordField { event.consume(); } + /** + * Replaces a range of characters with the given text. + * The text will be normalized to NFC. + * + * @param start The starting index in the range, inclusive. This must be >= 0 and < the end. + * @param end The ending index in the range, exclusive. This is one-past the last character to + * delete (consistent with the String manipulation methods). This must be > the start, + * and <= the length of the text. + * @param text The text that is to replace the range. This must not be null. + * @implNote Internally calls {@link PasswordField#replaceText(int, int, String)} with a dummy String for visual purposes. + */ @Override public void replaceText(int start, int end, String text) { + String normalizedText = Normalizer.normalize(text, Form.NFC); int removed = end - start; - int added = text.length(); - this.length += added - removed; - growContentIfNeeded(); - text.getChars(0, text.length(), content, start); + int added = normalizedText.length(); + int delta = added - removed; - String placeholderString = Strings.repeat(PLACEHOLDER, text.length()); + // ensure sufficient content buffer size + int oldLength = length; + this.length += delta; + growContentIfNeeded(); + + // shift existing content + if (delta != 0 && start < oldLength) { + System.arraycopy(content, end, content, end + delta, oldLength - end); + } + + // copy new text to content buffer + normalizedText.getChars(0, normalizedText.length(), content, start); + + String placeholderString = Strings.repeat(PLACEHOLDER, normalizedText.length()); super.replaceText(start, end, placeholderString); } @@ -69,7 +94,7 @@ public class SecPasswordField extends PasswordField { if (length > content.length) { char[] newContent = new char[length + GROW_BUFFER_SIZE]; System.arraycopy(content, 0, newContent, 0, content.length); - swipe(); + swipe(content); this.content = newContent; } } @@ -80,6 +105,7 @@ public class SecPasswordField extends PasswordField { * @return A character sequence backed by the SecPasswordField's buffer (not a copy). * @implNote The CharSequence will not copy the backing char[]. * Therefore any mutation to the SecPasswordField's content will mutate or eventually swipe the returned CharSequence. + * @implSpec The CharSequence is usually in NFC representation (unless NFD-encoded char[] is set via {@link #setPassword(char[])}). * @see #swipe() */ @Override @@ -87,6 +113,28 @@ public class SecPasswordField extends PasswordField { return CharBuffer.wrap(content, 0, length); } + /** + * Convenience method wrapper for {@link #setPassword(char[])}. + * + * @param password + * @see #setPassword(char[]) + */ + public void setPassword(CharSequence password) { + char[] buf = new char[password.length()]; + for (int i = 0; i < password.length(); i++) { + buf[i] = password.charAt(i); + } + setPassword(buf); + Arrays.fill(buf, SWIPE_CHAR); + } + + /** + * Directly sets the content of this password field to a copy of the given password. + * No conversion whatsoever happens. If you want to normalize the unicode representation of the password, + * do it before calling this method. + * + * @param password + */ public void setPassword(char[] password) { swipe(); content = Arrays.copyOf(password, password.length); @@ -100,7 +148,12 @@ public class SecPasswordField extends PasswordField { * Destroys the stored password by overriding each character with a different character. */ public void swipe() { - Arrays.fill(content, SWIPE_CHAR); + swipe(content); + length = 0; + } + + private void swipe(char[] buffer) { + Arrays.fill(buffer, SWIPE_CHAR); } } diff --git a/main/ui/src/test/java/org/cryptomator/ui/controls/SecPasswordFieldTest.java b/main/ui/src/test/java/org/cryptomator/ui/controls/SecPasswordFieldTest.java new file mode 100644 index 000000000..0c5538892 --- /dev/null +++ b/main/ui/src/test/java/org/cryptomator/ui/controls/SecPasswordFieldTest.java @@ -0,0 +1,163 @@ +package org.cryptomator.ui.controls; + +import javafx.embed.swing.JFXPanel; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import javax.swing.SwingUtilities; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + + +class SecPasswordFieldTest { + + private SecPasswordField pwField = new SecPasswordField(); + + @BeforeAll + static void initJavaFx() throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + SwingUtilities.invokeLater(() -> { + new JFXPanel(); // initializes JavaFX environment + latch.countDown(); + }); + + if (!latch.await(5L, TimeUnit.SECONDS)) { + throw new ExceptionInInitializerError(); + } + } + + @Nested + @DisplayName("Content Update Events") + class TextChange { + + @Test + @DisplayName("\"ant\".append(\"eater\")") + public void append() { + pwField.setPassword("ant"); + pwField.appendText("eater"); + + Assertions.assertEquals("anteater", pwField.getCharacters().toString()); + } + + @Test + @DisplayName("\"eater\".insert(0, \"ant\")") + public void insert1() { + pwField.setPassword("eater"); + pwField.insertText(0, "ant"); + + Assertions.assertEquals("anteater", pwField.getCharacters().toString()); + } + + @Test + @DisplayName("\"anteater\".insert(3, \"b\")") + public void insert2() { + pwField.setPassword("anteater"); + pwField.insertText(3, "b"); + + Assertions.assertEquals("antbeater", pwField.getCharacters().toString()); + } + + @Test + @DisplayName("\"anteater\".delete(0, 3)") + public void delete1() { + pwField.setPassword("anteater"); + pwField.deleteText(0, 3); + + Assertions.assertEquals("eater", pwField.getCharacters().toString()); + } + + @Test + @DisplayName("\"anteater\".delete(3, 8)") + public void delete2() { + pwField.setPassword("anteater"); + pwField.deleteText(3, 8); + + Assertions.assertEquals("ant", pwField.getCharacters().toString()); + } + + @Test + @DisplayName("\"anteater\".replace(0, 3, \"hand\")") + public void replace1() { + pwField.setPassword("anteater"); + pwField.replaceText(0, 3, "hand"); + + Assertions.assertEquals("handeater", pwField.getCharacters().toString()); + } + + @Test + @DisplayName("\"anteater\".replace(3, 6, \"keep\")") + public void replace2() { + pwField.setPassword("anteater"); + pwField.replaceText(3, 6, "keep"); + + Assertions.assertEquals("antkeeper", pwField.getCharacters().toString()); + } + + @Test + @DisplayName("\"anteater\".replace(0, 3, \"bee\")") + public void replace3() { + pwField.setPassword("anteater"); + pwField.replaceText(0, 3, "bee"); + + Assertions.assertEquals("beeeater", pwField.getCharacters().toString()); + } + + } + + @Test + @DisplayName("entering NFC string leads to NFC char[]") + public void enterNfcString() { + pwField.appendText("str\u00F6m"); // ström + pwField.insertText(0, "\u212Bng"); // Ång + pwField.appendText("\uD83D\uDCA9"); // 💩 + + CharSequence result = pwField.getCharacters(); + Assertions.assertEquals('\u00C5', result.charAt(0)); + Assertions.assertEquals('n', result.charAt(1)); + Assertions.assertEquals('g', result.charAt(2)); + Assertions.assertEquals('s', result.charAt(3)); + Assertions.assertEquals('t', result.charAt(4)); + Assertions.assertEquals('r', result.charAt(5)); + Assertions.assertEquals('ö', result.charAt(6)); + Assertions.assertEquals('m', result.charAt(7)); + Assertions.assertEquals('\uD83D', result.charAt(8)); + Assertions.assertEquals('\uDCA9', result.charAt(9)); + } + + @Test + @DisplayName("entering NFD string leads to NFC char[]") + public void enterNfdString() { + pwField.appendText("str\u006F\u0308m"); // ström + pwField.insertText(0, "\u0041\u030Ang"); // Ång + pwField.appendText("\uD83D\uDCA9"); // 💩 + + CharSequence result = pwField.getCharacters(); + Assertions.assertEquals('\u00C5', result.charAt(0)); + Assertions.assertEquals('n', result.charAt(1)); + Assertions.assertEquals('g', result.charAt(2)); + Assertions.assertEquals('s', result.charAt(3)); + Assertions.assertEquals('t', result.charAt(4)); + Assertions.assertEquals('r', result.charAt(5)); + Assertions.assertEquals('ö', result.charAt(6)); + Assertions.assertEquals('m', result.charAt(7)); + Assertions.assertEquals('\uD83D', result.charAt(8)); + Assertions.assertEquals('\uDCA9', result.charAt(9)); + } + + @Test + @DisplayName("test swipe char[]") + public void swipe() { + pwField.appendText("topSecret"); + + CharSequence result1 = pwField.getCharacters(); + Assertions.assertEquals("topSecret", result1.toString()); + pwField.swipe(); + CharSequence result2 = pwField.getCharacters(); + Assertions.assertEquals(" ", result1.toString()); + Assertions.assertEquals("", result2.toString()); + } + +}