changeset 789:88c5b678e974 monetdbs

URL parser passes the tests. Some tests had to change to accomodate Java.
author Joeri van Ruth <joeri.van.ruth@monetdbsolutions.com>
date Wed, 29 Nov 2023 13:28:52 +0100 (16 months ago)
parents 2f36ac68ac35
children 547eca89fc5e
files src/main/java/org/monetdb/mcl/net/MonetUrlParser.java src/main/java/org/monetdb/mcl/net/Parameter.java src/main/java/org/monetdb/mcl/net/ParameterType.java src/main/java/org/monetdb/mcl/net/Target.java src/main/java/org/monetdb/mcl/net/ValidationError.java src/main/java/org/monetdb/mcl/net/Verify.java tests/UrlTester.java tests/tests.md
diffstat 8 files changed, 2634 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
new file mode 100644
--- /dev/null
+++ b/src/main/java/org/monetdb/mcl/net/MonetUrlParser.java
@@ -0,0 +1,305 @@
+package org.monetdb.mcl.net;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
+import java.util.Properties;
+
+public class MonetUrlParser {
+    private final Properties props;
+    private final String urlText;
+    private final URI url;
+    boolean userWasSet = false;
+    boolean passwordWasSet = false;
+
+    public MonetUrlParser(Properties props, String url) throws URISyntaxException {
+        this.props = props;
+        this.urlText = url;
+        this.url = new URI(url);
+    }
+
+    public static void parse(Properties props, String url) throws URISyntaxException {
+        boolean modern = true;
+        if (url.startsWith("mapi:")) {
+            modern = false;
+            url = url.substring(5);
+            if (url.equals("monetdb://")) {
+                // deal with peculiarity of Java's URI parser
+                url = "monetdb:///";
+            }
+
+        }
+        try {
+            MonetUrlParser parser = new MonetUrlParser(props, url);
+            if (modern) {
+                parser.parseModern();
+            } else {
+                parser.parseClassic();
+            }
+            if (parser.userWasSet && !parser.passwordWasSet) parser.clear(Parameter.PASSWORD);
+        } catch (URISyntaxException e) {
+            int idx = e.getIndex();
+            if (idx >= 0 && !modern) {
+                // "mapi:"
+                idx += 5;
+            }
+            throw new URISyntaxException(e.getInput(), e.getReason(), idx);
+        }
+    }
+
+    private static String percentDecode(String context, String text) throws URISyntaxException {
+        try {
+            return URLDecoder.decode(text, "UTF-8");
+        } catch (UnsupportedEncodingException e) {
+            throw new IllegalStateException("should be unreachable: UTF-8 unknown??", e);
+        } catch (IllegalArgumentException e) {
+            throw new URISyntaxException(text, context + ": invalid percent escape");
+        }
+    }
+
+    private void set(Parameter parm, String value) {
+        parm = keyMagic(parm);
+        props.setProperty(parm.name, value != null ? value : "");
+    }
+
+    private void set(String key, String value) {
+        Parameter parm = Parameter.forName(key);
+        if (parm != null)
+            set(parm, value);
+        else
+            props.setProperty(key, value);
+    }
+
+    private void clear(Parameter parm) {
+        parm = keyMagic(parm);
+        String value = parm.type.format(Target.getDefault(parm));
+        props.setProperty(parm.name, value);
+    }
+
+    private Parameter keyMagic(Parameter key) {
+        switch (key) {
+            case USER:
+                userWasSet = true;
+                break;
+            case PASSWORD:
+                passwordWasSet = true;
+                break;
+            case FETCHSIZE:
+                key = Parameter.REPLYSIZE;
+                break;
+            default:
+                break;
+        }
+        return key;
+    }
+
+    private void parseModern() throws URISyntaxException {
+        clearBasic();
+
+        String scheme = url.getScheme();
+        if (scheme == null) throw new URISyntaxException(urlText, "URL scheme must be monetdb:// or monetdbs://");
+        switch (scheme) {
+            case "monetdb":
+                set(Parameter.TLS, "false");
+                break;
+            case "monetdbs":
+                set(Parameter.TLS, "true");
+                break;
+            default:
+                throw new URISyntaxException(urlText, "URL scheme must be monetdb:// or monetdbs://");
+        }
+
+        // The built-in getHost and getPort methods do strange things
+        // in edge cases such as percent-encoded host names and
+        // invalid port numbers
+        String authority = url.getAuthority();
+        String host;
+        String remainder;
+        int pos;
+        String raw = url.getRawSchemeSpecificPart();
+        if (authority == null) {
+            if (!url.getRawSchemeSpecificPart().startsWith("//")) {
+                throw new URISyntaxException(urlText, "expected //");
+            }
+            host = "";
+            remainder = "";
+        } else if (authority.startsWith("[")) {
+            // IPv6
+            pos = authority.indexOf(']');
+            if (pos < 0)
+                throw new URISyntaxException(urlText, "unmatched '['");
+            host = authority.substring(1, pos);
+            remainder = authority.substring(pos + 1);
+        } else if ((pos = authority.indexOf(':')) >= 0){
+            host = authority.substring(0, pos);
+            remainder = authority.substring(pos);
+        } else {
+            host = authority;
+            remainder = "";
+        }
+        switch (host) {
+            case "localhost":
+                set(Parameter.HOST, "");
+                break;
+            case "localhost.":
+                set(Parameter.HOST, "localhost");
+                break;
+            default:
+                set(Parameter.HOST, host);
+                break;
+        }
+
+        if (remainder.isEmpty()) {
+            // do nothing
+        } else if (remainder.startsWith(":")) {
+            String portStr = remainder.substring(1);
+            try {
+                int port = Integer.parseInt(portStr);
+                if (port <= 0 || port > 65535)
+                    portStr = null;
+            } catch (NumberFormatException e) {
+                portStr = null;
+            }
+            if (portStr == null)
+                throw new URISyntaxException(urlText, "invalid port number");
+            set(Parameter.PORT, portStr);
+        }
+
+        String path = url.getRawPath();
+        String[] parts = path.split("/", 5);
+        // <0: empty before leading slash> / <1: database> / <2: tableschema> / <3: table> / <4: should not exist>
+        switch (parts.length) {
+            case 5:
+                throw new URISyntaxException(urlText, "table name should not contain slashes");
+            case 4:
+                set(Parameter.TABLE, percentDecode(Parameter.TABLE.name, parts[3]));
+                // fallthrough
+            case 3:
+                set(Parameter.TABLESCHEMA, percentDecode(Parameter.TABLESCHEMA.name, parts[2]));
+                // fallthrough
+            case 2:
+                set(Parameter.DATABASE, percentDecode(Parameter.DATABASE.name, parts[1]));
+            case 1:
+            case 0:
+                // fallthrough
+                break;
+        }
+
+        final String query = url.getRawQuery();
+        if (query != null) {
+            final String args[] = query.split("&");
+            for (int i = 0; i < args.length; i++) {
+                pos = args[i].indexOf('=');
+                if (pos <= 0) {
+                    throw new URISyntaxException(args[i], "invalid key=value pair");
+                }
+                String key = args[i].substring(0, pos);
+                key = percentDecode(key, key);
+                Parameter parm = Parameter.forName(key);
+                if (parm != null && parm.isCore)
+                    throw new URISyntaxException(key, key + "= is not allowed as a query parameter");
+
+                String value = args[i].substring(pos + 1);
+                set(key, percentDecode(key, value));
+            }
+        }
+    }
+
+
+    private void parseClassic() throws URISyntaxException {
+        String scheme = url.getScheme();
+        if (scheme == null) throw new URISyntaxException(urlText, "URL scheme must be mapi:monetdb:// or mapi:merovingian://");
+        switch (scheme) {
+            case "monetdb":
+                clearBasic();
+                break;
+            case "merovingian":
+                throw new IllegalStateException("mapi:merovingian: not supported yet");
+            default:
+                throw new URISyntaxException(urlText, "URL scheme must be mapi:monetdb:// or mapi:merovingian://");
+        }
+
+        if (!url.getRawSchemeSpecificPart().startsWith("//")) {
+            throw new URISyntaxException(urlText, "expected //");
+        }
+
+        String authority = url.getRawAuthority();
+        String host;
+        String portStr;
+        int pos;
+        if (authority == null) {
+            host = "";
+            portStr = "";
+        } else if (authority.indexOf('@') >= 0) {
+            throw new URISyntaxException(urlText, "user@host syntax is not allowed");
+        } else if ((pos = authority.indexOf(':')) >= 0) {
+            host = authority.substring(0, pos);
+            portStr = authority.substring(pos + 1);
+        } else {
+            host = authority;
+            portStr = "";
+        }
+
+        if (!portStr.isEmpty()) {
+            int port;
+            try {
+                port = Integer.parseInt(portStr);
+            } catch (NumberFormatException e) {
+                port = -1;
+            }
+            if (port <= 0) {
+                throw new URISyntaxException(urlText, "invalid port number");
+            }
+            set(Parameter.PORT, portStr);
+        }
+
+        String path = url.getRawPath();
+        boolean isUnix;
+        if (host.isEmpty() && portStr.isEmpty()) {
+            // socket
+            isUnix = true;
+            clear(Parameter.HOST);
+            set(Parameter.SOCK, path != null ? path : "");
+        } else {
+            // tcp
+            isUnix = false;
+            clear(Parameter.SOCK);
+            set(Parameter.HOST, host);
+            if (path == null || path.isEmpty()) {
+                // do nothing
+            } else if (!path.startsWith("/")) {
+                throw new URISyntaxException(urlText, "expect path to start with /");
+            } else {
+                String database = path.substring(1);
+                if (database.contains("/"))
+                    throw new URISyntaxException(urlText, "no slashes allowed in database name");
+                set(Parameter.DATABASE, database);
+            }
+        }
+
+        final String query = url.getRawQuery();
+        if (query != null) {
+            final String args[] = query.split("&");
+            for (int i = 0; i < args.length; i++) {
+                String arg = args[i];
+                if (arg.startsWith("language=")) {
+                    String language = arg.substring(9);
+                    set(Parameter.LANGUAGE, language);
+                } else if (arg.startsWith("database=")) {
+                    String database = arg.substring(9);
+                    set(Parameter.DATABASE, database);
+                } else {
+                    // ignore
+                }
+            }
+        }
+    }
+
+    private void clearBasic() {
+        clear(Parameter.HOST);
+        clear(Parameter.PORT);
+        clear(Parameter.SOCK);
+        clear(Parameter.DATABASE);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/src/main/java/org/monetdb/mcl/net/Parameter.java
@@ -0,0 +1,81 @@
+package org.monetdb.mcl.net;
+
+
+public enum Parameter {
+    TLS("tls", ParameterType.Bool, false, "secure the connection using TLS", true),
+    HOST("host", ParameterType.Str, "", "IP number, domain name or one of the special values `localhost` and `localhost.`", true),
+    PORT("port", ParameterType.Int, -1, "Port to connect to, 1..65535 or -1 for 'not set'", true),
+    DATABASE("database", ParameterType.Str, "", "name of database to connect to", true),
+    TABLESCHEMA("tableschema", ParameterType.Str, "", "only used for REMOTE TABLE, otherwise unused", true),
+    TABLE("table", ParameterType.Str, "", "only used for REMOTE TABLE, otherwise unused", true),
+    SOCK("sock", ParameterType.Path, "", "path to Unix domain socket to connect to", false),
+    SOCKDIR("sockdir", ParameterType.Path, "/tmp", "Directory for implicit Unix domain sockets (.s.monetdb.PORT)", false),
+    CERT("cert", ParameterType.Path, "", "path to TLS certificate to authenticate server with", false),
+    CERTHASH("certhash", ParameterType.Str, "", "hash of server TLS certificate must start with these hex digits; overrides cert", false),
+    CLIENTKEY("clientkey", ParameterType.Path, "", "path to TLS key (+certs) to authenticate with as client", false),
+    CLIENTCERT("clientcert", ParameterType.Path, "", "path to TLS certs for 'clientkey', if not included there", false),
+    USER("user", ParameterType.Str, "", "user name to authenticate as", false),
+    PASSWORD("password", ParameterType.Str, "", "password to authenticate with", false),
+    LANGUAGE("language", ParameterType.Str, "sql", "for example, \"sql\", \"mal\", \"msql\", \"profiler\"", false),
+    AUTOCOMMIT("autocommit", ParameterType.Bool, false, "initial value of autocommit", false),
+    SCHEMA("schema", ParameterType.Str, "", "initial schema", false),
+    TIMEZONE("timezone", ParameterType.Int, null, "client time zone as minutes east of UTC", false),
+    BINARY("binary", ParameterType.Str, "on", "whether to use binary result set format (number or bool)", false),
+    REPLYSIZE("replysize", ParameterType.Int, 200, "rows beyond this limit are retrieved on demand, <1 means unlimited", false),
+    FETCHSIZE("fetchsize", ParameterType.Int, null, "alias for replysize, specific to jdbc", false),
+    HASH("hash", ParameterType.Str, "", "specific to jdbc", false),
+    DEBUG("debug", ParameterType.Bool, false, "specific to jdbc", false),
+    LOGFILE("logfile", ParameterType.Str, "", "specific to jdbc", false),
+
+    ;
+
+    public final String name;
+    public final ParameterType type;
+    public final Object defaultValue;
+    public final String description;
+    public final boolean isCore;
+
+    Parameter(String name, ParameterType type, Object defaultValue, String description, boolean isCore) {
+        this.name = name;
+        this.type = type;
+        this.isCore = isCore;
+        this.defaultValue = defaultValue;
+        this.description = description;
+    }
+
+    public static Parameter forName(String name) {
+        switch (name) {
+            case "tls": return TLS;
+            case "host": return HOST;
+            case "port": return PORT;
+            case "database": return DATABASE;
+            case "tableschema": return TABLESCHEMA;
+            case "table": return TABLE;
+            case "sock": return SOCK;
+            case "sockdir": return SOCKDIR;
+            case "cert": return CERT;
+            case "certhash": return CERTHASH;
+            case "clientkey": return CLIENTKEY;
+            case "clientcert": return CLIENTCERT;
+            case "user": return USER;
+            case "password": return PASSWORD;
+            case "language": return LANGUAGE;
+            case "autocommit": return AUTOCOMMIT;
+            case "schema": return SCHEMA;
+            case "timezone": return TIMEZONE;
+            case "binary": return BINARY;
+            case "replysize": return REPLYSIZE;
+            case "fetchsize": return FETCHSIZE;
+            case "hash": return HASH;
+            case "debug": return DEBUG;
+            case "logfile": return LOGFILE;
+            default: return null;
+        }
+    }
+
+    public static boolean isIgnored(String name) {
+        if (Parameter.forName(name) != null)
+            return false;
+        return name.contains("_");
+    }
+}
new file mode 100644
--- /dev/null
+++ b/src/main/java/org/monetdb/mcl/net/ParameterType.java
@@ -0,0 +1,68 @@
+package org.monetdb.mcl.net;
+
+public enum ParameterType {
+    Str,
+    Int,
+    Bool,
+    Path;
+
+    public Object parse(String name, String value) throws ValidationError {
+        if (value == null)
+            throw new NullPointerException();
+
+        try {
+            switch (this) {
+                case Bool:
+                    return parseBool(value);
+                case Int:
+                    return Integer.parseInt(value);
+                case Str:
+                case Path:
+                    return value;
+                default:
+                    throw new IllegalStateException("unreachable");
+            }
+        } catch (IllegalArgumentException e) {
+            String message = e.toString();
+            throw new ValidationError(name, message);
+        }
+    }
+
+    public String format(Object value) {
+        switch (this) {
+                case Bool:
+                    return (Boolean)value ? "true": "false";
+                case Int:
+                    return Integer.toString((Integer)value);
+                case Str:
+                case Path:
+                    return (String) value;
+                default:
+                    throw new IllegalStateException("unreachable");
+            }
+    }
+
+    public static boolean parseBool(String value) {
+        boolean lowered = false;
+        String original = value;
+        while (true) {
+            switch (value) {
+                case "true":
+                case "yes":
+                case "on":
+                    return true;
+                case "false":
+                case "no":
+                case "off":
+                    return false;
+                default:
+                    if (!lowered) {
+                        value = value.toLowerCase();
+                        lowered = true;
+                        continue;
+                    }
+                    throw new IllegalArgumentException("invalid boolean value: " + original);
+            }
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/src/main/java/org/monetdb/mcl/net/Target.java
@@ -0,0 +1,330 @@
+package org.monetdb.mcl.net;
+
+import java.util.Calendar;
+import java.util.Properties;
+import java.util.regex.Pattern;
+
+public class Target {
+    private static Pattern namePattern = Pattern.compile("^[a-zA-Z_][-a-zA-Z0-9_.]*$");
+    private static Pattern hashPattern = Pattern.compile("^sha256:[0-9a-fA-F:]*$");
+    private final boolean tls;
+    private final String host;
+    private final int port;
+    private final String database;
+    private final String tableschema;
+    private final String table;
+    private final String sock;
+    private final String sockdir;
+    private final String cert;
+    private final String certhash;
+    private final String clientkey;
+    private final String clientcert;
+    private final String user;
+    private final String password;
+    private final String language;
+    private final boolean autocommit;
+    private final String schema;
+    private final int timezone;
+    private final int binary;
+    private final int replysize;
+    private final String hash;
+    private final boolean debug;
+    private final String logfile;
+
+    public Target(Properties properties) throws ValidationError {
+
+        // 1. The parameters have the types listed in the table in [Section
+        //    Parameters](#parameters).
+        tls = validateBoolean(properties, Parameter.TLS);
+        host = validateString(properties, Parameter.HOST);
+        port = validateInt(properties, Parameter.PORT);
+        database = validateString(properties, Parameter.DATABASE);
+        tableschema = validateString(properties, Parameter.TABLESCHEMA);
+        table = validateString(properties, Parameter.TABLE);
+        sock = validateString(properties, Parameter.SOCK);
+        sockdir = validateString(properties, Parameter.SOCKDIR);
+        cert = validateString(properties, Parameter.CERT);
+        certhash = validateString(properties, Parameter.CERTHASH);
+        clientkey = validateString(properties, Parameter.CLIENTKEY);
+        clientcert = validateString(properties, Parameter.CLIENTCERT);
+        user = validateString(properties, Parameter.USER);
+        password = validateString(properties, Parameter.PASSWORD);
+        language = validateString(properties, Parameter.LANGUAGE);
+        autocommit = validateBoolean(properties, Parameter.AUTOCOMMIT);
+        schema = validateString(properties, Parameter.SCHEMA);
+        timezone = validateInt(properties, Parameter.TIMEZONE);
+        replysize = validateInt(properties, Parameter.REPLYSIZE);
+        hash = validateString(properties, Parameter.HASH);
+        debug = validateBoolean(properties, Parameter.DEBUG);
+        logfile = validateString(properties, Parameter.LOGFILE);
+
+        for (String name: properties.stringPropertyNames()) {
+            if (Parameter.forName(name) != null)
+                continue;
+            if (name.contains("_"))
+                continue;
+            throw new ValidationError("unknown parameter: " + name);
+        }
+
+        String binaryString = validateString(properties, Parameter.BINARY);
+        int binaryInt;
+        try {
+            binaryInt = (int) ParameterType.Int.parse(Parameter.BINARY.name, binaryString);
+        } catch (ValidationError e) {
+            try {
+                boolean b = (boolean) ParameterType.Bool.parse(Parameter.BINARY.name, binaryString);
+                binaryInt = b ? 65535 : 0;
+            } catch (ValidationError ee) {
+                throw new ValidationError("binary= must be either a number or true/yes/on/false/no/off");
+            }
+        }
+        if (binaryInt < 0)
+            throw new ValidationError("binary= cannot be negative");
+        binary = binaryInt;
+
+
+        // 2. At least one of **sock** and **host** must be empty.
+        if (!sock.isEmpty() && !host.isEmpty())
+            throw new ValidationError("sock=" + sock + " cannot be combined with host=" + host);
+
+        // 3. The string parameter **binary** must either parse as a boolean or as a
+        //    non-negative integer.
+        //
+        // (checked above)
+
+        // 4. If **sock** is not empty, **tls** must be 'off'.
+        if (!sock.isEmpty() && tls) throw new ValidationError("monetdbs:// cannot be combined with sock=");
+
+        // 5. If **certhash** is not empty, it must be of the form `{sha256}hexdigits`
+        //    where hexdigits is a non-empty sequence of 0-9, a-f, A-F and colons.
+        // TODO
+        if (!certhash.isEmpty()) {
+            if (!certhash.toLowerCase().startsWith("sha256:"))
+                throw new ValidationError("certificate hash must start with 'sha256:'");
+            if (!hashPattern.matcher(certhash).matches())
+                throw new ValidationError("invalid certificate hash");
+        }
+
+        // 6. If **tls** is 'off', **cert** and **certhash** must be 'off' as well.
+        if (!tls) {
+            if (!cert.isEmpty() || !certhash.isEmpty())
+                throw new ValidationError("cert= and certhash= are only allowed in combination with monetdbs://");
+        }
+
+        // 7. Parameters **database**, **tableschema** and **table** must consist only of
+        //    upper- and lowercase letters, digits, periods, dashes and underscores. They must not
+        //    start with a dash.
+        //    If **table** is not empty, **tableschema** must also not be empty.
+        //    If **tableschema** is not empty, **database** must also not be empty.
+        if (database.isEmpty() && !tableschema.isEmpty())
+            throw new ValidationError("table schema cannot be set without database");
+        if (tableschema.isEmpty() && !table.isEmpty())
+            throw new ValidationError("table cannot be set without schema");
+        if (!database.isEmpty() && !namePattern.matcher(database).matches())
+            throw new ValidationError("invalid database name");
+        if (!tableschema.isEmpty() && !namePattern.matcher(tableschema).matches())
+            throw new ValidationError("invalid table schema name");
+        if (!table.isEmpty() && !namePattern.matcher(table).matches())
+            throw new ValidationError("invalid table name");
+
+
+        // 8. Parameter **port** must be -1 or in the range 1-65535.
+        if (port < -1 || port == 0 || port > 65535) throw new ValidationError("invalid port number " + port);
+
+        // 9. If **clientcert** is set, **clientkey** must also be set.
+        if (!clientcert.isEmpty() && clientkey.isEmpty())
+            throw new ValidationError("clientcert= is only valid in combination with clientkey=");
+    }
+
+    public static boolean validateBoolean(Properties props, Parameter parm) throws ValidationError {
+        Object value = props.get(parm.name);
+        if (value != null) {
+            return (Boolean) parm.type.parse(parm.name, (String) value);
+        } else {
+            return (Boolean) getDefault(parm);
+        }
+    }
+
+    public static int validateInt(Properties props, Parameter parm) throws ValidationError {
+        Object value = props.get(parm.name);
+        if (value != null) {
+            return (Integer) parm.type.parse(parm.name, (String) value);
+        } else {
+            return (Integer) getDefault(parm);
+        }
+    }
+
+    public static String validateString(Properties props, Parameter parm) throws ValidationError {
+        Object value = props.get(parm.name);
+        if (value != null) {
+            return (String) parm.type.parse(parm.name, (String) value);
+        } else {
+            return (String) getDefault(parm);
+        }
+    }
+
+    private static int timezone() {
+        Calendar cal = Calendar.getInstance();
+        int offsetMillis = cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET);
+        int offsetSeconds = offsetMillis / 1000;
+        return offsetSeconds;
+    }
+
+    public static Object getDefault(Parameter parm) {
+        if (parm == Parameter.TIMEZONE) return timezone();
+        else return parm.defaultValue;
+    }
+
+    public static Properties defaultProperties() {
+        Properties props = new Properties();
+        return props;
+    }
+
+    public boolean getTls() {
+        return tls;
+    }
+
+    // Getter is private because you probably want connectTcp() instead
+    private String getHost() {
+        return host;
+    }
+
+    // Getter is private because you probably want connectPort() instead
+    private int getPort() {
+        return port;
+    }
+
+    public String getDatabase() {
+        return database;
+    }
+
+    public String getTableschema() {
+        return tableschema;
+    }
+
+    public String getTable() {
+        return table;
+    }
+
+    // Getter is private because you probably want connectUnix() instead
+    private String getSock() {
+        return sock;
+    }
+
+    public String getSockdir() {
+        return sockdir;
+    }
+
+    public String getCert() {
+        return cert;
+    }
+
+    public String getCerthash() {
+        return certhash;
+    }
+
+    public String getClientkey() {
+        return clientkey;
+    }
+
+    public String getClientcert() {
+        return clientcert;
+    }
+
+    public String getUser() {
+        return user;
+    }
+
+    public String getPassword() {
+        return password;
+    }
+
+    public String getLanguage() {
+        return language;
+    }
+
+    public boolean getAutocommit() {
+        return autocommit;
+    }
+
+    public String getSchema() {
+        return schema;
+    }
+
+    public int getTimezone() {
+        return timezone;
+    }
+
+    // Getter is private because you probably want connectBinary() instead
+    public int getBinary() {
+        return binary;
+    }
+
+    public int getReplysize() {
+        return replysize;
+    }
+
+    public String getHash() {
+        return hash;
+    }
+
+    public boolean getDebug() {
+        return debug;
+    }
+
+    public String getLogfile() {
+        return logfile;
+    }
+
+    public boolean connectScan() {
+        if (database.isEmpty()) return false;
+        if (!sock.isEmpty() || !host.isEmpty() || port != -1) return false;
+        return !tls;
+    }
+
+    public int connectPort() {
+        return port == -1 ? 50000 : port;
+    }
+
+    public String connectUnix() {
+        if (!sock.isEmpty()) return sock;
+        if (tls) return "";
+        if (host.isEmpty()) return sockdir + "/.s.monetdb." + connectPort();
+        return "";
+    }
+
+    public String connectTcp() {
+        if (!sock.isEmpty()) return "";
+        if (host.isEmpty()) return "localhost";
+        return host;
+    }
+
+    public Verify connectVerify() {
+        if (!tls) return Verify.None;
+        if (!certhash.isEmpty()) return Verify.Hash;
+        if (!cert.isEmpty()) return Verify.Cert;
+        return Verify.System;
+    }
+
+    public String connectCertHashDigits() {
+        if (!tls) return null;
+        StringBuilder builder = new StringBuilder(certhash.length());
+        for (int i = "sha256:".length(); i < certhash.length(); i++) {
+            char c = certhash.charAt(i);
+            if (Character.digit(c, 16) >= 0) builder.append(Character.toLowerCase(c));
+        }
+        return builder.toString();
+    }
+
+    public int connectBinary() {
+        return binary;
+    }
+
+    public String connectClientKey() {
+        return clientkey;
+    }
+
+    public String connectClientCert() {
+        return clientcert.isEmpty() ? clientkey : clientcert;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/src/main/java/org/monetdb/mcl/net/ValidationError.java
@@ -0,0 +1,11 @@
+package org.monetdb.mcl.net;
+
+public class ValidationError extends Exception {
+    public ValidationError(String parameter, String message) {
+        super(parameter + ": " + message);
+    }
+
+    public ValidationError(String message) {
+        super(message);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/src/main/java/org/monetdb/mcl/net/Verify.java
@@ -0,0 +1,8 @@
+package org.monetdb.mcl.net;
+
+public enum Verify {
+    None,
+    Cert,
+    Hash,
+    System;
+}
new file mode 100644
--- /dev/null
+++ b/tests/UrlTester.java
@@ -0,0 +1,373 @@
+import org.monetdb.mcl.net.*;
+
+import java.io.*;
+import java.net.URISyntaxException;
+import java.util.Properties;
+
+public class UrlTester {
+    String filename = null;
+    int verbose = 0;
+    BufferedReader reader = null;
+    int lineno = 0;
+    int testCount = 0;
+    Properties props = null;
+    Target validated = null;
+
+    public UrlTester() {
+    }
+
+    public UrlTester(String filename) {
+        this.filename = filename;
+    }
+
+    public static void main(String[] args) throws Exception {
+        int exitcode;
+        UrlTester tester = new UrlTester();
+        exitcode = tester.parseArgs(args);
+        if (exitcode == 0)
+            exitcode = tester.run();
+        System.exit(exitcode);
+    }
+
+    private int parseArgs(String[] args) {
+        for (int i = 0; i < args.length; i++) {
+            String arg = args[i];
+            if (arg.startsWith("-")) {
+                int result = handleFlags(arg);
+                if (result != 0)
+                    return result;
+            } else if (filename == null) {
+                filename = arg;
+            } else {
+                System.err.println("Unexpected argument: " + arg);
+                return 1;
+            }
+        }
+        return 0;
+    }
+
+    private int run() throws IOException {
+        if (filename != null) {
+            reader = new BufferedReader(new FileReader(filename));
+        } else {
+            String resourceName = "/tests.md";
+            InputStream stream = this.getClass().getResourceAsStream(resourceName);
+            if (stream == null) {
+                System.err.println("Resource " + resourceName + " not found");
+                return 1;
+            }
+            reader = new BufferedReader(new InputStreamReader(stream));
+            filename = "tests/tests.md";
+        }
+
+        try {
+            processFile();
+        } catch (Failure e) {
+            System.err.println("" + filename + ":" + lineno + ": " + e.getMessage());
+            return 1;
+        }
+        return 0;
+    }
+
+    private int handleFlags(String arg) {
+        if (!arg.startsWith("-") || arg.equals("-")) {
+            System.err.println("Invalid flag: " + arg);
+        }
+        String a = arg.substring(1);
+
+        while (!a.isEmpty()) {
+            char letter = a.charAt(0);
+            a = a.substring(1);
+            switch (letter) {
+                case 'v':
+                    verbose++;
+                    break;
+                default:
+                    System.err.println("Unexpected flag " + letter + " in " + arg);
+                    return -1;
+            }
+        }
+
+        return 0;
+    }
+
+    private void processFile() throws IOException, Failure {
+        while (true) {
+            String line = reader.readLine();
+            if (line == null)
+                break;
+            lineno++;
+            processLine(line);
+        }
+        if (verbose >= 1) {
+            System.out.println();
+            System.out.println("Ran " + testCount + " tests in " + lineno + " lines");
+        }
+    }
+
+    private void processLine(String line) throws Failure {
+        line = line.replaceFirst("\\s+$", ""); // remove trailing
+        if (props == null && line.equals("```test")) {
+            if (verbose >= 2) {
+                if (testCount > 0) {
+                    System.out.println();
+                }
+                System.out.println("\u25B6 " + filename + ":" + lineno);
+            }
+            props = Target.defaultProperties();
+            testCount++;
+            return;
+        }
+        if (props != null) {
+            if (line.equals("```")) {
+                stopProcessing();
+                return;
+            }
+            handleCommand(line);
+        }
+    }
+
+    private void stopProcessing() {
+        props = null;
+        validated = null;
+    }
+
+    private void handleCommand(String line) throws Failure {
+        if (verbose >= 3) {
+            System.out.println(line);
+        }
+        if (line.isEmpty())
+            return;
+
+        String[] parts = line.split("\\s+", 2);
+        String command = parts[0];
+        switch (command.toUpperCase()) {
+            case "ONLY":
+                handleOnly(true, parts[1]);
+                return;
+            case "NOT":
+                handleOnly(false, parts[1]);
+                return;
+            case "PARSE":
+                handleParse(parts[1], null);
+                return;
+            case "ACCEPT":
+                handleParse(parts[1], true);
+                return;
+            case "REJECT":
+                handleParse(parts[1], false);
+                return;
+            case "SET":
+                handleSet(parts[1]);
+                return;
+            case "EXPECT":
+                handleExpect(parts[1]);
+                return;
+            default:
+                throw new Failure("Unexpected command: " + command);
+        }
+
+    }
+
+    private void handleOnly(boolean mustBePresent, String rest) throws Failure {
+        boolean found = false;
+        for (String part: rest.split("\\s+")) {
+            if (part.equals("jdbc")) {
+                found = true;
+                break;
+            }
+        }
+        if (found != mustBePresent) {
+            // do not further process this block
+            stopProcessing();
+        }
+    }
+
+    private int findEqualSign(String rest) throws Failure {
+        int index = rest.indexOf('=');
+        if (index < -1)
+            throw new Failure("Expected to find a '='");
+        return index;
+    }
+
+    private String splitKey(String rest) throws Failure {
+        int index = findEqualSign(rest);
+        return rest.substring(0, index);
+    }
+
+    private String splitValue(String rest) throws Failure {
+        int index = findEqualSign(rest);
+        return rest.substring(index + 1);
+    }
+
+    private void handleSet(String rest) throws Failure {
+        validated = null;
+        String key = splitKey(rest);
+        String value = splitValue(rest);
+
+        props.put(key, value);
+    }
+
+    private void handleParse(String rest, Boolean shouldSucceed) throws Failure {
+        URISyntaxException parseError = null;
+        ValidationError validationError = null;
+
+        validated = null;
+        try {
+            MonetUrlParser.parse(props, rest);
+        } catch (URISyntaxException e) {
+            parseError = e;
+        }
+
+        if (parseError == null) {
+            try {
+                validated = new Target(props);
+            } catch (ValidationError e) {
+                validationError = e;
+            }
+        }
+
+        if (shouldSucceed == Boolean.FALSE) {
+            if (parseError != null || validationError != null)
+                return; // happy
+            else
+                throw new Failure("URL unexpectedly parsed and validated");
+        }
+
+        if (parseError != null)
+            throw new Failure("Parse error: " + parseError);
+        if (validationError != null && shouldSucceed == Boolean.TRUE)
+            throw new Failure("Validation error: " + validationError);
+    }
+
+    private void handleExpect(String rest) throws Failure {
+        String key = splitKey(rest);
+        String expectedString = splitValue(rest);
+
+        Object actual = null;
+        try {
+            actual = extract(key);
+        } catch (ValidationError e) {
+            throw new Failure(e.getMessage());
+        }
+
+        Object expected;
+        try {
+            if (actual instanceof Boolean)
+                expected = ParameterType.Bool.parse(key, expectedString);
+            else if (actual instanceof Integer)
+                expected = ParameterType.Int.parse(key, expectedString);
+            else
+                expected = expectedString;
+        } catch (ValidationError e) {
+            String typ = actual.getClass().getName();
+            throw new Failure("Cannot convert expected value <" + expectedString + "> to " + typ + ": " + e.getMessage());
+        }
+
+        if (actual.equals(expected))
+            return;
+        throw new Failure("Expected " + key + "=<" + expectedString + ">, found <" + actual + ">");
+    }
+
+    private Target tryValidate() throws ValidationError {
+        if (validated == null)
+            validated = new Target(props);
+        return validated;
+    }
+
+    private Object extract(String key) throws ValidationError, Failure {
+        switch (key) {
+            case "tls":
+                return Target.validateBoolean(props, Parameter.TLS);
+            case "host":
+                return Target.validateString(props, Parameter.HOST);
+            case "port":
+                return Target.validateInt(props, Parameter.PORT);
+            case "database":
+                return Target.validateString(props, Parameter.DATABASE);
+            case "tableschema":
+                return Target.validateString(props, Parameter.TABLESCHEMA);
+            case "table":
+                return Target.validateString(props, Parameter.TABLE);
+            case "sock":
+                return Target.validateString(props, Parameter.SOCK);
+            case "sockdir":
+                return Target.validateString(props, Parameter.SOCKDIR);
+            case "cert":
+                return Target.validateString(props, Parameter.CERT);
+            case "certhash":
+                return Target.validateString(props, Parameter.CERTHASH);
+            case "clientkey":
+                return Target.validateString(props, Parameter.CLIENTKEY);
+            case "clientcert":
+                return Target.validateString(props, Parameter.CLIENTCERT);
+            case "user":
+                return Target.validateString(props, Parameter.USER);
+            case "password":
+                return Target.validateString(props, Parameter.PASSWORD);
+            case "language":
+                return Target.validateString(props, Parameter.LANGUAGE);
+            case "autocommit":
+                return Target.validateBoolean(props, Parameter.AUTOCOMMIT);
+            case "schema":
+                return Target.validateString(props, Parameter.SCHEMA);
+            case "timezone":
+                return Target.validateInt(props, Parameter.TIMEZONE);
+            case "binary":
+                return Target.validateString(props, Parameter.BINARY);
+            case "replysize":
+                return Target.validateInt(props, Parameter.REPLYSIZE);
+            case "fetchsize":
+                return Target.validateInt(props, Parameter.FETCHSIZE);
+            case "hash":
+                return Target.validateString(props, Parameter.HASH);
+            case "debug":
+                return Target.validateBoolean(props, Parameter.DEBUG);
+            case "logfile":
+                return Target.validateString(props, Parameter.LOGFILE);
+
+            case "valid":
+                try {
+                    tryValidate();
+                } catch (ValidationError e) {
+                    return Boolean.FALSE;
+                }
+                return Boolean.TRUE;
+
+            case "connect_scan":
+                return tryValidate().connectScan();
+            case "connect_port":
+                return tryValidate().connectPort();
+            case "connect_unix":
+                return tryValidate().connectUnix();
+            case "connect_tcp":
+                return tryValidate().connectTcp();
+            case "connect_tls_verify":
+                switch (tryValidate().connectVerify()) {
+                    case None: return "";
+                    case Cert: return "cert";
+                    case Hash: return "hash";
+                    case System: return "system";
+                    default:
+                        throw new IllegalStateException("unreachable");
+                }
+            case "connect_certhash_digits":
+                return tryValidate().connectCertHashDigits();
+            case "connect_binary":
+                return tryValidate().connectBinary();
+            case "connect_clientkey":
+                return tryValidate().connectClientKey();
+            case "connect_clientcert":
+                return tryValidate().connectClientCert();
+
+            default:
+                throw new Failure("Unknown attribute: " + key);
+        }
+    }
+
+    private class Failure extends Exception {
+        public Failure(String message) {
+            super(message);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/tests/tests.md
@@ -0,0 +1,1458 @@
+# Tests
+
+This document contains a large number of test cases.
+They are embedded in the Markdown source, in <code>```test</code>
+. . .</code>```</code> blocks.
+
+
+The tests are written in a mini language with the following
+keywords:
+
+* `PARSE url`: parse the URL, this should succeed. The validity checks need
+  not be satisfied.
+
+* `ACCEPT url`: parse the URL, this should succeed. The validity checks
+  should pass.
+
+* `REJECT url`: parse the URL, it should be rejected either in the parsing stage
+  or by the validity checks.
+
+* `SET key=value`: modify a parameter, can occur before or after parsing the URL.
+  Used to model command line parameters, Java Properties objects, etc.
+
+* `EXPECT key=value`: verify that the given parameter now has the given
+  value. Fail the test case if the value is different.
+
+* `ONLY pymonetdb`: only process the rest of the block when testing
+  pymonetdb, ignore it for other implementations.
+
+* `NOT pymonetdb`: ignore the rest of the block when testing pymonetdb,
+  do process it for other implementations.
+
+At the start of each block the parameters are reset to their default values.
+
+The EXPECT clause can verify all parameters listen in the Parameters section of
+the spec, all 'virtual parameters' and also the special case `valid` which is a
+boolean indicating whether all validity rules in section 'Interpreting the
+parameters' hold.
+
+Note: an `EXPECT` of the virtual parameters implies `EXPECT valid=true`.
+
+TODO before 1.0 does the above explanation make sense?
+
+
+## Tests from the examples
+
+```test
+ACCEPT monetdb:///demo
+EXPECT database=demo
+EXPECT connect_scan=true
+```
+
+```test
+ACCEPT monetdb://localhost/demo
+EXPECT connect_scan=true
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdb://localhost./demo
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+EXPECT connect_port=50000
+EXPECT tls=off
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdb://localhost.:12345/demo
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+EXPECT connect_port=12345
+EXPECT tls=off
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdb://localhost:12345/demo
+EXPECT connect_scan=false
+EXPECT connect_unix=/tmp/.s.monetdb.12345
+EXPECT connect_tcp=localhost
+EXPECT connect_port=12345
+EXPECT tls=off
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdb:///demo?user=monetdb&password=monetdb
+EXPECT connect_scan=true
+EXPECT database=demo
+EXPECT user=monetdb
+EXPECT password=monetdb
+```
+
+```test
+ACCEPT monetdb://mdb.example.com:12345/demo
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=mdb.example.com
+EXPECT connect_port=12345
+EXPECT tls=off
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdb://192.168.13.4:12345/demo
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=192.168.13.4
+EXPECT connect_port=12345
+EXPECT tls=off
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdb://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:12345/demo
+EXPECT host=2001:0db8:85a3:0000:0000:8a2e:0370:7334
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=2001:0db8:85a3:0000:0000:8a2e:0370:7334
+EXPECT connect_port=12345
+EXPECT tls=off
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdb://localhost/
+EXPECT connect_unix=/tmp/.s.monetdb.50000
+EXPECT connect_scan=false
+EXPECT connect_tcp=localhost
+EXPECT tls=off
+EXPECT database=
+```
+
+```test
+ACCEPT monetdb://localhost
+EXPECT connect_scan=false
+EXPECT connect_unix=/tmp/.s.monetdb.50000
+EXPECT connect_tcp=localhost
+EXPECT tls=off
+EXPECT database=
+```
+
+```test
+ACCEPT monetdbs://mdb.example.com/demo
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=mdb.example.com
+EXPECT connect_port=50000
+EXPECT tls=on
+EXPECT connect_tls_verify=system
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdbs:///demo
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+EXPECT connect_port=50000
+EXPECT tls=on
+EXPECT connect_tls_verify=system
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdbs://mdb.example.com/demo?cert=/home/user/server.crt
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=mdb.example.com
+EXPECT connect_port=50000
+EXPECT tls=on
+EXPECT connect_tls_verify=cert
+EXPECT cert=/home/user/server.crt
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdbs://mdb.example.com/demo?certhash=sha256:fb:67:20:aa:00:9f:33:4c
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=mdb.example.com
+EXPECT connect_port=50000
+EXPECT tls=on
+EXPECT connect_tls_verify=hash
+EXPECT certhash=sha256:fb:67:20:aa:00:9f:33:4c
+EXPECT connect_certhash_digits=fb6720aa009f334c
+EXPECT database=demo
+```
+
+```test
+ACCEPT monetdb:///demo?sock=/var/monetdb/_sock&user=dbuser
+EXPECT connect_scan=false
+EXPECT connect_unix=/var/monetdb/_sock
+EXPECT connect_tcp=
+EXPECT tls=off
+EXPECT database=demo
+EXPECT user=dbuser
+EXPECT password=
+```
+
+```test
+ACCEPT monetdb://localhost/demo?sock=/var/monetdb/_sock&user=dbuser
+EXPECT connect_scan=false
+EXPECT connect_unix=/var/monetdb/_sock
+EXPECT connect_tcp=
+EXPECT tls=off
+EXPECT database=demo
+EXPECT user=dbuser
+EXPECT password=
+```
+
+
+## Parameter tests
+
+Tests derived from the parameter section. Test data types and defaults.
+
+Everything can be SET and EXPECTed
+
+```test
+SET tls=on
+EXPECT tls=on
+SET host=bananahost
+EXPECT host=bananahost
+SET port=123
+EXPECT port=123
+SET database=bananadatabase
+EXPECT database=bananadatabase
+SET tableschema=bananatableschema
+EXPECT tableschema=bananatableschema
+SET table=bananatable
+EXPECT table=bananatable
+SET sock=c:\foo.txt
+EXPECT sock=c:\foo.txt
+SET cert=c:\foo.txt
+EXPECT cert=c:\foo.txt
+SET certhash=bananacerthash
+EXPECT certhash=bananacerthash
+SET clientkey=c:\foo.txt
+EXPECT clientkey=c:\foo.txt
+SET clientcert=c:\foo.txt
+EXPECT clientcert=c:\foo.txt
+SET user=bananauser
+EXPECT user=bananauser
+SET password=bananapassword
+EXPECT password=bananapassword
+SET language=bananalanguage
+EXPECT language=bananalanguage
+SET autocommit=on
+EXPECT autocommit=on
+SET schema=bananaschema
+EXPECT schema=bananaschema
+SET timezone=123
+EXPECT timezone=123
+SET binary=bananabinary
+EXPECT binary=bananabinary
+SET replysize=123
+EXPECT replysize=123
+SET fetchsize=123
+EXPECT fetchsize=123
+```
+
+### core defaults
+
+```test
+EXPECT tls=false
+EXPECT host=
+EXPECT port=-1
+EXPECT database=
+EXPECT tableschema=
+EXPECT table=
+EXPECT binary=on
+```
+
+### sock
+
+Not supported on Windows, but they should still parse.
+
+```test
+EXPECT sock=
+ACCEPT monetdb:///?sock=/tmp/sock
+EXPECT sock=/tmp/sock
+ACCEPT monetdb:///?sock=C:/TEMP/sock
+EXPECT sock=C:/TEMP/sock
+NOT jdbc
+ACCEPT monetdb:///?sock=C:\TEMP\sock
+EXPECT sock=C:\TEMP\sock
+```
+
+### sockdir
+
+```test
+EXPECT sockdir=/tmp
+ACCEPT monetdb:///demo?sockdir=/tmp/nonstandard
+EXPECT sockdir=/tmp/nonstandard
+EXPECT connect_unix=/tmp/nonstandard/.s.monetdb.50000
+```
+
+### cert
+
+```test
+EXPECT cert=
+ACCEPT monetdbs:///?cert=/tmp/cert.pem
+EXPECT cert=/tmp/cert.pem
+ACCEPT monetdbs:///?cert=C:/TEMP/cert.pem
+EXPECT cert=C:/TEMP/cert.pem
+NOT jdbc
+ACCEPT monetdbs:///?cert=C:\TEMP\cert.pem
+EXPECT cert=C:\TEMP\cert.pem
+```
+
+### certhash
+
+```test
+EXPECT certhash=
+ACCEPT monetdbs:///?certhash=sha256:001122ff
+ACCEPT monetdbs:///?certhash=sha256:00:11:22:ff
+ACCEPT monetdbs:///?certhash=sha256:::::aa::ff:::::
+ACCEPT monetdbs:///?certhash=sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+```
+
+This string of hexdigits is longer than the length of a SHA-256 digest.
+It still parses, it will just never match.
+
+```test
+ACCEPT monetdbs:///?certhash=sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b8550
+ACCEPT monetdbs:///?certhash=sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855000000000000000000000000000000000000000001
+```
+
+```test
+REJECT monetdbs:///?certhash=001122ff
+REJECT monetdbs:///?certhash=Sha256:001122ff
+REJECT monetdbs:///?certhash=sha256:001122gg
+REJECT monetdbs:///?certhash=sha
+REJECT monetdbs:///?certhash=sha1:aabbcc
+REJECT monetdbs:///?certhash=sha1:
+REJECT monetdbs:///?certhash=sha1:X
+REJECT monetdbs:///?certhash=sha99:aabbcc
+REJECT monetdbs:///?certhash=sha99:
+REJECT monetdbs:///?certhash=sha99:X
+```
+
+### clientkey, clientcert
+
+```test
+EXPECT clientkey=
+EXPECT clientcert=
+ACCEPT monetdbs:///?clientkey=/tmp/clientkey.pem
+EXPECT clientkey=/tmp/clientkey.pem
+ACCEPT monetdbs:///?clientkey=C:/TEMP/clientkey.pem
+EXPECT clientkey=C:/TEMP/clientkey.pem
+NOT jdbc
+ACCEPT monetdbs:///?clientkey=C:\TEMP\clientkey.pem
+EXPECT clientkey=C:\TEMP\clientkey.pem
+```
+
+```test
+EXPECT connect_clientkey=
+EXPECT connect_clientcert=
+```
+
+```test
+SET clientkey=/tmp/key.pem
+SET clientcert=/tmp/cert.pem
+EXPECT valid=true
+EXPECT connect_clientkey=/tmp/key.pem
+EXPECT connect_clientcert=/tmp/cert.pem
+```
+
+```test
+SET clientkey=/tmp/key.pem
+EXPECT valid=true
+EXPECT connect_clientkey=/tmp/key.pem
+EXPECT connect_clientcert=/tmp/key.pem
+```
+
+```test
+SET clientcert=/tmp/cert.pem
+EXPECT valid=false
+```
+
+```test
+SET clientkey=dummy
+EXPECT clientcert=
+ACCEPT monetdbs:///?clientcert=/tmp/clientcert.pem
+EXPECT clientcert=/tmp/clientcert.pem
+ACCEPT monetdbs:///?clientcert=C:/TEMP/clientcert.pem
+EXPECT clientcert=C:/TEMP/clientcert.pem
+NOT jdbc
+ACCEPT monetdbs:///?clientcert=C:\TEMP\clientcert.pem
+EXPECT clientcert=C:\TEMP\clientcert.pem
+```
+
+### user, password
+
+Not testing the default because they are (unfortunately)
+implementation specific.
+
+```test
+ACCEPT monetdb:///?user=monetdb
+EXPECT user=monetdb
+ACCEPT monetdb:///?user=me&password=?
+EXPECT user=me
+EXPECT password=?
+```
+
+### language
+
+```test
+EXPECT language=sql
+ACCEPT monetdb:///?language=msql
+EXPECT language=msql
+ACCEPT monetdb:///?language=sql
+EXPECT language=sql
+```
+
+### autocommit
+
+```test
+ACCEPT monetdb:///?autocommit=true
+EXPECT autocommit=true
+ACCEPT monetdb:///?autocommit=on
+EXPECT autocommit=true
+ACCEPT monetdb:///?autocommit=yes
+EXPECT autocommit=true
+```
+
+```test
+ACCEPT monetdb:///?autocommit=false
+EXPECT autocommit=false
+ACCEPT monetdb:///?autocommit=off
+EXPECT autocommit=false
+ACCEPT monetdb:///?autocommit=no
+EXPECT autocommit=false
+```
+
+```test
+REJECT monetdb:///?autocommit=
+REJECT monetdb:///?autocommit=banana
+REJECT monetdb:///?autocommit=0
+REJECT monetdb:///?autocommit=1
+```
+
+### schema, timezone
+
+Must be accepted, no constraints on content
+
+```test
+EXPECT schema=
+ACCEPT monetdb:///?schema=foo
+EXPECT schema=foo
+ACCEPT monetdb:///?schema=
+EXPECT schema=
+ACCEPT monetdb:///?schema=foo
+```
+
+```test
+ACCEPT monetdb:///?timezone=0
+EXPECT timezone=0
+ACCEPT monetdb:///?timezone=120
+EXPECT timezone=120
+ACCEPT monetdb:///?timezone=-120
+EXPECT timezone=-120
+REJECT monetdb:///?timezone=banana
+REJECT monetdb:///?timezone=
+```
+
+### replysize and fetchsize
+
+Note we never check `EXPECT fetchsize=`, it doesn't exist.
+
+```test
+ACCEPT monetdb:///?replysize=150
+EXPECT replysize=150
+ACCEPT monetdb:///?fetchsize=150
+EXPECT replysize=150
+ACCEPT monetdb:///?fetchsize=100&replysize=200
+EXPECT replysize=200
+ACCEPT monetdb:///?replysize=100&fetchsize=200
+EXPECT replysize=200
+REJECT monetdb:///?replysize=
+REJECT monetdb:///?fetchsize=
+```
+
+### binary
+
+```test
+EXPECT binary=on
+EXPECT connect_binary=65535
+```
+
+```test
+ACCEPT monetdb:///?binary=on
+EXPECT connect_binary=65535
+
+ACCEPT monetdb:///?binary=yes
+EXPECT connect_binary=65535
+
+ACCEPT monetdb:///?binary=true
+EXPECT connect_binary=65535
+
+ACCEPT monetdb:///?binary=yEs
+EXPECT connect_binary=65535
+```
+
+```test
+ACCEPT monetdb:///?binary=off
+EXPECT connect_binary=0
+
+ACCEPT monetdb:///?binary=no
+EXPECT connect_binary=0
+
+ACCEPT monetdb:///?binary=false
+EXPECT connect_binary=0
+```
+
+```test
+ACCEPT monetdb:///?binary=0
+EXPECT connect_binary=0
+
+ACCEPT monetdb:///?binary=5
+EXPECT connect_binary=5
+
+ACCEPT monetdb:///?binary=0100
+EXPECT connect_binary=100
+```
+
+```test
+REJECT monetdb:///?binary=
+REJECT monetdb:///?binary=-1
+REJECT monetdb:///?binary=1.0
+REJECT monetdb:///?binary=banana
+```
+
+### unknown parameters
+
+```test
+REJECT monetdb:///?banana=bla
+```
+
+```test
+ACCEPT monetdb:///?ban_ana=bla
+ACCEPT monetdb:///?hash=sha1
+ACCEPT monetdb:///?debug=true
+ACCEPT monetdb:///?logfile=banana
+```
+
+Unfortunately we can't easily test that it won't allow us
+to SET banana.
+
+```test
+SET ban_ana=bla
+SET hash=sha1
+SET debug=true
+SET logfile=banana
+```
+
+## Combining sources
+
+The defaults have been tested in the previous section.
+
+Rule: If there is overlap, later sources take precedence.
+
+```test
+SET schema=a
+ACCEPT monetdb:///db1?schema=b
+EXPECT schema=b
+EXPECT database=db1
+EXPECT tls=off
+ACCEPT monetdbs:///db2?schema=c
+EXPECT tls=on
+EXPECT database=db2
+EXPECT schema=c
+```
+
+Rule: a source that sets user must set password or clear.
+
+```skiptest
+ACCEPT monetdb:///?user=foo
+EXPECT user=foo
+EXPECT password=
+SET password=banana
+EXPECT user=foo
+EXPECT password=banana
+SET user=bar
+EXPECT password=
+```
+
+Rule: fetchsize is an alias for replysize, last occurrence counts
+
+```test
+SET replysize=200
+ACCEPT monetdb:///?fetchsize=400
+EXPECT replysize=400
+ACCEPT monetdb:///?replysize=500&fetchsize=600
+EXPECT replysize=600
+```
+
+```test
+NOT jdbc
+SET replysize=200
+SET fetchsize=300
+EXPECT replysize=300
+```
+
+
+
+Rule: parsing a URL sets all of tls, host, port and database
+even if left out of the URL
+
+```test
+SET tls=on
+SET host=banana
+SET port=12345
+SET database=foo
+SET timezone=120
+ACCEPT monetdb:///
+EXPECT tls=off
+EXPECT host=
+EXPECT port=-1
+EXPECT database=
+```
+
+```test
+SET tls=on
+SET host=banana
+SET port=12345
+SET database=foo
+SET timezone=120
+ACCEPT monetdb://dbhost/dbdb
+EXPECT tls=off
+EXPECT host=dbhost
+EXPECT port=-1
+EXPECT database=dbdb
+```
+
+Careful around passwords
+
+```test
+SET user=alan
+SET password=turing
+ACCEPT monetdbs:///
+EXPECT user=alan
+EXPECT password=turing
+```
+
+```test
+SET user=alan
+SET password=turing
+ACCEPT monetdbs:///?user=mathison
+EXPECT user=mathison
+EXPECT password=
+```
+
+The rule is, "if **user** is set", not "if **user** is changed".
+
+```test
+SET user=alan
+SET password=turing
+ACCEPT monetdbs:///?user=alan
+EXPECT user=alan
+EXPECT password=
+```
+
+## URL syntax
+
+General form
+
+```test
+REJECT monetdb:
+REJECT monetdbs:
+REJECT monetdb:/
+REJECT monetdbs:/
+REJECT monetdb://
+REJECT monetdbs://
+ACCEPT monetdb:///
+ACCEPT monetdbs:///
+```
+
+
+```test
+ACCEPT monetdb://host:12345/db1/schema2/table3?user=mr&password=bean
+EXPECT tls=off
+EXPECT host=host
+EXPECT port=12345
+EXPECT database=db1
+EXPECT tableschema=schema2
+EXPECT table=table3
+EXPECT user=mr
+EXPECT password=bean
+```
+
+Also, TLS and percent-escapes
+
+```test
+ACCEPT monetdbs://h%6Fst:12345/db%31/schema%32/table%33?user=%6Dr&p%61ssword=bean
+EXPECT tls=on
+EXPECT host=host
+EXPECT port=12345
+EXPECT database=db1
+EXPECT tableschema=schema2
+EXPECT table=table3
+EXPECT user=mr
+EXPECT password=bean
+```
+
+Port number
+
+```test
+REJECT monetdb://banana:0/
+REJECT monetdb://banana:-1/
+REJECT monetdb://banana:65536/
+REJECT monetdb://banana:100000/
+```
+
+Trailing slash can be left off
+
+```test
+ACCEPT monetdb://host?user=claude&password=m%26ms
+EXPECT host=host
+EXPECT user=claude
+EXPECT password=m&ms
+```
+
+Error to set tls, host, port, database, tableschema and table as query parameters.
+
+```test
+REJECT monetdb://foo:1/bar?tls=off
+REJECT monetdb://foo:1/bar?host=localhost
+REJECT monetdb://foo:1/bar?port=12345
+REJECT monetdb://foo:1/bar?database=allmydata
+REJECT monetdb://foo:1/bar?tableschema=banana
+REJECT monetdb://foo:1/bar?table=tabularity
+```
+
+Last wins, already tested elsewhere but for completeness
+
+```test
+ACCEPT monetdbs:///?timezone=10&timezone=20
+EXPECT timezone=20
+```
+
+Interesting case: setting user must clear the password but does
+that also happen with repetitions within a URL?
+Not sure. For the time being, no. This makes it easier for
+situations where for example the query parameters come in
+alphabetical order
+
+```test
+ACCEPT monetdb:///?user=foo&password=banana&user=bar
+EXPECT user=bar
+EXPECT password=banana
+```
+
+Similar but even simpler: user comes after password but does not
+clear it.
+
+```test
+ACCEPT monetdb:///?password=pw&user=foo
+EXPECT user=foo
+EXPECT password=pw
+```
+
+Ways of writing booleans and the binary property have already been tested above.
+
+Ip numbers:
+
+```test
+ACCEPT monetdb://192.168.1.1:12345/foo
+EXPECT connect_unix=
+EXPECT connect_tcp=192.168.1.1
+EXPECT database=foo
+```
+
+```test
+ACCEPT monetdb://[::1]:12345/foo
+EXPECT connect_unix=
+EXPECT connect_tcp=::1
+EXPECT database=foo
+```
+
+Bad percent escapes:
+
+```test
+REJECT monetdb:///m%xxbad
+```
+
+
+## Interpreting
+
+Testing the validity constraints.
+They apply both when parsing a URL and with ad-hoc settings.
+
+Rule 1, the type constraints, has already been tested in [Section Parameter
+tests](#parameter-tests).
+
+Rule 2, interaction between **sock** and **host** is tested below in
+[the next subsection](#interaction-between-tls-host-sock-and-database).
+
+Rule 3, about **binary**, is tested in [Subsection Binary](#binary).
+
+Rule 4, **sock** vs **tls** is tested below in [the next
+subsection](#interaction-between-tls-host-sock-and-database).
+
+Rule 5, **certhash** syntax, is tested in [Subsection Certhash](#certhash).
+
+Rule 6, **tls** **cert** **certhash** interaction, is tested
+in [Subsection Interaction between tls, cert and certhash](#interaction-between-tls-cert-and-certhash).
+
+Rule 7, **database**, **tableschema**, **table** is tested in [Subsection
+Database, schema, table name
+constraints](#database-schema-table-name-constraints).
+
+Here are some tests for Rule 8, **port**.
+
+```test
+SET port=1
+EXPECT valid=true
+SET port=10
+EXPECT valid=true
+SET port=000010
+EXPECT valid=true
+SET port=65535
+EXPECT valid=true
+SET port=-1
+EXPECT valid=true
+SET port=0
+EXPECT valid=false
+SET port=-2
+EXPECT valid=false
+SET port=65536
+EXPECT valid=false
+```
+
+### Database, schema, table name constraints
+
+```test
+SET database=
+EXPECT valid=yes
+SET database=banana
+EXPECT valid=yes
+SET database=UPPERCASE
+EXPECT valid=yes
+SET database=_under_score_
+EXPECT valid=yes
+SET database=with-dashes
+EXPECT valid=yes
+```
+
+```test
+SET database=with/slash
+EXPECT valid=no
+SET database=-flag
+EXPECT valid=no
+SET database=with space
+EXPECT valid=no
+SET database=with.period
+EXPECT valid=yes
+SET database=with%percent
+EXPECT valid=no
+SET database=with!exclamation
+EXPECT valid=no
+SET database=with?questionmark
+EXPECT valid=no
+```
+
+```test
+SET database=demo
+SET tableschema=
+EXPECT valid=yes
+SET tableschema=banana
+EXPECT valid=yes
+SET tableschema=UPPERCASE
+EXPECT valid=yes
+SET tableschema=_under_score_
+EXPECT valid=yes
+SET tableschema=with-dashes
+EXPECT valid=yes
+```
+
+```test
+SET database=demo
+SET tableschema=with/slash
+EXPECT valid=no
+SET tableschema=-flag
+EXPECT valid=no
+SET tableschema=with space
+EXPECT valid=no
+SET tableschema=with.period
+EXPECT valid=yes
+SET tableschema=with%percent
+EXPECT valid=no
+SET tableschema=with!exclamation
+EXPECT valid=no
+SET tableschema=with?questionmark
+EXPECT valid=no
+```
+
+```test
+SET database=demo
+SET tableschema=sys
+SET table=
+EXPECT valid=yes
+SET table=banana
+EXPECT valid=yes
+SET table=UPPERCASE
+EXPECT valid=yes
+SET table=_under_score_
+EXPECT valid=yes
+SET table=with-dashes
+EXPECT valid=yes
+```
+
+```test
+SET database=demo
+SET tableschema=sys
+SET table=with/slash
+EXPECT valid=no
+SET table=-flag
+EXPECT valid=no
+SET table=with space
+EXPECT valid=no
+SET table=with.period
+EXPECT valid=yes
+SET table=with%percent
+EXPECT valid=no
+SET table=with!exclamation
+EXPECT valid=no
+SET table=with?questionmark
+EXPECT valid=no
+```
+
+### Interaction between tls, cert and certhash
+
+```test
+ACCEPT monetdbs:///?cert=/a/path
+EXPECT connect_tls_verify=cert
+ACCEPT monetdbs:///?certhash=sha256:aa
+EXPECT connect_tls_verify=hash
+ACCEPT monetdbs:///?cert=/a/path&certhash=sha256:aa
+EXPECT connect_tls_verify=hash
+REJECT monetdb:///?cert=/a/path
+REJECT monetdb:///?certhash=sha256:aa
+```
+
+```test
+SET tls=off
+SET cert=
+SET certhash=
+EXPECT valid=yes
+EXPECT connect_tls_verify=
+```
+
+```test
+SET tls=off
+SET cert=
+SET certhash=sha256:abcdef
+EXPECT valid=no
+```
+
+```test
+SET tls=off
+SET cert=/foo
+SET certhash=
+EXPECT valid=no
+```
+
+```test
+SET tls=off
+SET cert=/foo
+SET certhash=sha256:abcdef
+EXPECT valid=no
+```
+
+```test
+SET tls=on
+SET cert=
+SET certhash=
+EXPECT valid=yes
+EXPECT connect_tls_verify=system
+```
+
+```test
+SET tls=on
+SET cert=
+SET certhash=sha256:abcdef
+EXPECT valid=yes
+EXPECT connect_tls_verify=hash
+```
+
+```test
+SET tls=on
+SET cert=/foo
+SET certhash=
+EXPECT valid=yes
+EXPECT connect_tls_verify=cert
+```
+
+```test
+SET tls=on
+SET cert=/foo
+SET certhash=sha256:abcdef
+EXPECT valid=yes
+EXPECT connect_tls_verify=hash
+```
+
+
+### Interaction between tls, host, sock and database
+
+The following tests should exhaustively test all variants.
+
+```test
+ACCEPT monetdb:///
+EXPECT connect_scan=off
+EXPECT connect_unix=/tmp/.s.monetdb.50000
+EXPECT connect_tcp=localhost
+```
+
+```test
+ACCEPT monetdb:///?sock=/a/path
+EXPECT connect_scan=off
+EXPECT connect_unix=/a/path
+EXPECT connect_tcp=
+```
+
+```test
+ACCEPT monetdb://localhost/
+EXPECT connect_scan=off
+EXPECT connect_unix=/tmp/.s.monetdb.50000
+EXPECT connect_tcp=localhost
+```
+
+```test
+ACCEPT monetdb://localhost/?sock=/a/path
+EXPECT connect_scan=off
+EXPECT connect_unix=/a/path
+EXPECT connect_tcp=
+```
+
+```test
+ACCEPT monetdb://localhost./
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+REJECT monetdb://localhost./?sock=/a/path
+```
+
+```test
+ACCEPT monetdb://not.localhost/
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=not.localhost
+```
+
+```test
+REJECT monetdb://not.localhost/?sock=/a/path
+```
+
+```test
+ACCEPT monetdbs:///
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+REJECT monetdbs:///?sock=/a/path
+```
+
+```test
+ACCEPT monetdbs://localhost/
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+REJECT monetdbs://localhost/?sock=/a/path
+```
+
+```test
+ACCEPT monetdbs://localhost./
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+REJECT monetdbs://localhost./?sock=/a/path
+```
+
+```test
+ACCEPT monetdbs://not.localhost/
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=not.localhost
+```
+
+```test
+REJECT monetdbs://not.localhost/?sock=/a/path
+```
+
+```test
+ACCEPT monetdb:///demo
+EXPECT connect_scan=on
+```
+
+```test
+ACCEPT monetdb:///demo?sock=/a/path
+EXPECT connect_scan=off
+EXPECT connect_unix=/a/path
+EXPECT connect_tcp=
+```
+
+```test
+ACCEPT monetdb://localhost/demo
+EXPECT connect_scan=on
+```
+
+```test
+ACCEPT monetdb://localhost/demo?sock=/a/path
+EXPECT connect_scan=off
+EXPECT connect_unix=/a/path
+EXPECT connect_tcp=
+```
+
+```test
+ACCEPT monetdb://localhost./demo
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+REJECT monetdb://localhost./?sock=/a/path
+```
+
+```test
+ACCEPT monetdb://not.localhost/demo
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=not.localhost
+```
+
+```test
+REJECT monetdb://not.localhost/?sock=/a/path
+```
+
+```test
+ACCEPT monetdbs:///demo
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+REJECT monetdbs:///?sock=/a/path
+```
+
+```test
+ACCEPT monetdbs://localhost/demo
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+REJECT monetdbs://localhost/?sock=/a/path
+```
+
+```test
+ACCEPT monetdbs://localhost./demo
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+REJECT monetdbs://localhost./?sock=/a/path
+```
+
+```test
+ACCEPT monetdbs://not.localhost/demo
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=not.localhost
+```
+
+```test
+REJECT monetdbs://not.localhost/?sock=/a/path
+```
+
+### sock and sockdir
+
+Sockdir only applies to implicit Unix domain sockets,
+not to ones that are given explicitly
+
+```test
+EXPECT sockdir=/tmp
+EXPECT port=-1
+EXPECT host=
+EXPECT connect_unix=/tmp/.s.monetdb.50000
+SET sockdir=/somewhere/else
+EXPECT connect_unix=/somewhere/else/.s.monetdb.50000
+SET port=12345
+EXPECT connect_unix=/somewhere/else/.s.monetdb.12345
+```
+
+## Legacy URL's
+
+```test
+REJECT mapi:
+REJECT mapi:monetdb
+REJECT mapi:monetdb:
+REJECT mapi:monetdb:/
+```
+
+```test
+ACCEPT mapi:monetdb://monet.db:12345/demo
+EXPECT host=monet.db
+EXPECT port=12345
+EXPECT database=demo
+EXPECT tls=off
+EXPECT language=sql
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=monet.db
+```
+
+This one is the golden standard:
+
+```test
+ACCEPT mapi:monetdb://localhost:12345/demo
+EXPECT host=localhost
+EXPECT port=12345
+EXPECT database=demo
+EXPECT tls=off
+EXPECT language=sql
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+ACCEPT mapi:monetdb://localhost:12345/
+EXPECT host=localhost
+EXPECT port=12345
+EXPECT database=
+EXPECT tls=off
+EXPECT language=sql
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+ACCEPT mapi:monetdb://localhost:12345
+EXPECT host=localhost
+EXPECT port=12345
+EXPECT database=
+EXPECT tls=off
+EXPECT language=sql
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+```
+
+```test
+ACCEPT mapi:monetdb://localhost/demo
+EXPECT connect_scan=false
+EXPECT connect_unix=
+EXPECT connect_tcp=localhost
+EXPECT connect_port=50000
+```
+
+```test
+ACCEPT mapi:monetdb://:12345/demo
+EXPECT host=
+EXPECT port=12345
+EXPECT database=demo
+EXPECT tls=off
+EXPECT language=sql
+EXPECT connect_scan=off
+EXPECT connect_unix=/tmp/.s.monetdb.12345
+EXPECT connect_tcp=localhost
+```
+
+```test
+ACCEPT mapi:monetdb://127.0.0.1:12345/demo
+EXPECT host=127.0.0.1
+EXPECT port=12345
+EXPECT database=demo
+EXPECT tls=off
+EXPECT language=sql
+EXPECT connect_scan=off
+EXPECT connect_unix=
+EXPECT connect_tcp=127.0.0.1
+```
+
+Database parameter allowed, overrides path
+
+```test
+ACCEPT mapi:monetdb://localhost:12345/demo?database=foo
+EXPECT database=foo
+```
+
+User, username and password parameters are ignored:
+
+```test
+SET user=alan
+SET password=turing
+ACCEPT mapi:monetdb://localhost:12345/demo?user=foo
+EXPECT user=alan
+EXPECT password=turing
+ACCEPT mapi:monetdb://localhost:12345/demo?password=foo
+EXPECT user=alan
+EXPECT password=turing
+```
+
+Pymonetdb used to accept user name and password before
+the host name and should continue to do so.
+
+
+```test
+ONLY pymonetdb
+SET user=alan
+SET password=turing
+ACCEPT mapi:monetdb://foo:bar@localhost:12345/demo
+EXPECT user=foo
+EXPECT password=bar
+ACCEPT mapi:monetdb://banana@localhost:12345/demo
+EXPECT user=banana
+EXPECT password=
+```
+
+```test
+NOT pymonetdb
+SET user=alan
+SET password=turing
+REJECT mapi:monetdb://foo:bar@localhost:12345/demo
+REJECT mapi:monetdb://banana@localhost:12345/demo
+```
+
+Unix domain sockets
+
+```test
+ACCEPT mapi:monetdb:///path/to/sock?database=demo
+EXPECT host=
+EXPECT sock=/path/to/sock
+EXPECT port=-1
+EXPECT database=demo
+EXPECT tls=off
+EXPECT language=sql
+EXPECT connect_unix=/path/to/sock
+EXPECT connect_tcp=
+```
+
+```test
+ACCEPT mapi:monetdb:///path/to/sock
+EXPECT host=
+EXPECT sock=/path/to/sock
+EXPECT port=-1
+EXPECT database=
+EXPECT tls=off
+EXPECT language=sql
+EXPECT connect_unix=/path/to/sock
+EXPECT connect_tcp=
+```
+
+Corner case: both libmapi and pymonetdb interpret this as an attempt
+to connect to socket '/'.  This will fail of course but the URL does parse
+
+```test
+ACCEPT mapi:monetdb:///
+EXPECT host=
+EXPECT sock=/
+EXPECT connect_unix=/
+EXPECT connect_tcp=
+```
+
+```test
+NOT pymonetdb
+PARSE mapi:monetdb:///foo:bar@path/to/sock
+EXPECT sock=/foo:bar@path/to/sock
+REJECT mapi:monetdb://foo:bar@/path/to/sock
+```
+
+```test
+ONLY pymonetdb
+SET user=alan
+SET password=turing
+ACCEPT mapi:monetdb://foo:bar@/path/to/sock
+EXPECT host=
+EXPECT sock=/path/to/sock
+EXPECT user=foo
+EXPECT password=bar
+EXPECT connect_unix=/path/to/sock
+EXPECT connect_tcp=
+```
+
+```test
+ONLY pymonetdb
+SET user=alan
+SET password=turing
+ACCEPT mapi:monetdb://foo@/path/to/sock
+EXPECT host=
+EXPECT sock=/path/to/sock
+EXPECT user=foo
+EXPECT password=
+EXPECT connect_unix=/path/to/sock
+EXPECT connect_tcp=
+```
+
+Language is supported
+
+```test
+SET language=sql
+ACCEPT mapi:monetdb://localhost:12345?language=mal
+EXPECT host=localhost
+EXPECT sock=
+EXPECT language=mal
+SET language=sql
+ACCEPT mapi:monetdb:///path/to/sock?language=mal
+EXPECT host=
+EXPECT sock=/path/to/sock
+EXPECT language=mal
+```
+
+No percent decoding is performed
+
+```test
+REJECT mapi:monetdb://localhost:1234%35/demo
+PARSE mapi:monetdb://loc%61lhost:12345/d%61tabase
+EXPECT host=loc%61lhost
+EXPECT database=d%61tabase
+EXPECT valid=no
+```
+
+```test
+PARSE mapi:monetdb://localhost:12345/db?database=b%61r&language=m%61l
+EXPECT database=b%61r
+EXPECT language=m%61l
+EXPECT valid=no
+```
+
+l%61nguage is an unknown parameter, thus ignored not rejected
+
+```test
+SET language=sql
+ACCEPT mapi:monetdb://localhost:12345/db?l%61nguage=mal
+EXPECT language=sql
+ACCEPT mapi:monetdb://localhost:12345/db?_l%61nguage=mal
+```
+