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