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) { ...@@ -265,6 +265,7 @@ if (is_android) {
":gurl_jni_headers", ":gurl_jni_headers",
"//base:base_java", "//base:base_java",
"//base:base_java_test_support", "//base:base_java_test_support",
"//base:jni_java",
"//third_party/android_support_test_runner:rules_java", "//third_party/android_support_test_runner:rules_java",
"//third_party/android_support_test_runner:runner_java", "//third_party/android_support_test_runner:runner_java",
"//third_party/junit", "//third_party/junit",
......
...@@ -5,7 +5,11 @@ ...@@ -5,7 +5,11 @@
package org.chromium.url; package org.chromium.url;
import android.os.SystemClock; 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.CalledByNative;
import org.chromium.base.annotations.JNINamespace; import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.MainDex; import org.chromium.base.annotations.MainDex;
...@@ -27,6 +31,10 @@ import org.chromium.base.metrics.RecordHistogram; ...@@ -27,6 +31,10 @@ import org.chromium.base.metrics.RecordHistogram;
@JNINamespace("url") @JNINamespace("url")
@MainDex @MainDex
public class GURL { 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 // 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 // GURL component other than the spec itself. Should we cache return Strings (as
// WeakReference?) so that callers can share String memory? // WeakReference?) so that callers can share String memory?
...@@ -46,6 +54,12 @@ public class GURL { ...@@ -46,6 +54,12 @@ public class GURL {
* @param uri The string URI representation to parse into a GURL. * @param uri The string URI representation to parse into a GURL.
*/ */
public GURL(String uri) { 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(); ensureNativeInitializedForGURL();
GURLJni.get().init(uri, this); GURLJni.get().init(uri, this);
} }
...@@ -207,6 +221,64 @@ public class GURL { ...@@ -207,6 +221,64 @@ public class GURL {
return mSpec.equals(((GURL) other).mSpec); 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 @NativeMethods
interface Natives { interface Natives {
/** /**
......
...@@ -34,6 +34,10 @@ import org.chromium.base.annotations.NativeMethods; ...@@ -34,6 +34,10 @@ import org.chromium.base.annotations.NativeMethods;
private final Parsed mInnerUrl; private final Parsed mInnerUrl;
private final boolean mPotentiallyDanglingMarkup; 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 @CalledByNative
private Parsed(int schemeBegin, int schemeLength, int usernameBegin, int usernameLength, private Parsed(int schemeBegin, int schemeLength, int usernameBegin, int usernameLength,
int passwordBegin, int passwordLength, int hostBegin, int hostLength, int portBegin, int passwordBegin, int passwordLength, int hostBegin, int hostLength, int portBegin,
...@@ -70,6 +74,60 @@ import org.chromium.base.annotations.NativeMethods; ...@@ -70,6 +74,60 @@ import org.chromium.base.annotations.NativeMethods;
mRefBegin, mRefLength, mPotentiallyDanglingMarkup, inner); 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 @NativeMethods
interface Natives { interface Natives {
/** /**
......
...@@ -4,7 +4,12 @@ ...@@ -4,7 +4,12 @@
package org.chromium.url; package org.chromium.url;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.doThrow;
import org.junit.Assert; import org.junit.Assert;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.chromium.base.annotations.CalledByNative; import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.CalledByNativeJavaTest; import org.chromium.base.annotations.CalledByNativeJavaTest;
...@@ -17,14 +22,35 @@ import java.net.URISyntaxException; ...@@ -17,14 +22,35 @@ import java.net.URISyntaxException;
* correctly. * correctly.
*/ */
public class GURLJavaTest { public class GURLJavaTest {
@Mock
GURL.Natives mGURLMocks;
@CalledByNative @CalledByNative
private GURLJavaTest() {} private GURLJavaTest() {
MockitoAnnotations.initMocks(this);
}
@CalledByNative @CalledByNative
public GURL createGURL(String uri) { public GURL createGURL(String uri) {
return new GURL(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 // Equivalent of GURLTest.Components
@CalledByNativeJavaTest @CalledByNativeJavaTest
@SuppressWarnings(value = "AuthLeak") @SuppressWarnings(value = "AuthLeak")
...@@ -61,6 +87,10 @@ public class GURLJavaTest { ...@@ -61,6 +87,10 @@ public class GURLJavaTest {
// Equivalent of GURLTest.Empty // Equivalent of GURLTest.Empty
@CalledByNativeJavaTest @CalledByNativeJavaTest
public void testEmpty() { 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(""); GURL url = new GURL("");
Assert.assertFalse(url.isValid()); Assert.assertFalse(url.isValid());
Assert.assertEquals("", url.getSpec()); Assert.assertEquals("", url.getSpec());
...@@ -73,6 +103,7 @@ public class GURLJavaTest { ...@@ -73,6 +103,7 @@ public class GURLJavaTest {
Assert.assertEquals("", url.getPath()); Assert.assertEquals("", url.getPath());
Assert.assertEquals("", url.getQuery()); Assert.assertEquals("", url.getQuery());
Assert.assertEquals("", url.getRef()); Assert.assertEquals("", url.getRef());
GURLJni.TEST_HOOKS.setInstanceForTesting(null);
} }
// Test that GURL and URI return the correct Origin. // Test that GURL and URI return the correct Origin.
...@@ -106,4 +137,117 @@ public class GURLJavaTest { ...@@ -106,4 +137,117 @@ public class GURLJavaTest {
Assert.assertEquals("", url.getQuery()); Assert.assertEquals("", url.getQuery());
Assert.assertEquals("", url.getRef()); 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