1 // Copyright 2012 The Chromium Authors 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.net.test.util; 6 7 import android.util.Base64; 8 import android.util.Log; 9 import android.util.Pair; 10 11 import org.chromium.base.ApiCompatibilityUtils; 12 13 import java.io.IOException; 14 import java.io.OutputStream; 15 import java.io.PrintStream; 16 import java.nio.charset.Charset; 17 import java.security.MessageDigest; 18 import java.security.NoSuchAlgorithmException; 19 import java.text.SimpleDateFormat; 20 import java.util.ArrayList; 21 import java.util.Date; 22 import java.util.HashMap; 23 import java.util.List; 24 import java.util.Locale; 25 import java.util.Map; 26 27 /** 28 * Simple http test server for testing. 29 * 30 * Extends WebServer with the ability to map requests to prepared responses. 31 */ 32 public class TestWebServer extends WebServer { 33 private static final String TAG = "TestWebServer"; 34 35 private static class Response { 36 final byte[] mResponseData; 37 final List<Pair<String, String>> mResponseHeaders; 38 final boolean mIsRedirect; 39 final Runnable mResponseAction; 40 final boolean mIsNotFound; 41 final boolean mIsNoContent; 42 final boolean mForWebSocket; 43 final boolean mIsEmptyResponse; 44 Response(byte[] responseData, List<Pair<String, String>> responseHeaders, boolean isRedirect, boolean isNotFound, boolean isNoContent, boolean forWebSocket, boolean isEmptyResponse, Runnable responseAction)45 Response(byte[] responseData, List<Pair<String, String>> responseHeaders, 46 boolean isRedirect, boolean isNotFound, boolean isNoContent, boolean forWebSocket, 47 boolean isEmptyResponse, Runnable responseAction) { 48 mIsRedirect = isRedirect; 49 mIsNotFound = isNotFound; 50 mIsNoContent = isNoContent; 51 mForWebSocket = forWebSocket; 52 mIsEmptyResponse = isEmptyResponse; 53 mResponseData = responseData; 54 mResponseHeaders = responseHeaders == null ? new ArrayList<Pair<String, String>>() 55 : responseHeaders; 56 mResponseAction = responseAction; 57 } 58 } 59 60 // The Maps below are modified on both the client thread and the internal server thread, so 61 // need to use a lock when accessing them. 62 private final Object mLock = new Object(); 63 private final Map<String, Response> mResponseMap = new HashMap<String, Response>(); 64 private final Map<String, Integer> mResponseCountMap = new HashMap<String, Integer>(); 65 private final Map<String, HTTPRequest> mLastRequestMap = new HashMap<String, HTTPRequest>(); 66 67 /** 68 * Create and start a local HTTP server instance. 69 * @param port Port number the server must use, or 0 to automatically choose a free port. 70 * @param ssl True if the server should be using secure sockets. 71 * @param additional True if creating an additional server instance. 72 * @throws Exception 73 */ TestWebServer(int port, boolean ssl, boolean additional)74 private TestWebServer(int port, boolean ssl, boolean additional) throws Exception { 75 super(port, ssl, additional); 76 setRequestHandler(new Handler()); 77 } 78 79 private class Handler implements WebServer.RequestHandler { 80 @Override handleRequest(WebServer.HTTPRequest request, OutputStream stream)81 public void handleRequest(WebServer.HTTPRequest request, OutputStream stream) { 82 WebServerPrintStream printStream = new WebServerPrintStream(stream); 83 try { 84 outputResponse(request, printStream); 85 } catch (NoSuchAlgorithmException ignore) { 86 } catch (IOException e) { 87 Log.w(TAG, e); 88 } finally { 89 printStream.close(); 90 } 91 } 92 } 93 94 /** 95 * Create and start a local HTTP server instance. This function must only 96 * be called if no other instances were created. You are responsible for 97 * calling shutdown() on each instance you create. 98 * 99 * @param port Port number the server must use, or 0 to automatically choose a free port. 100 */ start(int port)101 public static TestWebServer start(int port) throws Exception { 102 return new TestWebServer(port, false, false); 103 } 104 105 /** 106 * Same as start(int) but chooses a free port. 107 */ start()108 public static TestWebServer start() throws Exception { 109 return start(0); 110 } 111 112 /** 113 * Create and start a local HTTP server instance. This function must only 114 * be called if you need more than one server instance and the first one 115 * was already created using start() or start(int). You are responsible for 116 * calling shutdown() on each instance you create. 117 * 118 * @param port Port number the server must use, or 0 to automatically choose a free port. 119 */ startAdditional(int port)120 public static TestWebServer startAdditional(int port) throws Exception { 121 return new TestWebServer(port, false, true); 122 } 123 124 /** 125 * Same as startAdditional(int) but chooses a free port. 126 */ startAdditional()127 public static TestWebServer startAdditional() throws Exception { 128 return startAdditional(0); 129 } 130 131 /** 132 * Create and start a local secure HTTP server instance. This function must 133 * only be called if no other secure instances were created. You are 134 * responsible for calling shutdown() on each instance you create. 135 * 136 * @param port Port number the server must use, or 0 to automatically choose a free port. 137 */ startSsl(int port)138 public static TestWebServer startSsl(int port) throws Exception { 139 return new TestWebServer(port, true, false); 140 } 141 142 /** 143 * Same as startSsl(int) but chooses a free port. 144 */ startSsl()145 public static TestWebServer startSsl() throws Exception { 146 return startSsl(0); 147 } 148 149 /** 150 * Create and start a local secure HTTP server instance. This function must 151 * only be called if you need more than one secure server instance and the 152 * first one was already created using startSsl() or startSsl(int). You are 153 * responsible for calling shutdown() on each instance you create. 154 * 155 * @param port Port number the server must use, or 0 to automatically choose a free port. 156 */ startAdditionalSsl(int port)157 public static TestWebServer startAdditionalSsl(int port) throws Exception { 158 return new TestWebServer(port, true, true); 159 } 160 161 /** 162 * Same as startAdditionalSsl(int) but chooses a free port. 163 */ startAdditionalSsl()164 public static TestWebServer startAdditionalSsl() throws Exception { 165 return startAdditionalSsl(0); 166 } 167 168 private static final int RESPONSE_STATUS_NORMAL = 0; 169 private static final int RESPONSE_STATUS_MOVED_TEMPORARILY = 1; 170 private static final int RESPONSE_STATUS_NOT_FOUND = 2; 171 private static final int RESPONSE_STATUS_NO_CONTENT = 3; 172 private static final int RESPONSE_STATUS_FOR_WEBSOCKET = 4; 173 private static final int RESPONSE_STATUS_EMPTY_RESPONSE = 5; 174 setResponseInternal(String requestPath, byte[] responseData, List<Pair<String, String>> responseHeaders, Runnable responseAction, int status)175 private String setResponseInternal(String requestPath, byte[] responseData, 176 List<Pair<String, String>> responseHeaders, Runnable responseAction, int status) { 177 final boolean isRedirect = (status == RESPONSE_STATUS_MOVED_TEMPORARILY); 178 final boolean isNotFound = (status == RESPONSE_STATUS_NOT_FOUND); 179 final boolean isNoContent = (status == RESPONSE_STATUS_NO_CONTENT); 180 final boolean forWebSocket = (status == RESPONSE_STATUS_FOR_WEBSOCKET); 181 final boolean isEmptyResponse = (status == RESPONSE_STATUS_EMPTY_RESPONSE); 182 183 synchronized (mLock) { 184 mResponseMap.put(requestPath, 185 new Response(responseData, responseHeaders, isRedirect, isNotFound, isNoContent, 186 forWebSocket, isEmptyResponse, responseAction)); 187 mResponseCountMap.put(requestPath, Integer.valueOf(0)); 188 mLastRequestMap.put(requestPath, null); 189 } 190 return getResponseUrl(requestPath); 191 } 192 193 /** 194 * Sets a 404 (not found) response to be returned when a particular request path is passed in. 195 * 196 * @param requestPath The path to respond to. 197 * @return The full URL including the path that should be requested to get the expected 198 * response. 199 */ setResponseWithNotFoundStatus(String requestPath)200 public String setResponseWithNotFoundStatus(String requestPath) { 201 return setResponseWithNotFoundStatus(requestPath, null); 202 } 203 204 /** 205 * Sets a 404 (not found) response to be returned when a particular request path is passed in. 206 * 207 * @param requestPath The path to respond to. 208 * @param responseHeaders Any additional headers that should be returned along with the 209 * response (null is acceptable). 210 * @return The full URL including the path that should be requested to get the expected 211 * response. 212 */ setResponseWithNotFoundStatus( String requestPath, List<Pair<String, String>> responseHeaders)213 public String setResponseWithNotFoundStatus( 214 String requestPath, List<Pair<String, String>> responseHeaders) { 215 return setResponseInternal(requestPath, ApiCompatibilityUtils.getBytesUtf8(""), 216 responseHeaders, null, RESPONSE_STATUS_NOT_FOUND); 217 } 218 219 /** 220 * Sets a 204 (no content) response to be returned when a particular request path is passed in. 221 * 222 * @param requestPath The path to respond to. 223 * @return The full URL including the path that should be requested to get the expected 224 * response. 225 */ setResponseWithNoContentStatus(String requestPath)226 public String setResponseWithNoContentStatus(String requestPath) { 227 return setResponseInternal(requestPath, ApiCompatibilityUtils.getBytesUtf8(""), null, null, 228 RESPONSE_STATUS_NO_CONTENT); 229 } 230 231 /** 232 * Sets an empty response to be returned when a particular request path is passed in. 233 * 234 * @param requestPath The path to respond to. 235 * @return The full URL including the path that should be requested to get the expected 236 * response. 237 */ setEmptyResponse(String requestPath)238 public String setEmptyResponse(String requestPath) { 239 return setResponseInternal(requestPath, ApiCompatibilityUtils.getBytesUtf8(""), null, null, 240 RESPONSE_STATUS_EMPTY_RESPONSE); 241 } 242 243 /** 244 * Sets a response to be returned when a particular request path is passed 245 * in (with the option to specify additional headers). 246 * 247 * @param requestPath The path to respond to. 248 * @param responseString The response body that will be returned. 249 * @param responseHeaders Any additional headers that should be returned along with the 250 * response (null is acceptable). 251 * @return The full URL including the path that should be requested to get the expected 252 * response. 253 */ setResponse( String requestPath, String responseString, List<Pair<String, String>> responseHeaders)254 public String setResponse( 255 String requestPath, String responseString, List<Pair<String, String>> responseHeaders) { 256 return setResponseInternal(requestPath, ApiCompatibilityUtils.getBytesUtf8(responseString), 257 responseHeaders, null, RESPONSE_STATUS_NORMAL); 258 } 259 260 /** 261 * Sets a response to be returned when a particular request path is passed 262 * in with the option to specify additional headers as well as an arbitrary action to be 263 * executed on each request. 264 * 265 * @param requestPath The path to respond to. 266 * @param responseString The response body that will be returned. 267 * @param responseHeaders Any additional headers that should be returned along with the 268 * response (null is acceptable). 269 * @param responseAction The action to be performed when fetching the response. This action 270 * will be executed for each request and will be handled on a background 271 * thread. 272 * @return The full URL including the path that should be requested to get the expected 273 * response. 274 */ setResponseWithRunnableAction(String requestPath, String responseString, List<Pair<String, String>> responseHeaders, Runnable responseAction)275 public String setResponseWithRunnableAction(String requestPath, String responseString, 276 List<Pair<String, String>> responseHeaders, Runnable responseAction) { 277 return setResponseInternal(requestPath, ApiCompatibilityUtils.getBytesUtf8(responseString), 278 responseHeaders, responseAction, RESPONSE_STATUS_NORMAL); 279 } 280 281 /** 282 * Sets a redirect. 283 * 284 * @param requestPath The path to respond to. 285 * @param targetLocation The path (or absolute URL) to redirect to. 286 * @return The full URL including the path that should be requested to get the expected 287 * response. 288 */ setRedirect(String requestPath, String targetLocation)289 public String setRedirect(String requestPath, String targetLocation) { 290 return setRedirect(requestPath, targetLocation, new ArrayList<>()); 291 } 292 293 /** 294 * Sets a redirect with optional headers. 295 * 296 * @param requestPath The path to respond to. 297 * @param targetLocation The path (or absolute URL) to redirect to. 298 * @param responseHeaders Any additional headers that should be returned along with the 299 * response (null is acceptable). 300 * @return The full URL including the path that should be requested to get the expected 301 * response. 302 */ setRedirect( String requestPath, String targetLocation, List<Pair<String, String>> responseHeaders)303 public String setRedirect( 304 String requestPath, String targetLocation, List<Pair<String, String>> responseHeaders) { 305 responseHeaders.add(Pair.create("Location", targetLocation)); 306 307 return setResponseInternal(requestPath, ApiCompatibilityUtils.getBytesUtf8(targetLocation), 308 responseHeaders, null, RESPONSE_STATUS_MOVED_TEMPORARILY); 309 } 310 311 /** 312 * Sets a base64 encoded response to be returned when a particular request path is passed 313 * in (with the option to specify additional headers). 314 * 315 * @param requestPath The path to respond to. 316 * @param base64EncodedResponse The response body that is base64 encoded. The actual server 317 * response will the decoded binary form. 318 * @param responseHeaders Any additional headers that should be returned along with the 319 * response (null is acceptable). 320 * @return The full URL including the path that should be requested to get the expected 321 * response. 322 */ setResponseBase64(String requestPath, String base64EncodedResponse, List<Pair<String, String>> responseHeaders)323 public String setResponseBase64(String requestPath, String base64EncodedResponse, 324 List<Pair<String, String>> responseHeaders) { 325 return setResponseInternal(requestPath, 326 Base64.decode(base64EncodedResponse, Base64.DEFAULT), responseHeaders, null, 327 RESPONSE_STATUS_NORMAL); 328 } 329 330 /** 331 * Sets a response to a WebSocket handshake request. 332 * 333 * @param requestPath The path to respond to. 334 * @param responseHeaders Any additional headers that should be returned along with the 335 * response (null is acceptable). 336 * @return The full URL including the path that should be requested to get the expected 337 * response. 338 */ setResponseForWebSocket( String requestPath, List<Pair<String, String>> responseHeaders)339 public String setResponseForWebSocket( 340 String requestPath, List<Pair<String, String>> responseHeaders) { 341 if (responseHeaders == null) { 342 responseHeaders = new ArrayList<Pair<String, String>>(); 343 } else { 344 responseHeaders = new ArrayList<Pair<String, String>>(responseHeaders); 345 } 346 responseHeaders.add(Pair.create("Connection", "Upgrade")); 347 responseHeaders.add(Pair.create("Upgrade", "websocket")); 348 return setResponseInternal(requestPath, ApiCompatibilityUtils.getBytesUtf8(""), 349 responseHeaders, null, RESPONSE_STATUS_FOR_WEBSOCKET); 350 } 351 352 /** 353 * Get the number of requests was made at this path since it was last set. 354 */ getRequestCount(String requestPath)355 public int getRequestCount(String requestPath) { 356 Integer count = null; 357 synchronized (mLock) { 358 count = mResponseCountMap.get(requestPath); 359 } 360 if (count == null) throw new IllegalArgumentException("Path not set: " + requestPath); 361 return count.intValue(); 362 } 363 364 /** 365 * Returns the last HttpRequest at this path. Can return null if it is never requested. 366 */ getLastRequest(String requestPath)367 public HTTPRequest getLastRequest(String requestPath) { 368 synchronized (mLock) { 369 if (!mLastRequestMap.containsKey(requestPath)) { 370 throw new IllegalArgumentException("Path not set: " + requestPath); 371 } 372 return mLastRequestMap.get(requestPath); 373 } 374 } 375 376 private static class WebServerPrintStream extends PrintStream { WebServerPrintStream(OutputStream out)377 WebServerPrintStream(OutputStream out) { 378 super(out); 379 } 380 381 @Override println(String s)382 public void println(String s) { 383 Log.w(TAG, s); 384 super.println(s); 385 } 386 } 387 388 /** 389 * Generate a response to the given request. 390 * 391 * <p>Always executed on the background server thread. 392 * 393 * <p>If there is an action associated with the response, it will be executed inside of 394 * this function. 395 * 396 * @throws NoSuchAlgorithmException, IOException 397 */ outputResponse(HTTPRequest request, WebServerPrintStream stream)398 private void outputResponse(HTTPRequest request, WebServerPrintStream stream) 399 throws NoSuchAlgorithmException, IOException { 400 // Don't dump headers to decrease log. 401 Log.w(TAG, request.requestLine()); 402 403 final String bodyTemplate = "<html><head><title>%s</title></head>" 404 + "<body>%s</body></html>"; 405 406 boolean copyHeadersToResponse = true; 407 boolean copyBinaryBodyToResponse = false; 408 boolean contentLengthAlreadyIncluded = false; 409 boolean contentTypeAlreadyIncluded = false; 410 StringBuilder textBody = new StringBuilder(); 411 412 String requestURI = request.getURI(); 413 414 Response response; 415 synchronized (mLock) { 416 response = mResponseMap.get(requestURI); 417 } 418 419 if (response == null || response.mIsNotFound) { 420 stream.println("HTTP/1.0 404 Not Found"); 421 textBody.append(String.format(bodyTemplate, "Not Found", "Not Found")); 422 } else if (response.mForWebSocket) { 423 String keyHeader = request.headerValue("Sec-WebSocket-Key"); 424 if (!keyHeader.isEmpty()) { 425 stream.println("HTTP/1.0 101 Switching Protocols"); 426 stream.println("Sec-WebSocket-Accept: " + computeWebSocketAccept(keyHeader)); 427 } else { 428 stream.println("HTTP/1.0 404 Not Found"); 429 textBody.append(String.format(bodyTemplate, "Not Found", "Not Found")); 430 copyHeadersToResponse = false; 431 } 432 } else if (response.mIsNoContent) { 433 stream.println("HTTP/1.0 204 No Content"); 434 copyHeadersToResponse = false; 435 } else if (response.mIsRedirect) { 436 stream.println("HTTP/1.0 302 Found"); 437 textBody.append(String.format(bodyTemplate, "Found", "Found")); 438 } else if (response.mIsEmptyResponse) { 439 stream.println("HTTP/1.0 200 OK"); 440 copyHeadersToResponse = false; 441 } else { 442 if (response.mResponseAction != null) response.mResponseAction.run(); 443 444 stream.println("HTTP/1.0 200 OK"); 445 copyBinaryBodyToResponse = true; 446 } 447 448 if (response != null) { 449 if (copyHeadersToResponse) { 450 for (Pair<String, String> header : response.mResponseHeaders) { 451 stream.println(header.first + ": " + header.second); 452 if (header.first.toLowerCase(Locale.ENGLISH).equals("content-length")) { 453 contentLengthAlreadyIncluded = true; 454 } else if (header.first.toLowerCase(Locale.ENGLISH).equals("content-type")) { 455 contentTypeAlreadyIncluded = true; 456 } 457 } 458 } 459 synchronized (mLock) { 460 mResponseCountMap.put(requestURI, 461 Integer.valueOf(mResponseCountMap.get(requestURI).intValue() + 1)); 462 mLastRequestMap.put(requestURI, request); 463 } 464 } 465 466 // RFC 1123 467 final SimpleDateFormat dateFormat = 468 new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); 469 470 // Using print and println() because we don't want to dump it into log. 471 stream.print("Date: " + dateFormat.format(new Date())); 472 stream.println(); 473 474 if (textBody.length() != 0) { 475 if (!contentTypeAlreadyIncluded 476 && (requestURI.endsWith(".html") || requestURI.endsWith(".htm"))) { 477 stream.println("Content-Type: text/html"); 478 } 479 stream.println("Content-Length: " + textBody.length()); 480 stream.println(); 481 stream.print(textBody.toString()); 482 } else if (copyBinaryBodyToResponse) { 483 if (!contentTypeAlreadyIncluded && requestURI.endsWith(".js")) { 484 stream.println("Content-Type: application/javascript"); 485 } else if (!contentTypeAlreadyIncluded 486 && (requestURI.endsWith(".html") || requestURI.endsWith(".htm"))) { 487 stream.println("Content-Type: text/html"); 488 } 489 if (!contentLengthAlreadyIncluded) { 490 stream.println("Content-Length: " + response.mResponseData.length); 491 } 492 stream.println(); 493 stream.write(response.mResponseData); 494 } else { 495 stream.println(); 496 } 497 } 498 499 /** 500 * Return a response for WebSocket handshake challenge. 501 */ computeWebSocketAccept(String keyString)502 private static String computeWebSocketAccept(String keyString) throws NoSuchAlgorithmException { 503 byte[] key = keyString.getBytes(Charset.forName("US-ASCII")); 504 byte[] guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(Charset.forName("US-ASCII")); 505 506 MessageDigest md = MessageDigest.getInstance("SHA"); 507 md.update(key); 508 md.update(guid); 509 byte[] output = md.digest(); 510 return Base64.encodeToString(output, Base64.NO_WRAP); 511 } 512 } 513