1# Copyright (c) 2012 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import contextlib
6import logging
7import posixpath
8import re
9
10from devil.android.sdk import version_codes
11
12logger = logging.getLogger(__name__)
13
14_CMDLINE_DIR = '/data/local/tmp'
15_CMDLINE_DIR_LEGACY = '/data/local'
16_RE_NEEDS_QUOTING = re.compile(r'[^\w-]')  # Not in: alphanumeric or hyphens.
17_QUOTES = '"\''  # Either a single or a double quote.
18_ESCAPE = '\\'  # A backslash.
19
20
21@contextlib.contextmanager
22def CustomCommandLineFlags(device, cmdline_name, flags):
23  """Context manager to change Chrome's command line temporarily.
24
25  Example:
26
27      with flag_changer.TemporaryCommandLineFlags(device, name, flags):
28        # Launching Chrome will use the provided flags.
29
30      # Previous set of flags on the device is now restored.
31
32  Args:
33    device: A DeviceUtils instance.
34    cmdline_name: Name of the command line file where to store flags.
35    flags: A sequence of command line flags to set.
36  """
37  changer = FlagChanger(device, cmdline_name)
38  try:
39    changer.ReplaceFlags(flags)
40    yield
41  finally:
42    changer.Restore()
43
44
45class FlagChanger(object):
46  """Changes the flags Chrome runs with.
47
48    Flags can be temporarily set for a particular set of unit tests.  These
49    tests should call Restore() to revert the flags to their original state
50    once the tests have completed.
51  """
52
53  def __init__(self, device, cmdline_file, use_legacy_path=False):
54    """Initializes the FlagChanger and records the original arguments.
55
56    Args:
57      device: A DeviceUtils instance.
58      cmdline_file: Name of the command line file where to store flags.
59      use_legacy_path: Whether to use the legacy commandline path (needed for
60        M54 and earlier)
61    """
62    self._device = device
63    self._should_reset_enforce = False
64
65    if posixpath.sep in cmdline_file:
66      raise ValueError(
67          'cmdline_file should be a file name only, do not include path'
68          ' separators in: %s' % cmdline_file)
69    cmdline_path = posixpath.join(_CMDLINE_DIR, cmdline_file)
70    alternate_cmdline_path = posixpath.join(_CMDLINE_DIR_LEGACY, cmdline_file)
71
72    if use_legacy_path:
73      cmdline_path, alternate_cmdline_path = (alternate_cmdline_path,
74                                              cmdline_path)
75      if not self._device.HasRoot():
76        raise ValueError('use_legacy_path requires a rooted device')
77    self._cmdline_path = cmdline_path
78
79    if self._device.PathExists(alternate_cmdline_path):
80      logger.warning('Removing alternate command line file %r.',
81                     alternate_cmdline_path)
82      self._device.RemovePath(alternate_cmdline_path, as_root=True)
83
84    self._state_stack = [None]  # Actual state is set by GetCurrentFlags().
85    self.GetCurrentFlags()
86
87  def GetCurrentFlags(self):
88    """Read the current flags currently stored in the device.
89
90    Also updates the internal state of the flag_changer.
91
92    Returns:
93      A list of flags.
94    """
95    if self._device.PathExists(self._cmdline_path):
96      command_line = self._device.ReadFile(
97          self._cmdline_path, as_root=True).strip()
98    else:
99      command_line = ''
100    flags = _ParseFlags(command_line)
101
102    # Store the flags as a set to facilitate adding and removing flags.
103    self._state_stack[-1] = set(flags)
104    return flags
105
106  def ReplaceFlags(self, flags, log_flags=True):
107    """Replaces the flags in the command line with the ones provided.
108       Saves the current flags state on the stack, so a call to Restore will
109       change the state back to the one preceeding the call to ReplaceFlags.
110
111    Args:
112      flags: A sequence of command line flags to set, eg. ['--single-process'].
113             Note: this should include flags only, not the name of a command
114             to run (ie. there is no need to start the sequence with 'chrome').
115
116    Returns:
117      A list with the flags now stored on the device.
118    """
119    new_flags = set(flags)
120    self._state_stack.append(new_flags)
121    self._SetPermissive()
122    return self._UpdateCommandLineFile(log_flags=log_flags)
123
124  def AddFlags(self, flags):
125    """Appends flags to the command line if they aren't already there.
126       Saves the current flags state on the stack, so a call to Restore will
127       change the state back to the one preceeding the call to AddFlags.
128
129    Args:
130      flags: A sequence of flags to add on, eg. ['--single-process'].
131
132    Returns:
133      A list with the flags now stored on the device.
134    """
135    return self.PushFlags(add=flags)
136
137  def RemoveFlags(self, flags):
138    """Removes flags from the command line, if they exist.
139       Saves the current flags state on the stack, so a call to Restore will
140       change the state back to the one preceeding the call to RemoveFlags.
141
142       Note that calling RemoveFlags after AddFlags will result in having
143       two nested states.
144
145    Args:
146      flags: A sequence of flags to remove, eg. ['--single-process'].  Note
147             that we expect a complete match when removing flags; if you want
148             to remove a switch with a value, you must use the exact string
149             used to add it in the first place.
150
151    Returns:
152      A list with the flags now stored on the device.
153    """
154    return self.PushFlags(remove=flags)
155
156  def PushFlags(self, add=None, remove=None):
157    """Appends and removes flags to/from the command line if they aren't already
158       there. Saves the current flags state on the stack, so a call to Restore
159       will change the state back to the one preceeding the call to PushFlags.
160
161    Args:
162      add: A list of flags to add on, eg. ['--single-process'].
163      remove: A list of flags to remove, eg. ['--single-process'].  Note that we
164              expect a complete match when removing flags; if you want to remove
165              a switch with a value, you must use the exact string used to add
166              it in the first place.
167
168    Returns:
169      A list with the flags now stored on the device.
170    """
171    new_flags = self._state_stack[-1].copy()
172    if add:
173      new_flags.update(add)
174    if remove:
175      new_flags.difference_update(remove)
176    return self.ReplaceFlags(new_flags)
177
178  def _SetPermissive(self):
179    """Set SELinux to permissive, if needed.
180
181    On Android N and above this is needed in order to allow Chrome to read the
182    legacy command line file.
183
184    TODO(crbug.com/699082): Remove when a better solution exists.
185    """
186    # TODO(crbug.com/948578): figure out the exact scenarios where the lowered
187    # permissions are needed, and document them in the code.
188    if not self._device.HasRoot():
189      return
190    if (self._device.build_version_sdk >= version_codes.NOUGAT
191        and self._device.GetEnforce()):
192      self._device.SetEnforce(enabled=False)
193      self._should_reset_enforce = True
194
195  def _ResetEnforce(self):
196    """Restore SELinux policy if it had been previously made permissive."""
197    if self._should_reset_enforce:
198      self._device.SetEnforce(enabled=True)
199      self._should_reset_enforce = False
200
201  def Restore(self):
202    """Restores the flags to their state prior to the last AddFlags or
203       RemoveFlags call.
204
205    Returns:
206      A list with the flags now stored on the device.
207    """
208    # The initial state must always remain on the stack.
209    assert len(self._state_stack) > 1, (
210        'Mismatch between calls to Add/RemoveFlags and Restore')
211    self._state_stack.pop()
212    if len(self._state_stack) == 1:
213      self._ResetEnforce()
214    return self._UpdateCommandLineFile()
215
216  def _UpdateCommandLineFile(self, log_flags=True):
217    """Writes out the command line to the file, or removes it if empty.
218
219    Returns:
220      A list with the flags now stored on the device.
221    """
222    command_line = _SerializeFlags(self._state_stack[-1])
223    if command_line is not None:
224      self._device.WriteFile(self._cmdline_path, command_line, as_root=True)
225    else:
226      self._device.RemovePath(self._cmdline_path, force=True, as_root=True)
227
228    flags = self.GetCurrentFlags()
229    logging.info('Flags now written on the device to %s', self._cmdline_path)
230    if log_flags:
231      logging.info('Flags: %s', flags)
232    return flags
233
234
235def _ParseFlags(line):
236  """Parse the string containing the command line into a list of flags.
237
238  It's a direct port of CommandLine.java::tokenizeQuotedArguments.
239
240  The first token is assumed to be the (unused) program name and stripped off
241  from the list of flags.
242
243  Args:
244    line: A string containing the entire command line.  The first token is
245          assumed to be the program name.
246
247  Returns:
248     A list of flags, with quoting removed.
249  """
250  flags = []
251  current_quote = None
252  current_flag = None
253
254  # pylint: disable=unsubscriptable-object
255  for c in line:
256    # Detect start or end of quote block.
257    if (current_quote is None and c in _QUOTES) or c == current_quote:
258      if current_flag is not None and current_flag[-1] == _ESCAPE:
259        # Last char was a backslash; pop it, and treat c as a literal.
260        current_flag = current_flag[:-1] + c
261      else:
262        current_quote = c if current_quote is None else None
263    elif current_quote is None and c.isspace():
264      if current_flag is not None:
265        flags.append(current_flag)
266        current_flag = None
267    else:
268      if current_flag is None:
269        current_flag = ''
270      current_flag += c
271
272  if current_flag is not None:
273    if current_quote is not None:
274      logger.warning('Unterminated quoted argument: ' + current_flag)
275    flags.append(current_flag)
276
277  # Return everything but the program name.
278  return flags[1:]
279
280
281def _SerializeFlags(flags):
282  """Serialize a sequence of flags into a command line string.
283
284  Args:
285    flags: A sequence of strings with individual flags.
286
287  Returns:
288    A line with the command line contents to save; or None if the sequence of
289    flags is empty.
290  """
291  if flags:
292    # The first command line argument doesn't matter as we are not actually
293    # launching the chrome executable using this command line.
294    args = ['_']
295    args.extend(_QuoteFlag(f) for f in flags)
296    return ' '.join(args)
297  else:
298    return None
299
300
301def _QuoteFlag(flag):
302  """Validate and quote a single flag.
303
304  Args:
305    A string with the flag to quote.
306
307  Returns:
308    A string with the flag quoted so that it can be parsed by the algorithm
309    in _ParseFlags; or None if the flag does not appear to be valid.
310  """
311  if '=' in flag:
312    key, value = flag.split('=', 1)
313  else:
314    key, value = flag, None
315
316  if not flag or _RE_NEEDS_QUOTING.search(key):
317    # Probably not a valid flag, but quote the whole thing so it can be
318    # parsed back correctly.
319    return '"%s"' % flag.replace('"', r'\"')
320
321  if value is None:
322    return key
323
324  if _RE_NEEDS_QUOTING.search(value):
325    value = '"%s"' % value.replace('"', r'\"')
326  return '='.join([key, value])
327