1"""
2Helper functions for use by mac modules
3.. versionadded:: 2016.3.0
4"""
5
6import logging
7import os
8import plistlib
9import subprocess
10import time
11import xml.parsers.expat
12
13import salt.grains.extra
14import salt.modules.cmdmod
15import salt.utils.args
16import salt.utils.files
17import salt.utils.path
18import salt.utils.platform
19import salt.utils.stringutils
20import salt.utils.timed_subprocess
21from salt.exceptions import (
22    CommandExecutionError,
23    SaltInvocationError,
24    TimedProcTimeoutError,
25)
26
27try:
28    import pwd
29except ImportError:
30    # The pwd module is not available on all platforms
31    pass
32
33
34DEFAULT_SHELL = salt.grains.extra.shell()["shell"]
35
36# Set up logging
37log = logging.getLogger(__name__)
38
39__virtualname__ = "mac_utils"
40
41__salt__ = {
42    "cmd.run_all": salt.modules.cmdmod._run_all_quiet,
43    "cmd.run": salt.modules.cmdmod._run_quiet,
44}
45
46
47def __virtual__():
48    """
49    Load only on Mac OS
50    """
51    if not salt.utils.platform.is_darwin():
52        return (
53            False,
54            "The mac_utils utility could not be loaded: "
55            "utility only works on MacOS systems.",
56        )
57
58    return __virtualname__
59
60
61def _run_all(cmd):
62    """
63
64    Args:
65        cmd:
66
67    Returns:
68
69    """
70    if not isinstance(cmd, list):
71        cmd = salt.utils.args.shlex_split(cmd, posix=False)
72
73    for idx, item in enumerate(cmd):
74        if not isinstance(cmd[idx], str):
75            cmd[idx] = str(cmd[idx])
76
77    cmd = " ".join(cmd)
78
79    run_env = os.environ.copy()
80
81    kwargs = {
82        "cwd": None,
83        "shell": DEFAULT_SHELL,
84        "env": run_env,
85        "stdin": None,
86        "stdout": subprocess.PIPE,
87        "stderr": subprocess.PIPE,
88        "with_communicate": True,
89        "timeout": None,
90        "bg": False,
91    }
92
93    try:
94        proc = salt.utils.timed_subprocess.TimedProc(cmd, **kwargs)
95
96    except OSError as exc:
97        raise CommandExecutionError(
98            "Unable to run command '{}' with the context '{}', reason: {}".format(
99                cmd, kwargs, exc
100            )
101        )
102
103    ret = {}
104
105    try:
106        proc.run()
107    except TimedProcTimeoutError as exc:
108        ret["stdout"] = str(exc)
109        ret["stderr"] = ""
110        ret["retcode"] = 1
111        ret["pid"] = proc.process.pid
112        return ret
113
114    out, err = proc.stdout, proc.stderr
115
116    if out is not None:
117        out = salt.utils.stringutils.to_str(out).rstrip()
118    if err is not None:
119        err = salt.utils.stringutils.to_str(err).rstrip()
120
121    ret["pid"] = proc.process.pid
122    ret["retcode"] = proc.process.returncode
123    ret["stdout"] = out
124    ret["stderr"] = err
125
126    return ret
127
128
129def _check_launchctl_stderr(ret):
130    """
131    helper class to check the launchctl stderr.
132    launchctl does not always return bad exit code
133    if there is a failure
134    """
135    err = ret["stderr"].lower()
136    if "service is disabled" in err:
137        return True
138    return False
139
140
141def execute_return_success(cmd):
142    """
143    Executes the passed command. Returns True if successful
144
145    :param str cmd: The command to run
146
147    :return: True if successful, otherwise False
148    :rtype: bool
149
150    :raises: Error if command fails or is not supported
151    """
152
153    ret = _run_all(cmd)
154    log.debug("Execute return success %s: %r", cmd, ret)
155
156    if ret["retcode"] != 0 or "not supported" in ret["stdout"].lower():
157        msg = "Command Failed: {}\n".format(cmd)
158        msg += "Return Code: {}\n".format(ret["retcode"])
159        msg += "Output: {}\n".format(ret["stdout"])
160        msg += "Error: {}\n".format(ret["stderr"])
161        raise CommandExecutionError(msg)
162
163    return True
164
165
166def execute_return_result(cmd):
167    """
168    Executes the passed command. Returns the standard out if successful
169
170    :param str cmd: The command to run
171
172    :return: The standard out of the command if successful, otherwise returns
173    an error
174    :rtype: str
175
176    :raises: Error if command fails or is not supported
177    """
178    ret = _run_all(cmd)
179
180    if ret["retcode"] != 0 or "not supported" in ret["stdout"].lower():
181        msg = "Command Failed: {}\n".format(cmd)
182        msg += "Return Code: {}\n".format(ret["retcode"])
183        msg += "Output: {}\n".format(ret["stdout"])
184        msg += "Error: {}\n".format(ret["stderr"])
185        raise CommandExecutionError(msg)
186
187    return ret["stdout"]
188
189
190def parse_return(data):
191    """
192    Returns the data portion of a string that is colon separated.
193
194    :param str data: The string that contains the data to be parsed. Usually the
195    standard out from a command
196
197    For example:
198    ``Time Zone: America/Denver``
199    will return:
200    ``America/Denver``
201    """
202
203    if ": " in data:
204        return data.split(": ")[1]
205    if ":\n" in data:
206        return data.split(":\n")[1]
207    else:
208        return data
209
210
211def validate_enabled(enabled):
212    """
213    Helper function to validate the enabled parameter. Boolean values are
214    converted to "on" and "off". String values are checked to make sure they are
215    either "on" or "off"/"yes" or "no". Integer ``0`` will return "off". All
216    other integers will return "on"
217
218    :param enabled: Enabled can be boolean True or False, Integers, or string
219    values "on" and "off"/"yes" and "no".
220    :type: str, int, bool
221
222    :return: "on" or "off" or errors
223    :rtype: str
224    """
225    if isinstance(enabled, str):
226        if enabled.lower() not in ["on", "off", "yes", "no"]:
227            msg = (
228                "\nMac Power: Invalid String Value for Enabled.\n"
229                "String values must be 'on' or 'off'/'yes' or 'no'.\n"
230                "Passed: {}".format(enabled)
231            )
232            raise SaltInvocationError(msg)
233
234        return "on" if enabled.lower() in ["on", "yes"] else "off"
235
236    return "on" if bool(enabled) else "off"
237
238
239def confirm_updated(value, check_fun, normalize_ret=False, wait=5):
240    """
241    Wait up to ``wait`` seconds for a system parameter to be changed before
242    deciding it hasn't changed.
243
244    :param str value: The value indicating a successful change
245
246    :param function check_fun: The function whose return is compared with
247        ``value``
248
249    :param bool normalize_ret: Whether to normalize the return from
250        ``check_fun`` with ``validate_enabled``
251
252    :param int wait: The maximum amount of seconds to wait for a system
253        parameter to change
254    """
255    for i in range(wait):
256        state = validate_enabled(check_fun()) if normalize_ret else check_fun()
257        log.debug(
258            "Confirm update try: %d func:%r state:%s value:%s",
259            i,
260            check_fun,
261            state,
262            value,
263        )
264        if value in state:
265            return True
266        time.sleep(1)
267    return False
268
269
270def launchctl(sub_cmd, *args, **kwargs):
271    """
272    Run a launchctl command and raise an error if it fails
273
274    Args: additional args are passed to launchctl
275        sub_cmd (str): Sub command supplied to launchctl
276
277    Kwargs: passed to ``cmd.run_all``
278        return_stdout (bool): A keyword argument. If true return the stdout of
279            the launchctl command
280
281    Returns:
282        bool: ``True`` if successful
283        str: The stdout of the launchctl command if requested
284
285    Raises:
286        CommandExecutionError: If command fails
287
288    CLI Example:
289
290    .. code-block:: bash
291
292        import salt.utils.mac_service
293        salt.utils.mac_service.launchctl('debug', 'org.cups.cupsd')
294    """
295    # Get return type
296    return_stdout = kwargs.pop("return_stdout", False)
297
298    # Construct command
299    cmd = ["launchctl", sub_cmd]
300    cmd.extend(args)
301
302    # fix for https://github.com/saltstack/salt/issues/57436
303    if sub_cmd == "bootout":
304        kwargs["success_retcodes"] = [
305            36,
306        ]
307
308    # Run command
309    kwargs["python_shell"] = False
310    kwargs = salt.utils.args.clean_kwargs(**kwargs)
311    ret = __salt__["cmd.run_all"](cmd, **kwargs)
312    error = _check_launchctl_stderr(ret)
313
314    # Raise an error or return successful result
315    if ret["retcode"] or error:
316        out = "Failed to {} service:\n".format(sub_cmd)
317        out += "stdout: {}\n".format(ret["stdout"])
318        out += "stderr: {}\n".format(ret["stderr"])
319        out += "retcode: {}".format(ret["retcode"])
320        raise CommandExecutionError(out)
321    else:
322        return ret["stdout"] if return_stdout else True
323
324
325def _read_plist_file(root, file_name):
326    """
327    :param root: The root path of the plist file
328    :param file_name: The name of the plist file
329    :return:  An empty dictionary if the plist file was invalid, otherwise, a dictionary with plist data
330    """
331    file_path = os.path.join(root, file_name)
332    log.debug("read_plist: Gathering service info for %s", file_path)
333
334    # Must be a plist file
335    if not file_path.lower().endswith(".plist"):
336        log.debug("read_plist: Not a plist file: %s", file_path)
337        return {}
338
339    # ignore broken symlinks
340    if not os.path.exists(os.path.realpath(file_path)):
341        log.warning("read_plist: Ignoring broken symlink: %s", file_path)
342        return {}
343
344    try:
345        with salt.utils.files.fopen(file_path, "rb") as handle:
346            plist = plistlib.load(handle)
347
348    except plistlib.InvalidFileException:
349        # Raised in python3 if the file is not XML.
350        # There's nothing we can do; move on to the next one.
351        log.warning(
352            'read_plist: Unable to parse "%s" as it is invalid XML: InvalidFileException.',
353            file_path,
354        )
355        return {}
356
357    except ValueError as err:
358        # fixes https://github.com/saltstack/salt/issues/58143
359        # choosing not to log a Warning as this would happen on BigSur+ machines.
360        log.debug(
361            "Caught ValueError: '%s', while trying to parse '%s'.", err, file_path
362        )
363        return {}
364
365    except xml.parsers.expat.ExpatError:
366        # Raised by py3 if the file is XML, but with errors.
367        log.warning(
368            'read_plist: Unable to parse "%s" as it is invalid XML: xml.parsers.expat.ExpatError.',
369            file_path,
370        )
371        return {}
372
373    if "Label" not in plist:
374        # not all launchd plists contain a Label key
375        log.debug(
376            "read_plist: Service does not contain a Label key. Skipping %s.", file_path
377        )
378        return {}
379
380    return {
381        "file_name": file_name,
382        "file_path": file_path,
383        "plist": plist,
384    }
385
386
387def _available_services(refresh=False):
388    """
389    This is a helper function for getting the available macOS services.
390
391    The strategy is to look through the known system locations for
392    launchd plist files, parse them, and use their information for
393    populating the list of services. Services can run without a plist
394    file present, but normally services which have an automated startup
395    will have a plist file, so this is a minor compromise.
396    """
397    if "available_services" in __context__ and not refresh:
398        log.debug("Found context for available services.")
399        __context__["using_cached_services"] = True
400        return __context__["available_services"]
401
402    launchd_paths = {
403        "/Library/LaunchAgents",
404        "/Library/LaunchDaemons",
405        "/System/Library/LaunchAgents",
406        "/System/Library/LaunchDaemons",
407    }
408
409    agent_path = "/Users/{}/Library/LaunchAgents"
410    launchd_paths.update(
411        {
412            agent_path.format(user)
413            for user in os.listdir("/Users/")
414            if os.path.isdir(agent_path.format(user))
415        }
416    )
417
418    result = {}
419    for launch_dir in launchd_paths:
420        for root, dirs, files in salt.utils.path.os_walk(launch_dir):
421            for file_name in files:
422                data = _read_plist_file(root, file_name)
423                if data:
424                    result[data["plist"]["Label"].lower()] = data
425
426    # put this in __context__ as this is a time consuming function.
427    # a fix for this issue. https://github.com/saltstack/salt/issues/48414
428    __context__["available_services"] = result
429    # this is a fresh gathering of services, set cached to false
430    __context__["using_cached_services"] = False
431
432    return result
433
434
435def available_services(refresh=False):
436    """
437    Return a dictionary of all available services on the system
438
439    :param bool refresh: If you wish to refresh the available services
440    as this data is cached on the first run.
441
442    Returns:
443        dict: All available services
444
445    CLI Example:
446
447    .. code-block:: bash
448
449        import salt.utils.mac_service
450        salt.utils.mac_service.available_services()
451    """
452    log.debug("Loading available services")
453    return _available_services(refresh)
454
455
456def console_user(username=False):
457    """
458    Gets the UID or Username of the current console user.
459
460    :return: The uid or username of the console user.
461
462    :param bool username: Whether to return the username of the console
463    user instead of the UID. Defaults to False
464
465    :rtype: Interger of the UID, or a string of the username.
466
467    Raises:
468        CommandExecutionError: If we fail to get the UID.
469
470    CLI Example:
471
472    .. code-block:: bash
473
474        import salt.utils.mac_service
475        salt.utils.mac_service.console_user()
476    """
477    try:
478        # returns the 'st_uid' stat from the /dev/console file.
479        uid = os.stat("/dev/console")[4]
480    except (OSError, IndexError):
481        # we should never get here but raise an error if so
482        raise CommandExecutionError("Failed to get a UID for the console user.")
483
484    if username:
485        return pwd.getpwuid(uid)[0]
486
487    return uid
488
489
490def git_is_stub():
491    """
492    Return whether macOS git is the standard OS stub or a real binary.
493    """
494    # On a fresh macOS install, /usr/bin/git is a stub, which if
495    # accessed, triggers a UI dialog box prompting the user to install
496    # the developer command line tools. We don't want that! So instead,
497    # running the below command will return a path to the installed dev
498    # tools and retcode 0, or print a bunch of info to stderr and
499    # retcode 2.
500    try:
501        cmd = ["/usr/bin/xcode-select", "-p"]
502        _ = subprocess.check_call(
503            cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1
504        )
505        log.debug("Xcode command line tools present")
506        return False
507    except subprocess.CalledProcessError:
508        log.debug("Xcode command line tools not present")
509        return True
510