1# hook.py - hook support for mercurial 2# 3# Copyright 2007 Olivia Mackall <olivia@selenic.com> 4# 5# This software may be used and distributed according to the terms of the 6# GNU General Public License version 2 or any later version. 7 8from __future__ import absolute_import 9 10import contextlib 11import errno 12import os 13import sys 14 15from .i18n import _ 16from .pycompat import getattr 17from . import ( 18 demandimport, 19 encoding, 20 error, 21 extensions, 22 pycompat, 23 util, 24) 25from .utils import ( 26 procutil, 27 resourceutil, 28 stringutil, 29) 30 31 32def pythonhook(ui, repo, htype, hname, funcname, args, throw): 33 """call python hook. hook is callable object, looked up as 34 name in python module. if callable returns "true", hook 35 fails, else passes. if hook raises exception, treated as 36 hook failure. exception propagates if throw is "true". 37 38 reason for "true" meaning "hook failed" is so that 39 unmodified commands (e.g. mercurial.commands.update) can 40 be run as hooks without wrappers to convert return values.""" 41 42 if callable(funcname): 43 obj = funcname 44 funcname = pycompat.sysbytes(obj.__module__ + "." + obj.__name__) 45 else: 46 d = funcname.rfind(b'.') 47 if d == -1: 48 raise error.HookLoadError( 49 _(b'%s hook is invalid: "%s" not in a module') 50 % (hname, funcname) 51 ) 52 modname = funcname[:d] 53 oldpaths = sys.path 54 if resourceutil.mainfrozen(): 55 # binary installs require sys.path manipulation 56 modpath, modfile = os.path.split(modname) 57 if modpath and modfile: 58 sys.path = sys.path[:] + [modpath] 59 modname = modfile 60 with demandimport.deactivated(): 61 try: 62 obj = __import__(pycompat.sysstr(modname)) 63 except (ImportError, SyntaxError): 64 e1 = sys.exc_info() 65 try: 66 # extensions are loaded with hgext_ prefix 67 obj = __import__("hgext_%s" % pycompat.sysstr(modname)) 68 except (ImportError, SyntaxError): 69 e2 = sys.exc_info() 70 if ui.tracebackflag: 71 ui.warn( 72 _( 73 b'exception from first failed import ' 74 b'attempt:\n' 75 ) 76 ) 77 ui.traceback(e1) 78 if ui.tracebackflag: 79 ui.warn( 80 _( 81 b'exception from second failed import ' 82 b'attempt:\n' 83 ) 84 ) 85 ui.traceback(e2) 86 87 if not ui.tracebackflag: 88 tracebackhint = _( 89 b'run with --traceback for stack trace' 90 ) 91 else: 92 tracebackhint = None 93 raise error.HookLoadError( 94 _(b'%s hook is invalid: import of "%s" failed') 95 % (hname, modname), 96 hint=tracebackhint, 97 ) 98 sys.path = oldpaths 99 try: 100 for p in funcname.split(b'.')[1:]: 101 obj = getattr(obj, p) 102 except AttributeError: 103 raise error.HookLoadError( 104 _(b'%s hook is invalid: "%s" is not defined') 105 % (hname, funcname) 106 ) 107 if not callable(obj): 108 raise error.HookLoadError( 109 _(b'%s hook is invalid: "%s" is not callable') 110 % (hname, funcname) 111 ) 112 113 ui.note(_(b"calling hook %s: %s\n") % (hname, funcname)) 114 starttime = util.timer() 115 116 try: 117 r = obj(ui=ui, repo=repo, hooktype=htype, **pycompat.strkwargs(args)) 118 except Exception as exc: 119 if isinstance(exc, error.Abort): 120 ui.warn(_(b'error: %s hook failed: %s\n') % (hname, exc.args[0])) 121 else: 122 ui.warn( 123 _(b'error: %s hook raised an exception: %s\n') 124 % (hname, stringutil.forcebytestr(exc)) 125 ) 126 if throw: 127 raise 128 if not ui.tracebackflag: 129 ui.warn(_(b'(run with --traceback for stack trace)\n')) 130 ui.traceback() 131 return True, True 132 finally: 133 duration = util.timer() - starttime 134 ui.log( 135 b'pythonhook', 136 b'pythonhook-%s: %s finished in %0.2f seconds\n', 137 htype, 138 funcname, 139 duration, 140 ) 141 if r: 142 if throw: 143 raise error.HookAbort(_(b'%s hook failed') % hname) 144 ui.warn(_(b'warning: %s hook failed\n') % hname) 145 return r, False 146 147 148def _exthook(ui, repo, htype, name, cmd, args, throw): 149 starttime = util.timer() 150 env = {} 151 152 # make in-memory changes visible to external process 153 if repo is not None: 154 tr = repo.currenttransaction() 155 repo.dirstate.write(tr) 156 if tr and tr.writepending(): 157 env[b'HG_PENDING'] = repo.root 158 env[b'HG_HOOKTYPE'] = htype 159 env[b'HG_HOOKNAME'] = name 160 161 if ui.config(b'hooks', b'%s:run-with-plain' % name) == b'auto': 162 plain = ui.plain() 163 else: 164 plain = ui.configbool(b'hooks', b'%s:run-with-plain' % name) 165 if plain: 166 env[b'HGPLAIN'] = b'1' 167 else: 168 env[b'HGPLAIN'] = b'' 169 170 for k, v in pycompat.iteritems(args): 171 # transaction changes can accumulate MBs of data, so skip it 172 # for external hooks 173 if k == b'changes': 174 continue 175 if callable(v): 176 v = v() 177 if isinstance(v, (dict, list)): 178 v = stringutil.pprint(v) 179 env[b'HG_' + k.upper()] = v 180 181 if ui.configbool(b'hooks', b'tonative.%s' % name, False): 182 oldcmd = cmd 183 cmd = procutil.shelltonative(cmd, env) 184 if cmd != oldcmd: 185 ui.note(_(b'converting hook "%s" to native\n') % name) 186 187 ui.note(_(b"running hook %s: %s\n") % (name, cmd)) 188 189 if repo: 190 cwd = repo.root 191 else: 192 cwd = encoding.getcwd() 193 r = ui.system(cmd, environ=env, cwd=cwd, blockedtag=b'exthook-%s' % (name,)) 194 195 duration = util.timer() - starttime 196 ui.log( 197 b'exthook', 198 b'exthook-%s: %s finished in %0.2f seconds\n', 199 name, 200 cmd, 201 duration, 202 ) 203 if r: 204 desc = procutil.explainexit(r) 205 if throw: 206 raise error.HookAbort(_(b'%s hook %s') % (name, desc)) 207 ui.warn(_(b'warning: %s hook %s\n') % (name, desc)) 208 return r 209 210 211# represent an untrusted hook command 212_fromuntrusted = object() 213 214 215def _allhooks(ui): 216 """return a list of (hook-id, cmd) pairs sorted by priority""" 217 hooks = _hookitems(ui) 218 # Be careful in this section, propagating the real commands from untrusted 219 # sources would create a security vulnerability, make sure anything altered 220 # in that section uses "_fromuntrusted" as its command. 221 untrustedhooks = _hookitems(ui, _untrusted=True) 222 for name, value in untrustedhooks.items(): 223 trustedvalue = hooks.get(name, ((), (), name, _fromuntrusted)) 224 if value != trustedvalue: 225 (lp, lo, lk, lv) = trustedvalue 226 hooks[name] = (lp, lo, lk, _fromuntrusted) 227 # (end of the security sensitive section) 228 return [(k, v) for p, o, k, v in sorted(hooks.values())] 229 230 231def _hookitems(ui, _untrusted=False): 232 """return all hooks items ready to be sorted""" 233 hooks = {} 234 for name, cmd in ui.configitems(b'hooks', untrusted=_untrusted): 235 if ( 236 name.startswith(b'priority.') 237 or name.startswith(b'tonative.') 238 or b':' in name 239 ): 240 continue 241 242 priority = ui.configint(b'hooks', b'priority.%s' % name, 0) 243 hooks[name] = ((-priority,), (len(hooks),), name, cmd) 244 return hooks 245 246 247_redirect = False 248 249 250def redirect(state): 251 global _redirect 252 _redirect = state 253 254 255def hashook(ui, htype): 256 """return True if a hook is configured for 'htype'""" 257 if not ui.callhooks: 258 return False 259 for hname, cmd in _allhooks(ui): 260 if hname.split(b'.')[0] == htype and cmd: 261 return True 262 return False 263 264 265def hook(ui, repo, htype, throw=False, **args): 266 if not ui.callhooks: 267 return False 268 269 hooks = [] 270 for hname, cmd in _allhooks(ui): 271 if hname.split(b'.')[0] == htype and cmd: 272 hooks.append((hname, cmd)) 273 274 res = runhooks(ui, repo, htype, hooks, throw=throw, **args) 275 r = False 276 for hname, cmd in hooks: 277 r = res[hname][0] or r 278 return r 279 280 281@contextlib.contextmanager 282def redirect_stdio(): 283 """Redirects stdout to stderr, if possible.""" 284 285 oldstdout = -1 286 try: 287 if _redirect: 288 try: 289 stdoutno = procutil.stdout.fileno() 290 stderrno = procutil.stderr.fileno() 291 # temporarily redirect stdout to stderr, if possible 292 if stdoutno >= 0 and stderrno >= 0: 293 procutil.stdout.flush() 294 oldstdout = os.dup(stdoutno) 295 os.dup2(stderrno, stdoutno) 296 except (OSError, AttributeError): 297 # files seem to be bogus, give up on redirecting (WSGI, etc) 298 pass 299 300 yield 301 302 finally: 303 # The stderr is fully buffered on Windows when connected to a pipe. 304 # A forcible flush is required to make small stderr data in the 305 # remote side available to the client immediately. 306 try: 307 procutil.stderr.flush() 308 except IOError as err: 309 if err.errno not in (errno.EPIPE, errno.EIO, errno.EBADF): 310 raise error.StdioError(err) 311 312 if _redirect and oldstdout >= 0: 313 try: 314 procutil.stdout.flush() # write hook output to stderr fd 315 except IOError as err: 316 if err.errno not in (errno.EPIPE, errno.EIO, errno.EBADF): 317 raise error.StdioError(err) 318 os.dup2(oldstdout, stdoutno) 319 os.close(oldstdout) 320 321 322def runhooks(ui, repo, htype, hooks, throw=False, **args): 323 args = pycompat.byteskwargs(args) 324 res = {} 325 326 with redirect_stdio(): 327 for hname, cmd in hooks: 328 if cmd is _fromuntrusted: 329 if throw: 330 raise error.HookAbort( 331 _(b'untrusted hook %s not executed') % hname, 332 hint=_(b"see 'hg help config.trusted'"), 333 ) 334 ui.warn(_(b'warning: untrusted hook %s not executed\n') % hname) 335 r = 1 336 raised = False 337 elif callable(cmd): 338 r, raised = pythonhook(ui, repo, htype, hname, cmd, args, throw) 339 elif cmd.startswith(b'python:'): 340 if cmd.count(b':') >= 2: 341 path, cmd = cmd[7:].rsplit(b':', 1) 342 path = util.expandpath(path) 343 if repo: 344 path = os.path.join(repo.root, path) 345 try: 346 mod = extensions.loadpath(path, b'hghook.%s' % hname) 347 except Exception: 348 ui.write(_(b"loading %s hook failed:\n") % hname) 349 raise 350 hookfn = getattr(mod, cmd) 351 else: 352 hookfn = cmd[7:].strip() 353 r, raised = pythonhook( 354 ui, repo, htype, hname, hookfn, args, throw 355 ) 356 else: 357 r = _exthook(ui, repo, htype, hname, cmd, args, throw) 358 raised = False 359 360 res[hname] = r, raised 361 362 return res 363