| # Copyright (C) 2008 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| import errno |
| import json |
| import os |
| import re |
| import subprocess |
| import sys |
| import traceback |
| import urllib.parse |
| |
| from error import HookError |
| from git_refs import HEAD |
| |
| |
| class RepoHook(object): |
| """A RepoHook contains information about a script to run as a hook. |
| |
| Hooks are used to run a python script before running an upload (for instance, |
| to run presubmit checks). Eventually, we may have hooks for other actions. |
| |
| This shouldn't be confused with files in the 'repo/hooks' directory. Those |
| files are copied into each '.git/hooks' folder for each project. Repo-level |
| hooks are associated instead with repo actions. |
| |
| Hooks are always python. When a hook is run, we will load the hook into the |
| interpreter and execute its main() function. |
| |
| Combinations of hook option flags: |
| - no-verify=False, verify=False (DEFAULT): |
| If stdout is a tty, can prompt about running hooks if needed. |
| If user denies running hooks, the action is cancelled. If stdout is |
| not a tty and we would need to prompt about hooks, action is |
| cancelled. |
| - no-verify=False, verify=True: |
| Always run hooks with no prompt. |
| - no-verify=True, verify=False: |
| Never run hooks, but run action anyway (AKA bypass hooks). |
| - no-verify=True, verify=True: |
| Invalid |
| """ |
| |
| def __init__(self, |
| hook_type, |
| hooks_project, |
| repo_topdir, |
| manifest_url, |
| bypass_hooks=False, |
| allow_all_hooks=False, |
| ignore_hooks=False, |
| abort_if_user_denies=False): |
| """RepoHook constructor. |
| |
| Params: |
| hook_type: A string representing the type of hook. This is also used |
| to figure out the name of the file containing the hook. For |
| example: 'pre-upload'. |
| hooks_project: The project containing the repo hooks. |
| If you have a manifest, this is manifest.repo_hooks_project. |
| OK if this is None, which will make the hook a no-op. |
| repo_topdir: The top directory of the repo client checkout. |
| This is the one containing the .repo directory. Scripts will |
| run with CWD as this directory. |
| If you have a manifest, this is manifest.topdir. |
| manifest_url: The URL to the manifest git repo. |
| bypass_hooks: If True, then 'Do not run the hook'. |
| allow_all_hooks: If True, then 'Run the hook without prompting'. |
| ignore_hooks: If True, then 'Do not abort action if hooks fail'. |
| abort_if_user_denies: If True, we'll abort running the hook if the user |
| doesn't allow us to run the hook. |
| """ |
| self._hook_type = hook_type |
| self._hooks_project = hooks_project |
| self._repo_topdir = repo_topdir |
| self._manifest_url = manifest_url |
| self._bypass_hooks = bypass_hooks |
| self._allow_all_hooks = allow_all_hooks |
| self._ignore_hooks = ignore_hooks |
| self._abort_if_user_denies = abort_if_user_denies |
| |
| # Store the full path to the script for convenience. |
| if self._hooks_project: |
| self._script_fullpath = os.path.join(self._hooks_project.worktree, |
| self._hook_type + '.py') |
| else: |
| self._script_fullpath = None |
| |
| def _GetHash(self): |
| """Return a hash of the contents of the hooks directory. |
| |
| We'll just use git to do this. This hash has the property that if anything |
| changes in the directory we will return a different has. |
| |
| SECURITY CONSIDERATION: |
| This hash only represents the contents of files in the hook directory, not |
| any other files imported or called by hooks. Changes to imported files |
| can change the script behavior without affecting the hash. |
| |
| Returns: |
| A string representing the hash. This will always be ASCII so that it can |
| be printed to the user easily. |
| """ |
| assert self._hooks_project, "Must have hooks to calculate their hash." |
| |
| # We will use the work_git object rather than just calling GetRevisionId(). |
| # That gives us a hash of the latest checked in version of the files that |
| # the user will actually be executing. Specifically, GetRevisionId() |
| # doesn't appear to change even if a user checks out a different version |
| # of the hooks repo (via git checkout) nor if a user commits their own revs. |
| # |
| # NOTE: Local (non-committed) changes will not be factored into this hash. |
| # I think this is OK, since we're really only worried about warning the user |
| # about upstream changes. |
| return self._hooks_project.work_git.rev_parse(HEAD) |
| |
| def _GetMustVerb(self): |
| """Return 'must' if the hook is required; 'should' if not.""" |
| if self._abort_if_user_denies: |
| return 'must' |
| else: |
| return 'should' |
| |
| def _CheckForHookApproval(self): |
| """Check to see whether this hook has been approved. |
| |
| We'll accept approval of manifest URLs if they're using secure transports. |
| This way the user can say they trust the manifest hoster. For insecure |
| hosts, we fall back to checking the hash of the hooks repo. |
| |
| Note that we ask permission for each individual hook even though we use |
| the hash of all hooks when detecting changes. We'd like the user to be |
| able to approve / deny each hook individually. We only use the hash of all |
| hooks because there is no other easy way to detect changes to local imports. |
| |
| Returns: |
| True if this hook is approved to run; False otherwise. |
| |
| Raises: |
| HookError: Raised if the user doesn't approve and abort_if_user_denies |
| was passed to the consturctor. |
| """ |
| if self._ManifestUrlHasSecureScheme(): |
| return self._CheckForHookApprovalManifest() |
| else: |
| return self._CheckForHookApprovalHash() |
| |
| def _CheckForHookApprovalHelper(self, subkey, new_val, main_prompt, |
| changed_prompt): |
| """Check for approval for a particular attribute and hook. |
| |
| Args: |
| subkey: The git config key under [repo.hooks.<hook_type>] to store the |
| last approved string. |
| new_val: The new value to compare against the last approved one. |
| main_prompt: Message to display to the user to ask for approval. |
| changed_prompt: Message explaining why we're re-asking for approval. |
| |
| Returns: |
| True if this hook is approved to run; False otherwise. |
| |
| Raises: |
| HookError: Raised if the user doesn't approve and abort_if_user_denies |
| was passed to the consturctor. |
| """ |
| hooks_config = self._hooks_project.config |
| git_approval_key = 'repo.hooks.%s.%s' % (self._hook_type, subkey) |
| |
| # Get the last value that the user approved for this hook; may be None. |
| old_val = hooks_config.GetString(git_approval_key) |
| |
| if old_val is not None: |
| # User previously approved hook and asked not to be prompted again. |
| if new_val == old_val: |
| # Approval matched. We're done. |
| return True |
| else: |
| # Give the user a reason why we're prompting, since they last told |
| # us to "never ask again". |
| prompt = 'WARNING: %s\n\n' % (changed_prompt,) |
| else: |
| prompt = '' |
| |
| # Prompt the user if we're not on a tty; on a tty we'll assume "no". |
| if sys.stdout.isatty(): |
| prompt += main_prompt + ' (yes/always/NO)? ' |
| response = input(prompt).lower() |
| print() |
| |
| # User is doing a one-time approval. |
| if response in ('y', 'yes'): |
| return True |
| elif response == 'always': |
| hooks_config.SetString(git_approval_key, new_val) |
| return True |
| |
| # For anything else, we'll assume no approval. |
| if self._abort_if_user_denies: |
| raise HookError('You must allow the %s hook or use --no-verify.' % |
| self._hook_type) |
| |
| return False |
| |
| def _ManifestUrlHasSecureScheme(self): |
| """Check if the URI for the manifest is a secure transport.""" |
| secure_schemes = ('file', 'https', 'ssh', 'persistent-https', 'sso', 'rpc') |
| parse_results = urllib.parse.urlparse(self._manifest_url) |
| return parse_results.scheme in secure_schemes |
| |
| def _CheckForHookApprovalManifest(self): |
| """Check whether the user has approved this manifest host. |
| |
| Returns: |
| True if this hook is approved to run; False otherwise. |
| """ |
| return self._CheckForHookApprovalHelper( |
| 'approvedmanifest', |
| self._manifest_url, |
| 'Run hook scripts from %s' % (self._manifest_url,), |
| 'Manifest URL has changed since %s was allowed.' % (self._hook_type,)) |
| |
| def _CheckForHookApprovalHash(self): |
| """Check whether the user has approved the hooks repo. |
| |
| Returns: |
| True if this hook is approved to run; False otherwise. |
| """ |
| prompt = ('Repo %s run the script:\n' |
| ' %s\n' |
| '\n' |
| 'Do you want to allow this script to run') |
| return self._CheckForHookApprovalHelper( |
| 'approvedhash', |
| self._GetHash(), |
| prompt % (self._GetMustVerb(), self._script_fullpath), |
| 'Scripts have changed since %s was allowed.' % (self._hook_type,)) |
| |
| @staticmethod |
| def _ExtractInterpFromShebang(data): |
| """Extract the interpreter used in the shebang. |
| |
| Try to locate the interpreter the script is using (ignoring `env`). |
| |
| Args: |
| data: The file content of the script. |
| |
| Returns: |
| The basename of the main script interpreter, or None if a shebang is not |
| used or could not be parsed out. |
| """ |
| firstline = data.splitlines()[:1] |
| if not firstline: |
| return None |
| |
| # The format here can be tricky. |
| shebang = firstline[0].strip() |
| m = re.match(r'^#!\s*([^\s]+)(?:\s+([^\s]+))?', shebang) |
| if not m: |
| return None |
| |
| # If the using `env`, find the target program. |
| interp = m.group(1) |
| if os.path.basename(interp) == 'env': |
| interp = m.group(2) |
| |
| return interp |
| |
| def _ExecuteHookViaReexec(self, interp, context, **kwargs): |
| """Execute the hook script through |interp|. |
| |
| Note: Support for this feature should be dropped ~Jun 2021. |
| |
| Args: |
| interp: The Python program to run. |
| context: Basic Python context to execute the hook inside. |
| kwargs: Arbitrary arguments to pass to the hook script. |
| |
| Raises: |
| HookError: When the hooks failed for any reason. |
| """ |
| # This logic needs to be kept in sync with _ExecuteHookViaImport below. |
| script = """ |
| import json, os, sys |
| path = '''%(path)s''' |
| kwargs = json.loads('''%(kwargs)s''') |
| context = json.loads('''%(context)s''') |
| sys.path.insert(0, os.path.dirname(path)) |
| data = open(path).read() |
| exec(compile(data, path, 'exec'), context) |
| context['main'](**kwargs) |
| """ % { |
| 'path': self._script_fullpath, |
| 'kwargs': json.dumps(kwargs), |
| 'context': json.dumps(context), |
| } |
| |
| # We pass the script via stdin to avoid OS argv limits. It also makes |
| # unhandled exception tracebacks less verbose/confusing for users. |
| cmd = [interp, '-c', 'import sys; exec(sys.stdin.read())'] |
| proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) |
| proc.communicate(input=script.encode('utf-8')) |
| if proc.returncode: |
| raise HookError('Failed to run %s hook.' % (self._hook_type,)) |
| |
| def _ExecuteHookViaImport(self, data, context, **kwargs): |
| """Execute the hook code in |data| directly. |
| |
| Args: |
| data: The code of the hook to execute. |
| context: Basic Python context to execute the hook inside. |
| kwargs: Arbitrary arguments to pass to the hook script. |
| |
| Raises: |
| HookError: When the hooks failed for any reason. |
| """ |
| # Exec, storing global context in the context dict. We catch exceptions |
| # and convert to a HookError w/ just the failing traceback. |
| try: |
| exec(compile(data, self._script_fullpath, 'exec'), context) |
| except Exception: |
| raise HookError('%s\nFailed to import %s hook; see traceback above.' % |
| (traceback.format_exc(), self._hook_type)) |
| |
| # Running the script should have defined a main() function. |
| if 'main' not in context: |
| raise HookError('Missing main() in: "%s"' % self._script_fullpath) |
| |
| # Call the main function in the hook. If the hook should cause the |
| # build to fail, it will raise an Exception. We'll catch that convert |
| # to a HookError w/ just the failing traceback. |
| try: |
| context['main'](**kwargs) |
| except Exception: |
| raise HookError('%s\nFailed to run main() for %s hook; see traceback ' |
| 'above.' % (traceback.format_exc(), self._hook_type)) |
| |
| def _ExecuteHook(self, **kwargs): |
| """Actually execute the given hook. |
| |
| This will run the hook's 'main' function in our python interpreter. |
| |
| Args: |
| kwargs: Keyword arguments to pass to the hook. These are often specific |
| to the hook type. For instance, pre-upload hooks will contain |
| a project_list. |
| """ |
| # Keep sys.path and CWD stashed away so that we can always restore them |
| # upon function exit. |
| orig_path = os.getcwd() |
| orig_syspath = sys.path |
| |
| try: |
| # Always run hooks with CWD as topdir. |
| os.chdir(self._repo_topdir) |
| |
| # Put the hook dir as the first item of sys.path so hooks can do |
| # relative imports. We want to replace the repo dir as [0] so |
| # hooks can't import repo files. |
| sys.path = [os.path.dirname(self._script_fullpath)] + sys.path[1:] |
| |
| # Initial global context for the hook to run within. |
| context = {'__file__': self._script_fullpath} |
| |
| # Add 'hook_should_take_kwargs' to the arguments to be passed to main. |
| # We don't actually want hooks to define their main with this argument-- |
| # it's there to remind them that their hook should always take **kwargs. |
| # For instance, a pre-upload hook should be defined like: |
| # def main(project_list, **kwargs): |
| # |
| # This allows us to later expand the API without breaking old hooks. |
| kwargs = kwargs.copy() |
| kwargs['hook_should_take_kwargs'] = True |
| |
| # See what version of python the hook has been written against. |
| data = open(self._script_fullpath).read() |
| interp = self._ExtractInterpFromShebang(data) |
| reexec = False |
| if interp: |
| prog = os.path.basename(interp) |
| if prog.startswith('python2') and sys.version_info.major != 2: |
| reexec = True |
| elif prog.startswith('python3') and sys.version_info.major == 2: |
| reexec = True |
| |
| # Attempt to execute the hooks through the requested version of Python. |
| if reexec: |
| try: |
| self._ExecuteHookViaReexec(interp, context, **kwargs) |
| except OSError as e: |
| if e.errno == errno.ENOENT: |
| # We couldn't find the interpreter, so fallback to importing. |
| reexec = False |
| else: |
| raise |
| |
| # Run the hook by importing directly. |
| if not reexec: |
| self._ExecuteHookViaImport(data, context, **kwargs) |
| finally: |
| # Restore sys.path and CWD. |
| sys.path = orig_syspath |
| os.chdir(orig_path) |
| |
| def _CheckHook(self): |
| # Bail with a nice error if we can't find the hook. |
| if not os.path.isfile(self._script_fullpath): |
| raise HookError('Couldn\'t find repo hook: %s' % self._script_fullpath) |
| |
| def Run(self, **kwargs): |
| """Run the hook. |
| |
| If the hook doesn't exist (because there is no hooks project or because |
| this particular hook is not enabled), this is a no-op. |
| |
| Args: |
| user_allows_all_hooks: If True, we will never prompt about running the |
| hook--we'll just assume it's OK to run it. |
| kwargs: Keyword arguments to pass to the hook. These are often specific |
| to the hook type. For instance, pre-upload hooks will contain |
| a project_list. |
| |
| Returns: |
| True: On success or ignore hooks by user-request |
| False: The hook failed. The caller should respond with aborting the action. |
| Some examples in which False is returned: |
| * Finding the hook failed while it was enabled, or |
| * the user declined to run a required hook (from _CheckForHookApproval) |
| In all these cases the user did not pass the proper arguments to |
| ignore the result through the option combinations as listed in |
| AddHookOptionGroup(). |
| """ |
| # Do not do anything in case bypass_hooks is set, or |
| # no-op if there is no hooks project or if hook is disabled. |
| if (self._bypass_hooks or |
| not self._hooks_project or |
| self._hook_type not in self._hooks_project.enabled_repo_hooks): |
| return True |
| |
| passed = True |
| try: |
| self._CheckHook() |
| |
| # Make sure the user is OK with running the hook. |
| if self._allow_all_hooks or self._CheckForHookApproval(): |
| # Run the hook with the same version of python we're using. |
| self._ExecuteHook(**kwargs) |
| except SystemExit as e: |
| passed = False |
| print('ERROR: %s hooks exited with exit code: %s' % (self._hook_type, str(e)), |
| file=sys.stderr) |
| except HookError as e: |
| passed = False |
| print('ERROR: %s' % str(e), file=sys.stderr) |
| |
| if not passed and self._ignore_hooks: |
| print('\nWARNING: %s hooks failed, but continuing anyways.' % self._hook_type, |
| file=sys.stderr) |
| passed = True |
| |
| return passed |
| |
| @classmethod |
| def FromSubcmd(cls, manifest, opt, *args, **kwargs): |
| """Method to construct the repo hook class |
| |
| Args: |
| manifest: The current active manifest for this command from which we |
| extract a couple of fields. |
| opt: Contains the commandline options for the action of this hook. |
| It should contain the options added by AddHookOptionGroup() in which |
| we are interested in RepoHook execution. |
| """ |
| for key in ('bypass_hooks', 'allow_all_hooks', 'ignore_hooks'): |
| kwargs.setdefault(key, getattr(opt, key)) |
| kwargs.update({ |
| 'hooks_project': manifest.repo_hooks_project, |
| 'repo_topdir': manifest.topdir, |
| 'manifest_url': manifest.manifestProject.GetRemote('origin').url, |
| }) |
| return cls(*args, **kwargs) |
| |
| @staticmethod |
| def AddOptionGroup(parser, name): |
| """Help options relating to the various hooks.""" |
| |
| # Note that verify and no-verify are NOT opposites of each other, which |
| # is why they store to different locations. We are using them to match |
| # 'git commit' syntax. |
| group = parser.add_option_group(name + ' hooks') |
| group.add_option('--no-verify', |
| dest='bypass_hooks', action='store_true', |
| help='Do not run the %s hook.' % name) |
| group.add_option('--verify', |
| dest='allow_all_hooks', action='store_true', |
| help='Run the %s hook without prompting.' % name) |
| group.add_option('--ignore-hooks', |
| action='store_true', |
| help='Do not abort if %s hooks fail.' % name) |