diff options
Diffstat (limited to 'catapult/devil/devil/android/flag_changer.py')
-rw-r--r-- | catapult/devil/devil/android/flag_changer.py | 263 |
1 files changed, 177 insertions, 86 deletions
diff --git a/catapult/devil/devil/android/flag_changer.py b/catapult/devil/devil/android/flag_changer.py index 5de54eaa..2c8cc3c2 100644 --- a/catapult/devil/devil/android/flag_changer.py +++ b/catapult/devil/devil/android/flag_changer.py @@ -3,12 +3,19 @@ # found in the LICENSE file. import logging - -from devil.android import device_errors +import posixpath +import re logger = logging.getLogger(__name__) +_CMDLINE_DIR = '/data/local/tmp' +_CMDLINE_DIR_LEGACY = '/data/local' +_RE_NEEDS_QUOTING = re.compile(r'[^\w-]') # Not in: alphanumeric or hyphens. +_QUOTES = '"\'' # Either a single or a double quote. +_ESCAPE = '\\' # A backslash. + + class FlagChanger(object): """Changes the flags Chrome runs with. @@ -22,26 +29,50 @@ class FlagChanger(object): Args: device: A DeviceUtils instance. - cmdline_file: Path to the command line file on the device. + cmdline_file: Name of the command line file where to store flags. """ self._device = device - # Unrooted devices have limited access to the file system. - # Place files in /data/local/tmp/ rather than /data/local/ - if not device.HasRoot() and not '/data/local/tmp/' in cmdline_file: - self._cmdline_file = cmdline_file.replace('/data/local/', - '/data/local/tmp/') + unused_dir, basename = posixpath.split(cmdline_file) + self._cmdline_path = posixpath.join(_CMDLINE_DIR, basename) + + # TODO(catapult:#3112): Make this fail instead of warn after all clients + # have been switched. + if unused_dir: + logging.warning( + 'cmdline_file argument of %s() should be a file name only (not a' + ' full path).', type(self).__name__) + if cmdline_file != self._cmdline_path: + logging.warning( + 'Client supplied %r, but %r will be used instead.', + cmdline_file, self._cmdline_path) + + cmdline_path_legacy = posixpath.join(_CMDLINE_DIR_LEGACY, basename) + if self._device.PathExists(cmdline_path_legacy): + logging.warning( + 'Removing legacy command line file %r.', cmdline_path_legacy) + self._device.RemovePath(cmdline_path_legacy, as_root=True) + + self._state_stack = [None] # Actual state is set by GetCurrentFlags(). + self.GetCurrentFlags() + + def GetCurrentFlags(self): + """Read the current flags currently stored in the device. + + Also updates the internal state of the flag_changer. + + Returns: + A list of flags. + """ + if self._device.PathExists(self._cmdline_path): + command_line = self._device.ReadFile(self._cmdline_path).strip() else: - self._cmdline_file = cmdline_file - - stored_flags = '' - if self._device.PathExists(self._cmdline_file): - try: - stored_flags = self._device.ReadFile(self._cmdline_file).strip() - except device_errors.CommandFailedError: - pass + command_line = '' + flags = _ParseFlags(command_line) + # Store the flags as a set to facilitate adding and removing flags. - self._state_stack = [set(self._TokenizeFlags(stored_flags))] + self._state_stack[-1] = set(flags) + return flags def ReplaceFlags(self, flags): """Replaces the flags in the command line with the ones provided. @@ -52,10 +83,13 @@ class FlagChanger(object): flags: A sequence of command line flags to set, eg. ['--single-process']. Note: this should include flags only, not the name of a command to run (ie. there is no need to start the sequence with 'chrome'). + + Returns: + A list with the flags now stored on the device. """ new_flags = set(flags) self._state_stack.append(new_flags) - self._UpdateCommandLineFile() + return self._UpdateCommandLineFile() def AddFlags(self, flags): """Appends flags to the command line if they aren't already there. @@ -64,8 +98,11 @@ class FlagChanger(object): Args: flags: A sequence of flags to add on, eg. ['--single-process']. + + Returns: + A list with the flags now stored on the device. """ - self.PushFlags(add=flags) + return self.PushFlags(add=flags) def RemoveFlags(self, flags): """Removes flags from the command line, if they exist. @@ -80,8 +117,11 @@ class FlagChanger(object): that we expect a complete match when removing flags; if you want to remove a switch with a value, you must use the exact string used to add it in the first place. + + Returns: + A list with the flags now stored on the device. """ - self.PushFlags(remove=flags) + return self.PushFlags(remove=flags) def PushFlags(self, add=None, remove=None): """Appends and removes flags to/from the command line if they aren't already @@ -94,91 +134,142 @@ class FlagChanger(object): expect a complete match when removing flags; if you want to remove a switch with a value, you must use the exact string used to add it in the first place. + + Returns: + A list with the flags now stored on the device. """ new_flags = self._state_stack[-1].copy() if add: new_flags.update(add) if remove: new_flags.difference_update(remove) - self.ReplaceFlags(new_flags) + return self.ReplaceFlags(new_flags) def Restore(self): """Restores the flags to their state prior to the last AddFlags or RemoveFlags call. + + Returns: + A list with the flags now stored on the device. """ # The initial state must always remain on the stack. assert len(self._state_stack) > 1, ( "Mismatch between calls to Add/RemoveFlags and Restore") self._state_stack.pop() - self._UpdateCommandLineFile() + return self._UpdateCommandLineFile() def _UpdateCommandLineFile(self): - """Writes out the command line to the file, or removes it if empty.""" - current_flags = list(self._state_stack[-1]) - logger.info('Current flags: %s', current_flags) - # Root is not required to write to /data/local/tmp/. - use_root = '/data/local/tmp/' not in self._cmdline_file - if current_flags: - # The first command line argument doesn't matter as we are not actually - # launching the chrome executable using this command line. - cmd_line = ' '.join(['_'] + current_flags) - self._device.WriteFile( - self._cmdline_file, cmd_line, as_root=use_root) - file_contents = self._device.ReadFile( - self._cmdline_file, as_root=use_root).rstrip() - assert file_contents == cmd_line, ( - 'Failed to set the command line file at %s' % self._cmdline_file) - else: - self._device.RunShellCommand('rm ' + self._cmdline_file, - as_root=use_root) - assert not self._device.FileExists(self._cmdline_file), ( - 'Failed to remove the command line file at %s' % self._cmdline_file) - - @staticmethod - def _TokenizeFlags(line): - """Changes the string containing the command line into a list of flags. - - Follows similar logic to CommandLine.java::tokenizeQuotedArguments: - * Flags are split using whitespace, unless the whitespace is within a - pair of quotation marks. - * Unlike the Java version, we keep the quotation marks around switch - values since we need them to re-create the file when new flags are - appended. + """Writes out the command line to the file, or removes it if empty. - Args: - line: A string containing the entire command line. The first token is - assumed to be the program name. + Returns: + A list with the flags now stored on the device. """ - if not line: - return [] - - tokenized_flags = [] - current_flag = "" - within_quotations = False - - # Move through the string character by character and build up each flag - # along the way. - for c in line.strip(): - if c is '"': - if len(current_flag) > 0 and current_flag[-1] == '\\': - # Last char was a backslash; pop it, and treat this " as a literal. - current_flag = current_flag[0:-1] + '"' - else: - within_quotations = not within_quotations - current_flag += c - elif not within_quotations and (c is ' ' or c is '\t'): - if current_flag is not "": - tokenized_flags.append(current_flag) - current_flag = "" - else: - current_flag += c + command_line = _SerializeFlags(self._state_stack[-1]) + if command_line is not None: + self._device.WriteFile(self._cmdline_path, command_line) + else: + self._device.RunShellCommand('rm ' + self._cmdline_path) + + current_flags = self.GetCurrentFlags() + logger.info('Flags now set on the device: %s', current_flags) + return current_flags - # Tack on the last flag. - if not current_flag: - if within_quotations: - logger.warn('Unterminated quoted argument: ' + line) + +def _ParseFlags(line): + """Parse the string containing the command line into a list of flags. + + It's a direct port of CommandLine.java::tokenizeQuotedArguments. + + The first token is assumed to be the (unused) program name and stripped off + from the list of flags. + + Args: + line: A string containing the entire command line. The first token is + assumed to be the program name. + + Returns: + A list of flags, with quoting removed. + """ + flags = [] + current_quote = None + current_flag = None + + for c in line: + # Detect start or end of quote block. + if (current_quote is None and c in _QUOTES) or c == current_quote: + if current_flag is not None and current_flag[-1] == _ESCAPE: + # Last char was a backslash; pop it, and treat c as a literal. + current_flag = current_flag[:-1] + c + else: + current_quote = c if current_quote is None else None + elif current_quote is None and c.isspace(): + if current_flag is not None: + flags.append(current_flag) + current_flag = None else: - tokenized_flags.append(current_flag) + if current_flag is None: + current_flag = '' + current_flag += c + + if current_flag is not None: + if current_quote is not None: + logger.warning('Unterminated quoted argument: ' + current_flag) + flags.append(current_flag) + + # Return everything but the program name. + return flags[1:] + + +def _SerializeFlags(flags): + """Serialize a sequence of flags into a command line string. + + Args: + flags: A sequence of strings with individual flags. + + Returns: + A line with the command line contents to save; or None if the sequence of + flags is empty. + """ + if flags: + # The first command line argument doesn't matter as we are not actually + # launching the chrome executable using this command line. + args = ['_'] + args.extend(_QuoteFlag(f) for f in flags) + return ' '.join(args) + else: + return None + + +def _QuoteFlag(flag): + """Validate and quote a single flag. + + Args: + A string with the flag to quote. + + Returns: + A string with the flag quoted so that it can be parsed by the algorithm + in _ParseFlags; or None if the flag does not appear to be valid. + """ + if '=' in flag: + key, value = flag.split('=', 1) + else: + key, value = flag, None + + if not flag or _RE_NEEDS_QUOTING.search(key): + # Probably not a valid flag, but quote the whole thing so it can be + # parsed back correctly. + return '"%s"' % flag.replace('"', r'\"') - # Return everything but the program name. - return tokenized_flags[1:] + if value is None: + return key + else: + # TODO(catapult:#3112): Remove this check when all clients comply. + if value[0] in _QUOTES and value[0] == value[-1]: + logging.warning( + 'Flag %s appears to be quoted, so will be passed as-is.', flag) + logging.warning( + 'Note: this behavior will be changed in the future. ' + 'Clients should pass values unquoted to prevent double-quoting.') + elif _RE_NEEDS_QUOTING.search(value): + value = '"%s"' % value.replace('"', r'\"') + return '='.join([key, value]) |