Commit c936d612 authored by Michael Thiessen's avatar Michael Thiessen Committed by Commit Bot

Introduce serialze/deserialize to Java GURL

In order to avoid loading the native library really early for use cases
like the SearchActivity, which caches a URL and uses it immediately on
Activity creation, we can serialze and deserialze GURLs.

See https://chromium-review.googlesource.com/c/chromium/src/+/2071376/6/chrome/android/java/src/org/chromium/chrome/browser/omnibox/suggestions/OmniboxSuggestion.java#244
for more context.

The serialization is quite simple, as GURL is comprised of ints, bools,
and a string. Further, if the version changes, instead of having to be
backwards/forwards compatible, as long as the GURL is the last token and
the delimiter doesn't change, the GURL can simply be re-parsed from the
spec at a small startup cost due to having to load the native library
early.

Bug: 783819
Change-Id: I4ac94c44e8041612d672ad52a92c83247e20c37d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2076400Reviewed-by: default avatarChris Palmer <palmer@chromium.org>
Commit-Queue: Michael Thiessen <mthiesse@chromium.org>
Cr-Commit-Position: refs/heads/master@{#745240}
parent f9e8a6b5
......@@ -265,6 +265,7 @@ if (is_android) {
":gurl_jni_headers",
"//base:base_java",
"//base:base_java_test_support",
"//base:jni_java",
"//third_party/android_support_test_runner:rules_java",
"//third_party/android_support_test_runner:runner_java",
"//third_party/junit",
......
......@@ -5,7 +5,11 @@
package org.chromium.url;
import android.os.SystemClock;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import org.chromium.base.Log;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.MainDex;
......@@ -27,6 +31,10 @@ import org.chromium.base.metrics.RecordHistogram;
@JNINamespace("url")
@MainDex
public class GURL {
private static final String TAG = "GURL";
/* package */ static final int SERIALIZER_VERSION = 1;
/* package */ static final char SERIALIZER_DELIMITER = '\0';
// TODO(https://crbug.com/1039841): Right now we return a new String with each request for a
// GURL component other than the spec itself. Should we cache return Strings (as
// WeakReference?) so that callers can share String memory?
......@@ -46,6 +54,12 @@ public class GURL {
* @param uri The string URI representation to parse into a GURL.
*/
public GURL(String uri) {
// Avoid a jni hop (and initializing the native library) for empty GURLs.
if (TextUtils.isEmpty(uri)) {
mSpec = "";
mParsed = Parsed.createEmpty();
return;
}
ensureNativeInitializedForGURL();
GURLJni.get().init(uri, this);
}
......@@ -207,6 +221,64 @@ public class GURL {
return mSpec.equals(((GURL) other).mSpec);
}
/**
* Serialize a GURL to a String, to be used with {@link GURL#deserialize(String)}.
*
* Note that a serialized GURL should only be used internally to Chrome, and should *never* be
* used if coming from an untrusted source.
*
* @return A serialzed GURL.
*/
public final String serialize() {
StringBuilder builder = new StringBuilder();
builder.append(SERIALIZER_VERSION).append(SERIALIZER_DELIMITER);
builder.append(mIsValid).append(SERIALIZER_DELIMITER);
builder.append(mParsed.serialize()).append(SERIALIZER_DELIMITER);
builder.append(mSpec);
String serialization = builder.toString();
return Integer.toString(serialization.length()) + SERIALIZER_DELIMITER + serialization;
}
/**
* Deserialize a GURL serialized with {@link GURL#serialize()}.
*
* This function should *never* be used on a String coming from an untrusted source.
*
* @return The deserialized GURL (or null if the input is empty).
*/
public static GURL deserialize(@Nullable String gurl) {
try {
if (TextUtils.isEmpty(gurl)) return emptyGURL();
String[] tokens = gurl.split(Character.toString(SERIALIZER_DELIMITER));
// First token MUST always be the length of the serialized data.
String length = tokens[0];
if (gurl.length() != Integer.parseInt(length) + length.length() + 1) {
throw new IllegalArgumentException("Serialized GURL had the wrong length.");
}
// Last token MUST always be the original spec - just re-parse the GURL on version
// changes.
String spec = tokens[tokens.length - 1];
// Special case for empty spec - it won't get its own token.
if (gurl.endsWith(Character.toString(SERIALIZER_DELIMITER))) spec = "";
// Second token MUST always be the version number.
int version = Integer.parseInt(tokens[1]);
if (version != SERIALIZER_VERSION) return new GURL(spec);
boolean isValid = Boolean.parseBoolean(tokens[2]);
Parsed parsed = Parsed.deserialize(tokens, 3);
GURL result = new GURL();
result.init(spec, isValid, parsed);
return result;
} catch (Exception e) {
// This is unexpected, maybe the storage got corrupted somehow?
Log.w(TAG, "Exception while deserializing a GURL: " + gurl, e);
return emptyGURL();
}
}
@NativeMethods
interface Natives {
/**
......
......@@ -34,6 +34,10 @@ import org.chromium.base.annotations.NativeMethods;
private final Parsed mInnerUrl;
private final boolean mPotentiallyDanglingMarkup;
/* packaged */ static Parsed createEmpty() {
return new Parsed(0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, 0, -1, false, null);
}
@CalledByNative
private Parsed(int schemeBegin, int schemeLength, int usernameBegin, int usernameLength,
int passwordBegin, int passwordLength, int hostBegin, int hostLength, int portBegin,
......@@ -70,6 +74,60 @@ import org.chromium.base.annotations.NativeMethods;
mRefBegin, mRefLength, mPotentiallyDanglingMarkup, inner);
}
/* package */ String serialize() {
StringBuilder builder = new StringBuilder();
builder.append(mSchemeBegin).append(GURL.SERIALIZER_DELIMITER);
builder.append(mSchemeLength).append(GURL.SERIALIZER_DELIMITER);
builder.append(mUsernameBegin).append(GURL.SERIALIZER_DELIMITER);
builder.append(mUsernameLength).append(GURL.SERIALIZER_DELIMITER);
builder.append(mPasswordBegin).append(GURL.SERIALIZER_DELIMITER);
builder.append(mPasswordLength).append(GURL.SERIALIZER_DELIMITER);
builder.append(mHostBegin).append(GURL.SERIALIZER_DELIMITER);
builder.append(mHostLength).append(GURL.SERIALIZER_DELIMITER);
builder.append(mPortBegin).append(GURL.SERIALIZER_DELIMITER);
builder.append(mPortLength).append(GURL.SERIALIZER_DELIMITER);
builder.append(mPathBegin).append(GURL.SERIALIZER_DELIMITER);
builder.append(mPathLength).append(GURL.SERIALIZER_DELIMITER);
builder.append(mQueryBegin).append(GURL.SERIALIZER_DELIMITER);
builder.append(mQueryLength).append(GURL.SERIALIZER_DELIMITER);
builder.append(mRefBegin).append(GURL.SERIALIZER_DELIMITER);
builder.append(mRefLength).append(GURL.SERIALIZER_DELIMITER);
builder.append(mPotentiallyDanglingMarkup).append(GURL.SERIALIZER_DELIMITER);
builder.append(mInnerUrl != null);
if (mInnerUrl != null) {
builder.append(GURL.SERIALIZER_DELIMITER).append(mInnerUrl.serialize());
}
return builder.toString();
}
/* package */ static Parsed deserialize(String[] tokens, int startIndex) {
int schemeBegin = Integer.parseInt(tokens[startIndex++]);
int schemeLength = Integer.parseInt(tokens[startIndex++]);
int usernameBegin = Integer.parseInt(tokens[startIndex++]);
int usernameLength = Integer.parseInt(tokens[startIndex++]);
int passwordBegin = Integer.parseInt(tokens[startIndex++]);
int passwordLength = Integer.parseInt(tokens[startIndex++]);
int hostBegin = Integer.parseInt(tokens[startIndex++]);
int hostLength = Integer.parseInt(tokens[startIndex++]);
int portBegin = Integer.parseInt(tokens[startIndex++]);
int portLength = Integer.parseInt(tokens[startIndex++]);
int pathBegin = Integer.parseInt(tokens[startIndex++]);
int pathLength = Integer.parseInt(tokens[startIndex++]);
int queryBegin = Integer.parseInt(tokens[startIndex++]);
int queryLength = Integer.parseInt(tokens[startIndex++]);
int refBegin = Integer.parseInt(tokens[startIndex++]);
int refLength = Integer.parseInt(tokens[startIndex++]);
boolean potentiallyDanglingMarkup = Boolean.parseBoolean(tokens[startIndex++]);
Parsed innerParsed = null;
if (Boolean.parseBoolean(tokens[startIndex++])) {
innerParsed = Parsed.deserialize(tokens, startIndex);
}
return new Parsed(schemeBegin, schemeLength, usernameBegin, usernameLength, passwordBegin,
passwordLength, hostBegin, hostLength, portBegin, portLength, pathBegin, pathLength,
queryBegin, queryLength, refBegin, refLength, potentiallyDanglingMarkup,
innerParsed);
}
@NativeMethods
interface Natives {
/**
......
......@@ -4,7 +4,12 @@
package org.chromium.url;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.doThrow;
import org.junit.Assert;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.CalledByNativeJavaTest;
......@@ -17,14 +22,35 @@ import java.net.URISyntaxException;
* correctly.
*/
public class GURLJavaTest {
@Mock
GURL.Natives mGURLMocks;
@CalledByNative
private GURLJavaTest() {}
private GURLJavaTest() {
MockitoAnnotations.initMocks(this);
}
@CalledByNative
public GURL createGURL(String uri) {
return new GURL(uri);
}
private void deepAssertEquals(GURL expected, GURL actual) {
Assert.assertEquals(expected, actual);
Assert.assertEquals(expected.getScheme(), actual.getScheme());
Assert.assertEquals(expected.getUsername(), actual.getUsername());
Assert.assertEquals(expected.getPassword(), actual.getPassword());
Assert.assertEquals(expected.getHost(), actual.getHost());
Assert.assertEquals(expected.getPort(), actual.getPort());
Assert.assertEquals(expected.getPath(), actual.getPath());
Assert.assertEquals(expected.getQuery(), actual.getQuery());
Assert.assertEquals(expected.getRef(), actual.getRef());
}
private String prependLengthToSerialization(String serialization) {
return Integer.toString(serialization.length()) + GURL.SERIALIZER_DELIMITER + serialization;
}
// Equivalent of GURLTest.Components
@CalledByNativeJavaTest
@SuppressWarnings(value = "AuthLeak")
......@@ -61,6 +87,10 @@ public class GURLJavaTest {
// Equivalent of GURLTest.Empty
@CalledByNativeJavaTest
public void testEmpty() {
GURLJni.TEST_HOOKS.setInstanceForTesting(mGURLMocks);
doThrow(new RuntimeException("Should not need to parse empty URL"))
.when(mGURLMocks)
.init(any(), any());
GURL url = new GURL("");
Assert.assertFalse(url.isValid());
Assert.assertEquals("", url.getSpec());
......@@ -73,6 +103,7 @@ public class GURLJavaTest {
Assert.assertEquals("", url.getPath());
Assert.assertEquals("", url.getQuery());
Assert.assertEquals("", url.getRef());
GURLJni.TEST_HOOKS.setInstanceForTesting(null);
}
// Test that GURL and URI return the correct Origin.
......@@ -106,4 +137,117 @@ public class GURLJavaTest {
Assert.assertEquals("", url.getQuery());
Assert.assertEquals("", url.getRef());
}
@CalledByNativeJavaTest
@SuppressWarnings(value = "AuthLeak")
public void testSerialization() {
GURL cases[] = {
// Common Standard URLs.
new GURL("https://www.google.com"),
new GURL("https://www.google.com/"),
new GURL("https://www.google.com/maps.htm"),
new GURL("https://www.google.com/maps/"),
new GURL("https://www.google.com/index.html"),
new GURL("https://www.google.com/index.html?q=maps"),
new GURL("https://www.google.com/index.html#maps/"),
new GURL("https://foo:bar@www.google.com/maps.htm"),
new GURL("https://www.google.com/maps/au/index.html"),
new GURL("https://www.google.com/maps/au/north"),
new GURL("https://www.google.com/maps/au/north/"),
new GURL("https://www.google.com/maps/au/index.html?q=maps#fragment/"),
new GURL("http://www.google.com:8000/maps/au/index.html?q=maps#fragment/"),
new GURL("https://www.google.com/maps/au/north/?q=maps#fragment"),
new GURL("https://www.google.com/maps/au/north?q=maps#fragment"),
// Less common standard URLs.
new GURL("filesystem:http://www.google.com/temporary/bar.html?baz=22"),
new GURL("file:///temporary/bar.html?baz=22"),
new GURL("ftp://foo/test/index.html"),
new GURL("gopher://foo/test/index.html"),
new GURL("ws://foo/test/index.html"),
// Non-standard,
new GURL("chrome://foo/bar.html"),
new GURL("httpa://foo/test/index.html"),
new GURL("blob:https://foo.bar/test/index.html"),
new GURL("about:blank"),
new GURL("data:foobar"),
new GURL("scheme:opaque_data"),
// Invalid URLs.
new GURL("foobar"),
// URLs containing the delimiter
new GURL("https://www.google.ca/" + GURL.SERIALIZER_DELIMITER + ",foo"),
new GURL("https://www.foo" + GURL.SERIALIZER_DELIMITER + "bar.com"),
};
GURLJni.TEST_HOOKS.setInstanceForTesting(mGURLMocks);
doThrow(new RuntimeException("Should not re-initialize for deserialization when the "
+ "version hasn't changed."))
.when(mGURLMocks)
.init(any(), any());
for (GURL url : cases) {
GURL out = GURL.deserialize(url.serialize());
deepAssertEquals(url, out);
}
GURLJni.TEST_HOOKS.setInstanceForTesting(null);
}
/**
* Tests that we re-parse the URL from the spec, which must always be the last token in the
* serialization, if the serialization version differs.
*/
@CalledByNativeJavaTest
public void testSerializationWithVersionSkew() {
GURL url = new GURL("https://www.google.com");
String serialization = (GURL.SERIALIZER_VERSION + 1)
+ ",0,0,0,0,foo,https://url.bad,blah,0,".replace(',', GURL.SERIALIZER_DELIMITER)
+ url.getSpec();
serialization = prependLengthToSerialization(serialization);
GURL out = GURL.deserialize(serialization);
deepAssertEquals(url, out);
}
/**
* Tests that fields that aren't visible to java code are correctly serialized.
*/
@CalledByNativeJavaTest
public void testSerializationOfPrivateFields() {
String serialization = GURL.SERIALIZER_VERSION
+ ",true,"
// Outer Parsed.
+ "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,false,true,"
// Inner Parsed.
+ "17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,true,false,"
+ "chrome://foo/bar.html";
serialization = serialization.replace(',', GURL.SERIALIZER_DELIMITER);
serialization = prependLengthToSerialization(serialization);
GURL url = GURL.deserialize(serialization);
Assert.assertEquals(url.serialize(), serialization);
}
/**
* Tests serialized GURL truncated by storage.
*/
@CalledByNativeJavaTest
public void testTruncatedDeserialization() {
String serialization = "123,1,true,1,2,3,4,5,6,7,8,9,10";
serialization = serialization.replace(',', GURL.SERIALIZER_DELIMITER);
GURL url = GURL.deserialize(serialization);
Assert.assertEquals(url, GURL.emptyGURL());
}
/**
* Tests serialized GURL truncated by storage.
*/
@CalledByNativeJavaTest
public void testCorruptedSerializations() {
String serialization = new GURL("https://www.google.ca").serialize();
// Replace the scheme length (5) with an extra delimiter.
String corruptedParsed = serialization.replace('5', GURL.SERIALIZER_DELIMITER);
GURL url = GURL.deserialize(corruptedParsed);
Assert.assertEquals(GURL.emptyGURL(), url);
String corruptedVersion =
serialization.replaceFirst(Integer.toString(GURL.SERIALIZER_VERSION), "x");
url = GURL.deserialize(corruptedVersion);
Assert.assertEquals(GURL.emptyGURL(), url);
}
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment