Commit 699ac366 authored by Douglas Creager's avatar Douglas Creager Committed by Commit Bot

Cronet: Test that NEL reports are generated

This adds test cases that verify that NEL reports are actually generated
and uploaded when NEL is activated.  We test both when the NEL policy is
received through HTTPS response headers, and when they're preloaded in
the Cronet experimental options config.

Cq-Include-Trybots: luci.chromium.try:ios-simulator-cronet;master.tryserver.chromium.android:android_cronet_tester
Change-Id: Id00f9095b04471a3adacc58f1fbf72b5c7848503
Reviewed-on: https://chromium-review.googlesource.com/c/1174564
Commit-Queue: Misha Efimov <mef@chromium.org>
Reviewed-by: default avatarMisha Efimov <mef@chromium.org>
Cr-Commit-Position: refs/heads/master@{#596019}
parent d677c800
...@@ -898,6 +898,7 @@ if (!is_component_build) { ...@@ -898,6 +898,7 @@ if (!is_component_build) {
"test/src/org/chromium/net/MockUrlRequestJobFactory.java", "test/src/org/chromium/net/MockUrlRequestJobFactory.java",
"test/src/org/chromium/net/NativeTestServer.java", "test/src/org/chromium/net/NativeTestServer.java",
"test/src/org/chromium/net/QuicTestServer.java", "test/src/org/chromium/net/QuicTestServer.java",
"test/src/org/chromium/net/ReportingCollector.java",
"test/src/org/chromium/net/TestFilesInstaller.java", "test/src/org/chromium/net/TestFilesInstaller.java",
"test/src/org/chromium/net/TestUploadDataStreamHandler.java", "test/src/org/chromium/net/TestUploadDataStreamHandler.java",
] ]
...@@ -1000,6 +1001,7 @@ if (!is_component_build) { ...@@ -1000,6 +1001,7 @@ if (!is_component_build) {
"test/javatests/src/org/chromium/net/GetStatusTest.java", "test/javatests/src/org/chromium/net/GetStatusTest.java",
"test/javatests/src/org/chromium/net/MetricsTestUtil.java", "test/javatests/src/org/chromium/net/MetricsTestUtil.java",
"test/javatests/src/org/chromium/net/NetworkChangeNotifierTest.java", "test/javatests/src/org/chromium/net/NetworkChangeNotifierTest.java",
"test/javatests/src/org/chromium/net/NetworkErrorLoggingTest.java",
"test/javatests/src/org/chromium/net/NQETest.java", "test/javatests/src/org/chromium/net/NQETest.java",
"test/javatests/src/org/chromium/net/PkpTest.java", "test/javatests/src/org/chromium/net/PkpTest.java",
"test/javatests/src/org/chromium/net/QuicTest.java", "test/javatests/src/org/chromium/net/QuicTest.java",
......
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.net;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.chromium.net.CronetTestRule.SERVER_CERT_PEM;
import static org.chromium.net.CronetTestRule.SERVER_KEY_PKCS8_PEM;
import static org.chromium.net.CronetTestRule.getContext;
import android.support.test.filters.SmallTest;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.chromium.base.test.BaseJUnit4ClassRunner;
import org.chromium.base.test.util.Feature;
import org.chromium.net.CronetTestRule.OnlyRunNativeCronet;
/**
* Tests requests that generate Network Error Logging reports.
*/
@RunWith(BaseJUnit4ClassRunner.class)
public class NetworkErrorLoggingTest {
@Rule
public final CronetTestRule mTestRule = new CronetTestRule();
private CronetEngine mCronetEngine;
@Before
public void setUp() throws Exception {
TestFilesInstaller.installIfNeeded(getContext());
assertTrue(Http2TestServer.startHttp2TestServer(
getContext(), SERVER_CERT_PEM, SERVER_KEY_PKCS8_PEM));
}
@After
public void tearDown() throws Exception {
assertTrue(Http2TestServer.shutdownHttp2TestServer());
if (mCronetEngine != null) {
mCronetEngine.shutdown();
}
}
@Test
@SmallTest
@Feature({"Cronet"})
@OnlyRunNativeCronet
public void testManualReportUpload() throws Exception {
ExperimentalCronetEngine.Builder builder =
new ExperimentalCronetEngine.Builder(getContext());
CronetTestUtil.setMockCertVerifierForTesting(
builder, QuicTestServer.createMockCertVerifier());
mCronetEngine = builder.build();
String url = Http2TestServer.getReportingCollectorUrl();
TestUrlRequestCallback callback = new TestUrlRequestCallback();
UrlRequest.Builder requestBuilder =
mCronetEngine.newUrlRequestBuilder(url, callback, callback.getExecutor());
TestUploadDataProvider dataProvider = new TestUploadDataProvider(
TestUploadDataProvider.SuccessCallbackMode.SYNC, callback.getExecutor());
dataProvider.addRead("[{\"type\": \"test_report\"}]".getBytes());
requestBuilder.setUploadDataProvider(dataProvider, callback.getExecutor());
requestBuilder.addHeader("Content-Type", "application/reports+json");
requestBuilder.build().start();
callback.blockForDone();
dataProvider.assertClosed();
assertEquals(200, callback.mResponseInfo.getHttpStatusCode());
assertTrue(Http2TestServer.getReportingCollector().containsReport(
"{\"type\": \"test_report\"}"));
}
@Test
@SmallTest
@Feature({"Cronet"})
@OnlyRunNativeCronet
public void testUploadNELReportsFromHeaders() throws Exception {
ExperimentalCronetEngine.Builder builder =
new ExperimentalCronetEngine.Builder(getContext());
builder.setExperimentalOptions("{\"NetworkErrorLogging\": {\"enable\": true}}");
CronetTestUtil.setMockCertVerifierForTesting(
builder, QuicTestServer.createMockCertVerifier());
mCronetEngine = builder.build();
String url = Http2TestServer.getSuccessWithNELHeadersUrl();
TestUrlRequestCallback callback = new TestUrlRequestCallback();
UrlRequest.Builder requestBuilder =
mCronetEngine.newUrlRequestBuilder(url, callback, callback.getExecutor());
requestBuilder.build().start();
callback.blockForDone();
assertEquals(200, callback.mResponseInfo.getHttpStatusCode());
Http2TestServer.getReportingCollector().waitForReports(1);
assertTrue(Http2TestServer.getReportingCollector().containsReport(""
+ "{"
+ " \"type\": \"network-error\","
+ " \"url\": \"" + url + "\","
+ " \"body\": {"
+ " \"method\": \"GET\","
+ " \"phase\": \"application\","
+ " \"protocol\": \"h2\","
+ " \"referrer\": \"\","
+ " \"sampling_fraction\": 1.0,"
+ " \"server_ip\": \"127.0.0.1\","
+ " \"status_code\": 200,"
+ " \"type\": \"ok\""
+ " }"
+ "}"));
}
@Test
@SmallTest
@Feature({"Cronet"})
@OnlyRunNativeCronet
public void testUploadNELReportsFromPreloadedPolicy() throws Exception {
ExperimentalCronetEngine.Builder builder =
new ExperimentalCronetEngine.Builder(getContext());
String serverOrigin = Http2TestServer.getServerUrl();
String collectorUrl = Http2TestServer.getReportingCollectorUrl();
builder.setExperimentalOptions(""
+ "{\"NetworkErrorLogging\": {"
+ " \"enable\": true,"
+ " \"preloaded_report_to_headers\": ["
+ " {"
+ " \"origin\": \"" + serverOrigin + "\","
+ " \"value\": {"
+ " \"group\": \"nel\","
+ " \"max_age\": 86400,"
+ " \"endpoints\": ["
+ " {\"url\": \"" + collectorUrl + "\"}"
+ " ]"
+ " }"
+ " }"
+ " ],"
+ " \"preloaded_nel_headers\": ["
+ " {"
+ " \"origin\": \"" + serverOrigin + "\","
+ " \"value\": {"
+ " \"report_to\": \"nel\","
+ " \"max_age\": 86400,"
+ " \"success_fraction\": 1.0"
+ " }"
+ " }"
+ " ]"
+ "}}");
CronetTestUtil.setMockCertVerifierForTesting(
builder, QuicTestServer.createMockCertVerifier());
mCronetEngine = builder.build();
String url = Http2TestServer.getEchoMethodUrl();
TestUrlRequestCallback callback = new TestUrlRequestCallback();
UrlRequest.Builder requestBuilder =
mCronetEngine.newUrlRequestBuilder(url, callback, callback.getExecutor());
requestBuilder.build().start();
callback.blockForDone();
assertEquals(200, callback.mResponseInfo.getHttpStatusCode());
Http2TestServer.getReportingCollector().waitForReports(1);
// Note that because we don't know in advance what the server IP address is for preloaded
// origins, we'll always get a "downgraded" dns.address_changed NEL report if we don't
// receive a replacement NEL policy with the request.
assertTrue(Http2TestServer.getReportingCollector().containsReport(""
+ "{"
+ " \"type\": \"network-error\","
+ " \"url\": \"" + url + "\","
+ " \"body\": {"
+ " \"method\": \"GET\","
+ " \"phase\": \"dns\","
+ " \"protocol\": \"h2\","
+ " \"referrer\": \"\","
+ " \"sampling_fraction\": 1.0,"
+ " \"server_ip\": \"127.0.0.1\","
+ " \"status_code\": 0,"
+ " \"type\": \"dns.address_changed\""
+ " }"
+ "}"));
}
}
...@@ -6,12 +6,16 @@ package org.chromium.net; ...@@ -6,12 +6,16 @@ package org.chromium.net;
import org.chromium.base.Log; import org.chromium.base.Log;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import static io.netty.buffer.Unpooled.copiedBuffer; import static io.netty.buffer.Unpooled.copiedBuffer;
import static io.netty.buffer.Unpooled.unreleasableBuffer; import static io.netty.buffer.Unpooled.unreleasableBuffer;
import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST;
import static io.netty.handler.codec.http.HttpResponseStatus.OK; import static io.netty.handler.codec.http.HttpResponseStatus.OK;
import static io.netty.handler.logging.LogLevel.INFO; import static io.netty.handler.logging.LogLevel.INFO;
...@@ -42,6 +46,8 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht ...@@ -42,6 +46,8 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht
public static final String ECHO_STREAM_PATH = "/echostream"; public static final String ECHO_STREAM_PATH = "/echostream";
public static final String ECHO_TRAILERS_PATH = "/echotrailers"; public static final String ECHO_TRAILERS_PATH = "/echotrailers";
public static final String SERVE_SIMPLE_BROTLI_RESPONSE = "/simplebrotli"; public static final String SERVE_SIMPLE_BROTLI_RESPONSE = "/simplebrotli";
public static final String REPORTING_COLLECTOR_PATH = "/reporting-collector";
public static final String SUCCESS_WITH_NEL_HEADERS_PATH = "/success-with-nel";
private static final String TAG = Http2TestHandler.class.getSimpleName(); private static final String TAG = Http2TestHandler.class.getSimpleName();
private static final Http2FrameLogger sLogger = private static final Http2FrameLogger sLogger =
...@@ -51,6 +57,9 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht ...@@ -51,6 +57,9 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht
private HashMap<Integer, RequestResponder> mResponderMap = new HashMap<>(); private HashMap<Integer, RequestResponder> mResponderMap = new HashMap<>();
private ReportingCollector mReportingCollector;
private String mServerUrl;
/** /**
* Builder for HTTP/2 test handler. * Builder for HTTP/2 test handler.
*/ */
...@@ -60,6 +69,16 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht ...@@ -60,6 +69,16 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht
frameLogger(sLogger); frameLogger(sLogger);
} }
public Builder setReportingCollector(ReportingCollector reportingCollector) {
mReportingCollector = reportingCollector;
return this;
}
public Builder setServerUrl(String serverUrl) {
mServerUrl = serverUrl;
return this;
}
@Override @Override
public Http2TestHandler build() { public Http2TestHandler build() {
return super.build(); return super.build();
...@@ -68,10 +87,14 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht ...@@ -68,10 +87,14 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht
@Override @Override
protected Http2TestHandler build(Http2ConnectionDecoder decoder, protected Http2TestHandler build(Http2ConnectionDecoder decoder,
Http2ConnectionEncoder encoder, Http2Settings initialSettings) { Http2ConnectionEncoder encoder, Http2Settings initialSettings) {
Http2TestHandler handler = new Http2TestHandler(decoder, encoder, initialSettings); Http2TestHandler handler = new Http2TestHandler(
decoder, encoder, initialSettings, mReportingCollector, mServerUrl);
frameListener(handler); frameListener(handler);
return handler; return handler;
} }
private ReportingCollector mReportingCollector;
private String mServerUrl;
} }
private class RequestResponder { private class RequestResponder {
...@@ -193,6 +216,76 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht ...@@ -193,6 +216,76 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht
} }
} }
// A RequestResponder that implements a Reporting collector.
private class ReportingCollectorResponder extends RequestResponder {
private ByteArrayOutputStream mPartialPayload = new ByteArrayOutputStream();
@Override
void onHeadersRead(ChannelHandlerContext ctx, int streamId, boolean endOfStream,
Http2Headers headers) {}
@Override
int onDataRead(ChannelHandlerContext ctx, int streamId, ByteBuf data, int padding,
boolean endOfStream) {
int processed = data.readableBytes() + padding;
try {
data.readBytes(mPartialPayload, data.readableBytes());
} catch (IOException e) {
}
if (endOfStream) {
processPayload(ctx, streamId);
}
return processed;
}
private void processPayload(ChannelHandlerContext ctx, int streamId) {
boolean succeeded = false;
try {
String payload = mPartialPayload.toString(CharsetUtil.UTF_8.name());
succeeded = mReportingCollector.addReports(payload);
} catch (UnsupportedEncodingException e) {
}
Http2Headers responseHeaders;
if (succeeded) {
responseHeaders = new DefaultHttp2Headers().status(OK.codeAsText());
} else {
responseHeaders = new DefaultHttp2Headers().status(BAD_REQUEST.codeAsText());
}
encoder().writeHeaders(ctx, streamId, responseHeaders, 0, true, ctx.newPromise());
ctx.flush();
}
}
// A RequestResponder that serves a successful response with Reporting and NEL headers
private class SuccessWithNELHeadersResponder extends RequestResponder {
@Override
void onHeadersRead(ChannelHandlerContext ctx, int streamId, boolean endOfStream,
Http2Headers headers) {
Http2Headers responseHeaders = new DefaultHttp2Headers().status(OK.codeAsText());
responseHeaders.add("report-to", getReportToHeader());
responseHeaders.add("nel", getNELHeader());
encoder().writeHeaders(ctx, streamId, responseHeaders, 0, true, ctx.newPromise());
ctx.flush();
}
@Override
int onDataRead(ChannelHandlerContext ctx, int streamId, ByteBuf data, int padding,
boolean endOfStream) {
int processed = data.readableBytes() + padding;
return processed;
}
private String getReportToHeader() {
return String.format("{\"group\": \"nel\", \"max_age\": 86400, "
+ "\"endpoints\": [{\"url\": \"%s%s\"}]}",
mServerUrl, REPORTING_COLLECTOR_PATH);
}
private String getNELHeader() {
return "{\"report_to\": \"nel\", \"max_age\": 86400, \"success_fraction\": 1.0}";
}
}
private static Http2Headers createDefaultResponseHeaders() { private static Http2Headers createDefaultResponseHeaders() {
return new DefaultHttp2Headers().status(OK.codeAsText()); return new DefaultHttp2Headers().status(OK.codeAsText());
} }
...@@ -212,8 +305,11 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht ...@@ -212,8 +305,11 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht
} }
private Http2TestHandler(Http2ConnectionDecoder decoder, Http2ConnectionEncoder encoder, private Http2TestHandler(Http2ConnectionDecoder decoder, Http2ConnectionEncoder encoder,
Http2Settings initialSettings) { Http2Settings initialSettings, ReportingCollector reportingCollector,
String serverUrl) {
super(decoder, encoder, initialSettings); super(decoder, encoder, initialSettings);
mReportingCollector = reportingCollector;
mServerUrl = serverUrl;
} }
@Override @Override
...@@ -251,6 +347,10 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht ...@@ -251,6 +347,10 @@ public final class Http2TestHandler extends Http2ConnectionHandler implements Ht
responder = new EchoMethodResponder(); responder = new EchoMethodResponder();
} else if (path.startsWith(SERVE_SIMPLE_BROTLI_RESPONSE)) { } else if (path.startsWith(SERVE_SIMPLE_BROTLI_RESPONSE)) {
responder = new ServeSimpleBrotliResponder(); responder = new ServeSimpleBrotliResponder();
} else if (path.startsWith(REPORTING_COLLECTOR_PATH)) {
responder = new ReportingCollectorResponder();
} else if (path.startsWith(SUCCESS_WITH_NEL_HEADERS_PATH)) {
responder = new SuccessWithNELHeadersResponder();
} else { } else {
responder = new RequestResponder(); responder = new RequestResponder();
} }
......
...@@ -45,10 +45,13 @@ public final class Http2TestServer { ...@@ -45,10 +45,13 @@ public final class Http2TestServer {
// Server port. // Server port.
private static final int PORT = 8443; private static final int PORT = 8443;
private static ReportingCollector sReportingCollector;
public static boolean shutdownHttp2TestServer() throws Exception { public static boolean shutdownHttp2TestServer() throws Exception {
if (sServerChannel != null) { if (sServerChannel != null) {
sServerChannel.close().sync(); sServerChannel.close().sync();
sServerChannel = null; sServerChannel = null;
sReportingCollector = null;
return true; return true;
} }
return false; return false;
...@@ -66,6 +69,10 @@ public final class Http2TestServer { ...@@ -66,6 +69,10 @@ public final class Http2TestServer {
return "https://" + HOST + ":" + PORT; return "https://" + HOST + ":" + PORT;
} }
public static ReportingCollector getReportingCollector() {
return sReportingCollector;
}
public static String getEchoAllHeadersUrl() { public static String getEchoAllHeadersUrl() {
return getServerUrl() + Http2TestHandler.ECHO_ALL_HEADERS_PATH; return getServerUrl() + Http2TestHandler.ECHO_ALL_HEADERS_PATH;
} }
...@@ -99,8 +106,23 @@ public final class Http2TestServer { ...@@ -99,8 +106,23 @@ public final class Http2TestServer {
return getServerUrl() + Http2TestHandler.SERVE_SIMPLE_BROTLI_RESPONSE; return getServerUrl() + Http2TestHandler.SERVE_SIMPLE_BROTLI_RESPONSE;
} }
/**
* @return url of the reporting collector
*/
public static String getReportingCollectorUrl() {
return getServerUrl() + Http2TestHandler.REPORTING_COLLECTOR_PATH;
}
/**
* @return url of a resource that includes Reporting and NEL policy headers in its response
*/
public static String getSuccessWithNELHeadersUrl() {
return getServerUrl() + Http2TestHandler.SUCCESS_WITH_NEL_HEADERS_PATH;
}
public static boolean startHttp2TestServer( public static boolean startHttp2TestServer(
Context context, String certFileName, String keyFileName) throws Exception { Context context, String certFileName, String keyFileName) throws Exception {
sReportingCollector = new ReportingCollector();
Http2TestServerRunnable http2TestServerRunnable = Http2TestServerRunnable http2TestServerRunnable =
new Http2TestServerRunnable(new File(CertTestUtil.CERTS_DIRECTORY + certFileName), new Http2TestServerRunnable(new File(CertTestUtil.CERTS_DIRECTORY + certFileName),
new File(CertTestUtil.CERTS_DIRECTORY + keyFileName)); new File(CertTestUtil.CERTS_DIRECTORY + keyFileName));
...@@ -185,7 +207,10 @@ public final class Http2TestServer { ...@@ -185,7 +207,10 @@ public final class Http2TestServer {
protected void configurePipeline(ChannelHandlerContext ctx, String protocol) protected void configurePipeline(ChannelHandlerContext ctx, String protocol)
throws Exception { throws Exception {
if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { if (ApplicationProtocolNames.HTTP_2.equals(protocol)) {
ctx.pipeline().addLast(new Http2TestHandler.Builder().build()); ctx.pipeline().addLast(new Http2TestHandler.Builder()
.setReportingCollector(sReportingCollector)
.setServerUrl(getServerUrl())
.build());
return; return;
} }
......
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.net;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/**
* Stores Reporting API reports received by a test collector, providing helper methods for checking
* whether expected reports were actually received.
*/
class ReportingCollector {
private ArrayList<JSONObject> mReceivedReports = new ArrayList<JSONObject>();
private Semaphore mReceivedReportsSemaphore = new Semaphore(0);
/**
* Stores a batch of uploaded reports.
* @param payload the POST payload from the upload
* @return whether the payload was parsed successfully
*/
public boolean addReports(String payload) {
try {
JSONArray reports = new JSONArray(payload);
int elementCount = 0;
synchronized (mReceivedReports) {
for (int i = 0; i < reports.length(); i++) {
JSONObject element = reports.optJSONObject(i);
if (element != null) {
mReceivedReports.add(element);
elementCount++;
}
}
}
mReceivedReportsSemaphore.release(elementCount);
return true;
} catch (JSONException e) {
return false;
}
}
/**
* Checks whether a report with the given payload exists or not.
*/
public boolean containsReport(String expected) {
try {
JSONObject expectedReport = new JSONObject(expected);
synchronized (mReceivedReports) {
for (JSONObject received : mReceivedReports) {
if (isJSONObjectSubset(expectedReport, received)) {
return true;
}
}
}
return false;
} catch (JSONException e) {
return false;
}
}
/**
* Waits until the requested number of reports have been received, with a 5-second timeout.
*/
public void waitForReports(int reportCount) {
final int timeoutSeconds = 5;
try {
mReceivedReportsSemaphore.tryAcquire(reportCount, timeoutSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
}
}
/**
* Checks whether one {@link JSONObject} is a subset of another. Any fields that appear in
* {@code lhs} must also appear in {@code rhs}, with the same value. There can be extra fields
* in {@code rhs}; if so, they are ignored.
*/
private boolean isJSONObjectSubset(JSONObject lhs, JSONObject rhs) {
Iterator<String> keys = lhs.keys();
while (keys.hasNext()) {
String key = keys.next();
Object lhsElement = lhs.opt(key);
Object rhsElement = rhs.opt(key);
if (rhsElement == null) {
// lhs has an element that doesn't appear in rhs
return false;
}
if (lhsElement instanceof JSONObject) {
if (!(rhsElement instanceof JSONObject)) {
return false;
}
return isJSONObjectSubset((JSONObject) lhsElement, (JSONObject) rhsElement);
}
if (!lhsElement.equals(rhsElement)) {
return false;
}
}
return true;
}
};
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