Chris@427
|
1 # -*- coding: utf-8 -*-
|
Chris@427
|
2 #
|
Chris@427
|
3 # EasyMercurial
|
Chris@427
|
4 #
|
Chris@427
|
5 # Based on hgExplorer by Jari Korhonen
|
Chris@427
|
6 # Copyright (c) 2010 Jari Korhonen
|
Chris@453
|
7 # Copyright (c) 2010-2011 Chris Cannam
|
Chris@453
|
8 # Copyright (c) 2010-2011 Queen Mary, University of London
|
Chris@427
|
9 #
|
Chris@427
|
10 # This program is free software; you can redistribute it and/or
|
Chris@427
|
11 # modify it under the terms of the GNU General Public License as
|
Chris@427
|
12 # published by the Free Software Foundation; either version 2 of the
|
Chris@427
|
13 # License, or (at your option) any later version. See the file
|
Chris@427
|
14 # COPYING included with this distribution for more information.
|
Chris@427
|
15
|
Chris@449
|
16 import sys, os, stat, urllib, urllib2, urlparse, platform, hashlib
|
Chris@427
|
17
|
Chris@438
|
18 from mercurial.i18n import _
|
Chris@433
|
19 from mercurial import ui, util, error
|
Chris@427
|
20 try:
|
Chris@427
|
21 from mercurial.url import passwordmgr
|
Chris@427
|
22 except:
|
Chris@427
|
23 from mercurial.httprepo import passwordmgr
|
Chris@427
|
24
|
Chris@427
|
25 # The value assigned here may be modified during installation, by
|
Chris@427
|
26 # replacing its default value with another one. We can't compare
|
Chris@427
|
27 # against its default value, because then the comparison text would
|
Chris@427
|
28 # get modified as well. So, compare using prefix only.
|
Chris@427
|
29 #
|
Chris@427
|
30 easyhg_import_path = 'NO_EASYHG_IMPORT_PATH'
|
Chris@427
|
31 if not easyhg_import_path.startswith('NO_'):
|
Chris@427
|
32 # We have an installation path: append it twice, once with
|
Chris@427
|
33 # the Python version suffixed
|
Chris@440
|
34 version_suffix = 'Py%d.%d' % (sys.version_info[0], sys.version_info[1])
|
Chris@427
|
35 sys.path.append(easyhg_import_path + "/" + version_suffix)
|
Chris@427
|
36 sys.path.append(easyhg_import_path)
|
Chris@427
|
37
|
Chris@427
|
38 # Try to load the PyQt4 module that we need. If this fails, we should
|
Chris@427
|
39 # bail out later (in uisetup), because if we bail out now, Mercurial
|
Chris@427
|
40 # will just continue without us and report success. The invoking
|
Chris@427
|
41 # application needs to be able to discover whether the module load
|
Chris@427
|
42 # succeeded or not, so we need to ensure that Mercurial itself returns
|
Chris@427
|
43 # failure if it didn't.
|
Chris@427
|
44 #
|
Chris@427
|
45 easyhg_pyqt_ok = True
|
Chris@427
|
46 try:
|
Chris@457
|
47 from PyQt4 import Qt, QtCore, QtGui
|
Chris@427
|
48 except ImportError:
|
Chris@427
|
49 easyhg_pyqt_ok = False
|
Chris@427
|
50 easyhg_qtapp = None
|
Chris@427
|
51
|
Chris@440
|
52 # These imports are optional, we just can't use the authfile (i.e.
|
Chris@438
|
53 # "remember this password") feature without them
|
Chris@438
|
54 #
|
Chris@437
|
55 easyhg_authfile_imports_ok = True
|
Chris@437
|
56 try:
|
Chris@437
|
57 from Crypto.Cipher import AES
|
Chris@437
|
58 import ConfigParser # Mercurial version won't write files
|
Chris@437
|
59 import base64
|
Chris@437
|
60 except ImportError:
|
Chris@445
|
61 print "EasyHg: Failed to import required modules for authfile support"
|
Chris@437
|
62 easyhg_authfile_imports_ok = False
|
Chris@433
|
63
|
Chris@431
|
64
|
Chris@452
|
65 class EasyHgAuthStore(object):
|
Chris@431
|
66
|
Chris@452
|
67 def __init__(self, ui, url, user, passwd):
|
Chris@452
|
68
|
Chris@452
|
69 self.ui = ui
|
Chris@452
|
70 self.remote_url = url
|
Chris@452
|
71
|
Chris@452
|
72 self.user = user
|
Chris@452
|
73 self.passwd = passwd
|
Chris@452
|
74
|
Chris@452
|
75 self.auth_key = self.ui.config('easyhg', 'authkey')
|
Chris@452
|
76 self.auth_file = self.ui.config('easyhg', 'authfile')
|
Chris@452
|
77
|
Chris@452
|
78 self.use_auth_file = (easyhg_authfile_imports_ok and
|
Chris@452
|
79 self.auth_key and self.auth_file)
|
Chris@452
|
80
|
Chris@458
|
81 self.auth_config = None
|
Chris@458
|
82 self.auth_cipher = None
|
Chris@458
|
83 self.remember = False
|
Chris@458
|
84
|
Chris@452
|
85 if self.use_auth_file:
|
Chris@452
|
86 self.auth_cipher = AES.new(self.auth_key, AES.MODE_CBC)
|
Chris@452
|
87 self.auth_file = os.path.expanduser(self.auth_file)
|
Chris@452
|
88 self.load_auth_data()
|
Chris@452
|
89
|
Chris@452
|
90 def save(self):
|
Chris@452
|
91 if self.use_auth_file:
|
Chris@452
|
92 self.save_auth_data()
|
Chris@452
|
93
|
Chris@452
|
94 def encrypt(self, text):
|
Chris@452
|
95 iv = os.urandom(12)
|
Chris@452
|
96 text = '%s.%d.%s.easyhg' % (base64.b64encode(iv), len(text), text)
|
Chris@452
|
97 text += (16 - (len(text) % 16)) * ' '
|
Chris@452
|
98 ctext = base64.b64encode(self.auth_cipher.encrypt(text))
|
Chris@452
|
99 return ctext
|
Chris@452
|
100
|
Chris@452
|
101 def decrypt(self, ctext):
|
Chris@448
|
102 try:
|
Chris@456
|
103 text = self.auth_cipher.decrypt(base64.b64decode(ctext))
|
Chris@456
|
104 (iv, d, text) = text.partition('.')
|
Chris@456
|
105 (tlen, d, text) = text.partition('.')
|
Chris@452
|
106 return text[0:int(tlen)]
|
Chris@448
|
107 except:
|
Chris@452
|
108 self.ui.write("failed to decrypt/convert cached data!")
|
Chris@452
|
109 return ''
|
Chris@452
|
110
|
Chris@452
|
111 def argless_url(self):
|
Chris@452
|
112 parsed = urlparse.urlparse(self.remote_url)
|
Chris@452
|
113 return "%s://%s%s" % (parsed.scheme, parsed.netloc, parsed.path)
|
Chris@452
|
114
|
Chris@452
|
115 def pathless_url(self):
|
Chris@452
|
116 parsed = urlparse.urlparse(self.remote_url)
|
Chris@452
|
117 return "%s://%s" % (parsed.scheme, parsed.netloc)
|
Chris@452
|
118
|
Chris@452
|
119 def load_config(self):
|
Chris@453
|
120 if not self.auth_config:
|
Chris@453
|
121 self.auth_config = ConfigParser.RawConfigParser()
|
Chris@452
|
122 fp = None
|
Chris@452
|
123 try:
|
Chris@452
|
124 fp = open(self.auth_file)
|
Chris@452
|
125 except:
|
Chris@452
|
126 self.ui.write("unable to read authfile %s, ignoring\n" % self.auth_file)
|
Chris@452
|
127 return
|
Chris@452
|
128 self.auth_config.readfp(fp)
|
Chris@452
|
129 fp.close()
|
Chris@452
|
130
|
Chris@452
|
131 def save_config(self):
|
Chris@452
|
132 ofp = None
|
Chris@452
|
133 try:
|
Chris@452
|
134 ofp = open(self.auth_file, 'w')
|
Chris@452
|
135 except:
|
Chris@452
|
136 self.ui.write("failed to open authfile %s for writing\n" % self.auth_file)
|
Chris@448
|
137 raise
|
Chris@452
|
138 if platform.system() != 'Windows':
|
Chris@452
|
139 try:
|
Chris@452
|
140 os.fchmod(ofp.fileno(), stat.S_IRUSR | stat.S_IWUSR)
|
Chris@452
|
141 except:
|
Chris@452
|
142 ofp.close()
|
Chris@452
|
143 self.ui.write("failed to set permissions on authfile %s\n" % self.auth_file)
|
Chris@452
|
144 raise
|
Chris@452
|
145 self.auth_config.write(ofp)
|
Chris@452
|
146 ofp.close()
|
Chris@434
|
147
|
Chris@452
|
148 def get_from_config(self, sect, key):
|
Chris@452
|
149 data = None
|
Chris@452
|
150 try:
|
Chris@452
|
151 data = self.auth_config.get(sect, key)
|
Chris@452
|
152 except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
Chris@452
|
153 pass
|
Chris@452
|
154 return data
|
Chris@436
|
155
|
Chris@452
|
156 def get_boolean_from_config(self, sect, key, deflt):
|
Chris@452
|
157 data = deflt
|
Chris@452
|
158 try:
|
Chris@452
|
159 data = self.auth_config.getboolean(sect, key)
|
Chris@452
|
160 except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
|
Chris@452
|
161 pass
|
Chris@452
|
162 return data
|
Chris@436
|
163
|
Chris@452
|
164 def set_to_config(self, sect, key, data):
|
Chris@452
|
165 if not self.auth_config.has_section(sect):
|
Chris@452
|
166 self.auth_config.add_section(sect)
|
Chris@452
|
167 self.auth_config.set(sect, key, data)
|
Chris@436
|
168
|
Chris@452
|
169 def remote_key(self, url, user):
|
Chris@452
|
170 # generate a "safe-for-config-file" key representing uri+user
|
Chris@459
|
171 # self.ui.write('generating remote_key for url %s and user %s\n' % (url, user))
|
Chris@452
|
172 s = '%s@@%s' % (url, user)
|
Chris@452
|
173 h = hashlib.sha1()
|
Chris@452
|
174 h.update(self.auth_key)
|
Chris@452
|
175 h.update(s)
|
Chris@452
|
176 hx = h.hexdigest()
|
Chris@452
|
177 return hx
|
Chris@452
|
178
|
Chris@452
|
179 def remote_user_key(self):
|
Chris@453
|
180 return self.remote_key(self.pathless_url(), '')
|
Chris@452
|
181
|
Chris@452
|
182 def remote_passwd_key(self):
|
Chris@452
|
183 return self.remote_key(self.pathless_url(), self.user)
|
Chris@452
|
184
|
Chris@452
|
185 def load_auth_data(self):
|
Chris@452
|
186
|
Chris@452
|
187 self.load_config()
|
Chris@452
|
188 if not self.auth_config: return
|
Chris@452
|
189
|
Chris@452
|
190 self.remember = self.get_boolean_from_config(
|
Chris@452
|
191 'preferences', 'remember', False)
|
Chris@452
|
192
|
Chris@452
|
193 if not self.user:
|
Chris@452
|
194 d = self.get_from_config('user', self.remote_user_key())
|
Chris@452
|
195 if d:
|
Chris@452
|
196 self.user = self.decrypt(d)
|
Chris@452
|
197
|
Chris@452
|
198 if self.user:
|
Chris@452
|
199 d = self.get_from_config('auth', self.remote_passwd_key())
|
Chris@452
|
200 if d:
|
Chris@452
|
201 self.passwd = self.decrypt(d)
|
Chris@452
|
202
|
Chris@452
|
203 def save_auth_data(self):
|
Chris@452
|
204
|
Chris@453
|
205 self.load_config()
|
Chris@452
|
206 if not self.auth_config: return
|
Chris@453
|
207
|
Chris@452
|
208 self.set_to_config('preferences', 'remember', self.remember)
|
Chris@452
|
209
|
Chris@459
|
210 # self.ui.write('aiming to store details for user %s\n' % self.user)
|
Chris@452
|
211
|
Chris@452
|
212 if self.remember and self.user:
|
Chris@452
|
213 d = self.encrypt(self.user)
|
Chris@452
|
214 self.set_to_config('user', self.remote_user_key(), d)
|
Chris@452
|
215 else:
|
Chris@452
|
216 self.set_to_config('user', self.remote_user_key(), '')
|
Chris@452
|
217
|
Chris@452
|
218 if self.remember and self.user and self.passwd:
|
Chris@452
|
219 d = self.encrypt(self.passwd)
|
Chris@452
|
220 self.set_to_config('auth', self.remote_passwd_key(), d)
|
Chris@452
|
221 elif self.user:
|
Chris@452
|
222 self.set_to_config('auth', self.remote_passwd_key(), '')
|
Chris@452
|
223
|
Chris@452
|
224 self.save_config()
|
Chris@452
|
225
|
Chris@452
|
226 class EasyHgAuthDialog(object):
|
Chris@452
|
227
|
Chris@452
|
228 auth_store = None
|
Chris@452
|
229
|
Chris@452
|
230 def __init__(self, ui, url, user, passwd):
|
Chris@452
|
231 self.auth_store = EasyHgAuthStore(ui, url, user, passwd)
|
Chris@452
|
232
|
Chris@459
|
233 def ask(self, force_dialog):
|
Chris@459
|
234
|
Chris@460
|
235 if self.auth_store.user and self.auth_store.passwd and self.auth_store.remember:
|
Chris@459
|
236 if not force_dialog:
|
Chris@459
|
237 return (self.auth_store.user, self.auth_store.passwd)
|
Chris@459
|
238
|
Chris@452
|
239 dialog = QtGui.QDialog()
|
Chris@452
|
240 layout = QtGui.QGridLayout()
|
Chris@452
|
241 dialog.setLayout(layout)
|
Chris@452
|
242
|
Chris@452
|
243 layout.addWidget(QtGui.QLabel(_('<h3>Login required</h3><p>Please provide your login details for the repository at<br><code>%s</code>:') % self.auth_store.argless_url()), 0, 0, 1, 2)
|
Chris@452
|
244
|
Chris@452
|
245 user_field = QtGui.QLineEdit()
|
Chris@452
|
246 if self.auth_store.user: user_field.setText(self.auth_store.user)
|
Chris@452
|
247 layout.addWidget(QtGui.QLabel(_('User:')), 1, 0)
|
Chris@452
|
248 layout.addWidget(user_field, 1, 1)
|
Chris@452
|
249
|
Chris@452
|
250 passwd_field = QtGui.QLineEdit()
|
Chris@452
|
251 passwd_field.setEchoMode(QtGui.QLineEdit.Password)
|
Chris@452
|
252 if self.auth_store.passwd: passwd_field.setText(self.auth_store.passwd)
|
Chris@452
|
253 layout.addWidget(QtGui.QLabel(_('Password:')), 2, 0)
|
Chris@452
|
254 layout.addWidget(passwd_field, 2, 1)
|
Chris@452
|
255
|
Chris@452
|
256 user_field.connect(user_field, Qt.SIGNAL("textChanged(QString)"),
|
Chris@452
|
257 passwd_field, Qt.SLOT("clear()"))
|
Chris@452
|
258
|
Chris@452
|
259 remember_field = None
|
Chris@452
|
260 if self.auth_store.use_auth_file:
|
Chris@452
|
261 remember_field = QtGui.QCheckBox()
|
Chris@452
|
262 remember_field.setChecked(self.auth_store.remember)
|
Chris@452
|
263 remember_field.setText(_('Remember these details while EasyMercurial is running'))
|
Chris@452
|
264 layout.addWidget(remember_field, 3, 1)
|
Chris@457
|
265 warning_field = QtGui.QLabel()
|
Chris@461
|
266 warning_field.setText(_('<qt><i><small>Do not use this option if anyone else has access to your computer!</small></i><br></qt>'))
|
Chris@457
|
267 warning_field.hide()
|
Chris@457
|
268 remember_field.connect(remember_field, Qt.SIGNAL("clicked()"),
|
Chris@457
|
269 warning_field, Qt.SLOT("show()"))
|
Chris@457
|
270 layout.addWidget(warning_field, 4, 1, QtCore.Qt.AlignRight)
|
Chris@452
|
271
|
Chris@452
|
272 bb = QtGui.QDialogButtonBox()
|
Chris@452
|
273 ok = bb.addButton(bb.Ok)
|
Chris@452
|
274 cancel = bb.addButton(bb.Cancel)
|
Chris@452
|
275 cancel.setDefault(False)
|
Chris@452
|
276 cancel.setAutoDefault(False)
|
Chris@452
|
277 ok.setDefault(True)
|
Chris@452
|
278 bb.connect(ok, Qt.SIGNAL("clicked()"), dialog, Qt.SLOT("accept()"))
|
Chris@452
|
279 bb.connect(cancel, Qt.SIGNAL("clicked()"), dialog, Qt.SLOT("reject()"))
|
Chris@457
|
280 layout.addWidget(bb, 5, 0, 1, 2)
|
Chris@452
|
281
|
Chris@452
|
282 dialog.setWindowTitle(_('EasyMercurial: Login'))
|
Chris@452
|
283 dialog.show()
|
Chris@452
|
284
|
Chris@452
|
285 if not self.auth_store.user:
|
Chris@452
|
286 user_field.setFocus(True)
|
Chris@452
|
287 elif not self.auth_store.passwd:
|
Chris@452
|
288 passwd_field.setFocus(True)
|
Chris@452
|
289 else:
|
Chris@452
|
290 ok.setFocus(True)
|
Chris@452
|
291
|
Chris@452
|
292 dialog.raise_()
|
Chris@452
|
293 ok = dialog.exec_()
|
Chris@452
|
294 if not ok:
|
Chris@452
|
295 raise util.Abort(_('password entry cancelled'))
|
Chris@452
|
296
|
Chris@452
|
297 self.auth_store.user = user_field.text()
|
Chris@452
|
298 self.auth_store.passwd = passwd_field.text()
|
Chris@452
|
299
|
Chris@452
|
300 if remember_field:
|
Chris@452
|
301 self.auth_store.remember = remember_field.isChecked()
|
Chris@452
|
302
|
Chris@452
|
303 self.auth_store.save()
|
Chris@452
|
304
|
Chris@452
|
305 return (self.auth_store.user, self.auth_store.passwd)
|
Chris@438
|
306
|
Chris@440
|
307
|
Chris@440
|
308 def uisetup(ui):
|
Chris@440
|
309 if not easyhg_pyqt_ok:
|
Chris@440
|
310 raise util.Abort(_('Failed to load PyQt4 module required by easyhg.py'))
|
Chris@440
|
311 global easyhg_qtapp
|
Chris@440
|
312 easyhg_qtapp = QtGui.QApplication([])
|
Chris@440
|
313
|
Chris@440
|
314 def monkeypatch_method(cls):
|
Chris@440
|
315 def decorator(func):
|
Chris@440
|
316 setattr(cls, func.__name__, func)
|
Chris@440
|
317 return func
|
Chris@440
|
318 return decorator
|
Chris@440
|
319
|
Chris@440
|
320 orig_find = passwordmgr.find_user_password
|
Chris@440
|
321
|
Chris@427
|
322 @monkeypatch_method(passwordmgr)
|
Chris@427
|
323 def find_user_password(self, realm, authuri):
|
Chris@427
|
324
|
Chris@459
|
325 if not hasattr(self, '__easyhg_last'):
|
Chris@459
|
326 self.__easyhg_last = None
|
Chris@459
|
327
|
Chris@427
|
328 if not self.ui.interactive():
|
Chris@427
|
329 return orig_find(self, realm, authuri)
|
Chris@427
|
330 if not easyhg_pyqt_ok:
|
Chris@427
|
331 return orig_find(self, realm, authuri)
|
Chris@427
|
332
|
Chris@427
|
333 authinfo = urllib2.HTTPPasswordMgrWithDefaultRealm.find_user_password(
|
Chris@427
|
334 self, realm, authuri)
|
Chris@427
|
335 user, passwd = authinfo
|
Chris@427
|
336
|
Chris@427
|
337 if user and passwd:
|
Chris@459
|
338 # self.ui.write("note: user and passwd both provided\n")
|
Chris@427
|
339 return orig_find(self, realm, authuri)
|
Chris@427
|
340
|
Chris@459
|
341 # self.ui.write("want username and/or password for %s\n" % authuri)
|
Chris@459
|
342
|
Chris@459
|
343 force_dialog = False
|
Chris@459
|
344
|
Chris@459
|
345 # if self.__easyhg_last:
|
Chris@459
|
346 # self.ui.write("last = realm '%s' authuri '%s'\n" % self.__easyhg_last)
|
Chris@459
|
347 # self.ui.write("me = realm '%s' authuri '%s'\n" % (realm, authuri))
|
Chris@459
|
348
|
Chris@459
|
349 if (realm, authuri) == self.__easyhg_last:
|
Chris@459
|
350 # If we are called again just after identical previous
|
Chris@459
|
351 # request, then the previously returned auth must have been
|
Chris@459
|
352 # wrong. So we note this to force password prompt (and avoid
|
Chris@459
|
353 # reusing bad password indefinitely). Thanks to
|
Chris@459
|
354 # mercurial_keyring (Marcin Kasperski) for this logic
|
Chris@459
|
355 # self.ui.write("same again!\n")
|
Chris@459
|
356 force_dialog = True
|
Chris@427
|
357
|
Chris@452
|
358 dialog = EasyHgAuthDialog(self.ui, authuri, user, passwd)
|
Chris@427
|
359
|
Chris@459
|
360 (user, passwd) = dialog.ask(force_dialog)
|
Chris@433
|
361
|
Chris@459
|
362 # self.add_password(realm, authuri, user, passwd)
|
Chris@459
|
363 self.__easyhg_last = (realm, authuri)
|
Chris@459
|
364 # self.ui.write("done = realm '%s' authuri '%s'\n" % self.__easyhg_last)
|
Chris@427
|
365 return (user, passwd)
|
Chris@427
|
366
|
Chris@427
|
367
|