From 42a014f32aa76be7eb54cd3522214110cd3c4c38 Mon Sep 17 00:00:00 2001
From: akwizgran <michael@briarproject.org>
Date: Tue, 28 Mar 2023 16:44:54 +0100
Subject: [PATCH] Add BridgeTest.

---
 onionwrapper-core/build.gradle                |  12 ++
 .../briarproject/onionwrapper/TestUtils.java  |  70 +++++++
 onionwrapper-java/build.gradle                |   6 +
 .../briarproject/onionwrapper/BridgeTest.java | 186 ++++++++++++++++++
 .../briarproject/onionwrapper/Multiset.java   | 102 ++++++++++
 5 files changed, 376 insertions(+)
 create mode 100644 onionwrapper-core/src/test/java/org/briarproject/onionwrapper/TestUtils.java
 create mode 100644 onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BridgeTest.java
 create mode 100644 onionwrapper-java/src/test/java/org/briarproject/onionwrapper/Multiset.java

diff --git a/onionwrapper-core/build.gradle b/onionwrapper-core/build.gradle
index 0a83e78..a5fd862 100644
--- a/onionwrapper-core/build.gradle
+++ b/onionwrapper-core/build.gradle
@@ -15,3 +15,15 @@ dependencies {
 
     testImplementation "junit:junit:4.13.2"
 }
+
+// Make test classes available to other modules
+configurations {
+    testOutput.extendsFrom(testCompile)
+}
+task jarTest(type: Jar, dependsOn: testClasses) {
+    from sourceSets.test.output, sourceSets.main.output
+    classifier = 'test'
+}
+artifacts {
+    testOutput jarTest
+}
diff --git a/onionwrapper-core/src/test/java/org/briarproject/onionwrapper/TestUtils.java b/onionwrapper-core/src/test/java/org/briarproject/onionwrapper/TestUtils.java
new file mode 100644
index 0000000..028916e
--- /dev/null
+++ b/onionwrapper-core/src/test/java/org/briarproject/onionwrapper/TestUtils.java
@@ -0,0 +1,70 @@
+package org.briarproject.onionwrapper;
+
+import org.briarproject.nullsafety.NotNullByDefault;
+
+import java.io.File;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Logger;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
+
+import static java.util.logging.Level.WARNING;
+import static java.util.logging.Logger.getLogger;
+
+@ThreadSafe
+@NotNullByDefault
+public class TestUtils {
+
+	private static final Logger LOG = getLogger(TestUtils.class.getName());
+
+	private static final AtomicInteger nextTestDir = new AtomicInteger(0);
+
+	public static File getTestDirectory() {
+		return new File("test.tmp/" + nextTestDir.getAndIncrement());
+	}
+
+	public static void deleteTestDirectory(File testDir) {
+		deleteFileOrDir(testDir);
+		// Delete test.tmp if empty
+		testDir.getParentFile().delete();
+	}
+
+	public static void deleteFileOrDir(File f) {
+		if (f.isFile()) {
+			delete(f);
+		} else if (f.isDirectory()) {
+			File[] children = f.listFiles();
+			if (children == null) {
+				if (LOG.isLoggable(WARNING)) {
+					LOG.warning("Could not list files in " + f.getAbsolutePath());
+				}
+			} else {
+				for (File child : children) deleteFileOrDir(child);
+			}
+			delete(f);
+		}
+	}
+
+	public static void delete(File f) {
+		if (!f.delete() && LOG.isLoggable(WARNING)) {
+			LOG.warning("Could not delete " + f.getAbsolutePath());
+		}
+	}
+
+	public static boolean isLinux() {
+		String os = System.getProperty("os.name");
+		return os != null && os.contains("Linux");
+	}
+
+	@Nullable
+	public static String getArchitectureForTorBinary() {
+		String arch = System.getProperty("os.arch");
+		if (arch == null) return null;
+		//noinspection IfCanBeSwitch
+		if (arch.equals("amd64")) return "x86_64";
+		else if (arch.equals("aarch64")) return "aarch64";
+		else if (arch.equals("arm")) return "armhf";
+		return null;
+	}
+}
diff --git a/onionwrapper-java/build.gradle b/onionwrapper-java/build.gradle
index 9e35b43..f8e7667 100644
--- a/onionwrapper-java/build.gradle
+++ b/onionwrapper-java/build.gradle
@@ -12,4 +12,10 @@ dependencies {
     def jna_version = '4.5.2'
     implementation "net.java.dev.jna:jna:$jna_version"
     implementation "net.java.dev.jna:jna-platform:$jna_version"
+
+    testImplementation project(path: ':onionwrapper-core', configuration: 'testOutput')
+    testImplementation 'junit:junit:4.13.2'
+    testImplementation 'org.briarproject:tor-linux:0.4.7.13-2'
+    testImplementation 'org.briarproject:obfs4proxy-linux:0.0.14-tor2'
+    testImplementation 'org.briarproject:snowflake-linux:2.5.1'
 }
diff --git a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BridgeTest.java b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BridgeTest.java
new file mode 100644
index 0000000..d49f4ff
--- /dev/null
+++ b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BridgeTest.java
@@ -0,0 +1,186 @@
+package org.briarproject.onionwrapper;
+
+import org.briarproject.onionwrapper.CircumventionProvider.BridgeType;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ExecutorService;
+import java.util.logging.Logger;
+
+import javax.annotation.concurrent.GuardedBy;
+
+import static java.util.Collections.singletonList;
+import static java.util.concurrent.Executors.newCachedThreadPool;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.logging.Logger.getLogger;
+import static org.briarproject.nullsafety.NullSafety.requireNonNull;
+import static org.briarproject.onionwrapper.CircumventionProvider.BridgeType.DEFAULT_OBFS4;
+import static org.briarproject.onionwrapper.CircumventionProvider.BridgeType.MEEK;
+import static org.briarproject.onionwrapper.CircumventionProvider.BridgeType.NON_DEFAULT_OBFS4;
+import static org.briarproject.onionwrapper.CircumventionProvider.BridgeType.SNOWFLAKE;
+import static org.briarproject.onionwrapper.CircumventionProvider.BridgeType.VANILLA;
+import static org.briarproject.onionwrapper.TestUtils.deleteTestDirectory;
+import static org.briarproject.onionwrapper.TestUtils.getArchitectureForTorBinary;
+import static org.briarproject.onionwrapper.TestUtils.getTestDirectory;
+import static org.briarproject.onionwrapper.TestUtils.isLinux;
+import static org.briarproject.onionwrapper.TorWrapper.TorState.CONNECTED;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeNotNull;
+import static org.junit.Assume.assumeTrue;
+
+@RunWith(Parameterized.class)
+public class BridgeTest extends BaseTest {
+
+	private final static Logger LOG = getLogger(BridgeTest.class.getName());
+
+	private static final String[] SNOWFLAKE_COUNTRY_CODES = {"TM", "ZZ"};
+	private static final int SOCKS_PORT = 59060;
+	private static final int CONTROL_PORT = 59061;
+	private final static long TIMEOUT = MINUTES.toMillis(2);
+	private final static long MEEK_TIMEOUT = MINUTES.toMillis(6);
+	private final static int UNREACHABLE_BRIDGES_ALLOWED = 6;
+	private final static int ATTEMPTS_PER_BRIDGE = 5;
+
+	@Parameters
+	public static Iterable<Params> data() {
+		// Share stats among all the test instances
+		Stats stats = new Stats();
+		CircumventionProvider provider = new CircumventionProviderImpl();
+		List<Params> states = new ArrayList<>();
+		for (int i = 0; i < ATTEMPTS_PER_BRIDGE; i++) {
+			for (String bridge : provider.getBridges(DEFAULT_OBFS4, "", true)) {
+				states.add(new Params(bridge, DEFAULT_OBFS4, stats, false));
+			}
+			for (String bridge : provider.getBridges(NON_DEFAULT_OBFS4, "", true)) {
+				states.add(new Params(bridge, NON_DEFAULT_OBFS4, stats, false));
+			}
+			for (String bridge : provider.getBridges(VANILLA, "", true)) {
+				states.add(new Params(bridge, VANILLA, stats, false));
+			}
+			for (String bridge : provider.getBridges(MEEK, "", true)) {
+				states.add(new Params(bridge, MEEK, stats, true));
+			}
+			for (String countryCode : SNOWFLAKE_COUNTRY_CODES) {
+				for (String bridge : provider.getBridges(SNOWFLAKE, countryCode, true)) {
+					states.add(new Params(bridge, SNOWFLAKE, stats, true));
+				}
+				for (String bridge : provider.getBridges(SNOWFLAKE, countryCode, false)) {
+					states.add(new Params(bridge, SNOWFLAKE, stats, true));
+				}
+			}
+		}
+		return states;
+	}
+
+	private final ExecutorService executor = newCachedThreadPool();
+	private final File torDir = getTestDirectory();
+	private final Params params;
+
+	public BridgeTest(Params params) {
+		this.params = params;
+	}
+
+	@Before
+	public void setUp() {
+		assumeTrue(isLinux());
+		assumeNotNull(getArchitectureForTorBinary());
+	}
+
+	@After
+	public void tearDown() {
+		deleteTestDirectory(torDir);
+		executor.shutdown();
+	}
+
+	@Test
+	public void testBridges() throws Exception {
+		if (params.stats.hasSucceeded(params.bridge)) {
+			LOG.info("Skipping previously successful bridge: " + params.bridge);
+			return;
+		}
+
+		String architecture = requireNonNull(getArchitectureForTorBinary());
+		TorWrapper tor = new UnixTorWrapper(executor, executor, architecture, torDir,
+				CONTROL_PORT, SOCKS_PORT);
+
+		LOG.warning("Testing " + params.bridge);
+		try {
+			tor.start();
+			tor.enableBridges(singletonList(params.bridge));
+			tor.enableNetwork(true);
+			long start = System.currentTimeMillis();
+			long timeout = params.bridgeType == MEEK ? MEEK_TIMEOUT : TIMEOUT;
+			while (System.currentTimeMillis() - start < timeout) {
+				if (tor.getTorState() == CONNECTED) break;
+				//noinspection BusyWait
+				Thread.sleep(500);
+			}
+			if (tor.getTorState() == CONNECTED) {
+				LOG.info("Connected to Tor: " + params.bridge);
+				params.stats.countSuccess(params.bridge);
+			} else {
+				LOG.warning("Could not connect to Tor within timeout: " + params.bridge);
+				params.stats.countFailure(params.bridge, params.essential);
+			}
+		} finally {
+			tor.stop();
+		}
+	}
+
+	private static class Params {
+
+		private final String bridge;
+		private final BridgeType bridgeType;
+		private final Stats stats;
+		private final boolean essential;
+
+		private Params(String bridge, BridgeType bridgeType, Stats stats, boolean essential) {
+			this.bridge = bridge;
+			this.bridgeType = bridgeType;
+			this.stats = stats;
+			this.essential = essential;
+		}
+	}
+
+	private static class Stats {
+
+		@GuardedBy("this")
+		private final Set<String> successes = new HashSet<>();
+		@GuardedBy("this")
+		private final Multiset<String> failures = new Multiset<>();
+		@GuardedBy("this")
+		private final Set<String> unreachable = new TreeSet<>();
+
+		private synchronized boolean hasSucceeded(String bridge) {
+			return successes.contains(bridge);
+		}
+
+		private synchronized void countSuccess(String bridge) {
+			successes.add(bridge);
+		}
+
+		private synchronized void countFailure(String bridge, boolean essential) {
+			if (failures.add(bridge) == ATTEMPTS_PER_BRIDGE) {
+				LOG.warning("Bridge is unreachable after "
+						+ ATTEMPTS_PER_BRIDGE + " attempts: " + bridge);
+				unreachable.add(bridge);
+				if (unreachable.size() > UNREACHABLE_BRIDGES_ALLOWED) {
+					fail(unreachable.size() + " bridges are unreachable: " + unreachable);
+				}
+				if (essential) {
+					fail("essential bridge is unreachable");
+				}
+			}
+		}
+	}
+}
diff --git a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/Multiset.java b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/Multiset.java
new file mode 100644
index 0000000..3a05962
--- /dev/null
+++ b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/Multiset.java
@@ -0,0 +1,102 @@
+package org.briarproject.onionwrapper;
+
+import org.briarproject.nullsafety.NotNullByDefault;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Set;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+@NotThreadSafe
+@NotNullByDefault
+public class Multiset<T> {
+
+	private final Map<T, Integer> map = new HashMap<>();
+
+	private int total = 0;
+
+	/**
+	 * Returns how many items the multiset contains in total.
+	 */
+	public int getTotal() {
+		return total;
+	}
+
+	/**
+	 * Returns how many unique items the multiset contains.
+	 */
+	public int getUnique() {
+		return map.size();
+	}
+
+	/**
+	 * Returns how many of the given item the multiset contains.
+	 */
+	public int getCount(T t) {
+		Integer count = map.get(t);
+		return count == null ? 0 : count;
+	}
+
+	/**
+	 * Adds the given item to the multiset and returns how many of the item
+	 * the multiset now contains.
+	 */
+	public int add(T t) {
+		Integer count = map.get(t);
+		if (count == null) count = 0;
+		map.put(t, count + 1);
+		total++;
+		return count + 1;
+	}
+
+	/**
+	 * Removes the given item from the multiset and returns how many of the
+	 * item the multiset now contains.
+	 *
+	 * @throws NoSuchElementException if the item is not in the multiset.
+	 */
+	public int remove(T t) {
+		Integer count = map.get(t);
+		if (count == null) throw new NoSuchElementException();
+		if (count == 1) map.remove(t);
+		else map.put(t, count - 1);
+		total--;
+		return count - 1;
+	}
+
+	/**
+	 * Removes all occurrences of the given item from the multiset.
+	 */
+	public int removeAll(T t) {
+		Integer count = map.remove(t);
+		if (count == null) return 0;
+		total -= count;
+		return count;
+	}
+
+	/**
+	 * Returns true if the multiset contains any occurrences of the given item.
+	 */
+	public boolean contains(T t) {
+		return map.containsKey(t);
+	}
+
+	/**
+	 * Removes all items from the multiset.
+	 */
+	public void clear() {
+		map.clear();
+		total = 0;
+	}
+
+	/**
+	 * Returns the set of unique items the multiset contains. The returned set
+	 * is unmodifiable.
+	 */
+	public Set<T> keySet() {
+		return Collections.unmodifiableSet(map.keySet());
+	}
+}
-- 
GitLab