proxyHostAndPort) {
+ if (connectionType == null) {
+ throw new IllegalArgumentException("Connection type must not be null");
+ }
+ if (proxyHostAndPort == null) {
+ throw new IllegalArgumentException("Proxy must not be null");
+ }
+ if (connectionType != ConnectionType.DIRECT && !proxyHostAndPort.isPresent()) {
+ throw new IllegalArgumentException(String.format("When connection type is not %s proxy is required", ConnectionType.DIRECT));
+ }
+ this.connectionType = connectionType;
+ this.proxyHostAndPort = proxyHostAndPort;
+ }
+
+ /**
+ * Gets the connection type component of the directive, e.g. SOCKS
+ *
+ * @return the connection type for this directive.
+ */
+ public ConnectionType connectionType() {
+ return connectionType;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ FindProxyDirective that = (FindProxyDirective) o;
+ return connectionType == that.connectionType && Objects.equals(proxyHostAndPort, that.proxyHostAndPort);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(connectionType, proxyHostAndPort);
+ }
+
+ /**
+ * Tests whether this directive has connection type {@link ConnectionType#DIRECT}.
+ *
+ * @return true if this directive has a direct connection type; false otherwise.
+ */
+ public boolean isDirect() {
+ return connectionType == ConnectionType.DIRECT;
+ }
+
+ /**
+ * Tests whether this directive has connection type other than {@link ConnectionType#DIRECT}.
+ *
+ * @return true if this directive has a proxy connection type; false if the connection type is direct.
+ */
+ public boolean isProxy() {
+ return !isDirect();
+ }
+
+ /**
+ * Gets the proxy host component of the directive, e.g. "192.168.1.1"
+ *
+ * @return the proxy host for this directive, or null if the connection type is {@link ConnectionType#DIRECT}.
+ */
+ public String proxyHost() {
+ return Optional.ofNullable(unresolvedProxyAddress()).map(InetSocketAddress::getHostString)
+ .orElse(null);
+ }
+
+ /**
+ * Gets the proxy port component of the directive, e.g. 8080.
+ *
+ * @return the proxy port for this directive, or null if the connection type is {@link ConnectionType#DIRECT}.
+ */
+ public Integer proxyPort() {
+ return Optional.ofNullable(unresolvedProxyAddress()).map(InetSocketAddress::getPort)
+ .orElse(null);
+ }
+
+ /**
+ * Gets the proxy and host component of the directive, e.g. "10.1.1.1:8080"
+ *
+ * @return the proxy:host for this directive, or null if the connection type is {@link ConnectionType#DIRECT}.
+ */
+ public String proxyHostAndPort() {
+ return proxyHostAndPort.orElse(null);
+ }
+
+ /**
+ * Get the proxy address associated with this directive.
+ *
+ * Note that if the proxy host is a hostname, it will be resolved to an IP address by this method.
+ * To create an unresolved {@link InetSocketAddress}, use {@link #unresolvedProxyAddress()} instead.
+ *
+ * @return the proxy address, or null if the connection type is {@link ConnectionType#DIRECT}.
+ * @see #unresolvedProxyAddress()
+ */
+ public InetSocketAddress resolvedProxyAddress() {
+ return Optional.ofNullable(unresolvedProxyAddress())
+ .map(unresolved -> new InetSocketAddress(unresolved.getHostString(), unresolved.getPort()))
+ .orElse(null);
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder(connectionType.name());
+ proxyHostAndPort.ifPresent(proxy -> builder.append(" ").append(proxy));
+ return builder.toString();
+ }
+
+ /**
+ * Get the proxy address associated with this directive.
+ *
+ * Note that the {@link InetSocketAddress} returned by this method is created via
+ * {@link InetSocketAddress#createUnresolved(String, int)}. To create a resolved {@link InetSocketAddress}
+ * use {@link #resolvedProxyAddress()} instead.
+ *
+ * @return the proxy address, or null if the connection type is {@link ConnectionType#DIRECT}.
+ * @see #resolvedProxyAddress()
+ */
+ public InetSocketAddress unresolvedProxyAddress() {
+ return proxyHostAndPort.map(hostAndPort -> {
+ final String[] hostPortParts = hostAndPort.split(HOST_PORT_DELIMITER);
+ final String host = hostPortParts[0];
+ final int port = Integer.parseInt(hostPortParts[1]);
+ return InetSocketAddress.createUnresolved(host, port);
+ }).orElse(null);
+ }
+
+ /**
+ * Parses a single proxy directive.
+ *
+ * @param value the value to parse.
+ * @return the parsed @{@link FindProxyDirective}.
+ * @throws PacInterpreterException if the given value cannot be parsed.
+ */
+ public static FindProxyDirective parse(final String value) throws PacInterpreterException {
+ if (value == null) {
+ return new FindProxyDirective(ConnectionType.DIRECT);
+ }
+ final Matcher matcher = RESULT_PATTERN.matcher(value.trim());
+ if (!matcher.matches()) {
+ throw new PacInterpreterException(String.format("Invalid proxy find result: \"%s\"", value));
+ }
+
+ final ConnectionType connectionType;
+ try {
+ connectionType = ConnectionType.fromValue(matcher.group(1));
+ } catch (IllegalArgumentException e) {
+ // This shouldn't really happen because the regular expression is built to only accept valid connection types
+ throw new PacInterpreterException(String.format("Failed to parse connection type from \"%s\"", value), e);
+ }
+
+ if (connectionType == ConnectionType.DIRECT) {
+ return new FindProxyDirective(connectionType);
+ }
+
+ final String proxyHostAndPort = matcher.group(2).trim();
+ return new FindProxyDirective(connectionType, proxyHostAndPort);
+ }
+}
diff --git a/src/main/java/com/mabl/net/proxy/FindProxyResult.java b/src/main/java/com/mabl/net/proxy/FindProxyResult.java
new file mode 100644
index 0000000..2665d68
--- /dev/null
+++ b/src/main/java/com/mabl/net/proxy/FindProxyResult.java
@@ -0,0 +1,138 @@
+package com.mabl.net.proxy;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Random;
+import java.util.stream.Collectors;
+
+/**
+ * Represents the result from invoking the FindProxyForURL function with a given URL.
+ */
+public class FindProxyResult implements Iterable {
+ private static final String PROXY_RESULT_SEPARATOR = ";";
+ private static final Random random = new Random();
+ private final List directives;
+
+ private FindProxyResult(final List directives) {
+ if (directives == null) {
+ throw new IllegalArgumentException("Directives must not be null");
+ }
+ this.directives = Collections.unmodifiableList(directives);
+ }
+
+ /**
+ * Gets all proxy directives contained in this result.
+ *
+ * @return the list of all directives.
+ */
+ public List all() {
+ return directives;
+ }
+
+ /**
+ * Gets the first proxy directive contained in this result.
+ *
+ * @return the first directive.
+ */
+ public FindProxyDirective first() {
+ return directives.get(0);
+ }
+
+ /**
+ * Finds the first proxy directive in this result with a connection type other than {@link ConnectionType#DIRECT}.
+ *
+ * @return the first directive with a non-direct connection type, if any.
+ */
+ public Optional firstProxy() {
+ return directives.stream()
+ .filter(directive -> directive.connectionType() != ConnectionType.DIRECT)
+ .findFirst();
+ }
+
+ /**
+ * Gets the proxy directive with the given index.
+ *
+ * @param index the index of the directive to retrieve (valid values: [0, size() - 1])
+ * @return the directive at the given index.
+ */
+ public FindProxyDirective get(final int index) {
+ return directives.get(index);
+ }
+
+ @Override
+ public Iterator iterator() {
+ return directives.iterator();
+ }
+
+ /**
+ * Creates a normalized copy of this {@link FindProxyResult} by removing all non-unique proxy directives
+ * while maintaining the original relative ordering of the directives.
+ *
+ * @return a normalized copy of this result.
+ */
+ public FindProxyResult normalize() {
+ return new FindProxyResult(new ArrayList<>(new LinkedHashSet<>(directives)));
+ }
+
+ /**
+ * Gets a random proxy directive from this result.
+ *
+ * @return a random directive.
+ */
+ public FindProxyDirective random() {
+ return get(random.nextInt(size()));
+ }
+
+ /**
+ * Gets the number of proxy directives contained in this result.
+ *
+ * @return the number of directives.
+ */
+ public int size() {
+ return directives.size();
+ }
+
+ @Override
+ public String toString() {
+ return directives.stream()
+ .map(FindProxyDirective::toString)
+ .collect(Collectors.joining(PROXY_RESULT_SEPARATOR + " "));
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ FindProxyResult that = (FindProxyResult) o;
+ return Objects.equals(directives, that.directives);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(directives);
+ }
+
+ /**
+ * Parses the result from the output of the FindProxyForURL function.
+ *
+ * @param result the output from the FindProxyForURL function.
+ * @return the @{@link FindProxyResult} obtained from parsing the result.
+ * @throws PacInterpreterException if the given result cannot be parsed.
+ */
+ public static FindProxyResult parse(final String result) throws PacInterpreterException {
+ final List directives = new ArrayList<>();
+ if (result != null) {
+ for (final String directive : result.split(PROXY_RESULT_SEPARATOR)) {
+ directives.add(FindProxyDirective.parse(directive));
+ }
+ } else {
+ directives.add(FindProxyDirective.parse(null));
+ }
+ return new FindProxyResult(directives);
+ }
+}
diff --git a/src/main/java/com/mabl/net/proxy/PacInterpreter.java b/src/main/java/com/mabl/net/proxy/PacInterpreter.java
new file mode 100644
index 0000000..eb6f364
--- /dev/null
+++ b/src/main/java/com/mabl/net/proxy/PacInterpreter.java
@@ -0,0 +1,33 @@
+package com.mabl.net.proxy;
+
+import java.net.MalformedURLException;
+
+public interface PacInterpreter {
+ /**
+ * Gets the PAC that is in use by this @{@link SimplePacInterpreter}.
+ *
+ * @return the PAC contents.
+ */
+ String getPac();
+
+ /**
+ * Evaluates the PAC script for the given URL.
+ * Automatically parses the host from the URL before passing it to the PAC script.
+ *
+ * @param url the URL to evaluate.
+ * @return the result of executing the PAC script with the given URL.
+ * @throws MalformedURLException if the URL cannot be parsed.
+ * @throws PacInterpreterException if an error occurs evaluating the PAC script or parsing the results.
+ */
+ FindProxyResult findProxyForUrl(final String url) throws MalformedURLException, PacInterpreterException;
+
+ /**
+ * Evaluates the PAC script for the given URL and host.
+ *
+ * @param url the URL to evaluate.
+ * @param host the host component of the URL (the URL substring between :// and the first : or /).
+ * @return the result of executing the PAC script with the given URL and host.
+ * @throws PacInterpreterException if an error occurs evaluating the PAC script or parsing the results.
+ */
+ FindProxyResult findProxyForUrl(final String url, final String host) throws PacInterpreterException;
+}
diff --git a/src/main/java/com/mabl/net/proxy/PacInterpreterException.java b/src/main/java/com/mabl/net/proxy/PacInterpreterException.java
new file mode 100644
index 0000000..6c0e330
--- /dev/null
+++ b/src/main/java/com/mabl/net/proxy/PacInterpreterException.java
@@ -0,0 +1,13 @@
+package com.mabl.net.proxy;
+
+public class PacInterpreterException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public PacInterpreterException(final String message) {
+ super(message);
+ }
+
+ public PacInterpreterException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/mabl/net/proxy/ReloadablePacInterpreter.java b/src/main/java/com/mabl/net/proxy/ReloadablePacInterpreter.java
new file mode 100644
index 0000000..05e6e92
--- /dev/null
+++ b/src/main/java/com/mabl/net/proxy/ReloadablePacInterpreter.java
@@ -0,0 +1,163 @@
+package com.mabl.net.proxy;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.time.Duration;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+/**
+ * A {@link PacInterpreter} that allows the PAC script to be reloaded explicitly or automatically with a specified period.
+ *
+ * After creating a {@link ReloadablePacInterpreter}, use the {@link #reload()} method to immediately reload the PAC.
+ * Alternatively, use the {@link #start(Duration)} method to begin automatic reloads and the {@link #stop()} method to terminate the reload timer.
+ *
+ *
+ * To silence GraalVM warnings set the "polyglot.engine.WarnInterpreterOnly" system property to "false" e.g. -Dpolyglot.engine.WarnInterpreterOnly=false
+ *
+ *
+ * @see "https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling/Proxy_Auto-Configuration_PAC_file"
+ * @see "https://www.graalvm.org/latest/reference-manual/js/FAQ/#warning-implementation-does-not-support-runtime-compilation"
+ */
+public class ReloadablePacInterpreter implements PacInterpreter {
+ private static final Logger logger = LoggerFactory.getLogger(ReloadablePacInterpreter.class);
+ private final Supplier pacInterpreterSupplier;
+ private volatile PacInterpreter pacInterpreter;
+ private ScheduledExecutorService timer; // All access must be synchronized on AutoReloadingPacInterpreter.this
+
+ protected ReloadablePacInterpreter(final Supplier pacInterpreterSupplier) throws PacInterpreterException {
+ if (pacInterpreterSupplier == null) {
+ throw new IllegalArgumentException("PAC interpreter supplier cannot be null");
+ }
+ this.pacInterpreterSupplier = pacInterpreterSupplier;
+ this.pacInterpreter = getPacInterpreter();
+ }
+
+ /**
+ * Starts auto-updates with the given period.
+ *
+ * @param updatePeriod how frequently the PAC should be reloaded.
+ */
+ synchronized public void start(final Duration updatePeriod) {
+ if (timer != null) {
+ return;
+ }
+ timer = Executors.newSingleThreadScheduledExecutor((final Runnable runnable) -> {
+ final Thread thread = new Thread(runnable, ReloadablePacInterpreter.class.getSimpleName() + " Reload Timer");
+ thread.setDaemon(true);
+ return thread;
+ });
+ timer.scheduleWithFixedDelay(this::reloadSafe, updatePeriod.toMillis(), updatePeriod.toMillis(), TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Forces an immediate reload of the backing PAC source.
+ * Calling this method has no effect on the timing of the next scheduled reload or whether the timer is started or stopped.
+ *
+ * @throws PacInterpreterException if an error occurs when reinitializing the underlying {@link PacInterpreter}.
+ */
+ public void reload() throws PacInterpreterException {
+ logger.debug("Reloading PAC");
+ pacInterpreter = getPacInterpreter();
+ logger.debug("PAC reloaded successfully");
+ }
+
+ protected void reloadSafe() {
+ try {
+ reload();
+ } catch (Exception e) {
+ logger.error("Failed to reload PAC: " + e, e);
+ }
+ }
+
+ protected PacInterpreter getPacInterpreter() throws PacInterpreterException {
+ try {
+ return pacInterpreterSupplier.get();
+ } catch (Exception e) {
+ throw new PacInterpreterException(e.getMessage(), e.getCause());
+ }
+ }
+
+ /**
+ * Stops auto-updates.
+ */
+ synchronized public void stop() {
+ if (timer == null) {
+ return;
+ }
+ timer.shutdownNow();
+ timer = null;
+ }
+
+ @Override
+ public String getPac() {
+ return pacInterpreter.getPac();
+ }
+
+ @Override
+ public FindProxyResult findProxyForUrl(final String url) throws MalformedURLException, PacInterpreterException {
+ return pacInterpreter.findProxyForUrl(url);
+ }
+
+ @Override
+ public FindProxyResult findProxyForUrl(final String url, final String host) throws PacInterpreterException {
+ return pacInterpreter.findProxyForUrl(url, host);
+ }
+
+ /**
+ * Creates an {@link ReloadablePacInterpreter} using the given PAC script supplier.
+ *
+ * @param pacScript supplier for the PAC script.
+ * @return a {@link ReloadablePacInterpreter} for the given PAC script.
+ * @throws PacInterpreterException if an error occurs evaluating the PAC script.
+ */
+ public static ReloadablePacInterpreter forScript(final Supplier pacScript) throws PacInterpreterException {
+ return new ReloadablePacInterpreter(() -> {
+ try {
+ return SimplePacInterpreter.forScript(pacScript.get());
+ } catch (Exception e) {
+ throw new RuntimePacInterpreterException(e.getMessage(), e.getCause());
+ }
+ });
+ }
+
+ /**
+ * Creates a {@link ReloadablePacInterpreter} using the given PAC file.
+ *
+ * @param pacFile the PAC file.
+ * @return a {@link ReloadablePacInterpreter} for the given PAC file.
+ * @throws PacInterpreterException if an error occurs evaluating the PAC file.
+ */
+ public static ReloadablePacInterpreter forFile(final File pacFile) throws PacInterpreterException {
+ return new ReloadablePacInterpreter(() -> {
+ try {
+ return SimplePacInterpreter.forFile(pacFile);
+ } catch (Exception e) {
+ throw new RuntimePacInterpreterException(e.getMessage(), e.getCause());
+ }
+ });
+ }
+
+ /**
+ * Creates an {@link ReloadablePacInterpreter} using the given PAC URL.
+ *
+ * @param pacUrl the PAC URL.
+ * @return a {@link ReloadablePacInterpreter} for the given PAC URL.
+ * @throws PacInterpreterException if an error occurs evaluating the PAC URL.
+ */
+ public static ReloadablePacInterpreter forUrl(final URL pacUrl) throws PacInterpreterException {
+ return new ReloadablePacInterpreter(() -> {
+ try {
+ return SimplePacInterpreter.forUrl(pacUrl);
+ } catch (Exception e) {
+ throw new RuntimePacInterpreterException(e.getMessage(), e.getCause());
+ }
+ });
+ }
+}
diff --git a/src/main/java/com/mabl/net/proxy/RuntimePacInterpreterException.java b/src/main/java/com/mabl/net/proxy/RuntimePacInterpreterException.java
new file mode 100644
index 0000000..9bde872
--- /dev/null
+++ b/src/main/java/com/mabl/net/proxy/RuntimePacInterpreterException.java
@@ -0,0 +1,13 @@
+package com.mabl.net.proxy;
+
+public class RuntimePacInterpreterException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ public RuntimePacInterpreterException(final String message) {
+ super(message);
+ }
+
+ public RuntimePacInterpreterException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/mabl/net/proxy/SimplePacInterpreter.java b/src/main/java/com/mabl/net/proxy/SimplePacInterpreter.java
new file mode 100644
index 0000000..c836997
--- /dev/null
+++ b/src/main/java/com/mabl/net/proxy/SimplePacInterpreter.java
@@ -0,0 +1,159 @@
+package com.mabl.net.proxy;
+
+import com.mabl.io.IoUtils;
+import org.graalvm.polyglot.Context;
+import org.graalvm.polyglot.Engine;
+import org.graalvm.polyglot.HostAccess;
+import org.graalvm.polyglot.Value;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * An interpreter for Proxy Auto-Configuration files/URLs.
+ *
+ * To silence GraalVM warnings set the "polyglot.engine.WarnInterpreterOnly" system property to "false" e.g. -Dpolyglot.engine.WarnInterpreterOnly=false
+ *
+ *
+ * @see "https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling/Proxy_Auto-Configuration_PAC_file"
+ * @see "https://www.graalvm.org/latest/reference-manual/js/FAQ/#warning-implementation-does-not-support-runtime-compilation"
+ */
+public class SimplePacInterpreter implements PacInterpreter {
+ private static final String PAC_UTILS_PATH = "/pacUtils.js";
+ private static final String PAC_LANGUAGE_ID = "js";
+ private static final String PAC_FUNCTION_NAME = "FindProxyForURL";
+ private static final List> ALLOWED_JAVA_CLASSES = Collections.unmodifiableList(Arrays.asList(
+ // Allows JavaScript to invoke InetAddress methods (required for DNS/IP utility functions)
+ InetAddress.class
+ ));
+ private static final String PAC_UTILS = readPacUtils();
+ private static final Engine engine = initializeEngine();
+ private final String pac;
+ private final Value findProxyForUrlFunction;
+
+ protected SimplePacInterpreter(final String pac) throws PacInterpreterException {
+ this.pac = validatePac(pac);
+ final Context context = initializeContext();
+
+ // Evaluate the PAC content, and extract a reference to the PAC function:
+ try {
+ context.eval(PAC_LANGUAGE_ID, pac);
+ final Value jsBindings = context.getBindings(PAC_LANGUAGE_ID);
+ this.findProxyForUrlFunction = jsBindings.getMember(PAC_FUNCTION_NAME);
+ } catch (Exception e) {
+ throw new PacInterpreterException("Error evaluating PAC script", e);
+ }
+ }
+
+ private static String validatePac(final String pac) {
+ if (pac == null) {
+ throw new IllegalArgumentException("PAC cannot be null");
+ }
+ if (pac.length() == 0) {
+ throw new IllegalArgumentException("PAC cannot be empty");
+ }
+ if (!pac.contains(PAC_FUNCTION_NAME)) {
+ throw new IllegalArgumentException(String.format("PAC must contain \"%s\" function", PAC_FUNCTION_NAME));
+ }
+ return pac;
+ }
+
+ private static Engine initializeEngine() {
+ return Engine.newBuilder()
+ .build();
+ }
+
+ private static Context initializeContext() {
+ final Context context = Context.newBuilder(PAC_LANGUAGE_ID)
+ .engine(engine)
+ .allowHostAccess(HostAccess.ALL)
+ .allowHostClassLoading(true)
+ .allowHostClassLookup(clazz -> ALLOWED_JAVA_CLASSES.stream()
+ .map(Class::getCanonicalName)
+ .anyMatch(clazz::equals))
+ .allowIO(true)
+ .build();
+
+ // Make PAC utility functions available to the context:
+ context.eval(PAC_LANGUAGE_ID, PAC_UTILS);
+
+ return context;
+ }
+
+ private static String readPacUtils() {
+ try {
+ return IoUtils.readClasspathFileToString(PAC_UTILS_PATH);
+ } catch (IOException e) {
+ // This file is included in the jar, so if we can't open/read it something is seriously wrong.
+ // There is likely nothing the caller can do to handle this, so just rethrow as a runtime exception.
+ throw new RuntimePacInterpreterException(String.format("Failed to read \"%s\" from classpath", PAC_UTILS_PATH), e);
+ }
+ }
+
+ @Override
+ public String getPac() {
+ return pac;
+ }
+
+ @Override
+ public FindProxyResult findProxyForUrl(final String url) throws MalformedURLException, PacInterpreterException {
+ return findProxyForUrl(url, new URL(url).getHost());
+ }
+
+ @Override
+ public FindProxyResult findProxyForUrl(final String url, final String host) throws PacInterpreterException {
+ final String result;
+ try {
+ // Call the PAC function with the given URL:
+ result = findProxyForUrlFunction.execute(
+ Optional.ofNullable(url).orElse(""),
+ Optional.ofNullable(host).orElse(""))
+ .asString();
+ } catch (Exception e) {
+ throw new PacInterpreterException(String.format("Error executing %s", PAC_FUNCTION_NAME), e);
+ }
+ return FindProxyResult.parse(result);
+ }
+
+ /**
+ * Creates a {@link SimplePacInterpreter} using the given PAC script.
+ *
+ * @param pacScript the PAC script.
+ * @return a {@link SimplePacInterpreter} for the given PAC script.
+ * @throws PacInterpreterException if an error occurs evaluating the PAC script.
+ */
+ public static SimplePacInterpreter forScript(final String pacScript) throws PacInterpreterException {
+ return new SimplePacInterpreter(pacScript);
+ }
+
+ /**
+ * Creates a {@link SimplePacInterpreter} using the given PAC file.
+ *
+ * @param pacFile the PAC file.
+ * @return a {@link SimplePacInterpreter} for the given PAC file.
+ * @throws IOException if an error occurs reading the PAC script from the given file.
+ * @throws PacInterpreterException if an error occurs evaluating the PAC file.
+ */
+ public static SimplePacInterpreter forFile(final File pacFile) throws IOException, PacInterpreterException {
+ return forScript(IoUtils.readFileToString(pacFile));
+ }
+
+ /**
+ * Creates a {@link SimplePacInterpreter} using the given PAC URL.
+ *
+ * @param pacUrl the PAC URL.
+ * @return a {@link SimplePacInterpreter} for the given PAC URL.
+ * @throws IOException if an error occurs reading the PAC script from the given URL.
+ * @throws PacInterpreterException if an error occurs evaluating the PAC URL.
+ */
+ public static SimplePacInterpreter forUrl(final URL pacUrl) throws IOException, PacInterpreterException {
+ return forScript(IoUtils.readUrlToString(pacUrl));
+ }
+}
diff --git a/src/main/resources/pacUtils.js b/src/main/resources/pacUtils.js
new file mode 100644
index 0000000..7b3cdfe
--- /dev/null
+++ b/src/main/resources/pacUtils.js
@@ -0,0 +1,252 @@
+// Source: https://github.com/manugarg/pactester/blob/master/pac_utils.js
+
+/* This file is an adaption of netwerk/base/src/nsProxyAutoConfig.js file in
+ * mozilla source code.
+ *
+ * **** BEGIN LICENSE BLOCK ****
+ * Version: LGPL 2.1
+
+ * This file is a free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+
+ * This file is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
+ * USA
+ * **** END LICENSE BLOCK ****
+ *
+ * Original Contributors:
+ * Akhil Arora
+ * Tomi Leppikangas
+ * Darin Fisher
+ * Gagan Saksena 04/24/00
+ *
+ * Adapted for pactester by:
+ * Manu Garg 01/10/2007
+ *
+ * Adapted for GraalVM by:
+ * James Baldassari 07/14/2023
+ */
+
+function dnsDomainIs(host, domain) {
+ return (host.length >= domain.length &&
+ host.substring(host.length - domain.length) == domain);
+}
+function dnsDomainLevels(host) {
+ return host.split('.').length-1;
+}
+function convert_addr(ipchars) {
+ var bytes = ipchars.split('.');
+ var result = ((bytes[0] & 0xff) << 24) |
+ ((bytes[1] & 0xff) << 16) |
+ ((bytes[2] & 0xff) << 8) |
+ (bytes[3] & 0xff);
+ return result;
+}
+function isInNet(ipaddr, pattern, maskstr) {
+ var test = new RegExp("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$").exec(ipaddr);
+ if (test == null) {
+ ipaddr = dnsResolve(ipaddr);
+ if (ipaddr == 'null')
+ return false;
+ } else if (test[1] > 255 || test[2] > 255 ||
+ test[3] > 255 || test[4] > 255) {
+ return false; // not an IP address
+ }
+ var host = convert_addr(ipaddr);
+ var pat = convert_addr(pattern);
+ var mask = convert_addr(maskstr);
+ return ((host & mask) == (pat & mask));
+
+}
+function isPlainHostName(host) {
+ return (host.search('\\.') == -1);
+}
+function isResolvable(host) {
+ var ip = dnsResolve(host);
+ return (ip != 'null');
+}
+function localHostOrDomainIs(host, hostdom) {
+ return (host == hostdom) ||
+ (hostdom.lastIndexOf(host + '.', 0) == 0);
+}
+function shExpMatch(url, pattern) {
+ pattern = pattern.replace(/\./g, '\\.');
+ pattern = pattern.replace(/\*/g, '.*');
+ pattern = pattern.replace(/\?/g, '.');
+ var newRe = new RegExp('^'+pattern+'$');
+ return newRe.test(url);
+}
+var wdays = new Array('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT');
+var monthes = new Array('JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC');
+function weekdayRange() {
+ function getDay(weekday) {
+ for (var i = 0; i < 6; i++) {
+ if (weekday == wdays[i])
+ return i;
+ }
+ return -1;
+ }
+ var date = new Date();
+ var argc = arguments.length;
+ var wday;
+ if (argc < 1)
+ return false;
+ if (arguments[argc - 1] == 'GMT') {
+ argc--;
+ wday = date.getUTCDay();
+ } else {
+ wday = date.getDay();
+ }
+ var wd1 = getDay(arguments[0]);
+ var wd2 = (argc == 2) ? getDay(arguments[1]) : wd1;
+ return (wd1 == -1 || wd2 == -1) ? false
+ : (wd1 <= wday && wday <= wd2);
+}
+function dateRange() {
+ function getMonth(name) {
+ for (var i = 0; i < 6; i++) {
+ if (name == monthes[i])
+ return i;
+ }
+ return -1;
+ }
+ var date = new Date();
+ var argc = arguments.length;
+ if (argc < 1) {
+ return false;
+ }
+ var isGMT = (arguments[argc - 1] == 'GMT');
+
+ if (isGMT) {
+ argc--;
+ }
+ // function will work even without explict handling of this case
+ if (argc == 1) {
+ var tmp = parseInt(arguments[0]);
+ if (isNaN(tmp)) {
+ return ((isGMT ? date.getUTCMonth() : date.getMonth()) ==
+getMonth(arguments[0]));
+ } else if (tmp < 32) {
+ return ((isGMT ? date.getUTCDate() : date.getDate()) == tmp);
+ } else {
+ return ((isGMT ? date.getUTCFullYear() : date.getFullYear()) ==
+tmp);
+ }
+ }
+ var year = date.getFullYear();
+ var date1, date2;
+ date1 = new Date(year, 0, 1, 0, 0, 0);
+ date2 = new Date(year, 11, 31, 23, 59, 59);
+ var adjustMonth = false;
+ for (var i = 0; i < (argc >> 1); i++) {
+ var tmp = parseInt(arguments[i]);
+ if (isNaN(tmp)) {
+ var mon = getMonth(arguments[i]);
+ date1.setMonth(mon);
+ } else if (tmp < 32) {
+ adjustMonth = (argc <= 2);
+ date1.setDate(tmp);
+ } else {
+ date1.setFullYear(tmp);
+ }
+ }
+ for (var i = (argc >> 1); i < argc; i++) {
+ var tmp = parseInt(arguments[i]);
+ if (isNaN(tmp)) {
+ var mon = getMonth(arguments[i]);
+ date2.setMonth(mon);
+ } else if (tmp < 32) {
+ date2.setDate(tmp);
+ } else {
+ date2.setFullYear(tmp);
+ }
+ }
+ if (adjustMonth) {
+ date1.setMonth(date.getMonth());
+ date2.setMonth(date.getMonth());
+ }
+ if (isGMT) {
+ var tmp = date;
+ tmp.setFullYear(date.getUTCFullYear());
+ tmp.setMonth(date.getUTCMonth());
+ tmp.setDate(date.getUTCDate());
+ tmp.setHours(date.getUTCHours());
+ tmp.setMinutes(date.getUTCMinutes());
+ tmp.setSeconds(date.getUTCSeconds());
+ date = tmp;
+ }
+ return ((date1 <= date) && (date <= date2));
+}
+function timeRange() {
+ var argc = arguments.length;
+ var date = new Date();
+ var isGMT= false;
+
+ if (argc < 1) {
+ return false;
+ }
+ if (arguments[argc - 1] == 'GMT') {
+ isGMT = true;
+ argc--;
+ }
+
+ var hour = isGMT ? date.getUTCHours() : date.getHours();
+ var date1, date2;
+ date1 = new Date();
+ date2 = new Date();
+
+ if (argc == 1) {
+ return (hour == arguments[0]);
+ } else if (argc == 2) {
+ return ((arguments[0] <= hour) && (hour <= arguments[1]));
+ } else {
+ switch (argc) {
+ case 6:
+ date1.setSeconds(arguments[2]);
+ date2.setSeconds(arguments[5]);
+ case 4:
+ var middle = argc >> 1;
+ date1.setHours(arguments[0]);
+ date1.setMinutes(arguments[1]);
+ date2.setHours(arguments[middle]);
+ date2.setMinutes(arguments[middle + 1]);
+ if (middle == 2) {
+ date2.setSeconds(59);
+ }
+ break;
+ default:
+ throw 'timeRange: bad number of arguments'
+ }
+ }
+
+ if (isGMT) {
+ date.setFullYear(date.getUTCFullYear());
+ date.setMonth(date.getUTCMonth());
+ date.setDate(date.getUTCDate());
+ date.setHours(date.getUTCHours());
+ date.setMinutes(date.getUTCMinutes());
+ date.setSeconds(date.getUTCSeconds());
+ }
+ return ((date1 <= date) && (date <= date2));
+}
+
+// The following functions rely on GraalVM's Java integration because they require
+// functionality that is not available in pure JavaScript (e.g. host => IP resolution):
+function dnsResolve(host) {
+ return Java.type('java.net.InetAddress')
+ .getByName(host)
+ .getHostAddress();
+}
+function myIpAddress() {
+ return Java.type('java.net.InetAddress')
+ .getLocalHost()
+ .getHostAddress();
+}
diff --git a/src/test/java/com/mabl/net/proxy/FindProxyDirectiveTest.java b/src/test/java/com/mabl/net/proxy/FindProxyDirectiveTest.java
new file mode 100644
index 0000000..14805cb
--- /dev/null
+++ b/src/test/java/com/mabl/net/proxy/FindProxyDirectiveTest.java
@@ -0,0 +1,42 @@
+package com.mabl.net.proxy;
+
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class FindProxyDirectiveTest {
+ @Test
+ public void direct() throws Exception {
+ final FindProxyDirective directive = FindProxyDirective.parse("DIRECT");
+ assertTrue(directive.isDirect());
+ assertFalse(directive.isProxy());
+ assertEquals(ConnectionType.DIRECT, directive.connectionType());
+ assertNull(directive.proxyHostAndPort());
+ assertNull(directive.proxyHost());
+ assertNull(directive.proxyPort());
+ assertEquals("DIRECT", directive.toString());
+ assertEquals(directive, FindProxyDirective.parse(" DIRECT "));
+ }
+
+ @Test
+ public void proxy() throws Exception {
+ final FindProxyDirective directive = FindProxyDirective.parse("PROXY 10.0.0.1:8080");
+ assertFalse(directive.isDirect());
+ assertTrue(directive.isProxy());
+ assertEquals(ConnectionType.PROXY, directive.connectionType());
+ assertEquals("10.0.0.1:8080", directive.proxyHostAndPort());
+ assertEquals("10.0.0.1", directive.proxyHost());
+ assertEquals(new Integer(8080), directive.proxyPort());
+ assertEquals(InetSocketAddress.createUnresolved("10.0.0.1", 8080), directive.unresolvedProxyAddress());
+ assertEquals(new InetSocketAddress("10.0.0.1", 8080), directive.resolvedProxyAddress());
+ assertEquals("10.0.0.1", directive.unresolvedProxyAddress().getHostString());
+ assertEquals(8080, directive.unresolvedProxyAddress().getPort());
+ assertEquals("PROXY 10.0.0.1:8080", directive.toString());
+ assertEquals(directive, FindProxyDirective.parse(" PROXY 10.0.0.1:8080 "));
+ }
+}
diff --git a/src/test/java/com/mabl/net/proxy/FindProxyResultTest.java b/src/test/java/com/mabl/net/proxy/FindProxyResultTest.java
new file mode 100644
index 0000000..dfe1061
--- /dev/null
+++ b/src/test/java/com/mabl/net/proxy/FindProxyResultTest.java
@@ -0,0 +1,144 @@
+package com.mabl.net.proxy;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class FindProxyResultTest {
+ @Test
+ public void all() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080; DIRECT");
+ final List directives = result.all();
+
+ final FindProxyDirective directive1 = directives.get(0);
+ assertEquals(ConnectionType.PROXY, directive1.connectionType());
+ assertEquals("10.0.0.1:8080", directive1.proxyHostAndPort());
+
+ final FindProxyDirective directive2 = directives.get(1);
+ assertEquals(ConnectionType.SOCKS, directive2.connectionType());
+ assertEquals("10.0.0.1:1080", directive2.proxyHostAndPort());
+
+ final FindProxyDirective directive3 = directives.get(2);
+ assertEquals(ConnectionType.DIRECT, directive3.connectionType());
+ assertNull(directive3.proxyHostAndPort());
+ }
+
+ @Test
+ public void first() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080; DIRECT");
+ final FindProxyDirective first = result.first();
+ assertEquals(ConnectionType.PROXY, first.connectionType());
+ assertEquals("10.0.0.1:8080", first.proxyHostAndPort());
+ }
+
+ @Test
+ public void firstProxyWithProxyPresent() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("DIRECT; PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080");
+ final Optional maybeFirstProxy = result.firstProxy();
+ assertTrue(maybeFirstProxy.isPresent());
+ final FindProxyDirective firstProxy = maybeFirstProxy.get();
+ assertEquals(ConnectionType.PROXY, firstProxy.connectionType());
+ assertEquals("10.0.0.1:8080", firstProxy.proxyHostAndPort());
+ }
+
+ @Test
+ public void firstProxyWithNoProxyPresent() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("DIRECT");
+ final Optional maybeFirstProxy = result.firstProxy();
+ assertFalse(maybeFirstProxy.isPresent());
+ }
+
+ @Test
+ public void get() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080; DIRECT");
+
+ final FindProxyDirective directive1 = result.get(0);
+ assertEquals(ConnectionType.PROXY, directive1.connectionType());
+ assertEquals("10.0.0.1:8080", directive1.proxyHostAndPort());
+
+ final FindProxyDirective directive2 = result.get(1);
+ assertEquals(ConnectionType.SOCKS, directive2.connectionType());
+ assertEquals("10.0.0.1:1080", directive2.proxyHostAndPort());
+
+ final FindProxyDirective directive3 = result.get(2);
+ assertEquals(ConnectionType.DIRECT, directive3.connectionType());
+ assertNull(directive3.proxyHostAndPort());
+ }
+
+ @Test(expected = PacInterpreterException.class)
+ public void invalid() throws Exception {
+ FindProxyResult.parse("FOO");
+ fail("Parsing should have failed");
+ }
+
+ @Test
+ public void iterator() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080; DIRECT");
+ final List directives = new ArrayList<>(result.size());
+ result.iterator().forEachRemaining(directives::add);
+
+ final FindProxyDirective directive1 = directives.get(0);
+ assertEquals(ConnectionType.PROXY, directive1.connectionType());
+ assertEquals("10.0.0.1:8080", directive1.proxyHostAndPort());
+
+ final FindProxyDirective directive2 = directives.get(1);
+ assertEquals(ConnectionType.SOCKS, directive2.connectionType());
+ assertEquals("10.0.0.1:1080", directive2.proxyHostAndPort());
+
+ final FindProxyDirective directive3 = directives.get(2);
+ assertEquals(ConnectionType.DIRECT, directive3.connectionType());
+ assertNull(directive3.proxyHostAndPort());
+ }
+
+ @Test
+ public void normalize() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("PROXY 10.0.0.1:8080; PROXY 10.0.0.1:8080; DIRECT; SOCKS 10.0.0.1:1080; DIRECT; DIRECT");
+ assertEquals(6, result.size());
+
+ final FindProxyResult normalized = result.normalize();
+ assertEquals(3, normalized.size());
+
+ final FindProxyDirective directive1 = normalized.get(0);
+ assertEquals(ConnectionType.PROXY, directive1.connectionType());
+ assertEquals("10.0.0.1:8080", directive1.proxyHostAndPort());
+
+ final FindProxyDirective directive2 = normalized.get(1);
+ assertEquals(ConnectionType.DIRECT, directive2.connectionType());
+ assertNull(directive2.proxyHostAndPort());
+
+ final FindProxyDirective directive3 = normalized.get(2);
+ assertEquals(ConnectionType.SOCKS, directive3.connectionType());
+ assertEquals("10.0.0.1:1080", directive3.proxyHostAndPort());
+ }
+
+ @Test
+ public void random() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080; DIRECT");
+ final Set directives = new HashSet<>(result.all());
+ for (int ii = 0; ii < 10; ii++) {
+ assertTrue(directives.contains(result.random()));
+ }
+ }
+
+ @Test
+ public void resultToString() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080; DIRECT ");
+ assertEquals("PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080; DIRECT", result.toString());
+ }
+
+ @Test
+ public void size() throws Exception {
+ final FindProxyResult result = FindProxyResult.parse("PROXY 10.0.0.1:8080; SOCKS 10.0.0.1:1080; DIRECT");
+ assertEquals(3, result.size());
+ }
+}
diff --git a/src/test/java/com/mabl/net/proxy/PacInterpreterTest.java b/src/test/java/com/mabl/net/proxy/PacInterpreterTest.java
new file mode 100644
index 0000000..f1fee8f
--- /dev/null
+++ b/src/test/java/com/mabl/net/proxy/PacInterpreterTest.java
@@ -0,0 +1,119 @@
+package com.mabl.net.proxy;
+
+import com.mabl.io.IoUtils;
+import io.undertow.Undertow;
+import io.undertow.util.Headers;
+import org.junit.After;
+import org.junit.Before;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+abstract public class PacInterpreterTest {
+ protected static final String PAC_1 = readFromClasspath("/pac1.js");
+ protected static final String PAC_2 = readFromClasspath("/pac2.js");
+ protected static final String PAC_3 = readFromClasspath("/pac3.js");
+ protected Undertow pacServer;
+ private volatile String pacServerContent;
+
+ @Before
+ public void silenceGraalvmWarnings() {
+ System.setProperty("polyglot.engine.WarnInterpreterOnly", Boolean.TRUE.toString());
+ }
+
+ @After
+ public void tearDown() {
+ if (pacServer != null) {
+ pacServer.stop();
+ pacServer = null;
+ pacServerContent = null;
+ }
+ }
+
+ protected static String readFromClasspath(final String path) {
+ try {
+ return IoUtils.readClasspathFileToString(path);
+ } catch (IOException e) {
+ throw new RuntimeException(String.format("Failed to read \"%s\" from classpath", path), e);
+ }
+ }
+
+ protected static File writePacContentToFile(final String pacContent) throws IOException {
+ final File pacFile = File.createTempFile("pac", ".js");
+ pacFile.deleteOnExit();
+ return writePacContentToFile(pacContent, pacFile);
+ }
+
+ protected static File writePacContentToFile(final String pacContent, final File pacFile) throws IOException {
+ try (final BufferedWriter writer = new BufferedWriter(new FileWriter(pacFile))) {
+ writer.write(pacContent);
+ writer.flush();
+ }
+ return pacFile;
+ }
+
+ protected Undertow startPacServer(final String pacContent) {
+ updatePacServerContent(pacContent);
+ pacServer = Undertow.builder()
+ .addHttpListener(0, "localhost")
+ .setHandler(exchange -> {
+ exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/javascript");
+ exchange.getResponseSender().send(pacServerContent);
+ }).build();
+ pacServer.start();
+ return pacServer;
+ }
+
+ protected void updatePacServerContent(final String pacContent) {
+ assertNotNull(pacContent);
+ this.pacServerContent = pacContent;
+ }
+
+ protected void assertPac1Correct(final PacInterpreter interpreter) throws PacInterpreterException, MalformedURLException {
+ final FindProxyResult results = interpreter.findProxyForUrl("https://example.com");
+ assertEquals(2, results.size());
+
+ final FindProxyDirective first = results.first();
+ assertEquals(ConnectionType.PROXY, first.connectionType());
+ assertEquals("4.5.6.7:8080", first.proxyHostAndPort());
+ assertTrue(interpreter.getPac().contains(first.toString()));
+
+ final FindProxyDirective second = results.get(1);
+ assertEquals(ConnectionType.PROXY, second.connectionType());
+ assertEquals("7.8.9.10:8080", second.proxyHostAndPort());
+ assertTrue(interpreter.getPac().contains(second.toString()));
+
+ final Set directives = new HashSet<>(results.all());
+ assertTrue(directives.contains(results.random()));
+ }
+
+ protected void assertPac2Correct(final PacInterpreter interpreter) throws PacInterpreterException, MalformedURLException {
+ final FindProxyResult results = interpreter.findProxyForUrl("https://example.com");
+ assertEquals(1, results.size());
+
+ final FindProxyDirective first = results.first();
+ assertEquals(ConnectionType.PROXY, first.connectionType());
+ assertEquals("wcg1.example.com:8080", first.proxyHostAndPort());
+ assertTrue(interpreter.getPac().contains(first.toString()));
+ }
+
+ protected void assertPac3Correct(final PacInterpreter interpreter) throws PacInterpreterException, MalformedURLException {
+ final FindProxyResult results = interpreter.findProxyForUrl("https://example.com");
+ assertEquals(1, results.size());
+
+ final FindProxyDirective first = results.first();
+ assertEquals(ConnectionType.DIRECT, first.connectionType());
+ assertNull(first.proxyHostAndPort());
+ }
+
+}
diff --git a/src/test/java/com/mabl/net/proxy/ReloadablePacInterpreterTest.java b/src/test/java/com/mabl/net/proxy/ReloadablePacInterpreterTest.java
new file mode 100644
index 0000000..80d0d01
--- /dev/null
+++ b/src/test/java/com/mabl/net/proxy/ReloadablePacInterpreterTest.java
@@ -0,0 +1,74 @@
+package com.mabl.net.proxy;
+
+import org.junit.After;
+import org.junit.Test;
+
+import java.io.File;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class ReloadablePacInterpreterTest extends PacInterpreterTest {
+ private ReloadablePacInterpreter pacInterpreter;
+
+ @After
+ public void stopReload() {
+ if (pacInterpreter != null) {
+ pacInterpreter.stop();
+ pacInterpreter = null;
+ }
+ }
+
+ @Test
+ public void forScript() throws Exception {
+ final AtomicReference script = new AtomicReference<>(PAC_1);
+
+ pacInterpreter = ReloadablePacInterpreter.forScript(script::get);
+ assertPac1Correct(pacInterpreter);
+
+ script.set(PAC_2);
+ pacInterpreter.reload();
+ assertPac2Correct(pacInterpreter);
+ }
+
+ @Test
+ public void forFile() throws Exception {
+ final File pacFile = writePacContentToFile(PAC_1);
+
+ pacInterpreter = ReloadablePacInterpreter.forFile(pacFile);
+ assertPac1Correct(pacInterpreter);
+
+ writePacContentToFile(PAC_2, pacFile);
+ pacInterpreter.reload();
+ assertPac2Correct(pacInterpreter);
+ }
+
+ @Test
+ public void forServer() throws Exception {
+ final InetSocketAddress serverAddress = (InetSocketAddress) startPacServer(PAC_2).getListenerInfo().get(0).getAddress();
+
+ pacInterpreter = ReloadablePacInterpreter.forUrl(new URL(String.format("http://%s:%d/pac.js", serverAddress.getAddress().getHostAddress(), serverAddress.getPort())));
+ assertPac2Correct(pacInterpreter);
+
+ updatePacServerContent(PAC_3);
+ pacInterpreter.reload();
+ assertPac3Correct(pacInterpreter);
+ }
+
+ @Test
+ public void timer() throws Exception {
+ final AtomicReference script = new AtomicReference<>(PAC_1);
+
+ pacInterpreter = ReloadablePacInterpreter.forScript(script::get);
+ assertPac1Correct(pacInterpreter);
+
+ final Duration reloadPeriod = Duration.ofSeconds(1);
+ script.set(PAC_2);
+ pacInterpreter.start(reloadPeriod);
+ assertPac1Correct(pacInterpreter);
+
+ Thread.sleep(reloadPeriod.toMillis() * 2);
+ assertPac2Correct(pacInterpreter);
+ }
+}
diff --git a/src/test/java/com/mabl/net/proxy/SimplePacInterpreterTest.java b/src/test/java/com/mabl/net/proxy/SimplePacInterpreterTest.java
new file mode 100644
index 0000000..588a892
--- /dev/null
+++ b/src/test/java/com/mabl/net/proxy/SimplePacInterpreterTest.java
@@ -0,0 +1,58 @@
+package com.mabl.net.proxy;
+
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+import java.net.URL;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class SimplePacInterpreterTest extends PacInterpreterTest {
+
+ @Test
+ public void forScript() throws Exception {
+ final SimplePacInterpreter interpreter = SimplePacInterpreter.forScript(PAC_1);
+ assertPac1Correct(interpreter);
+ }
+
+ @Test
+ public void forFile() throws Exception {
+ final SimplePacInterpreter interpreter = SimplePacInterpreter.forFile(writePacContentToFile(PAC_2));
+ assertPac2Correct(interpreter);
+ }
+
+ @Test
+ public void forUrl() throws Exception {
+ final InetSocketAddress serverAddress = (InetSocketAddress) startPacServer(PAC_3).getListenerInfo().get(0).getAddress();
+ final SimplePacInterpreter interpreter = SimplePacInterpreter.forUrl(new URL(String.format("http://%s:%d/pac.js", serverAddress.getAddress().getHostAddress(), serverAddress.getPort())));
+ assertPac3Correct(interpreter);
+ }
+
+ @Test
+ public void nullMapsToDirect() throws Exception {
+ final String pacFileContent = "function FindProxyForURL(url, host) { return null; }";
+ final SimplePacInterpreter interpreter = SimplePacInterpreter.forScript(pacFileContent);
+ final FindProxyResult results = interpreter.findProxyForUrl("https://example.com");
+ assertEquals(1, results.size());
+
+ // null should map to DIRECT
+ final FindProxyDirective first = results.first();
+ assertEquals(ConnectionType.DIRECT, first.connectionType());
+ assertNull(first.proxyHostAndPort());
+ }
+
+ @Test
+ public void undefinedMapsToDirect() throws Exception {
+ final String pacFileContent = "function FindProxyForURL(url, host) { return undefined; }";
+ final SimplePacInterpreter interpreter = SimplePacInterpreter.forScript(pacFileContent);
+ final FindProxyResult results = interpreter.findProxyForUrl("https://example.com");
+ assertEquals(1, results.size());
+
+ // undefined should map to DIRECT
+ final FindProxyDirective first = results.first();
+ assertEquals(ConnectionType.DIRECT, first.connectionType());
+ assertNull(first.proxyHostAndPort());
+ }
+
+}
diff --git a/src/test/resources/pac1.js b/src/test/resources/pac1.js
new file mode 100644
index 0000000..a5a8bac
--- /dev/null
+++ b/src/test/resources/pac1.js
@@ -0,0 +1,30 @@
+// Source: http://findproxyforurl.com/example-pac-file/
+
+function FindProxyForURL(url, host) {
+ // If the hostname matches, send direct.
+ if (dnsDomainIs(host, "intranet.domain.com") ||
+ shExpMatch(host, "(*.abcdomain.com|abcdomain.com)"))
+ return "DIRECT";
+
+ // If the protocol or URL matches, send direct.
+ if (url.substring(0, 4)=="ftp:" ||
+ shExpMatch(url, "http://abcdomain.com/folder/*"))
+ return "DIRECT";
+
+ // If the requested website is hosted within the internal network, send direct.
+ if (isPlainHostName(host) ||
+ shExpMatch(host, "*.local") ||
+ isInNet(dnsResolve(host), "10.0.0.0", "255.0.0.0") ||
+ isInNet(dnsResolve(host), "172.16.0.0", "255.240.0.0") ||
+ isInNet(dnsResolve(host), "192.168.0.0", "255.255.0.0") ||
+ isInNet(dnsResolve(host), "127.0.0.0", "255.255.255.0"))
+ return "DIRECT";
+
+ // If the IP address of the local machine is within a defined
+ // subnet, send to a specific proxy.
+ if (isInNet(myIpAddress(), "10.10.5.0", "255.255.255.0"))
+ return "PROXY 1.2.3.4:8080";
+
+ // DEFAULT RULE: All other traffic, use below proxies, in fail-over order.
+ return "PROXY 4.5.6.7:8080; PROXY 7.8.9.10:8080";
+}
diff --git a/src/test/resources/pac2.js b/src/test/resources/pac2.js
new file mode 100644
index 0000000..798edbf
--- /dev/null
+++ b/src/test/resources/pac2.js
@@ -0,0 +1,67 @@
+// Source: https://www.websense.com/content/support/library/web/v76/pac_file_best_practices/PAC_file_sample.aspx
+
+function FindProxyForURL(url, host) {
+ /* Normalize the URL for pattern matching */
+ url = url.toLowerCase();
+ host = host.toLowerCase();
+
+ /* Don't proxy local hostnames */
+ if (isPlainHostName(host)) {
+ return 'DIRECT';
+ }
+
+ /* Don't proxy local domains */
+ if (dnsDomainIs(host, ".example1.com") ||
+ (host == "example1.com") ||
+ dnsDomainIs(host, ".example2.com") ||
+ (host == "example2.com") ||
+ dnsDomainIs(host, ".example3.com") ||
+ (host == "example3.com")) {
+ return 'DIRECT';
+ }
+
+ /* Don't proxy Windows Update */
+ if ((host == "download.microsoft.com") ||
+ (host == "ntservicepack.microsoft.com") ||
+ (host == "cdm.microsoft.com") ||
+ (host == "wustat.windows.com") ||
+ (host == "windowsupdate.microsoft.com") ||
+ (dnsDomainIs(host, ".windowsupdate.microsoft.com")) ||
+ (host == "update.microsoft.com") ||
+ (dnsDomainIs(host, ".update.microsoft.com")) ||
+ (dnsDomainIs(host, ".windowsupdate.com"))) {
+ return 'DIRECT';
+ }
+
+ if (isResolvable(host)) {
+ var hostIP = dnsResolve(host);
+
+ /* Don't proxy non-routable addresses (RFC 3330) */
+ if (isInNet(hostIP, '0.0.0.0', '255.0.0.0') ||
+ isInNet(hostIP, '10.0.0.0', '255.0.0.0') ||
+ isInNet(hostIP, '127.0.0.0', '255.0.0.0') ||
+ isInNet(hostIP, '169.254.0.0', '255.255.0.0') ||
+ isInNet(hostIP, '172.16.0.0', '255.240.0.0') ||
+ isInNet(hostIP, '192.0.2.0', '255.255.255.0') ||
+ isInNet(hostIP, '192.88.99.0', '255.255.255.0') ||
+ isInNet(hostIP, '192.168.0.0', '255.255.0.0') ||
+ isInNet(hostIP, '198.18.0.0', '255.254.0.0') ||
+ isInNet(hostIP, '224.0.0.0', '240.0.0.0') ||
+ isInNet(hostIP, '240.0.0.0', '240.0.0.0')) {
+ return 'DIRECT';
+ }
+
+ /* Don't proxy local addresses.*/
+ if (false) {
+ return 'DIRECT';
+ }
+ }
+
+ if (url.substring(0, 5) == 'http:' ||
+ url.substring(0, 6) == 'https:' ||
+ url.substring(0, 4) == 'ftp:') {
+ return 'PROXY wcg1.example.com:8080';
+ }
+
+ return 'DIRECT';
+}
diff --git a/src/test/resources/pac3.js b/src/test/resources/pac3.js
new file mode 100644
index 0000000..ebcc93e
--- /dev/null
+++ b/src/test/resources/pac3.js
@@ -0,0 +1,20 @@
+// Source: https://www.websense.com/content/support/library/web/v76/pac_file_best_practices/PAC_file_sample.aspx
+
+function FindProxyForURL(url, host) {
+ if (isInNet(myIpAddress(), "1.1.0.0", "255.0.0.0")) {
+ return "PROXY wcg1.example.com:8080; " + "PROXY wcg2.example.com:8080";
+ }
+
+ if (isInNet(myIpAddress(), "1.2.0.0", "255.0.0.0")) {
+ return "PROXY wcg1.example.com:8080; " + "PROXY wcg2.example.com:8080";
+ }
+
+ if (isInNet(myIpAddress(), "1.3.0.0", "255.0.0.0")) {
+ return "PROXY wcg2.example.com:8080; " + "PROXY wcg1.example.com:8080";
+ }
+
+ if (isInNet(myIpAddress(), "1.4.0.0", "255.0.0.0")) {
+ return "PROXY wcg2.example.com:8080; " + "PROXY wcg1.example.com:8080";
+ }
+ else return "DIRECT";
+}