changeset 699:a483b2b68a2e

Provisional APIs and respective implementation for http, https and ssh remote repositories
author Artem Tikhomirov <tikhomirov.artem@gmail.com>
date Thu, 08 Aug 2013 19:18:50 +0200
parents 822f3a83ff57
children 6e7786086f77
files src/org/tmatesoft/hg/auth/HgAuthFailedException.java src/org/tmatesoft/hg/auth/HgAuthMethod.java src/org/tmatesoft/hg/auth/HgAuthenticator.java src/org/tmatesoft/hg/auth/package.html src/org/tmatesoft/hg/core/SessionContext.java src/org/tmatesoft/hg/internal/remote/BasicAuthenticator.java src/org/tmatesoft/hg/internal/remote/Connector.java src/org/tmatesoft/hg/internal/remote/ConnectorBase.java src/org/tmatesoft/hg/internal/remote/HttpAuthMethod.java src/org/tmatesoft/hg/internal/remote/HttpConnector.java src/org/tmatesoft/hg/internal/remote/RemoteConnectorDescriptor.java src/org/tmatesoft/hg/internal/remote/SshAuthMethod.java src/org/tmatesoft/hg/internal/remote/SshConnector.java src/org/tmatesoft/hg/repo/HgLookup.java src/org/tmatesoft/hg/repo/HgRemoteRepository.java test/org/tmatesoft/hg/test/Configuration.java
diffstat 16 files changed, 726 insertions(+), 159 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/auth/HgAuthFailedException.java	Thu Aug 08 19:18:50 2013 +0200
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.auth;
+
+import org.tmatesoft.hg.internal.Experimental;
+
+/**
+ * FIXME split and describe
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ * @since 1.2
+ */
+@SuppressWarnings("serial")
+@Experimental(reason="Provisional API. Work in progress")
+public class HgAuthFailedException extends Exception /*XXX HgRemoteException?*/ {
+	public HgAuthFailedException(String message, Throwable cause) {
+		super(message, cause);
+	}
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/auth/HgAuthMethod.java	Thu Aug 08 19:18:50 2013 +0200
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.auth;
+
+import java.io.InputStream;
+import java.security.cert.X509Certificate;
+
+import org.tmatesoft.hg.internal.Experimental;
+
+/**
+ * Clients do not implement this interface, instead, they invoke appropriate authentication method
+ * once they got user input
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ * @since 1.2
+ */
+@Experimental(reason="Provisional API. Work in progress")
+public interface HgAuthMethod {
+	public void noCredentials() throws HgAuthFailedException;
+	public boolean supportsPassword();
+	public void withPassword(String username, String password) throws HgAuthFailedException;
+	public boolean supportsPublicKey();
+	public void withPublicKey(String username, InputStream privateKey, String passphrase) throws HgAuthFailedException;
+	public boolean supportsCertificate();
+	public void withCertificate(X509Certificate[] clientCertChain) throws HgAuthFailedException;
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/auth/HgAuthenticator.java	Thu Aug 08 19:18:50 2013 +0200
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.auth;
+
+import org.tmatesoft.hg.core.SessionContext;
+import org.tmatesoft.hg.internal.Experimental;
+import org.tmatesoft.hg.repo.HgRemoteRepository.RemoteDescriptor;
+
+/**
+ * Client may implement this interface if they need more control over authentication process.
+ * 
+ * @see SessionContext#getAuthenticator(RemoteDescriptor)
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ * @since 1.2
+ */
+@Experimental(reason="Provisional API. Work in progress")
+public interface HgAuthenticator {
+	// XXX either another AuthMethod or a separate #authenticate
+	// to perform server check. Alternatively, as methods in AuthMethod
+	public void authenticate(RemoteDescriptor rd, HgAuthMethod authMethod) throws HgAuthFailedException;
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/auth/package.html	Thu Aug 08 19:18:50 2013 +0200
@@ -0,0 +1,6 @@
+<html>
+<body>
+<h2>Authentication API</h2>
+<p>Classes and interfaces to support authentication for remote connections</p>
+</body>
+</html>
\ No newline at end of file
--- a/src/org/tmatesoft/hg/core/SessionContext.java	Tue Aug 06 21:18:33 2013 +0200
+++ b/src/org/tmatesoft/hg/core/SessionContext.java	Thu Aug 08 19:18:50 2013 +0200
@@ -18,10 +18,12 @@
 
 import java.net.URI;
 
+import org.tmatesoft.hg.auth.HgAuthenticator;
 import org.tmatesoft.hg.internal.BasicSessionContext;
 import org.tmatesoft.hg.internal.Experimental;
+import org.tmatesoft.hg.internal.remote.BasicAuthenticator;
 import org.tmatesoft.hg.internal.remote.RemoteConnectorDescriptor;
-import org.tmatesoft.hg.repo.HgLookup.RemoteDescriptor;
+import org.tmatesoft.hg.repo.HgRemoteRepository;
 import org.tmatesoft.hg.util.LogFacility;
 import org.tmatesoft.hg.util.Path;
 
@@ -84,10 +86,19 @@
 	 *  
 	 * @return <code>null</code> if supplied URI doesn't point to a remote repository or repositories of that kind are not supported
 	 */
-	@Experimental(reason="Work in progress, provisional API")
-	public RemoteDescriptor getRemoteDescriptor(URI uri) {
+	@Experimental(reason="Provisional API. Work in progress")
+	public HgRemoteRepository.RemoteDescriptor getRemoteDescriptor(URI uri) {
 		return new RemoteConnectorDescriptor.Provider().get(this, uri);
 	}
+	
+	/**
+	 * Facility to perform authentication for a given remote connection
+	 * @return never <code>null</code>
+	 */
+	@Experimental(reason="Provisional API. Work in progress")
+	public HgAuthenticator getAuthenticator(HgRemoteRepository.RemoteDescriptor rd) {
+		return new BasicAuthenticator(getLog());
+	}
 
 	/**
 	 * Providers of the context may implement
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/internal/remote/BasicAuthenticator.java	Thu Aug 08 19:18:50 2013 +0200
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.internal.remote;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+import org.tmatesoft.hg.auth.HgAuthFailedException;
+import org.tmatesoft.hg.auth.HgAuthMethod;
+import org.tmatesoft.hg.auth.HgAuthenticator;
+import org.tmatesoft.hg.repo.HgRemoteRepository.RemoteDescriptor;
+import org.tmatesoft.hg.util.LogFacility;
+import org.tmatesoft.hg.util.LogFacility.Severity;
+
+/**
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+public class BasicAuthenticator implements HgAuthenticator {
+	private final LogFacility log;
+
+	public BasicAuthenticator(LogFacility logFacility) {
+		log = logFacility;
+	}
+			
+	public void authenticate(RemoteDescriptor rd, HgAuthMethod authMethod) throws HgAuthFailedException {
+		if (authMethod.supportsPublicKey()) {
+			if (tryPlatformDefaultKeyLocations(rd, authMethod)) {
+				return;
+			}
+		}
+		authMethod.noCredentials();
+	}
+
+	// return true is successfully aithenticated
+	protected boolean tryPlatformDefaultKeyLocations(RemoteDescriptor rd, HgAuthMethod authMethod) {
+		final String userHome = System.getProperty("user.home");
+		File sshDir = new File(userHome, ".ssh");
+		if (!sshDir.isDirectory()) {
+			return false;
+		}
+		final String username = System.getProperty("user.name");
+		for (String fn : new String[] { "id_rsa", "id_dsa", "identity"}) {
+			File id = new File(sshDir, fn);
+			if (!id.canRead()) {
+				continue;
+			}
+			try {
+				FileInputStream fis = new FileInputStream(id);
+				authMethod.withPublicKey(username, fis, null);
+				fis.close();
+				return true;
+			} catch (IOException ex) {
+				log.dump(getClass(), Severity.Warn, ex, String.format("Attempting default ssh identity key locations: %s", id));
+				// ignore
+			} catch (HgAuthFailedException ex) {
+				log.dump(getClass(), Severity.Debug, ex, String.format("Attempting default ssh identity key locations: %s", id));
+				// ignore
+			}
+		}
+		return false;
+	}
+}
\ No newline at end of file
--- a/src/org/tmatesoft/hg/internal/remote/Connector.java	Tue Aug 06 21:18:33 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/remote/Connector.java	Thu Aug 08 19:18:50 2013 +0200
@@ -18,14 +18,15 @@
 
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.net.URI;
 import java.util.Collection;
 import java.util.List;
 
+import org.tmatesoft.hg.auth.HgAuthFailedException;
 import org.tmatesoft.hg.core.HgRemoteConnectionException;
 import org.tmatesoft.hg.core.Nodeid;
 import org.tmatesoft.hg.core.SessionContext;
 import org.tmatesoft.hg.repo.HgRemoteRepository.Range;
+import org.tmatesoft.hg.repo.HgRemoteRepository.RemoteDescriptor;
 import org.tmatesoft.hg.repo.HgRuntimeException;
 
 /**
@@ -46,10 +47,11 @@
 	static final String NS_BOOKMARKS = "bookmarks";
 	static final String NS_PHASES = "phases";
 	
-	void init(URI uri, SessionContext sessionContext, Object globalConfig) throws HgRuntimeException;
+	// note, #init shall not assume remote is instanceof RemoteConnectorDescriptor, but Adaptable to it, instead
+	void init(RemoteDescriptor remote, SessionContext sessionContext, Object globalConfig) throws HgRuntimeException;
 	String getServerLocation();
 	//
-	void connect() throws HgRemoteConnectionException, HgRuntimeException;
+	void connect() throws HgAuthFailedException, HgRemoteConnectionException, HgRuntimeException;
 	void disconnect() throws HgRemoteConnectionException, HgRuntimeException;
 	void sessionBegin() throws HgRemoteConnectionException, HgRuntimeException;
 	void sessionEnd() throws HgRemoteConnectionException, HgRuntimeException;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/internal/remote/ConnectorBase.java	Thu Aug 08 19:18:50 2013 +0200
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.internal.remote;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.util.Collection;
+import java.util.List;
+
+import org.tmatesoft.hg.auth.HgAuthFailedException;
+import org.tmatesoft.hg.core.HgRemoteConnectionException;
+import org.tmatesoft.hg.core.Nodeid;
+import org.tmatesoft.hg.core.SessionContext;
+import org.tmatesoft.hg.repo.HgRemoteRepository.RemoteDescriptor;
+import org.tmatesoft.hg.repo.HgRuntimeException;
+import org.tmatesoft.hg.repo.HgRemoteRepository.Range;
+
+/**
+ * An abstract base class for {@link Connector} implementations,
+ * to keep binary compatibility once {@link Connector} interface changes.
+ * 
+ * <p>Provides default implementation for {@link #getServerLocation()} that hides user credentials from uri, if any
+ * 
+ * <p>Present method implementations are not expected to be invoked and do nothing, this may change in future to return 
+ * reasonable error objects. New methods, added to {@link Connector}, will get default implementation in this class as well.
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+public abstract class ConnectorBase implements Connector {
+	protected URI uri;
+	
+	protected ConnectorBase() {
+	}
+	
+	protected void setURI(URI uri) {
+		this.uri = uri;
+	}
+
+	// clients may invoke this method, or call #setURI(URI) directly
+	public void init(RemoteDescriptor remote, SessionContext sessionContext, Object globalConfig) throws HgRuntimeException {
+		setURI(remote.getURI());
+	}
+
+	public String getServerLocation() {
+		if (uri == null) {
+			return "";
+		}
+		if (uri.getUserInfo() == null) {
+			return uri.toString();
+		}
+		if (uri.getPort() != -1) {
+			return String.format("%s://%s:%d%s", uri.getScheme(), uri.getHost(), uri.getPort(), uri.getPath());
+		} else {
+			return String.format("%s://%s%s", uri.getScheme(), uri.getHost(), uri.getPath());
+		}
+	}
+
+	public void connect() throws HgAuthFailedException, HgRemoteConnectionException, HgRuntimeException {
+	}
+
+	public void disconnect() throws HgRemoteConnectionException, HgRuntimeException {
+	}
+
+	public void sessionBegin() throws HgRemoteConnectionException, HgRuntimeException {
+	}
+
+	public void sessionEnd() throws HgRemoteConnectionException, HgRuntimeException {
+	}
+
+	public String getCapabilities() throws HgRemoteConnectionException, HgRuntimeException {
+		return null;
+	}
+
+	public InputStream heads() throws HgRemoteConnectionException, HgRuntimeException {
+		return null;
+	}
+
+	public InputStream between(Collection<Range> ranges) throws HgRemoteConnectionException, HgRuntimeException {
+		return null;
+	}
+
+	public InputStream branches(List<Nodeid> nodes) throws HgRemoteConnectionException, HgRuntimeException {
+		return null;
+	}
+
+	public InputStream changegroup(List<Nodeid> roots) throws HgRemoteConnectionException, HgRuntimeException {
+		return null;
+	}
+
+	public OutputStream unbundle(long outputLen, List<Nodeid> remoteHeads) throws HgRemoteConnectionException, HgRuntimeException {
+		return null;
+	}
+
+	public InputStream pushkey(String opName, String namespace, String key, String oldValue, String newValue) throws HgRemoteConnectionException, HgRuntimeException {
+		return null;
+	}
+
+	public InputStream listkeys(String namespace, String actionName) throws HgRemoteConnectionException, HgRuntimeException {
+		return null;
+	}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/internal/remote/HttpAuthMethod.java	Thu Aug 08 19:18:50 2013 +0200
@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.internal.remote;
+
+import static org.tmatesoft.hg.util.LogFacility.Severity.Info;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.prefs.BackingStoreException;
+import java.util.prefs.Preferences;
+
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
+import org.tmatesoft.hg.auth.HgAuthFailedException;
+import org.tmatesoft.hg.auth.HgAuthMethod;
+import org.tmatesoft.hg.core.HgRemoteConnectionException;
+import org.tmatesoft.hg.core.SessionContext;
+import org.tmatesoft.hg.repo.HgInvalidStateException;
+
+/**
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+public class HttpAuthMethod implements HgAuthMethod {
+	
+	private final SessionContext ctx;
+	private final URL url;
+	private String authInfo;
+	private SSLContext sslContext;
+
+	/**
+	 * @param sessionContext
+	 * @param url location fully ready to attempt connection to perform authentication check, e.g. hello command (anything with *small* output will do)
+	 * @throws HgRemoteConnectionException
+	 */
+	HttpAuthMethod(SessionContext sessionContext, URL url) throws HgRemoteConnectionException {
+		ctx = sessionContext;
+		if (!"http".equals(url.getProtocol()) && !"https".equals(url.getProtocol())) {
+			throw new HgInvalidStateException(String.format("http protocol expected: %s", url.toString()));
+		}
+		this.url = url;
+		if ("https".equals(url.getProtocol())) {
+			try {
+				sslContext = SSLContext.getInstance("SSL");
+				class TrustEveryone implements X509TrustManager {
+					public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+					}
+					public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+					}
+					public X509Certificate[] getAcceptedIssuers() {
+						return new X509Certificate[0];
+					}
+				};
+				sslContext.init(null, new TrustManager[] { new TrustEveryone() }, null);
+			} catch (Exception ex) {
+				throw new HgRemoteConnectionException("Can't initialize secure connection", ex);
+			}
+		} else {
+			sslContext = null;
+		}
+	}
+	
+	public void tryWithUserInfo(String uriUserInfo) throws HgAuthFailedException {
+		int colon = uriUserInfo.indexOf(':');
+		if (colon == -1) {
+			withPassword(uriUserInfo, null);
+		} else {
+			withPassword(uriUserInfo.substring(0, colon), uriUserInfo.substring(colon+1));
+		}
+	}
+
+	public void noCredentials() throws HgAuthFailedException {
+		// TODO Auto-generated method stub
+		checkConnection();
+	}
+
+	public boolean supportsPassword() {
+		return true;
+	}
+
+	public void withPassword(String username, String password) throws HgAuthFailedException {
+		authInfo = buildAuthValue(username, password == null ? "" : password);
+		checkConnection();
+	}
+
+	public boolean supportsPublicKey() {
+		return false;
+	}
+
+	public void withPublicKey(String username, InputStream privateKey, String passphrase) throws HgAuthFailedException {
+	}
+
+	public boolean supportsCertificate() {
+		return "https".equals(url.getProtocol());
+	}
+
+	public void withCertificate(X509Certificate[] clientCert) throws HgAuthFailedException {
+		// TODO Auto-generated method stub
+		checkConnection();
+	}
+
+	private void checkConnection() throws HgAuthFailedException {
+		// we've checked the protocol to be http(s)
+		HttpURLConnection c = null;
+		try {
+			c = (HttpURLConnection) url.openConnection();
+			c = setupConnection(c);
+			c.connect();
+			InputStream is = c.getInputStream();
+			while (is.read() != -1) {
+			}
+			is.close();
+			final int HTTP_UNAUTHORIZED = 401;
+			if (c.getResponseCode() == HTTP_UNAUTHORIZED) {
+				throw new HgAuthFailedException(c.getResponseMessage(), null);
+			}
+		} catch (IOException ex) {
+			throw new HgAuthFailedException("Communication failure while authenticating", ex);
+		} finally {
+			if (c != null) {
+				c.disconnect();
+			}
+		}
+	}
+
+	HttpURLConnection setupConnection(HttpURLConnection urlConnection) {
+		if (authInfo != null) {
+			urlConnection.addRequestProperty("Authorization", "Basic " + authInfo);
+		}
+		if (sslContext != null) {
+			((HttpsURLConnection) urlConnection).setSSLSocketFactory(sslContext.getSocketFactory());
+		}
+		return urlConnection;
+	}
+
+	private String buildAuthValue(String username, String password) {
+		String ai = null;
+		try {
+			// Hack to get Base64-encoded credentials
+			Preferences tempNode = Preferences.userRoot().node("xxx");
+			tempNode.putByteArray("xxx", String.format("%s:%s", username, password).getBytes());
+			ai = tempNode.get("xxx", null);
+			tempNode.removeNode();
+		} catch (BackingStoreException ex) {
+			ctx.getLog().dump(getClass(), Info, ex, null);
+			// IGNORE
+		}
+		return ai;
+	}
+}
--- a/src/org/tmatesoft/hg/internal/remote/HttpConnector.java	Tue Aug 06 21:18:33 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/remote/HttpConnector.java	Thu Aug 08 19:18:50 2013 +0200
@@ -16,8 +16,6 @@
  */
 package org.tmatesoft.hg.internal.remote;
 
-import static org.tmatesoft.hg.util.LogFacility.Severity.Info;
-
 import java.io.BufferedReader;
 import java.io.ByteArrayInputStream;
 import java.io.FilterOutputStream;
@@ -28,27 +26,20 @@
 import java.io.SequenceInputStream;
 import java.net.HttpURLConnection;
 import java.net.MalformedURLException;
-import java.net.URI;
 import java.net.URL;
 import java.net.URLConnection;
-import java.security.cert.CertificateException;
-import java.security.cert.X509Certificate;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
-import java.util.prefs.BackingStoreException;
-import java.util.prefs.Preferences;
 
-import javax.net.ssl.HttpsURLConnection;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.TrustManager;
-import javax.net.ssl.X509TrustManager;
-
+import org.tmatesoft.hg.auth.HgAuthFailedException;
+import org.tmatesoft.hg.auth.HgAuthenticator;
 import org.tmatesoft.hg.core.HgRemoteConnectionException;
 import org.tmatesoft.hg.core.Nodeid;
 import org.tmatesoft.hg.core.SessionContext;
 import org.tmatesoft.hg.internal.PropertyMarshal;
 import org.tmatesoft.hg.repo.HgRemoteRepository.Range;
+import org.tmatesoft.hg.repo.HgRemoteRepository.RemoteDescriptor;
 import org.tmatesoft.hg.repo.HgRuntimeException;
 
 /**
@@ -56,69 +47,43 @@
  * @author Artem Tikhomirov
  * @author TMate Software Ltd.
  */
-public class HttpConnector implements Connector {
-	private URI uri;
+public class HttpConnector extends ConnectorBase {
+	private RemoteDescriptor rd;
 	private URL url;
-	private SSLContext sslContext;
-	private String authInfo;
 	private boolean debug;
 	private SessionContext sessionCtx;
 	//
 	private HttpURLConnection conn;
+	private HttpAuthMethod authMediator;
 
-	public void init(URI uri, SessionContext sessionContext, Object globalConfig) throws HgRuntimeException {
-		this.uri = uri;
+	public void init(RemoteDescriptor remote, SessionContext sessionContext, Object globalConfig) throws HgRuntimeException {
+		rd = remote;
+		setURI(remote.getURI());
 		sessionCtx = sessionContext;
-		debug = new PropertyMarshal(sessionCtx).getBoolean("hg4j.remote.debug", false);
-		if (uri.getUserInfo() != null) {
-			String ai = null;
-			try {
-				// Hack to get Base64-encoded credentials
-				Preferences tempNode = Preferences.userRoot().node("xxx");
-				tempNode.putByteArray("xxx", uri.getUserInfo().getBytes());
-				ai = tempNode.get("xxx", null);
-				tempNode.removeNode();
-			} catch (BackingStoreException ex) {
-				sessionContext.getLog().dump(getClass(), Info, ex, null);
-				// IGNORE
-			}
-			authInfo = ai;
-		} else {
-			authInfo = null;
-		}
+		debug = new PropertyMarshal(sessionContext).getBoolean("hg4j.remote.debug", false);
 	}
 	
-	public void connect() throws HgRemoteConnectionException, HgRuntimeException {
+	public void connect() throws HgAuthFailedException, HgRemoteConnectionException, HgRuntimeException {
 		try {
 			url = uri.toURL();
 		} catch (MalformedURLException ex) {
 			throw new HgRemoteConnectionException("Bad URL", ex);
 		}
-		if ("https".equals(url.getProtocol())) {
+		authMediator = new HttpAuthMethod(sessionCtx, url);
+		authenticateClient();
+	}
+
+	private void authenticateClient() throws HgAuthFailedException {
+		String userInfo = url.getUserInfo();
+		if (userInfo != null) {
 			try {
-				sslContext = SSLContext.getInstance("SSL");
-				class TrustEveryone implements X509TrustManager {
-					public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
-						if (debug) {
-							System.out.println("checkClientTrusted:" + authType);
-						}
-					}
-					public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
-						if (debug) {
-							System.out.println("checkServerTrusted:" + authType);
-						}
-					}
-					public X509Certificate[] getAcceptedIssuers() {
-						return new X509Certificate[0];
-					}
-				};
-				sslContext.init(null, new TrustManager[] { new TrustEveryone() }, null);
-			} catch (Exception ex) {
-				throw new HgRemoteConnectionException("Can't initialize secure connection", ex);
+				authMediator.tryWithUserInfo(userInfo);
+			} catch (HgAuthFailedException ex) {
+				// FALL THROUGH to try Authenticator 
 			}
-		} else {
-			sslContext = null;
 		}
+		HgAuthenticator auth = sessionCtx.getAuthenticator(rd);
+		auth.authenticate(rd, authMediator);
 	}
 
 	public void disconnect() throws HgRemoteConnectionException, HgRuntimeException {
@@ -138,17 +103,6 @@
 		}
 	}
 	
-	public String getServerLocation() {
-		if (uri.getUserInfo() == null) {
-			return uri.toString();
-		}
-		if (uri.getPort() != -1) {
-			return String.format("%s://%s:%d%s", uri.getScheme(), uri.getHost(), uri.getPort(), uri.getPath());
-		} else {
-			return String.format("%s://%s%s", uri.getScheme(), uri.getHost(), uri.getPath());
-		}
-	}
-
 	public String getCapabilities() throws HgRemoteConnectionException {
 		// say hello to server, check response
 		try {
@@ -367,13 +321,7 @@
 	private HttpURLConnection setupConnection(URLConnection urlConnection) {
 		urlConnection.setRequestProperty("User-Agent", "hg4j/1.0.0");
 		urlConnection.addRequestProperty("Accept", "application/mercurial-0.1");
-		if (authInfo != null) {
-			urlConnection.addRequestProperty("Authorization", "Basic " + authInfo);
-		}
-		if (sslContext != null) {
-			((HttpsURLConnection) urlConnection).setSSLSocketFactory(sslContext.getSocketFactory());
-		}
-		return (HttpURLConnection) urlConnection;
+		return authMediator.setupConnection((HttpURLConnection) urlConnection);
 	}
 	
 	private StringBuilder appendNodeidListArgument(String key, List<Nodeid> values, StringBuilder sb) {
--- a/src/org/tmatesoft/hg/internal/remote/RemoteConnectorDescriptor.java	Tue Aug 06 21:18:33 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/remote/RemoteConnectorDescriptor.java	Thu Aug 08 19:18:50 2013 +0200
@@ -22,8 +22,6 @@
 
 import org.tmatesoft.hg.core.HgBadArgumentException;
 import org.tmatesoft.hg.core.SessionContext;
-import org.tmatesoft.hg.repo.HgLookup.Authenticator;
-import org.tmatesoft.hg.repo.HgLookup.RemoteDescriptor;
 import org.tmatesoft.hg.repo.HgRemoteRepository;
 import org.tmatesoft.hg.util.Pair;
 
@@ -38,7 +36,7 @@
  * @author Artem Tikhomirov
  * @author TMate Software Ltd.
  */
-public class RemoteConnectorDescriptor implements RemoteDescriptor {
+public class RemoteConnectorDescriptor implements HgRemoteRepository.RemoteDescriptor {
 	
 	private Map<String, Pair<ClassLoader, String>> connFactory;
 	private final URI uri;
@@ -56,11 +54,6 @@
 		return uri;
 	}
 
-	public Authenticator getAuth() {
-		// TODO Auto-generated method stub
-		return null;
-	}
-
 	public Connector createConnector() throws HgBadArgumentException {
 		Pair<ClassLoader, String> connectorToBe = connFactory.get(uri.getScheme());
 		if (connectorToBe == null || connectorToBe.second() == null) {
@@ -95,7 +88,7 @@
 			knownConnectors.put("ssh", new Pair<ClassLoader, String>(cl, SshConnector.class.getName()));
 		}
 
-		public RemoteDescriptor get(SessionContext ctx, URI uri) {
+		public HgRemoteRepository.RemoteDescriptor get(SessionContext ctx, URI uri) {
 			if (knownConnectors.containsKey(uri.getScheme())) {
 				return new RemoteConnectorDescriptor(knownConnectors, uri);
 			}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/internal/remote/SshAuthMethod.java	Thu Aug 08 19:18:50 2013 +0200
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2013 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.internal.remote;
+
+import java.io.CharArrayWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+
+import org.tmatesoft.hg.auth.HgAuthFailedException;
+import org.tmatesoft.hg.auth.HgAuthMethod;
+
+import com.trilead.ssh2.Connection;
+
+/**
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+public final class SshAuthMethod implements HgAuthMethod {
+
+	private final Connection conn;
+
+	public SshAuthMethod(Connection connection) {
+		conn = connection;
+	}
+
+	public void tryWithUserInfo(String uriUserInfo) throws HgAuthFailedException {
+		assert uriUserInfo != null && uriUserInfo.trim().length() > 0;
+		int colon = uriUserInfo.indexOf(':');
+		if (colon == -1) {
+			String username = uriUserInfo;
+			withPassword(username, null);
+		} else {
+			String username = uriUserInfo.substring(0, colon);
+			String password = uriUserInfo.substring(colon+1);
+			withPassword(username, password);
+		}
+		return;
+	}
+
+	public void noCredentials() throws HgAuthFailedException {
+		try {
+			String username = System.getProperty("user.name");
+			if (!conn.authenticateWithNone(username)) {
+				throw authFailed(username);
+			}
+		} catch (IOException ex) {
+			throw commFailed(ex);
+		}
+	}
+
+	public void withPublicKey(String username, InputStream privateKey, String passphrase) throws HgAuthFailedException {
+		if (username == null) {
+			// FIXME AuthFailure and AuthFailed or similar distinct exceptions to tell true authentication issues from
+			// failures around it.
+			throw new HgAuthFailedException("Need username", null);
+		}
+		if (privateKey == null) {
+			throw new HgAuthFailedException("Need private key", null);
+		}
+		CharArrayWriter a = new CharArrayWriter(2048);
+		int r;
+		try {
+			while((r = privateKey.read()) != -1) {
+				a.append((char) r);
+			}
+		} catch (IOException ex) {
+			throw new HgAuthFailedException("Failed to read private key", ex);
+		}
+		try {
+			boolean success = conn.authenticateWithPublicKey(username, a.toCharArray(), passphrase);
+			if (!success) {
+				throw authFailed(username);
+			}
+		} catch (IOException ex) {
+			throw commFailed(ex);
+		}
+	}
+
+	public void withPassword(String username, String password) throws HgAuthFailedException {
+		if (username == null) {
+			throw new HgAuthFailedException("Need username", null);
+		}
+		try {
+			boolean success;
+			if (password == null) {
+				success = conn.authenticateWithNone(username);
+			} else {
+				success = conn.authenticateWithPassword(username, password);
+			}
+			if (!success) {
+				throw authFailed(username);
+			}
+		} catch (IOException ex) {
+			throw commFailed(ex);
+		}
+	}
+
+	public void withCertificate(X509Certificate[] clientCert) throws HgAuthFailedException {
+	}
+
+	public boolean supportsPublicKey() {
+		return true;
+	}
+
+	public boolean supportsPassword() {
+		return true;
+	}
+
+	public boolean supportsCertificate() {
+		return true;
+	}
+
+	private HgAuthFailedException commFailed(IOException ex) {
+		return new HgAuthFailedException("Communication failure while authenticating", ex);
+	}
+
+	private HgAuthFailedException authFailed(String username) throws IOException {
+		final String[] authMethodsLeft = conn.getRemainingAuthMethods(username);
+		return new HgAuthFailedException(String.format("Failed to authenticate, other methods to try: %s", Arrays.toString(authMethodsLeft)), null);
+	}
+}
\ No newline at end of file
--- a/src/org/tmatesoft/hg/internal/remote/SshConnector.java	Tue Aug 06 21:18:33 2013 +0200
+++ b/src/org/tmatesoft/hg/internal/remote/SshConnector.java	Thu Aug 08 19:18:50 2013 +0200
@@ -20,7 +20,6 @@
 import java.io.ByteArrayInputStream;
 import java.io.Closeable;
 import java.io.EOFException;
-import java.io.File;
 import java.io.FilterInputStream;
 import java.io.FilterOutputStream;
 import java.io.IOException;
@@ -28,21 +27,22 @@
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.SequenceInputStream;
-import java.net.URI;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 
+import org.tmatesoft.hg.auth.HgAuthFailedException;
+import org.tmatesoft.hg.auth.HgAuthenticator;
 import org.tmatesoft.hg.core.HgRemoteConnectionException;
 import org.tmatesoft.hg.core.Nodeid;
 import org.tmatesoft.hg.core.SessionContext;
 import org.tmatesoft.hg.repo.HgRemoteRepository.Range;
+import org.tmatesoft.hg.repo.HgRemoteRepository.RemoteDescriptor;
 import org.tmatesoft.hg.repo.HgRuntimeException;
 import org.tmatesoft.hg.util.LogFacility.Severity;
 
 import com.trilead.ssh2.Connection;
-import com.trilead.ssh2.ConnectionInfo;
 import com.trilead.ssh2.Session;
 import com.trilead.ssh2.StreamGobbler;
 
@@ -52,9 +52,9 @@
  * @author Artem Tikhomirov
  * @author TMate Software Ltd.
  */
-public class SshConnector implements Connector {
+public class SshConnector extends ConnectorBase {
+	private RemoteDescriptor rd;
 	private SessionContext sessionCtx;
-	private URI uri;
 	private Connection conn;
 	private Session session;
 	private int sessionUse;
@@ -62,30 +62,39 @@
 	private StreamGobbler remoteErr, remoteOut;
 	private OutputStream remoteIn;
 	
-	public void init(URI uri, SessionContext sessionContext, Object globalConfig) throws HgRuntimeException {
+	public void init(RemoteDescriptor remote, SessionContext sessionContext, Object globalConfig) throws HgRuntimeException {
+		rd = remote;
 		sessionCtx = sessionContext;
-		this.uri = uri;
+		setURI(remote.getURI());
 	}
 	
-	public void connect() throws HgRemoteConnectionException, HgRuntimeException {
+	public void connect() throws HgAuthFailedException, HgRemoteConnectionException, HgRuntimeException {
 		try {
 			conn = new Connection(uri.getHost(), uri.getPort() == -1 ? 22 : uri.getPort());
 			conn.connect();
+			authenticateClient();
 		} catch (IOException ex) {
-			throw new HgRemoteConnectionException("Failed to establish connection");
+			throw new HgRemoteConnectionException("Failed to establish connection").setServerInfo(getServerLocation());
 		}
-		try {
-			conn.authenticateWithPublicKey(System.getProperty("user.name"), new File(System.getProperty("user.home"), ".ssh/id_rsa"), null);
-			ConnectionInfo ci = conn.getConnectionInfo();
-			System.out.printf("%s %s %s %d %s %s %s\n", ci.clientToServerCryptoAlgorithm, ci.clientToServerMACAlgorithm, ci.keyExchangeAlgorithm, ci.keyExchangeCounter, ci.serverHostKeyAlgorithm, ci.serverToClientCryptoAlgorithm, ci.serverToClientMACAlgorithm);
-		} catch (IOException ex) {
-			throw new HgRemoteConnectionException("Failed to authenticate", ex).setServerInfo(getServerLocation());
+	}
+
+	private void authenticateClient() throws HgAuthFailedException {
+		SshAuthMethod m = new SshAuthMethod(conn);
+		if (uri.getUserInfo() != null) {
+			try {
+				m.tryWithUserInfo(uri.getUserInfo());
+				return;
+			} catch (HgAuthFailedException ex) {
+				// FALL-THROUGH to try with Authenticator
+			}
 		}
+		HgAuthenticator auth = sessionCtx.getAuthenticator(rd);
+		auth.authenticate(rd, m);
 	}
 	
 	public void disconnect() throws HgRemoteConnectionException {
 		if (session != null) {
-			forceSessionClose();
+			doSessionClose();
 		}
 		if (conn != null) {
 			conn.close();
@@ -119,13 +128,9 @@
 			sessionUse--;
 			return;
 		}
-		forceSessionClose();
+		doSessionClose();
 	}
 
-	public String getServerLocation() {
-		return uri.toString(); // FIXME
-	}
-	
 	public String getCapabilities() throws HgRemoteConnectionException {
 		try {
 			consume(remoteOut);
@@ -291,7 +296,7 @@
 	}
 
 
-	private void forceSessionClose() {
+	private void doSessionClose() {
 		if (session != null) {
 			closeQuietly(remoteErr);
 			closeQuietly(remoteOut);
--- a/src/org/tmatesoft/hg/repo/HgLookup.java	Tue Aug 06 21:18:33 2013 +0200
+++ b/src/org/tmatesoft/hg/repo/HgLookup.java	Thu Aug 08 19:18:50 2013 +0200
@@ -19,7 +19,6 @@
 import static org.tmatesoft.hg.util.LogFacility.Severity.Warn;
 
 import java.io.File;
-import java.io.InputStream;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
@@ -33,7 +32,6 @@
 import org.tmatesoft.hg.internal.BasicSessionContext;
 import org.tmatesoft.hg.internal.ConfigFile;
 import org.tmatesoft.hg.internal.DataAccessProvider;
-import org.tmatesoft.hg.internal.Experimental;
 import org.tmatesoft.hg.internal.RequiresFile;
 import org.tmatesoft.hg.repo.HgRepoConfig.PathsSection;
 
@@ -184,7 +182,7 @@
 	 * @throws HgBadArgumentException
 	 */
 	public HgRemoteRepository detectRemote(URI uriRemote) throws HgBadArgumentException {
-		RemoteDescriptor rd = getSessionContext().getRemoteDescriptor(uriRemote);
+		HgRemoteRepository.RemoteDescriptor rd = getSessionContext().getRemoteDescriptor(uriRemote);
 		if (rd == null) {
 			throw new HgBadArgumentException(String.format("Unsupported remote repository location:%s", uriRemote), null);
 		}
@@ -210,41 +208,4 @@
 		}
 		return sessionContext;
 	}
-
-	
-	/**
-	 * Session context  ({@link SessionContext#getRemoteDescriptor(URI)} gives descriptor of remote when asked.
-	 */
-	@Experimental(reason="Work in progress")
-	public interface RemoteDescriptor {
-		URI getURI();
-		Authenticator getAuth();
-	}
-	
-	@Experimental(reason="Work in progress")
-	public interface Authenticator {
-		public void authenticate(RemoteDescriptor remote, AuthMethod authMethod);
-	}
-
-	/**
-	 * Clients do not implement this interface, instead, they invoke appropriate authentication method
-	 * once they got user input
-	 */
-	@Experimental(reason="Work in progress")
-	public interface AuthMethod {
-		public void noCredentials() throws AuthFailedException;
-		public boolean supportsPassword();
-		public void withPassword(String username, byte[] password) throws AuthFailedException;
-		public boolean supportsPublicKey();
-		public void withPublicKey(String username, InputStream publicKey, String passphrase) throws AuthFailedException;
-		public boolean supportsCertificate();
-		public void withCertificate() throws AuthFailedException;
-	}
-	
-	@SuppressWarnings("serial")
-	public class AuthFailedException extends Exception /*XXX HgRemoteException?*/ {
-		public AuthFailedException(String message, Throwable cause) {
-			super(message, cause);
-		}
-	}
 }
--- a/src/org/tmatesoft/hg/repo/HgRemoteRepository.java	Tue Aug 06 21:18:33 2013 +0200
+++ b/src/org/tmatesoft/hg/repo/HgRemoteRepository.java	Thu Aug 08 19:18:50 2013 +0200
@@ -27,6 +27,7 @@
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.StreamTokenizer;
+import java.net.URI;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -39,6 +40,7 @@
 import java.util.Map;
 import java.util.Set;
 
+import org.tmatesoft.hg.auth.HgAuthFailedException;
 import org.tmatesoft.hg.core.HgBadArgumentException;
 import org.tmatesoft.hg.core.HgIOException;
 import org.tmatesoft.hg.core.HgRemoteConnectionException;
@@ -49,12 +51,13 @@
 import org.tmatesoft.hg.internal.DataSerializer;
 import org.tmatesoft.hg.internal.DataSerializer.OutputStreamSerializer;
 import org.tmatesoft.hg.internal.EncodingHelper;
+import org.tmatesoft.hg.internal.Experimental;
 import org.tmatesoft.hg.internal.FileUtils;
 import org.tmatesoft.hg.internal.Internals;
 import org.tmatesoft.hg.internal.PropertyMarshal;
 import org.tmatesoft.hg.internal.remote.Connector;
 import org.tmatesoft.hg.internal.remote.RemoteConnectorDescriptor;
-import org.tmatesoft.hg.repo.HgLookup.RemoteDescriptor;
+import org.tmatesoft.hg.util.Adaptable;
 import org.tmatesoft.hg.util.LogFacility.Severity;
 import org.tmatesoft.hg.util.Outcome;
 import org.tmatesoft.hg.util.Pair;
@@ -77,13 +80,14 @@
 	private Connector remote;
 	
 	HgRemoteRepository(SessionContext ctx, RemoteDescriptor rd) throws HgBadArgumentException {
-		if (false == rd instanceof RemoteConnectorDescriptor) {
+		RemoteConnectorDescriptor rcd = Adaptable.Factory.getAdapter(rd, RemoteConnectorDescriptor.class, null);
+		if (rcd == null) {
 			throw new IllegalArgumentException(String.format("Present implementation supports remote connections via %s only", Connector.class.getName()));
 		}
 		sessionContext = ctx;
 		debug = new PropertyMarshal(ctx).getBoolean("hg4j.remote.debug", false);
-		remote = ((RemoteConnectorDescriptor) rd).createConnector();
-		remote.init(rd.getURI(), ctx, null);
+		remote = rcd.createConnector();
+		remote.init(rd /*sic! pass original*/, ctx, null);
 	}
 	
 	public boolean isInvalid() throws HgRemoteConnectionException {
@@ -369,7 +373,11 @@
 		if (remoteCapabilities != null) {
 			return;
 		}
-		remote.connect();
+		try {
+			remote.connect();
+		} catch (HgAuthFailedException ex) {
+			throw new HgRemoteConnectionException("Failed to authenticate", ex).setServerInfo(remote.getServerLocation());
+		}
 		try {
 			remote.sessionBegin();
 			String capsLine = remote.getCapabilities();
@@ -540,4 +548,19 @@
 			return pub;
 		}
 	}
+
+	/**
+	 * Session context  ({@link SessionContext#getRemoteDescriptor(URI)} gives descriptor of remote when asked.
+	 * Clients may supply own descriptors e.g. if need to pass extra information into Authenticator. 
+	 * Present implementation of {@link HgRemoteRepository} will be happy with any {@link RemoteDescriptor} subclass
+	 * as long as it's {@link Adaptable adaptable} to {@link RemoteConnectorDescriptor} 
+	 * @since 1.2
+	 */
+	@Experimental(reason="Provisional API. Work in progress")
+	public interface RemoteDescriptor {
+		/**
+		 * @return remote location, never <code>null</code>
+		 */
+		URI getURI();
+	}
 }
--- a/test/org/tmatesoft/hg/test/Configuration.java	Tue Aug 06 21:18:33 2013 +0200
+++ b/test/org/tmatesoft/hg/test/Configuration.java	Thu Aug 08 19:18:50 2013 +0200
@@ -84,7 +84,7 @@
 		if (remoteServers == null) {
 			String rr = System.getProperty("hg4j.tests.remote");
 			assertNotNull("System property hg4j.tests.remote is undefined", rr);
-			remoteServers = Arrays.asList(rr.split(" "));
+			remoteServers = Arrays.asList(rr.split("\\s"));
 		}
 		ArrayList<HgRemoteRepository> rv = new ArrayList<HgRemoteRepository>(remoteServers.size());
 		for (String key : remoteServers) {