blob: 7d9c5fd1d519693cde4e60597785c3e7283c0b4b [file] [log] [blame]
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.webkit.cts;
import android.content.Context;
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.net.Uri;
import android.os.Environment;
import android.util.Base64;
import android.util.Log;
import android.util.Pair;
import android.webkit.MimeTypeMap;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.HttpVersion;
import org.apache.http.NameValuePair;
import org.apache.http.RequestLine;
import org.apache.http.StatusLine;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.FileEntity;
import org.apache.http.entity.InputStreamEntity;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.DefaultHttpServerConnection;
import org.apache.http.impl.cookie.DateUtils;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.CoreProtocolPNames;
import org.apache.http.params.HttpParams;
import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.net.URLEncoder;
import java.security.Key;
import java.security.KeyFactory;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Vector;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSession;
import javax.net.ssl.X509TrustManager;
/**
* Simple http test server for testing webkit client functionality.
*/
public class CtsTestServer {
private static final String TAG = "CtsTestServer";
public static final String FAVICON_PATH = "/favicon.ico";
public static final String USERAGENT_PATH = "/useragent.html";
public static final String TEST_DOWNLOAD_PATH = "/download.html";
public static final String CACHEABLE_TEST_DOWNLOAD_PATH =
"/cacheable-download.html";
private static final String DOWNLOAD_ID_PARAMETER = "downloadId";
private static final String NUM_BYTES_PARAMETER = "numBytes";
private static final String ASSET_PREFIX = "/assets/";
private static final String RAW_PREFIX = "raw/";
private static final String FAVICON_ASSET_PATH = ASSET_PREFIX + "webkit/favicon.png";
private static final String APPCACHE_PATH = "/appcache.html";
private static final String APPCACHE_MANIFEST_PATH = "/appcache.manifest";
private static final String REDIRECT_PREFIX = "/redirect";
private static final String QUERY_REDIRECT_PATH = "/alt_redirect";
private static final String ECHO_HEADERS_PREFIX = "/echo_headers";
private static final String DELAY_PREFIX = "/delayed";
private static final String BINARY_PREFIX = "/binary";
private static final String SET_COOKIE_PREFIX = "/setcookie";
private static final String COOKIE_PREFIX = "/cookie";
private static final String LINKED_SCRIPT_PREFIX = "/linkedscriptprefix";
private static final String AUTH_PREFIX = "/auth";
public static final String NOLENGTH_POSTFIX = "nolength";
private static final int DELAY_MILLIS = 2000;
public static final String ECHOED_RESPONSE_HEADER_PREFIX = "x-request-header-";
public static final String AUTH_REALM = "Android CTS";
public static final String AUTH_USER = "cts";
public static final String AUTH_PASS = "secret";
// base64 encoded credentials "cts:secret" used for basic authentication
public static final String AUTH_CREDENTIALS = "Basic Y3RzOnNlY3JldA==";
public static final String MESSAGE_401 = "401 unauthorized";
public static final String MESSAGE_403 = "403 forbidden";
public static final String MESSAGE_404 = "404 not found";
private static Hashtable<Integer, String> sReasons;
private ServerThread mServerThread;
private String mServerUri;
private AssetManager mAssets;
private Context mContext;
private Resources mResources;
private @SslMode int mSsl;
private MimeTypeMap mMap;
private Vector<String> mQueries;
private ArrayList<HttpEntity> mRequestEntities;
private final Map<String, Integer> mRequestCountMap = new HashMap<String, Integer>();
private final Map<String, HttpRequest> mLastRequestMap = new HashMap<String, HttpRequest>();
private final Map<String, HttpResponse> mResponseMap = new HashMap<String, HttpResponse>();
private long mDocValidity;
private long mDocAge;
private X509TrustManager mTrustManager;
/**
* Create and start a local HTTP server instance.
* @param context The application context to use for fetching assets.
* @throws IOException
*/
public CtsTestServer(Context context) throws Exception {
this(context, SslMode.INSECURE);
}
public static String getReasonString(int status) {
if (sReasons == null) {
sReasons = new Hashtable<Integer, String>();
sReasons.put(HttpStatus.SC_UNAUTHORIZED, "Unauthorized");
sReasons.put(HttpStatus.SC_NOT_FOUND, "Not Found");
sReasons.put(HttpStatus.SC_FORBIDDEN, "Forbidden");
sReasons.put(HttpStatus.SC_MOVED_TEMPORARILY, "Moved Temporarily");
}
return sReasons.get(status);
}
/**
* Create and start a local HTTP server instance.
* @param context The application context to use for fetching assets.
* @param ssl True if the server should be using secure sockets.
* @throws Exception
*/
public CtsTestServer(Context context, boolean ssl) throws Exception {
this(context, ssl ? SslMode.NO_CLIENT_AUTH : SslMode.INSECURE);
}
/**
* Create and start a local HTTP server instance.
* @param context The application context to use for fetching assets.
* @param sslMode Whether to use SSL, and if so, what client auth (if any) to use.
* @throws Exception
*/
public CtsTestServer(Context context, @SslMode int sslMode) throws Exception {
this(context, sslMode, 0, 0);
}
/**
* Create and start a local HTTP server instance.
* @param context The application context to use for fetching assets.
* @param sslMode Whether to use SSL, and if so, what client auth (if any) to use.
* @param trustManager the trustManager
* @throws Exception
*/
public CtsTestServer(Context context, @SslMode int sslMode, X509TrustManager trustManager)
throws Exception {
this(context, sslMode, trustManager, 0, 0);
}
/**
* Create and start a local HTTP server instance.
* @param context The application context to use for fetching assets.
* @param sslMode Whether to use SSL, and if so, what client auth (if any) to use.
* @param keyResId Raw resource ID of the server private key to use.
* @param certResId Raw resource ID of the server certificate to use.
* @throws Exception
*/
public CtsTestServer(Context context, @SslMode int sslMode, int keyResId, int certResId)
throws Exception {
this(context, sslMode, new CtsTrustManager(), keyResId, certResId);
}
/**
* Create and start a local HTTP server instance.
* @param context The application context to use for fetching assets.
* @param sslMode Whether to use SSL, and if so, what client auth (if any) to use.
* @param trustManager the trustManager
* @param keyResId Raw resource ID of the server private key to use.
* @param certResId Raw resource ID of the server certificate to use.
* @throws Exception
*/
public CtsTestServer(Context context, @SslMode int sslMode, X509TrustManager trustManager,
int keyResId, int certResId) throws Exception {
mContext = context;
mAssets = mContext.getAssets();
mResources = mContext.getResources();
mSsl = sslMode;
mRequestEntities = new ArrayList<HttpEntity>();
mMap = MimeTypeMap.getSingleton();
mQueries = new Vector<String>();
mTrustManager = trustManager;
if (keyResId == 0 && certResId == 0) {
mServerThread = new ServerThread(this, mSsl, null, null);
} else {
mServerThread = new ServerThread(this, mSsl, mResources.openRawResource(keyResId),
mResources.openRawResource(certResId));
}
if (mSsl == SslMode.INSECURE) {
mServerUri = "http:";
} else {
mServerUri = "https:";
}
mServerUri += "//localhost:" + mServerThread.mSocket.getLocalPort();
mServerThread.start();
}
/**
* Terminate the http server.
*/
public void shutdown() {
mServerThread.shutDownOnClientThread();
try {
// Block until the server thread is done shutting down.
mServerThread.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
/**
* {@link X509TrustManager} that trusts everybody. This is used so that
* the client calling {@link CtsTestServer#shutdown()} can issue a request
* for shutdown by blindly trusting the {@link CtsTestServer}'s
* credentials.
*/
static class CtsTrustManager implements X509TrustManager {
public void checkClientTrusted(X509Certificate[] chain, String authType) {
// Trust the CtSTestServer's client...
}
public void checkServerTrusted(X509Certificate[] chain, String authType) {
// Trust the CtSTestServer...
}
public X509Certificate[] getAcceptedIssuers() {
return null;
}
}
/**
* @return a trust manager array of size 1.
*/
private X509TrustManager[] getTrustManagers() {
return new X509TrustManager[] { mTrustManager };
}
/**
* {@link HostnameVerifier} that verifies everybody. This permits
* the client to trust the web server and call
* {@link CtsTestServer#shutdown()}.
*/
private static class CtsHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session) {
return true;
}
}
/**
* Sets a response to be returned when a particular request path is passed in (with the option
* to specify additional headers).
*
* @param requestPath The path to respond to.
* @param responseString The response body that will be returned.
* @param responseHeaders Any additional headers that should be returned along with the response
* (null is acceptable).
* @return The full URL including the path that should be requested to get the expected
* response.
*/
public synchronized String setResponse(
String requestPath, String responseString, List<Pair<String, String>> responseHeaders) {
HttpResponse response = createResponse(HttpStatus.SC_OK);
response.setEntity(createEntity(responseString));
if (responseHeaders != null) {
for (Pair<String, String> headerPair : responseHeaders) {
response.setHeader(headerPair.first, headerPair.second);
}
}
mResponseMap.put(requestPath, response);
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(requestPath);
return sb.toString();
}
/**
* Return the URI that points to the server root.
*/
public String getBaseUri() {
return mServerUri;
}
/**
* Return the absolute URL that refers to a path.
*/
public String getAbsoluteUrl(String path) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(path);
return sb.toString();
}
/**
* Return the absolute URL that refers to the given asset.
* @param path The path of the asset. See {@link AssetManager#open(String)}
*/
public String getAssetUrl(String path) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(ASSET_PREFIX);
sb.append(path);
return sb.toString();
}
/**
* Return an artificially delayed absolute URL that refers to the given asset. This can be
* used to emulate a slow HTTP server or connection.
* @param path The path of the asset. See {@link AssetManager#open(String)}
*/
public String getDelayedAssetUrl(String path) {
return getDelayedAssetUrl(path, DELAY_MILLIS);
}
/**
* Return an artificially delayed absolute URL that refers to the given asset. This can be
* used to emulate a slow HTTP server or connection.
* @param path The path of the asset. See {@link AssetManager#open(String)}
* @param delayMs The number of milliseconds to delay the request
*/
public String getDelayedAssetUrl(String path, int delayMs) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(DELAY_PREFIX);
sb.append("/");
sb.append(delayMs);
sb.append(ASSET_PREFIX);
sb.append(path);
return sb.toString();
}
/**
* Return an absolute URL that refers to the given asset and is protected by
* HTTP authentication.
* @param path The path of the asset. See {@link AssetManager#open(String)}
*/
public String getAuthAssetUrl(String path) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(AUTH_PREFIX);
sb.append(ASSET_PREFIX);
sb.append(path);
return sb.toString();
}
/**
* Return an absolute URL that refers to an endpoint which will send received headers back to
* the sender with a prefix.
*/
public String getEchoHeadersUrl() {
return getBaseUri() + ECHO_HEADERS_PREFIX;
}
/**
* Return an absolute URL that indirectly refers to the given asset.
* When a client fetches this URL, the server will respond with a temporary redirect (302)
* referring to the absolute URL of the given asset.
* @param path The path of the asset. See {@link AssetManager#open(String)}
*/
public String getRedirectingAssetUrl(String path) {
return getRedirectingAssetUrl(path, 1);
}
/**
* Return an absolute URL that indirectly refers to the given asset.
* When a client fetches this URL, the server will respond with a temporary redirect (302)
* referring to the absolute URL of the given asset.
* @param path The path of the asset. See {@link AssetManager#open(String)}
* @param numRedirects The number of redirects required to reach the given asset.
*/
public String getRedirectingAssetUrl(String path, int numRedirects) {
StringBuilder sb = new StringBuilder(getBaseUri());
for (int i = 0; i < numRedirects; i++) {
sb.append(REDIRECT_PREFIX);
}
sb.append(ASSET_PREFIX);
sb.append(path);
return sb.toString();
}
/**
* Return an absolute URL that indirectly refers to the given asset, without having
* the destination path be part of the redirecting path.
* When a client fetches this URL, the server will respond with a temporary redirect (302)
* referring to the absolute URL of the given asset.
* @param path The path of the asset. See {@link AssetManager#open(String)}
*/
public String getQueryRedirectingAssetUrl(String path) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(QUERY_REDIRECT_PATH);
sb.append("?dest=");
try {
sb.append(URLEncoder.encode(getAssetUrl(path), "UTF-8"));
} catch (UnsupportedEncodingException e) {
}
return sb.toString();
}
/**
* getSetCookieUrl returns a URL that attempts to set the cookie
* "key=value" when fetched.
* @param path a suffix to disambiguate multiple Cookie URLs.
* @param key the key of the cookie.
* @return the url for a page that attempts to set the cookie.
*/
public String getSetCookieUrl(String path, String key, String value) {
return getSetCookieUrl(path, key, value, null);
}
/**
* getSetCookieUrl returns a URL that attempts to set the cookie
* "key=value" with the given list of attributes when fetched.
* @param path a suffix to disambiguate multiple Cookie URLs.
* @param key the key of the cookie
* @param attributes the attributes to set
* @return the url for a page that attempts to set the cookie.
*/
public String getSetCookieUrl(String path, String key, String value, String attributes) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(SET_COOKIE_PREFIX);
sb.append(path);
sb.append("?key=");
sb.append(key);
sb.append("&value=");
sb.append(value);
if (attributes != null) {
sb.append("&attributes=");
sb.append(attributes);
}
return sb.toString();
}
/**
* getLinkedScriptUrl returns a URL for a page with a script tag where
* src equals the URL passed in.
* @param path a suffix to disambiguate mulitple Linked Script URLs.
* @param url the src of the script tag.
* @return the url for the page with the script link in.
*/
public String getLinkedScriptUrl(String path, String url) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(LINKED_SCRIPT_PREFIX);
sb.append(path);
sb.append("?url=");
try {
sb.append(URLEncoder.encode(url, "UTF-8"));
} catch (UnsupportedEncodingException e) {
}
return sb.toString();
}
public String getBinaryUrl(String mimeType, int contentLength) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(BINARY_PREFIX);
sb.append("?type=");
sb.append(mimeType);
sb.append("&length=");
sb.append(contentLength);
return sb.toString();
}
public String getCookieUrl(String path) {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(COOKIE_PREFIX);
sb.append("/");
sb.append(path);
return sb.toString();
}
public String getUserAgentUrl() {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(USERAGENT_PATH);
return sb.toString();
}
public String getAppCacheUrl() {
StringBuilder sb = new StringBuilder(getBaseUri());
sb.append(APPCACHE_PATH);
return sb.toString();
}
/**
* @param downloadId used to differentiate the files created for each test
* @param numBytes of the content that the CTS server should send back
* @return url to get the file from
*/
public String getTestDownloadUrl(String downloadId, int numBytes) {
return Uri.parse(getBaseUri())
.buildUpon()
.path(TEST_DOWNLOAD_PATH)
.appendQueryParameter(DOWNLOAD_ID_PARAMETER, downloadId)
.appendQueryParameter(NUM_BYTES_PARAMETER, Integer.toString(numBytes))
.build()
.toString();
}
/**
* @param downloadId used to differentiate the files created for each test
* @param numBytes of the content that the CTS server should send back
* @return url to get the file from
*/
public String getCacheableTestDownloadUrl(String downloadId, int numBytes) {
return Uri.parse(getBaseUri())
.buildUpon()
.path(CACHEABLE_TEST_DOWNLOAD_PATH)
.appendQueryParameter(DOWNLOAD_ID_PARAMETER, downloadId)
.appendQueryParameter(NUM_BYTES_PARAMETER, Integer.toString(numBytes))
.build()
.toString();
}
/**
* Returns true if the resource identified by url has been requested since
* the server was started or the last call to resetRequestState().
*
* @param url The relative url to check whether it has been requested.
*/
public synchronized boolean wasResourceRequested(String url) {
Iterator<String> it = mQueries.iterator();
while (it.hasNext()) {
String request = it.next();
if (request.endsWith(url)) {
return true;
}
}
return false;
}
/**
* Returns all received request entities since the last reset.
*/
public synchronized ArrayList<HttpEntity> getRequestEntities() {
return mRequestEntities;
}
/**
* Returns the total number of requests made.
*/
public synchronized int getRequestCount() {
return mQueries.size();
}
/**
* Returns the number of requests made for a path.
*/
public synchronized int getRequestCount(String requestPath) {
Integer count = mRequestCountMap.get(requestPath);
if (count == null) throw new IllegalArgumentException("Path not set: " + requestPath);
return count.intValue();
}
/**
* Set the validity of any future responses in milliseconds. If this is set to a non-zero
* value, the server will include a "Expires" header.
* @param timeMillis The time, in milliseconds, for which any future response will be valid.
*/
public synchronized void setDocumentValidity(long timeMillis) {
mDocValidity = timeMillis;
}
/**
* Set the age of documents served. If this is set to a non-zero value, the server will include
* a "Last-Modified" header calculated from the value.
* @param timeMillis The age, in milliseconds, of any document served in the future.
*/
public synchronized void setDocumentAge(long timeMillis) {
mDocAge = timeMillis;
}
/**
* Resets the saved requests and request counts.
*/
public synchronized void resetRequestState() {
mQueries.clear();
mRequestEntities = new ArrayList<HttpEntity>();
}
/**
* Returns the last HttpRequest at this path.
* Can return null if it is never requested.
*
* Use this method if the request you're looking for was
* for an asset.
*/
public HttpRequest getLastAssetRequest(String requestPath) {
String relativeUrl = getRelativeAssetUrl(requestPath);
return mLastRequestMap.get(relativeUrl);
}
/**
* Returns the last HttpRequest at this path.
* Can return null if it is never requested.
*/
public HttpRequest getLastRequest(String requestPath) {
return mLastRequestMap.get(requestPath);
}
/**
* Hook for adding stuffs for HTTP POST. Default implementation does nothing.
* @return null to use the default response mechanism of sending the requested uri as it is.
* Otherwise, the whole response should be handled inside onPost.
*/
protected HttpResponse onPost(HttpRequest request) throws Exception {
return null;
}
/**
* Return the relative URL that refers to the given asset.
* @param path The path of the asset. See {@link AssetManager#open(String)}
*/
private String getRelativeAssetUrl(String path) {
StringBuilder sb = new StringBuilder(ASSET_PREFIX);
sb.append(path);
return sb.toString();
}
/**
* Generate a response to the given request.
* @throws InterruptedException
* @throws IOException
*/
private HttpResponse getResponse(HttpRequest request) throws Exception {
RequestLine requestLine = request.getRequestLine();
HttpResponse response = null;
String uriString = requestLine.getUri();
Log.i(TAG, requestLine.getMethod() + ": " + uriString);
synchronized (this) {
mQueries.add(uriString);
int requestCount = 0;
if (mRequestCountMap.containsKey(uriString)) {
requestCount = mRequestCountMap.get(uriString);
}
mRequestCountMap.put(uriString, requestCount + 1);
mLastRequestMap.put(uriString, request);
if (request instanceof HttpEntityEnclosingRequest) {
mRequestEntities.add(((HttpEntityEnclosingRequest)request).getEntity());
}
}
if (requestLine.getMethod().equals("POST")) {
HttpResponse responseOnPost = onPost(request);
if (responseOnPost != null) {
return responseOnPost;
}
}
URI uri = URI.create(uriString);
String path = uri.getPath();
String query = uri.getQuery();
if (path.startsWith(ECHO_HEADERS_PREFIX)) {
response = createResponse(HttpStatus.SC_OK);
for (Header header : request.getAllHeaders()) {
response.addHeader(
ECHOED_RESPONSE_HEADER_PREFIX + header.getName(), header.getValue());
}
}
if (path.equals(FAVICON_PATH)) {
path = FAVICON_ASSET_PATH;
}
if (path.startsWith(DELAY_PREFIX)) {
String delayPath = path.substring(DELAY_PREFIX.length() + 1);
String delay = delayPath.substring(0, delayPath.indexOf('/'));
path = delayPath.substring(delay.length());
try {
Thread.sleep(Integer.valueOf(delay));
} catch (InterruptedException ignored) {
// ignore
}
}
if (path.startsWith(AUTH_PREFIX)) {
// authentication required
Header[] auth = request.getHeaders("Authorization");
if ((auth.length > 0 && auth[0].getValue().equals(AUTH_CREDENTIALS))
// This is a hack to make sure that loads to this url's will always
// ask for authentication. This is what the test expects.
&& !path.endsWith("embedded_image.html")) {
// fall through and serve content
path = path.substring(AUTH_PREFIX.length());
} else {
// request authorization
response = createResponse(HttpStatus.SC_UNAUTHORIZED);
response.addHeader("WWW-Authenticate", "Basic realm=\"" + AUTH_REALM + "\"");
}
}
if (path.startsWith(BINARY_PREFIX)) {
List <NameValuePair> args = URLEncodedUtils.parse(uri, "UTF-8");
int length = 0;
String mimeType = null;
try {
for (NameValuePair pair : args) {
String name = pair.getName();
if (name.equals("type")) {
mimeType = pair.getValue();
} else if (name.equals("length")) {
length = Integer.parseInt(pair.getValue());
}
}
if (length > 0 && mimeType != null) {
ByteArrayEntity entity = new ByteArrayEntity(new byte[length]);
entity.setContentType(mimeType);
response = createResponse(HttpStatus.SC_OK);
response.setEntity(entity);
response.addHeader("Content-Disposition", "attachment; filename=test.bin");
response.addHeader("Content-Type", mimeType);
response.addHeader("Content-Length", "" + length);
} else {
// fall through, return 404 at the end
}
} catch (Exception e) {
// fall through, return 404 at the end
Log.w(TAG, e);
}
} else if (path.startsWith(ASSET_PREFIX)) {
path = path.substring(ASSET_PREFIX.length());
// request for an asset file
try {
InputStream in;
if (path.startsWith(RAW_PREFIX)) {
String resourceName = path.substring(RAW_PREFIX.length());
int id = mResources.getIdentifier(resourceName, "raw", mContext.getPackageName());
if (id == 0) {
Log.w(TAG, "Can't find raw resource " + resourceName);
throw new IOException();
}
in = mResources.openRawResource(id);
} else if (path.startsWith(
Environment.getExternalStorageDirectory().getAbsolutePath())) {
in = new FileInputStream(path);
} else {
in = mAssets.open(path);
}
response = createResponse(HttpStatus.SC_OK);
InputStreamEntity entity = new InputStreamEntity(in, in.available());
String mimeType =
mMap.getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(path));
if (mimeType == null) {
mimeType = "text/html";
}
entity.setContentType(mimeType);
response.setEntity(entity);
if (query == null || !query.contains(NOLENGTH_POSTFIX)) {
response.setHeader("Content-Length", "" + entity.getContentLength());
}
} catch (IOException | NullPointerException e) {
response = null;
// fall through, return 404 at the end
}
} else if (path.startsWith(REDIRECT_PREFIX)) {
response = createResponse(HttpStatus.SC_MOVED_TEMPORARILY);
String location = getBaseUri() + path.substring(REDIRECT_PREFIX.length());
Log.i(TAG, "Redirecting to: " + location);
response.addHeader("Location", location);
} else if (path.equals(QUERY_REDIRECT_PATH)) {
Uri androidUri = Uri.parse(uriString);
String location = androidUri.getQueryParameter("dest");
int statusCode = HttpStatus.SC_MOVED_TEMPORARILY;
String statusCodeParam = androidUri.getQueryParameter("statusCode");
if (statusCodeParam != null) {
try {
int parsedStatusCode = Integer.parseInt(statusCodeParam);
if (300 <= parsedStatusCode && parsedStatusCode < 400) {
statusCode = parsedStatusCode;
}
} catch (NumberFormatException ignored) { }
}
if (location != null) {
Log.i(TAG, "Redirecting to: " + location);
response = createResponse(statusCode);
response.addHeader("Location", location);
}
} else if (path.startsWith(COOKIE_PREFIX)) {
/*
* Return a page with a title containing a list of all incoming cookies,
* separated by '|' characters. If a numeric 'count' value is passed in a cookie,
* return a cookie with the value incremented by 1. Otherwise, return a cookie
* setting 'count' to 0.
*/
response = createResponse(HttpStatus.SC_OK);
Header[] cookies = request.getHeaders("Cookie");
Pattern p = Pattern.compile("count=(\\d+)");
StringBuilder cookieString = new StringBuilder(100);
cookieString.append(cookies.length);
int count = 0;
for (Header cookie : cookies) {
cookieString.append("|");
String value = cookie.getValue();
cookieString.append(value);
Matcher m = p.matcher(value);
if (m.find()) {
count = Integer.parseInt(m.group(1)) + 1;
}
}
response.addHeader("Set-Cookie", "count=" + count + "; path=" + COOKIE_PREFIX);
response.setEntity(createPage(cookieString.toString(), cookieString.toString()));
} else if (path.startsWith(SET_COOKIE_PREFIX)) {
response = createResponse(HttpStatus.SC_OK);
Uri parsedUri = Uri.parse(uriString);
String key = parsedUri.getQueryParameter("key");
String value = parsedUri.getQueryParameter("value");
String attributes = parsedUri.getQueryParameter("attributes");
String cookie = key + "=" + value;
if (attributes != null) {
cookie = cookie + "; " + attributes;
}
response.addHeader("Set-Cookie", cookie);
response.setEntity(createPage(cookie, cookie));
} else if (path.startsWith(LINKED_SCRIPT_PREFIX)) {
response = createResponse(HttpStatus.SC_OK);
String src = Uri.parse(uriString).getQueryParameter("url");
String scriptTag = "<script src=\"" + src + "\"></script>";
response.setEntity(createPage("LinkedScript", scriptTag));
} else if (path.equals(USERAGENT_PATH)) {
response = createResponse(HttpStatus.SC_OK);
Header agentHeader = request.getFirstHeader("User-Agent");
String agent = "";
if (agentHeader != null) {
agent = agentHeader.getValue();
}
response.setEntity(createPage(agent, agent));
} else if (path.equals(TEST_DOWNLOAD_PATH)) {
response = createTestDownloadResponse(mContext, Uri.parse(uriString));
} else if (path.equals(CACHEABLE_TEST_DOWNLOAD_PATH)) {
response = createCacheableTestDownloadResponse(mContext, Uri.parse(uriString));
} else if (path.equals(APPCACHE_PATH)) {
response = createResponse(HttpStatus.SC_OK);
response.setEntity(createEntity("<!DOCTYPE HTML>" +
"<html manifest=\"appcache.manifest\">" +
" <head>" +
" <title>Waiting</title>" +
" <script>" +
" function updateTitle(x) { document.title = x; }" +
" window.applicationCache.onnoupdate = " +
" function() { updateTitle(\"onnoupdate Callback\"); };" +
" window.applicationCache.oncached = " +
" function() { updateTitle(\"oncached Callback\"); };" +
" window.applicationCache.onupdateready = " +
" function() { updateTitle(\"onupdateready Callback\"); };" +
" window.applicationCache.onobsolete = " +
" function() { updateTitle(\"onobsolete Callback\"); };" +
" window.applicationCache.onerror = " +
" function() { updateTitle(\"onerror Callback\"); };" +
" </script>" +
" </head>" +
" <body onload=\"updateTitle('Loaded');\">AppCache test</body>" +
"</html>"));
} else if (path.equals(APPCACHE_MANIFEST_PATH)) {
response = createResponse(HttpStatus.SC_OK);
try {
StringEntity entity = new StringEntity("CACHE MANIFEST");
// This entity property is not used when constructing the response, (See
// AbstractMessageWriter.write(), which is called by
// AbstractHttpServerConnection.sendResponseHeader()) so we have to set this header
// manually.
// TODO: Should we do this for all responses from this server?
entity.setContentType("text/cache-manifest");
response.setEntity(entity);
response.setHeader("Content-Type", "text/cache-manifest");
} catch (UnsupportedEncodingException e) {
Log.w(TAG, "Unexpected UnsupportedEncodingException");
}
}
// If a response was set, it should override whatever was generated
if (mResponseMap.containsKey(path)) {
response = mResponseMap.get(path);
}
if (response == null) {
response = createResponse(HttpStatus.SC_NOT_FOUND);
}
StatusLine sl = response.getStatusLine();
Log.i(TAG, sl.getStatusCode() + "(" + sl.getReasonPhrase() + ")");
setDateHeaders(response);
return response;
}
private void setDateHeaders(HttpResponse response) {
long time = System.currentTimeMillis();
synchronized (this) {
if (mDocValidity != 0) {
String expires = DateUtils.formatDate(new Date(time + mDocValidity),
DateUtils.PATTERN_RFC1123);
response.addHeader("Expires", expires);
}
if (mDocAge != 0) {
String modified = DateUtils.formatDate(new Date(time - mDocAge),
DateUtils.PATTERN_RFC1123);
response.addHeader("Last-Modified", modified);
}
}
response.addHeader("Date", DateUtils.formatDate(new Date(), DateUtils.PATTERN_RFC1123));
}
/**
* Create an empty response with the given status.
*/
private static HttpResponse createResponse(int status) {
HttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_0, status, null);
// Fill in error reason. Avoid use of the ReasonPhraseCatalog, which is Locale-dependent.
String reason = getReasonString(status);
if (reason != null) {
response.setEntity(createPage(reason, reason));
}
return response;
}
/**
* Create a string entity for the given content.
*/
private static StringEntity createEntity(String content) {
try {
StringEntity entity = new StringEntity(content);
entity.setContentType("text/html");
return entity;
} catch (UnsupportedEncodingException e) {
Log.w(TAG, e);
}
return null;
}
/**
* Create a string entity for a bare bones html page with provided title and body.
*/
private static StringEntity createPage(String title, String bodyContent) {
return createEntity("<html><head><title>" + title + "</title></head>" +
"<body>" + bodyContent + "</body></html>");
}
private static HttpResponse createTestDownloadResponse(Context context, Uri uri)
throws IOException {
String downloadId = uri.getQueryParameter(DOWNLOAD_ID_PARAMETER);
int numBytes = uri.getQueryParameter(NUM_BYTES_PARAMETER) != null
? Integer.parseInt(uri.getQueryParameter(NUM_BYTES_PARAMETER))
: 0;
HttpResponse response = createResponse(HttpStatus.SC_OK);
response.setHeader("Content-Length", Integer.toString(numBytes));
response.setEntity(createFileEntity(context, downloadId, numBytes));
return response;
}
private static HttpResponse createCacheableTestDownloadResponse(Context context, Uri uri)
throws IOException {
HttpResponse response = createTestDownloadResponse(context, uri);
response.setHeader("Cache-Control", "max-age=300");
return response;
}
private static FileEntity createFileEntity(Context context, String downloadId, int numBytes)
throws IOException {
String storageState = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equalsIgnoreCase(storageState)) {
File storageDir = context.getExternalFilesDir(null);
File file = new File(storageDir, downloadId + ".bin");
BufferedOutputStream stream = new BufferedOutputStream(new FileOutputStream(file));
byte data[] = new byte[1024];
for (int i = 0; i < data.length; i++) {
data[i] = 1;
}
try {
for (int i = 0; i < numBytes / data.length; i++) {
stream.write(data);
}
stream.write(data, 0, numBytes % data.length);
stream.flush();
} finally {
stream.close();
}
return new FileEntity(file, "application/octet-stream");
} else {
throw new IllegalStateException("External storage must be mounted for this test!");
}
}
protected DefaultHttpServerConnection createHttpServerConnection() {
return new DefaultHttpServerConnection();
}
private static class ServerThread extends Thread {
private CtsTestServer mServer;
private ServerSocket mSocket;
private @SslMode int mSsl;
private boolean mWillShutDown = false;
private SSLContext mSslContext;
private ExecutorService mExecutorService = Executors.newFixedThreadPool(20);
private Object mLock = new Object();
// All the sockets bound to an open connection.
private Set<Socket> mSockets = new HashSet<Socket>();
/**
* Defines the keystore contents for the server, BKS version. Holds just a
* single self-generated key. The subject name is "Test Server".
*/
private static final String SERVER_KEYS_BKS =
"AAAAAQAAABQDkebzoP1XwqyWKRCJEpn/t8dqIQAABDkEAAVteWtleQAAARpYl20nAAAAAQAFWC41" +
"MDkAAAJNMIICSTCCAbKgAwIBAgIESEfU1jANBgkqhkiG9w0BAQUFADBpMQswCQYDVQQGEwJVUzET" +
"MBEGA1UECBMKQ2FsaWZvcm5pYTEMMAoGA1UEBxMDTVRWMQ8wDQYDVQQKEwZHb29nbGUxEDAOBgNV" +
"BAsTB0FuZHJvaWQxFDASBgNVBAMTC1Rlc3QgU2VydmVyMB4XDTA4MDYwNTExNTgxNFoXDTA4MDkw" +
"MzExNTgxNFowaTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExDDAKBgNVBAcTA01U" +
"VjEPMA0GA1UEChMGR29vZ2xlMRAwDgYDVQQLEwdBbmRyb2lkMRQwEgYDVQQDEwtUZXN0IFNlcnZl" +
"cjCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA0LIdKaIr9/vsTq8BZlA3R+NFWRaH4lGsTAQy" +
"DPMF9ZqEDOaL6DJuu0colSBBBQ85hQTPa9m9nyJoN3pEi1hgamqOvQIWcXBk+SOpUGRZZFXwniJV" +
"zDKU5nE9MYgn2B9AoiH3CSuMz6HRqgVaqtppIe1jhukMc/kHVJvlKRNy9XMCAwEAATANBgkqhkiG" +
"9w0BAQUFAAOBgQC7yBmJ9O/eWDGtSH9BH0R3dh2NdST3W9hNZ8hIa8U8klhNHbUCSSktZmZkvbPU" +
"hse5LI3dh6RyNDuqDrbYwcqzKbFJaq/jX9kCoeb3vgbQElMRX8D2ID1vRjxwlALFISrtaN4VpWzV" +
"yeoHPW4xldeZmoVtjn8zXNzQhLuBqX2MmAAAAqwAAAAUvkUScfw9yCSmALruURNmtBai7kQAAAZx" +
"4Jmijxs/l8EBaleaUru6EOPioWkUAEVWCxjM/TxbGHOi2VMsQWqRr/DZ3wsDmtQgw3QTrUK666sR" +
"MBnbqdnyCyvM1J2V1xxLXPUeRBmR2CXorYGF9Dye7NkgVdfA+9g9L/0Au6Ugn+2Cj5leoIgkgApN" +
"vuEcZegFlNOUPVEs3SlBgUF1BY6OBM0UBHTPwGGxFBBcetcuMRbUnu65vyDG0pslT59qpaR0TMVs" +
"P+tcheEzhyjbfM32/vwhnL9dBEgM8qMt0sqF6itNOQU/F4WGkK2Cm2v4CYEyKYw325fEhzTXosck" +
"MhbqmcyLab8EPceWF3dweoUT76+jEZx8lV2dapR+CmczQI43tV9btsd1xiBbBHAKvymm9Ep9bPzM" +
"J0MQi+OtURL9Lxke/70/MRueqbPeUlOaGvANTmXQD2OnW7PISwJ9lpeLfTG0LcqkoqkbtLKQLYHI" +
"rQfV5j0j+wmvmpMxzjN3uvNajLa4zQ8l0Eok9SFaRr2RL0gN8Q2JegfOL4pUiHPsh64WWya2NB7f" +
"V+1s65eA5ospXYsShRjo046QhGTmymwXXzdzuxu8IlnTEont6P4+J+GsWk6cldGbl20hctuUKzyx" +
"OptjEPOKejV60iDCYGmHbCWAzQ8h5MILV82IclzNViZmzAapeeCnexhpXhWTs+xDEYSKEiG/camt" +
"bhmZc3BcyVJrW23PktSfpBQ6D8ZxoMfF0L7V2GQMaUg+3r7ucrx82kpqotjv0xHghNIm95aBr1Qw" +
"1gaEjsC/0wGmmBDg1dTDH+F1p9TInzr3EFuYD0YiQ7YlAHq3cPuyGoLXJ5dXYuSBfhDXJSeddUkl" +
"k1ufZyOOcskeInQge7jzaRfmKg3U94r+spMEvb0AzDQVOKvjjo1ivxMSgFRZaDb/4qw=";
private static final String PASSWORD = "android";
private static final char[] EMPTY_PASSWORD = new char[0];
/**
* Loads a keystore from a base64-encoded String. Returns the KeyManager[]
* for the result.
*/
private static KeyManager[] getHardCodedKeyManagers() throws Exception {
byte[] bytes = Base64.decode(SERVER_KEYS_BKS.getBytes(), Base64.DEFAULT);
InputStream inputStream = new ByteArrayInputStream(bytes);
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(inputStream, PASSWORD.toCharArray());
inputStream.close();
String algorithm = KeyManagerFactory.getDefaultAlgorithm();
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(algorithm);
keyManagerFactory.init(keyStore, PASSWORD.toCharArray());
return keyManagerFactory.getKeyManagers();
}
private KeyManager[] getKeyManagersFromStreams(InputStream key, InputStream cert)
throws Exception {
ByteArrayOutputStream os = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int n;
while ((n = key.read(buffer, 0, buffer.length)) != -1) {
os.write(buffer, 0, n);
}
key.close();
byte[] keyBytes = os.toByteArray();
KeyFactory kf = KeyFactory.getInstance("RSA");
Key privKey = kf.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Certificate[] chain = new Certificate[1];
chain[0] = cf.generateCertificate(cert);
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(/*stream=*/null, /*password*/null);
keyStore.setKeyEntry("server", privKey, EMPTY_PASSWORD, chain);
String algorithm = KeyManagerFactory.getDefaultAlgorithm();
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(algorithm);
keyManagerFactory.init(keyStore, EMPTY_PASSWORD);
return keyManagerFactory.getKeyManagers();
}
ServerThread(CtsTestServer server, @SslMode int sslMode, InputStream key,
InputStream cert) throws Exception {
super("ServerThread");
mServer = server;
mSsl = sslMode;
KeyManager[] keyManagers;
if (key == null && cert == null) {
keyManagers = getHardCodedKeyManagers();
} else {
keyManagers = getKeyManagersFromStreams(key, cert);
}
int retry = 3;
while (true) {
try {
if (mSsl == SslMode.INSECURE) {
mSocket = new ServerSocket(0);
} else { // Use SSL
mSslContext = SSLContext.getInstance("TLS");
mSslContext.init(keyManagers, mServer.getTrustManagers(), null);
mSocket = mSslContext.getServerSocketFactory().createServerSocket(0);
if (mSsl == SslMode.TRUST_ANY_CLIENT) {
HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String s, SSLSession sslSession) {
return true;
}
});
HttpsURLConnection.setDefaultSSLSocketFactory(
mSslContext.getSocketFactory());
} else if (mSsl == SslMode.WANTS_CLIENT_AUTH) {
((SSLServerSocket) mSocket).setWantClientAuth(true);
} else if (mSsl == SslMode.NEEDS_CLIENT_AUTH) {
((SSLServerSocket) mSocket).setNeedClientAuth(true);
}
}
return;
} catch (IOException e) {
if (--retry == 0) {
throw e;
}
// sleep in case server socket is still being closed
Thread.sleep(1000);
}
}
}
public void run() {
while (!mWillShutDown) {
try {
Socket socket = mSocket.accept();
synchronized(mLock) {
mSockets.add(socket);
}
DefaultHttpServerConnection conn = mServer.createHttpServerConnection();
HttpParams params = new BasicHttpParams();
params.setParameter(CoreProtocolPNames.PROTOCOL_VERSION, HttpVersion.HTTP_1_0);
conn.bind(socket, params);
// Determine whether we need to shutdown early before
// parsing the response since conn.close() will crash
// for SSL requests due to UnsupportedOperationException.
HttpRequest request = conn.receiveRequestHeader();
if (request instanceof HttpEntityEnclosingRequest) {
conn.receiveRequestEntity( (HttpEntityEnclosingRequest) request);
}
mExecutorService.execute(new HandleResponseTask(conn, request, socket));
} catch (IOException e) {
// normal during shutdown, ignore
Log.w(TAG, e);
} catch (RejectedExecutionException e) {
// normal during shutdown, ignore
Log.w(TAG, e);
} catch (HttpException e) {
Log.w(TAG, e);
} catch (UnsupportedOperationException e) {
// DefaultHttpServerConnection's close() throws an
// UnsupportedOperationException.
Log.w(TAG, e);
}
}
}
/**
* Shutdown the socket and the executor service.
* Note this method is called on the client thread, instead of the server thread.
*/
public void shutDownOnClientThread() {
try {
mWillShutDown = true;
mExecutorService.shutdown();
mExecutorService.awaitTermination(1L, TimeUnit.MINUTES);
mSocket.close();
// To prevent the server thread from being blocked on read from socket,
// which is called when the server tries to receiveRequestHeader,
// close all the sockets here.
synchronized(mLock) {
for (Socket socket : mSockets) {
socket.close();
}
}
} catch (IOException ignored) {
// safe to ignore
} catch (InterruptedException e) {
Log.e(TAG, "Shutting down threads", e);
}
}
private class HandleResponseTask implements Runnable {
private DefaultHttpServerConnection mConnection;
private HttpRequest mRequest;
private Socket mSocket;
public HandleResponseTask(DefaultHttpServerConnection connection,
HttpRequest request, Socket socket) {
this.mConnection = connection;
this.mRequest = request;
this.mSocket = socket;
}
@Override
public void run() {
try {
HttpResponse response = mServer.getResponse(mRequest);
mConnection.sendResponseHeader(response);
mConnection.sendResponseEntity(response);
} catch (Exception e) {
Log.e(TAG, "Error handling request:", e);
} finally {
try {
mConnection.close();
} catch (IOException e) {
Log.e(TAG, "Failed to close http connection", e);
}
// mConnection.close() closes mSocket.
// mConnection only throws an IOException when the socket.close() call fails, at
// which point, there is not much that can be done anyways.
synchronized(mLock) {
ServerThread.this.mSockets.remove(mSocket);
}
}
}
}
}
}