Forráskód Böngészése

Improved SecPasswordField and added unit tests

SecPasswordFields will now normalize any input to NFC on the fly. Any input typed into the password field will now get converted to NFC on-the-fly. This allows subsequent code to keep working on the CharSequence returned by getCharacters() without the need of additional Normalization. Affects #521
Sebastian Stenzel 6 éve
szülő
commit
4bfd1e6433

+ 6 - 0
main/pom.xml

@@ -221,6 +221,12 @@
 				<artifactId>hamcrest-all</artifactId>
 				<version>${hamcrest.version}</version>
 			</dependency>
+			<dependency>
+				<groupId>org.openjfx</groupId>
+				<artifactId>javafx-swing</artifactId>
+				<version>${javafx.version}</version>
+				<scope>test</scope>
+			</dependency>
 		</dependencies>
 	</dependencyManagement>
 

+ 5 - 0
main/ui/pom.xml

@@ -109,5 +109,10 @@
 			<version>1.1</version>
 			<scope>test</scope>
 		</dependency>
+		<dependency>
+			<groupId>org.openjfx</groupId>
+			<artifactId>javafx-swing</artifactId>
+			<scope>test</scope>
+		</dependency>
 	</dependencies>
 </project>

+ 59 - 6
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 <a href="https://www.unicode.org/glossary/#normalization_form_c">NFC</a>.
+	 *
+	 * @param start The starting index in the range, inclusive. This must be &gt;= 0 and &lt; 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 &gt; the start,
+	 * and &lt;= 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;
+		int added = normalizedText.length();
+		int delta = added - removed;
+
+		// ensure sufficient content buffer size
+		int oldLength = length;
+		this.length += delta;
 		growContentIfNeeded();
-		text.getChars(0, text.length(), content, start);
 
-		String placeholderString = Strings.repeat(PLACEHOLDER, text.length());
+		// 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 <a href="https://www.unicode.org/glossary/#normalization_form_c">NFC</a> 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);
 	}
 
 }

+ 163 - 0
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());
+	}
+
+}