From e0d6371147bc1d660f645abe6e04cf857e7298fd Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Fri, 3 Aug 2018 13:56:52 +0200 Subject: [PATCH 1/7] use constants for index.xml and index.jar throughout the code --- .../java/org/fdroid/fdroid/localrepo/LocalRepoManager.java | 5 +++-- app/src/main/java/org/fdroid/fdroid/RepoUpdater.java | 5 ++++- .../java/org/fdroid/fdroid/views/ManageReposActivity.java | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) 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/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/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)) { From 57a00938a023ca75857a56260922543bfd46e440 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Fri, 3 Aug 2018 14:41:59 +0200 Subject: [PATCH 2/7] fix broken index.jar signing for swap repos Broken in the switch to bouncycastle: 5c6c54cadfa8511296b8f7374b113c4e26b7b3a4 --- .../optional/SignatureBlockGenerator.java | 4 +- .../localrepo/LocalRepoKeyStoreTest.java | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 app/src/testFull/java/org/fdroid/fdroid/localrepo/LocalRepoKeyStoreTest.java 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/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)); + } +} From f56fbf4dcb9ab60256dbdd21927dafb8f269a0f3 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 6 Aug 2018 22:42:39 +0200 Subject: [PATCH 3/7] swap connections fail with strange errors with keep-alive enabled --- app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java | 1 + 1 file changed, 1 insertion(+) 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..9588252ac 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java @@ -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)); From ddb85befa6a2ef5cb272318ab94c7739a13db07a Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 6 Aug 2018 23:20:32 +0200 Subject: [PATCH 4/7] index.xml must use "sha256" as hash type, not "SHA-256" Otherwise the string matching in RepoXMLHandler.endElement() fails, and the swap repo's index.xml will use "SHA-256" instead of the correct "sha256". --- app/src/main/java/org/fdroid/fdroid/data/App.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From e4c9d1d522ee6caeadd30337a398e442b76355a9 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 6 Aug 2018 10:13:20 +0200 Subject: [PATCH 5/7] move requestSwap method to static Intent-sending method pattern --- .../full/java/org/fdroid/fdroid/net/LocalHTTPD.java | 13 +------------ .../fdroid/views/swap/SwapWorkflowActivity.java | 10 ++++++++++ 2 files changed, 11 insertions(+), 12 deletions(-) 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..da63dcf1f 100644 --- a/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java +++ b/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java @@ -69,17 +69,6 @@ public class LocalHTTPD extends NanoHTTPD { 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 @@ -111,7 +100,7 @@ public class LocalHTTPD extends NanoHTTPD { return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_PLAINTEXT, "Requires 'repo' parameter to be posted."); } - requestSwap(session.getParms().get("repo")); + SwapWorkflowActivity.requestSwap(context, session.getParms().get("repo")); return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "Swap request received."); } return newFixedLengthResponse(""); 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 From f1e5653601472da30802829872d482e139ed4d9b Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 6 Aug 2018 16:22:21 +0200 Subject: [PATCH 6/7] hardcode limited MIME Types for the swap webserver The MIME Types only need to be set on files that we are actually using to display in the browser. All others should not be set so that they cannot be abused. --- .../java/org/fdroid/fdroid/net/LocalHTTPD.java | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) 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 da63dcf1f..ae71ff414 100644 --- a/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java +++ b/app/src/full/java/org/fdroid/fdroid/net/LocalHTTPD.java @@ -4,7 +4,6 @@ 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 org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.Utils; @@ -40,6 +39,11 @@ public class LocalHTTPD extends NanoHTTPD { 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"); } /** @@ -296,16 +300,6 @@ public class LocalHTTPD extends NanoHTTPD { 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); From 738216c205950ab2a77ccea76d69f26034c6664b Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 6 Aug 2018 16:57:33 +0200 Subject: [PATCH 7/7] totally rework LocalHTTPD based on nanohttpd 2.3.1 The webserver was totally broken since nanohttpd had changed so much since the swap webserver was implemented. This syncs up with the sample file and gets rid of our hacks. The only differences now are the stuff that is removed since it is totally unused in F-Droid. This also adds a full test suite. this actually closes #248 --- .../org/fdroid/fdroid/net/LocalHTTPD.java | 625 ++++++++++-------- .../org/fdroid/fdroid/net/HttpDownloader.java | 2 +- .../org/fdroid/fdroid/net/LocalHTTPDTest.java | 449 +++++++++++++ app/src/testFull/resources/icon.png | Bin 0 -> 1413 bytes app/src/testFull/resources/index.html | 5 + app/src/testFull/resources/test.html | 8 + app/src/testFull/resources/urzip.apk | Bin 0 -> 9969 bytes 7 files changed, 815 insertions(+), 274 deletions(-) create mode 100644 app/src/testFull/java/org/fdroid/fdroid/net/LocalHTTPDTest.java create mode 100644 app/src/testFull/resources/icon.png create mode 100644 app/src/testFull/resources/index.html create mode 100644 app/src/testFull/resources/test.html create mode 100644 app/src/testFull/resources/urzip.apk 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 ae71ff414..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,21 +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 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; @@ -26,15 +57,36 @@ 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(); @@ -46,278 +98,63 @@ public class LocalHTTPD extends NanoHTTPD { 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; } - } - - @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."); - } - SwapWorkflowActivity.requestSwap(context, 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 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) { @@ -329,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(); @@ -336,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(); @@ -347,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(")
  • "); } @@ -386,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/main/java/org/fdroid/fdroid/net/HttpDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java index 9588252ac..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; 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 0000000000000000000000000000000000000000..21439b7b84cf9900c9c44cf62e177688d5560fb1 GIT binary patch literal 1413 zcmV;01$z34P)J%d*P?3pm8#f{3Uf1cDbfGT=A@qNNUb6mo`;Am;c_Lrpc7 zR$iFOp@?FW#t~T=*5sJl0qY_#f~W{AEU0_D{ozECP*~5O@AsYO^FH5;!8Fv?dH|u( znQ)g0m)#v}wj6e# z*-AnG2-uvq6u5JRFP-Mp^vQXA zdwe&PuFeSYa}QFu$=goW)=|+LdE_XRoCu9e7EW%y;fcB0O_UrI6x4g@a0S)Xou<-~ z9TXlmi}(Ujt2$_&yUZ&P9TqFzEldXhc*um=0Re&Jq4w-^^VNn)RDP39zT%0wT$CIf z9Nc@Mp^2VW|NVPZR9HX~nWEZ%cC1XH3Bo85qO=;shWUB%?YN!x0&!h>$w6*HM&8us zT5U*gV{;1;5e*Cs(AdL=WHy^=XlRHGLqqgtQ6b3`s%q{0#WGL-P`p!+iW}9XY?+Io z&e>I23IIpG(?2mDHFP4-M}<|(qIoKrC{3%?_BOX%BqAb<*+jd`N@;a!8s%hfqtx^@ z)YIEXW1~jeUXVv3iTt=g86Z}8`GbfUm0WyOBvWUPPwk;}ix3H&7dkrFW!l+tPfJ`~ zhtHn-V#2oE((7o`tC_TJ?OLj=J~du9jtovGXSyBsfER28N(yy zQrN5zN?ZF1JvN!?@7n+22edTtB^oh~PN z>TccMN)}5to6WUbyl4?PY}PY}uTZ!_Bocwg<4veL@EuWFwu>=aEUAZ&RWp1p%LM@a zxo)G9yY!W8N!i(T^Opm^wY9@XqnY&TG7JL-05i!N-_Z$WyWV0lRwu6-FpR_!U>OJe zs_Vp=#vcSGvsJ<2+Ja}t!+(*Jz!?eg@hmQf%^?~O0QLjJ=#q|}{;g3l3*!xg1L*4N zMxg(U=LUTJRTD~fzID@vVNAp@HscBU2aUyJk4*j@di|JA*N*!7dRVRW+~!%?x%&3* zzD&Ly=SKj40fa)qa+#|*vZuQv(J=U+{7h|4Pu0m&(>HH!L{@gL{>IIoje~vN4MfCD z+L)3M0irI(*FzfP=j;1>W%bDk*YWeSwX`N<9a${a@mfn$18KEdJ=aMZ2Vl~NHXfz= z{3Yz$T~2MCea$S>@Wvg*h3!=}wNp;s(%gt`xdq*~?;E!~HuN>@eedn*Mc%h32LLd` zL%P7%*Qc$r=G24)W{ZWI8ZOYxnKL^@Zk|g3;N|>1=(zaVPDlgsu0Pu1XF7Wly zw4JD`p(BSrrRbO!DL*fl)SjLL0;$Ie07zV)4*<^x0D!Ws$p8RvH(`R*RnjGs$tZlz z9P;+^BzuAQXln7tocT!`#`~uN*h~f3@qP``6X#=7>e4UywoKBP(NM2YxRY2SF>@UR zXR6C{OqB=s;(zPmz;%SqP$3~AKqeA6G}+oY^s58s2B=;LpU~Iw%`p%Wyp-~MsnREj z0W*Ebm2)4#md(IlEnV*DB=||KjaK=D#ZBlj-O~ds7c7p8;Y7wQVTDCR0s#I1LCmDA T+X8aK00000NkvXXu0mjfAS<@z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ee5e5cba868321f3eb85d1e3cc8b50a0a7fd560f GIT binary patch literal 9969 zcmdsdbzB@vv*_aP8iKoPkf6bXI|K;sK^9nCgF~=DZ~_Dft|1U0c<|uvVH4cKA_0QF z*_`iuC->a%-22|Yul85d)z#fqJ=L`}J=LSBf`p6-f14y>U}ffH;pt*!Z{yDG>SA~M83c;aR9D2oq<{;+QF<(=4V2_?L`Ma# zP;ZF~5C}z8Nlse#aeq;YujaHTd0!rle{JWdx*_hCp}HYNERmkCq0yzZ$f0CH{7@0Z zf+AdBV)w|Gw3`qK%jw(G(4tREE0_; z%K-G7?WV5@- zJ{kyGmGX$Sw!?kv7Btj1d-fxG8)cSn4V^o1_1T6fdV+>SZlffI95UE@P?@D&w(-@g z=i1u($;xH*m^Qm=AkEW3-vIvr;gsr8^dktKcGKXJ`s}RM#C_j&)|A)i@hYV1F701q znithlD2a)u{kL6FW@d7VCzGj&sfA)M5Va~q?Q>g(Iq<1SIlgeIqJew{!WR$_Le}%o z_8})TkxIHjuhiPV#WX)np`@f-_Rr6c$<@ER@z>RT@`y0AU@_RMQZ*mr6DvVMJw;FC zPv4`1rkpcGHxs`>X%`uVFVmYw&X2)X5G(=$9Bl0I`R*udp=<|x`)F)g*;89Bx2~upIlH#Kl^q?PCjL;h@@MX_rRjZge6KZ_Q zNKYTTAlEXa?BMx~V#69-IDINOJ+-80Rj9CN`cjcT=DO+8Jz9OApJCDDsZRrR8FlOu zH4gGUV(~F@$V5fwZ59{YHB_FOM2+QMS$_JkJh^8-Rgqcma$r*@cloiAP(cEN z6sr>$HZ5N)`Sn?9D0t&~2+i-IU6=3mWcJY@Gr!mC`nniu*WVqS^s4h7#?cWcchm?S z1s>=Ose3y)A@{aQiw|oJCk$zOcUR%fUECIGz>OY}vRAw3RHTJ!53kS~#wA*OuT`Sz zEtTI*hWcE><)z<~s?+67!-eL{v+wPy?NnQepkRb>nJ)Lt3X!^!7KRDli#OM*&T1Mn zgE~{fU>7-kuj<>rk=*#Oz!e<19@WK+WPF4I?!Gx$inXo1D}S3GgBWv1C+{2TnXRY{ zIcJZIRCJIW8jK|7lqk^5C!>U@i!QI3_Lof5opS<5IHzEJRJ{=gdV&o)>R%?8qn(>k zRvHiQH;N0L3hoCU9_+|}X=FhI@pj98?3|k`sjAzY4o9|)jmV29K=4|2M!tLYi zf~ic5;rLXvwdmhiw483)eHbyRBNRhOGsJRiAfaOM-e6nrww8N=m}q?&YO*zF`ySWt zYPt3?6+czLm|L-ssvmB|JoKKXJn3ZGI<}phC3IF``yuloEo;yCnEX2Tn@eh=Zsk~1 z6~5m4@w}3cBzAOn3P~id7=!3Nxt-aAx>EPG-|6|>Rqu_7UTFt{8|M9g%9m(#M1{>S zknSn zcoGUt&+hgbEl4%A-k^td-0=2k z6lCp?6hwNu6_&f%WC}oNv|i|lO0R2W<$4m0=&YSji)ct=jjL@VoFZT1UJ_iATvA;k z`Qf&OCsS*Y`e6s+1R~uaON8A<(i0Rs&<&-QU6qxR4VJ!pL{=bY_Ne0#Pd*l3fsH+m zQB-?Gu1v5D&zl~Cb+kZiiIAW$zZNLo4d@5$9#{DUwVr+Y_bH}p@F(}CEs9hJ#UH2n zRh@N+WB&DCow4>WcVU$iRP%eddDK2mYx6~84g_2GVGAYfewMHW{2B%r>y)TvqnoGh zMgwk#n8-Ef9H0LjV{QRo_ zd*6@1xF=y?6fS)n(|xTX@3QDZX|A>9#_K@ASgMOu0yjKooOb`+OWO2ld%ZFl!x^p& z41fLY4=*Yz+WPc5;vdtPUX_2&u6<{wM<5k;h#C-hI=fAG*;K5>-o;;2dS%X613LPQ z?5NfIu8Mt3Z@hxo;Nj5^q|$^Jgd-j)VHz}@VGKOf^k-~h2Dx`w<_0fSY6)BYM5Aa+ zrt?L!CvFkI)IaD=dPg^!ks(Vmvn|n^N&QB(`{;RTT`eeIm_Ev=?B;@b@<`-N3WuUq zyono?=LoWQSkH)b*GO$^U%Xg;)Pg)|zeLg$*L1E&lF{QmRea1=5t!C{JId}@`@-1S zz#eCTYa%N8K3J?#zH-2`r<%4yJ~O}Mct-)8Ci$tOoyG6AVO|0$%FiirO&xCuQd(3( z#f>=hia{oz+I*_5#;Xv1T&Gu@wZdzImvIn4y9z}GKp`{eRI)p9%%J|s*jnX#o!3c_S`ySzzf9I23(;}PU9`F$F4%V4 zp^?kRH(FLu5Q%%g4bE@#(faBJVP;vyfu5>WSm~NFn}~`l{Tl ztwqgPZsFto56F!-yGm~3#an{ralYk)ty%H+47{Ie7Sas{Yws2tDp~B%T(xrjn3e4Q zwsvo=cf&YwfvwQi>-A>NgZ&b!)_T|^>R7_mna(Wb>2jpZFxNdx7dysufxZl-{^dwn zm_oR)B`lJvRTuqBDNF&!^mCU=ROw9ytPeYvs^t7zv;_^gj_hZ9CjOW3`GoQ<2lF9v zt2jeWXBzLyL%HZ5BjKg1Z`b)9;xgT)!R6hTYJz1nC&jX`;&5RbSTR-W0`s25u^(fa z?-YC%t<9w3b4H^NeHKW?{X6VUXS;?Ppg+omT{^S*d>*3l(1Z$ev>Jw`d&4m4d?25) z1$2c2`Tkb%uS;U6*WkF9275M;kIyxSRsP2f3*PN z5QG4{3Ty)<@J{e=;QcoR0*M2(4}g&XW&ubEatGOfARr!)H9%Q_yaBWXIRQK#kORmH zWCqX{AWx7BK-&XccMvzo6{y+$m$C3Zz92Br&jT1|0rd3%MtcG10r1U0&H&E=7{v|f zb_OKE=jRE?@&mZ8Kn)$F1IYIHH3}{p0`Rawj{%+&@B@Qrfq8*}{!YLw>3}u>5GfJf zAb~)^0120D3$$|sBRv64?m#aGpmYIby94}ZfE@UYxc`gJUsC?RxKIKM4>$%W8YtoG z4)+1Ljp4jMf%8uSD6s*g1rY8R$bg;Tc6SG8^#A4qAP_1b>94hg*CD@p!)*@t0l2Ov zp!_F)qX61O08|Cg1wbrdW&V>_0q>-Mo`3Qi_}naj)pP}X$pxUCffWUT@cuml3~aC1|5ZL*4qQIGgwG$ogM_d7FNHw)C&2Z10F^)6w~w$<-_an@}Ku)SU{_llLZ6K(MwoF?jJA0{cb$hyDX7=GrJUa((o ziCyGXIqi9%t9Kx(_f%MKe7ZJ7#C4Lx-lU!l@d;P5jI++0h#N&r#wvf>$MJ|e=iaUl z3z!-hB~QK_wyg>tdXEf~?r+SG=Xg&wN?tF~Jbf$0Eje{O5w_|i0eiJ275sIe<9oo} zT1Ac=6TMMxGl$adpB9^=7M*Vu!C_?KpLF}rk$>oPGX6vs^ngFxwh5L%JBLkzkMI`2arq32yo&=x2XOa~v#yoi1FJnA`~ zDdOSt!?46hq}~F!>Y?dfja^bmkwM>)^H7JN{7sl&knTXYNIga|AG;)=(zT!pEzev1--U2qUh z#Vai}kMJFZ!5mv0nHn`Q1f>>zG{mm!1Zo4ryl{s3L!Du0(6O#7sOSaX1sGpFpKBcH zL+FY*F(ryds5ong095>f{sPC9q!r~0N-#`TNIbE0jeFsNxQ*#mP^`nd2V29F5y0?y89 zW>w!s(8ou@ywZ5vOb^F|Lq^Q8wvlekK~5;%&z+x{V24tYBZ_sZyRtN4E#rJgI0t5i zM~=z>)iI|ON5>6ib4Bl&xy*KkqhnVY!nA2A5{@Od~dWO|zTB3<-GtYzMQ&&nqCsgQ+l*xLUDAuF- z;-?(=awlmN5)A1C{j`fXl-rpvSx$N#`DNtkWY;m-_7V%XcrX`~(j)c@3}VK346Bs} z!;?ppnNyv*c9ZK&28jcW2FL^T2T+;1Q^@#LIvA~^l9^2i&pV`3!19537z=8N==Vxq zDd!T_MdWH7qc8=lRUXx|u121&$?SS)RbIu_Hgfoj*{+eiBNdI1C|Xprm>~;~d&Occ z_u79sh?*aVi*Z&lB}khb-@s4$kS0(_VVkKXwnss-CG$zW=W4Mx5iEuow!tlH3^U8WUZY zB;@SC3!v!>qvBujAi9f!Nnr+;%8s7@xV;Nq4dt1o3*5V*)BQLwJ3!!rK z$14kryQ6~gh29x%fc4Hlut8};?x-$CaGQBd zO-P5qSsv?89lFf%!?A((R6_4qz4y=Z$`_R88SEqGe!42;BD9f@B{x$!HX0mCSFgZ6 z#`sm9MZR>zogx?9UU;cc{gB>6xJ!fVyJOFn!Hww8-H5)D5k?eGYG##-@?Au`p1wz` z7*KPP-ix=afu0);4?xBQ?rj!i+dtB{PupA*a$w1~E&9Dcnj=#!+KIuYp)F-bN~2^1 zUA^%(@5ukv`H8B+XcDPVbkH)#>FO z%%f$~7I|^Aj))s?u*Kaa*FL!B{YkXzm9T*O0j1TG^O%>go$|5ZCL>fli`JUR7i)QC z?>vYeURc#*{UqR^C^DT5%b3aUp|13h^op+S>n}qi*jx+WuA8!@+Jz8_$?vS3c8&SJ z%NwXmnyJgcx6W=g%sgdra3xvlHp+VE^(9M*8pr zuMUM>oEdS{^MGzwA~By;>>xbU&xb+<_0L`v)uDc8iIbGymCs9)%WHq}(KqkY%WFxe z(-xdux63Tng(#g`1F)jmB&#ROrqkf^i&wS1w2-x=s)2{kuVNg!DZq!gd{M?)zx^c zq`ejE0W&U4u=r<88!HLcA+F-K$2acEu}D|Qyv8z`W7wA}aQ-Y0r5 zs?d+W)8MK3`SZgAW}ynt&Ys)mw`c7;a=H=*%2!i@{;KmoMPR4O`jT_iBbNr#!#ULP z6T5L^1uYeqr}in5&4k#e?PM+T$*Gar^@&XWLPFrew?Y!6g|ebk$%8xZO2?l%?O_n0 z-R9oA#*zoCvdk8V%@tkE6-DW0l|7Jpb=Gy(`7uDXjjVEWs;3$J!pC)?BEUGcRd$d| z8JD%03${EuEr6_Zz)xVsW~?W9TiHDg5!V@mskROA{@AaMd0(q?T7<`ywHs(&5%<#Y zNOckwtH-@ZfmQrllR65A#UU?~LWTM}-pyvn=rvTyaj+nbZ%ELj#&R=oseInH>qzZI z;!*h8=+Ij?^MKQdt1`&)xx}jYdnmEqk-7a?Shb8W5 zu@|(Ix!L!SFM+6;RlupfUw(alINF8{Lz6ZEWpS<|cpdZDHTCR>d9HjxfqA4DIevnjd z>_&QEoFY;l$$Wy5o$*}Uugj}$9Fb4fdYsU2KaW)1X_9&WKiOiw!gQk&xv|F9NYMQ2eOMSMx!nbQIFd z=AvV(CxAsQRipI`!QIvM4}eG0`QO^od_rUisRK?I{IAr3+>#}{m71u7hCYCj|qv|@F1 z(#QMb?bcV)OfNWc*5B;lQb18Cb8>BEBS)V+DG_VH+K+j>rsFfO7~vMA1XIE4=((^F zBQg-5mVRxS7yrQTUYd(LjP_b|C>Wti#OO?;;njWao%}7sOYdCEDz$VEui_hM?v=;Q z!?di)og*>a-NlC=tgOFfNJq{_;C{CzGqhgvlBgdSD1AS6N;&1gEKza4=6w+9q@d%J z11bN5?(sC?@P^v2+kWj}Ow<+LkI?Tj96tG91GCIK;?##n%dMk??lK>*4V)dYw`H37 zUD$xgW|w*vvmeLS^1S!Zu9(7&;^*KL)R{_{t1od}tg+X!KEy7%Kc%)K_t7xTg(L{) zmXE9N=But~QOS4p1LcFq_lzWj-+3sc+3b*d(Wo5sR?9E?2F;A^p(x~^Xu5UXU-wO; zAmLHS+}_fcnM;m)kYi&x9-Q)^J~ho}TZW_1v5LajVGVC-1oqTnjj3Lh@Ulf_PJpY` z<9;v-mJRu>u`f=6-2M7gFUx$*b@}%cxEAXgEt1BtU>*#W;DL9XhxP(3;j{GWtao$-MPjD^FwMfiNeNZY|rLOR-XDBSSq1& zQU(z-TE}U^I$dTo8Hsc=CJ9p{Om=il9%{WU?vPR%Xxd>n@wvYovsm-^so_gjzx?uL zn@0;$WC^nAW*J$|OVybC?#C{tJrUQ7LF%`m;$#?U%;=fA=yo$yZ0E}d8e*|bG5b~? zSiWwa+-|3_kF}emWV6uCIIap(XDe|qmbGYcg^qHpL|8uOhPL_ccObwGyT5nDNd_b* z@&Wv3!@@HR|2C|wytXd4j)L>35;*h`2n8w@a~EQjjf(6{tmbF_z7&Csp38KY7h6Qs zNgyn=qrF(AptvzTy_ILI-vQT0Es2=+vujeKyq>;js1gB`H7)L}1wZdpyci`RPb~Cs zyWILKF&}p`ZU3NPTrV@8e!51ZYMaN9M%2M@fP;UQWN6g4Dh3Z^JZpQ-TlQdq=D`6f z5mLMScW&oc5FL3_6ZI6owyvJQIc1?XH&)A)j8K7S^j*s)hBu>MskNGir_jc=FVzL* zY0{*h*3smHF4U{|_9w$*!pMxz-*Mm^*&QWw&9mb7JN(?>;e38(KvQH$fj$s*4~e7A~d%dEi6Od7nT zpo3)EM4_Pnh7?kZF`Uoo^hq_pD-sbkfEG8Mx5dg-Su4o)MSeyI`r~lJ3^MitXmXZr zmJ2P*sH45LxwXBmwY9am6E)MQBjP&6lSj?#_m@Rz7UJV7v4`W^uuoOSe?R*MEe8~fg!Okks6D2N@~o)K5T^<%ksSMTNP5t0@ z*Y6`|?q!e~{rwYR!_U=$qHS#r3M#7_LW7t2`-}ehb9#w8Lr#nS#^viE$MQDK549g| zUVln!-gTVgpD%Xf`C7w&8|>I01Dah&;|OY(=m}&zpu1Y?U}stxSiPBw7`GPh@1 zeIF|S^2XGHT4m&J*uv=5vqo|uNk01kbX7dmGix;Q2Z6LqEYtSp4oli-h)#xrf}?uIQ2&`XEq{Yim4?zEqc z24A;gM{ZUsC~WLDmr0tsh<+ZAAJ1y+<+-dyZ?t^Y6tzp7&ovg_NTlKns1I`hd(Su& znl_HsBysJLR$bTnj7IDy_UD;#lL(;!Api}16z3A(49Fj%135!Y6$C^~&>x2!|LJ=s zz(s$9|LyB0xHMuQh5P5x`tJwC@Wkj}&<*6O|EGiYzf1c4SO$Ke{1=d+{m0|w-*424l?>HLH&OK%L#iP literal 0 HcmV?d00001