diff --git a/pgpainless-core/src/main/java/org/pgpainless/key/util/UserId.java b/pgpainless-core/src/main/java/org/pgpainless/key/util/UserId.java deleted file mode 100644 index b115afda..00000000 --- a/pgpainless-core/src/main/java/org/pgpainless/key/util/UserId.java +++ /dev/null @@ -1,356 +0,0 @@ -// SPDX-FileCopyrightText: 2020 Paul Schaub , 2021 Flowcrypt a.s. -// -// SPDX-License-Identifier: Apache-2.0 - -package org.pgpainless.key.util; - -import java.util.Comparator; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - -public final class UserId implements CharSequence { - - // Email regex: https://emailregex.com/ - // switched "a-z0-9" to "\p{L}\u0900-\u097F0-9" for better support for international characters - // \\p{L} = Unicode Letters - // \u0900-\u097F = Hindi Letters - private static final Pattern emailPattern = Pattern.compile("(?:[\\p{L}\\u0900-\\u097F0-9!#\\$%&'*+/=?^_`{|}~-]+(?:\\.[\\p{L}\\u0900-\\u097F0-9!#\\$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-" + - "\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[\\p{L}\\u0900-\\u097F0-9](?:[\\p{L}\\u0900-\\u097F0-9" + - "-]*[\\p{L}\\u0900-\\u097F0-9])?\\.)+[\\p{L}\\u0900-\\u097F0-9](?:[\\p{L}\\u0900-\\u097F0-9-]*[\\p{L}\\u0900-\\u097F0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" + - "\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[$\\p{L}\\u0900-\\u097F0-9-]*[\\p{L}\\u0900-\\u097F0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f" + - "\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)])"); - - // User-ID Regex - // "Firstname Lastname (Comment) " - // All groups are optional - // https://www.rfc-editor.org/rfc/rfc5322#page-16 - private static final Pattern nameAddrPattern = Pattern.compile("^((?.+?)\\s)?(\\((?.+?)\\)\\s)?(<(?.+?)>)?$"); - - public static final class Builder { - private String name; - private String comment; - private String email; - - private Builder() { - } - - private Builder(String name, String comment, String email) { - this.name = name; - this.comment = comment; - this.email = email; - } - - public Builder withName(@Nonnull String name) { - this.name = name; - return this; - } - - public Builder withComment(@Nonnull String comment) { - this.comment = comment; - return this; - } - - public Builder withEmail(@Nonnull String email) { - this.email = email; - return this; - } - - public Builder noName() { - name = null; - return this; - } - - public Builder noComment() { - comment = null; - return this; - } - - public Builder noEmail() { - email = null; - return this; - } - - public UserId build() { - return new UserId(name, comment, email); - } - } - - /** - * Parse a {@link UserId} from free-form text,
name-addr
or
mailbox
string and split it - * up into its components. - * Example inputs for this method: - *
    - *
  • john@pgpainless.org
  • - *
  • <john@pgpainless.org>
  • - *
  • John Doe
  • - *
  • John Doe <john@pgpainless.org>
  • - *
  • John Doe (work email) <john@pgpainless.org>
  • - *
- * In these cases, this method will detect email addresses, names and comments and expose those - * via the respective getters. - * This method does not support parsing mail addresses of the following formats: - *
    - *
  • Local domains without TLDs (
    user@localdomain1
    )
  • - *
  • " "@example.org
    (spaces between the quotes)
  • - *
  • "very.(),:;<>[]\".VERY.\"very@\\ \"very\".unusual"@strange.example.com
  • - *
- * Note: This method does not guarantee that
string.equals(UserId.parse(string).toString())
is true. - * For example,
UserId.parse("alice@pgpainless.org").toString()
wraps the mail address in angled brackets. - * - * @see RFC5322 §3.4. Address Specification - * @param string user-id - * @return parsed {@link UserId} object - */ - public static UserId parse(@Nonnull String string) { - Builder builder = newBuilder(); - string = string.trim(); - Matcher matcher = nameAddrPattern.matcher(string); - if (matcher.find()) { - String name = matcher.group(2); - String comment = matcher.group(4); - String mail = matcher.group(6); - matcher = emailPattern.matcher(mail); - if (!matcher.matches()) { - throw new IllegalArgumentException("Malformed email address"); - } - - if (name != null) { - builder.withName(name); - } - if (comment != null) { - builder.withComment(comment); - } - builder.withEmail(mail); - } else { - matcher = emailPattern.matcher(string); - if (matcher.matches()) { - builder.withEmail(string); - } else { - throw new IllegalArgumentException("Malformed email address"); - } - } - return builder.build(); - } - - private final String name; - private final String comment; - private final String email; - private long hash = Long.MAX_VALUE; - - private UserId(@Nullable String name, @Nullable String comment, @Nullable String email) { - this.name = name == null ? null : name.trim(); - this.comment = comment == null ? null : comment.trim(); - this.email = email == null ? null : email.trim(); - } - - public static UserId onlyEmail(@Nonnull String email) { - return new UserId(null, null, email); - } - - public static UserId nameAndEmail(@Nonnull String name, @Nonnull String email) { - return new UserId(name, null, email); - } - - public static Builder newBuilder() { - return new Builder(); - } - - public Builder toBuilder() { - return new Builder(name, comment, email); - } - - public String getName() { - return getName(false); - } - - public String getName(boolean preserveQuotes) { - if (name == null || name.isEmpty()) { - return name; - } - - if (name.startsWith("\"")) { - if (preserveQuotes) { - return name; - } - String withoutQuotes = name.substring(1); - if (withoutQuotes.endsWith("\"")) { - withoutQuotes = withoutQuotes.substring(0, withoutQuotes.length() - 1); - } - return withoutQuotes; - } - return name; - } - - public String getComment() { - return comment; - } - - public String getEmail() { - return email; - } - - @Override - public int length() { - return toString().length(); - } - - @Override - public char charAt(int i) { - return toString().charAt(i); - } - - @Override - public @Nonnull CharSequence subSequence(int i, int i1) { - return toString().subSequence(i, i1); - } - - @Override - public @Nonnull String toString() { - StringBuilder sb = new StringBuilder(); - if (name != null && !name.isEmpty()) { - sb.append(getName(true)); - } - if (comment != null && !comment.isEmpty()) { - if (sb.length() > 0) { - sb.append(' '); - } - sb.append('(').append(comment).append(')'); - } - if (email != null && !email.isEmpty()) { - if (sb.length() > 0) { - sb.append(' '); - } - sb.append('<').append(email).append('>'); - } - return sb.toString(); - } - - /** - * Returns a string representation of the object. - * @return a string representation of the object. - * @deprecated use {@link #toString()} instead. - */ - @Deprecated - public String asString() { - return toString(); - } - - @Override - public boolean equals(Object o) { - if (o == null) return false; - if (o == this) return true; - if (!(o instanceof UserId)) return false; - final UserId other = (UserId) o; - return isEqualComponent(name, other.name, false) - && isEqualComponent(comment, other.comment, false) - && isEqualComponent(email, other.email, true); - } - - @Override - public int hashCode() { - if (hash != Long.MAX_VALUE) { - return (int) hash; - } else { - int hashCode = 7; - hashCode = 31 * hashCode + (name == null ? 0 : name.hashCode()); - hashCode = 31 * hashCode + (comment == null ? 0 : comment.hashCode()); - hashCode = 31 * hashCode + (email == null ? 0 : email.toLowerCase().hashCode()); - this.hash = hashCode; - return hashCode; - } - } - - private static boolean isEqualComponent(String value, String otherValue, boolean ignoreCase) { - final boolean valueIsNull = (value == null); - final boolean otherValueIsNull = (otherValue == null); - return (valueIsNull && otherValueIsNull) - || (!valueIsNull && !otherValueIsNull - && (ignoreCase ? value.equalsIgnoreCase(otherValue) : value.equals(otherValue))); - } - - public static int compare(@Nullable UserId o1, @Nullable UserId o2, @Nonnull Comparator comparator) { - return comparator.compare(o1, o2); - } - - public static class DefaultComparator implements Comparator { - - @Override - public int compare(UserId o1, UserId o2) { - if (o1 == o2) { - return 0; - } - if (o1 == null) { - return -1; - } - if (o2 == null) { - return 1; - } - - NullSafeStringComparator c = new NullSafeStringComparator(); - int cName = c.compare(o1.getName(), o2.getName()); - if (cName != 0) { - return cName; - } - - int cComment = c.compare(o1.getComment(), o2.getComment()); - if (cComment != 0) { - return cComment; - } - - return c.compare(o1.getEmail(), o2.getEmail()); - } - } - - public static class DefaultIgnoreCaseComparator implements Comparator { - - @Override - public int compare(UserId o1, UserId o2) { - if (o1 == o2) { - return 0; - } - if (o1 == null) { - return -1; - } - if (o2 == null) { - return 1; - } - - NullSafeStringComparator c = new NullSafeStringComparator(); - int cName = c.compare(lower(o1.getName()), lower(o2.getName())); - if (cName != 0) { - return cName; - } - - int cComment = c.compare(lower(o1.getComment()), lower(o2.getComment())); - if (cComment != 0) { - return cComment; - } - - return c.compare(lower(o1.getEmail()), lower(o2.getEmail())); - } - - private static String lower(String string) { - return string == null ? null : string.toLowerCase(); - } - } - - private static class NullSafeStringComparator implements Comparator { - - @Override - public int compare(String o1, String o2) { - // noinspection StringEquality - if (o1 == o2) { - return 0; - } - if (o1 == null) { - return -1; - } - if (o2 == null) { - return 1; - } - return o1.compareTo(o2); - } - } -} diff --git a/pgpainless-core/src/main/kotlin/org/pgpainless/key/util/UserId.kt b/pgpainless-core/src/main/kotlin/org/pgpainless/key/util/UserId.kt new file mode 100644 index 00000000..b461f6b4 --- /dev/null +++ b/pgpainless-core/src/main/kotlin/org/pgpainless/key/util/UserId.kt @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2023 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.key.util + +class UserId internal constructor( + name: String?, + comment: String?, + email: String? +) : CharSequence { + + private val _name: String? + val comment: String? + val email: String? + + init { + this._name = name?.trim() + this.comment = comment?.trim() + this.email = email?.trim() + } + + val full: String = buildString { + if (name?.isNotBlank() == true) { + append(getName(true)) + } + if (comment?.isNotBlank() == true) { + if (isNotEmpty()) { + append(' ') + } + append("($comment)") + } + if (email?.isNotBlank() == true) { + if (isNotEmpty()) { + append(' ') + } + append("<$email>") + } + } + + override val length: Int + get() = full.length + + val name: String? + get() = getName(false) + + fun getName(preserveQuotes: Boolean): String? { + return if (preserveQuotes || _name.isNullOrBlank()) { + _name + } else _name.removeSurrounding("\"") + } + + override fun equals(other: Any?): Boolean { + if (other === null) { + return false + } + if (this === other) { + return true + } + if (other !is UserId) { + return false + } + return isComponentEqual(_name, other._name, false) + && isComponentEqual(comment, other.comment, false) + && isComponentEqual(email, other.email, true) + } + + override fun get(index: Int): Char { + return full[index] + } + + override fun hashCode(): Int { + return toString().hashCode() + } + + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { + return full.subSequence(startIndex, endIndex) + } + + override fun toString(): String { + return full + } + + private fun isComponentEqual(value: String?, otherValue: String?, ignoreCase: Boolean): Boolean = value.equals(otherValue, ignoreCase) + + fun toBuilder() = builder().also { builder -> + if (this._name != null) builder.withName(_name) + if (this.comment != null) builder.withComment(comment) + if (this.email != null) builder.withEmail(email) + } + + companion object { + + // Email regex: https://emailregex.com/ + // switched "a-z0-9" to "\p{L}\u0900-\u097F0-9" for better support for international characters + // \\p{L} = Unicode Letters + // \u0900-\u097F = Hindi Letters + @JvmStatic + private val emailPattern = ("(?:[\\p{L}\\u0900-\\u097F0-9!#\\$%&'*+/=?^_`{|}~-]+(?:\\.[\\p{L}\\u0900-\\u097F0-9!#\\$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-" + + "\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[\\p{L}\\u0900-\\u097F0-9](?:[\\p{L}\\u0900-\\u097F0-9" + + "-]*[\\p{L}\\u0900-\\u097F0-9])?\\.)+[\\p{L}\\u0900-\\u097F0-9](?:[\\p{L}\\u0900-\\u097F0-9-]*[\\p{L}\\u0900-\\u097F0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" + + "\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[$\\p{L}\\u0900-\\u097F0-9-]*[\\p{L}\\u0900-\\u097F0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f" + + "\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)])").toPattern() + + // User-ID Regex + // "Firstname Lastname (Comment) " + // All groups are optional + // https://www.rfc-editor.org/rfc/rfc5322#page-16 + @JvmStatic + private val nameAddrPattern = "^((?.+?)\\s)?(\\((?.+?)\\)\\s)?(<(?.+?)>)?$".toPattern() + + /** + * Parse a [UserId] from free-form text,
name-addr
or
mailbox
string and split it + * up into its components. + * Example inputs for this method: + *
    + *
  • john@pgpainless.org
  • + *
  • <john@pgpainless.org>
  • + *
  • John Doe
  • + *
  • John Doe <john@pgpainless.org>
  • + *
  • John Doe (work email) <john@pgpainless.org>
  • + *
+ * In these cases, this method will detect email addresses, names and comments and expose those + * via the respective getters. + * This method does not support parsing mail addresses of the following formats: + *
    + *
  • Local domains without TLDs (
    user@localdomain1
    )
  • + *
  • " "@example.org
    (spaces between the quotes)
  • + *
  • "very.(),:;<>[]\".VERY.\"very@\\ \"very\".unusual"@strange.example.com
  • + *
+ * Note: This method does not guarantee that
string.equals(UserId.parse(string).toString())
is true. + * For example,
UserId.parse("alice@pgpainless.org").toString()
wraps the mail address in angled brackets. + * + * @see RFC5322 §3.4. Address Specification + * @param string user-id + * @return parsed UserId object + */ + @JvmStatic + fun parse(string: String): UserId { + val trimmed = string.trim() + nameAddrPattern.matcher(trimmed).let { nameAddrMatcher -> + if (nameAddrMatcher.find()) { + val name = nameAddrMatcher.group(2) + val comment = nameAddrMatcher.group(4) + val mail = nameAddrMatcher.group(6) + require(emailPattern.matcher(mail).matches()) { "Malformed email address" } + return UserId(name, comment, mail) + } else { + require(emailPattern.matcher(trimmed).matches()) { "Malformed email address" } + return UserId(null, null, trimmed) + } + } + } + + @JvmStatic + fun onlyEmail(email: String) = UserId(null, null, email) + + @JvmStatic + fun nameAndEmail(name: String, email: String) = UserId(name, null, email) + + @JvmStatic + fun compare(u1: UserId?, u2: UserId?, comparator: Comparator) = comparator.compare(u1, u2) + + @JvmStatic + @Deprecated("Deprecated in favor of builde() method.", ReplaceWith("builder()")) + fun newBuilder() = builder() + + @JvmStatic + fun builder() = Builder() + } + + class Builder internal constructor() { + var name: String? = null + var comment: String? = null + var email: String? = null + + fun withName(name: String) = apply { this.name = name } + fun withComment(comment: String) = apply { this.comment = comment} + fun withEmail(email: String) = apply { this.email = email } + + fun noName() = apply { this.name = null } + fun noComment() = apply { this.comment = null } + fun noEmail() = apply { this.email = null } + + fun build() = UserId(name, comment, email) + } + + class DefaultComparator : Comparator { + override fun compare(o1: UserId?, o2: UserId?): Int { + return compareBy { it?._name } + .thenBy { it?.comment } + .thenBy { it?.email } + .compare(o1, o2) + } + } + + class DefaultIgnoreCaseComparator : Comparator { + override fun compare(p0: UserId?, p1: UserId?): Int { + return compareBy { it?._name?.lowercase() } + .thenBy { it?.comment?.lowercase() } + .thenBy { it?.email?.lowercase() } + .compare(p0, p1) + } + + } +} \ No newline at end of file diff --git a/pgpainless-core/src/test/java/org/pgpainless/key/UserIdTest.java b/pgpainless-core/src/test/java/org/pgpainless/key/UserIdTest.java index 93cb6922..1290ad9c 100644 --- a/pgpainless-core/src/test/java/org/pgpainless/key/UserIdTest.java +++ b/pgpainless-core/src/test/java/org/pgpainless/key/UserIdTest.java @@ -197,15 +197,14 @@ public class UserIdTest { } @Test - public void asStringTest() { - UserId id = UserId.newBuilder() + public void toStringTest() { + UserId id = UserId.builder() .withName("Alice") .withComment("Work Email") .withEmail("alice@pgpainless.org") .build(); - // noinspection deprecation - assertEquals(id.toString(), id.asString()); + assertEquals(id.toString(), id.toString()); } @Test