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