[PATCH 3 of 3] hgkeyring: add new extension hgkeyring
Markus Zapke-Gründemann
markuszapke at gmx.net
Fri Aug 24 04:40:19 CDT 2012
# HG changeset patch
# User Markus Zapke-Gründemann <markus at keimlink.de>
# Date 1345799209 -7200
# Node ID bf1c189b5eb8bcfdfe7434081e3da19b0539d2bd
# Parent eb40ccf52cf171cc111d64418fbe81b7ecf42143
hgkeyring: add new extension hgkeyring
hgkeyring is an improved version of the mercurial_keyting extension. It
can be used to securely save HTTP authentication details.
diff --git a/hgext/hgkeyring.py b/hgext/hgkeyring.py
new file mode 100644
--- /dev/null
+++ b/hgext/hgkeyring.py
@@ -0,0 +1,434 @@
+# hgkeyring.py - secure password databases for mercurial
+#
+# Based on the mercurial_keyring extension.
+#
+# Copyright 2009 Marcin Kasperski <Marcin.Kasperski at mekk.waw.pl>
+# Copyright 2012 Markus Zapke-Gruendemann <markus at keimlink.de>
+"""securely save HTTP authentication details
+
+hgkeyring is a Mercurial extension used to securely save HTTP
+authentication passwords in password databases (GNOME Keyring, KDE
+KWallet, OS X Keychain, specific solutions for Win32 and command line).
+This extension uses and wraps services of the python keyring library.
+
+How it works
+------------
+
+The extension prompts for the password on the first pull/push, just like
+it is done by default, but saves the password. On successive runs it
+checks for the username in the configuration, then for the suitable
+password in the password database, and uses those credentials (if
+found).
+
+In case password turns out to be incorrect (either because it was
+invalid, or because it was changed on the server) or missing it just
+prompts the user again.
+
+Passwords are identified by the combination of username and remote
+address, so they can be reused between repositories if they access the
+same remote repository.
+
+Installation
+------------
+
+Prerequisites
+.............
+
+Install the keyring library using ``pip``::
+
+ pip install keyring
+
+or ``easy_install``::
+
+ easy_install keyring
+
+On Debian the library can be also installed from the official archive
+(packages ``python-keyring`` and either ``python-keyring-gnome`` or
+``python-keyring-kwallet``).
+
+Note: keyring >= 0.3 is strongly recommended, especially in case text
+backend is to be used.
+
+Extension installation
+......................
+
+Enable the extension in the configuration::
+
+ [extensions]
+ hgkeyring =
+
+Password backend configuration
+------------------------------
+
+The library should usually pick the most appropriate password backend
+without configuration. Still, if necessary, it can be configured using
+the ``~/keyringrc.cfg`` file (``keyringrc.cfg`` in the home directory of
+the current user). Refer to keyring documentation for more details.
+
+Repository configuration
+------------------------
+
+Edit repository-local ``hgrc`` and save there the remote repository path
+and the username, but do not save the password. For example::
+
+ [paths]
+ default = https://hg.example.com/repo/someproject
+
+ [auth]
+ myremote.schemes = http https
+ myremote.prefix = hg.example.com/repo
+ myremote.username = alice
+
+Simpler form with url-embedded name can also be used::
+
+ [paths]
+ bitbucket = https://bob@bitbucket.org/bob/project_name/
+
+If prefix is specified, it is used to identify the password (so all
+repositories with the same prefix and the same username will share the
+same password). Otherwise the full repository URL is used for this
+purpose.
+
+Note: if both username and password are given in the configuration, the
+extension will use them without using the password database. If username
+is not given, extension will prompt for credentials every time, also
+without saving the password.
+
+Finally, if you are consistent about remote repository nicknames, you
+can configure the username in your global configuration. For example,
+write there::
+
+ [auth]
+ acme.schemes = http https
+ acme.prefix = hg.acme.com/repositories
+ acme.username = clara
+
+As long as you will be using alias `acme` for repositories like
+`https://hg.acme.com/repositories/my_beautiful_app`, username `clara`
+will be used, and the same password reused.
+
+The advantage of this method is that it works also for :hg:`clone`.
+
+Usage
+-----
+
+Configure the repository as above, then just :hg:`pull`, :hg:`push`,
+etc. You should be asked for the password only once (per every username
+and remote repository prefix or url combination).
+"""
+import urllib2
+
+from mercurial import httpconnection, url, util
+from mercurial.i18n import _
+# mercurial.demandimport incompatibility workaround, otherwise
+# gnomekeyring, one of the possible keyring backends, would not to work.
+from mercurial.demandimport import ignore
+if 'gobject._gobject' not in ignore:
+ ignore.append('gobject._gobject')
+import keyring
+
+
+class passwordstore(object):
+ """Helper object handling keyring usage.
+
+ Controls how passwords are saved and read and the way they are keyed
+ in the keyring.
+ """
+ KEYRING_SERVICE = 'Mercurial'
+
+ @staticmethod
+ def _formatkey(uri, username):
+ """Creates a key for http passwords."""
+ return '%s@@%s' % (username, uri)
+
+ def getpassword(self, uri, username):
+ """Reads the password from the keyring."""
+ return keyring.get_password(self.KEYRING_SERVICE,
+ self._formatkey(uri, username)) # pragma: no cover
+
+ def setpassword(self, uri, username, password):
+ """Saves the password in the keyring."""
+ keyring.set_password(self.KEYRING_SERVICE,
+ self._formatkey(uri, username), password) # pragma: no cover
+
+ def clearpassword(self, uri, username):
+ """Clears a user's password for an uri."""
+ self.setpassword(uri, username, '') # pragma: no cover
+
+password_store = passwordstore()
+
+
+def canonicalurl(uri):
+ """Strips query and fragment from uri.
+
+ Example:
+
+ >>> url = 'https://hg.example.com/repos/module?cmd=capabilities'
+ >>> canonicalurl(url)
+ 'https://hg.example.com/repos/module'
+ """
+ u = util.url(uri)
+ u.query = None
+ u.fragment = None
+ return str(u)
+
+
+def expandprefix(prefix, uri):
+ """Expands auth.prefix.
+
+ Examples:
+
+ >>> prefix = '*'
+ >>> url = 'https://hg.example.com/repo'
+ >>> expandprefix(prefix, url)
+ 'https://hg.example.com/repo'
+ >>> prefix = None
+ >>> expandprefix(prefix, url)
+ 'https://hg.example.com/repo'
+ >>> prefix= 'hg.example.com'
+ >>> expandprefix(prefix, url)
+ 'https://hg.example.com'
+ >>> expandprefix(prefix, 'example.com')
+ 'http://hg.example.com'
+ >>> prefix= 'https://hg.example.com'
+ >>> expandprefix(prefix, url)
+ 'https://hg.example.com'
+ """
+ if not prefix or prefix == '*':
+ return uri
+ if util.hasscheme(prefix):
+ return prefix
+ try:
+ scheme, hostpath = uri.split('://')
+ except ValueError:
+ scheme = 'http'
+ return '%s://%s' % (scheme, prefix)
+
+
+class requesthistory(object):
+ """Stores information about different requests.
+
+ Use it to store and compare request information.
+
+ Examples:
+
+ >>> from urllib2 import Request
+ >>> uri = 'https://hg.example.com/'
+ >>> r = Request(uri)
+ >>> h = requesthistory()
+ >>> h == (uri, None, r)
+ False
+ >>> h.update(uri, None, r)
+ >>> h == (uri, None, r)
+ True
+ >>> h == (uri, 'auth', r)
+ False
+ >>> r.add_header('User-agent', 'mercurial/proto-1.0')
+ >>> h == (uri, None, r)
+ False
+ >>> h.update(uri)
+ >>> h == (uri, None, None)
+ True
+ """
+ def __init__(self):
+ self.uri = self.realm = self.headers = None
+
+ @staticmethod
+ def getheaders(request):
+ """Extracts headers from a Request object."""
+ try:
+ headers = request.header_items()
+ except AttributeError:
+ headers = None
+ return headers
+
+ def update(self, uri, realm=None, request=None):
+ """Updates requesthistory instance."""
+ self.uri = uri
+ self.realm = realm
+ self.headers = self.getheaders(request)
+
+ def __eq__(self, data):
+ """Compares if requesthistory instance and a data tuple are equal.
+
+ The tuple must have three elements: (uri, realm, request)
+ """
+ return (self.uri == data[0] and self.realm == data[1]
+ and self.headers == self.getheaders(data[2]))
+
+
+class httppasswordhandler(urllib2.HTTPPasswordMgrWithDefaultRealm):
+ """Actual implementation of password handling.
+
+ An instance of this class can be used as passwordmgr in mercurial.url.
+ """
+ def __init__(self, ui):
+ urllib2.HTTPPasswordMgrWithDefaultRealm.__init__(self)
+ self.pwdcache = {}
+ self.req = None
+ self.reqhistory = requesthistory()
+ self.ui = ui
+
+ def _writedebug(self, msg, uri=None, user=None, pwd=None):
+ """Writes debugging message to stdout.
+
+ If one or all of uri, user or pwd are set they are appended
+ to the debug message on a new line.
+ """
+ extramsg = []
+ if uri:
+ extramsg.append('url: %s' % util.hidepassword(uri))
+ if user:
+ extramsg.append('user: %s' % user)
+ if pwd:
+ extramsg.append('password: %s' % '***')
+ self.ui.debug('%s\n' % msg)
+ if len(extramsg):
+ self.ui.debug('\t%s\n' % ', '.join(extramsg))
+
+ def _debugbadauth(self, uri, realm):
+ """Print debugging information after bad authentication."""
+ self._writedebug(
+ 'detected bad authentication, cached passwords not used')
+ self._writedebug('current request:')
+ self._writedebug('\turi: %s' % uri)
+ if self.req:
+ self._writedebug('\trequest headers:')
+ for key, value in self.req.header_items():
+ if key == 'Authorization':
+ value = '***'
+ self._writedebug('\t\t%s: %s' % (key, value))
+ self._writedebug('\trealm: %s' % realm)
+ self._writedebug('previous request:')
+ self._writedebug('\turi: %s' % self.reqhistory.uri)
+ if self.reqhistory.headers:
+ self._writedebug('\trequest headers:')
+ for key, value in self.reqhistory.headers:
+ if key == 'Authorization':
+ value = '***'
+ self._writedebug('\t\t%s: %s' % (key, value))
+ self._writedebug('\trealm: %s' % self.reqhistory.realm)
+
+ def setrequest(self, request):
+ """Stores a Request instance for later analysis."""
+ self.req = request
+
+ def find_stored_password(self, authuri):
+ return self.find_user_password(None, authuri) # pragma: no cover
+
+ def find_user_password(self, realm, authuri):
+ """keyring-based implementation of username/password query.
+
+ Passwords are saved in GNOME Keyring, KDE KWallet, OS X Keychain
+ or other platform specific storage and keyed by the repository
+ url.
+ """
+ # If we are called again just after identical previous
+ # request, then the previously returned auth must have been
+ # wrong. So we note this to force password prompt (and avoid
+ # reusing bad password indifinitely).
+ afterbadauth = self.reqhistory == (authuri, realm, self.req)
+ if afterbadauth:
+ self._debugbadauth(authuri, realm)
+ baseurl = canonicalurl(authuri)
+ # Extracting possible username (and password if available)
+ # stored directly in repository url.
+ user, pwd = urllib2.HTTPPasswordMgrWithDefaultRealm.find_user_password(
+ self, realm, authuri)
+ if user and pwd:
+ self._writedebug('auth data found in repository url', baseurl,
+ user, pwd)
+ self.reqhistory.update(authuri, realm, self.req)
+ return user, pwd
+ if user:
+ self._writedebug('stripped username "%s" from url' % user)
+ cleanurl = util.removeauth(baseurl)
+ group, authuser, pwd, prefix = self.loadauthconf(cleanurl, user)
+ keyringurl = expandprefix(prefix, cleanurl)
+ self._writedebug('keyring url: %s' % keyringurl)
+ # Checking the memory cache (there may be many http calls per command)
+ cachekey = (realm, keyringurl)
+ if not afterbadauth:
+ cachedauth = self.pwdcache.get(cachekey)
+ if cachedauth:
+ user, pwd = cachedauth
+ self._writedebug('cached auth data found', baseurl, user, pwd)
+ self.reqhistory.update(authuri, realm, self.req)
+ return user, pwd
+ if authuser:
+ if user and (user == authuser):
+ msg = 'hgkeyring: username %s for %s specified both in '
+ msg += 'url/paths and in auth - please, leave only one of those'
+ raise util.Abort(_(msg % (user, keyringurl)))
+ user = authuser
+ self._writedebug('using auth.%s.* for authentication' % group)
+ if pwd:
+ self.pwdcache[cachekey] = user, pwd
+ self.reqhistory.update(authuri, realm, self.req)
+ return user, pwd
+ # Loading password from keyring. Only if username is known (so
+ # we know the key) and we are not after failure (so we don't
+ # reuse the bad password).
+ if user and not afterbadauth:
+ self._writedebug('looking for password for user %s and url %s'
+ % (user, keyringurl))
+ pwd = password_store.getpassword(keyringurl, user)
+ if pwd:
+ self.pwdcache[cachekey] = user, pwd
+ self._writedebug('password found on keyring', baseurl, user,
+ pwd)
+ self.reqhistory.update(authuri, realm, self.req)
+ return user, pwd
+ else:
+ self._writedebug('password not present in keyring')
+ # Is the username permanently set?
+ fixeduser = (user and True or False)
+ # Last resort: interactive prompt
+ if not self.ui.interactive():
+ msg = 'hgkeyring: http authorization required but program '
+ msg += 'used in non-interactive mode'
+ raise util.Abort(_(msg))
+ if not fixeduser:
+ msg = 'username not set in configuration - keyring is not used\n'
+ self.ui.status(_(msg))
+ self.ui.write(_('http authorization required\n'))
+ self.ui.status(_('realm: %s\n') % realm)
+ if fixeduser:
+ self.ui.write(_('user: %s (fixed in configuration)\n') % user)
+ else:
+ user = self.ui.prompt(_('user:'), default=None)
+ pwd = self.ui.getpass(_('password: '))
+ if fixeduser:
+ # Saving password to the keyring. It is done only if the
+ # username is permanently set. Otherwise we won't be able to
+ # find the password so it does not make much sense to
+ # preserve it.
+ self._writedebug('saving password for %s to keyring' % user)
+ password_store.setpassword(keyringurl, user, pwd)
+ # Saving password to the memory cache
+ self.pwdcache[cachekey] = user, pwd
+ self._writedebug('manually entered password', baseurl, user, pwd)
+ self.reqhistory.update(authuri, realm, self.req)
+ return user, pwd
+
+ def loadauthconf(self, uri, user):
+ """Loading auth section contents from local configuration.
+
+ Returns (group, username, password, prefix) tuple (every element
+ can be None).
+ """
+ authinfo = httpconnection.readauthforuri(self.ui, uri, user)
+ if authinfo:
+ group, auth = authinfo
+ username = auth.get('username')
+ pwd = auth.get('password')
+ prefix = auth.get('prefix')
+ else:
+ group = username = pwd = prefix = None
+ return group, username, pwd, prefix
+
+
+def uisetup(ui):
+ url.pwmgrclass = httppasswordhandler
+
+testedwith = '2.3'
diff --git a/tests/hghave.py b/tests/hghave.py
--- a/tests/hghave.py
+++ b/tests/hghave.py
@@ -107,6 +107,13 @@ def has_inotify():
os.unlink(name)
return True
+def has_keyring():
+ try:
+ import keyring
+ return True
+ except ImportError:
+ return False
+
def has_fifo():
if getattr(os, "mkfifo", None) is None:
return False
@@ -286,6 +293,7 @@ checks = {
"hardlink": (has_hardlink, "hardlinks"),
"icasefs": (has_icasefs, "case insensitive file system"),
"inotify": (has_inotify, "inotify extension support"),
+ "keyring": (has_keyring, "python keyring library"),
"lsprof": (has_lsprof, "python lsprof module"),
"mtn": (has_mtn, "monotone client (>= 1.0)"),
"outer-repo": (has_outer_repo, "outer repo"),
diff --git a/tests/test-hgkeyring.py b/tests/test-hgkeyring.py
new file mode 100644
--- /dev/null
+++ b/tests/test-hgkeyring.py
@@ -0,0 +1,228 @@
+"""Unit tests and doctests for the hgkeyring extension."""
+import os
+from urllib2 import Request
+import sys
+
+from mercurial import ui, util
+
+from hgext import hgkeyring
+
+
+class passwordstorefake(hgkeyring.passwordstore):
+ """A passwordstore fake using an internal cache."""
+ def __init__(self):
+ self.cache = {}
+
+ def getpassword(self, uri, username):
+ testclient.debug('reading password from keyring for %s\n' % uri)
+ cache_key = self._formatkey(uri, username)
+ try:
+ password = self.cache[cache_key]
+ except KeyError:
+ password = ''
+ return password
+
+ def setpassword(self, uri, username, password):
+ cache_key = self._formatkey(uri, username)
+ self.cache[cache_key] = password
+ testclient.debug('stored username %s and password %s\n'
+ % (username, password))
+
+
+hgkeyring.password_store = passwordstorefake()
+
+
+class uifake(ui.ui):
+ """A ui fake class for authentication tests."""
+ def __init__(self, user, password, interactive):
+ """Creates a new ui instance.
+
+ Set user and password to the desired values.
+ interactive is a boolean to control the interactive shell.
+ """
+ self._user = user
+ self._password = password
+ self._interactive = interactive
+ super(uifake, self).__init__()
+
+ def interactive(self):
+ """Returns True if the ui instance is interactive."""
+ return self._interactive
+
+ def prompt(self, msg, default='y'):
+ """"Prompt the user for a username.
+
+ Because this is a SUT the method returns always the username set
+ at instantiation.
+ """
+ return self._user
+
+ def getpass(self, prompt=None, default=None):
+ """"Prompt the user for a password.
+
+ Because this is a SUT the method returns always the password set
+ at instantiation.
+ """
+ return self._password
+
+
+class testclient(object):
+ """Test client for hgkeyring."""
+ def __init__(self, user, password=None, interactive=True):
+ """Creates a test client.
+
+ You must set a username. If no password is set a default
+ password is used. The interactive shell is enabled by default.
+ """
+ self.user = user
+ if password is None:
+ self.password = 'secret'
+ else:
+ self.password = password
+ self.ui = uifake(self.user, self.password, interactive=interactive)
+ self.pwhandler = hgkeyring.httppasswordhandler(self.ui)
+
+ @staticmethod
+ def debug(msg):
+ sys.stdout.write('[test] %s' % msg)
+
+ def testuri(self, uri, msg=None, headers=None, realm=None, debug=True):
+ """Tests an uri with username and password.
+
+ Use msg to print a message when the test starts.
+ Add additional headers using the headers kwarg.
+ Set the realm using the realm kwarg.
+ Set debug to False to disable hgkeyring debug output.
+ """
+ if headers is None:
+ headers = {}
+ if msg is not None:
+ self.debug(msg + '\n')
+ if debug:
+ debug_orig = self.ui.config('ui', 'debug')
+ self.ui.setconfig('ui', 'debug', 'True')
+ self.debug('testing user %s at %s\n' % (self.user, uri))
+ request = Request(uri, headers=headers)
+ if headers:
+ self.debug('request headers:\n')
+ for key, value in headers.items():
+ self.debug('\t%s: %s\n' % (key, value))
+ self.pwhandler.setrequest(request)
+ try:
+ uriobj = util.url(uri)
+ if uriobj.user:
+ self.pwhandler.add_password(realm, uri, uriobj.user,
+ uriobj.passwd)
+ user, pwd = self.pwhandler.find_user_password(realm, uri)
+ assert user == self.user, 'user "%s" did not match "%s"' \
+ % (user, self.user)
+ assert pwd == self.password, 'password "%s" did not match "%s"' \
+ % (pwd, self.password)
+ except util.Abort, err:
+ self.debug('abort: %s\n' % err)
+ except AssertionError, err:
+ self.debug('warning: %s\n' % err)
+ sys.stdout.write('\n')
+ if debug:
+ self.ui.setconfig('ui', 'debug', debug_orig)
+
+
+t = testclient('alice')
+t.testuri('https://hg.example.com/repo', msg='ask for password',
+ headers={'Authorization': 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==',
+ 'User-agent': 'mercurial/proto-1.0'})
+t.testuri('https://hg.example.com/repo',
+ msg='ignore password cache because of identical request',
+ headers={'Authorization': 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==',
+ 'User-agent': 'mercurial/proto-1.0'})
+t.testuri('https://alice@hg.example.com/repo',
+ msg='read username from URI, use cached password')
+t.testuri('https://alice:secret@hg.example.com/repo',
+ msg='read username and password from URI, cache is not used')
+t.testuri('https://hg.example.com/repo', msg='use cached password')
+
+# add auth section to .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'w')
+hgrc.write('[auth]\n')
+hgrc.write('myremote.prefix = hg.example.com\n')
+hgrc.write('myremote.username = bob\n')
+hgrc.close()
+
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo', msg='read password from keyring')
+t.testuri('https://hg.example.com/repo/module?cmd=capabilities',
+ msg='use cached password and strip off query params')
+
+pwd = 'cookies'
+# add password to .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'a')
+hgrc.write('myremote.password = %s\n' % pwd)
+hgrc.close()
+
+t = testclient('bob', pwd)
+t.testuri('https://bob@hg.example.com/repo',
+ msg='aborts because username is set in url and .hgrc')
+t.testuri('https://hg.example.com/repo',
+ msg='password is read from .hgrc')
+t.testuri('https://hg.example.com/repo?cmd=capabilities',
+ msg='use cached auth data')
+t.testuri('https://clara@hg.example.com/repo',
+ msg='store password for clara on keyring as user bob')
+
+# delete password from .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'a')
+hgrc.write('myremote.password =\n')
+hgrc.close()
+
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo',
+ msg='username is taken from .hgrc and password is read from keyring')
+
+t = testclient('clara', pwd)
+t.testuri('https://clara@hg.example.com/repo',
+ msg='read password for clara on keyring')
+
+# add catch all prefix to .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'a')
+hgrc.write('myremote.prefix = *\n')
+hgrc.close()
+
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo',
+ msg='read password from keyring, use full URI because of catch all prefix')
+
+# add not matching prefix to .hgrc
+hgrc = open(os.environ["HGRCPATH"], 'a')
+hgrc.write('myremote.prefix = code.example.com\n')
+hgrc.close()
+
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo',
+ msg='will prompt for password because prefix does not match')
+
+t = testclient('bob', interactive=False)
+t.testuri('https://hg.example.com/repo',
+ msg='will abort because terminal is non-interactive', debug=False)
+
+# test (simulated) clone operation
+t = testclient('bob')
+t.testuri('https://hg.example.com/repo/?cmd=capabilities',
+ msg='test with different http headers')
+t.testuri('https://hg.example.com/repo/?cmd=batch',
+ headers={'x-hgarg-1': 'cmds=heads+%3Bknown+nodes%3D'})
+revs = ('0000000000000000000000000000000000000000',
+ 'b066b1ec09fb7b604b548375170e78bc008bb6b2')
+t.testuri('https://hg.example.com/repo/?cmd=getbundle',
+ headers={'x-hgarg-1': 'common=%s&heads=%s' % revs})
+t.testuri('https://hg.example.com/repo/?cmd=listkeys',
+ headers={'x-hgarg-1': 'namespace=phases'})
+t.testuri('https://hg.example.com/repo/?cmd=listkeys',
+ headers={'x-hgarg-1': 'namespace=bookmarks'})
+
+# this is hack to make sure no escape characters are inserted into the
+# output
+if 'TERM' in os.environ:
+ del os.environ['TERM']
+
+import doctest
+doctest.testmod(hgkeyring)
diff --git a/tests/test-hgkeyring.py.out b/tests/test-hgkeyring.py.out
new file mode 100644
--- /dev/null
+++ b/tests/test-hgkeyring.py.out
@@ -0,0 +1,192 @@
+[test] ask for password
+[test] testing user alice at https://hg.example.com/repo
+[test] request headers:
+[test] Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
+[test] User-agent: mercurial/proto-1.0
+keyring url: https://hg.example.com/repo
+username not set in configuration - keyring is not used
+http authorization required
+realm: None
+manually entered password
+ url: https://hg.example.com/repo, user: alice, password: ***
+
+[test] ignore password cache because of identical request
+[test] testing user alice at https://hg.example.com/repo
+[test] request headers:
+[test] Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
+[test] User-agent: mercurial/proto-1.0
+detected bad authentication, cached passwords not used
+current request:
+ uri: https://hg.example.com/repo
+ request headers:
+ Authorization: ***
+ User-agent: mercurial/proto-1.0
+ realm: None
+previous request:
+ uri: https://hg.example.com/repo
+ request headers:
+ Authorization: ***
+ User-agent: mercurial/proto-1.0
+ realm: None
+keyring url: https://hg.example.com/repo
+username not set in configuration - keyring is not used
+http authorization required
+realm: None
+manually entered password
+ url: https://hg.example.com/repo, user: alice, password: ***
+
+[test] read username from URI, use cached password
+[test] testing user alice at https://alice@hg.example.com/repo
+stripped username "alice" from url
+keyring url: https://hg.example.com/repo
+cached auth data found
+ url: https://alice@hg.example.com/repo, user: alice, password: ***
+
+[test] read username and password from URI, cache is not used
+[test] testing user alice at https://alice:secret@hg.example.com/repo
+auth data found in repository url
+ url: https://alice:***@hg.example.com/repo, user: alice, password: ***
+
+[test] use cached password
+[test] testing user alice at https://hg.example.com/repo
+keyring url: https://hg.example.com/repo
+cached auth data found
+ url: https://hg.example.com/repo, user: alice, password: ***
+
+[test] read password from keyring
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com
+using auth.myremote.* for authentication
+looking for password for user bob and url https://hg.example.com
+[test] reading password from keyring for https://hg.example.com
+password not present in keyring
+http authorization required
+realm: None
+user: bob (fixed in configuration)
+saving password for bob to keyring
+[test] stored username bob and password secret
+manually entered password
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] use cached password and strip off query params
+[test] testing user bob at https://hg.example.com/repo/module?cmd=capabilities
+keyring url: https://hg.example.com
+cached auth data found
+ url: https://hg.example.com/repo/module, user: bob, password: ***
+
+[test] aborts because username is set in url and .hgrc
+[test] testing user bob at https://bob@hg.example.com/repo
+stripped username "bob" from url
+keyring url: https://hg.example.com
+[test] abort: hgkeyring: username bob for https://hg.example.com specified both in url/paths and in auth - please, leave only one of those
+
+[test] password is read from .hgrc
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com
+using auth.myremote.* for authentication
+
+[test] use cached auth data
+[test] testing user bob at https://hg.example.com/repo?cmd=capabilities
+keyring url: https://hg.example.com
+cached auth data found
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] store password for clara on keyring as user bob
+[test] testing user bob at https://clara@hg.example.com/repo
+stripped username "clara" from url
+keyring url: https://hg.example.com/repo
+looking for password for user clara and url https://hg.example.com/repo
+[test] reading password from keyring for https://hg.example.com/repo
+password not present in keyring
+http authorization required
+realm: None
+user: clara (fixed in configuration)
+saving password for clara to keyring
+[test] stored username clara and password cookies
+manually entered password
+ url: https://clara@hg.example.com/repo, user: clara, password: ***
+[test] warning: user "clara" did not match "bob"
+
+[test] username is taken from .hgrc and password is read from keyring
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com
+using auth.myremote.* for authentication
+looking for password for user bob and url https://hg.example.com
+[test] reading password from keyring for https://hg.example.com
+password found on keyring
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] read password for clara on keyring
+[test] testing user clara at https://clara@hg.example.com/repo
+stripped username "clara" from url
+keyring url: https://hg.example.com/repo
+looking for password for user clara and url https://hg.example.com/repo
+[test] reading password from keyring for https://hg.example.com/repo
+password found on keyring
+ url: https://clara@hg.example.com/repo, user: clara, password: ***
+
+[test] read password from keyring, use full URI because of catch all prefix
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com/repo
+using auth.myremote.* for authentication
+looking for password for user bob and url https://hg.example.com/repo
+[test] reading password from keyring for https://hg.example.com/repo
+password not present in keyring
+http authorization required
+realm: None
+user: bob (fixed in configuration)
+saving password for bob to keyring
+[test] stored username bob and password secret
+manually entered password
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] will prompt for password because prefix does not match
+[test] testing user bob at https://hg.example.com/repo
+keyring url: https://hg.example.com/repo
+username not set in configuration - keyring is not used
+http authorization required
+realm: None
+manually entered password
+ url: https://hg.example.com/repo, user: bob, password: ***
+
+[test] will abort because terminal is non-interactive
+[test] testing user bob at https://hg.example.com/repo
+[test] abort: hgkeyring: http authorization required but program used in non-interactive mode
+
+[test] test with different http headers
+[test] testing user bob at https://hg.example.com/repo/?cmd=capabilities
+keyring url: https://hg.example.com/repo/
+username not set in configuration - keyring is not used
+http authorization required
+realm: None
+manually entered password
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
+[test] testing user bob at https://hg.example.com/repo/?cmd=batch
+[test] request headers:
+[test] x-hgarg-1: cmds=heads+%3Bknown+nodes%3D
+keyring url: https://hg.example.com/repo/
+cached auth data found
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
+[test] testing user bob at https://hg.example.com/repo/?cmd=getbundle
+[test] request headers:
+[test] x-hgarg-1: common=0000000000000000000000000000000000000000&heads=b066b1ec09fb7b604b548375170e78bc008bb6b2
+keyring url: https://hg.example.com/repo/
+cached auth data found
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
+[test] testing user bob at https://hg.example.com/repo/?cmd=listkeys
+[test] request headers:
+[test] x-hgarg-1: namespace=phases
+keyring url: https://hg.example.com/repo/
+cached auth data found
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
+[test] testing user bob at https://hg.example.com/repo/?cmd=listkeys
+[test] request headers:
+[test] x-hgarg-1: namespace=bookmarks
+keyring url: https://hg.example.com/repo/
+cached auth data found
+ url: https://hg.example.com/repo/, user: bob, password: ***
+
diff --git a/tests/test-hgkeyring.t b/tests/test-hgkeyring.t
new file mode 100644
--- /dev/null
+++ b/tests/test-hgkeyring.t
@@ -0,0 +1,79 @@
+ $ "$TESTDIR/hghave" serve || exit 80
+ $ "$TESTDIR/hghave" keyring || exit 80
+
+ $ hg init test
+ $ cd test
+ $ echo foo>foo
+ $ mkdir foo.d foo.d/bAr.hg.d foo.d/baR.d.hg
+ $ echo foo>foo.d/foo
+ $ echo bar>foo.d/bAr.hg.d/BaR
+ $ echo bar>foo.d/baR.d.hg/bAR
+ $ hg commit -A -m 1
+ adding foo
+ adding foo.d/bAr.hg.d/BaR
+ adding foo.d/baR.d.hg/bAR
+ adding foo.d/foo
+ $ hg phase -fpr 0:tip
+
+test http authentication with hgkeyring extension
+
+ $ cat << EOT > userpass.py
+ > import base64
+ > from mercurial.hgweb import common
+ > def perform_authentication(hgweb, req, op):
+ > auth = req.env.get('HTTP_AUTHORIZATION')
+ > if not auth:
+ > raise common.ErrorResponse(common.HTTP_UNAUTHORIZED, 'who',
+ > [('WWW-Authenticate', 'Basic Realm="mercurial"')])
+ > authinfo = base64.b64decode(auth.split()[1]).split(':', 1)
+ > if authinfo != ['user', 'pass']:
+ > raise common.ErrorResponse(common.HTTP_FORBIDDEN, 'no')
+ > def extsetup():
+ > common.permhooks.insert(0, perform_authentication)
+ > EOT
+ $ hg --config extensions.x=userpass.py serve -p $HGPORT -d --pid-file=pid \
+ > --config server.preferuncompressed=True
+ $ cat pid >> $DAEMON_PIDS
+
+ $ echo '[extensions]' >> .hg/hgrc
+ $ echo 'hgkeyring=' >> .hg/hgrc
+ $ hg id http://localhost:$HGPORT/
+ abort: hgkeyring: http authorization required but program used in non-interactive mode
+ [255]
+ $ hg id http://user@localhost:$HGPORT/
+ abort: hgkeyring: http authorization required but program used in non-interactive mode
+ [255]
+ $ hg id http://user:pass@localhost:$HGPORT/
+ 8b6053c928fe
+ $ echo '[auth]' >> .hg/hgrc
+ $ echo 'l.schemes=http' >> .hg/hgrc
+ $ echo 'l.prefix=*' >> .hg/hgrc
+ $ echo 'l.username=user' >> .hg/hgrc
+ $ echo 'l.password=pass' >> .hg/hgrc
+ $ hg id http://localhost:$HGPORT/
+ 8b6053c928fe
+ $ hg id http://user@localhost:$HGPORT/
+ abort: hgkeyring: username user for http://localhost:$HGPORT/ specified both in url/paths and in auth - please, leave only one of those
+ [255]
+ $ hg id http://user:pass@localhost:$HGPORT/
+ 8b6053c928fe
+ $ hg id http://user2@localhost:$HGPORT/
+ abort: hgkeyring: http authorization required but program used in non-interactive mode
+ [255]
+ $ hg clone http://user:pass@localhost:$HGPORT/ dest
+ streaming all changes
+ 6 files to transfer, 606 bytes of data
+ transferred 606 bytes in * seconds (* KB/sec) (glob)
+ updating to branch default
+ 4 files updated, 0 files merged, 0 files removed, 0 files unresolved
+ $ cd dest
+ $ cp ../.hg/hgrc .hg
+ $ hg pull http://localhost:$HGPORT/
+ pulling from http://localhost:$HGPORT/
+ searching for changes
+ no changes found
+ $ hg push http://localhost:$HGPORT/
+ pushing to http://localhost:$HGPORT/
+ searching for changes
+ no changes found
+ [1]
More information about the Mercurial-devel
mailing list