diff --git a/app/src/full/java/kellinwood/security/zipsigner/optional/SignatureBlockGenerator.java b/app/src/full/java/kellinwood/security/zipsigner/optional/SignatureBlockGenerator.java index 197cefa44..b7b89d6b2 100644 --- a/app/src/full/java/kellinwood/security/zipsigner/optional/SignatureBlockGenerator.java +++ b/app/src/full/java/kellinwood/security/zipsigner/optional/SignatureBlockGenerator.java @@ -38,10 +38,10 @@ public class SignatureBlockGenerator { CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); - JcaContentSignerBuilder jcaContentSignerBuilder = new JcaContentSignerBuilder(keySet.getSignatureAlgorithm()).setProvider("SC"); + JcaContentSignerBuilder jcaContentSignerBuilder = new JcaContentSignerBuilder(keySet.getSignatureAlgorithm()).setProvider("BC"); ContentSigner sha1Signer = jcaContentSignerBuilder.build(keySet.getPrivateKey()); - JcaDigestCalculatorProviderBuilder jcaDigestCalculatorProviderBuilder = new JcaDigestCalculatorProviderBuilder().setProvider("SC"); + JcaDigestCalculatorProviderBuilder jcaDigestCalculatorProviderBuilder = new JcaDigestCalculatorProviderBuilder().setProvider("BC"); DigestCalculatorProvider digestCalculatorProvider = jcaDigestCalculatorProviderBuilder.build(); JcaSignerInfoGeneratorBuilder jcaSignerInfoGeneratorBuilder = new JcaSignerInfoGeneratorBuilder(digestCalculatorProvider); diff --git a/app/src/full/java/org/fdroid/fdroid/localrepo/LocalRepoManager.java b/app/src/full/java/org/fdroid/fdroid/localrepo/LocalRepoManager.java index 7ea5905cc..6256b6209 100644 --- a/app/src/full/java/org/fdroid/fdroid/localrepo/LocalRepoManager.java +++ b/app/src/full/java/org/fdroid/fdroid/localrepo/LocalRepoManager.java @@ -17,6 +17,7 @@ import android.util.Log; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Hasher; import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.RepoUpdater; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; @@ -103,7 +104,7 @@ public final class LocalRepoManager { repoDir = new SanitizedFile(fdroidDir, "repo"); repoDirCaps = new SanitizedFile(fdroidDirCaps, "REPO"); iconsDir = new SanitizedFile(repoDir, "icons"); - xmlIndexJar = new SanitizedFile(repoDir, "index.jar"); + xmlIndexJar = new SanitizedFile(repoDir, RepoUpdater.SIGNED_FILE_NAME); xmlIndexJarUnsigned = new SanitizedFile(repoDir, "index.unsigned.jar"); if (!fdroidDir.exists() && !fdroidDir.mkdir()) { @@ -481,7 +482,7 @@ public final class LocalRepoManager { public void writeIndexJar() throws IOException, XmlPullParserException, LocalRepoKeyStore.InitException { BufferedOutputStream bo = new BufferedOutputStream(new FileOutputStream(xmlIndexJarUnsigned)); JarOutputStream jo = new JarOutputStream(bo); - JarEntry je = new JarEntry("index.xml"); + JarEntry je = new JarEntry(RepoUpdater.DATA_FILE_NAME); jo.putNextEntry(je); new IndexXmlBuilder().build(context, apps, jo); jo.close(); diff --git a/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java b/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java index 4ba03df04..4d4aade69 100644 --- a/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java +++ b/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java @@ -1,22 +1,52 @@ package org.fdroid.fdroid.net; +/* + * #%L + * NanoHttpd-Webserver + * %% + * Copyright (C) 2012 - 2015 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + import android.content.Context; -import android.content.Intent; import android.net.Uri; -import android.util.Log; -import android.webkit.MimeTypeMap; import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.NanoHTTPD.Response.IStatus; import org.fdroid.fdroid.BuildConfig; -import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.localrepo.LocalRepoKeyStore; import org.fdroid.fdroid.views.swap.SwapWorkflowActivity; import javax.net.ssl.SSLServerSocketFactory; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.FilenameFilter; import java.io.IOException; -import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Arrays; @@ -27,314 +57,104 @@ import java.util.List; import java.util.Map; import java.util.StringTokenizer; +/** + * A HTTP server for serving the files that are being swapped via WiFi, etc. + * The only changes were to remove unneeded extras like {@code main()}, the + * plugin interface, and custom CORS header manipulation. + *

+ * This is mostly just synced from {@code SimpleWebServer.java} from NanoHTTPD. + * + * @see webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java + */ public class LocalHTTPD extends NanoHTTPD { private static final String TAG = "LocalHTTPD"; - private final Context context; - private final File webRoot; + /** + * Default Index file names. + */ + public static final String[] INDEX_FILE_NAMES = {"index.html"}; + private final Context context; + + protected List rootDirs; + + /** + * Configure and start the webserver. This also sets the MIME Types only + * for files that should be downloadable when a browser is used to display + * the swap repo, rather than the F-Droid client. The other file types + * should not be added because it could expose exploits to the browser. + */ public LocalHTTPD(Context context, String hostname, int port, File webRoot, boolean useHttps) { super(hostname, port); - this.webRoot = webRoot; + rootDirs = Collections.singletonList(webRoot); this.context = context.getApplicationContext(); if (useHttps) { enableHTTPS(); } + MIME_TYPES = new HashMap<>(); // ignore nanohttpd's list + MIME_TYPES.put("apk", "application/vnd.android.package-archive"); + MIME_TYPES.put("html", "text/html"); + MIME_TYPES.put("png", "image/png"); + MIME_TYPES.put("xml", "application/xml"); + } + + private boolean canServeUri(String uri, File homeDir) { + boolean canServeUri; + File f = new File(homeDir, uri); + canServeUri = f.exists(); + return canServeUri; } /** * URL-encodes everything between "/"-characters. Encodes spaces as '%20' * instead of '+'. */ - private String encodeUriBetweenSlashes(String uri) { + private String encodeUri(String uri) { String newUri = ""; StringTokenizer st = new StringTokenizer(uri, "/ ", true); while (st.hasMoreTokens()) { String tok = st.nextToken(); - switch (tok) { - case "/": - newUri += "/"; - break; - case " ": - newUri += "%20"; - break; - default: - try { - newUri += URLEncoder.encode(tok, "UTF-8"); - } catch (UnsupportedEncodingException ignored) { - } - break; + if ("/".equals(tok)) { + newUri += "/"; + } else if (" ".equals(tok)) { + newUri += "%20"; + } else { + try { + newUri += URLEncoder.encode(tok, "UTF-8"); + } catch (UnsupportedEncodingException ignored) { + } } } return newUri; } - private void requestSwap(String repo) { - Utils.debugLog(TAG, "Received request to swap with " + repo); - Utils.debugLog(TAG, "Showing confirm screen to check whether that is okay with the user."); - - Uri repoUri = Uri.parse(repo); - Intent intent = new Intent(context, SwapWorkflowActivity.class); - intent.setData(repoUri); - intent.putExtra(SwapWorkflowActivity.EXTRA_CONFIRM, true); - intent.putExtra(SwapWorkflowActivity.EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, true); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(intent); - } - - @Override - public Response serve(IHTTPSession session) { - - if (session.getMethod() == Method.POST) { - try { - session.parseBody(new HashMap()); - } catch (IOException e) { - Log.e(TAG, "An error occured while parsing the POST body", e); - return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, - "Internal server error, check logcat on server for details."); - } catch (ResponseException re) { - return newFixedLengthResponse(re.getStatus(), MIME_PLAINTEXT, re.getMessage()); - } - - return handlePost(session); - } - return handleGet(session); - } - - private Response handlePost(IHTTPSession session) { - Uri uri = Uri.parse(session.getUri()); - switch (uri.getPath()) { - case "/request-swap": - if (!session.getParms().containsKey("repo")) { - Log.e(TAG, "Malformed /request-swap request to local repo HTTP server." - + " Should have posted a 'repo' parameter."); - return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_PLAINTEXT, - "Requires 'repo' parameter to be posted."); - } - requestSwap(session.getParms().get("repo")); - return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "Swap request received."); - } - return newFixedLengthResponse(""); - } - - private Response handleGet(IHTTPSession session) { - - Map header = session.getHeaders(); - Map parms = session.getParms(); - String uri = session.getUri(); - - if (BuildConfig.DEBUG) { - Utils.debugLog(TAG, session.getMethod() + " '" + uri + "' "); - - Iterator e = header.keySet().iterator(); - while (e.hasNext()) { - String value = e.next(); - Utils.debugLog(TAG, " HDR: '" + value + "' = '" + header.get(value) + "'"); - } - e = parms.keySet().iterator(); - while (e.hasNext()) { - String value = e.next(); - Utils.debugLog(TAG, " PRM: '" + value + "' = '" + parms.get(value) + "'"); - } - } - - if (!webRoot.isDirectory()) { - return createResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, - "INTERNAL ERRROR: given path is not a directory (" + webRoot + ")."); - } - - return respond(Collections.unmodifiableMap(header), uri); - } - - private void enableHTTPS() { - try { - LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context); - SSLServerSocketFactory factory = NanoHTTPD.makeSSLSocketFactory( - localRepoKeyStore.getKeyStore(), - localRepoKeyStore.getKeyManagers()); - makeSecure(factory, null); - } catch (LocalRepoKeyStore.InitException | IOException e) { - Log.e(TAG, "Could not enable HTTPS", e); - } - } - - private Response respond(Map headers, String uri) { - // Remove URL arguments - uri = uri.trim().replace(File.separatorChar, '/'); - if (uri.indexOf('?') >= 0) { - uri = uri.substring(0, uri.indexOf('?')); - } - - // Prohibit getting out of current directory - if (uri.contains("../")) { - return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, - "FORBIDDEN: Won't serve ../ for security reasons."); - } - - File f = new File(webRoot, uri); - if (!f.exists()) { - return createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, - "Error 404, file not found."); - } - - // Browsers get confused without '/' after the directory, send a - // redirect. - if (f.isDirectory() && !uri.endsWith("/")) { - uri += "/"; - Response res = createResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, - "Redirected: " + uri + ""); - res.addHeader("Location", uri); - return res; - } - - if (f.isDirectory()) { - // First look for index files (index.html, index.htm, etc) and if - // none found, list the directory if readable. - String indexFile = findIndexFileInDirectory(f); - if (indexFile == null) { - if (f.canRead()) { - // No index file, list the directory if it is readable - return createResponse(Response.Status.OK, NanoHTTPD.MIME_HTML, - listDirectory(uri, f)); - } else { - return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, - "FORBIDDEN: No directory listing."); - } - } else { - return respond(headers, uri + indexFile); - } - } - - Response response = serveFile(headers, f, getAndroidMimeTypeForFile(uri)); - return response != null ? response : - createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, - "Error 404, file not found."); - } - - /** - * Serves file from homeDir and its' subdirectories (only). Uses only URI, - * ignores all headers and HTTP parameters. - */ - private Response serveFile(Map header, File file, String mime) { - Response res; - try { - // Calculate etag - String etag = Integer - .toHexString((file.getAbsolutePath() + file.lastModified() + String.valueOf(file.length())) - .hashCode()); - - // Support (simple) skipping: - long startFrom = 0; - long endAt = -1; - String range = header.get("range"); - if (range != null && range.startsWith("bytes=")) { - range = range.substring("bytes=".length()); - int minus = range.indexOf('-'); - try { - if (minus > 0) { - startFrom = Long.parseLong(range.substring(0, minus)); - endAt = Long.parseLong(range.substring(minus + 1)); - } - } catch (NumberFormatException ignored) { - } - } - - // Change return code and add Content-Range header when skipping is - // requested - long fileLen = file.length(); - if (range != null && startFrom >= 0) { - if (startFrom >= fileLen) { - res = createResponse(Response.Status.RANGE_NOT_SATISFIABLE, - NanoHTTPD.MIME_PLAINTEXT, ""); - res.addHeader("Content-Range", "bytes 0-0/" + fileLen); - res.addHeader("ETag", etag); - } else { - if (endAt < 0) { - endAt = fileLen - 1; - } - long newLen = endAt - startFrom + 1; - if (newLen < 0) { - newLen = 0; - } - - final long dataLen = newLen; - FileInputStream fis = new FileInputStream(file) { - @Override - public int available() throws IOException { - return (int) dataLen; - } - }; - long skipped = fis.skip(startFrom); - if (skipped != startFrom) { - throw new IOException("unable to skip the required " + startFrom + " bytes."); - } - - res = createResponse(Response.Status.PARTIAL_CONTENT, mime, fis); - res.addHeader("Content-Length", String.valueOf(dataLen)); - res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" - + fileLen); - res.addHeader("ETag", etag); - } - } else { - if (etag.equals(header.get("if-none-match"))) { - res = createResponse(Response.Status.NOT_MODIFIED, mime, ""); - } else { - res = createResponse(Response.Status.OK, mime, new FileInputStream(file)); - res.addHeader("Content-Length", String.valueOf(fileLen)); - res.addHeader("ETag", etag); - } - } - } catch (IOException ioe) { - res = createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, - "FORBIDDEN: Reading file failed."); - } - - return res; - } - - // Announce that the file server accepts partial content requests - private Response createResponse(Response.Status status, String mimeType, InputStream message) { - Response res = newChunkedResponse(status, mimeType, message); - res.addHeader("Accept-Ranges", "bytes"); - return res; - } - - // Announce that the file server accepts partial content requests - private Response createResponse(Response.Status status, String mimeType, String message) { - Response res = newFixedLengthResponse(status, mimeType, message); - res.addHeader("Accept-Ranges", "bytes"); - return res; - } - - private static String getAndroidMimeTypeForFile(String uri) { - String type = null; - String extension = MimeTypeMap.getFileExtensionFromUrl(uri); - if (extension != null) { - MimeTypeMap mime = MimeTypeMap.getSingleton(); - type = mime.getMimeTypeFromExtension(extension); - } - return type; - } - private String findIndexFileInDirectory(File directory) { - String indexFileName = "index.html"; - File indexFile = new File(directory, indexFileName); - if (indexFile.exists()) { - return indexFileName; + for (String fileName : LocalHTTPD.INDEX_FILE_NAMES) { + File indexFile = new File(directory, fileName); + if (indexFile.isFile()) { + return fileName; + } } return null; } - private String listDirectory(String uri, File f) { + protected Response getForbiddenResponse(String s) { + return newFixedLengthResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: " + s); + } + + protected Response getInternalErrorResponse(String s) { + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "INTERNAL ERROR: " + s); + } + + protected Response getNotFoundResponse() { + return newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Error 404, file not found."); + } + + protected String listDirectory(String uri, File f) { String heading = "Directory " + uri; - StringBuilder msg = new StringBuilder("" + heading - + "" + - "

" + heading + "

"); + StringBuilder msg = + new StringBuilder("" + heading + "" + "

" + heading + "

"); String up = null; if (uri.length() > 1) { @@ -346,6 +166,7 @@ public class LocalHTTPD extends NanoHTTPD { } List files = Arrays.asList(f.list(new FilenameFilter() { + @Override public boolean accept(File dir, String name) { return new File(dir, name).isFile(); @@ -353,6 +174,7 @@ public class LocalHTTPD extends NanoHTTPD { })); Collections.sort(files); List directories = Arrays.asList(f.list(new FilenameFilter() { + @Override public boolean accept(File dir, String name) { return new File(dir, name).isDirectory(); @@ -364,35 +186,27 @@ public class LocalHTTPD extends NanoHTTPD { if (up != null || directories.size() > 0) { msg.append("
"); if (up != null) { - msg.append("
  • ..
  • "); + msg.append("
  • ..
  • "); } for (String directory : directories) { String dir = directory + "/"; - msg.append("
  • ").append(dir) - .append("
  • "); + msg.append("
  • ").append(dir).append("
  • "); } msg.append("
    "); } if (files.size() > 0) { msg.append("
    "); for (String file : files) { - msg.append("
  • ").append(file) - .append(""); + msg.append("
  • ").append(file).append(""); File curFile = new File(f, file); long len = curFile.length(); msg.append(" ("); if (len < 1024) { msg.append(len).append(" bytes"); } else if (len < 1024 * 1024) { - msg.append(len / 1024).append('.').append(len % 1024 / 10 % 100) - .append(" KB"); + msg.append(len / 1024).append(".").append(len % 1024 / 10 % 100).append(" KB"); } else { - msg.append(len / (1024 * 1024)).append('.') - .append(len % (1024 * 1024) / 10 % 100).append(" MB"); + msg.append(len / (1024 * 1024)).append(".").append(len % (1024 * 1024) / 10000 % 100).append(" MB"); } msg.append(")
  • "); } @@ -403,4 +217,252 @@ public class LocalHTTPD extends NanoHTTPD { msg.append(""); return msg.toString(); } + + public static Response newFixedLengthResponse(IStatus status, String mimeType, String message) { + Response response = NanoHTTPD.newFixedLengthResponse(status, mimeType, message); + response.addHeader("Accept-Ranges", "bytes"); + return response; + } + + private Response respond(Map headers, IHTTPSession session, String uri) { + return defaultRespond(headers, session, uri); + } + + private Response defaultRespond(Map headers, IHTTPSession session, String uri) { + // Remove URL arguments + uri = uri.trim().replace(File.separatorChar, '/'); + if (uri.indexOf('?') >= 0) { + uri = uri.substring(0, uri.indexOf('?')); + } + + // Prohibit getting out of current directory + if (uri.contains("../")) { + return getForbiddenResponse("Won't serve ../ for security reasons."); + } + + boolean canServeUri = false; + File homeDir = null; + for (int i = 0; !canServeUri && i < this.rootDirs.size(); i++) { + homeDir = this.rootDirs.get(i); + canServeUri = canServeUri(uri, homeDir); + } + if (!canServeUri) { + return getNotFoundResponse(); + } + + // Browsers get confused without '/' after the directory, send a + // redirect. + File f = new File(homeDir, uri); + if (f.isDirectory() && !uri.endsWith("/")) { + uri += "/"; + Response res = + newFixedLengthResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, "Redirected: " + uri + ""); + res.addHeader("Location", uri); + return res; + } + + if (f.isDirectory()) { + // First look for index files (index.html, index.htm, etc) and if + // none found, list the directory if readable. + String indexFile = findIndexFileInDirectory(f); + if (indexFile == null) { + if (f.canRead()) { + // No index file, list the directory if it is readable + return newFixedLengthResponse(Response.Status.OK, NanoHTTPD.MIME_HTML, listDirectory(uri, f)); + } else { + return getForbiddenResponse("No directory listing."); + } + } else { + return respond(headers, session, uri + indexFile); + } + } + String mimeTypeForFile = getMimeTypeForFile(uri); + Response response = serveFile(uri, headers, f, mimeTypeForFile); + return response != null ? response : getNotFoundResponse(); + } + + @Override + public Response serve(IHTTPSession session) { + Map header = session.getHeaders(); + Map parms = session.getParms(); + String uri = session.getUri(); + + if (BuildConfig.DEBUG) { + System.out.println(session.getMethod() + " '" + uri + "' "); + + Iterator e = header.keySet().iterator(); + while (e.hasNext()) { + String value = e.next(); + System.out.println(" HDR: '" + value + "' = '" + header.get(value) + "'"); + } + e = parms.keySet().iterator(); + while (e.hasNext()) { + String value = e.next(); + System.out.println(" PRM: '" + value + "' = '" + parms.get(value) + "'"); + } + } + + if (session.getMethod() == Method.POST) { + try { + session.parseBody(new HashMap()); + } catch (IOException e) { + return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT, + "Internal server error, check logcat on server for details."); + } catch (ResponseException re) { + return newFixedLengthResponse(re.getStatus(), MIME_PLAINTEXT, re.getMessage()); + } + + return handlePost(session); + } + + for (File homeDir : this.rootDirs) { + // Make sure we won't die of an exception later + if (!homeDir.isDirectory()) { + return getInternalErrorResponse("given path is not a directory (" + homeDir + ")."); + } + } + return respond(Collections.unmodifiableMap(header), session, uri); + } + + private Response handlePost(IHTTPSession session) { + Uri uri = Uri.parse(session.getUri()); + switch (uri.getPath()) { + case "/request-swap": + if (!session.getParms().containsKey("repo")) { + return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_PLAINTEXT, + "Requires 'repo' parameter to be posted."); + } + SwapWorkflowActivity.requestSwap(context, session.getParms().get("repo")); + return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "Swap request received."); + } + return newFixedLengthResponse(""); + } + + /** + * Serves file from homeDir and its' subdirectories (only). Uses only URI, + * ignores all headers and HTTP parameters. + */ + Response serveFile(String uri, Map header, File file, String mime) { + Response res; + try { + // Calculate etag + String etag = Integer.toHexString((file.getAbsolutePath() + file.lastModified() + "" + file.length()).hashCode()); + + // Support (simple) skipping: + long startFrom = 0; + long endAt = -1; + String range = header.get("range"); + if (range != null) { + if (range.startsWith("bytes=")) { + range = range.substring("bytes=".length()); + int minus = range.indexOf('-'); + try { + if (minus > 0) { + startFrom = Long.parseLong(range.substring(0, minus)); + endAt = Long.parseLong(range.substring(minus + 1)); + } + } catch (NumberFormatException ignored) { + } + } + } + + // get if-range header. If present, it must match etag or else we + // should ignore the range request + String ifRange = header.get("if-range"); + boolean headerIfRangeMissingOrMatching = (ifRange == null || etag.equals(ifRange)); + + String ifNoneMatch = header.get("if-none-match"); + boolean headerIfNoneMatchPresentAndMatching = ifNoneMatch != null && ("*".equals(ifNoneMatch) || ifNoneMatch.equals(etag)); + + // Change return code and add Content-Range header when skipping is + // requested + long fileLen = file.length(); + if (headerIfRangeMissingOrMatching && range != null && startFrom >= 0 && startFrom < fileLen) { + // range request that matches current etag + // and the startFrom of the range is satisfiable + if (headerIfNoneMatchPresentAndMatching) { + // range request that matches current etag + // and the startFrom of the range is satisfiable + // would return range from file + // respond with not-modified + res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, ""); + res.addHeader("ETag", etag); + } else { + if (endAt < 0) { + endAt = fileLen - 1; + } + long newLen = endAt - startFrom + 1; + if (newLen < 0) { + newLen = 0; + } + + FileInputStream fis = new FileInputStream(file); + fis.skip(startFrom); + + res = newFixedLengthResponse(Response.Status.PARTIAL_CONTENT, mime, fis, newLen); + res.addHeader("Accept-Ranges", "bytes"); + res.addHeader("Content-Length", "" + newLen); + res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + fileLen); + res.addHeader("ETag", etag); + } + } else { + + if (headerIfRangeMissingOrMatching && range != null && startFrom >= fileLen) { + // return the size of the file + // 4xx responses are not trumped by if-none-match + res = newFixedLengthResponse(Response.Status.RANGE_NOT_SATISFIABLE, NanoHTTPD.MIME_PLAINTEXT, ""); + res.addHeader("Content-Range", "bytes */" + fileLen); + res.addHeader("ETag", etag); + } else if (range == null && headerIfNoneMatchPresentAndMatching) { + // full-file-fetch request + // would return entire file + // respond with not-modified + res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, ""); + res.addHeader("ETag", etag); + } else if (!headerIfRangeMissingOrMatching && headerIfNoneMatchPresentAndMatching) { + // range request that doesn't match current etag + // would return entire (different) file + // respond with not-modified + + res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, ""); + res.addHeader("ETag", etag); + } else { + // supply the file + res = newFixedFileResponse(file, mime); + res.addHeader("Content-Length", "" + fileLen); + res.addHeader("ETag", etag); + } + } + } catch (IOException ioe) { + res = getForbiddenResponse("Reading file failed."); + } + + return res; + } + + private Response newFixedFileResponse(File file, String mime) throws FileNotFoundException { + Response res; + res = newFixedLengthResponse(Response.Status.OK, mime, new FileInputStream(file), (int) file.length()); + res.addHeader("Accept-Ranges", "bytes"); + return res; + } + + private void enableHTTPS() { + try { + LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context); + SSLServerSocketFactory factory = NanoHTTPD.makeSSLSocketFactory( + localRepoKeyStore.getKeyStore(), + localRepoKeyStore.getKeyManagers()); + makeSecure(factory, null); + } catch (LocalRepoKeyStore.InitException | IOException e) { + e.printStackTrace(); + } + } + + protected Response addCORSHeaders(Map queryHeaders, Response resp, String cors) { + resp.addHeader("Access-Control-Allow-Credentials", "false"); + resp.addHeader("Access-Control-Allow-Methods", "GET, POST, HEAD"); + // TODO add HTTP Content Security Policy headers + return resp; + } } diff --git a/app/src/full/java/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java b/app/src/full/java/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java index 71f86e287..fd8616fd3 100644 --- a/app/src/full/java/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java +++ b/app/src/full/java/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java @@ -123,6 +123,16 @@ public class SwapWorkflowActivity extends AppCompatActivity { private LocalBroadcastManager localBroadcastManager; private WifiManager wifiManager; + public static void requestSwap(Context context, String repo) { + Uri repoUri = Uri.parse(repo); + Intent intent = new Intent(context, SwapWorkflowActivity.class); + intent.setData(repoUri); + intent.putExtra(EXTRA_CONFIRM, true); + intent.putExtra(EXTRA_PREVENT_FURTHER_SWAP_REQUESTS, true); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + @NonNull private final ServiceConnection serviceConnection = new ServiceConnection() { @Override diff --git a/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java b/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java index 92d78bbc1..6432cd261 100644 --- a/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java +++ b/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java @@ -83,6 +83,9 @@ import java.util.jar.JarFile; public class RepoUpdater { private static final String TAG = "RepoUpdater"; + public static final String SIGNED_FILE_NAME = "index.jar"; + public static final String DATA_FILE_NAME = "index.xml"; + final String indexUrl; @NonNull @@ -205,7 +208,7 @@ public class RepoUpdater { FDroidApp.disableBouncyCastleOnLollipop(); JarFile jarFile = new JarFile(downloadedFile, true); - JarEntry indexEntry = (JarEntry) jarFile.getEntry("index.xml"); + JarEntry indexEntry = (JarEntry) jarFile.getEntry(RepoUpdater.DATA_FILE_NAME); indexInputStream = new ProgressBufferedInputStream(jarFile.getInputStream(indexEntry), processIndexListener, repo.address, (int) indexEntry.getSize()); diff --git a/app/src/main/java/org/fdroid/fdroid/data/App.java b/app/src/main/java/org/fdroid/fdroid/data/App.java index a1b02e49e..a10d03512 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/App.java +++ b/app/src/main/java/org/fdroid/fdroid/data/App.java @@ -382,7 +382,7 @@ public class App extends ValueObject implements Comparable, Parcelable { SanitizedFile apkFile = SanitizedFile.knownSanitized(packageInfo.applicationInfo.publicSourceDir); app.installedApk = new Apk(); if (apkFile.canRead()) { - String hashType = "SHA-256"; + String hashType = "sha256"; String hash = Utils.getBinaryHash(apkFile, hashType); if (TextUtils.isEmpty(hash)) { return null; diff --git a/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java index 75f9416e5..63bfe99b2 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java @@ -53,7 +53,7 @@ import java.net.URL; public class HttpDownloader extends Downloader { private static final String TAG = "HttpDownloader"; - private static final String HEADER_FIELD_ETAG = "ETag"; + static final String HEADER_FIELD_ETAG = "ETag"; private final String username; private final String password; @@ -167,6 +167,7 @@ public class HttpDownloader extends Downloader { if (isSwapUrl(sourceUrl)) { // swap never works with a proxy, its unrouted IP on the same subnet connection = (HttpURLConnection) sourceUrl.openConnection(); + connection.setRequestProperty("Connection", "Close"); // avoid keep-alive } else { if (queryString != null) { connection = NetCipher.getHttpURLConnection(new URL(urlString + "?" + queryString)); diff --git a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java index dbb5e7b47..85b30525a 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java @@ -55,6 +55,7 @@ import android.widget.TextView; import android.widget.Toast; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.R; +import org.fdroid.fdroid.RepoUpdater; import org.fdroid.fdroid.UpdateService; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.compat.CursorAdapterCompat; @@ -567,7 +568,7 @@ public class ManageReposActivity extends AppCompatActivity return addressWithoutIndex; } - final Uri uri = builder.appendPath("index.jar").build(); + final Uri uri = builder.appendPath(RepoUpdater.SIGNED_FILE_NAME).build(); try { if (checkForRepository(uri)) { diff --git a/app/src/testFull/java/org/fdroid/fdroid/localrepo/LocalRepoKeyStoreTest.java b/app/src/testFull/java/org/fdroid/fdroid/localrepo/LocalRepoKeyStoreTest.java new file mode 100644 index 000000000..e45d48783 --- /dev/null +++ b/app/src/testFull/java/org/fdroid/fdroid/localrepo/LocalRepoKeyStoreTest.java @@ -0,0 +1,57 @@ +package org.fdroid.fdroid.localrepo; + +import android.content.Context; +import android.text.TextUtils; +import org.apache.commons.io.IOUtils; +import org.fdroid.fdroid.RepoUpdater; +import org.fdroid.fdroid.Utils; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.cert.Certificate; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +@RunWith(RobolectricTestRunner.class) +public class LocalRepoKeyStoreTest { + + @Test + public void testSignZip() throws IOException, LocalRepoKeyStore.InitException, RepoUpdater.SigningException { + Context context = RuntimeEnvironment.application; + + File xmlIndexJarUnsigned = File.createTempFile(getClass().getName(), "unsigned.jar"); + BufferedOutputStream bo = new BufferedOutputStream(new FileOutputStream(xmlIndexJarUnsigned)); + JarOutputStream jo = new JarOutputStream(bo); + JarEntry je = new JarEntry(RepoUpdater.DATA_FILE_NAME); + jo.putNextEntry(je); + InputStream inputStream = getClass().getClassLoader().getResourceAsStream("smallRepo.xml"); + IOUtils.copy(inputStream, jo); + jo.close(); + bo.close(); + + LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context); + Certificate localCert = localRepoKeyStore.getCertificate(); + assertFalse(TextUtils.isEmpty(Utils.calcFingerprint(localCert))); + + File xmlIndexJar = File.createTempFile(getClass().getName(), RepoUpdater.SIGNED_FILE_NAME); + localRepoKeyStore.signZip(xmlIndexJarUnsigned, xmlIndexJar); + + JarFile jarFile = new JarFile(xmlIndexJar, true); + JarEntry indexEntry = (JarEntry) jarFile.getEntry(RepoUpdater.DATA_FILE_NAME); + byte[] data = IOUtils.toByteArray(jarFile.getInputStream(indexEntry)); + assertEquals(17187, data.length); + assertNotNull(RepoUpdater.getSigningCertFromJar(indexEntry)); + } +} diff --git a/app/src/testFull/java/org/fdroid/fdroid/net/LocalHTTPDTest.java b/app/src/testFull/java/org/fdroid/fdroid/net/LocalHTTPDTest.java new file mode 100644 index 000000000..373eaad10 --- /dev/null +++ b/app/src/testFull/java/org/fdroid/fdroid/net/LocalHTTPDTest.java @@ -0,0 +1,449 @@ +package org.fdroid.fdroid.net; + +/* + * #%L + * NanoHttpd-Webserver + * %% + * Copyright (C) 2012 - 2015 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import android.content.Context; +import android.text.TextUtils; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.shadows.ShadowLog; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.URL; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +/** + * Synced from NanoHTTPD's {@code TestHttpServer.java} + * + * @see webserver/src/test/java/fi/iki/elonen/LocalHTTPDTest.java + */ +@SuppressWarnings("LineLength") +@RunWith(RobolectricTestRunner.class) +public class LocalHTTPDTest { + + private static ClassLoader classLoader; + private static LocalHTTPD localHttpd; + private static Thread serverStartThread; + private static File webRoot; + + @Before + public void setUp() throws Exception { + ShadowLog.stream = System.out; + classLoader = getClass().getClassLoader(); + + final Context context = RuntimeEnvironment.application.getApplicationContext(); + webRoot = context.getFilesDir(); + FileUtils.deleteDirectory(webRoot); + assertTrue(webRoot.mkdir()); + assertTrue(webRoot.isDirectory()); + + final File testdir = new File(webRoot, "testdir"); + assertTrue(testdir.mkdir()); + IOUtils.copy(classLoader.getResourceAsStream("test.html"), + new FileOutputStream(new File(testdir, "test.html"))); + + serverStartThread = new Thread(new Runnable() { + + @Override + public void run() { + localHttpd = new LocalHTTPD( + context, + "localhost", + 8888, + webRoot, + false); + try { + localHttpd.start(); + } catch (IOException e) { + e.printStackTrace(); + } + assertTrue(localHttpd.isAlive()); + } + }); + serverStartThread.start(); + // give the server some tine to start. + Thread.sleep(100); + } + + @After + public void tearDown() throws Exception { + localHttpd.stop(); + serverStartThread.join(5000); + assertFalse(localHttpd.isAlive()); + assertFalse(serverStartThread.isAlive()); + } + + @Test + public void doTest404() throws Exception { + URL url = new URL("http://localhost:8888/xxx/yyy.html"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setReadTimeout(5000); + connection.connect(); + Assert.assertEquals(404, connection.getResponseCode()); + connection.disconnect(); + } + + @Test + public void doSomeBasicTest() throws Exception { + URL url = new URL("http://localhost:8888/testdir/test.html"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + assertEquals(200, connection.getResponseCode()); + String string = IOUtils.toString(connection.getInputStream(), "UTF-8"); + Assert.assertEquals("\n\ndummy\n\n\n\t

    it works

    \n\n", string); + connection.disconnect(); + + url = new URL("http://localhost:8888/"); + connection = (HttpURLConnection) url.openConnection(); + assertEquals(200, connection.getResponseCode()); + string = IOUtils.toString(connection.getInputStream(), "UTF-8"); + System.out.println("REPLY: " + string); + assertTrue(string.indexOf("testdir") > 0); + connection.disconnect(); + + url = new URL("http://localhost:8888/testdir"); + connection = (HttpURLConnection) url.openConnection(); + assertEquals(200, connection.getResponseCode()); + string = IOUtils.toString(connection.getInputStream(), "UTF-8"); + assertTrue(string.indexOf("test.html") > 0); + connection.disconnect(); + + IOUtils.copy(classLoader.getResourceAsStream("index.microg.jar"), + new FileOutputStream(new File(webRoot, "index.microg.jar"))); + url = new URL("http://localhost:8888/index.microg.jar"); + connection = (HttpURLConnection) url.openConnection(); + assertEquals(200, connection.getResponseCode()); + byte[] actual = IOUtils.toByteArray(connection.getInputStream()); + byte[] expected = IOUtils.toByteArray(classLoader.getResourceAsStream("index.microg.jar")); + Assert.assertArrayEquals(expected, actual); + connection.disconnect(); + + IOUtils.copy(classLoader.getResourceAsStream("extendedPerms.xml"), + new FileOutputStream(new File(webRoot, "extendedPerms.xml"))); + url = new URL("http://localhost:8888/extendedPerms.xml"); + connection = (HttpURLConnection) url.openConnection(); + assertEquals(200, connection.getResponseCode()); + actual = IOUtils.toByteArray(connection.getInputStream()); + expected = IOUtils.toByteArray(classLoader.getResourceAsStream("extendedPerms.xml")); + Assert.assertArrayEquals(expected, actual); + connection.disconnect(); + } + + @Test + public void testAPKMimeType() throws IOException { + String fileName = "urzip.apk"; + String mimeType = "application/vnd.android.package-archive"; + IOUtils.copy(classLoader.getResourceAsStream(fileName), + new FileOutputStream(new File(webRoot, fileName))); + URL url = new URL("http://localhost:8888/" + fileName); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); + assertEquals(200, connection.getResponseCode()); + Assert.assertEquals(mimeType, connection.getContentType()); + connection.disconnect(); + } + + @Test + public void testHeadRequest() throws IOException { + File indexFile = new File(webRoot, "index.html"); + IOUtils.copy(classLoader.getResourceAsStream("index.html"), + new FileOutputStream(indexFile)); + + URL url = new URL("http://localhost:8888/"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("HEAD"); + String mimeType = "text/html"; + System.out.println(mimeType + " " + connection.getContentType()); + assertEquals(mimeType, connection.getContentType()); + assertEquals(indexFile.length(), connection.getContentLength()); + assertNotEquals(0, connection.getContentLength()); + + String etag = connection.getHeaderField(HttpDownloader.HEADER_FIELD_ETAG); + assertFalse(TextUtils.isEmpty(etag)); + + assertEquals(200, connection.getResponseCode()); + connection.disconnect(); + } + + @Test + public void testPostRequest() throws IOException { + URL url = new URL("http://localhost:8888/request-swap"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setDoInput(true); + connection.setDoOutput(true); + + OutputStream outputStream = connection.getOutputStream(); + OutputStreamWriter writer = new OutputStreamWriter(outputStream); + writer.write("repo=http://localhost:8888"); + writer.flush(); + writer.close(); + outputStream.close(); + + assertEquals(200, connection.getResponseCode()); + connection.disconnect(); + } + + @Test + public void testBadPostRequest() throws IOException { + URL url = new URL("http://localhost:8888/request-swap"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setDoInput(true); + connection.setDoOutput(true); + OutputStream outputStream = connection.getOutputStream(); + OutputStreamWriter writer = new OutputStreamWriter(outputStream); + writer.write("repolkasdfkjhttp://localhost:8888"); + writer.flush(); + writer.close(); + outputStream.close(); + assertEquals(400, connection.getResponseCode()); + connection.disconnect(); + } + + @Test + public void doArgumentTest() throws InterruptedException, UnsupportedEncodingException, IOException { + final int testPort = 9458; + Thread testServer = new Thread(new Runnable() { + + @Override + public void run() { + LocalHTTPD localHttpd = new LocalHTTPD( + RuntimeEnvironment.application, + "localhost", + testPort, + webRoot, + false); + try { + localHttpd.start(); + } catch (IOException e) { + e.printStackTrace(); + } + assertTrue(localHttpd.isAlive()); + } + }); + + testServer.start(); + Thread.sleep(200); + + URL url = new URL("http://localhost:" + testPort + "/"); + + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) url.openConnection(); + assertEquals(200, connection.getResponseCode()); + String str = IOUtils.toString(connection.getInputStream(), "UTF-8"); + assertTrue("The response entity didn't contain the string 'testdir'", str.contains("testdir")); + } finally { + if (connection != null) { + connection.disconnect(); + } + testServer.join(5000); + } + } + + @Test + public void testURLContainsParentDirectory() throws IOException { + HttpURLConnection connection = null; + URL url = new URL("http://localhost:8888/testdir/../index.html"); + try { + connection = (HttpURLConnection) url.openConnection(); + Assert.assertEquals("The response status should be 403(Forbidden), " + "since the server won't serve requests with '../' due to security reasons", + 403, connection.getResponseCode()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @Test + public void testIndexFileIsShownWhenURLEndsWithDirectory() throws IOException { + HttpURLConnection connection = null; + try { + String dirName = "indexDir"; + File indexDir = new File(webRoot, dirName); + assertTrue(indexDir.mkdir()); + IOUtils.copy(classLoader.getResourceAsStream("index.html"), + new FileOutputStream(new File(indexDir, "index.html"))); + URL url = new URL("http://localhost:8888/" + dirName); + connection = (HttpURLConnection) url.openConnection(); + String responseString = IOUtils.toString(connection.getInputStream(), "UTF-8"); + Assert.assertThat("When the URL ends with a directory, and if an index.html file is present in that directory," + " the server should respond with that file", + responseString, containsString("Simple index file")); + + IOUtils.copy(classLoader.getResourceAsStream("index.html"), + new FileOutputStream(new File(webRoot, "index.html"))); + url = new URL("http://localhost:8888/"); + connection = (HttpURLConnection) url.openConnection(); + responseString = IOUtils.toString(connection.getInputStream(), "UTF-8"); + Assert.assertThat("When the URL ends with a directory, and if an index.html file is present in that directory," + + " the server should respond with that file", + responseString, containsString("Simple index file")); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @Test + public void testRangeHeaderWithStartPositionOnly() throws IOException { + HttpURLConnection connection = null; + try { + URL url = new URL("http://localhost:8888/testdir/test.html"); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("Connection", "close"); + connection.addRequestProperty("range", "bytes=10-"); + connection.setReadTimeout(5000); + String responseString = IOUtils.toString(connection.getInputStream(), "UTF-8"); + System.out.println("responseString " + responseString); + Assert.assertThat("The data from the beginning of the file should have been skipped as specified in the 'range' header", responseString, + not(containsString(""))); + Assert.assertThat("The response should contain the data from the end of the file since end position was not given in the 'range' header", responseString, + containsString("")); + Assert.assertEquals("The content length should be the length starting from the requested byte", "74", connection.getHeaderField("Content-Length")); + Assert.assertEquals("The 'Content-Range' header should contain the correct lengths and offsets based on the range served", "bytes 10-83/84", + connection.getHeaderField("Content-Range")); + Assert.assertEquals("Response status for a successful range request should be PARTIAL_CONTENT(206)", + 206, connection.getResponseCode()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @Test + public void testRangeStartGreaterThanFileLength() throws IOException { + HttpURLConnection connection = null; + try { + URL url = new URL("http://localhost:8888/testdir/test.html"); + connection = (HttpURLConnection) url.openConnection(); + connection.addRequestProperty("range", "bytes=1000-"); + connection.connect(); + Assert.assertEquals("Response status for a request with 'range' header value which exceeds file length should be RANGE_NOT_SATISFIABLE(416)", + 416, connection.getResponseCode()); + Assert.assertEquals("The 'Content-Range' header should contain the correct lengths and offsets based on the range served", + "bytes */84", connection.getHeaderField("Content-Range")); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @Test + public void testRangeHeaderWithStartAndEndPosition() throws IOException { + HttpURLConnection connection = null; + try { + URL url = new URL("http://localhost:8888/testdir/test.html"); + connection = (HttpURLConnection) url.openConnection(); + connection.addRequestProperty("range", "bytes=10-40"); + String responseString = IOUtils.toString(connection.getInputStream(), "UTF-8"); + Assert.assertThat("The data from the beginning of the file should have been skipped as specified in the 'range' header", + responseString, not(containsString(""))); + Assert.assertThat("The data from the end of the file should have been skipped as specified in the 'range' header", + responseString, not(containsString(""))); + Assert.assertEquals("The 'Content-Length' should be the length from the requested start position to end position", + "31", connection.getHeaderField("Content-Length")); + Assert.assertEquals("The 'Contnet-Range' header should contain the correct lengths and offsets based on the range served", + "bytes 10-40/84", connection.getHeaderField("Content-Range")); + Assert.assertEquals("Response status for a successful request with 'range' header should be PARTIAL_CONTENT(206)", + 206, connection.getResponseCode()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @Test + public void testIfNoneMatchHeader() throws IOException { + HttpURLConnection connection = null; + int status = -1; + while (status == -1) { + System.out.println("testIfNoneMatchHeader connect attempt"); + try { + URL url = new URL("http://localhost:8888/testdir/test.html"); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("if-none-match", "*"); + connection.setRequestProperty("Connection", "close"); + connection.connect(); + status = connection.getResponseCode(); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + Assert.assertEquals("The response status to a reqeuest with 'if-non-match=*' header should be NOT_MODIFIED(304), if the file exists", + 304, status); + } + + @Test + public void testRangeHeaderAndIfNoneMatchHeader() throws IOException { + HttpURLConnection connection = null; + try { + URL url = new URL("http://localhost:8888/testdir/test.html"); + connection = (HttpURLConnection) url.openConnection(); + connection.addRequestProperty("range", "bytes=10-20"); + connection.addRequestProperty("if-none-match", "*"); + Assert.assertEquals("The response status to a reqeuest with 'if-non-match=*' header and 'range' header should be NOT_MODIFIED(304)," + + " if the file exists, because 'if-non-match' header should be given priority", 304, connection.getResponseCode()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } +} diff --git a/app/src/testFull/resources/icon.png b/app/src/testFull/resources/icon.png new file mode 100644 index 000000000..21439b7b8 Binary files /dev/null and b/app/src/testFull/resources/icon.png differ diff --git a/app/src/testFull/resources/index.html b/app/src/testFull/resources/index.html new file mode 100644 index 000000000..d6405a434 --- /dev/null +++ b/app/src/testFull/resources/index.html @@ -0,0 +1,5 @@ + + +Simple index file + + \ No newline at end of file diff --git a/app/src/testFull/resources/test.html b/app/src/testFull/resources/test.html new file mode 100644 index 000000000..4cb157cc3 --- /dev/null +++ b/app/src/testFull/resources/test.html @@ -0,0 +1,8 @@ + + +dummy + + +

    it works

    + + \ No newline at end of file diff --git a/app/src/testFull/resources/urzip.apk b/app/src/testFull/resources/urzip.apk new file mode 100644 index 000000000..ee5e5cba8 Binary files /dev/null and b/app/src/testFull/resources/urzip.apk differ