Mercurial > hg > monetdb-java
changeset 793:5bfe3357fb1c monetdbs
Use the new url parser
author | Joeri van Ruth <joeri.van.ruth@monetdbsolutions.com> |
---|---|
date | Wed, 06 Dec 2023 16:17:13 +0100 (16 months ago) |
parents | 9dea0795a926 |
children | a418afda6b21 |
files | src/main/java/org/monetdb/jdbc/MonetConnection.java src/main/java/org/monetdb/jdbc/MonetDriver.java src/main/java/org/monetdb/mcl/MCLException.java src/main/java/org/monetdb/mcl/io/BufferedMCLReader.java src/main/java/org/monetdb/mcl/net/MapiSocket.java src/main/java/org/monetdb/mcl/net/MonetUrlParser.java src/main/java/org/monetdb/mcl/net/Target.java tests/tests.md |
diffstat | 8 files changed, 576 insertions(+), 686 deletions(-) [+] |
line wrap: on
line diff
--- a/src/main/java/org/monetdb/jdbc/MonetConnection.java +++ b/src/main/java/org/monetdb/jdbc/MonetConnection.java @@ -25,7 +25,6 @@ import java.sql.SQLWarning; import java.sql.Savepoint; import java.sql.Statement; import java.util.ArrayList; -import java.util.Calendar; import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; @@ -36,8 +35,8 @@ import java.util.concurrent.Executor; import org.monetdb.mcl.io.BufferedMCLReader; import org.monetdb.mcl.io.BufferedMCLWriter; import org.monetdb.mcl.io.LineType; -import org.monetdb.mcl.net.HandshakeOption; import org.monetdb.mcl.net.MapiSocket; +import org.monetdb.mcl.net.Target; import org.monetdb.mcl.parser.HeaderLineParser; import org.monetdb.mcl.parser.MCLParseException; import org.monetdb.mcl.parser.StartOfHeaderParser; @@ -74,17 +73,8 @@ public class MonetConnection extends MonetWrapper implements Connection, AutoCloseable { - /** The hostname to connect to */ - private final String hostname; - /** The port to connect on the host to */ - private int port; - /** The database to use (currently not used) */ - private final String database; - /** The username to use when authenticating */ - private final String username; - /** The password to use when authenticating */ - private final String password; - + /** All connection parameters */ + Target target; /** A connection to mserver5 using a TCP socket */ private final MapiSocket server; /** The Reader from the server */ @@ -120,6 +110,9 @@ public class MonetConnection /** The number of results we receive from the server at once */ private int curReplySize = 100; // server default + private boolean sizeHeaderEnabled = false; // used during handshake + private boolean timeZoneSet = false; // used during handshake + /** A template to apply to each query (like pre and post fixes), filled in constructor */ // note: it is made public to the package as queryTempl[2] is used from MonetStatement @@ -137,11 +130,6 @@ public class MonetConnection /** The language which is used */ private final int lang; - /** Whether or not BLOB is mapped to Types.VARBINARY instead of Types.BLOB within this connection */ - private boolean treatBlobAsVarBinary = true; // turned on by default for optimal performance (from JDBC Driver release 3.0 onwards) - /** Whether or not CLOB is mapped to Types.VARCHAR instead of Types.CLOB within this connection */ - private boolean treatClobAsVarChar = true; // turned on by default for optimal performance (from JDBC Driver release 3.0 onwards) - /** The last set query timeout on the server as used by Statement, PreparedStatement and CallableStatement */ protected int lastSetQueryTimeout = 0; // 0 means no timeout, which is the default on the server @@ -155,137 +143,23 @@ public class MonetConnection * createStatement() call. This constructor is only accessible to * classes from the jdbc package. * - * @param props a Property hashtable holding the properties needed for connecting + * @param target a Target object holding the connection parameters * @throws SQLException if a database error occurs * @throws IllegalArgumentException is one of the arguments is null or empty */ - MonetConnection(final Properties props) + MonetConnection(Target target) throws SQLException, IllegalArgumentException { - HandshakeOption.AutoCommit autoCommitSetting = new HandshakeOption.AutoCommit(true); - HandshakeOption.ReplySize replySizeSetting = new HandshakeOption.ReplySize(DEF_FETCHSIZE); - HandshakeOption.SizeHeader sizeHeaderSetting = new HandshakeOption.SizeHeader(true); - HandshakeOption.TimeZone timeZoneSetting = new HandshakeOption.TimeZone(0); - - // for debug: System.out.println("New connection object. Received properties are: " + props.toString()); - // get supported property values from the props argument. - this.hostname = props.getProperty("host"); - - final String port_prop = props.getProperty("port"); - if (port_prop != null) { - try { - this.port = Integer.parseInt(port_prop); - } catch (NumberFormatException e) { - addWarning("Unable to parse port number from: " + port_prop, "M1M05"); - } - } - - this.database = props.getProperty("database"); - this.username = props.getProperty("user"); - this.password = props.getProperty("password"); - String language = props.getProperty("language"); - - boolean debug = false; - String debug_prop = props.getProperty("debug"); - if (debug_prop != null) { - debug = Boolean.parseBoolean(debug_prop); - } - - final String hash = props.getProperty("hash"); - - String autocommit_prop = props.getProperty("autocommit"); - if (autocommit_prop != null) { - boolean ac = Boolean.parseBoolean(autocommit_prop); - autoCommitSetting.set(ac); - } - - final String fetchsize_prop = props.getProperty("fetchsize"); - if (fetchsize_prop != null) { - try { - int fetchsize = Integer.parseInt(fetchsize_prop); - if (fetchsize > 0 || fetchsize == -1) { - replySizeSetting.set(fetchsize); - } else { - addWarning("Fetch size must either be positive or -1. Value " + fetchsize + " ignored", "M1M05"); - } - } catch (NumberFormatException e) { - addWarning("Unable to parse fetch size number from: " + fetchsize_prop, "M1M05"); - } - } - - final String treatBlobAsVarBinary_prop = props.getProperty("treat_blob_as_binary"); - if (treatBlobAsVarBinary_prop != null) { - treatBlobAsVarBinary = Boolean.parseBoolean(treatBlobAsVarBinary_prop); - if (treatBlobAsVarBinary) - typeMap.put("blob", Byte[].class); - } - - final String treatClobAsVarChar_prop = props.getProperty("treat_clob_as_varchar"); - if (treatClobAsVarChar_prop != null) { - treatClobAsVarChar = Boolean.parseBoolean(treatClobAsVarChar_prop); - if (treatClobAsVarChar) - typeMap.put("clob", String.class); - } - - int sockTimeout = 0; - final String so_timeout_prop = props.getProperty("so_timeout"); - if (so_timeout_prop != null) { - try { - sockTimeout = Integer.parseInt(so_timeout_prop); - if (sockTimeout < 0) { - addWarning("Negative socket timeout not allowed. Value ignored", "M1M05"); - sockTimeout = 0; - } - } catch (NumberFormatException e) { - addWarning("Unable to parse socket timeout number from: " + so_timeout_prop, "M1M05"); - } - } - - // check mandatory input arguments - if (hostname == null || hostname.isEmpty()) - throw new IllegalArgumentException("Missing or empty host name"); - if (port <= 0 || port > 65535) - throw new IllegalArgumentException("Invalid port number: " + port - + ". It should not be " + (port < 0 ? "negative" : (port > 65535 ? "larger than 65535" : "0"))); - if (username == null || username.isEmpty()) - throw new IllegalArgumentException("Missing or empty user name"); - if (password == null || password.isEmpty()) - throw new IllegalArgumentException("Missing or empty password"); - if (language == null || language.isEmpty()) { - // fallback to default language: sql - language = "sql"; - addWarning("No language specified, defaulting to 'sql'", "M1M05"); - } - - // warn about unrecognized property names - for (Entry<Object,Object> e: props.entrySet()) { - checkValidProperty(e.getKey().toString(), "MonetConnection"); - } - + this.target = target; server = new MapiSocket(); - if (hash != null) - server.setHash(hash); - if (database != null) - server.setDatabase(database); - server.setLanguage(language); - - // calculate our time zone offset - final Calendar cal = Calendar.getInstance(); - final int offsetMillis = cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET); - final int offsetSeconds = offsetMillis / 1000; - timeZoneSetting.set(offsetSeconds); - - server.setHandshakeOptions(new HandshakeOption<?>[] { - autoCommitSetting, - replySizeSetting, - sizeHeaderSetting, - timeZoneSetting, - }); // we're debugging here... uhm, should be off in real life - if (debug) { + if (target.isDebug()) { try { - final String fname = props.getProperty("logfile", "monet_" + System.currentTimeMillis() + ".log"); + String fname = target.getLogfile(); + if (fname == null) + fname = "monet_" + System.currentTimeMillis() + ".log"; + File f = new File(fname); int ext = fname.lastIndexOf('.'); @@ -304,26 +178,45 @@ public class MonetConnection } } + SqlOptionsCallback callback = null; + switch (target.getLanguage()) { + case "sql": + lang = LANG_SQL; + queryTempl[0] = "s"; // pre + queryTempl[1] = "\n;"; // post + queryTempl[2] = "\n;\n"; // separator + commandTempl[0] = "X"; // pre + commandTempl[1] = ""; // post + callback = new SqlOptionsCallback(); + break; + case "mal": + lang = LANG_MAL; + queryTempl[0] = ""; // pre + queryTempl[1] = ";\n"; // post + queryTempl[2] = ";\n"; // separator + commandTempl[0] = ""; // pre + commandTempl[1] = ""; // post + break; + default: + lang = LANG_UNKNOWN; + break; + } + try { - final java.util.List<String> warnings = server.connect(hostname, port, username, password); + + final java.util.List<String> warnings = server.connect(target, callback); for (String warning : warnings) { addWarning(warning, "01M02"); } - // apply NetworkTimeout value from legacy (pre 4.1) driver - // so_timeout calls - server.setSoTimeout(sockTimeout); - in = server.getReader(); out = server.getWriter(); final String error = in.discardRemainder(); if (error != null) throw new SQLNonTransientConnectionException((error.length() > 6) ? error.substring(6) : error, "08001"); - } catch (java.net.UnknownHostException e) { - throw new SQLNonTransientConnectionException("Unknown Host (" + hostname + "): " + e.getMessage(), "08006"); } catch (IOException e) { - throw new SQLNonTransientConnectionException("Unable to connect (" + hostname + ":" + port + "): " + e.getMessage(), "08006"); + throw new SQLNonTransientConnectionException("Cannot connect: " + e.getMessage(), "08006"); } catch (MCLParseException e) { throw new SQLNonTransientConnectionException(e.getMessage(), "08001"); } catch (org.monetdb.mcl.MCLException e) { @@ -335,53 +228,17 @@ public class MonetConnection throw sqle; } - // we seem to have managed to log in, let's store the - // language used and language specific query templates - if ("sql".equals(language)) { - lang = LANG_SQL; - - queryTempl[0] = "s"; // pre - queryTempl[1] = "\n;"; // post - queryTempl[2] = "\n;\n"; // separator - - commandTempl[0] = "X"; // pre - commandTempl[1] = ""; // post - //commandTempl[2] = "\nX"; // separator (is not used) - } else if ("mal".equals(language)) { - lang = LANG_MAL; - - queryTempl[0] = ""; // pre - queryTempl[1] = ";\n"; // post - queryTempl[2] = ";\n"; // separator - - commandTempl[0] = ""; // pre - commandTempl[1] = ""; // post - //commandTempl[2] = ""; // separator (is not used) - } else { - lang = LANG_UNKNOWN; - } - // Now take care of any handshake options not handled during the handshake - if (replySizeSetting.isSent()) { - this.curReplySize = replySizeSetting.get(); - } - this.defaultFetchSize = replySizeSetting.get(); + curReplySize = defaultFetchSize; if (lang == LANG_SQL) { - if (autoCommitSetting.mustSend(autoCommit)) { - setAutoCommit(autoCommitSetting.get()); - } else { - // update bookkeeping - autoCommit = autoCommitSetting.get(); + if (autoCommit != target.isAutocommit()) { + setAutoCommit(target.isAutocommit()); } - if (sizeHeaderSetting.mustSend(false)) { + if (!sizeHeaderEnabled) { sendControlCommand("sizeheader 1"); - } else { - // no bookkeeping to update } - if (timeZoneSetting.mustSend(0)) { - setTimezone(timeZoneSetting.get()); - } else { - // no bookkeeping to update + if (!timeZoneSet) { + setTimezone(target.getTimezone()); } } @@ -1780,7 +1637,7 @@ public class MonetConnection * @return whether the JDBC BLOB type should be mapped to VARBINARY type. */ boolean mapBlobAsVarBinary() { - return treatBlobAsVarBinary; + return target.isTreatBlobAsBinary(); } /** @@ -1791,7 +1648,7 @@ public class MonetConnection * @return whether the JDBC CLOB type should be mapped to VARCHAR type. */ boolean mapClobAsVarChar() { - return treatClobAsVarChar; + return target.isTreatClobAsVarchar(); } /** @@ -1800,13 +1657,7 @@ public class MonetConnection * @return the MonetDB JDBC Connection URL (without user name and password). */ String getJDBCURL() { - final StringBuilder sb = new StringBuilder(128); - sb.append("jdbc:monetdb://").append(hostname) - .append(':').append(port) - .append('/').append(database); - if (lang == LANG_MAL) - sb.append("?language=mal"); - return sb.toString(); + return target.buildUrl(); } /** @@ -3887,4 +3738,48 @@ public class MonetConnection super.close(); } } + + public static enum SqlOption { + Autocommit(1, "auto_commit"), + ReplySize(2, "reply_size"), + SizeHeader(3, "size_header"), + // NOTE: 4 has been omitted on purpose + TimeZone(5, "time_zone"), + ; + final int level; + final String field; + + SqlOption(int level, String field) { + this.level = level; + this.field = field; + } + } + + + private class SqlOptionsCallback extends MapiSocket.OptionsCallback { + private int level; + @Override + public void addOptions(String lang, int level) { + if (!lang.equals("sql")) + return; + this.level = level; + + // Try to add options and record that this happened if it succeeds. + if (contribute(SqlOption.Autocommit, target.isAutocommit() ? 1 : 0)) + autoCommit = target.isAutocommit(); + if (contribute(SqlOption.ReplySize, target.getReplysize())) + defaultFetchSize = target.getReplysize(); + if (contribute(SqlOption.SizeHeader, 1)) + sizeHeaderEnabled = true; + if (contribute(SqlOption.TimeZone, target.getTimezone())) + timeZoneSet = true; + } + + private boolean contribute(SqlOption opt, int value) { + if (this.level <= opt.level) + return false; + contribute(opt.field, value); + return true; + } + } }
--- a/src/main/java/org/monetdb/jdbc/MonetDriver.java +++ b/src/main/java/org/monetdb/jdbc/MonetDriver.java @@ -8,7 +8,12 @@ package org.monetdb.jdbc; -import java.net.URI; +import org.monetdb.mcl.net.MonetUrlParser; +import org.monetdb.mcl.net.Parameter; +import org.monetdb.mcl.net.Target; +import org.monetdb.mcl.net.ValidationError; + +import java.net.URISyntaxException; import java.sql.Connection; import java.sql.Driver; import java.sql.DriverManager; @@ -97,56 +102,24 @@ public final class MonetDriver implement if (!acceptsURL(url)) return null; - final Properties props = new Properties(); - // set the optional properties and their defaults here - props.put("port", "50000"); - props.put("debug", "false"); - props.put("language", "sql"); // mal, sql, <future> - props.put("so_timeout", "0"); + Target target = new Target(); - if (info != null) - props.putAll(info); - info = props; - - // remove leading "jdbc:" so the rest is a valid hierarchical URI - final URI uri; try { - uri = new URI(url.substring(5)); - } catch (java.net.URISyntaxException e) { - return null; + if (info != null) { + for (String key : info.stringPropertyNames()) { + String value = info.getProperty(key); + if (key.equals(Parameter.HOST.name)) + value = Target.unpackHost(value); + target.setString(key, value); + } + } + MonetUrlParser.parse(target, url.substring(5)); + } catch (ValidationError | URISyntaxException e) { + throw new SQLException(e.getMessage()); } - final String uri_host = uri.getHost(); - if (uri_host == null) - return null; - info.put("host", uri_host); - - int uri_port = uri.getPort(); - if (uri_port > 0) - info.put("port", Integer.toString(uri_port)); - - // check the database - String uri_path = uri.getPath(); - if (uri_path != null && !uri_path.isEmpty()) { - uri_path = uri_path.substring(1).trim(); - if (!uri_path.isEmpty()) - info.put("database", uri_path); - } - - final String uri_query = uri.getQuery(); - if (uri_query != null) { - int pos; - // handle additional connection properties separated by the & character - final String args[] = uri_query.split("&"); - for (int i = 0; i < args.length; i++) { - pos = args[i].indexOf('='); - if (pos > 0) - info.put(args[i].substring(0, pos), args[i].substring(pos + 1)); - } - } - - // finally return the Connection object as requested - return new MonetConnection(info); + // finally return the Connection object as requested + return new MonetConnection(target); } /**
--- a/src/main/java/org/monetdb/mcl/MCLException.java +++ b/src/main/java/org/monetdb/mcl/MCLException.java @@ -15,7 +15,11 @@ package org.monetdb.mcl; public final class MCLException extends Exception { private static final long serialVersionUID = 1L; - public MCLException(String e) { - super(e); + public MCLException(String message) { + super(message); + } + + public MCLException(String message, Exception cause) { + super(message, cause); } }
--- a/src/main/java/org/monetdb/mcl/io/BufferedMCLReader.java +++ b/src/main/java/org/monetdb/mcl/io/BufferedMCLReader.java @@ -95,6 +95,14 @@ public final class BufferedMCLReader { } /** + * Return a substring of the current line, or null if we're at the end or before the beginning. + * @return the current line or null + */ + public String getLine(int start) { + return getLine().substring(start); + } + + /** * getLineType returns the type of the current line. * * @return Linetype representing the kind of line this is, one of the
--- a/src/main/java/org/monetdb/mcl/net/MapiSocket.java +++ b/src/main/java/org/monetdb/mcl/net/MapiSocket.java @@ -17,16 +17,17 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Writer; -import java.net.Socket; -import java.net.SocketException; -import java.net.UnknownHostException; -import java.net.URI; +import java.net.*; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import org.monetdb.mcl.MCLException; import org.monetdb.mcl.io.BufferedMCLReader; @@ -85,10 +86,21 @@ import org.monetdb.mcl.parser.MCLParseEx * @see org.monetdb.mcl.io.BufferedMCLWriter */ public final class MapiSocket { + private static final String[][] KNOWN_ALGORITHMS = new String[][] { + {"SHA512", "SHA-512"}, + {"SHA384", "SHA-384"}, + {"SHA256", "SHA-256"}, + // should we deprecate this by now? + {"SHA1", "SHA-1"}, + }; + + // MUST be lowercase! + private static final char[] HEXDIGITS = "0123456789abcdef".toCharArray(); + + /** Connection parameters */ + private Target target; /** The TCP Socket to mserver */ private Socket con; - /** The TCP Socket timeout in milliseconds. Default is 0 meaning the timeout is disabled (i.e., timeout of infinity) */ - private int soTimeout = 0; /** Stream from the Socket for reading */ private BlockInputStream fromMonet; /** Stream from the Socket for writing */ @@ -100,36 +112,25 @@ public final class MapiSocket { /** protocol version of the connection */ private int version; - /** The database to connect to */ - private String database = null; - /** The language to connect with */ - private String language = "sql"; - /** The hash methods to use (null = default) */ - private String hash = null; - /** Whether we should follow redirects */ private boolean followRedirects = true; /** How many redirections do we follow until we're fed up with it? */ private int ttl = 10; - /** Whether we are debugging or not */ - private boolean debug = false; /** The Writer for the debug log-file */ private Writer log; /** The blocksize (hardcoded in compliance with MonetDB common/stream/stream.h) */ - public final static int BLOCK = 8 * 1024 - 2; + public final static int BLOCK = 8190; /** A short in two bytes for holding the block size in bytes */ private final byte[] blklen = new byte[2]; - /** Options that can be sent during the auth handshake if the server supports it */ - private HandshakeOption<?>[] handshakeOptions; - /** * Constructs a new MapiSocket. */ public MapiSocket() { + target = new Target(); con = null; } @@ -141,7 +142,7 @@ public final class MapiSocket { * @param db the database */ public void setDatabase(final String db) { - this.database = db; + target.setDatabase(db); } /** @@ -150,7 +151,7 @@ public final class MapiSocket { * @param lang the language */ public void setLanguage(final String lang) { - this.language = lang; + target.setLanguage(lang); } /** @@ -163,7 +164,7 @@ public final class MapiSocket { * @param hash the hash method to use */ public void setHash(final String hash) { - this.hash = hash; + target.setHash(hash); } /** @@ -208,7 +209,7 @@ public final class MapiSocket { if (s < 0) { throw new IllegalArgumentException("timeout can't be negative"); } - this.soTimeout = s; + target.setSoTimeout(s); // limit time to wait on blocking operations if (con != null) { con.setSoTimeout(s); @@ -222,10 +223,7 @@ public final class MapiSocket { * @throws SocketException Issue with the socket */ public int getSoTimeout() throws SocketException { - if (con != null) { - this.soTimeout = con.getSoTimeout(); - } - return this.soTimeout; + return target.getSoTimeout(); } /** @@ -234,7 +232,7 @@ public final class MapiSocket { * @param debug Value to set */ public void setDebug(final boolean debug) { - this.debug = debug; + target.setDebug(debug); } /** @@ -257,375 +255,299 @@ public final class MapiSocket { public List<String> connect(final String host, final int port, final String user, final String pass) throws IOException, SocketException, UnknownHostException, MCLParseException, MCLException { - // Wrap around the internal connect that needs to know if it - // should really make a TCP connection or not. - return connect(host, port, user, pass, true); + target.setHost(host); + target.setPort(port); + target.setUser(user); + target.setPassword(pass); + return connect(target, null); } - /** - * Connects to the given host and port, logging in as the given - * user. If followRedirect is false, a RedirectionException is - * thrown when a redirect is encountered. - * - * @param host the hostname, or null for the loopback address - * @param port the port number (must be between 0 and 65535, inclusive) - * @param user the username - * @param pass the password - * @param makeConnection whether a new socket connection needs to be created - * @return A List with informational (warning) messages. If this - * list is empty; then there are no warnings. - * @throws IOException if an I/O error occurs when creating the socket - * @throws SocketException - if there is an error in the underlying protocol, such as a TCP error. - * @throws UnknownHostException if the IP address of the host could not be determined - * @throws MCLParseException if bogus data is received - * @throws MCLException if an MCL related error occurs - */ - private List<String> connect(final String host, final int port, final String user, final String pass, final boolean makeConnection) - throws IOException, SocketException, UnknownHostException, MCLParseException, MCLException - { - if (ttl-- <= 0) - throw new MCLException("Maximum number of redirects reached, aborting connection attempt."); + public List<String> connect(Target target, OptionsCallback callback) throws MCLException, MCLParseException, IOException { + // get rid of any earlier connection state, including the existing target + close(); + this.target = target; - if (makeConnection) { - con = new Socket(host, port); - con.setSoTimeout(this.soTimeout); - // set nodelay, as it greatly speeds up small messages (like we often do) - con.setTcpNoDelay(true); - con.setKeepAlive(true); - - fromMonet = new BlockInputStream(con.getInputStream()); - toMonet = new BlockOutputStream(con.getOutputStream()); - reader = new BufferedMCLReader(fromMonet, StandardCharsets.UTF_8); - writer = new BufferedMCLWriter(toMonet, StandardCharsets.UTF_8); - writer.registerReader(reader); + Target.Validated validated; + try { + validated = target.validate(); + } catch (ValidationError e) { + throw new MCLException(e.getMessage()); } - reader.advance(); - final String c = reader.getLine(); - reader.discardRemainder(); - writer.writeLine(getChallengeResponse(c, user, pass, language, database, hash)); - - // read monetdb mserver response till prompt - final ArrayList<String> redirects = new ArrayList<String>(); - final List<String> warns = new ArrayList<String>(); - String err = "", tmp; - do { - reader.advance(); - tmp = reader.getLine(); - if (tmp == null) - throw new IOException("Read from " + - con.getInetAddress().getHostName() + ":" + - con.getPort() + ": End of stream reached"); - if (reader.getLineType() == LineType.ERROR) { - err += "\n" + tmp.substring(7); - } else if (reader.getLineType() == LineType.INFO) { - warns.add(tmp.substring(1)); - } else if (reader.getLineType() == LineType.REDIRECT) { - redirects.add(tmp.substring(1)); - } - } while (reader.getLineType() != LineType.PROMPT); - - if (err.length() > 0) { - close(); - throw new MCLException(err); + if (validated.connectScan()) { + return scanUnixSockets(callback); } - if (!redirects.isEmpty()) { - if (followRedirects) { - // Ok, server wants us to go somewhere else. The list - // might have multiple clues on where to go. For now we - // don't support anything intelligent but trying the - // first one. URI should be in form of: - // "mapi:monetdb://host:port/database?arg=value&..." - // or - // "mapi:merovingian://proxy?arg=value&..." - // note that the extra arguments must be obeyed in both - // cases - final String suri = redirects.get(0).toString(); - if (!suri.startsWith("mapi:")) - throw new MCLException("unsupported redirect: " + suri); + ArrayList<String> warnings = new ArrayList<>(); + int attempts = 0; + do { + boolean ok = false; + try { + boolean done = tryConnect(callback, warnings); + ok = true; + if (done) { + return warnings; + } + } finally { + if (!ok) + close(); + } + } while (attempts++ < this.ttl); + throw new MCLException("max redirect count exceeded"); + } - final URI u; - try { - u = new URI(suri.substring(5)); - } catch (java.net.URISyntaxException e) { - throw new MCLParseException(e.toString()); - } + private List<String> scanUnixSockets(OptionsCallback callback) throws MCLException, MCLParseException, IOException { + // Because we do not support Unix Domain sockets, we just go back to connect(). + // target.connectScan() will now return false; + target.setHost("localhost"); + return connect(target, callback); + } - tmp = u.getQuery(); - if (tmp != null) { - final String args[] = tmp.split("&"); - for (int i = 0; i < args.length; i++) { - int pos = args[i].indexOf('='); - if (pos > 0) { - tmp = args[i].substring(0, pos); - switch (tmp) { - case "database": - tmp = args[i].substring(pos + 1); - if (!tmp.equals(database)) { - warns.add("redirect points to different database: " + tmp); - setDatabase(tmp); - } - break; - case "language": - tmp = args[i].substring(pos + 1); - warns.add("redirect specifies use of different language: " + tmp); - setLanguage(tmp); - break; - case "user": - tmp = args[i].substring(pos + 1); - if (!tmp.equals(user)) - warns.add("ignoring different username '" + tmp + "' set by " + - "redirect, what are the security implications?"); - break; - case "password": - warns.add("ignoring different password set by redirect, " + - "what are the security implications?"); - break; - default: - warns.add("ignoring unknown argument '" + tmp + "' from redirect"); - break; - } - } else { - warns.add("ignoring illegal argument from redirect: " + args[i]); - } - } - } + private boolean tryConnect(OptionsCallback callback, ArrayList<String> warningBuffer) throws MCLException, IOException { + try { + // We need a valid target + Target.Validated validated = target.validate(); + // con will be non-null if the previous attempt ended in a redirect to mapi:monetdb://proxy + if (con == null) + connectSocket(validated); + return handshake(validated, callback, warningBuffer); + } catch (IOException | MCLException e) { + close(); + throw e; + } catch (ValidationError e) { + close(); + throw new MCLException(e.getMessage()); + } + } - if (u.getScheme().equals("monetdb")) { - // this is a redirect to another (monetdb) server, - // which means a full reconnect - // avoid the debug log being closed - if (debug) { - debug = false; - close(); - debug = true; - } else { - close(); - } - tmp = u.getPath(); - if (tmp != null && tmp.length() > 0) { - tmp = tmp.substring(1).trim(); - if (!tmp.isEmpty() && !tmp.equals(database)) { - warns.add("redirect points to different database: " + tmp); - setDatabase(tmp); - } - } - final int p = u.getPort(); - warns.addAll(connect(u.getHost(), p == -1 ? port : p, user, pass, true)); - warns.add("Redirect by " + host + ":" + port + " to " + suri); - } else if (u.getScheme().equals("merovingian")) { - // reuse this connection to inline connect to the - // right database that Merovingian proxies for us - reader.resetLineType(); - warns.addAll(connect(host, port, user, pass, false)); - } else { - throw new MCLException("unsupported scheme in redirect: " + suri); - } - } else { - final StringBuilder msg = new StringBuilder("The server sent a redirect for this connection:"); - for (String it : redirects) { - msg.append(" [" + it + "]"); - } - throw new MCLException(msg.toString()); - } + private void connectSocket(Target.Validated validated) throws MCLException, IOException { + // This method performs steps 2-6 of the procedure outlined in the URL spec + String tcpHost = validated.connectTcp(); + if (tcpHost.isEmpty()) { + throw new MCLException("Unix domain sockets are not supported, only TCP"); } - return warns; + int port = validated.connectPort(); + Socket sock = new Socket(tcpHost, port); + sock.setSoTimeout(validated.getSoTimeout()); + sock.setTcpNoDelay(true); + sock.setKeepAlive(true); + + sock = wrapTLS(sock, validated); + + fromMonet = new BlockInputStream(sock.getInputStream()); + toMonet = new BlockOutputStream(sock.getOutputStream()); + reader = new BufferedMCLReader(fromMonet, StandardCharsets.UTF_8); + writer = new BufferedMCLWriter(toMonet, StandardCharsets.UTF_8); + writer.registerReader(reader); + reader.advance(); + + // Only assign to sock when everything went ok so far + con = sock; + } + + private Socket wrapTLS(Socket sock, Target.Validated validated) throws MCLException { + if (validated.getTls()) + throw new MCLException("TLS connections (monetdbs://) are not supported yet"); + return sock; } - /** - * A little helper function that processes a challenge string, and - * returns a response string for the server. If the challenge - * string is null, a challengeless response is returned. - * - * @param chalstr the challenge string - * for example: H8sRMhtevGd:mserver:9:PROT10,RIPEMD160,SHA256,SHA1,COMPRESSION_SNAPPY,COMPRESSION_LZ4:LIT:SHA512: - * @param username the username to use - * @param password the password to use - * @param language the language to use - * @param database the database to connect to - * @param hash the hash method(s) to use, or NULL for all supported hashes - * @return the response string for the server - * @throws MCLParseException when parsing failed - * @throws MCLException if an MCL related error occurs - * @throws IOException when IO exception occurred - */ - private String getChallengeResponse( - final String chalstr, - String username, - String password, - final String language, - final String database, - final String hash - ) throws MCLParseException, MCLException, IOException { - // parse the challenge string, split it on ':' - final String[] chaltok = chalstr.split(":"); - if (chaltok.length <= 5) - throw new MCLParseException("Server challenge string unusable! It contains too few (" + chaltok.length + ") tokens: " + chalstr); + private boolean handshake(Target.Validated validated, OptionsCallback callback, ArrayList<String> warnings) throws IOException, MCLException { + String challenge = reader.getLine(); + reader.advance(); + if (reader.getLineType() != LineType.PROMPT) + throw new MCLException("Garbage after server challenge: " + reader.getLine()); + String response = challengeResponse(validated, challenge, callback); + writer.writeLine(response); + reader.advance(); - try { - version = Integer.parseInt(chaltok[2]); // protocol version - } catch (NumberFormatException e) { - throw new MCLParseException("Protocol version (" + chaltok[2] + ") unparseable as integer."); + // Process the response lines. + String redirect = null; + StringBuilder errors = new StringBuilder(); + while (reader.getLineType() != LineType.PROMPT) { + switch (reader.getLineType()) { + case REDIRECT: + if (redirect == null) + redirect = reader.getLine(1); + break; + case ERROR: + if (errors.length() > 0) + errors.append("\n"); + errors.append(reader.getLine(7)); // 7 not 1! + break; + case INFO: + warnings.add(reader.getLine(1)); + break; + default: + // ignore??!! + break; + } + reader.advance(); } + if (errors.length() > 0) + throw new MCLException(errors.toString()); + + if (redirect == null) + return true; // we're happy - // handle the challenge according to the version it is - switch (version) { - case 9: - // proto 9 is like 8, but uses a hash instead of the plain password - // the server tells us (in 6th token) which hash in the - // challenge after the byte-order token + // process redirect + try { + MonetUrlParser.parse(target, redirect); + } catch (URISyntaxException | ValidationError e) { + throw new MCLException("While processing redirect " + redirect + ": " + e.getMessage(), e); + } + if (redirect.startsWith("mapi:merovingian://proxy")) { + // The reader is stuck at LineType.PROMPT but actually the + // next challenge is already there. + reader.resetLineType(); + reader.advance(); + } else { + close(); + } + + return false; // we need another go + } - String algo; - String pwhash = chaltok[5]; - /* NOTE: Java doesn't support RIPEMD160 :( */ - /* see: https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#MessageDigest */ - switch (pwhash) { - case "SHA512": - algo = "SHA-512"; - break; - case "SHA384": - algo = "SHA-384"; - break; - case "SHA256": - algo = "SHA-256"; - /* NOTE: Java 7 doesn't support SHA-224. Java 8 does but we have not tested it. It is also not requested yet. */ - break; - case "SHA1": - algo = "SHA-1"; - break; - default: - /* Note: MD5 has been deprecated by security experts and support is removed from Oct 2020 release */ - throw new MCLException("Unsupported password hash: " + pwhash); - } - try { - final MessageDigest md = MessageDigest.getInstance(algo); - md.update(password.getBytes(StandardCharsets.UTF_8)); - password = toHex(md.digest()); - } catch (NoSuchAlgorithmException e) { - throw new MCLException("This JVM does not support password hash: " + pwhash + "\n" + e); - } + private String challengeResponse( + Target.Validated validated, final String challengeLine, + OptionsCallback callback + ) throws MCLException { + // The challengeLine looks like this: + // + // 45IYyVyRnbgEnK92ad:merovingian:9:RIPEMD160,SHA512,SHA384,SHA256,SHA224,SHA1:LIT:SHA512: + // WgHIibSyH:mserver:9:RIPEMD160,SHA512,SHA384,SHA256,SHA224,SHA1:LIT:SHA512:sql=6:BINARY=1: + // 0 1 2 3 4 5 6 7 + + String parts[] = challengeLine.split(":"); + if (parts.length < 3) + throw new MCLException("Invalid challenge: expect at least 3 fields"); + String challengePart = parts[0]; + String serverTypePart = parts[1]; + String versionPart = parts[2]; + int version; + if (versionPart.equals("9")) + version = 9; + else + throw new MCLException("Protocol versions other than 9 are note supported: " + versionPart); + if (parts.length < 6) + throw new MCLException("Protocol version " + version + " requires at least 6 fields, found " + parts.length + ": " + challengeLine); + String serverHashesPart = parts[3]; +// String endianPart = parts[4]; + String passwordHashPart = parts[5]; + String optionsPart = parts.length > 6 ? parts[6] : null; +// String binaryPart = parts.length > 7 ? parts[7] : null; - // proto 7 (finally) used the challenge and works with a - // password hash. The supported implementations come - // from the server challenge. We chose the best hash - // we can find, in the order SHA512, SHA1, MD5, plain. - // Also the byte-order is reported in the challenge string, - // which makes sense, since only blockmode is supported. - // proto 8 made this obsolete, but retained the - // byte-order report for future "binary" transports. - // In proto 8, the byte-order of the blocks is always little - // endian because most machines today are. - final String hashes = (hash == null || hash.isEmpty()) ? chaltok[3] : hash; - final HashSet<String> hashesSet = new HashSet<String>(java.util.Arrays.asList(hashes.toUpperCase().split("[, ]"))); // split on comma or space + String userResponse; + String password = target.getPassword(); + if (serverTypePart.equals("merovingian") && !target.getLanguage().equals("control")) { + userResponse = "merovingian"; + password = "merovingian"; + } else { + userResponse = target.getUser(); + } + String passwordResponse = hashPassword(challengePart, password, passwordHashPart, validated.getHash(), serverHashesPart); - // if we deal with merovingian, mask our credentials - if (chaltok[1].equals("merovingian") && !language.equals("control")) { - username = "merovingian"; - password = "merovingian"; - } + String optionsResponse = handleOptions(callback, optionsPart); - // reuse variables algo and pwhash - algo = null; - pwhash = null; - if (hashesSet.contains("SHA512")) { - algo = "SHA-512"; - pwhash = "{SHA512}"; - } else if (hashesSet.contains("SHA384")) { - algo = "SHA-384"; - pwhash = "{SHA384}"; - } else if (hashesSet.contains("SHA256")) { - algo = "SHA-256"; - pwhash = "{SHA256}"; - } else if (hashesSet.contains("SHA1")) { - algo = "SHA-1"; - pwhash = "{SHA1}"; - } else { - /* Note: MD5 has been deprecated by security experts and support is removed from Oct 2020 release */ - throw new MCLException("no supported hash algorithms found in " + hashes); - } - try { - final MessageDigest md = MessageDigest.getInstance(algo); - md.update(password.getBytes(StandardCharsets.UTF_8)); - md.update(chaltok[0].getBytes(StandardCharsets.UTF_8)); // salt/key - pwhash += toHex(md.digest()); - } catch (NoSuchAlgorithmException e) { - throw new MCLException("This JVM does not support password hash: " + pwhash + "\n" + e); - } + // Response looks like this: + // + // LIT:monetdb:{RIPEMD160}f2236256e5a9b20a5ecab4396e36c14f66c3e3c5:sql:demo + // :FILETRANS:auto_commit=1,reply_size=1000,size_header=0,columnar_protocol=0,time_zone=3600: + StringBuilder response = new StringBuilder(80); + response.append("BIG:"); + response.append(userResponse).append(":"); + response.append(passwordResponse).append(":"); + response.append(validated.getLanguage()).append(":"); + response.append(validated.getDatabase()).append(":"); + response.append("FILETRANS:"); + response.append(optionsResponse).append(":"); + + return response.toString(); + } + + // challengePart, passwordHashPart, supportedHashesPart, target.getPassword() + private String hashPassword(String challenge, String password, String passwordAlgo, String configuredHashes, String serverSupportedAlgos) throws MCLException { + int maxHashLength = 512; + + StringBuilder output = new StringBuilder(10 + maxHashLength / 4); + MessageDigest passwordDigest = pickBestAlgorithm(Collections.singleton(passwordAlgo), output); - // TODO: some day when we need this, we should store this - if (chaltok[4].equals("BIG")) { - // byte-order of server is big-endian - } else if (chaltok[4].equals("LIT")) { - // byte-order of server is little-endian - } else { - throw new MCLParseException("Invalid byte-order: " + chaltok[4]); - } + Set<String> algoSet = new HashSet(Arrays.asList(serverSupportedAlgos.split(","))); + if (!configuredHashes.isEmpty()) { + Set<String> keep = new HashSet(Arrays.asList(configuredHashes.toUpperCase().split("[, ]"))); + algoSet.retainAll(keep); + if (algoSet.isEmpty()) { + throw new MCLException("None of the hash algorithms <" + configuredHashes + "> are supported, server only supports <" + serverSupportedAlgos + ">"); + } + } + MessageDigest challengeDigest = pickBestAlgorithm(algoSet, null); + + // First we use the password algo to hash the password. + // Then we use the challenge algo to hash the combination of the resulting hash digits and the challenge. + StringBuilder intermediate = new StringBuilder(maxHashLength / 4 + challenge.length()); + hexhash(intermediate, passwordDigest, password); + intermediate.append(challenge); + hexhash(output, challengeDigest, intermediate.toString()); + return output.toString(); + } - // compose and return response - String response = "BIG:" // JVM byte-order is big-endian - + username + ":" - + pwhash + ":" - + language + ":" - + (database == null ? "" : database) + ":" - + "FILETRANS:"; // this capability is added in monetdb-jdbc-3.2.jre8.jar - if (chaltok.length > 6) { - // if supported, send handshake options - for (String part : chaltok[6].split(",")) { - if (part.startsWith("sql=") && handshakeOptions != null) { - int level; - try { - level = Integer.parseInt(chaltok[6].substring(4)); - } catch (NumberFormatException e) { - throw new MCLParseException("Invalid handshake level: " + chaltok[6]); - } - boolean first = true; - for (HandshakeOption<?> opt: handshakeOptions) { - if (opt.getLevel() < level) { - // server supports it - if (first) { - first = false; - } else { - response += ","; - } - response += opt.getFieldName() + "=" + opt.numericValue(); - opt.setSent(true); - } - } - break; - } - } - // this ':' delimits the handshake options field. - response += ":"; - } - return response; - default: - throw new MCLException("Unsupported protocol version: " + version); + private MessageDigest pickBestAlgorithm(Set<String> algos, StringBuilder appendPrefixHere) throws MCLException { + for (String[] choice: KNOWN_ALGORITHMS) { + String mapiName = choice[0]; + String algoName = choice[1]; + MessageDigest digest; + if (!algos.contains(mapiName)) + continue; + try { + digest = MessageDigest.getInstance(algoName); + } catch (NoSuchAlgorithmException e) { + continue; + } + // we found a match + if (appendPrefixHere != null) { + appendPrefixHere.append('{'); + appendPrefixHere.append(mapiName); + appendPrefixHere.append('}'); + } + return digest; + } + String algoNames = algos.stream().collect(Collectors.joining()); + throw new MCLException("No supported hash algorithm: " + algoNames); + } + + private void hexhash(StringBuilder buffer, MessageDigest digest, String text) { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + digest.update(bytes); + byte[] output = digest.digest(); + for (byte b: output) { + int hi = (b & 0xF0) >> 4; + int lo = b & 0x0F; + buffer.append(HEXDIGITS[hi]); + buffer.append(HEXDIGITS[lo]); } } - /** - * Small helper method to convert a byte string to a hexadecimal - * string representation. - * - * @param digest the byte array to convert - * @return the byte array as hexadecimal string - */ - private final static String toHex(final byte[] digest) { - final char[] result = new char[digest.length * 2]; - int pos = 0; - for (int i = 0; i < digest.length; i++) { - result[pos++] = hexChar((digest[i] & 0xf0) >> 4); - result[pos++] = hexChar(digest[i] & 0x0f); - } - return new String(result); - } + private String handleOptions(OptionsCallback callback, String optionsPart) throws MCLException { + if (callback == null || optionsPart == null || optionsPart.isEmpty()) + return ""; - private final static char hexChar(final int n) { - return (n > 9) - ? (char) ('a' + (n - 10)) - : (char) ('0' + n); - } + StringBuilder buffer = new StringBuilder(); + callback.setBuffer(buffer); + for (String optlevel: optionsPart.split(",")) { + int eqindex = optlevel.indexOf('='); + if (eqindex < 0) + throw new MCLException("Invalid options part in server challenge: " + optionsPart); + String lang = optlevel.substring(0, eqindex); + int level; + try { + level = Integer.parseInt(optlevel.substring(eqindex + 1)); + } catch (NumberFormatException e) { + throw new MCLException("Invalid option level in server challenge: " + optlevel); + } + callback.addOptions(lang, level); + } + + return buffer.toString(); + } /** * Returns an InputStream that reads from this open connection on @@ -716,7 +638,7 @@ public final class MapiSocket { */ public void debug(final Writer out) { log = out; - debug = true; + setDebug(true); } /** @@ -748,15 +670,6 @@ public final class MapiSocket { } /** - * Set the HandshakeOptions - * - * @param handshakeOptions the options array - */ - public void setHandshakeOptions(HandshakeOption<?>[] handshakeOptions) { - this.handshakeOptions = handshakeOptions; - } - - /** * For internal use * * @param b to enable/disable insert 'fake' newline and prompt @@ -766,6 +679,10 @@ public final class MapiSocket { return fromMonet.setInsertFakePrompts(b); } + public boolean isDebug() { + return target.isDebug(); + } + /** * Inner class that is used to write data on a normal stream as a @@ -805,7 +722,7 @@ public final class MapiSocket { // it's a bit nasty if an exception is thrown from the log, // but ignoring it can be nasty as well, so it is decided to // let it go so there is feedback about something going wrong - if (debug) { + if (isDebug()) { log.flush(); } } @@ -839,7 +756,7 @@ public final class MapiSocket { // write the actual block out.write(block, 0, writePos); - if (debug) { + if (isDebug()) { if (last) { log("TD ", "write final block: " + writePos + " bytes", false); } else { @@ -964,7 +881,7 @@ public final class MapiSocket { // if we have read something before, we should have been // able to read the whole, so make this fatal if (off > 0) { - if (debug) { + if (isDebug()) { log("RD ", "the following incomplete block was received:", false); log("RX ", new String(b, 0, off, StandardCharsets.UTF_8), true); } @@ -972,7 +889,7 @@ public final class MapiSocket { con.getInetAddress().getHostName() + ":" + con.getPort() + ": Incomplete block read from stream"); } - if (debug) + if (isDebug()) log("RD ", "server closed the connection (EOF)", true); return false; } @@ -1023,7 +940,7 @@ public final class MapiSocket { readPos = 0; - if (debug) { + if (isDebug()) { if (wasEndBlock) { log("RD ", "read final block: " + blockLen + " bytes", false); } else { @@ -1039,7 +956,7 @@ public final class MapiSocket { if (!_read(block, blockLen)) return -1; - if (debug) + if (isDebug()) log("RX ", new String(block, 0, blockLen, StandardCharsets.UTF_8), true); // if this is the last block, make it end with a newline and prompt @@ -1054,7 +971,7 @@ public final class MapiSocket { block[blockLen++] = b; } block[blockLen++] = '\n'; - if (debug) { + if (isDebug()) { log("RD ", "inserting prompt", true); } } @@ -1070,7 +987,7 @@ public final class MapiSocket { return -1; } - if (debug) + if (isDebug()) log("RX ", new String(block, readPos, 1, StandardCharsets.UTF_8), true); return (int)block[readPos++]; @@ -1209,7 +1126,7 @@ public final class MapiSocket { con = null; } catch (IOException e) { /* ignore it */ } } - if (debug && log != null && log instanceof FileWriter) { + if (isDebug() && log != null && log instanceof FileWriter) { try { log.close(); log = null; @@ -1520,4 +1437,22 @@ public final class MapiSocket { return off - origOff; } } + + public static abstract class OptionsCallback { + private StringBuilder buffer; + + protected void contribute(String field, int value) { + if (buffer.length() > 0) + buffer.append(','); + buffer.append(field); + buffer.append('='); + buffer.append(value); + } + + public abstract void addOptions(String lang, int level); + + void setBuffer(StringBuilder buf) { + buffer = buf; + } + } }
--- a/src/main/java/org/monetdb/mcl/net/MonetUrlParser.java +++ b/src/main/java/org/monetdb/mcl/net/MonetUrlParser.java @@ -27,31 +27,24 @@ public class MonetUrlParser { } public static void parse(Target target, String url) throws URISyntaxException, ValidationError { - 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:///"; - } + if (url.equals("monetdb://")) { + // deal with peculiarity of Java's URI parser + url = "monetdb:///"; } target.barrier(); - try { - MonetUrlParser parser = new MonetUrlParser(target, url); - if (modern) { - parser.parseModern(); - } else { + if (url.startsWith("mapi:")) { + try { + MonetUrlParser parser = new MonetUrlParser(target, url.substring(5)); parser.parseClassic(); + } catch (URISyntaxException e) { + URISyntaxException exc = new URISyntaxException(e.getInput(), e.getReason(), -1); + exc.setStackTrace(e.getStackTrace()); + throw exc; } - } catch (URISyntaxException e) { - int idx = e.getIndex(); - if (idx >= 0 && !modern) { - // "mapi:" - idx += 5; - } - throw new URISyntaxException(e.getInput(), e.getReason(), idx); + } else { + MonetUrlParser parser = new MonetUrlParser(target, url); + parser.parseModern(); } target.barrier(); } @@ -89,7 +82,6 @@ public class MonetUrlParser { String host; String remainder; int pos; - String raw = url.getRawSchemeSpecificPart(); if (authority == null) { if (!url.getRawSchemeSpecificPart().startsWith("//")) { throw new URISyntaxException(urlText, "expected //"); @@ -173,22 +165,48 @@ public class MonetUrlParser { } private void parseClassic() throws URISyntaxException, ValidationError { + if (!url.getRawSchemeSpecificPart().startsWith("//")) { + throw new URISyntaxException(urlText, "expected //"); + } + String scheme = url.getScheme(); - if (scheme == null) throw new URISyntaxException(urlText, "URL scheme must be mapi:monetdb:// or mapi:merovingian://"); + if (scheme == null) + scheme = ""; switch (scheme) { case "monetdb": - clearBasic(); + parseClassicAuthorityAndPath(); break; case "merovingian": - throw new IllegalStateException("mapi:merovingian: not supported yet"); + String authority = url.getRawAuthority(); + // authority must be "proxy" ignore authority and path + boolean valid = urlText.startsWith("merovingian://proxy?") || urlText.equals("merovingian://proxy"); + if (!valid) + throw new URISyntaxException(urlText, "with mapi:merovingian:, only //proxy is supported"); + break; default: throw new URISyntaxException(urlText, "URL scheme must be mapi:monetdb:// or mapi:merovingian://"); } - if (!url.getRawSchemeSpecificPart().startsWith("//")) { - throw new URISyntaxException(urlText, "expected //"); + 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); + target.setString(Parameter.LANGUAGE, language); + } else if (arg.startsWith("database=")) { + String database = arg.substring(9); + target.setString(Parameter.DATABASE, database); + } else { + // ignore + } + } } + } + private void parseClassicAuthorityAndPath() throws URISyntaxException, ValidationError { + clearBasic(); String authority = url.getRawAuthority(); String host; String portStr; @@ -220,15 +238,12 @@ public class MonetUrlParser { } String path = url.getRawPath(); - boolean isUnix; if (host.isEmpty() && portStr.isEmpty()) { // socket - isUnix = true; target.clear(Parameter.HOST); target.setString(Parameter.SOCK, path != null ? path : ""); } else { // tcp - isUnix = false; target.clear(Parameter.SOCK); target.setString(Parameter.HOST, host); if (path == null || path.isEmpty()) { @@ -240,29 +255,12 @@ public class MonetUrlParser { target.setString(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); - target.setString(Parameter.LANGUAGE, language); - } else if (arg.startsWith("database=")) { - String database = arg.substring(9); - target.setString(Parameter.DATABASE, database); - } else { - // ignore - } - } - } } private void clearBasic() { + target.clear(Parameter.TLS); target.clear(Parameter.HOST); target.clear(Parameter.PORT); - target.clear(Parameter.SOCK); target.clear(Parameter.DATABASE); } }
--- a/src/main/java/org/monetdb/mcl/net/Target.java +++ b/src/main/java/org/monetdb/mcl/net/Target.java @@ -35,6 +35,7 @@ public class Target { private boolean userWasSet = false; private boolean passwordWasSet = false; protected static final Target defaults = new Target(); + private Validated validated = null; private static Pattern namePattern = Pattern.compile("^[a-zzA-Z_][-a-zA-Z0-9_.]*$"); private static Pattern hashPattern = Pattern.compile("^sha256:[0-9a-fA-F:]*$"); @@ -171,6 +172,7 @@ public class Target { public void setTls(boolean tls) { this.tls = tls; + validated = null; } public String getHost() { @@ -179,6 +181,7 @@ public class Target { public void setHost(String host) { this.host = host; + validated = null; } public int getPort() { @@ -187,6 +190,7 @@ public class Target { public void setPort(int port) { this.port = port; + validated = null; } public String getDatabase() { @@ -195,6 +199,7 @@ public class Target { public void setDatabase(String database) { this.database = database; + validated = null; } public String getTableschema() { @@ -203,6 +208,7 @@ public class Target { public void setTableschema(String tableschema) { this.tableschema = tableschema; + validated = null; } public String getTable() { @@ -211,6 +217,7 @@ public class Target { public void setTable(String table) { this.table = table; + validated = null; } public String getSock() { @@ -219,6 +226,7 @@ public class Target { public void setSock(String sock) { this.sock = sock; + validated = null; } public String getSockdir() { @@ -227,6 +235,7 @@ public class Target { public void setSockdir(String sockdir) { this.sockdir = sockdir; + validated = null; } public String getCert() { @@ -235,6 +244,7 @@ public class Target { public void setCert(String cert) { this.cert = cert; + validated = null; } public String getCerthash() { @@ -243,6 +253,7 @@ public class Target { public void setCerthash(String certhash) { this.certhash = certhash; + validated = null; } public String getClientkey() { @@ -251,6 +262,7 @@ public class Target { public void setClientkey(String clientkey) { this.clientkey = clientkey; + validated = null; } public String getClientcert() { @@ -259,6 +271,7 @@ public class Target { public void setClientcert(String clientcert) { this.clientcert = clientcert; + validated = null; } public String getUser() { @@ -268,6 +281,7 @@ public class Target { public void setUser(String user) { this.user = user; this.userWasSet = true; + validated = null; } public String getPassword() { @@ -277,6 +291,7 @@ public class Target { public void setPassword(String password) { this.password = password; this.passwordWasSet = true; + validated = null; } public String getLanguage() { @@ -285,6 +300,7 @@ public class Target { public void setLanguage(String language) { this.language = language; + validated = null; } public boolean isAutocommit() { @@ -293,6 +309,7 @@ public class Target { public void setAutocommit(boolean autocommit) { this.autocommit = autocommit; + validated = null; } public String getSchema() { @@ -301,6 +318,7 @@ public class Target { public void setSchema(String schema) { this.schema = schema; + validated = null; } public int getTimezone() { @@ -309,6 +327,7 @@ public class Target { public void setTimezone(int timezone) { this.timezone = timezone; + validated = null; } public String getBinary() { @@ -317,6 +336,7 @@ public class Target { public void setBinary(String binary) { this.binary = binary; + validated = null; } public int getReplysize() { @@ -325,6 +345,7 @@ public class Target { public void setReplysize(int replysize) { this.replysize = replysize; + validated = null; } public String getHash() { @@ -333,6 +354,7 @@ public class Target { public void setHash(String hash) { this.hash = hash; + validated = null; } public boolean isDebug() { @@ -341,6 +363,7 @@ public class Target { public void setDebug(boolean debug) { this.debug = debug; + validated = null; } public String getLogfile() { @@ -349,6 +372,7 @@ public class Target { public void setLogfile(String logfile) { this.logfile = logfile; + validated = null; } public int getSoTimeout() { @@ -358,10 +382,12 @@ public class Target { public void setSoTimeout(int soTimeout) { this.soTimeout = soTimeout; + validated = null; } public void setTreatClobAsVarchar(boolean treatClobAsVarchar) { this.treatClobAsVarchar = treatClobAsVarchar; + validated = null; } public boolean isTreatClobAsVarchar() { @@ -374,10 +400,23 @@ public class Target { public void setTreatBlobAsBinary(boolean treatBlobAsBinary) { this.treatBlobAsBinary = treatBlobAsBinary; + validated = null; } public Validated validate() throws ValidationError { - return new Validated(); + if (validated == null) + validated = new Validated(); + return validated; + } + + public String buildUrl() { + final StringBuilder sb = new StringBuilder(128); + sb.append("jdbc:monetdb://").append(host) + .append(':').append(port) + .append('/').append(database); + if (!language.equals("sql")) + sb.append("?language=").append(language); + return sb.toString(); } public class Validated { @@ -457,6 +496,10 @@ public class Target { // 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="); + + // JDBC specific + if (soTimeout < 0) + throw new ValidationError("so_timeout= must not be negative"); } public boolean getTls() {
--- a/tests/tests.md +++ b/tests/tests.md @@ -1457,6 +1457,40 @@ EXPECT language=sql ACCEPT mapi:monetdb://localhost:12345/db?_l%61nguage=mal ``` + +## Merovingian URLs + +These occur in redirects. +Leave host, port and database are not cleared the way the are +for `monetdb:`, `monetdbs:` and `mapi:monetdb:` URLs. + +```test +SET host=banana +SET port=123 +SET tls=on +SET sock=/tmp/sock +SET database=dummy +PARSE mapi:merovingian://proxy +EXPECT host=banana +EXPECT port=123 +EXPECT tls=on +EXPECT sock=/tmp/sock +EXPECT database=dummy +PARSE mapi:merovingian://proxy? +EXPECT host=banana +EXPECT port=123 +EXPECT tls=on +EXPECT sock=/tmp/sock +EXPECT database=dummy +PARSE mapi:merovingian://proxy?database=yeah&unknown=unknown +EXPECT host=banana +EXPECT port=123 +EXPECT tls=on +EXPECT sock=/tmp/sock +EXPECT database=yeah +``` + + # lalala Java ```test