Add a general utility class for parsing user-agent strings.

This commit is contained in:
Jon Chambers 2020-09-18 12:02:37 -04:00 committed by Jon Chambers
parent b041fbe3ec
commit baab6b951b
5 changed files with 236 additions and 0 deletions

View File

@ -0,0 +1,7 @@
package org.whispersystems.textsecuregcm.util.ua;
public enum ClientPlatform {
ANDROID,
DESKTOP,
IOS;
}

View File

@ -0,0 +1,15 @@
package org.whispersystems.textsecuregcm.util.ua;
public class UnrecognizedUserAgentException extends Exception {
public UnrecognizedUserAgentException() {
}
public UnrecognizedUserAgentException(final String message) {
super(message);
}
public UnrecognizedUserAgentException(final Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,59 @@
package org.whispersystems.textsecuregcm.util.ua;
import com.vdurmont.semver4j.Semver;
import java.util.Objects;
import java.util.Optional;
public class UserAgent {
private final ClientPlatform platform;
private final Semver version;
private final String additionalSpecifiers;
public UserAgent(final ClientPlatform platform, final Semver version) {
this(platform, version, null);
}
public UserAgent(final ClientPlatform platform, final Semver version, final String additionalSpecifiers) {
this.platform = platform;
this.version = version;
this.additionalSpecifiers = additionalSpecifiers;
}
public ClientPlatform getPlatform() {
return platform;
}
public Semver getVersion() {
return version;
}
public Optional<String> getAdditionalSpecifiers() {
return Optional.ofNullable(additionalSpecifiers);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final UserAgent userAgent = (UserAgent)o;
return platform == userAgent.platform &&
version.equals(userAgent.version) &&
Objects.equals(additionalSpecifiers, userAgent.additionalSpecifiers);
}
@Override
public int hashCode() {
return Objects.hash(platform, version, additionalSpecifiers);
}
@Override
public String toString() {
return "UserAgent{" +
"platform=" + platform +
", version=" + version +
", additionalSpecifiers='" + additionalSpecifiers + '\'' +
'}';
}
}

View File

@ -0,0 +1,81 @@
package org.whispersystems.textsecuregcm.util.ua;
import com.google.common.annotations.VisibleForTesting;
import com.vdurmont.semver4j.Semver;
import org.apache.commons.lang3.StringUtils;
import java.util.EnumMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class UserAgentUtil {
private static final Pattern STANDARD_UA_PATTERN = Pattern.compile("^Signal-(Android|Desktop|iOS)/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE);
private static final Map<ClientPlatform, Pattern> LEGACY_PATTERNS_BY_PLATFORM = new EnumMap<>(ClientPlatform.class);
static {
LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.ANDROID, Pattern.compile("^Signal-Android ([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE));
LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.DESKTOP, Pattern.compile("^Signal Desktop (.+)$", Pattern.CASE_INSENSITIVE));
LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.IOS, Pattern.compile("^Signal/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE));
}
public static UserAgent parseUserAgentString(final String userAgentString) throws UnrecognizedUserAgentException {
if (StringUtils.isBlank(userAgentString)) {
throw new UnrecognizedUserAgentException("User-Agent string is blank");
}
try {
final UserAgent standardUserAgent = parseStandardUserAgentString(userAgentString);
if (standardUserAgent != null) {
return standardUserAgent;
}
final UserAgent legacyUserAgent = parseLegacyUserAgentString(userAgentString);
if (legacyUserAgent != null) {
return legacyUserAgent;
}
} catch (final Exception e) {
throw new UnrecognizedUserAgentException(e);
}
throw new UnrecognizedUserAgentException();
}
@VisibleForTesting
static UserAgent parseStandardUserAgentString(final String userAgentString) {
final Matcher matcher = STANDARD_UA_PATTERN.matcher(userAgentString);
if (matcher.matches()) {
return new UserAgent(ClientPlatform.valueOf(matcher.group(1).toUpperCase()), new Semver(matcher.group(2)), StringUtils.stripToNull(matcher.group(4)));
}
return null;
}
@VisibleForTesting
static UserAgent parseLegacyUserAgentString(final String userAgentString) {
for (final Map.Entry<ClientPlatform, Pattern> entry : LEGACY_PATTERNS_BY_PLATFORM.entrySet()) {
final ClientPlatform platform = entry.getKey();
final Pattern pattern = entry.getValue();
final Matcher matcher = pattern.matcher(userAgentString);
if (matcher.matches()) {
final UserAgent userAgent;
if (matcher.groupCount() > 1) {
userAgent = new UserAgent(platform, new Semver(matcher.group(1)), StringUtils.stripToNull(matcher.group(matcher.groupCount())));
} else {
userAgent = new UserAgent(platform, new Semver(matcher.group(1)));
}
return userAgent;
}
}
return null;
}
}

View File

@ -0,0 +1,74 @@
package org.whispersystems.textsecuregcm.util.ua;
import com.vdurmont.semver4j.Semver;
import junitparams.JUnitParamsRunner;
import junitparams.Parameters;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import static org.junit.Assert.*;
@RunWith(JUnitParamsRunner.class)
public class UserAgentUtilTest {
@Test
@Parameters(method = "argumentsForTestParseUserAgentString")
public void testParseUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) throws UnrecognizedUserAgentException {
assertEquals(expectedUserAgent, UserAgentUtil.parseUserAgentString(userAgentString));
}
private static Object argumentsForTestParseUserAgentString() {
return new Object[] {
new Object[] { "Signal-Android/4.68.3 Android/25", new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25") },
new Object[] { "Signal-Android 4.53.7 (Android 8.1)", new UserAgent(ClientPlatform.ANDROID, new Semver("4.53.7"), "(Android 8.1)") },
};
}
@Test
@Parameters(method = "argumentsForTestParseBogusUserAgentString")
public void testParseBogusUserAgentString(final String userAgentString) {
assertThrows(UnrecognizedUserAgentException.class, () -> UserAgentUtil.parseUserAgentString(userAgentString));
}
private static Object argumentsForTestParseBogusUserAgentString() {
return new Object[] {
null,
"This is obviously not a reasonable User-Agent string.",
"Signal-Android/4.6-8.3.unreasonableversionstring-17"
};
}
@Test
@Parameters(method = "argumentsForTestParseStandardUserAgentString")
public void testParseStandardUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) {
assertEquals(expectedUserAgent, UserAgentUtil.parseStandardUserAgentString(userAgentString));
}
private static Object argumentsForTestParseStandardUserAgentString() {
return new Object[] {
new Object[] { "This is obviously not a reasonable User-Agent string.", null },
new Object[] { "Signal-Android/4.68.3 Android/25", new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25") },
new Object[] { "Signal-Android/4.68.3", new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3")) },
new Object[] { "Signal-Desktop/1.32.0-beta.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.32.0-beta.3")) },
new Object[] { "Signal-iOS/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)") },
new Object[] { "Signal-iOS/3.9.0", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0")) }
};
}
@Test
@Parameters(method = "argumentsForTestParseLegacyUserAgentString")
public void testParseLegacyUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) {
assertEquals(expectedUserAgent, UserAgentUtil.parseLegacyUserAgentString(userAgentString));
}
private static Object argumentsForTestParseLegacyUserAgentString() {
return new Object[] {
new Object[] { "This is obviously not a reasonable User-Agent string.", null },
new Object[] { "Signal-Android 4.53.7 (Android 8.1)", new UserAgent(ClientPlatform.ANDROID, new Semver("4.53.7"), "(Android 8.1)") },
new Object[] { "Signal Desktop 1.2.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3")) },
new Object[] { "Signal Desktop 1.32.0-beta.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.32.0-beta.3")) },
new Object[] { "Signal/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)") }
};
}
}