# beipack https://github.com/allisonkarlitskaya/beipack
import importlib.abc
import importlib.util
import io
import sys
from types import ModuleType
from typing import BinaryIO, Dict, Iterator, Optional, Sequence
class BeipackLoader(importlib.abc.SourceLoader, importlib.abc.MetaPathFinder):
    if sys.version_info >= (3, 11):
        from importlib.resources.abc import ResourceReader as AbstractResourceReader
    else:
        AbstractResourceReader = object
    class ResourceReader(AbstractResourceReader):
        def __init__(self, contents: Dict[str, bytes], filename: str) -> None:
            self._contents = contents
            self._dir = f'{filename}/'
        def is_resource(self, resource: str) -> bool:
            return f'{self._dir}{resource}' in self._contents
        def open_resource(self, resource: str) -> BinaryIO:
            return io.BytesIO(self._contents[f'{self._dir}{resource}'])
        def resource_path(self, resource: str) -> str:
            raise FileNotFoundError
        def contents(self) -> Iterator[str]:
            dir_length = len(self._dir)
            result = set()
            for filename in self._contents:
                if filename.startswith(self._dir):
                    try:
                        next_slash = filename.index('/', dir_length)
                    except ValueError:
                        next_slash = None
                    result.add(filename[dir_length:next_slash])
            return iter(result)
    contents: Dict[str, bytes]
    modules: Dict[str, str]
    def __init__(self, contents: Dict[str, bytes]) -> None:
        try:
            contents[__file__] = __self_source__  # type: ignore[name-defined]
        except NameError:
            pass
        self.contents = contents
        self.modules = {
            self.get_fullname(filename): filename
            for filename in contents
            if filename.endswith(".py")
        }
    def get_fullname(self, filename: str) -> str:
        assert filename.endswith(".py")
        filename = filename[:-3]
        if filename.endswith("/__init__"):
            filename = filename[:-9]
        return filename.replace("/", ".")
    def get_resource_reader(self, fullname: str) -> ResourceReader:
        return BeipackLoader.ResourceReader(self.contents, fullname.replace('.', '/'))
    def get_data(self, path: str) -> bytes:
        return self.contents[path]
    def get_filename(self, fullname: str) -> str:
        return self.modules[fullname]
    def find_spec(
        self,
        fullname: str,
        path: Optional[Sequence[str]],
        target: Optional[ModuleType] = None
      ) -> Optional[importlib.machinery.ModuleSpec]:
        if fullname not in self.modules:
            return None
        return importlib.util.spec_from_loader(fullname, self)

import sys
sys.meta_path.insert(0, BeipackLoader({
  'cockpit/__init__.py': br'''# SPDX-License-Identifier: LGPL-2.1-or-later
from ._version import __version__

__all__ = (
    '__version__',
)
''',
  'cockpit/_vendor/__init__.py': br'''''',
  'cockpit/_vendor/bei/__init__.py': br'''''',
  'cockpit/_vendor/bei/beiboot.py': br"""# beiboot - Remote bootloader for Python
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import argparse
import asyncio
import os
import shlex
import subprocess
import sys
import threading
from typing import IO, List, Sequence, Tuple

from .bootloader import make_bootloader


def get_python_command(local: bool = False,
                       tty: bool = False,
                       sh: bool = False) -> Sequence[str]:
    interpreter = sys.executable if local else 'python3'
    command: Sequence[str]

    if tty:
        command = (interpreter, '-iq')
    else:
        command = (
            interpreter, '-ic',
            # https://github.com/python/cpython/issues/93139
            '''" - beiboot - "; import sys; sys.ps1 = ''; sys.ps2 = '';'''
        )

    if sh:
        command = (' '.join(shlex.quote(arg) for arg in command),)

    return command


def get_ssh_command(*args: str, tty: bool = False) -> Sequence[str]:
    return ('ssh',
            *(['-t'] if tty else ()),
            *args,
            *get_python_command(tty=tty, sh=True))


def get_container_command(*args: str, tty: bool = False) -> Sequence[str]:
    return ('podman', 'exec', '--interactive',
            *(['--tty'] if tty else ()),
            *args,
            *get_python_command(tty=tty))


def get_command(*args: str, tty: bool = False, sh: bool = False) -> Sequence[str]:
    return (*args, *get_python_command(local=True, tty=tty, sh=sh))


def splice_in_thread(src: int, dst: IO[bytes]) -> None:
    def _thread() -> None:
        # os.splice() only in Python 3.10
        with dst:
            block_size = 1 << 20
            while True:
                data = os.read(src, block_size)
                if not data:
                    break
                dst.write(data)
                dst.flush()

    threading.Thread(target=_thread, daemon=True).start()


def send_and_splice(command: Sequence[str], script: bytes) -> None:
    with subprocess.Popen(command, stdin=subprocess.PIPE) as proc:
        assert proc.stdin is not None
        proc.stdin.write(script)

        splice_in_thread(0, proc.stdin)
        sys.exit(proc.wait())


def send_xz_and_splice(command: Sequence[str], script: bytes) -> None:
    import ferny

    class Responder(ferny.InteractionResponder):
        async def do_custom_command(self,
                                    command: str,
                                    args: Tuple,
                                    fds: List[int],
                                    stderr: str) -> None:
            assert proc.stdin is not None
            if command == 'beiboot.provide':
                proc.stdin.write(script)
                proc.stdin.flush()

    agent = ferny.InteractionAgent(Responder())
    with subprocess.Popen(command, stdin=subprocess.PIPE, stderr=agent) as proc:
        assert proc.stdin is not None
        proc.stdin.write(make_bootloader([
            ('boot_xz', ('script.py.xz', len(script), [], True)),
        ], gadgets=ferny.BEIBOOT_GADGETS).encode())
        proc.stdin.flush()

        asyncio.run(agent.communicate())
        splice_in_thread(0, proc.stdin)
        sys.exit(proc.wait())


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument('--sh', action='store_true',
                        help='Pass Python interpreter command as shell-script')
    parser.add_argument('--xz', help="the xz to run remotely")
    parser.add_argument('--script',
                        help="the script to run remotely (must be repl-friendly)")
    parser.add_argument('command', nargs='*')

    args = parser.parse_args()
    tty = not args.script and os.isatty(0)

    if args.command == []:
        command = get_python_command(tty=tty)
    elif args.command[0] == 'ssh':
        command = get_ssh_command(*args.command[1:], tty=tty)
    elif args.command[0] == 'container':
        command = get_container_command(*args.command[1:], tty=tty)
    else:
        command = get_command(*args.command, tty=tty, sh=args.sh)

    if args.script:
        with open(args.script, 'rb') as file:
            script = file.read()

        send_and_splice(command, script)

    elif args.xz:
        with open(args.xz, 'rb') as file:
            script = file.read()

        send_xz_and_splice(command, script)

    else:
        # If we're streaming from stdin then this is a lot easier...
        os.execlp(command[0], *command)

    # Otherwise, "full strength"

if __name__ == '__main__':
    main()
""",
  'cockpit/_vendor/bei/beipack.py': br'''# beipack - Remote bootloader for Python
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import argparse
import binascii
import lzma
import os
import sys
import tempfile
import zipfile
from typing import Dict, Iterable, List, Optional, Set, Tuple

from .data import read_data_file


def escape_string(data: str) -> str:
    # Avoid mentioning ' ' ' literally, to make our own packing a bit prettier
    triplequote = "'" * 3
    if triplequote not in data:
        return "r" + triplequote + data + triplequote
    if '"""' not in data:
        return 'r"""' + data + '"""'
    return repr(data)


def ascii_bytes_repr(data: bytes) -> str:
    return 'b' + escape_string(data.decode('ascii'))


def utf8_bytes_repr(data: bytes) -> str:
    return escape_string(data.decode('utf-8')) + ".encode('utf-8')"


def base64_bytes_repr(data: bytes, imports: Set[str]) -> str:
    # base85 is smaller, but base64 is in C, and ~20x faster.
    # when compressing with `xz -e` the size difference is marginal.
    imports.add('from binascii import a2b_base64')
    encoded = binascii.b2a_base64(data).decode('ascii').strip()
    return f'a2b_base64("{encoded}")'


def bytes_repr(data: bytes, imports: Set[str]) -> str:
    # Strategy:
    #   if the file is ascii, encode it directly as bytes
    #   otherwise, if it's UTF-8, use a unicode string and encode
    #   otherwise, base64

    try:
        return ascii_bytes_repr(data)
    except UnicodeDecodeError:
        # it's not ascii
        pass

    # utf-8
    try:
        return utf8_bytes_repr(data)
    except UnicodeDecodeError:
        # it's not utf-8
        pass

    return base64_bytes_repr(data, imports)


def dict_repr(contents: Dict[str, bytes], imports: Set[str]) -> str:
    return ('{\n' +
            ''.join(f'  {repr(k)}: {bytes_repr(v, imports)},\n'
                    for k, v in contents.items()) +
            '}')


def pack(contents: Dict[str, bytes],
         entrypoint: Optional[str] = None,
         args: str = '') -> str:
    """Creates a beipack with the given `contents`.

    If `entrypoint` is given, it should be an entry point which is run as the
    "main" function.  It is given in the `package.module:func format` such that
    the following code is emitted:

        from package.module import func as main
        main()

    Additionally, if `args` is given, it is written verbatim between the parens
    of the call to main (ie: it should already be in Python syntax).
    """

    loader = read_data_file('beipack_loader.py')
    lines = [line for line in loader.splitlines() if line]
    lines.append('')

    imports = {'import sys'}
    contents_txt = dict_repr(contents, imports)
    lines.extend(imports)
    lines.append(f'sys.meta_path.insert(0, BeipackLoader({contents_txt}))')

    if entrypoint:
        package, main = entrypoint.split(':')
        lines.append(f'from {package} import {main} as main')
        lines.append(f'main({args})')

    return ''.join(f'{line}\n' for line in lines)


def collect_contents(filenames: List[str],
                     relative_to: Optional[str] = None) -> Dict[str, bytes]:
    contents: Dict[str, bytes] = {}

    for filename in filenames:
        with open(filename, 'rb') as file:
            contents[os.path.relpath(filename, start=relative_to)] = file.read()

    return contents


def collect_module(name: str, *, recursive: bool) -> Dict[str, bytes]:
    import importlib.resources
    from importlib.resources.abc import Traversable

    def walk(path: str, entry: Traversable) -> Iterable[Tuple[str, bytes]]:
        for item in entry.iterdir():
            itemname = f'{path}/{item.name}'
            if item.is_file():
                yield itemname, item.read_bytes()
            elif recursive and item.name != '__pycache__':
                yield from walk(itemname, item)

    return dict(walk(name.replace('.', '/'), importlib.resources.files(name)))


def collect_zip(filename: str) -> Dict[str, bytes]:
    contents = {}

    with zipfile.ZipFile(filename) as file:
        for entry in file.filelist:
            if '.dist-info/' in entry.filename:
                continue
            contents[entry.filename] = file.read(entry)

    return contents


def collect_pep517(path: str) -> Dict[str, bytes]:
    with tempfile.TemporaryDirectory() as tmpdir:
        import build
        builder = build.ProjectBuilder(path)
        wheel = builder.build('wheel', tmpdir)
        return collect_zip(wheel)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument('--python', '-p',
                        help="add a #!python3 interpreter line using the given path")
    parser.add_argument('--xz', '-J', action='store_true',
                        help="compress the output with `xz`")
    parser.add_argument('--topdir',
                        help="toplevel directory (paths are stored relative to this)")
    parser.add_argument('--output', '-o',
                        help="write output to a file (default: stdout)")
    parser.add_argument('--main', '-m', metavar='MODULE:FUNC',
                        help="use FUNC from MODULE as the main function")
    parser.add_argument('--main-args', metavar='ARGS',
                        help="arguments to main() in Python syntax", default='')
    parser.add_argument('--module', action='append', default=[],
                        help="collect installed modules (recursively)")
    parser.add_argument('--zip', '-z', action='append', default=[],
                        help="include files from a zipfile (or wheel)")
    parser.add_argument('--build', metavar='DIR', action='append', default=[],
                        help="PEP-517 from a given source directory")
    parser.add_argument('files', nargs='*',
                        help="files to include in the beipack")
    args = parser.parse_args()

    contents = collect_contents(args.files, relative_to=args.topdir)

    for file in args.zip:
        contents.update(collect_zip(file))

    for name in args.module:
        contents.update(collect_module(name, recursive=True))

    for path in args.build:
        contents.update(collect_pep517(path))

    result = pack(contents, args.main, args.main_args).encode('utf-8')

    if args.python:
        result = b'#!' + args.python.encode('ascii') + b'\n' + result

    if args.xz:
        result = lzma.compress(result, preset=lzma.PRESET_EXTREME)

    if args.output:
        with open(args.output, 'wb') as file:
            file.write(result)
    else:
        if args.xz and os.isatty(1):
            sys.exit('refusing to write compressed output to a terminal')
        sys.stdout.buffer.write(result)


if __name__ == '__main__':
    main()
''',
  'cockpit/_vendor/bei/bootloader.py': br'''# beiboot - Remote bootloader for Python
#
# Copyright (C) 2023 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import textwrap
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple

GADGETS = {
    "_frame": r"""
        import sys
        import traceback
        try:
            ...
        except SystemExit:
            raise
        except BaseException:
            command('beiboot.exc', traceback.format_exc())
            sys.exit(37)
    """,
    "try_exec": r"""
        import contextlib
        import os
        def try_exec(argv):
            with contextlib.suppress(OSError):
                os.execvp(argv[0], argv)
    """,
    "boot_xz": r"""
        import lzma
        import sys
        def boot_xz(filename, size, args=[], send_end=False):
            command('beiboot.provide', size)
            src_xz = sys.stdin.buffer.read(size)
            src = lzma.decompress(src_xz)
            sys.argv = [filename, *args]
            if send_end:
                end()
            exec(src, {
                '__name__': '__main__',
                '__self_source__': src_xz,
                '__file__': filename})
            sys.exit()
    """,
}


def split_code(code: str, imports: Set[str]) -> Iterable[Tuple[str, str]]:
    for line in textwrap.dedent(code).splitlines():
        text = line.lstrip(" ")
        if text.startswith("import "):
            imports.add(text)
        elif text:
            spaces = len(line) - len(text)
            assert (spaces % 4) == 0
            yield "\t" * (spaces // 4), text


def yield_body(user_gadgets: Dict[str, str],
               steps: Sequence[Tuple[str, Sequence[object]]],
               imports: Set[str]) -> Iterable[Tuple[str, str]]:
    # Allow the caller to override our gadgets, but keep the original
    # variable for use in the next step.
    gadgets = dict(GADGETS, **user_gadgets)

    # First emit the gadgets.  Emit all gadgets provided by the caller,
    # plus any referred to by the caller's list of steps.
    provided_gadgets = set(user_gadgets)
    step_gadgets = {name for name, _args in steps}
    for name in provided_gadgets | step_gadgets:
        yield from split_code(gadgets[name], imports)

    # Yield functions mentioned in steps from the caller
    for name, args in steps:
        yield '', name + repr(tuple(args))


def make_bootloader(steps: Sequence[Tuple[str, Sequence[object]]],
                    gadgets: Optional[Dict[str, str]] = None) -> str:
    imports: Set[str] = set()
    lines: List[str] = []

    for frame_spaces, frame_text in split_code(GADGETS["_frame"], imports):
        if frame_text == "...":
            for spaces, text in yield_body(gadgets or {}, steps, imports):
                lines.append(frame_spaces + spaces + text)
        else:
            lines.append(frame_spaces + frame_text)

    return "".join(f"{line}\n" for line in [*imports, *lines]) + "\n"
''',
  'cockpit/_vendor/bei/data/__init__.py': br'''import sys

if sys.version_info >= (3, 9):
    import importlib.abc
    import importlib.resources

    def read_data_file(filename: str) -> str:
        return (importlib.resources.files(__name__) / filename).read_text()
else:
    def read_data_file(filename: str) -> str:
        loader = __loader__  # type: ignore[name-defined]
        data = loader.get_data(__file__.replace('__init__.py', filename))
        return data.decode('utf-8')
''',
  'cockpit/_vendor/bei/data/beipack_loader.py': br'''# beipack https://github.com/allisonkarlitskaya/beipack

import importlib.abc
import importlib.util
import io
import sys
from types import ModuleType
from typing import BinaryIO, Dict, Iterator, Optional, Sequence


class BeipackLoader(importlib.abc.SourceLoader, importlib.abc.MetaPathFinder):
    if sys.version_info >= (3, 11):
        from importlib.resources.abc import ResourceReader as AbstractResourceReader
    else:
        AbstractResourceReader = object

    class ResourceReader(AbstractResourceReader):
        def __init__(self, contents: Dict[str, bytes], filename: str) -> None:
            self._contents = contents
            self._dir = f'{filename}/'

        def is_resource(self, resource: str) -> bool:
            return f'{self._dir}{resource}' in self._contents

        def open_resource(self, resource: str) -> BinaryIO:
            return io.BytesIO(self._contents[f'{self._dir}{resource}'])

        def resource_path(self, resource: str) -> str:
            raise FileNotFoundError

        def contents(self) -> Iterator[str]:
            dir_length = len(self._dir)
            result = set()

            for filename in self._contents:
                if filename.startswith(self._dir):
                    try:
                        next_slash = filename.index('/', dir_length)
                    except ValueError:
                        next_slash = None
                    result.add(filename[dir_length:next_slash])

            return iter(result)

    contents: Dict[str, bytes]
    modules: Dict[str, str]

    def __init__(self, contents: Dict[str, bytes]) -> None:
        try:
            contents[__file__] = __self_source__  # type: ignore[name-defined]
        except NameError:
            pass

        self.contents = contents
        self.modules = {
            self.get_fullname(filename): filename
            for filename in contents
            if filename.endswith(".py")
        }

    def get_fullname(self, filename: str) -> str:
        assert filename.endswith(".py")
        filename = filename[:-3]
        if filename.endswith("/__init__"):
            filename = filename[:-9]
        return filename.replace("/", ".")

    def get_resource_reader(self, fullname: str) -> ResourceReader:
        return BeipackLoader.ResourceReader(self.contents, fullname.replace('.', '/'))

    def get_data(self, path: str) -> bytes:
        return self.contents[path]

    def get_filename(self, fullname: str) -> str:
        return self.modules[fullname]

    def find_spec(
        self,
        fullname: str,
        path: Optional[Sequence[str]],
        target: Optional[ModuleType] = None
      ) -> Optional[importlib.machinery.ModuleSpec]:
        if fullname not in self.modules:
            return None
        return importlib.util.spec_from_loader(fullname, self)
''',
  'cockpit/_vendor/bei/spawn.py': br'''"""Helper to create a beipack to spawn a command with files in a tmpdir"""

import argparse
import os
import sys

from . import pack, tmpfs


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument('--file', '-f', action='append')
    parser.add_argument('command', nargs='+', help='The command to execute')
    args = parser.parse_args()

    contents = {
        '_beitmpfs.py': tmpfs.__spec__.loader.get_data(tmpfs.__spec__.origin)
    }

    if args.file is not None:
        files = args.file
    else:
        file = args.command[-1]
        files = [file]
        args.command[-1] = './' + os.path.basename(file)

    for filename in files:
        with open(filename, 'rb') as file:
            basename = os.path.basename(filename)
            contents[f'tmpfs/{basename}'] = file.read()

    script = pack.pack(contents, '_beitmpfs:main', '*' + repr(args.command))
    sys.stdout.write(script)


if __name__ == '__main__':
    main()
''',
  'cockpit/_vendor/bei/tmpfs.py': br'''import os
import subprocess
import sys
import tempfile


def main(*command: str) -> None:
    with tempfile.TemporaryDirectory() as tmpdir:
        os.chdir(tmpdir)

        for key, value in __loader__.get_contents().items():
            if key.startswith('tmpfs/'):
                subdir = os.path.dirname(key)
                os.makedirs(subdir, exist_ok=True)
                with open(key, 'wb') as fp:
                    fp.write(value)

        os.chdir('tmpfs')

        result = subprocess.run(command, check=False)
        sys.exit(result.returncode)
''',
  'cockpit/_vendor/ferny/__init__.py': br'''from .interaction_agent import (
    BEIBOOT_GADGETS,
    COMMAND_TEMPLATE,
    AskpassHandler,
    InteractionAgent,
    InteractionError,
    InteractionHandler,
    temporary_askpass,
    write_askpass_to_tmpdir,
)
from .session import Session
from .ssh_askpass import (
    AskpassPrompt,
    SshAskpassResponder,
    SshFIDOPINPrompt,
    SshFIDOUserPresencePrompt,
    SshHostKeyPrompt,
    SshPassphrasePrompt,
    SshPasswordPrompt,
    SshPKCS11PINPrompt,
)
from .ssh_errors import (
    SshAuthenticationError,
    SshChangedHostKeyError,
    SshError,
    SshHostKeyError,
    SshUnknownHostKeyError,
)
from .transport import FernyTransport, SubprocessError

__all__ = [
    'AskpassHandler',
    'AskpassPrompt',
    'AuthenticationError',
    'BEIBOOT_GADGETS',
    'COMMAND_TEMPLATE',
    'ChangedHostKeyError',
    'FernyTransport',
    'HostKeyError',
    'InteractionAgent',
    'InteractionError',
    'InteractionHandler',
    'Session',
    'SshAskpassResponder',
    'SshAuthenticationError',
    'SshChangedHostKeyError',
    'SshError',
    'SshFIDOPINPrompt',
    'SshFIDOUserPresencePrompt',
    'SshHostKeyError',
    'SshHostKeyPrompt',
    'SshPKCS11PINPrompt',
    'SshPassphrasePrompt',
    'SshPasswordPrompt',
    'SshUnknownHostKeyError',
    'SubprocessError',
    'temporary_askpass',
    'write_askpass_to_tmpdir',
]

__version__ = '0'
''',
  'cockpit/_vendor/ferny/askpass.py': br'''from .interaction_client import main

if __name__ == '__main__':
    main()
''',
  'cockpit/_vendor/ferny/interaction_agent.py': r'''# ferny - asyncio SSH client library, using ssh(1)
#
# Copyright (C) 2023 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import array
import ast
import asyncio
import contextlib
import logging
import os
import re
import socket
import tempfile
from typing import Any, Callable, ClassVar, Generator, Sequence

from . import interaction_client

logger = logging.getLogger(__name__)


COMMAND_RE = re.compile(b'\0ferny\0([^\n]*)\0\0\n')
COMMAND_TEMPLATE = '\0ferny\0{(command, args)!r}\0\0\n'

BEIBOOT_GADGETS = {
    "command": rf"""
        import sys
        def command(command, *args):
            sys.stderr.write(f{COMMAND_TEMPLATE!r})
            sys.stderr.flush()
    """,
    "end": r"""
        def end():
            command('ferny.end')
    """,
}


class InteractionError(Exception):
    pass


try:
    recv_fds = socket.recv_fds
except AttributeError:
    # Python < 3.9

    def recv_fds(
        sock: socket.socket, bufsize: int, maxfds: int, flags: int = 0
    ) -> 'tuple[bytes, list[int], int, None]':
        fds = array.array("i")
        msg, ancdata, flags, addr = sock.recvmsg(bufsize, socket.CMSG_LEN(maxfds * fds.itemsize))
        for cmsg_level, cmsg_type, cmsg_data in ancdata:
            if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
                fds.frombytes(cmsg_data[: len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
        return msg, list(fds), flags, addr


def get_running_loop() -> asyncio.AbstractEventLoop:
    try:
        return asyncio.get_running_loop()
    except AttributeError:
        # Python 3.6
        return asyncio.get_event_loop()


class InteractionHandler:
    commands: ClassVar[Sequence[str]]

    async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
        raise NotImplementedError


class AskpassHandler(InteractionHandler):
    commands: ClassVar[Sequence[str]] = ('ferny.askpass',)

    async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None':
        """Prompt the user for an authentication or confirmation interaction.

        'messages' is data that was sent to stderr before the interaction was requested.
        'prompt' is the interaction prompt.

        The expected response type depends on hint:

            - "confirm": ask for permission, returning "yes" if accepted
                - example: authorizing agent operation

            - "none": show a request without need for a response
                - example: please touch your authentication token

            - otherwise: return a password or other form of text token
                - examples: enter password, unlock private key

        In any case, the function should properly handle cancellation.  For the
        "none" case, this will be the normal way to dismiss the dialog.
        """
        return None

    async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:
        """Prompt the user for a decision regarding acceptance of a host key.

        The "reason" will be either "HOSTNAME" or "ADDRESS" (if `CheckHostIP` is enabled).

        The host, algorithm, and key parameters are the values in the form that
        they would appear one a single line in the known hosts file.  The
        fingerprint is the key fingerprint in the format that ssh would
        normally present it to the user.

        In case the host key should be accepted, this function needs to return
        True.  Returning False means that ssh implements its default logic.  To
        interrupt the connection, raise an exception.
        """
        return False

    async def do_custom_command(
        self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str
    ) -> None:
        """Handle a custom command.

        The command name, its arguments, the passed fds, and the stderr leading
        up to the command invocation are all provided.

        See doc/interaction-protocol.md
        """

    async def _askpass_command(self, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
        logger.debug('_askpass_command(%s, %s, %s)', args, fds, stderr)
        try:
            argv, env = args
            assert isinstance(argv, list)
            assert all(isinstance(arg, str) for arg in argv)
            assert isinstance(env, dict)
            assert all(isinstance(key, str) and isinstance(val, str) for key, val in env.items())
            assert len(fds) == 2
        except (ValueError, TypeError, AssertionError) as exc:
            logger.error('Invalid arguments to askpass interaction: %s, %s: %s', args, fds, exc)
            return

        with open(fds.pop(0), 'w') as status, open(fds.pop(0), 'w') as stdout:
            try:
                loop = get_running_loop()
                try:
                    task = asyncio.current_task()
                except AttributeError:
                    task = asyncio.Task.current_task()  # type:ignore[attr-defined] # (Python 3.6)
                assert task is not None
                loop.add_reader(status, task.cancel)

                if len(argv) == 2:
                    # normal askpass
                    prompt = argv[1]
                    hint = env.get('SSH_ASKPASS_PROMPT', '')
                    logger.debug('do_askpass(%r, %r, %r)', stderr, prompt, hint)
                    answer = await self.do_askpass(stderr, prompt, hint)
                    logger.debug('do_askpass answer %r', answer)
                    if answer is not None:
                        print(answer, file=stdout)
                        print(0, file=status)

                elif len(argv) == 6:
                    # KnownHostsCommand
                    argv0, reason, host, algorithm, key, fingerprint = argv
                    if reason in ['ADDRESS', 'HOSTNAME']:
                        logger.debug('do_hostkey(%r, %r, %r, %r, %r)', reason, host, algorithm, key, fingerprint)
                        if await self.do_hostkey(reason, host, algorithm, key, fingerprint):
                            print(host, algorithm, key, file=stdout)
                    else:
                        logger.debug('ignoring KnownHostsCommand reason %r', reason)

                    print(0, file=status)

                else:
                    logger.error('Incorrect number of command-line arguments to ferny-askpass: %s', argv)
            finally:
                loop.remove_reader(status)

    async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
        logger.debug('run_command(%s, %s, %s, %s)', command, args, fds, stderr)
        if command == 'ferny.askpass':
            await self._askpass_command(args, fds, stderr)
        else:
            await self.do_custom_command(command, args, fds, stderr)


class InteractionAgent:
    _handlers: 'dict[str, InteractionHandler]'

    _loop: asyncio.AbstractEventLoop

    _tasks: 'set[asyncio.Task[None]]'

    _buffer: bytearray
    _ours: socket.socket
    _theirs: socket.socket

    _completion_future: 'asyncio.Future[str]'
    _pending_result: 'None | str | Exception' = None
    _end: bool = False

    def _consider_completion(self) -> None:
        logger.debug('_consider_completion(%r)', self)

        if self._pending_result is None or self._tasks:
            logger.debug('  but not ready yet')

        elif self._completion_future.done():
            logger.debug('  but already complete')

        elif isinstance(self._pending_result, str):
            logger.debug('  submitting stderr (%r) to completion_future', self._pending_result)
            self._completion_future.set_result(self._pending_result)

        else:
            logger.debug('  submitting exception (%r) to completion_future')
            self._completion_future.set_exception(self._pending_result)

    def _result(self, result: 'str | Exception') -> None:
        logger.debug('_result(%r, %r)', self, result)

        if self._pending_result is None:
            self._pending_result = result

        if self._ours.fileno() != -1:
            logger.debug('  remove_reader(%r)', self._ours)
            self._loop.remove_reader(self._ours.fileno())

        for task in self._tasks:
            logger.debug('    cancel(%r)', task)
            task.cancel()

        logger.debug('  closing sockets')
        self._theirs.close()  # idempotent
        self._ours.close()

        self._consider_completion()

    def _invoke_command(self, stderr: 'bytes | bytearray', command_blob: bytes, fds: 'list[int]') -> None:
        logger.debug('_invoke_command(%r, %r, %r)', stderr, command_blob, fds)
        try:
            command, args = ast.literal_eval(command_blob.decode())
            if not isinstance(command, str) or not isinstance(args, tuple):
                raise TypeError('Invalid argument types')
        except (UnicodeDecodeError, SyntaxError, ValueError, TypeError) as exc:
            logger.error('Received invalid ferny command: %s: %s', command_blob, exc)
            return

        if command == 'ferny.end':
            self._end = True
            self._result(self._buffer.decode(errors='replace'))
            return

        try:
            handler = self._handlers[command]
        except KeyError:
            logger.error('Received unhandled ferny command: %s', command)
            return

        # The task is responsible for the list of fds and removing itself
        # from the set.
        task_fds = list(fds)
        task = self._loop.create_task(handler.run_command(command, args, task_fds, stderr.decode()))

        def bottom_half(completed_task: 'asyncio.Task[None]') -> None:
            assert completed_task is task
            while task_fds:
                os.close(task_fds.pop())
            self._tasks.remove(task)

            try:
                task.result()
                logger.debug('%r completed cleanly', handler)
            except asyncio.CancelledError:
                # this is not an error — it just means ferny-askpass exited via signal
                logger.debug('%r was cancelled', handler)
            except Exception as exc:
                logger.debug('%r raised %r', handler, exc)
                self._result(exc)

            self._consider_completion()

        task.add_done_callback(bottom_half)
        self._tasks.add(task)
        fds[:] = []

    def _got_data(self, data: bytes, fds: 'list[int]') -> None:
        logger.debug('_got_data(%r, %r)', data, fds)

        if data == b'':
            self._result(self._buffer.decode(errors='replace'))
            return

        self._buffer.extend(data)

        # Read zero or more "remote" messages
        chunks = COMMAND_RE.split(self._buffer)
        self._buffer = bytearray(chunks.pop())
        while len(chunks) > 1:
            self._invoke_command(chunks[0], chunks[1], [])
            chunks = chunks[2:]

        # Maybe read one "local" message
        if fds:
            assert self._buffer.endswith(b'\0'), self._buffer
            stderr = self._buffer[:-1]
            self._buffer = bytearray(b'')
            with open(fds.pop(0), 'rb') as command_channel:
                command = command_channel.read()
            self._invoke_command(stderr, command, fds)

    def _read_ready(self) -> None:
        try:
            data, fds, _flags, _addr = recv_fds(self._ours, 4096, 10, flags=socket.MSG_DONTWAIT)
        except BlockingIOError:
            return
        except OSError as exc:
            self._result(exc)
        else:
            self._got_data(data, fds)
        finally:
            while fds:
                os.close(fds.pop())

    def __init__(
        self,
        handlers: Sequence[InteractionHandler],
        loop: 'asyncio.AbstractEventLoop | None' = None,
        done_callback: 'Callable[[asyncio.Future[str]], None] | None' = None,
    ) -> None:
        self._loop = loop or get_running_loop()
        self._completion_future = self._loop.create_future()
        self._tasks = set()
        self._handlers = {}

        for handler in handlers:
            for command in handler.commands:
                self._handlers[command] = handler

        if done_callback is not None:
            self._completion_future.add_done_callback(done_callback)

        self._theirs, self._ours = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
        self._buffer = bytearray()

    def fileno(self) -> int:
        return self._theirs.fileno()

    def start(self) -> None:
        logger.debug('start(%r)', self)
        if self._ours.fileno() != -1:
            logger.debug('  add_reader(%r)', self._ours)
            self._loop.add_reader(self._ours.fileno(), self._read_ready)
        else:
            logger.debug('  ...but agent is already finished.')

        logger.debug('  close(%r)', self._theirs)
        self._theirs.close()

    def force_completion(self) -> None:
        logger.debug('force_completion(%r)', self)

        # read any residual data on stderr, but don't process commands, and
        # don't block
        try:
            if self._ours.fileno() != -1:
                logger.debug('  draining pending stderr data (non-blocking)')
                with contextlib.suppress(BlockingIOError):
                    while True:
                        data = self._ours.recv(4096, socket.MSG_DONTWAIT)
                        logger.debug('    got %d bytes', len(data))
                        if not data:
                            break
                        self._buffer.extend(data)
        except OSError as exc:
            self._result(exc)
        else:
            self._result(self._buffer.decode(errors='replace'))

    async def communicate(self) -> None:
        logger.debug('_communicate(%r)', self)
        try:
            self.start()
            # We assume that we are the only ones to write to
            # self._completion_future.  If we directly await it, though, it can
            # also have a asyncio.CancelledError posted to it from outside.
            # Shield it to prevent that from happening.
            stderr = await asyncio.shield(self._completion_future)
            logger.debug('_communicate(%r) stderr result is %r', self, stderr)
        finally:
            logger.debug('_communicate finished.  Ensuring completion.')
            self.force_completion()
        if not self._end:
            logger.debug('_communicate never saw ferny.end.  raising InteractionError.')
            raise InteractionError(stderr.strip())


def write_askpass_to_tmpdir(tmpdir: str) -> str:
    askpass_path = os.path.join(tmpdir, 'ferny-askpass')
    fd = os.open(askpass_path, os.O_CREAT | os.O_WRONLY | os.O_CLOEXEC | os.O_EXCL | os.O_NOFOLLOW, 0o777)
    try:
        os.write(fd, __loader__.get_data(interaction_client.__file__))  # type: ignore
    finally:
        os.close(fd)
    return askpass_path


@contextlib.contextmanager
def temporary_askpass(**kwargs: Any) -> Generator[str, None, None]:
    with tempfile.TemporaryDirectory(**kwargs) as directory:
        yield write_askpass_to_tmpdir(directory)
'''.encode('utf-8'),
  'cockpit/_vendor/ferny/interaction_client.py': br'''#!/usr/bin/python3

import array
import io
import os
import socket
import sys
from typing import Sequence


def command(stderr_fd: int, command: str, *args: object, fds: Sequence[int] = ()) -> None:
    cmd_read, cmd_write = [io.open(*end) for end in zip(os.pipe(), 'rw')]

    with cmd_write:
        with cmd_read:
            with socket.fromfd(stderr_fd, socket.AF_UNIX, socket.SOCK_STREAM) as sock:
                fd_array = array.array('i', (cmd_read.fileno(), *fds))
                sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fd_array)])

        cmd_write.write(repr((command, args)))


def askpass(stderr_fd: int, stdout_fd: int, args: 'list[str]', env: 'dict[str, str]') -> int:
    ours, theirs = socket.socketpair()

    with theirs:
        command(stderr_fd, 'ferny.askpass', args, env, fds=(theirs.fileno(), stdout_fd))

    with ours:
        return int(ours.recv(16) or b'1')


def main() -> None:
    if len(sys.argv) == 1:
        command(2, 'ferny.end', [])
    else:
        sys.exit(askpass(2, 1, sys.argv, dict(os.environ)))


if __name__ == '__main__':
    main()
''',
  'cockpit/_vendor/ferny/py.typed': br'''''',
  'cockpit/_vendor/ferny/session.py': r'''# ferny - asyncio SSH client library, using ssh(1)
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import asyncio
import ctypes
import functools
import logging
import os
import shlex
import signal
import subprocess
import tempfile
from typing import Mapping, Sequence

from . import ssh_errors
from .interaction_agent import InteractionAgent, InteractionError, InteractionHandler, write_askpass_to_tmpdir

prctl = ctypes.CDLL(None).prctl
logger = logging.getLogger(__name__)
PR_SET_PDEATHSIG = 1


@functools.lru_cache()
def has_feature(feature: str, teststr: str = 'x') -> bool:
    try:
        subprocess.check_output(['ssh', f'-o{feature} {teststr}', '-G', 'nonexisting'], stderr=subprocess.DEVNULL)
        return True
    except subprocess.CalledProcessError:
        return False


class SubprocessContext:
    def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:
        """Return the args required to launch a process in the given context.

        For example, this might return a vector with
            ["sudo"]
        or
            ["flatpak-spawn", "--host"]
        prepended.

        It is also possible that more substantial changes may be performed.

        This function is not permitted to modify its argument, although it may
        (optionally) return it unmodified, if no changes are required.
        """
        return args

    def wrap_subprocess_env(self, env: Mapping[str, str]) -> Mapping[str, str]:
        """Return the envp required to launch a process in the given context.

        For example, this might set the "SUDO_ASKPASS" environment variable, if
        needed.

        As with wrap_subprocess_args(), this function is not permitted to
        modify its argument, although it may (optionally) return it unmodified
        if no changes are required.
        """
        return env


class Session(SubprocessContext, InteractionHandler):
    # Set after .connect() called, even if failed
    _controldir: 'tempfile.TemporaryDirectory[str] | None' = None
    _controlsock: 'str | None' = None

    # Set if connected, else None
    _process: 'asyncio.subprocess.Process | None' = None

    async def connect(self,
                      destination: str,
                      handle_host_key: bool = False,
                      configfile: 'str | None' = None,
                      identity_file: 'str | None' = None,
                      login_name: 'str | None' = None,
                      options: 'Mapping[str, str] | None' = None,
                      pkcs11: 'str | None' = None,
                      port: 'int | None' = None,
                      interaction_responder: 'InteractionHandler | None' = None) -> None:
        rundir = os.path.join(os.environ.get('XDG_RUNTIME_DIR', '/run'), 'ferny')
        os.makedirs(rundir, exist_ok=True)
        self._controldir = tempfile.TemporaryDirectory(dir=rundir)
        self._controlsock = f'{self._controldir.name}/socket'

        # In general, we can't guarantee an accessible and executable version
        # of this file, but since it's small and we're making a temporary
        # directory anyway, let's just copy it into place and use it from
        # there.
        askpass_path = write_askpass_to_tmpdir(self._controldir.name)

        env = dict(os.environ)
        env['SSH_ASKPASS'] = askpass_path
        env['SSH_ASKPASS_REQUIRE'] = 'force'
        # old SSH doesn't understand SSH_ASKPASS_REQUIRE and guesses based on DISPLAY instead
        env['DISPLAY'] = '-'

        args = [
            '-M',
            '-N',
            '-S', self._controlsock,
            '-o', 'PermitLocalCommand=yes',
            '-o', f'LocalCommand={askpass_path}',
        ]

        if configfile is not None:
            args.append(f'-F{configfile}')

        if identity_file is not None:
            args.append(f'-i{identity_file}')

        if options is not None:
            for key in options:  # Note: Mapping may not have .items()
                args.append(f'-o{key} {options[key]}')

        if pkcs11 is not None:
            args.append(f'-I{pkcs11}')

        if port is not None:
            args.append(f'-p{port}')

        if login_name is not None:
            args.append(f'-l{login_name}')

        if handle_host_key and has_feature('KnownHostsCommand'):
            args.extend([
                '-o', f'KnownHostsCommand={askpass_path} %I %H %t %K %f',
                '-o', 'StrictHostKeyChecking=yes',
            ])

        agent = InteractionAgent([interaction_responder] if interaction_responder is not None else [])

        # SSH_ASKPASS_REQUIRE is not generally available, so use setsid
        process = await asyncio.create_subprocess_exec(
            *('ssh', *args, '--', destination), env=env,
            start_new_session=True, stdin=asyncio.subprocess.DEVNULL,
            stdout=asyncio.subprocess.DEVNULL, stderr=agent,  # type: ignore
            preexec_fn=lambda: prctl(PR_SET_PDEATHSIG, signal.SIGKILL))

        # This is tricky: we need to clean up the subprocess, but only in case
        # if failure.  Otherwise, we keep it around.
        try:
            await agent.communicate()
            assert os.path.exists(self._controlsock)
            self._process = process
        except InteractionError as exc:
            await process.wait()
            raise ssh_errors.get_exception_for_ssh_stderr(str(exc)) from None
        except BaseException:
            # If we get here because the InteractionHandler raised an
            # exception then SSH might still be running, and may even attempt
            # further interactions (ie: 2nd attempt for password).  We already
            # have our exception and don't need any more info.  Kill it.
            try:
                process.kill()
            except ProcessLookupError:
                pass  # already exited?  good.
            await process.wait()
            raise

    def is_connected(self) -> bool:
        return self._process is not None

    async def wait(self) -> None:
        assert self._process is not None
        await self._process.wait()

    def exit(self) -> None:
        assert self._process is not None
        self._process.terminate()

    async def disconnect(self) -> None:
        self.exit()
        await self.wait()

    # Launching of processes
    def wrap_subprocess_args(self, args: Sequence[str]) -> Sequence[str]:
        assert self._controlsock is not None
        # 1. We specify the hostname as the empty string: it will be ignored
        #    when ssh is trying to use the control socket, but in case the
        #    socket has stopped working, ssh will try to fall back to directly
        #    connecting, in which case an empty hostname will prevent that.
        # 2. We need to quote the arguments — ssh will paste them together
        #    using only spaces, executing the result using the user's shell.
        return ('ssh', '-S', self._controlsock, '', *map(shlex.quote, args))
'''.encode('utf-8'),
  'cockpit/_vendor/ferny/ssh_askpass.py': br'''import logging
import re
from typing import ClassVar, Match, Sequence

from .interaction_agent import AskpassHandler

logger = logging.getLogger(__name__)


class AskpassPrompt:
    """An askpass prompt resulting from a call to ferny-askpass.

      stderr: the contents of stderr from before ferny-askpass was called.
              Likely related to previous failed operations.
      messages: all but the last line of the prompt as handed to ferny-askpass.
                Usually contains context about the question.
      prompt: the last line handed to ferny-askpass.  The prompt itself.
    """
    stderr: str
    messages: str
    prompt: str

    def __init__(self, prompt: str, messages: str, stderr: str) -> None:
        self.stderr = stderr
        self.messages = messages
        self.prompt = prompt

    def reply(self, response: str) -> None:
        pass

    def close(self) -> None:
        pass

    async def handle_via(self, responder: 'SshAskpassResponder') -> None:
        try:
            response = await self.dispatch(responder)
            if response is not None:
                self.reply(response)
        finally:
            self.close()

    async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
        return await responder.do_prompt(self)


class SSHAskpassPrompt(AskpassPrompt):
    # The valid answers to prompts of this type.  If this is None then any
    # answer is permitted.  If it's a sequence then only answers from the
    # sequence are permitted.  If it's an empty sequence, then no answer is
    # permitted (ie: the askpass callback should never return).
    answers: 'ClassVar[Sequence[str] | None]' = None

    # Patterns to capture.  `_pattern` *must* match.
    _pattern: ClassVar[str]
    # `_extra_patterns` can fill in extra class attributes if they match.
    _extra_patterns: ClassVar[Sequence[str]] = ()

    def __init__(self, prompt: str, messages: str, stderr: str, match: 'Match[str]') -> None:
        super().__init__(prompt, messages, stderr)
        self.__dict__.update(match.groupdict())

        for pattern in self._extra_patterns:
            extra_match = re.search(with_helpers(pattern), messages, re.M)
            if extra_match is not None:
                self.__dict__.update(extra_match.groupdict())


# Specific prompts
HELPERS = {
    "%{algorithm}": r"(?P<algorithm>\b[-\w]+\b)",
    "%{filename}": r"(?P<filename>.+)",
    "%{fingerprint}": r"(?P<fingerprint>SHA256:[0-9A-Za-z+/]{43})",
    "%{hostname}": r"(?P<hostname>[^ @']+)",
    "%{pkcs11_id}": r"(?P<pkcs11_id>.+)",
    "%{username}": r"(?P<username>[^ @']+)",
}


class SshPasswordPrompt(SSHAskpassPrompt):
    _pattern = r"%{username}@%{hostname}'s password: "
    username: 'str | None' = None
    hostname: 'str | None' = None

    async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
        return await responder.do_password_prompt(self)


class SshPassphrasePrompt(SSHAskpassPrompt):
    _pattern = r"Enter passphrase for key '%{filename}': "
    filename: str

    async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
        return await responder.do_passphrase_prompt(self)


class SshFIDOPINPrompt(SSHAskpassPrompt):
    _pattern = r"Enter PIN for %{algorithm} key %{filename}: "
    algorithm: str
    filename: str

    async def dispatch(self, responder: 'SshAskpassResponder') -> 'str  | None':
        return await responder.do_fido_pin_prompt(self)


class SshFIDOUserPresencePrompt(SSHAskpassPrompt):
    _pattern = r"Confirm user presence for key %{algorithm} %{fingerprint}"
    answers = ()
    algorithm: str
    fingerprint: str

    async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
        return await responder.do_fido_user_presence_prompt(self)


class SshPKCS11PINPrompt(SSHAskpassPrompt):
    _pattern = r"Enter PIN for '%{pkcs11_id}': "
    pkcs11_id: str

    async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
        return await responder.do_pkcs11_pin_prompt(self)


class SshHostKeyPrompt(SSHAskpassPrompt):
    _pattern = r"Are you sure you want to continue connecting \(yes/no(/\[fingerprint\])?\)\? "
    _extra_patterns = [
        r"%{fingerprint}[.]$",
        r"^%{algorithm} key fingerprint is",
        r"^The fingerprint for the %{algorithm} key sent by the remote host is$"
    ]
    answers = ('yes', 'no')
    algorithm: str
    fingerprint: str

    async def dispatch(self, responder: 'SshAskpassResponder') -> 'str | None':
        return await responder.do_host_key_prompt(self)


def with_helpers(pattern: str) -> str:
    for name, helper in HELPERS.items():
        pattern = pattern.replace(name, helper)

    assert '%{' not in pattern
    return pattern


def categorize_ssh_prompt(string: str, stderr: str) -> AskpassPrompt:
    classes = [
        SshFIDOPINPrompt,
        SshFIDOUserPresencePrompt,
        SshHostKeyPrompt,
        SshPKCS11PINPrompt,
        SshPassphrasePrompt,
        SshPasswordPrompt,
    ]

    # The last line is the line after the last newline character, excluding the
    # optional final newline character.  eg: "x\ny\nLAST\n" or "x\ny\nLAST"
    second_last_newline = string.rfind('\n', 0, -1)
    if second_last_newline >= 0:
        last_line = string[second_last_newline + 1:]
        extras = string[:second_last_newline + 1]
    else:
        last_line = string
        extras = ''

    for cls in classes:
        pattern = with_helpers(cls._pattern)
        match = re.fullmatch(pattern, last_line)
        if match is not None:
            return cls(last_line, extras, stderr, match)

    return AskpassPrompt(last_line, extras, stderr)


class SshAskpassResponder(AskpassHandler):
    async def do_askpass(self, stderr: str, prompt: str, hint: str) -> 'str | None':
        return await categorize_ssh_prompt(prompt, stderr).dispatch(self)

    async def do_prompt(self, prompt: AskpassPrompt) -> 'str | None':
        # Default fallback for unrecognised message types: unimplemented
        return None

    async def do_fido_pin_prompt(self, prompt: SshFIDOPINPrompt) -> 'str | None':
        return await self.do_prompt(prompt)

    async def do_fido_user_presence_prompt(self, prompt: SshFIDOUserPresencePrompt) -> 'str | None':
        return await self.do_prompt(prompt)

    async def do_host_key_prompt(self, prompt: SshHostKeyPrompt) -> 'str | None':
        return await self.do_prompt(prompt)

    async def do_pkcs11_pin_prompt(self, prompt: SshPKCS11PINPrompt) -> 'str | None':
        return await self.do_prompt(prompt)

    async def do_passphrase_prompt(self, prompt: SshPassphrasePrompt) -> 'str | None':
        return await self.do_prompt(prompt)

    async def do_password_prompt(self, prompt: SshPasswordPrompt) -> 'str | None':
        return await self.do_prompt(prompt)
''',
  'cockpit/_vendor/ferny/ssh_errors.py': br'''# ferny - asyncio SSH client library, using ssh(1)
#
# Copyright (C) 2023 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import ctypes
import errno
import os
import re
import socket
from typing import ClassVar, Iterable, Match, Pattern


class SshError(Exception):
    PATTERN: 'ClassVar[Pattern[str]]'

    def __init__(self, match: 'Match[str] | None', stderr: str) -> None:
        super().__init__(match.group(0) if match is not None else stderr)
        self.stderr = stderr


class SshAuthenticationError(SshError):
    PATTERN = re.compile(r'^([^:]+): Permission denied \(([^()]+)\)\.$', re.M)

    def __init__(self, match: 'Match[str]', stderr: str) -> None:
        super().__init__(match, stderr)
        self.destination = match.group(1)
        self.methods = match.group(2).split(',')
        self.message = match.group(0)


class SshInvalidHostnameError(SshError):
    PATTERN = re.compile(r'^hostname contains invalid characters', re.I)


# generic host key error for OSes without KnownHostsCommand support
class SshHostKeyError(SshError):
    PATTERN = re.compile(r'^Host key verification failed.$', re.M)


# specific errors for OSes with KnownHostsCommand
class SshUnknownHostKeyError(SshHostKeyError):
    PATTERN = re.compile(r'^No .* host key is known.*Host key verification failed.$', re.S | re.M)


class SshChangedHostKeyError(SshHostKeyError):
    PATTERN = re.compile(r'warning.*remote host identification has changed', re.I)


# Functionality for mapping getaddrinfo()-family error messages to their
# equivalent Python exceptions.
def make_gaierror_map() -> 'Iterable[tuple[str, int]]':
    libc = ctypes.CDLL(None)
    libc.gai_strerror.restype = ctypes.c_char_p

    for key in dir(socket):
        if key.startswith('EAI_'):
            errnum = getattr(socket, key)
            yield libc.gai_strerror(errnum).decode('utf-8'), errnum


gaierror_map = dict(make_gaierror_map())


# Functionality for passing strerror() error messages to their equivalent
# Python exceptions.
# There doesn't seem to be an official API for turning an errno into the
# correct subtype of OSError, and the list that cpython uses is hidden fairly
# deeply inside of the implementation.  This is basically copied from the
# ADD_ERRNO() lines in _PyExc_InitState in cpython/Objects/exceptions.c
oserror_subclass_map = dict((errnum, cls) for cls, errnum in [
    (BlockingIOError, errno.EAGAIN),
    (BlockingIOError, errno.EALREADY),
    (BlockingIOError, errno.EINPROGRESS),
    (BlockingIOError, errno.EWOULDBLOCK),
    (BrokenPipeError, errno.EPIPE),
    (BrokenPipeError, errno.ESHUTDOWN),
    (ChildProcessError, errno.ECHILD),
    (ConnectionAbortedError, errno.ECONNABORTED),
    (ConnectionRefusedError, errno.ECONNREFUSED),
    (ConnectionResetError, errno.ECONNRESET),
    (FileExistsError, errno.EEXIST),
    (FileNotFoundError, errno.ENOENT),
    (IsADirectoryError, errno.EISDIR),
    (NotADirectoryError, errno.ENOTDIR),
    (InterruptedError, errno.EINTR),
    (PermissionError, errno.EACCES),
    (PermissionError, errno.EPERM),
    (ProcessLookupError, errno.ESRCH),
    (TimeoutError, errno.ETIMEDOUT),
])


def get_exception_for_ssh_stderr(stderr: str) -> Exception:
    stderr = stderr.replace('\r\n', '\n')  # fix line separators

    # check for the specific error messages first, then for generic SshHostKeyError
    for ssh_cls in [SshInvalidHostnameError, SshAuthenticationError,
                    SshChangedHostKeyError, SshUnknownHostKeyError, SshHostKeyError]:
        match = ssh_cls.PATTERN.search(stderr)
        if match is not None:
            return ssh_cls(match, stderr)

    before, colon, after = stderr.rpartition(':')
    if colon and after:
        potential_strerror = after.strip()

        # DNS lookup errors
        if potential_strerror in gaierror_map:
            errnum = gaierror_map[potential_strerror]
            return socket.gaierror(errnum, stderr)

        # Network connect errors
        for errnum in errno.errorcode:
            if os.strerror(errnum) == potential_strerror:
                os_cls = oserror_subclass_map.get(errnum, OSError)
                return os_cls(errnum, stderr)

    # No match?  Generic.
    return SshError(None, stderr)
''',
  'cockpit/_vendor/ferny/transport.py': br'''# ferny - asyncio SSH client library, using ssh(1)
#
# Copyright (C) 2023 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import asyncio
import contextlib
import logging
import typing
from typing import Any, Callable, Iterable, Sequence, TypeVar

from .interaction_agent import InteractionAgent, InteractionHandler, get_running_loop
from .ssh_errors import get_exception_for_ssh_stderr

logger = logging.getLogger(__name__)

P = TypeVar('P', bound=asyncio.Protocol)


class SubprocessError(Exception):
    returncode: int
    stderr: str

    def __init__(self, returncode: int, stderr: str) -> None:
        super().__init__(returncode, stderr)
        self.returncode = returncode
        self.stderr = stderr


class FernyTransport(asyncio.Transport, asyncio.SubprocessProtocol):
    _agent: InteractionAgent
    _exec_task: 'asyncio.Task[tuple[asyncio.SubprocessTransport, FernyTransport]]'
    _is_ssh: bool
    _protocol: asyncio.Protocol
    _protocol_disconnected: bool = False

    # These get initialized in connection_made() and once set, never get unset.
    _subprocess_transport: 'asyncio.SubprocessTransport | None' = None
    _stdin_transport: 'asyncio.WriteTransport | None' = None
    _stdout_transport: 'asyncio.ReadTransport | None' = None

    # We record events that might build towards a connection termination here
    # and consider them from _consider_disconnect() in order to try to get the
    # best possible Exception for the protocol, rather than just taking the
    # first one (which is likely to be somewhat random).
    _exception: 'Exception | None' = None
    _stderr_output: 'str | None' = None
    _returncode: 'int | None' = None
    _transport_disconnected: bool = False
    _closed: bool = False

    @classmethod
    def spawn(
        cls: 'type[typing.Self]',
        protocol_factory: Callable[[], P],
        args: Sequence[str],
        loop: 'asyncio.AbstractEventLoop | None' = None,
        interaction_handlers: Sequence[InteractionHandler] = (),
        is_ssh: bool = True,
        **kwargs: Any
    ) -> 'tuple[typing.Self, P]':
        """Connects a FernyTransport to a protocol, using the given command.

        This spawns an external command and connects the stdin and stdout of
        the command to the protocol returned by the factory.

        An instance of ferny.InteractionAgent is created and attached to the
        stderr of the spawned process, using the provided handlers.  It is the
        responsibility of the caller to ensure that:
          - a `ferny-askpass` client program is installed somewhere; and
          - any relevant command-line arguments or environment variables are
            passed correctly to the program to be spawned

        This function returns immediately and never raises exceptions, assuming
        all preconditions are met.

        If spawning the process fails then connection_lost() will be
        called with the relevant OSError, even before connection_made() is
        called.  This is somewhat non-standard behaviour, but is the easiest
        way to report these errors without making this function async.

        Once the process is successfully executed, connection_made() will be
        called and the transport can be used as normal.  connection_lost() will
        be called if the process exits or another error occurs.

        The return value of this function is the transport, but it exists in a
        semi-initialized state.  You can call .close() on it, but nothing else.
        Once .connection_made() is called, you can call all the other
        functions.

        After you call this function, `.connection_lost()` will be called on
        your Protocol, exactly once, no matter what.  Until that happens, you
        are responsible for holding a reference to the returned transport.

        :param args: the full argv of the command to spawn
        :param loop: the event loop to use.  If none is provided, we use the
            one which is (read: must be) currently running.
        :param interaction_handlers: the handlers passed to the
            InteractionAgent
        :param is_ssh: whether we should attempt to interpret stderr as ssh
            error messages
        :param kwargs: anything else is passed through to `subprocess_exec()`
        :returns: the usual `(Transport, Protocol)` pair
        """
        logger.debug('spawn(%r, %r, %r)', cls, protocol_factory, args)

        protocol = protocol_factory()
        self = cls(protocol)
        self._is_ssh = is_ssh

        if loop is None:
            loop = get_running_loop()

        self._agent = InteractionAgent(interaction_handlers, loop, self._interaction_completed)
        kwargs.setdefault('stderr', self._agent.fileno())

        # As of Python 3.12 this isn't really asynchronous (since it uses the
        # subprocess module, which blocks while waiting for the exec() to
        # complete in the child), but we have to deal with the complication of
        # the async interface anyway.  Since we, ourselves, want to export a
        # non-async interface, that means that we need a task here and a
        # bottom-half handler below.
        self._exec_task = loop.create_task(loop.subprocess_exec(lambda: self, *args, **kwargs))

        def exec_completed(task: "asyncio.Task[tuple[asyncio.SubprocessTransport, typing.Self]]") -> None:
            logger.debug('exec_completed(%r, %r)', self, task)
            assert task is self._exec_task
            try:
                transport, me = task.result()
                assert me is self
                logger.debug('  success.')
            except asyncio.CancelledError:
                return  # in that case, do nothing
            except OSError as exc:
                logger.debug('  OSError %r', exc)
                self.close(exc)
                return

            # Our own .connection_made() handler should have gotten called by
            # now.  Make sure everything got filled in properly.
            assert self._subprocess_transport is transport
            assert self._stdin_transport is not None
            assert self._stdout_transport is not None

            # Ask the InteractionAgent to start processing stderr.
            self._agent.start()

        self._exec_task.add_done_callback(exec_completed)

        return self, protocol

    def __init__(self, protocol: asyncio.Protocol) -> None:
        self._protocol = protocol

    def _consider_disconnect(self) -> None:
        logger.debug('_consider_disconnect(%r)', self)
        # We cannot disconnect as long as any of these three things are happening
        if not self._exec_task.done():
            logger.debug('  exec_task still running %r', self._exec_task)
            return

        if self._subprocess_transport is not None and not self._transport_disconnected:
            logger.debug('  transport still connected %r', self._subprocess_transport)
            return

        if self._stderr_output is None:
            logger.debug('  agent still running')
            return

        # All conditions for disconnection are satisfied.
        if self._protocol_disconnected:
            logger.debug('  already disconnected')
            return
        self._protocol_disconnected = True

        # Now we just need to determine what we report to the protocol...
        if self._exception is not None:
            # If we got an exception reported, that's our reason for closing.
            logger.debug('  disconnect with exception %r', self._exception)
            self._protocol.connection_lost(self._exception)
        elif self._returncode == 0 or self._closed:
            # If we called close() or have a zero return status, that's a clean
            # exit, regardless of noise that might have landed in stderr.
            logger.debug('  clean disconnect')
            self._protocol.connection_lost(None)
        elif self._is_ssh and self._returncode == 255:
            # This is an error code due to an SSH failure.  Try to interpret it.
            logger.debug('  disconnect with ssh error %r', self._stderr_output)
            self._protocol.connection_lost(get_exception_for_ssh_stderr(self._stderr_output))
        else:
            # Otherwise, report the stderr text and return code.
            logger.debug('  disconnect with exit code %r, stderr %r', self._returncode, self._stderr_output)
            # We surely have _returncode set here, since otherwise:
            #  - exec_task failed with an exception (which we handle above); or
            #  - we're still connected...
            assert self._returncode is not None
            self._protocol.connection_lost(SubprocessError(self._returncode, self._stderr_output))

    def _interaction_completed(self, future: 'asyncio.Future[str]') -> None:
        logger.debug('_interaction_completed(%r, %r)', self, future)
        try:
            self._stderr_output = future.result()
            logger.debug('  stderr: %r', self._stderr_output)
        except Exception as exc:
            logger.debug('  exception: %r', exc)
            self._stderr_output = ''  # we need to set this in order to complete
            self.close(exc)

        self._consider_disconnect()

    # BaseProtocol implementation
    def connection_made(self, transport: asyncio.BaseTransport) -> None:
        logger.debug('connection_made(%r, %r)', self, transport)
        assert isinstance(transport, asyncio.SubprocessTransport)
        self._subprocess_transport = transport

        stdin_transport = transport.get_pipe_transport(0)
        assert isinstance(stdin_transport, asyncio.WriteTransport)
        self._stdin_transport = stdin_transport

        stdout_transport = transport.get_pipe_transport(1)
        assert isinstance(stdout_transport, asyncio.ReadTransport)
        self._stdout_transport = stdout_transport

        stderr_transport = transport.get_pipe_transport(2)
        assert stderr_transport is None

        logger.debug('calling connection_made(%r, %r)', self, self._protocol)
        self._protocol.connection_made(self)

    def connection_lost(self, exc: 'Exception | None') -> None:
        logger.debug('connection_lost(%r, %r)', self, exc)
        if self._exception is None:
            self._exception = exc
        self._transport_disconnected = True
        self._consider_disconnect()

    # SubprocessProtocol implementation
    def pipe_data_received(self, fd: int, data: bytes) -> None:
        logger.debug('pipe_data_received(%r, %r, %r)', self, fd, len(data))
        assert fd == 1  # stderr is handled separately
        self._protocol.data_received(data)

    def pipe_connection_lost(self, fd: int, exc: 'Exception | None') -> None:
        logger.debug('pipe_connection_lost(%r, %r, %r)', self, fd, exc)
        assert fd in (0, 1)  # stderr is handled separately

        # We treat this as a clean close
        if isinstance(exc, BrokenPipeError):
            exc = None

        # Record serious errors to propagate them to the protocol
        # If this is a clean exit on stdout, report an EOF
        if exc is not None:
            self.close(exc)
        elif fd == 1 and not self._closed:
            if not self._protocol.eof_received():
                self.close()

    def process_exited(self) -> None:
        logger.debug('process_exited(%r)', self)
        assert self._subprocess_transport is not None
        self._returncode = self._subprocess_transport.get_returncode()
        logger.debug('  ._returncode = %r', self._returncode)
        self._agent.force_completion()

    def pause_writing(self) -> None:
        logger.debug('pause_writing(%r)', self)
        self._protocol.pause_writing()

    def resume_writing(self) -> None:
        logger.debug('resume_writing(%r)', self)
        self._protocol.resume_writing()

    # Transport implementation.  Most of this is straight delegation.
    def close(self, exc: 'Exception | None' = None) -> None:
        logger.debug('close(%r, %r)', self, exc)
        self._closed = True
        if self._exception is None:
            logger.debug('  setting exception %r', exc)
            self._exception = exc
        if not self._exec_task.done():
            logger.debug('  cancelling _exec_task')
            self._exec_task.cancel()
        if self._subprocess_transport is not None:
            logger.debug('  closing _subprocess_transport')
            # https://github.com/python/cpython/issues/112800
            with contextlib.suppress(PermissionError):
                self._subprocess_transport.close()
        self._agent.force_completion()

    def is_closing(self) -> bool:
        assert self._subprocess_transport is not None
        return self._subprocess_transport.is_closing()

    def get_extra_info(self, name: str, default: object = None) -> object:
        assert self._subprocess_transport is not None
        return self._subprocess_transport.get_extra_info(name, default)

    def set_protocol(self, protocol: asyncio.BaseProtocol) -> None:
        assert isinstance(protocol, asyncio.Protocol)
        self._protocol = protocol

    def get_protocol(self) -> asyncio.Protocol:
        return self._protocol

    def is_reading(self) -> bool:
        assert self._stdout_transport is not None
        try:
            return self._stdout_transport.is_reading()
        except NotImplementedError:
            # This is (incorrectly) unimplemented before Python 3.11
            return not self._stdout_transport._paused  # type:ignore[attr-defined]
        except AttributeError:
            # ...and in Python 3.6 it's even worse
            try:
                selector = self._stdout_transport._loop._selector  # type:ignore[attr-defined]
                selector.get_key(self._stdout_transport._fileno)  # type:ignore[attr-defined]
                return True
            except KeyError:
                return False

    def pause_reading(self) -> None:
        assert self._stdout_transport is not None
        self._stdout_transport.pause_reading()

    def resume_reading(self) -> None:
        assert self._stdout_transport is not None
        self._stdout_transport.resume_reading()

    def abort(self) -> None:
        assert self._stdin_transport is not None
        assert self._subprocess_transport is not None
        self._stdin_transport.abort()
        self._subprocess_transport.kill()

    def can_write_eof(self) -> bool:
        assert self._stdin_transport is not None
        return self._stdin_transport.can_write_eof()  # will always be True

    def get_write_buffer_size(self) -> int:
        assert self._stdin_transport is not None
        return self._stdin_transport.get_write_buffer_size()

    def get_write_buffer_limits(self) -> 'tuple[int, int]':
        assert self._stdin_transport is not None
        return self._stdin_transport.get_write_buffer_limits()

    def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None:
        assert self._stdin_transport is not None
        return self._stdin_transport.set_write_buffer_limits(high, low)

    def write(self, data: 'bytes | bytearray | memoryview') -> None:
        assert self._stdin_transport is not None
        return self._stdin_transport.write(data)

    def writelines(self, list_of_data: 'Iterable[bytes | bytearray | memoryview]') -> None:
        assert self._stdin_transport is not None
        return self._stdin_transport.writelines(list_of_data)

    def write_eof(self) -> None:
        assert self._stdin_transport is not None
        return self._stdin_transport.write_eof()

    # We don't really implement SubprocessTransport, but provide these as
    # "extras" to our user.
    def get_pid(self) -> int:
        assert self._subprocess_transport is not None
        return self._subprocess_transport.get_pid()

    def get_returncode(self) -> 'int | None':
        return self._returncode

    def kill(self) -> None:
        assert self._subprocess_transport is not None
        self._subprocess_transport.kill()

    def send_signal(self, number: int) -> None:
        assert self._subprocess_transport is not None
        self._subprocess_transport.send_signal(number)

    def terminate(self) -> None:
        assert self._subprocess_transport is not None
        self._subprocess_transport.terminate()
''',
  'cockpit/_vendor/systemd_ctypes/__init__.py': br'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""systemd_ctypes"""

__version__ = "0"

from .bus import Bus, BusError, BusMessage
from .bustypes import BusType, JSONEncoder, Variant
from .event import Event, EventLoopPolicy, run_async
from .handle import Handle
from .pathwatch import PathWatch

__all__ = [
    "Bus",
    "BusError",
    "BusMessage",
    "BusType",
    "Event",
    "EventLoopPolicy",
    "Handle",
    "JSONEncoder",
    "PathWatch",
    "Variant",
    "run_async",
]
''',
  'cockpit/_vendor/systemd_ctypes/bus.py': br'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import asyncio
import enum
import logging
import typing
from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union

from . import bustypes, introspection, libsystemd
from .librarywrapper import WeakReference, byref

logger = logging.getLogger(__name__)

# Set of active async tasks to prevent garbage collection before completion
_active_tasks: 'set[asyncio.Task]' = set()


class BusError(Exception):
    """An exception corresponding to a D-Bus error message

    This exception is raised by the method call methods.  You can also raise it
    from your own method handlers.  It can also be passed directly to functions
    such as Message.reply_method_error().

    :name: the 'code' of the error, like org.freedesktop.DBus.Error.UnknownMethod
    :message: a human-readable description of the error
    """
    def __init__(self, name: str, message: str):
        super().__init__(f'{name}: {message}')
        self.name = name
        self.message = message


class BusMessage(libsystemd.sd_bus_message):
    """A message, received from or to be sent over D-Bus

    This is the low-level interface to receiving and sending individual
    messages over D-Bus.  You won't normally need to use it.

    A message is associated with a particular bus.  You can create messages for
    a bus with Bus.message_new_method_call() or Bus.message_new_signal().  You
    can create replies to method calls with Message.new_method_return() or
    Message.new_method_error().  You can append arguments with Message.append()
    and send the message with Message.send().
    """
    def get_bus(self) -> 'Bus':
        """Get the bus that a message is associated with.

        This is the bus that a message came from or will be sent on.  Every
        message has an associated bus, and it cannot be changed.

        :returns: the Bus
        """
        return Bus.ref(self._get_bus())

    def get_error(self) -> Optional[BusError]:
        """Get the BusError from a message.

        :returns: a BusError for an error message, or None for a non-error message
        """
        error = self._get_error()
        if error:
            return BusError(*error.contents.get())
        else:
            return None

    def new_method_return(self, signature: str = '', *args: Any) -> 'BusMessage':
        """Create a new (successful) return message as a reply to this message.

        This only makes sense when performed on a method call message.

        :signature: The signature of the result, as a string.
        :args: The values to send, conforming to the signature string.

        :returns: the reply message
        """
        reply = BusMessage()
        self._new_method_return(byref(reply))
        reply.append(signature, *args)
        return reply

    def new_method_error(self, error: Union[BusError, OSError]) -> 'BusMessage':
        """Create a new error message as a reply to this message.

        This only makes sense when performed on a method call message.

        :error: BusError or OSError of the error to send

        :returns: the reply message
        """
        reply = BusMessage()
        if isinstance(error, BusError):
            self._new_method_errorf(byref(reply), error.name, "%s", error.message)
        else:
            assert isinstance(error, OSError)
            assert isinstance(error.errno, int), 'OSError must have an integer errno'
            self._new_method_errnof(byref(reply), error.errno, "%s", str(error))
        return reply

    def append_arg(self, typestring: str, arg: Any) -> None:
        """Append a single argument to the message.

        :typestring: a single typestring, such as 's', or 'a{sv}'
        :arg: the argument to append, matching the typestring
        """
        type_, = bustypes.from_signature(typestring)
        type_.writer(self, arg)

    def append(self, signature: str, *args: Any) -> None:
        """Append zero or more arguments to the message.

        :signature: concatenated typestrings, such 'a{sv}' (one arg), or 'ss' (two args)
        :args: one argument for each type string in the signature
        """
        types = bustypes.from_signature(signature)
        assert len(types) == len(args), f'call args {args} have different length than signature {signature}'
        for type_, arg in zip(types, args):
            type_.writer(self, arg)

    def get_body(self) -> Tuple[object, ...]:
        """Gets the body of a message.

        Possible return values are (), ('single',), or ('x', 'y').  If you
        check the signature of the message using Message.has_signature() then
        you can use tuple unpacking.

           single, = message.get_body()

           x, y = other_message.get_body()

        :returns: an n-tuple containing one value per argument in the message
        """
        self.rewind(True)
        types = bustypes.from_signature(self.get_signature(True))
        return tuple(type_.reader(self) for type_ in types)

    def send(self) -> bool:  # Literal[True]
        """Sends a message on the bus that it was created for.

        :returns: True
        """
        self.get_bus().send(self, None)
        return True

    def reply_method_error(self, error: Union[BusError, OSError]) -> bool:  # Literal[True]
        """Sends an error as a reply to a method call message.

        :error: A BusError or OSError

        :returns: True
        """
        return self.new_method_error(error).send()

    def reply_method_return(self, signature: str = '', *args: Any) -> bool:  # Literal[True]
        """Sends a return value as a reply to a method call message.

        :signature: The signature of the result, as a string.
        :args: The values to send, conforming to the signature string.

        :returns: True
        """
        return self.new_method_return(signature, *args).send()

    def _coroutine_task_complete(self, out_type: bustypes.MessageType, task: asyncio.Task) -> None:
        try:
            self.reply_method_function_return_value(out_type, task.result())
        except (BusError, OSError) as exc:
            self.reply_method_error(exc)

    def reply_method_function_return_value(self,
                                           out_type: bustypes.MessageType,
                                           return_value: Any) -> bool:  # Literal[True]:
        """Sends the result of a function call as a reply to a method call message.

        This call does a bit of magic: it adapts from the usual Python return
        value conventions (where the return value is ``None``, a single value,
        or a tuple) to the normal D-Bus return value conventions (where the
        result is always a tuple).

        Additionally, if the value is found to be a coroutine, a task is
        created to run the coroutine to completion and return the result
        (including exception handling).

        :out_types: The types of the return values, as an iterable.
        :return_value: The return value of a Python function call.

        :returns: True
        """
        if asyncio.coroutines.iscoroutine(return_value):
            task = asyncio.create_task(return_value)

            # Keep task alive to prevent garbage collection
            _active_tasks.add(task)

            def done_callback(task: asyncio.Task) -> None:
                _active_tasks.discard(task)
                self._coroutine_task_complete(out_type, task)

            task.add_done_callback(done_callback)
            return True

        reply = self.new_method_return()
        # In the general case, a function returns an n-tuple, but...
        if len(out_type) == 0:
            # Functions with no return value return None.
            assert return_value is None
        elif len(out_type) == 1:
            # Functions with a single return value return that value.
            out_type.write(reply, return_value)
        else:
            # (general case) n return values are handled as an n-tuple.
            assert len(out_type) == len(return_value)
            out_type.write(reply, *return_value)
        return reply.send()


class Slot(libsystemd.sd_bus_slot):
    def __init__(self, callback: Callable[[BusMessage], bool]):
        def handler(message: WeakReference, _data: object, _err: object) -> int:
            return 1 if callback(BusMessage.ref(message)) else 0
        self.trampoline = libsystemd.sd_bus_message_handler_t(handler)


if typing.TYPE_CHECKING:
    FutureMessage = asyncio.Future[BusMessage]
else:
    # Python 3.6 can't subscript asyncio.Future
    FutureMessage = asyncio.Future


class PendingCall(Slot):
    future: FutureMessage

    def __init__(self) -> None:
        future = asyncio.get_running_loop().create_future()

        def done(message: BusMessage) -> bool:
            error = message.get_error()
            if future.cancelled():
                return True
            if error is not None:
                future.set_exception(error)
            else:
                future.set_result(message)
            return True

        super().__init__(done)
        self.future = future


class Bus(libsystemd.sd_bus):
    _default_system_instance = None
    _default_user_instance = None

    class NameFlags(enum.IntFlag):
        DEFAULT = 0
        REPLACE_EXISTING = 1 << 0
        ALLOW_REPLACEMENT = 1 << 1
        QUEUE = 1 << 2

    @staticmethod
    def new(
            fd: Optional[int] = None,
            address: Optional[str] = None,
            bus_client: bool = False,
            server: bool = False,
            start: bool = True,
            attach_event: bool = True
    ) -> 'Bus':
        bus = Bus()
        Bus._new(byref(bus))
        if address is not None:
            bus.set_address(address)
        if fd is not None:
            bus.set_fd(fd, fd)
        if bus_client:
            bus.set_bus_client(True)
        if server:
            bus.set_server(True, libsystemd.sd_id128())
        if address is not None or fd is not None:
            if start:
                bus.start()
            if attach_event:
                bus.attach_event(None, 0)
        return bus

    @staticmethod
    def default_system(attach_event: bool = True) -> 'Bus':
        if Bus._default_system_instance is None:
            Bus._default_system_instance = Bus()
            Bus._default_system(byref(Bus._default_system_instance))
            if attach_event:
                Bus._default_system_instance.attach_event(None, 0)
        return Bus._default_system_instance

    @staticmethod
    def default_user(attach_event: bool = True) -> 'Bus':
        if Bus._default_user_instance is None:
            Bus._default_user_instance = Bus()
            Bus._default_user(byref(Bus._default_user_instance))
            if attach_event:
                Bus._default_user_instance.attach_event(None, 0)
        return Bus._default_user_instance

    def message_new_method_call(
            self,
            destination: Optional[str],
            path: str,
            interface: str,
            member: str,
            types: str = '',
            *args: object
    ) -> BusMessage:
        message = BusMessage()
        self._message_new_method_call(byref(message), destination, path, interface, member)
        message.append(types, *args)
        return message

    def message_new_signal(
        self, path: str, interface: str, member: str, types: str = '', *args: object
    ) -> BusMessage:
        message = BusMessage()
        self._message_new_signal(byref(message), path, interface, member)
        message.append(types, *args)
        return message

    def call(self, message: BusMessage, timeout: Optional[int] = None) -> BusMessage:
        reply = BusMessage()
        error = libsystemd.sd_bus_error()
        try:
            self._call(message, timeout or 0, byref(error), byref(reply))
            return reply
        except OSError as exc:
            raise BusError(*error.get()) from exc

    def call_method(
            self,
            destination: str,
            path: str,
            interface: str,
            member: str,
            types: str = '',
            *args: object,
            timeout: Optional[int] = None
    ) -> Tuple[object, ...]:
        logger.debug('Doing sync method call %s %s %s %s %s %s',
                     destination, path, interface, member, types, args)
        message = self.message_new_method_call(destination, path, interface, member, types, *args)
        message = self.call(message, timeout)
        return message.get_body()

    async def call_async(
            self,
            message: BusMessage,
            timeout: Optional[int] = None
    ) -> BusMessage:
        pending = PendingCall()
        self._call_async(byref(pending), message, pending.trampoline, pending.userdata, timeout or 0)
        return await pending.future

    async def call_method_async(
        self,
        destination: Optional[str],
        path: str,
        interface: str,
        member: str,
        types: str = '',
        *args: object,
        timeout: Optional[int] = None
    ) -> Tuple[object, ...]:
        logger.debug('Doing async method call %s %s %s %s %s %s',
                     destination, path, interface, member, types, args)
        message = self.message_new_method_call(destination, path, interface, member, types, *args)
        message = await self.call_async(message, timeout)
        return message.get_body()

    def add_match(self, rule: str, handler: Callable[[BusMessage], bool]) -> Slot:
        slot = Slot(handler)
        self._add_match(byref(slot), rule, slot.trampoline, slot.userdata)
        return slot

    def add_object(self, path: str, obj: 'BaseObject') -> Slot:
        slot = Slot(obj.message_received)
        self._add_object(byref(slot), path, slot.trampoline, slot.userdata)
        obj.registered_on_bus(self, path)
        return slot


class BaseObject:
    """Base object type for exporting objects on the bus

    This is the lowest-level class that can be passed to Bus.add_object().

    If you want to directly subclass this, you'll need to implement
    `message_received()`.

    Subclassing from `bus.Object` is probably a better choice.
    """
    _dbus_bus: Optional[Bus] = None
    _dbus_path: Optional[str] = None

    def registered_on_bus(self, bus: Bus, path: str) -> None:
        """Report that an instance was exported on a given bus and path.

        This is used so that the instance knows where to send signals.
        Bus.add_object() calls this: you probably shouldn't call this on your
        own.
        """
        self._dbus_bus = bus
        self._dbus_path = path

        self.registered()

    def registered(self) -> None:
        """Called after an object has been registered on the bus

        This is the correct method to implement to do some initial work that
        needs to be done after registration.  The default implementation does
        nothing.
        """
        pass

    def emit_signal(
            self, interface: str, name: str, signature: str, *args: Any
    ) -> bool:
        """Emit a D-Bus signal on this object

        The object must have been exported on the bus with Bus.add_object().

        :interface: the interface of the signal
        :name: the 'member' name of the signal to emit
        :signature: the type signature, as a string
        :args: the arguments, according to the signature
        :returns: True
        """
        assert self._dbus_bus is not None
        assert self._dbus_path is not None
        return self._dbus_bus.message_new_signal(self._dbus_path, interface, name, signature, *args).send()

    def message_received(self, message: BusMessage) -> bool:
        """Called when a message is received for this object

        This is the lowest level interface to the BaseObject.  You need to
        handle method calls, properties, and introspection.

        You are expected to handle the message and return True.  Normally this
        means that you send a reply.  If you don't want to handle the message,
        return False and other handlers will have a chance to run.  If no
        handler handles the message, systemd will generate a suitable error
        message and send that, instead.

        :message: the message that was received
        :returns: True if the message was handled
        """
        raise NotImplementedError


class Interface:
    """The high-level base class for defining D-Bus interfaces

    This class provides high-level APIs for defining methods, properties, and
    signals, as well as implementing introspection.

    On its own, this class doesn't provide a mechanism for exporting anything
    on the bus.  The Object class does that, and you'll generally want to
    subclass from it, as it contains several built-in standard interfaces
    (introspection, properties, etc.).

    The name of your class will be interpreted as a D-Bus interface name.
    Underscores are converted to dots.  No case conversion is performed.  If
    the interface name can't be represented using this scheme, or if you'd like
    to name your class differently, you can provide an interface= kwarg to the
    class definition.

        class com_example_Interface(bus.Object):
            pass

        class MyInterface(bus.Object, interface='org.cockpit_project.Interface'):
            pass

    The methods, properties, and signals which are visible from D-Bus are
    defined using helper classes with the corresponding names (Method,
    Property, Signal).  You should use normal Python snake_case conventions for
    the member names: they will automatically be converted to CamelCase by
    splitting on underscore and converting the first letter of each resulting
    word to uppercase.  For example, `method_name` becomes `MethodName`.

    Each Method, Property, or Signal constructor takes an optional name= kwargs
    to override the automatic name conversion convention above.

    An example class might look like:

        class com_example_MyObject(bus.Object):
            created = bus.Interface.Signal('s', 'i')
            renames = bus.Interface.Property('u', value=0)
            name = bus.Interface.Property('s', 'undefined')

            @bus.Interface.Method(out_types=(), in_types='s')
            def rename(self, name):
                self.renames += 1
                self.name = name

            def registered(self):
                self.created('Hello', 42)

    See the documentation for the Method, Property, and Signal classes for
    more information and examples.
    """

    # Class variables
    _dbus_interfaces: Dict[str, Dict[str, Dict[str, Any]]]
    _dbus_members: Optional[Tuple[str, Dict[str, Dict[str, Any]]]]

    # Instance variables: stored in Python form
    _dbus_property_values: Optional[Dict[str, Any]] = None

    @classmethod
    def __init_subclass__(cls, interface: Optional[str] = None) -> None:
        if interface is None:
            assert '__' not in cls.__name__, 'Class name cannot contain sequential underscores'
            interface = cls.__name__.replace('_', '.')

        # This is the information for this subclass directly
        members: Dict[str, Dict[str, Interface._Member]] = {'methods': {}, 'properties': {}, 'signals': {}}
        for name, member in cls.__dict__.items():
            if isinstance(member, Interface._Member):
                member.setup(interface, name, members)

        # We only store the information if something was actually defined
        if sum(len(category) for category in members.values()) > 0:
            cls._dbus_members = (interface, members)

        # This is the information for this subclass, with all its ancestors
        cls._dbus_interfaces = dict(ancestor.__dict__['_dbus_members']
                                    for ancestor in cls.mro()
                                    if '_dbus_members' in ancestor.__dict__)

    @classmethod
    def _find_interface(cls, interface: str) -> Dict[str, Dict[str, '_Member']]:
        try:
            return cls._dbus_interfaces[interface]
        except KeyError as exc:
            raise Object.Method.Unhandled from exc

    @classmethod
    def _find_category(cls, interface: str, category: str) -> Dict[str, '_Member']:
        return cls._find_interface(interface)[category]

    @classmethod
    def _find_member(cls, interface: str, category: str, member: str) -> '_Member':
        members = cls._find_category(interface, category)
        try:
            return members[member]
        except KeyError as exc:
            raise Object.Method.Unhandled from exc

    class _Member:
        _category: str  # filled in from subclasses

        _python_name: Optional[str] = None
        _name: Optional[str] = None
        _interface: Optional[str] = None
        _description: Optional[Dict[str, Any]]

        def __init__(self, name: Optional[str] = None) -> None:
            self._python_name = None
            self._interface = None
            self._name = name

        def setup(self, interface: str, name: str, members: Dict[str, Dict[str, 'Interface._Member']]) -> None:
            self._python_name = name  # for error messages
            if self._name is None:
                self._name = ''.join(word.title() for word in name.split('_'))
            self._interface = interface
            self._description = self._describe()
            members[self._category][self._name] = self

        def _describe(self) -> Dict[str, Any]:
            raise NotImplementedError

        def __getitem__(self, key: str) -> Any:
            # Acts as an adaptor for dict accesses from introspection.to_xml()
            assert self._description is not None
            return self._description[key]

    class Property(_Member):
        """Defines a D-Bus property on an interface

        There are two main ways to define properties: with and without getters.
        If you define a property without a getter, then you must provide a
        value (via the value= kwarg).  In this case, the property value is
        maintained internally and can be accessed from Python in the usual way.
        Change signals are sent automatically.

            class MyObject(bus.Object):
                counter = bus.Interface.Property('i', value=0)

            a = MyObject()
            a.counter = 5
            a.counter += 1
            print(a.counter)

        The other way to define properties is with a getter function.  In this
        case, you can read from the property in the normal way, but not write
        to it.  You are responsible for emitting change signals for yourself.
        You must not provide the value= kwarg.

            class MyObject(bus.Object):
                _counter = 0

                counter = bus.Interface.Property('i')
                @counter.getter
                def get_counter(self):
                    return self._counter

                @counter.setter
                def set_counter(self, value):
                    self._counter = value
                    self.property_changed('Counter')

        In either case, you can provide a setter function.  This function has
        no impact on Python code, but makes the property writable from the view
        of D-Bus.  Your setter will be called when a Properties.Set() call is
        made, and no other action will be performed.  A trivial implementation
        might look like:

            class MyObject(bus.Object):
                counter = bus.Interface.Property('i', value=0)
                @counter.setter
                def set_counter(self, value):
                    # we got a request to set the counter from D-Bus
                    self.counter = value

        In all cases, the first (and only mandatory) argument to the
        constructor is the D-Bus type of the property.

        Your getter and setter functions can be provided by kwarg to the
        constructor.  You can also give a name= kwarg to override the default
        name conversion scheme.
        """
        _category = 'properties'

        _getter: Optional[Callable[[Any], Any]]
        _setter: Optional[Callable[[Any, Any], None]]
        _type: bustypes.Type
        _value: Any

        def __init__(self, type_string: str,
                     value: Any = None,
                     name: Optional[str] = None,
                     getter: Optional[Callable[[Any], Any]] = None,
                     setter: Optional[Callable[[Any, Any], None]] = None):
            assert value is None or getter is None, 'A property cannot have both a value and a getter'

            super().__init__(name=name)
            self._getter = getter
            self._setter = setter
            self._type, = bustypes.from_signature(type_string)
            self._value = value

        def _describe(self) -> Dict[str, Any]:
            return {'type': self._type.typestring, 'flags': 'r' if self._setter is None else 'w'}

        def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Any:
            assert self._name is not None
            if obj is None:
                return self
            if self._getter is not None:
                return self._getter.__get__(obj, cls)()
            elif self._value is not None:
                if obj._dbus_property_values is not None:
                    return obj._dbus_property_values.get(self._name, self._value)
                else:
                    return self._value
            else:
                raise AttributeError(f"'{obj.__class__.__name__}' property '{self._python_name}' "
                                     f"was not properly initialised: use either the 'value=' kwarg or "
                                     f"the @'{self._python_name}.getter' decorator")

        def __set__(self, obj: 'Object', value: Any) -> None:
            assert self._name is not None
            if self._getter is not None:
                raise AttributeError(f"Cannot directly assign '{obj.__class__.__name__}' "
                                     "property '{self._python_name}' because it has a getter")
            if obj._dbus_property_values is None:
                obj._dbus_property_values = {}
            obj._dbus_property_values[self._name] = value
            if obj._dbus_bus is not None:
                obj.properties_changed(self._interface, {self._name: bustypes.Variant(value, self._type)}, [])

        def to_dbus(self, obj: 'Object') -> bustypes.Variant:
            return bustypes.Variant(self.__get__(obj), self._type)

        def from_dbus(self, obj: 'Object', value: bustypes.Variant) -> None:
            if self._setter is None or self._type != value.type:
                raise Object.Method.Unhandled
            self._setter.__get__(obj)(value.value)

        def getter(self, getter: Callable[[Any], Any]) -> Callable[[Any], Any]:
            if self._value is not None:
                raise ValueError('A property cannot have both a value and a getter')
            if self._getter is not None:
                raise ValueError('This property already has a getter')
            self._getter = getter
            return getter

        def setter(self, setter: Callable[[Any, Any], None]) -> Callable[[Any, Any], None]:
            self._setter = setter
            return setter

    class Signal(_Member):
        """Defines a D-Bus signal on an interface

        This is a callable which will result in the signal being emitted.

        The constructor takes the types of the arguments, each one as a
        separate parameter.  For example:

            properties_changed = Interface.Signal('s', 'a{sv}', 'as')

        You can give a name= kwarg to override the default name conversion
        scheme.
        """
        _category = 'signals'
        _type: bustypes.MessageType

        def __init__(self, *out_types: str, name: Optional[str] = None) -> None:
            super().__init__(name=name)
            self._type = bustypes.MessageType(out_types)

        def _describe(self) -> Dict[str, Any]:
            return {'in': self._type.typestrings}

        def __get__(self, obj: 'Object', cls: Optional[type] = None) -> Callable[..., None]:
            def emitter(obj: Object, *args: Any) -> None:
                assert self._interface is not None
                assert self._name is not None
                assert obj._dbus_bus is not None
                assert obj._dbus_path is not None
                message = obj._dbus_bus.message_new_signal(obj._dbus_path, self._interface, self._name)
                self._type.write(message, *args)
                message.send()
            return emitter.__get__(obj, cls)

    class Method(_Member):
        """Defines a D-Bus method on an interface

        This is a function decorator which marks a given method for export.

        The constructor takes two arguments: the type of the output arguments,
        and the type of the input arguments.  Both should be given as a
        sequence.

            @Interface.Method(['a{sv}'], ['s'])
            def get_all(self, interface):
                ...

        You can give a name= kwarg to override the default name conversion
        scheme.
        """
        _category = 'methods'

        class Unhandled(Exception):
            """Raised by a method to indicate that the message triggering that
            method call remains unhandled."""
            pass

        def __init__(self, out_types: Sequence[str] = (), in_types: Sequence[str] = (), name: Optional[str] = None):
            super().__init__(name=name)
            self._out_type = bustypes.MessageType(out_types)
            self._in_type = bustypes.MessageType(in_types)
            self._func = None

        def __get__(self, obj, cls=None):
            return self._func.__get__(obj, cls)

        def __call__(self, *args, **kwargs):
            # decorator
            self._func, = args
            return self

        def _describe(self) -> Dict[str, Any]:
            return {'in': [item.typestring for item in self._in_type.item_types],
                    'out': [item.typestring for item in self._out_type.item_types]}

        def _invoke(self, obj, message):
            args = self._in_type.read(message)
            if args is None:
                return False
            try:
                result = self._func.__get__(obj)(*args)
            except (BusError, OSError) as error:
                return message.reply_method_error(error)

            return message.reply_method_function_return_value(self._out_type, result)


class org_freedesktop_DBus_Peer(Interface):
    @Interface.Method()
    @staticmethod
    def ping() -> None:
        pass

    @Interface.Method('s')
    @staticmethod
    def get_machine_id() -> str:
        with open('/etc/machine-id', encoding='ascii') as file:
            return file.read().strip()


class org_freedesktop_DBus_Introspectable(Interface):
    @Interface.Method('s')
    @classmethod
    def introspect(cls) -> str:
        return introspection.to_xml(cls._dbus_interfaces)


class org_freedesktop_DBus_Properties(Interface):
    properties_changed = Interface.Signal('s', 'a{sv}', 'as')

    @Interface.Method('v', 'ss')
    def get(self, interface, name):
        return self._find_member(interface, 'properties', name).to_dbus(self)

    @Interface.Method(['a{sv}'], 's')
    def get_all(self, interface):
        properties = self._find_category(interface, 'properties')
        return {name: prop.to_dbus(self) for name, prop in properties.items()}

    @Interface.Method('', 'ssv')
    def set(self, interface, name, value):
        self._find_member(interface, 'properties', name).from_dbus(self, value)


class Object(org_freedesktop_DBus_Introspectable,
             org_freedesktop_DBus_Peer,
             org_freedesktop_DBus_Properties,
             BaseObject,
             Interface):
    """High-level base class for exporting objects on D-Bus

    This is usually where you should start.

    This provides a base for exporting objects on the bus, implements the
    standard D-Bus interfaces, and allows you to add your own interfaces to the
    mix.  See the documentation for Interface to find out how to define and
    implement your D-Bus interface.
    """
    def message_received(self, message: BusMessage) -> bool:
        interface = message.get_interface()
        name = message.get_member()

        try:
            method = self._find_member(interface, 'methods', name)
            assert isinstance(method, Interface.Method)
            return method._invoke(self, message)
        except Object.Method.Unhandled:
            return False
''',
  'cockpit/_vendor/systemd_ctypes/bustypes.py': br'''# systemd_ctypes
#
# Copyright (C) 2023 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


# This file is responsible for creating functions to (de)serialize Python
# objects into and out of BusMessage objects.
#
# Each Type corresponds to a (possibly complex) D-Bus type.  It has a .reader
# and a .writer property.  The readers take a message and deserialize a single
# value from it, returning the value:
#
#   def reader(message: BusMessage) -> object:
#
# The writers take a message and a value, and append the value to the message.
#
#   def writer(message: BusMessage, value: object) -> None:
#
# The necessary information for the specific type of object to be handled is
# part of the function.  No additional information needs to be provided.

import binascii
import ctypes
import functools
import inspect
import json
import os
import re
from enum import Enum
from typing import Any, Callable, ClassVar, Dict, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union

from . import libsystemd, typing
from .handle import Handle
from .typing import Annotated, TypeGuard

_object_path_re = re.compile(r'/|(/[A-Za-z0-9_]+)+')


def is_object_path(candidate: str) -> TypeGuard['BusType.objectpath']:
    return _object_path_re.fullmatch(candidate) is not None


def is_signature(candidate: str) -> TypeGuard['BusType.signature']:
    offset = 0

    def maybe_pop(acceptable: str) -> Optional[str]:
        nonlocal offset
        char = candidate[offset]
        if char in acceptable:
            offset += 1
            return char
        else:
            return None

    def pop(acceptable: str) -> str:
        char = maybe_pop(acceptable)
        assert char is not None
        return char

    def find_next() -> None:
        first = pop('ybnqiuxtsogdva(')    # valid first characters
        if first == 'a':
            if maybe_pop('{'):              # dict
                pop('ybnqiuxtsogd')           # key
                find_next()                   # value
                pop('}')
            else:                           # array
                find_next()                   # item
        elif first == '(':                  # structure
            find_next()                       # at least one item
            while not maybe_pop(')'):
                find_next()                   # and possibly more

    try:
        while offset < len(candidate):
            find_next()
        return True
    except (AssertionError, IndexError):
        return False


def yield_base_helpers() -> Iterable[Tuple[str, object]]:
    for method in ['enter_container', 'exit_container', 'open_container', 'close_container',
                   'append_basic', 'read_basic', 'append_array', 'read_array']:
        yield method, libsystemd.libsystemd[f'sd_bus_message_{method}']

    for name in ['size_t', 'char_p']:
        instance = getattr(ctypes, f'c_{name}')()
        yield f'{name}', instance
        yield f'{name}_ref', ctypes.byref(instance)
        yield f'{name}_setter', instance.__class__.value.__set__

    for char in 'aehrvy':
        yield char, ctypes.c_char(ord(char))

    # https://docs.python.org/3/c-api/unicode.html#c.PyUnicode_FromString
    to_bytes = ctypes.pythonapi.PyBytes_FromStringAndSize
    to_bytes.restype = ctypes.py_object
    yield 'to_bytes', to_bytes


_base_helpers = dict(yield_base_helpers())

T = TypeVar('T')


def call_with_kwargs(func: Callable[..., T], kwargs: Dict[str, Any]) -> T:
    parameters = set(inspect.signature(func).parameters)
    return func(**{key: value for key, value in kwargs.items() if key in parameters})


class Type:
    _cache: ClassVar[Dict[Tuple[type, Tuple[object, ...]], 'Type']] = {}

    __slots__ = 'bytes_typestring', 'reader', 'typestring', 'writer'
    typestring: str
    bytes_typestring: bytes
    reader: Callable[[libsystemd.sd_bus_message], object]
    writer: Callable[[libsystemd.sd_bus_message, object], None]

    def __new__(cls, *args: Any) -> 'Type':
        instance = Type._cache.get((cls, args))
        if instance is None:
            instance = object.__new__(cls)
            Type._cache[(cls, args)] = instance
        return instance

    def __init__(self, typestring: str, **kwargs: Any):
        self.typestring = typestring
        self.bytes_typestring = typestring.encode('ascii')

        kwargs = dict(_base_helpers, **kwargs)
        self.writer = call_with_kwargs(self.get_writer, kwargs)
        self.reader = call_with_kwargs(self.get_reader, kwargs)

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}('{self.typestring}')"

    def get_writer(self, **kwargs: object) -> Callable[[object, object], None]:
        raise NotImplementedError

    def get_reader(self, **kwargs: object) -> Callable[[object], object]:
        raise NotImplementedError


class BasicType(Type):
    __slots__ = ()

    def __init__(self, typestring: str, ctype, get_wrapper=None, **kwargs):
        variable = ctype()  # NB: not thread-safe
        super().__init__(typestring, ctype=ctype, type_constant=ctypes.c_char(ord(typestring)),
                         getter=get_wrapper or ctype.value.__get__, setter=ctype.value.__set__,
                         variable=variable, reference=ctypes.byref(variable), **kwargs)

    def get_reader(self, read_basic, type_constant, variable, reference, getter):
        def basic_reader(message: libsystemd.sd_bus_message) -> object:
            if read_basic(message, type_constant, reference) <= 0:
                raise StopIteration
            return getter(variable)
        return basic_reader


class FixedType(BasicType):
    __slots__ = ()

    def get_writer(self, append_basic, type_constant, variable, reference, setter, getter):
        def fixed_writer(message: libsystemd.sd_bus_message, value: object) -> None:
            setter(variable, value)
            if getter(variable) != value:
                raise TypeError(f"Cannot represent value {value} with type '{self.typestring}'")
            append_basic(message, type_constant, reference)
        return fixed_writer


class HandleType(Type):
    __slots__ = ()

    # We're a bit more relaxed about performance here: sending fds is a heavy
    # lift and not something that happens often, so we simplify the
    # implementation.

    def get_writer(self, append_basic, h):
        def handle_writer(message: libsystemd.sd_bus_message, value: object) -> None:
            # We need this to be specifically `Handle` and not just any int:
            # otherwise receiving a JSON document from elsewhere and sending it
            # verbatim (as Cockpit bridge does) could result local fds being
            # used, which is definitely unintended.  Forcing the creation of a
            # Handle instance avoids that.
            try:
                fd = int(value.fileno())  # type: ignore[attr-defined]
            except (AttributeError, TypeError, ValueError) as exc:
                raise TypeError(f"'h' type requires file object, not '{value.__class__.__name__}'") from exc
            append_basic(message, h, ctypes.byref(ctypes.c_int(fd)))
        return handle_writer

    def get_reader(self, read_basic, h):
        def handle_reader(message: libsystemd.sd_bus_message) -> Handle:
            variable = ctypes.c_int()
            if read_basic(message, h, ctypes.byref(variable)) <= 0:
                raise StopIteration
            # The fd is owned by the message; dup it so Handle can own its copy
            return Handle(os.dup(variable.value))
        return handle_reader


class StringLikeType(BasicType):
    __slots__ = ()

    @staticmethod
    def get_guarded_conversion(typestring: str, guard: Callable[[str], bool]) -> Callable[[object], bytes]:
        def convert(candidate: object) -> bytes:
            if not isinstance(candidate, str):
                raise TypeError(f"'{typestring}' encodes 'str', not '{candidate.__class__.__name__}'")
            if not guard(candidate):
                raise ValueError(f"Invalid value provided for type '{typestring}'")
            return str.encode(candidate)
        return convert

    def __init__(self, typestring: str, guard: Optional[Callable[[str], bool]] = None):
        # https://docs.python.org/3/c-api/unicode.html#c.PyUnicode_FromString
        to_unicode = ctypes.pythonapi.PyUnicode_FromString
        to_unicode.restype = ctypes.py_object

        if guard is not None:
            convert = StringLikeType.get_guarded_conversion(typestring, guard)
        else:
            convert = str.encode  # type: ignore[assignment] # can throw TypeError on call

        super().__init__(typestring, ctypes.c_char_p, to_unicode, convert=convert)

    def get_writer(self, append_basic, type_constant, convert):
        def string_writer(message: libsystemd.sd_bus_message, value: object) -> None:
            append_basic(message, type_constant, convert(value))
        return string_writer


class BytestringType(Type):
    __slots__ = ()

    def get_writer(self, append_array, y, size_t_setter, size_t):
        def bytes_writer(message: libsystemd.sd_bus_message, value: object) -> None:
            if not isinstance(value, bytes):
                if isinstance(value, str):
                    try:
                        value = binascii.a2b_base64(value.encode('ascii'))  # or decode base64
                    except binascii.Error as exc:
                        raise ValueError("'ay' cannot encode invalid base64 string") from exc
                elif isinstance(value, (memoryview, bytearray)):
                    value = bytes(value)
                else:
                    raise TypeError("'ay' can only encode bytes-like or base64 string objects, "
                                    f"not '{value.__class__.__name__}'.")
            size_t_setter(size_t, len(value))
            append_array(message, y, value, size_t)
        return bytes_writer

    def get_reader(self, read_array, y, to_bytes, char_p, char_p_ref, size_t, size_t_ref):
        def bytes_reader(message):
            if read_array(message, y, char_p_ref, size_t_ref) <= 0:
                raise StopIteration
            return to_bytes(char_p, size_t)
        return bytes_reader


class ContainerType(Type):
    _typestring_template: ClassVar[str]
    __slots__ = 'item_types'
    item_types: Sequence[Type]

    def __init__(self, *item_types: Type, **kwargs: Any):
        assert len(item_types) > 0
        item_typestrings = ''.join(item.typestring for item in item_types)
        self.item_types = item_types
        super().__init__(self._typestring_template.replace('_', item_typestrings),
                         type_contents=ctypes.c_char_p(item_typestrings.encode('ascii')),
                         **kwargs)


class ArrayType(ContainerType):
    _typestring_template = 'a_'
    __slots__ = ()

    def __init__(self, item_type: Type):
        super().__init__(item_type,
                         item_writer=item_type.writer,
                         item_reader=item_type.reader,
                         list_append=list.append)

    def get_reader(self, enter_container, exit_container, list_append, item_reader):
        def array_reader(message: libsystemd.sd_bus_message) -> object:
            if enter_container(message, 0, None) <= 0:
                raise StopIteration
            result: List[object] = []
            try:
                while True:
                    list_append(result, item_reader(message))
            except StopIteration:
                return result
            finally:
                exit_container(message)
        return array_reader

    def get_writer(self, a, type_contents, open_container, close_container, item_writer):
        def array_writer(message: libsystemd.sd_bus_message, value: object) -> None:
            open_container(message, a, type_contents)
            for item in value:  # type: ignore[attr-defined] # can throw TypeError
                item_writer(message, item)
            close_container(message)
        return array_writer


class StructType(ContainerType):
    _typestring_template = '(_)'
    __slots__ = ()

    def get_reader(self, enter_container, exit_container):
        item_readers = tuple(item_type.reader for item_type in self.item_types)

        def array_reader(message: libsystemd.sd_bus_message) -> object:
            if enter_container(message, 0, None) <= 0:
                raise StopIteration
            result = tuple(item_reader(message) for item_reader in item_readers)
            exit_container(message)
            return result
        return array_reader

    def get_writer(self, r, type_contents, open_container, close_container):
        item_writers = tuple(item_type.writer for item_type in self.item_types)

        def struct_writer(message: libsystemd.sd_bus_message, value: object) -> None:
            n_items = len(value)  # type: ignore[arg-type] # can throw TypeError
            if n_items != len(item_writers):
                raise TypeError(f"Wrong numbers of items ({n_items}) for structure type '{self.typestring}'")
            open_container(message, r, type_contents)
            for item_writer, item in zip(item_writers, value):  # type: ignore[call-overload] # can throw TypeError
                item_writer(message, item)
            close_container(message)
        return struct_writer


class DictionaryType(ContainerType):
    _typestring_template = 'a{_}'
    __slots__ = ()

    def __init__(self, key_type: Type, value_type: Type):
        assert isinstance(key_type, BasicType)
        item_type = '{' + key_type.typestring + value_type.typestring + '}'
        super().__init__(key_type, value_type,
                         key_reader=key_type.reader, key_writer=key_type.writer,
                         value_reader=value_type.reader, value_writer=value_type.writer,
                         item_type=ctypes.c_char_p(item_type.encode('ascii')))

    def get_reader(self, enter_container, exit_container, key_reader, value_reader):
        def dict_reader(message: libsystemd.sd_bus_message) -> object:
            if enter_container(message, 0, None) <= 0:    # array
                raise StopIteration
            result = {}
            while enter_container(message, 0, None) > 0:  # entry
                key = key_reader(message)
                value = value_reader(message)
                result[key] = value
                exit_container(message)
            exit_container(message)
            return result
        return dict_reader

    def get_writer(self, a, item_type, e, type_contents, open_container, close_container, key_writer, value_writer):
        def dict_writer(message: libsystemd.sd_bus_message, value: object) -> None:
            open_container(message, a, item_type)                    # array
            for key, val in value.items():  # type: ignore[attr-defined] # can raise AttributeError
                open_container(message, e, type_contents)              # entry
                key_writer(message, key)                                 # key
                value_writer(message, val)                               # value
                close_container(message)                               # end entry
            close_container(message)                                 # end array
        return dict_writer


class VariantType(Type):
    __slots__ = ()

    def get_reader(self, enter_container, exit_container):
        def variant_reader(message: libsystemd.sd_bus_message) -> object:
            if enter_container(message, 0, None) <= 0:
                raise StopIteration
            typestring = message.get_signature(False)
            type_, = from_signature(typestring)
            value = type_.reader(message)
            exit_container(message)
            return Variant(value, type_)
        return variant_reader

    def get_writer(self, open_container, close_container, v):
        def variant_writer(message: libsystemd.sd_bus_message, value: object) -> None:
            if isinstance(value, Variant):
                type_ = value.type
                contents = value.value
            else:
                try:
                    type_, = from_signature(value['t'])  # type: ignore[index] # can throw TypeError
                    contents = value['v']  # type: ignore[index] # can throw TypeError
                except KeyError as exc:
                    raise TypeError("'v' can encode Variant objects, or mappings with 't' and 'v' keys") from exc

            open_container(message, v, type_.bytes_typestring)
            type_.writer(message, contents)
            close_container(message)
        return variant_writer


class Variant:
    __slots__ = 'type', 'value'
    type: Type
    value: object

    def __init__(self, value: object, hint: object = None):
        if isinstance(hint, Type):
            self.type = hint
        elif isinstance(hint, str):
            self.type, = from_signature(hint)
        else:
            self.type = from_annotation(hint or value.__class__)
        self.value = value

    def __repr__(self) -> str:
        return f"systemd_ctypes.Variant({self.value}, '{self.type.typestring}')"

    def __eq__(self, other: object) -> bool:
        if isinstance(other, Variant):
            return self.type == other.type and self.value == other.value
        elif isinstance(other, dict):
            return (self.type,) == from_signature(other['t']) and self.value == other['v']
        else:
            return False

    def __hash__(self) -> int:
        return hash(self.type) ^ hash(self.value)


class BusType(Enum):
    boolean = Annotated[bool, FixedType('b', ctypes.c_int, ctypes.c_int.__bool__)]
    byte = Annotated[int, FixedType('y', ctypes.c_uint8)]
    int16 = Annotated[int, FixedType('n', ctypes.c_int16)]
    uint16 = Annotated[int, FixedType('q', ctypes.c_uint16)]
    int32 = Annotated[int, FixedType('i', ctypes.c_int32)]
    uint32 = Annotated[int, FixedType('u', ctypes.c_uint32)]
    int64 = Annotated[int, FixedType('x', ctypes.c_int64)]
    uint64 = Annotated[int, FixedType('t', ctypes.c_uint64)]
    double = Annotated[float, FixedType('d', ctypes.c_double)]
    string = Annotated[str, StringLikeType('s')]
    objectpath = Annotated[str, StringLikeType('o', is_object_path)]
    signature = Annotated[str, StringLikeType('g', is_signature)]
    bytestring = Annotated[bytes, BytestringType('ay')]
    variant = Annotated[dict, VariantType('v')]
    handle = Annotated[Handle, HandleType('h')]


# mypy gets confused by enums, so just use Any
_base_equivalence_map: Dict[type, Any] = {
    bool: BusType.boolean,
    bytes: BusType.bytestring,
    int: BusType.int32,
    str: BusType.string,
    Variant: BusType.variant,
}

_factory_map: Dict[object, Callable[..., Type]] = {
    dict: DictionaryType, Dict: DictionaryType,
    list: ArrayType, List: ArrayType,
    tuple: StructType, Tuple: StructType,
}


@functools.lru_cache()
def from_annotation(annotation: Union[str, type, BusType]) -> Type:
    # Simple Python types
    if isinstance(annotation, str):
        types = from_signature(annotation)
        if len(types) != 1:
            raise TypeError(f"Signature '{annotation}' invalid as a type string "
                            f"because it describes {len(types)} types, not one.")
        return types[0]

    if isinstance(annotation, type):
        annotation = _base_equivalence_map.get(annotation, annotation)

    # Our own BusType types
    if isinstance(annotation, BusType):
        bus_type = typing.get_args(annotation.value)[1]
        assert isinstance(bus_type, Type)
        return bus_type

    # Container types
    try:
        factory = _factory_map[typing.get_origin(annotation)]
        args = [from_annotation(arg) for arg in typing.get_args(annotation)]
        return factory(*args)
    except (AssertionError, AttributeError, KeyError, TypeError):
        raise TypeError(f"Cannot interpret {annotation} as a dbus type") from None


_base_typestring_map: Dict[str, Type] = {
    bustype.typestring: bustype for bustype in (from_annotation(entry) for entry in BusType)
}


@functools.lru_cache()
def from_signature(signature: str) -> Tuple[Type, ...]:
    stack = list(reversed(signature))

    def get_one() -> Type:
        first = stack.pop()
        if first == 'a':
            if stack[-1] == 'y':
                first += stack.pop()
            elif stack[-1] == '{':
                stack.pop()
                return DictionaryType(*get_several('}'))
            else:
                return ArrayType(get_one())
        elif first == '(':
            return StructType(*get_several(')'))

        return _base_typestring_map[first]

    def get_several(end: str) -> Iterable[Type]:
        yield get_one()
        while stack[-1] != end:
            yield get_one()
        stack.pop()

    def get_all() -> Iterable[Type]:
        while stack:
            yield get_one()

    try:
        return tuple(get_all())
    except (AssertionError, IndexError, KeyError) as exc:
        raise TypeError(f"Invalid type signature '{signature}'") from exc


class MessageType:
    item_types: Sequence[Type]
    typestrings: List[str]
    signature: str

    def __init__(self, item_types: Sequence[Union[str, type, BusType]]):
        self.item_types = [from_annotation(item_type) for item_type in item_types]
        self.typestrings = [item_type.typestring for item_type in self.item_types]
        self.signature = ''.join(self.typestrings)

    def write(self, message: libsystemd.sd_bus_message, *items: object) -> None:
        assert len(items) == len(self.item_types)
        for item_type, item in zip(self.item_types, items):
            item_type.writer(message, item)

    def read(self, message: libsystemd.sd_bus_message) -> Optional[Tuple[object, ...]]:
        if not message.has_signature(self.signature):
            return None
        return tuple(item_type.reader(message) for item_type in self.item_types)

    def __len__(self) -> int:
        return len(self.item_types)


class JSONEncoder(json.JSONEncoder):
    def default(self, obj: object) -> object:
        if isinstance(obj, Variant):
            return {"t": obj.type.typestring, "v": obj.value}
        elif isinstance(obj, bytes):
            return binascii.b2a_base64(obj, newline=False).decode('ascii')
        return super().default(obj)
''',
  'cockpit/_vendor/systemd_ctypes/event.py': br'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import asyncio
import selectors
import sys
from typing import Callable, ClassVar, Coroutine, List, Optional, Tuple

from . import inotify, libsystemd
from .librarywrapper import Reference, UserData, byref


class Event(libsystemd.sd_event):
    class Source(libsystemd.sd_event_source):
        def cancel(self) -> None:
            self._unref()
            self.value = None

    _default_instance: ClassVar[Optional['Event']] = None

    @staticmethod
    def default() -> 'Event':
        if Event._default_instance is None:
            Event._default_instance = Event()
            Event._default(byref(Event._default_instance))
        return Event._default_instance

    InotifyHandler = Callable[[inotify.Event, int, Optional[bytes]], None]

    class InotifySource(Source):
        def __init__(self, handler: 'Event.InotifyHandler') -> None:
            def callback(source: libsystemd.sd_event_source,
                         _event: Reference[inotify.inotify_event],
                         userdata: UserData) -> int:
                event = _event.contents
                handler(inotify.Event(event.mask), event.cookie, event.name)
                return 0
            self.trampoline = libsystemd.sd_event_inotify_handler_t(callback)

    def add_inotify(self, path: str, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:
        source = Event.InotifySource(handler)
        self._add_inotify(byref(source), path, mask, source.trampoline, source.userdata)
        return source

    def add_inotify_fd(self, fd: int, mask: inotify.Event, handler: InotifyHandler) -> InotifySource:
        source = Event.InotifySource(handler)
        self._add_inotify_fd(byref(source), fd, mask, source.trampoline, source.userdata)
        return source


# This is all a bit more awkward than it should have to be: systemd's event
# loop chaining model is designed for glib's prepare/check/dispatch paradigm;
# failing to call prepare() can lead to deadlocks, for example.
#
# Hack a selector subclass which calls prepare() before sleeping and this for us.
class Selector(selectors.DefaultSelector):
    def __init__(self, event: Optional[Event] = None) -> None:
        super().__init__()
        self.sd_event = event or Event.default()
        self.key = self.register(self.sd_event.get_fd(), selectors.EVENT_READ)

    def select(
            self, timeout: Optional[float] = None
    ) -> List[Tuple[selectors.SelectorKey, int]]:
        # It's common to drop the last reference to a Source or Slot object on
        # a dispatch of that same source/slot from the main loop.  If we happen
        # to garbage collect before returning, the trampoline could be
        # destroyed before we're done using it.  Provide a mechanism to defer
        # the destruction of trampolines for as long as we might be
        # dispatching.  This gets cleared again at the bottom, before return.
        libsystemd.Trampoline.deferred = []

        while self.sd_event.prepare():
            self.sd_event.dispatch()
        ready = super().select(timeout)
        # workaround https://github.com/systemd/systemd/issues/23826
        # keep calling wait() until there's nothing left
        while self.sd_event.wait(0):
            self.sd_event.dispatch()
            while self.sd_event.prepare():
                self.sd_event.dispatch()

        # We can be sure we're not dispatching callbacks anymore
        libsystemd.Trampoline.deferred = None

        # This could return zero events with infinite timeout, but nobody seems to mind.
        return [(key, events) for (key, events) in ready if key != self.key]


def selector_event_loop_factory() -> asyncio.AbstractEventLoop:
    """Factory function to create an asyncio event loop using Selector."""
    return asyncio.SelectorEventLoop(Selector())


# deprecated in Python 3.12
class EventLoopPolicy(asyncio.DefaultEventLoopPolicy):
    def new_event_loop(self) -> asyncio.AbstractEventLoop:
        return selector_event_loop_factory()


def run_async(main: Coroutine[None, None, None], debug: Optional[bool] = None) -> None:
    polyfill = sys.version_info < (3, 7, 0) and not hasattr(asyncio, 'run')
    loop_factory = sys.version_info >= (3, 12, 0)

    if not loop_factory:
        asyncio.set_event_loop_policy(EventLoopPolicy())

    if polyfill:
        # Polyfills for Python 3.6:
        loop = asyncio.get_event_loop()

        assert not hasattr(asyncio, 'get_running_loop')
        asyncio.get_running_loop = lambda: loop

        assert not hasattr(asyncio, 'create_task')
        asyncio.create_task = loop.create_task

        assert not hasattr(asyncio, 'run')

        def run(
                main: Coroutine[None, None, None], debug: Optional[bool] = None
        ) -> None:
            if debug is not None:
                loop.set_debug(debug)
            loop.run_until_complete(main)

        asyncio.run = run  # type: ignore[assignment]

        asyncio._systemd_ctypes_polyfills = True  # type: ignore[attr-defined]

    if loop_factory:
        asyncio.run(main, debug=debug, loop_factory=selector_event_loop_factory)
    else:
        asyncio.run(main, debug=debug)

    if polyfill:
        del asyncio.create_task, asyncio.get_running_loop, asyncio.run
''',
  'cockpit/_vendor/systemd_ctypes/handle.py': br'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
from typing import Any


class Handle(int):
    """An integer subclass that makes it easier to work with file descriptors"""

    def __new__(cls, fd: int = -1) -> 'Handle':
        return super(Handle, cls).__new__(cls, fd)

    # separate __init__() to set _needs_close mostly to keep pylint quiet
    def __init__(self, fd: int = -1):
        super().__init__()
        self._needs_close = fd != -1

    def __bool__(self) -> bool:
        return self != -1

    def close(self) -> None:
        if self._needs_close:
            self._needs_close = False
            os.close(self)

    def __eq__(self, value: object) -> bool:
        if int.__eq__(self, value):  # also handles both == -1
            return True

        if not isinstance(value, int):  # other object is not an int
            return False

        if not self or not value:  # when only one == -1
            return False

        return os.path.sameopenfile(self, value)

    def __del__(self) -> None:
        if self._needs_close:
            self.close()

    def __enter__(self) -> 'Handle':
        return self

    def __exit__(self, *_args: object) -> None:
        self.close()

    @classmethod
    def open(cls, *args: Any, **kwargs: Any) -> 'Handle':
        return cls(os.open(*args, **kwargs))

    def steal(self) -> 'Handle':
        self._needs_close = False
        return self.__class__(int(self))

    def fileno(self) -> int:
        return int(self)
''',
  'cockpit/_vendor/systemd_ctypes/inotify.py': br'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import ctypes
from enum import IntFlag, auto
from typing import Optional


class inotify_event(ctypes.Structure):
    _fields_ = (
        ('wd', ctypes.c_int32),
        ('mask', ctypes.c_uint32),
        ('cookie', ctypes.c_uint32),
        ('len', ctypes.c_uint32),
    )

    @property
    def name(self) -> Optional[bytes]:
        if self.len == 0:
            return None

        # inotify(7) says:
        # This filename is null-terminated, and may include further null bytes
        # ('\0') to align subsequent reads to a suitable address boundary.
        addr = ctypes.addressof(self) + ctypes.sizeof(self)
        return ctypes.string_at(addr, self.len).rstrip(b'\0')


class Event(IntFlag):
    ACCESS = auto()
    MODIFY = auto()
    ATTRIB = auto()
    CLOSE_WRITE = auto()
    CLOSE_NOWRITE = auto()
    OPEN = auto()
    MOVED_FROM = auto()
    MOVED_TO = auto()
    CREATE = auto()
    DELETE = auto()
    DELETE_SELF = auto()
    MOVE_SELF = auto()

    UNMOUNT = 1 << 13
    Q_OVERFLOW = auto()
    IGNORED = auto()

    ONLYDIR = 1 << 24
    DONT_FOLLOW = auto()
    EXCL_UNLINK = auto()

    MASK_CREATE = 1 << 28
    MASK_ADD = auto()
    ISDIR = auto()
    ONESHOT = auto()

    CLOSE = CLOSE_WRITE | CLOSE_NOWRITE
    MOVE = MOVED_FROM | MOVED_TO
    CHANGED = (MODIFY | ATTRIB | CLOSE_WRITE | MOVE |
               CREATE | DELETE | DELETE_SELF | MOVE_SELF)
''',
  'cockpit/_vendor/systemd_ctypes/introspection.py': br'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import xml.etree.ElementTree as ET
from typing import Any


def parse_method(method: ET.Element) -> 'dict[str, list[str]]':
    return {
        "in": [tag.attrib['type'] for tag in method.findall("arg") if tag.get('direction', 'in') == 'in'],
        "out": [tag.attrib['type'] for tag in method.findall("arg[@direction='out']")]
    }


def parse_property(prop: ET.Element) -> 'dict[str, str]':
    return {
        "flags": 'w' if prop.attrib.get('access') == 'write' else 'r',
        "type": prop.attrib['type']
    }


def parse_signal(signal: ET.Element) -> 'dict[str, list[str]]':
    return {"in": [tag.attrib['type'] for tag in signal.findall("arg")]}


def parse_interface(interface: ET.Element) -> 'dict[str, dict[str, Any]]':
    return {
        "methods": {tag.attrib['name']: parse_method(tag) for tag in interface.findall('method')},
        "properties": {tag.attrib['name']: parse_property(tag) for tag in interface.findall('property')},
        "signals": {tag.attrib['name']: parse_signal(tag) for tag in interface.findall('signal')}
    }


def parse_xml(xml: str):
    et = ET.fromstring(xml)
    return {tag.attrib['name']: parse_interface(tag) for tag in et.findall('interface')}


# Pretend like this is a little bit functional
def element(tag, children=(), **kwargs) -> ET.Element:
    tag = ET.Element(tag, kwargs)
    tag.extend(children)
    return tag


def method_to_xml(name, method_info):
    return element('method', name=name,
                   children=[
                       element('arg', type=arg_type, direction=direction)
                       for direction in ['in', 'out']
                       for arg_type in method_info[direction]
                   ])


def property_to_xml(name, property_info):
    return element('property', name=name,
                   access='write' if property_info['flags'] == 'w' else 'read',
                   type=property_info['type'])


def signal_to_xml(name, signal_info):
    return element('signal', name=name,
                   children=[
                       element('arg', type=arg_type) for arg_type in signal_info['in']
                   ])


def interface_to_xml(name, interface_info):
    return element('interface', name=name,
                   children=[
                       *(method_to_xml(name, info) for name, info in interface_info['methods'].items()),
                       *(property_to_xml(name, info) for name, info in interface_info['properties'].items()),
                       *(signal_to_xml(name, info) for name, info in interface_info['signals'].items()),
                   ])


def to_xml(interfaces):
    node = element('node', children=(interface_to_xml(name, members) for name, members in interfaces.items()))
    return ET.tostring(node, encoding='unicode')
''',
  'cockpit/_vendor/systemd_ctypes/librarywrapper.py': br'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import ctypes
import inspect
import logging
import os
import sys
import types
from typing import (
    Any,
    Callable,
    Dict,
    Generic,
    NewType,
    NoReturn,
    Optional,
    Tuple,
    Type,
    TypeVar,
    Union,
)

from . import typing

# First in 3.10, and conditional import gives type errors
NoneType = type(None)

logger = logging.getLogger(__name__)

if typing.TYPE_CHECKING:
    CType = TypeVar("CType", bound=ctypes._CData)
    Callback = ctypes._FuncPointer
else:
    CType = TypeVar("CType")
    Callback = ctypes.c_void_p


if typing.TYPE_CHECKING:
    class Reference(Generic[CType], ctypes._Pointer[CType]):
        pass

    def byref(x: CType) -> Reference[CType]:
        raise NotImplementedError
else:
    class Reference(Generic[CType]):
        pass

    byref = ctypes.byref


UserData = Optional[ctypes.c_void_p]


class negative_errno(ctypes.c_int):
    def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> int:
        result = self.value
        if result < 0:
            raise OSError(-result, f"{func.__name__}: {os.strerror(-result)}")
        return result


class utf8(ctypes.c_char_p):
    def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> str:
        assert self.value is not None
        return self.value.decode()

    @classmethod
    def from_param(cls, value: str) -> 'utf8':
        return cls(value.encode())


class utf8_or_null(ctypes.c_char_p):
    def errcheck(self,
                 func: Callable[..., object],
                 _args: Tuple[object, ...]) -> Optional[str]:
        return self.value.decode() if self.value is not None else None

    @classmethod
    def from_param(cls, value: Optional[str]) -> 'utf8_or_null':
        return cls(value.encode() if value is not None else None)


class boolint(ctypes.c_int):
    def errcheck(self, func: Callable[..., object], _args: Tuple[object, ...]) -> bool:
        return bool(self.value)


WeakReference = NewType("WeakReference", int)
Errno = typing.Annotated[NoReturn, "errno"]


type_map = {
    Union[None, Errno]: negative_errno,  # technically returns int
    Union[bool, Errno]: negative_errno,  # technically returns int
    Union[int, Errno]: negative_errno,
    bool: boolint,
    Optional[str]: utf8_or_null,
    str: utf8,
    int: ctypes.c_int,
    WeakReference: ctypes.c_void_p
}


def map_type(annotation: Any, global_vars: Dict[str, object]) -> Any:
    try:
        return type_map[annotation]
    except KeyError:
        pass  # ... and try more cases below

    if isinstance(annotation, typing.ForwardRef):
        annotation = annotation.__forward_arg__

    if isinstance(annotation, str):
        annotation = global_vars[annotation]

    origin = typing.get_origin(annotation)
    args = typing.get_args(annotation)

    if origin is Reference:
        return ctypes.POINTER(map_type(args[0], global_vars))

    elif origin is Union and NoneType in args:
        # the C pointer types are already nullable
        other_arg, = set(args) - {NoneType}
        return map_type(other_arg, global_vars)

    elif origin is typing.Annotated:
        return args[1]

    else:
        assert origin is None, origin
        return annotation


class ReferenceType(ctypes.c_void_p):
    @classmethod
    def _install_cfuncs(cls, cdll: ctypes.CDLL) -> None:
        logger.debug('Installing stubs for %s:', cls)
        stubs = tuple(cls.__dict__.items())
        for name, stub in stubs:
            # Skip private members and name-mangled fallback methods
            if name.startswith(("__", f"_{cls.__name__}__fallback")):
                continue
            cls._wrap(cdll, stub)

        cls._wrap(cdll, cls._ref)
        cls._wrap(cdll, cls._unref)

    @classmethod
    def _wrap(cls, cdll: ctypes.CDLL, stub: object) -> None:
        stub_type = type(stub)
        if isinstance(stub, staticmethod):
            # In older Python versions, staticmethod() isn't considered
            # callable, doesn't have a name, and can't be introspected with
            # inspect.signature(). Unwrap it.
            stub = stub.__func__
        assert isinstance(stub, types.FunctionType)
        name = stub.__name__
        signature = inspect.signature(stub)
        stub_globals = sys.modules.get(cls.__module__).__dict__

        c_func_name = f'{cls.__name__}_{name.lstrip("_")}'

        try:
            func = cdll[c_func_name]
        except AttributeError as e:
            # check if there is a fallback method
            try:
                func = getattr(cls, f'_{cls.__name__}__fallback{name}')
            except AttributeError:
                raise AttributeError(
                    f'{c_func_name} does not exist, no fallback available') from e

        func.argtypes = tuple(
            map_type(parameter.annotation, stub_globals)
            for parameter in signature.parameters.values()
        )
        func.restype = map_type(signature.return_annotation, stub_globals)
        errcheck = getattr(func.restype, 'errcheck', None)
        if errcheck is not None:
            func.errcheck = errcheck

        logger.debug('  create wrapper %s.%s%s', cls.__name__, name, signature)
        logger.debug('    args %s res %s', func.argtypes, func.restype)

        # ctypes function pointer objects don't implement the usual function
        # descriptor logic, which means they won't bind as methods.  For static
        # methods, that's good, but for instance methods, we add a wrapper as
        # the easiest and most performant way to get the binding behaviour.
        if stub_type is not staticmethod:
            setattr(cls, name, lambda *args: func(*args))
        else:
            setattr(cls, name, func)

    def _unref(self: 'ReferenceType') -> None:
        ...

    def _ref(self: 'ReferenceType') -> None:
        ...

    T = TypeVar("T", bound='ReferenceType')

    @classmethod
    def ref(cls: Type[T], origin: WeakReference) -> T:
        self = cls(origin)
        self._ref()
        return self

    def __del__(self) -> None:
        if self.value is not None:
            self._unref()
''',
  'cockpit/_vendor/systemd_ctypes/libsystemd.py': r'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import ctypes
import os
import sys
from typing import ClassVar, Optional, Tuple, Union

from .inotify import inotify_event
from .librarywrapper import (
    Callback,
    Errno,
    Reference,
    ReferenceType,
    UserData,
    WeakReference,
    byref,
)
from .typing import Annotated


class Trampoline(ReferenceType):
    deferred: 'ClassVar[list[Callback] | None]' = None
    trampoline: Callback
    userdata: UserData = None

    def cancel(self) -> None:
        self._unref()
        self.value = None

    def __del__(self) -> None:
        # This might be the currently-dispatching callback — make sure we don't
        # destroy the trampoline before we return.  We drop the deferred list
        # from the event loop when we're sure we're not doing any dispatches.
        if Trampoline.deferred is not None:
            Trampoline.deferred.append(self.trampoline)
        if self.value is not None:
            self._unref()


class sd_bus_error(ctypes.Structure):
    # This is ABI, so we are safe to assume it doesn't change.
    # Unfortunately, we lack anything like sd_bus_error_new().
    _fields_ = (
        ("name", ctypes.c_char_p),
        ("message", ctypes.c_char_p),
        ("_need_free", ctypes.c_int),
    )

    def get(self) -> Tuple[str, str]:
        return self.name.decode(), self.message.decode()

    def set(self, name: str, message: str) -> None:
        result = libsystemd.sd_bus_error_set(byref(self), name, message)
        if result < 0:
            raise OSError(-result, f"sd_bus_error_set: {os.strerror(-result)}")

    def __del__(self) -> None:
        if self._b_needsfree_:
            libsystemd.sd_bus_error_free(byref(self))


class sd_id128(ctypes.Structure):
    # HACK: Pass-by-value of array-containing-structs is broken on Python
    # 3.6. See https://bugs.python.org/issue22273
    _fields_ = (
        [("bytes", ctypes.c_uint8 * 16)]
        if sys.version_info >= (3, 7, 0)
        else [("one", ctypes.c_uint64), ("two", ctypes.c_uint64)]
    )


class sd_event_source(Trampoline):
    ...


class sd_event(ReferenceType):
    def _add_inotify(
        self: 'sd_event',
        source: Reference[sd_event_source],
        path: str,
        event: int,
        callback: Callback,
        user_data: UserData,
    ) -> Union[None, Errno]:
        ...

    def _add_inotify_fd(
        self: 'sd_event',
        source: Reference[sd_event_source],
        fd: int,
        event: int,
        callback: Callback,
        user_data: UserData,
    ) -> Union[None, Errno]:
        ...

    # HACK: sd_event_add_inotify_fd() got added in 250, which is too new.  Fake it.
    def __fallback_add_inotify_fd(
        self: 'sd_event',
        source: Reference[sd_event_source],
        fd: int,
        event: int,
        callback: Callback,
        user_data: UserData,
    ) -> Union[None, Errno]:
        return self._add_inotify(source, f'/proc/self/fd/{fd}', event, callback, user_data)

    def dispatch(self: 'sd_event') -> Union[None, Errno]:
        ...

    def get_fd(self: 'sd_event') -> Union[int, Errno]:
        raise NotImplementedError

    def get_state(self: 'sd_event') -> Union[int, Errno]:
        raise NotImplementedError

    def loop(self: 'sd_event') -> Union[None, Errno]:
        ...

    def prepare(self: 'sd_event') -> Union[None, Errno]:
        ...

    def wait(
        self: 'sd_event', timeout: Annotated[int, ctypes.c_uint64]
    ) -> Union[None, Errno]:
        ...

    @staticmethod
    def _default(ret: Reference['sd_event']) -> Union[None, Errno]:
        ...


class sd_bus_slot(Trampoline):
    ...


class sd_bus_message(ReferenceType):
    def rewind(self: 'sd_bus_message', complete: bool) -> Union[None, Errno]:
        ...

    def _get_error(self: 'sd_bus_message') -> Reference[sd_bus_error]:
        raise NotImplementedError

    def has_signature(self: 'sd_bus_message', signature: str) -> Union[bool, Errno]:
        raise NotImplementedError

    def is_method_error(self: 'sd_bus_message', name: str) -> Union[bool, Errno]:
        raise NotImplementedError

    def _new_method_errnof(
            self: 'sd_bus_message',
            message: Reference['sd_bus_message'],
            error: int,
            format_str: str,
            first_arg: str
    ) -> Union[None, Errno]:
        ...

    def _new_method_errorf(
        self: 'sd_bus_message',
        m: Reference['sd_bus_message'],
        name: str,
        format_str: str,
        first_arg: str
    ) -> Union[None, Errno]:
        ...

    def _new_method_return(
        self: 'sd_bus_message', m: Reference['sd_bus_message']
    ) -> Union[None, Errno]:
        ...

    def seal(
        self: 'sd_bus_message',
        cookie: Annotated[int, ctypes.c_uint64],
        timeout: Annotated[int, ctypes.c_uint64],
    ) -> Union[None, Errno]:
        ...

    def _get_bus(self: 'sd_bus_message') -> WeakReference:
        raise NotImplementedError

    def get_destination(self: 'sd_bus_message') -> str:
        raise NotImplementedError

    def get_interface(self: 'sd_bus_message') -> str:
        raise NotImplementedError

    def get_member(self: 'sd_bus_message') -> str:
        raise NotImplementedError

    def get_path(self: 'sd_bus_message') -> str:
        raise NotImplementedError

    def get_sender(self: 'sd_bus_message') -> Optional[str]:
        raise NotImplementedError

    def get_signature(self: 'sd_bus_message', complete: bool) -> str:
        raise NotImplementedError

    def set_allow_interactive_authorization(self: 'sd_bus_message', b: bool) -> Union[None, Errno]:
        ...


class sd_bus(ReferenceType):
    def _add_match(
        self: 'sd_bus',
        slot: Reference[sd_bus_slot],
        match: str,
        handler: Callback,
        user_data: UserData,
    ) -> Union[None, Errno]:
        ...

    def _add_match_async(
        self: 'sd_bus',
        slot: Reference[sd_bus_slot],
        match: str,
        callback: Callback,
        install_callback: Callback,
        user_data: UserData,
    ) -> Union[None, Errno]:
        ...

    def _add_object(
        self: 'sd_bus',
        slot: Reference[sd_bus_slot],
        path: str,
        callback: Callback,
        user_data: UserData,
    ) -> Union[None, Errno]:
        ...

    def attach_event(
        self: 'sd_bus', event: Optional[sd_event], priority: int
    ) -> Union[None, Errno]:
        ...

    def _call(
        self: 'sd_bus',
        message: sd_bus_message,
        timeout: Annotated[int, ctypes.c_uint64],
        ret_error: Reference[sd_bus_error],
        reply: Reference[sd_bus_message],
    ) -> Union[None, Errno]:
        ...

    def _call_async(
        self: 'sd_bus',
        slot: Reference[sd_bus_slot],
        message: sd_bus_message,
        callback: Callback,
        user_data: UserData,
        timeout_usec: Annotated[int, ctypes.c_uint64],
    ) -> Union[None, Errno]:
        ...

    def flush(self: 'sd_bus') -> Union[None, Errno]:
        ...

    def get_fd(self: 'sd_bus') -> Union[int, Errno]:
        raise NotImplementedError

    def _message_new_method_call(
        self: 'sd_bus',
        message: Reference[sd_bus_message],
        destination: Optional[str],
        path: str,
        interface: str,
        member: str,
    ) -> Union[None, Errno]:
        ...

    def _message_new_signal(
        self: 'sd_bus',
        message: Reference[sd_bus_message],
        path: str,
        interface: str,
        member: str,
    ) -> Union[None, Errno]:
        ...

    def release_name(self: 'sd_bus', name: str) -> Union[None, Errno]:
        ...

    def request_name(
        self: 'sd_bus', name: str, flags: Annotated[int, ctypes.c_uint64]
    ) -> Union[None, Errno]:
        ...

    def set_address(self: 'sd_bus', address: str) -> Union[None, Errno]:
        ...

    def set_allow_interactive_authorization(self: 'sd_bus', b: bool) -> Union[None, Errno]:
        ...

    def set_bus_client(self: 'sd_bus', b: bool) -> Union[None, Errno]:
        ...

    def set_fd(self: 'sd_bus', input_fd: int, output_fd: int) -> Union[None, Errno]:
        ...

    def set_server(self: 'sd_bus', b: bool, bus_d: sd_id128) -> Union[None, Errno]:
        ...

    def start(self: 'sd_bus') -> Union[None, Errno]:
        ...

    def wait(
        self: 'sd_bus', timeout_usec: Annotated[int, ctypes.c_uint64]
    ) -> Union[None, Errno]:
        ...

    def send(
        self: 'sd_bus', message: sd_bus_message, cookie: Optional[Reference[ctypes.c_uint64]]
    ) -> Union[None, Errno]:
        ...

    @staticmethod
    def _default_system(ret: Reference['sd_bus']) -> Union[None, Errno]:
        ...

    @staticmethod
    def _default_user(ret: Reference['sd_bus']) -> Union[None, Errno]:
        ...

    @staticmethod
    def _new(ret: Reference['sd_bus']) -> Union[None, Errno]:
        ...


sd_bus_message_handler_t = ctypes.CFUNCTYPE(
    ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(sd_bus_error))
sd_event_inotify_handler_t = ctypes.CFUNCTYPE(
    ctypes.c_int, ctypes.c_void_p, ctypes.POINTER(inotify_event), ctypes.c_void_p)


libsystemd = ctypes.CDLL("libsystemd.so.0")
for cls in {
    sd_bus,
    sd_bus_message,
    sd_bus_slot,
    sd_event,
    sd_event_source,
}:
    cls._install_cfuncs(libsystemd)
'''.encode('utf-8'),
  'cockpit/_vendor/systemd_ctypes/pathwatch.py': br'''# systemd_ctypes
#
# Copyright (C) 2022 Allison Karlitskaya <allison.karlitskaya@redhat.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import errno
import logging
import os
import stat
from typing import List, Optional

from .event import Event
from .handle import Handle
from .inotify import Event as IN

logger = logging.getLogger(__name__)


# inotify hard facts:
#
# DELETE_SELF doesn't get called until all references to an inode are gone
#   - including open fds
#   - including on directories
#
# ATTRIB gets called when unlinking files (because the link count changes) but
# not on directories.  When unlinking an open directory, no events at all
# happen on the directory.  ATTRIB also collects child events, which means we
# get a lot of unwanted noise.
#
# There's nothing like UNLINK_SELF, unfortunately.
#
# Even if it was possible to take this approach, it might not work: after
# you've opened the fd, it might get deleted before you can establish the watch
# on it.
#
# Additionally, systemd makes it impossible to register those events on
# symlinks (because it removes IN_DONT_FOLLOW in order to watch via
# /proc/self/fd).
#
# For all of these reasons, unfortunately, the best way seems to be to watch
# for CREATE|DELETE|MOVE events on each intermediate directory.
#
# Unfortunately there is no way to filter to only the name we're interested in,
# so we're gonna get a lot of unnecessary wakeups.
#
# Also: due to the above-mentioned race about watching after opening the fd,
# let's just always watch for both create and delete events *before* trying to
# open the fd.  We could try to reduce the mask after the fact, but meh...
#
# We use a WatchInvalidator utility class to fill the role of "Tell me when an
# event happened on this (directory) fd which impacted the name file".  We
# build a series of these when setting up a watch in order to find out if any
# part of the path leading to the monitored file changed.


class WatchInvalidator:
    _name: bytes
    _source: Optional[Event.Source]
    _watch: Optional['PathWatch']

    def event(self, mask: IN, _cookie: int, name: Optional[bytes]) -> None:
        logger.debug('invalidator event %s %s', mask, name)
        if self._watch is not None:
            # If this node itself disappeared, that's definitely an
            # invalidation.  Otherwise, the name needs to match.
            if IN.IGNORED in mask or self._name == name:
                logger.debug('Invalidating!')
                self._watch.invalidate()

    def __init__(self, watch: 'PathWatch', event: Event, dirfd: int, name: str):
        self._watch = watch
        self._name = name.encode('utf-8')

        # establishing invalidation watches is best-effort and can fail for a
        # number of reasons, including search (+x) but not read (+r) permission
        # on a particular path component, or exceeding limits on watches
        try:
            mask = IN.CREATE | IN.DELETE | IN.MOVE | IN.DELETE_SELF | IN.IGNORED
            self._source = event.add_inotify_fd(dirfd, mask, self.event)
        except OSError:
            self._source = None

    def close(self) -> None:
        # This is a little bit tricky: systemd doesn't have a specific close
        # API outside of unref, so let's make it as explicit as possible.
        self._watch = None
        if self._source is not None:
            self._source.cancel()
            self._source = None


class PathStack(List[str]):
    def add_path(self, pathname: str) -> None:
        # TO DO: consider doing something reasonable with trailing slashes
        # this is a stack, popped from the end: push components in reverse
        self.extend(item for item in reversed(pathname.split('/')) if item)
        if pathname.startswith('/'):
            self.append('/')

    def __init__(self, path: str):
        super().__init__()
        self.add_path(path)


class Listener:
    def do_inotify_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
        raise NotImplementedError

    def do_identity_changed(self, fd: Optional[Handle], errno: Optional[int]) -> None:
        raise NotImplementedError


class PathWatch:
    _event: Event
    _listener: Listener
    _path: str
    _invalidators: List[WatchInvalidator]
    _errno: Optional[int]
    _source: Optional[Event.Source]
    _tag: Optional[None]
    _fd: Handle

    def __init__(self, path: str, listener: Listener, event: Optional[Event] = None):
        self._event = event or Event.default()
        self._path = path
        self._listener = listener

        self._invalidators = []
        self._errno = None
        self._source = None
        self._tag = None
        self._fd = Handle()

        self.invalidate()

    def got_event(self, mask: IN, cookie: int, name: Optional[bytes]) -> None:
        logger.debug('target event %s: %s %s %s', self._path, mask, cookie, name)
        self._listener.do_inotify_event(mask, cookie, name)

    def _cancel_source(self) -> None:
        if self._source is not None:
            self._source.cancel()
            self._source = None

    def invalidate(self) -> None:
        for invalidator in self._invalidators:
            invalidator.close()
        self._invalidators = []

        try:
            fd = self.walk()
        except OSError as error:
            logger.debug('walk ended in error %d', error.errno)

            if self._source or self._fd or self._errno != error.errno:
                logger.debug('Ending existing watches.')
                self._cancel_source()
                self._fd.close()
                self._fd = Handle()
                self._errno = error.errno

                logger.debug('Notifying of new error state %d', self._errno)
                self._listener.do_identity_changed(None, self._errno)

            return

        with fd:
            logger.debug('walk successful.  Got fd %d', fd)
            if fd == self._fd:
                logger.debug('fd seems to refer to same file.  Doing nothing.')
                return

            logger.debug('This file is new for us.  Removing old watch.')
            self._cancel_source()
            self._fd.close()
            self._fd = fd.steal()

            try:
                logger.debug('Establishing a new watch.')
                self._source = self._event.add_inotify_fd(self._fd, IN.CHANGED, self.got_event)
                logger.debug('Watching successfully.  Notifying of new identity.')
                self._listener.do_identity_changed(self._fd, None)
            except OSError as error:
                logger.debug('Watching failed (%d).  Notifying of new identity.', error.errno)
                self._listener.do_identity_changed(self._fd, error.errno)

    def walk(self) -> Handle:
        remaining_symlink_lookups = 40
        remaining_components = PathStack(self._path)
        dirfd = Handle()

        try:
            logger.debug('Starting path walk')

            while remaining_components:
                logger.debug('r=%s dfd=%s', remaining_components, dirfd)

                name = remaining_components.pop()

                if dirfd and name != '/':
                    self._invalidators.append(WatchInvalidator(self, self._event, dirfd, name))

                with Handle.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=dirfd) as fd:
                    mode = os.fstat(fd).st_mode

                    if stat.S_ISLNK(mode):
                        if remaining_symlink_lookups == 0:
                            raise OSError(errno.ELOOP, os.strerror(errno.ELOOP))
                        remaining_symlink_lookups -= 1
                        linkpath = os.readlink('', dir_fd=fd)
                        logger.debug('%s is a symlink.  adding %s to components', name, linkpath)
                        remaining_components.add_path(linkpath)

                    else:
                        dirfd.close()
                        dirfd = fd.steal()

            return dirfd.steal()

        finally:
            dirfd.close()

    def close(self) -> None:
        for invalidator in self._invalidators:
            invalidator.close()
        self._invalidators = []
        self._cancel_source()
        self._fd.close()
''',
  'cockpit/_vendor/systemd_ctypes/py.typed': br'''''',
  'cockpit/_vendor/systemd_ctypes/typing.py': br'''import typing
from typing import TYPE_CHECKING

# The goal here is to continue to work on Python 3.6 while pretending to have
# access to some modern typing features.  The shims provided here are only
# enough for what we need for systemd_ctypes to work at runtime.


if TYPE_CHECKING:
    # See https://github.com/python/mypy/issues/1153 for why we do this separately
    from typing import Annotated, ForwardRef, TypeGuard, get_args, get_origin

else:
    # typing.get_args() and .get_origin() appeared in Python 3.8 but Annotated
    # arrived in 3.9.  Unfortunately, it's difficult to implement a mocked up
    # version of Annotated which works with the real typing.get_args() and
    # .get_origin() in Python 3.8, so we use our own versions there as well.
    try:
        from typing import Annotated, get_args, get_origin
    except ImportError:
        class AnnotatedMeta(type):
            def __getitem__(cls, params):
                class AnnotatedType:
                    __origin__ = Annotated
                    __args__ = params
                return AnnotatedType

        class Annotated(metaclass=AnnotatedMeta):
            pass

        def get_args(annotation: typing.Any) -> typing.Tuple[typing.Any]:
            return getattr(annotation, '__args__', ())

        def get_origin(annotation: typing.Any) -> typing.Any:
            return getattr(annotation, '__origin__', None)

    try:
        from typing import ForwardRef
    except ImportError:
        from typing import _ForwardRef as ForwardRef

    try:
        from typing import TypeGuard
    except ImportError:
        T = typing.TypeVar('T')

        class TypeGuard(typing.Generic[T]):
            pass


__all__ = (
    'TYPE_CHECKING',
    'Annotated',
    'ForwardRef',
    'TypeGuard',
    'get_args',
    'get_origin',
)
''',
  'cockpit/_version.py': br'''__version__ = '360'
''',
  'cockpit/beiboot.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import argparse
import asyncio
import base64
import importlib.resources
import logging
import os
import re
import shlex
import tempfile
import time
from pathlib import Path
from typing import Dict, Literal, Optional, Sequence

from cockpit import polyfills
from cockpit._vendor import ferny
from cockpit._vendor.bei import bootloader
from cockpit.beipack import BridgeBeibootHelper
from cockpit.bridge import parse_os_release, setup_logging
from cockpit.channel import ChannelRoutingRule
from cockpit.channels.packages import PackagesChannel
from cockpit.jsonutil import JsonObject, get_str
from cockpit.osinfo import supported_oses
from cockpit.packages import Packages, PackagesLoader, patch_libexecdir
from cockpit.peer import Peer
from cockpit.protocol import CockpitProblem, CockpitProtocolError
from cockpit.router import Router, RoutingRule
from cockpit.transports import StdioTransport

logger = logging.getLogger('cockpit.beiboot')


def ensure_ferny_askpass() -> Path:
    """Create askpass executable

    We need this for the flatpak: ssh and thus the askpass program run on the host (via flatpak-spawn),
    not the flatpak. Thus we cannot use the shipped cockpit-askpass program.
    """
    src_path = importlib.resources.files(ferny.__name__) / 'interaction_client.py'
    src_data = src_path.read_bytes()

    # Create the file in $XDG_CACHE_HOME, one of the few locations that a flatpak can write to
    xdg_cache_home = os.environ.get('XDG_CACHE_HOME')
    if xdg_cache_home is None:
        xdg_cache_home = os.path.expanduser('~/.cache')
    os.makedirs(xdg_cache_home, exist_ok=True)
    dest_path = Path(xdg_cache_home, 'cockpit-client-askpass')

    logger.debug("Checking if %s exists...", dest_path)

    # Check first to see if we already wrote the current version
    try:
        if dest_path.read_bytes() != src_data:
            logger.debug("  ... it exists but is not the same version...")
            raise ValueError
        if not dest_path.stat().st_mode & 0o100:
            logger.debug("  ... it has the correct contents, but is not executable...")
            raise ValueError
    except (FileNotFoundError, ValueError):
        logger.debug("  ... writing contents.")
        dest_path.write_bytes(src_data)
        dest_path.chmod(0o700)

    return dest_path


class ProxyPackagesLoader(PackagesLoader):
    file_status: Dict[str, bool]

    def __init__(self, file_status: Dict[str, bool]):
        self.file_status = file_status

    def path_exists(self, path: str) -> bool:
        return self.file_status.get(path, False)


BEIBOOT_GADGETS = {
    "report_exists": r"""
    import os
    def report_exists(files):
        command('cockpit.report-exists', {name: os.path.exists(name) for name in files})
    """,
    "check_os_release": r"""
    import os
    def check_os_release(_argv):
        try:
            with open('/etc/os-release') as f:
                command('cockpit.check-os-release', f.read())
        except OSError:
                command('cockpit.check-os-release', "")
    """,
    "force_exec": r"""
    import os
    def force_exec(argv):
        try:
            os.execvp(argv[0], argv)
        except OSError as e:
            command('cockpit.fail-no-cockpit', str(e))
    """,
    **ferny.BEIBOOT_GADGETS
}


class DefaultRoutingRule(RoutingRule):
    peer: 'Peer | None'

    def __init__(self, router: Router):
        super().__init__(router)

    def apply_rule(self, options: JsonObject) -> 'Peer | None':
        return self.peer

    def shutdown(self) -> None:
        if self.peer is not None:
            self.peer.close()


class AuthorizeResponder(ferny.AskpassHandler):
    commands = ('ferny.askpass', 'cockpit.report-exists', 'cockpit.fail-no-cockpit', 'cockpit.check-os-release')
    router: Router

    def __init__(self, router: Router, basic_password: Optional[str]):
        self.router = router
        self.basic_password = basic_password
        self.have_basic_password = basic_password is not None

    async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]:
        logger.debug("AuthorizeResponder: prompt %r, messages %r, hint %r", prompt, messages, hint)

        if self.have_basic_password and 'password:' in prompt.lower():
            # only the first prompt is the current password (with NumberOfPasswordPrompts=1); further prompts
            # are e.g. forced/expired PAM password changes
            if self.basic_password is not None:
                logger.debug("AuthorizeResponder: sending Basic auth password for prompt %r", prompt)
                reply = self.basic_password
                self.basic_password = None
                return reply

        if hint == 'none':
            # We have three problems here:
            #
            #   - we have no way to present a message on the login
            #     screen without presenting a prompt and a button
            #   - the login screen will not try to repost the login
            #     request because it doesn't understand that we are not
            #     waiting on input, which means that it won't notice
            #     that we've logged in successfully
            #   - cockpit-ws has an issue where if we retry the request
            #     again after login succeeded then it won't forward the
            #     init message to the client, stalling the login.  This
            #     is a race and can't be fixed without -ws changes.
            #
            # Let's avoid all of that by just showing nothing.
            return None

        # FIXME: is this a host key prompt? This should be handled more elegantly,
        # see https://github.com/cockpit-project/cockpit/pull/19668
        # Since openssh 10.2p1-1 the host key prompt changed:
        # - now includes a colon after "key fingerprint is"
        # - does not end on a dot but a newline (likely for easier copy-pasting
        fp_match = re.search(r'\n(\w+) key fingerprint is:? ([^.\n]+)', prompt)
        # let ssh resolve aliases, don't use our original "destination"
        host_match = re.search(r"authenticity of host '([^ ]+) ", prompt)
        args = {}
        if fp_match and host_match:
            hostname = host_match.group(1)
            # common case: don't ask for localhost's host key
            if hostname == '127.0.0.1':
                logger.debug('auto-accepting fingerprint for 127.0.0.1: %s', host_match)
                return 'yes'

            # login.js do_hostkey_verification() expects host-key to be "hostname keytype key"
            # we don't have access to the full key yet (that will be sent later as `login-data` challenge response),
            # so just send a placeholder
            args['host-key'] = f'{hostname} {fp_match.group(1)} login-data'
            # very oddly named, login.js do_hostkey_verification() expects the fingerprint here for user confirmation
            args['default'] = fp_match.group(2)

        challenge_id = f'{os.getpid()}-{time.time()}'
        challenge_prefix = f'X-Conversation {challenge_id}'
        challenge = challenge_prefix + ' ' + base64.b64encode(prompt.encode()).decode()
        response = await self.router.request_authorization(challenge,
                                                           timeout=None,
                                                           messages=messages,
                                                           prompt=prompt,
                                                           hint=hint,
                                                           echo=False,
                                                           **args)

        if not response.startswith(challenge_prefix):
            raise CockpitProtocolError(
                f"AuthorizeResponder: response {response} does not match challenge {challenge_prefix}")
        b64 = response.removeprefix(challenge_prefix).strip()
        response = base64.b64decode(b64.encode()).decode()
        logger.debug('Returning a %d chars response', len(response))
        return response

    async def do_custom_command(self, command: str, args: tuple, fds: list[int], stderr: str) -> None:
        logger.debug('Got ferny command %s %s %s', command, args, stderr)

        if command == 'cockpit.report-exists':
            file_status, = args
            # FIXME: evil duck typing here -- this is a half-way Bridge
            self.router.packages = Packages(loader=ProxyPackagesLoader(file_status))  # type: ignore[attr-defined]
            self.router.routing_rules.insert(0, ChannelRoutingRule(self.router, [PackagesChannel]))

        if command == 'cockpit.fail-no-cockpit':
            raise CockpitProblem('no-cockpit', message=args[0])

        if command == 'cockpit.check-os-release':
            remote_os = parse_os_release(args[0])
            logger.debug("cockpit.check-os-release: remote OS: %r", remote_os)
            logger.debug("cockpit.check-os-release: supported OSes: %r", supported_oses)

            for osinfo in supported_oses:
                # we want to allow e.g. VERSION_ID == None matching to check for key absence
                if all(remote_os.get(k) == v for k, v in osinfo.items()):
                    logger.debug("cockpit.check-os-release: remote matches supported OS %r", osinfo)
                    return

            # allow unknown OSes as long as local and remote are the same
            logger.debug("cockpit.check-os-release: remote: %r", remote_os)
            try:
                with open("/etc/os-release") as f:
                    this_os = parse_os_release(f.read())
            except OSError as e:
                logger.warning("failed to read local /etc/os-release, skipping OS compatibility check: %s", e)
                return

            if remote_os.get('ID') == this_os.get('ID') and remote_os.get('VERSION_ID') == this_os.get('VERSION_ID'):
                logger.debug("cockpit.check-os-release: remote OS matches local OS %r", this_os)
                return

            unsupported = f'{remote_os.get("NAME", remote_os.get("ID", "?"))} {remote_os.get("VERSION_ID", "")}'
            raise CockpitProblem('no-cockpit', unsupported=unsupported)


def python_interpreter(comment: str) -> tuple[Sequence[str], Sequence[str]]:
    return ('python3', '-ic', f'# {comment}'), ()


def via_ssh(cmd: Sequence[str], dest: str, ssh_askpass: Path, *ssh_opts: str) -> tuple[Sequence[str], Sequence[str]]:
    host, _, port = dest.rpartition(':')
    # catch cases like `host:123` but not cases like `[2001:abcd::1]` or `::1`
    if port.isdigit() and not host.endswith(':'):
        # strip off [] IPv6 brackets
        if host.startswith('[') and host.endswith(']'):
            host = host[1:-1]
        destination = ['-p', port, '--', host]
    else:
        destination = ['--', dest]

    return (
        'ssh', *ssh_opts, *destination, shlex.join(cmd)
    ), (
        f'SSH_ASKPASS={ssh_askpass!s}',
        # DISPLAY=x helps trigger a heuristic in old ssh versions to force them
        # to use askpass.  Newer ones look at SSH_ASKPASS_REQUIRE.
        'DISPLAY=x',
        'SSH_ASKPASS_REQUIRE=force',
    )


def flatpak_spawn(cmd: Sequence[str], env: Sequence[str]) -> tuple[Sequence[str], Sequence[str]]:
    return (
        'flatpak-spawn', '--host',
        *(f'--env={kv}' for kv in env),
        *cmd
    ), (
    )


class SshPeer(Peer):
    mode: 'Literal["always"] | Literal["never"] | Literal["supported"] | Literal["auto"]'

    def __init__(self, router: Router, destination: str, args: argparse.Namespace):
        self.destination = destination
        self.remote_bridge = args.remote_bridge
        self.tmpdir = tempfile.TemporaryDirectory()
        self.known_hosts_file = Path(self.tmpdir.name) / 'user-known-hosts'
        self.basic_password: 'str | None' = None
        super().__init__(router)

    async def do_connect_transport(self) -> None:
        # Choose your own adventure...
        if os.path.exists('/.flatpak-info'):
            await self.connect_from_flatpak()
        else:
            await self.connect_from_bastion_host()

    async def connect_from_flatpak(self) -> None:
        # We want to run a python interpreter somewhere...
        cmd, env = python_interpreter('cockpit-bridge')

        # Remote host?  Wrap command with SSH
        if self.destination != 'localhost':
            # we run ssh and thus the helper on the host, always use the xdg-cache helper
            cmd, env = via_ssh(cmd, self.destination, ensure_ferny_askpass())

        cmd, env = flatpak_spawn(cmd, env)

        await self.boot(cmd, env)

    async def connect_from_bastion_host(self) -> None:
        known_hosts = None
        # right now we open a new ssh connection for each auth attempt
        args = ['-o', 'NumberOfPasswordPrompts=1']

        # do we have user/password (Basic auth) from the login page?
        auth = await self.router.request_authorization_object("*")
        response = get_str(auth, 'response')

        if response.startswith('Basic '):
            decoded = base64.b64decode(response[6:]).decode()
            user_password, _, known_hosts = decoded.partition('\0')
            user, _, self.basic_password = user_password.partition(':')
            if user:  # this can be empty, i.e. auth is just ":"
                logger.debug("got username %s and password from Basic auth", user)
                args += ['-l', user]

        if self.basic_password is None:
            args += ['-o', 'PasswordAuthentication=no']

        # We want to run a python interpreter somewhere...
        cmd, env = python_interpreter('cockpit-bridge')

        # outside of the flatpak we expect cockpit-ws and thus an installed helper
        askpass = patch_libexecdir('${libexecdir}/cockpit-askpass')
        assert isinstance(askpass, str)
        ssh_askpass = Path(askpass)
        if not ssh_askpass.exists():
            logger.error("Could not find cockpit-askpass helper at %r", askpass)

        env_known_hosts = os.getenv('COCKPIT_SSH_KNOWN_HOSTS_FILE')
        if env_known_hosts is not None:
            args += ['-o', f'GlobalKnownHostsFile={env_known_hosts}']

        if known_hosts is not None:
            self.known_hosts_file.write_text(known_hosts)
            args += ['-o', f'UserKnownHostsfile={self.known_hosts_file!s}']
        cmd, env = via_ssh(cmd, self.destination, ssh_askpass, *args)

        await self.boot(cmd, env)

    async def boot(self, cmd: Sequence[str], env: Sequence[str]) -> None:
        beiboot_helper = BridgeBeibootHelper(self)
        agent = ferny.InteractionAgent([AuthorizeResponder(self.router, self.basic_password), beiboot_helper])

        logger.debug("Launching command: cmd=%s env=%s", cmd, env)
        transport = await self.spawn(cmd, env, stderr=agent, start_new_session=True)

        if self.remote_bridge == 'auto':
            exec_cockpit_bridge_steps = [('try_exec', (['cockpit-bridge'],))]
        elif self.remote_bridge == 'always':
            exec_cockpit_bridge_steps = [('force_exec', (['cockpit-bridge'],))]
        elif self.remote_bridge == 'supported':
            # native bridge first; check OS compatibility for beiboot fallback
            exec_cockpit_bridge_steps = [('try_exec', (['cockpit-bridge'],)), ('check_os_release', ([],))]
        else:
            assert self.remote_bridge == 'never'
            exec_cockpit_bridge_steps = []

        # Send the first-stage bootloader
        stage1 = bootloader.make_bootloader([
            *exec_cockpit_bridge_steps,
            ('report_exists', [list(PackagesLoader.get_condition_files())]),
            *beiboot_helper.steps,
        ], gadgets=BEIBOOT_GADGETS)
        transport.write(stage1.encode())

        # Wait for "init" or error, handling auth and beiboot requests
        await agent.communicate()

    def do_superuser_init_done(self) -> None:
        self.basic_password = None

    def do_authorize(self, message: JsonObject) -> None:
        logger.debug("SshPeer.do_authorize: %r; have password %s", message, self.basic_password is not None)
        if get_str(message, 'challenge').startswith('plain1:'):
            cookie = get_str(message, 'cookie')
            if self.basic_password is not None:
                logger.debug("SshPeer.do_authorize: responded with password")
                self.write_control(command='authorize', cookie=cookie, response=self.basic_password)
                self.basic_password = None  # once is enough
                return

        logger.debug("SshPeer.do_authorize: authentication-unavailable")
        self.write_control(command='authorize', cookie=cookie, problem='authentication-unavailable')


class SshBridge(Router):
    packages: Optional[Packages] = None
    ssh_peer: SshPeer

    def __init__(self, args: argparse.Namespace):
        # By default, we route everything to the other host.  We add an extra
        # routing rule for the packages webserver only if we're running the
        # beipack.
        rule = DefaultRoutingRule(self)
        super().__init__([rule])

        # This needs to be created after Router.__init__ is called.
        self.ssh_peer = SshPeer(self, args.destination, args)
        rule.peer = self.ssh_peer

    def do_send_init(self) -> None:
        pass  # wait for the peer to do it first

    def do_init(self, message):
        # forward our init options to the remote bridge; we are transparent
        # except for the explicit-superuser handling in SshPeer
        logger.debug("SshBridge.do_init: %r", message)
        self.ssh_peer.write_control(message)

    def setup_session(self) -> None:
        # if ssh dies during the session, go down with it
        self.ssh_peer.add_done_callback(self.close)


async def run(args) -> None:
    logger.debug("Hi. How are you today?")

    bridge = SshBridge(args)
    StdioTransport(asyncio.get_running_loop(), bridge)

    try:
        message = dict(await bridge.ssh_peer.start())

        if bridge.ssh_peer.known_hosts_file.exists():
            bridge.write_control(
                command='authorize', challenge='x-login-data', cookie='-', login_data={
                    'known-hosts': bridge.ssh_peer.known_hosts_file.read_text()
                }
            )

        # See comment in do_init() above: we tell cockpit-ws that we support
        # this and then handle it ourselves when we get the init message.
        capabilities = message.setdefault('capabilities', {})
        if not isinstance(capabilities, dict):
            bridge.write_control(command='init', problem='protocol-error', message='capabilities must be a dict')
            return
        assert isinstance(capabilities, dict)  # convince mypy
        capabilities['explicit-superuser'] = True

        # only patch the packages line if we are in beiboot mode
        if bridge.packages:
            message['packages'] = dict.fromkeys(bridge.packages.packages)

        bridge.write_control(message)
        bridge.ssh_peer.thaw_endpoint()
    except ferny.InteractionError as exc:
        error = ferny.ssh_errors.get_exception_for_ssh_stderr(str(exc))
        logger.debug("ferny.InteractionError: %s, interpreted as: %r", exc, error)
        if isinstance(error, ferny.SshAuthenticationError):
            problem = 'authentication-failed'
        elif isinstance(error, ferny.SshChangedHostKeyError):
            problem = 'invalid-hostkey'
        elif isinstance(error, ferny.SshHostKeyError):
            problem = 'unknown-hostkey'
        elif isinstance(error, OSError):
            # usually DNS/socket errors
            problem = 'unknown-host'
        elif isinstance(error, ferny.SshError):
            problem = 'authentication-failed'
        else:
            problem = 'internal-error'
        # if the user confirmed a new SSH host key before the error, tell the UI
        if bridge.ssh_peer.known_hosts_file.exists():
            bridge.write_control(command='init', problem=problem, message=str(error),
                                 known_hosts=bridge.ssh_peer.known_hosts_file.read_text())
        else:
            bridge.write_control(command='init', problem=problem, message=str(error))
        return
    except CockpitProblem as exc:
        logger.debug("CockpitProblem: %s", exc)
        bridge.write_control(exc.attrs, command='init')
        return
    except asyncio.CancelledError:
        logger.debug("Peer bridge got cancelled, exiting")
        return

    logger.debug('Startup done.  Looping until connection closes.')
    bridge.setup_session()
    try:
        await bridge.communicate()
    except BrokenPipeError:
        # expected if the peer doesn't hang up cleanly
        pass


def main() -> None:
    polyfills.install()

    parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')
    parser.add_argument('--remote-bridge', choices=['auto', 'never', 'supported', 'always'], default='auto',
                        help="How to run cockpit-bridge from the remote host: auto: if installed (default), "
                        "never: always copy the local one; "
                        "supported: if not installed, copy local one for compatible OSes, fail otherwise; "
                        "always: fail if not installed")
    parser.add_argument('--debug', action='store_true')
    parser.add_argument('destination', help="Name of the remote host to connect to, or 'localhost'")
    args = parser.parse_args()

    setup_logging(debug=args.debug)

    asyncio.run(run(args), debug=args.debug)


if __name__ == '__main__':
    main()
''',
  'cockpit/beipack.py': br'''#
# Copyright (C) 2023 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import logging
import lzma
from typing import Sequence, Tuple

from cockpit._vendor import ferny
from cockpit._vendor.bei import beipack

from .data import read_cockpit_data_file
from .peer import Peer, PeerError

logger = logging.getLogger(__name__)


def get_bridge_beipack_xz() -> Tuple[str, bytes]:
    try:
        bridge_beipack_xz = read_cockpit_data_file('cockpit-bridge.beipack.xz')
        logger.debug('Got pre-built cockpit-bridge.beipack.xz')
    except FileNotFoundError:
        logger.debug('Pre-built cockpit-bridge.beipack.xz; building our own.')
        # beipack ourselves
        cockpit_contents = beipack.collect_module('cockpit', recursive=True)
        bridge_beipack = beipack.pack(cockpit_contents, entrypoint='cockpit.bridge:main', args='beipack=True')
        bridge_beipack_xz = lzma.compress(bridge_beipack.encode())
        logger.debug('  ... done!')

    return 'cockpit/data/cockpit-bridge.beipack.xz', bridge_beipack_xz


class BridgeBeibootHelper(ferny.InteractionHandler):
    # ferny.InteractionHandler ClassVar
    commands = ['beiboot.provide', 'beiboot.exc']

    peer: Peer
    payload: bytes
    steps: Sequence[Tuple[str, Sequence[object]]]

    def __init__(self, peer: Peer, args: Sequence[str] = ()) -> None:
        filename, payload = get_bridge_beipack_xz()

        self.peer = peer
        self.payload = payload
        self.steps = (('boot_xz', (filename, len(payload), tuple(args))),)

    async def run_command(self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str) -> None:
        logger.debug('Got ferny request %s %s %s %s', command, args, fds, stderr)
        if command == 'beiboot.provide':
            try:
                size, = args
                assert size == len(self.payload)
            except (AssertionError, ValueError) as exc:
                raise PeerError('internal-error', message=f'ferny interaction error {exc!s}') from exc

            assert self.peer.transport is not None
            logger.debug('Writing %d bytes of payload', len(self.payload))
            self.peer.transport.write(self.payload)
        elif command == 'beiboot.exc':
            raise PeerError('internal-error', message=f'Remote exception: {args[0]}')
        else:
            raise PeerError('internal-error', message=f'Unexpected ferny interaction command {command}')
''',
  'cockpit/bridge.py': r'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import argparse
import asyncio
import contextlib
import grp
import json
import logging
import os
import pwd
import shlex
import socket
import stat
import subprocess
import sys
from typing import Dict, Iterable, List, Optional, Sequence, Tuple, Type

from cockpit._vendor.ferny import interaction_client
from cockpit._vendor.systemd_ctypes import bus, run_async

from . import polyfills
from ._version import __version__
from .channel import ChannelRoutingRule
from .channels import CHANNEL_TYPES
from .config import Config, Environment
from .internal_endpoints import EXPORTS
from .jsonutil import JsonError, JsonObject, JsonValue, get_dict
from .packages import BridgeConfig, Packages, PackagesListener
from .peer import PeersRoutingRule
from .remote import HostRoutingRule
from .router import Router
from .superuser import SuperuserRoutingRule
from .transports import StdioTransport

logger = logging.getLogger(__name__)


class InternalBus:
    exportees: List[bus.Slot]

    def __init__(self, exports: Iterable[Tuple[str, Type[bus.BaseObject]]]):
        client_socket, server_socket = socket.socketpair()
        self.client = bus.Bus.new(fd=client_socket.detach())
        self.server = bus.Bus.new(fd=server_socket.detach(), server=True)
        self.exportees = [self.server.add_object(path, cls()) for path, cls in exports]

    def export(self, path: str, obj: bus.BaseObject) -> None:
        self.exportees.append(self.server.add_object(path, obj))


class Bridge(Router, PackagesListener):
    channels: ChannelRoutingRule
    internal_bus: InternalBus
    packages: Optional[Packages]
    bridge_configs: Sequence[BridgeConfig]
    args: argparse.Namespace

    def __init__(self, args: argparse.Namespace):
        self.internal_bus = InternalBus(EXPORTS)
        self.bridge_configs = []
        self.args = args

        self.superuser_rule = SuperuserRoutingRule(self, privileged=args.privileged)
        self.internal_bus.export('/superuser', self.superuser_rule)

        self.internal_bus.export('/config', Config())
        self.internal_bus.export('/environment', Environment())

        self.peers_rule = PeersRoutingRule(self)

        if args.beipack:
            # Some special stuff for beipack
            self.superuser_rule.set_configs((
                BridgeConfig({
                    "privileged": True,
                    "spawn": ["sudo", "-k", "-A", "python3", "-ic", "# cockpit-bridge", "--privileged"],
                    "environ": ["SUDO_ASKPASS=ferny-askpass"],
                }),
                BridgeConfig({
                    "privileged": True,
                    "polkit": True,
                    "method": "StartTransientUnit",
                    "spawn": [sys.executable, "-ic", "# cockpit-bridge", "--privileged"],
                }),
            ))
            self.packages = None
        elif args.privileged:
            self.packages = None
        else:
            self.packages = Packages(self)
            self.internal_bus.export('/packages', self.packages)
            self.packages_loaded()

        self.channels = ChannelRoutingRule(self, CHANNEL_TYPES)

        super().__init__([
            HostRoutingRule(self),
            self.superuser_rule,
            self.channels,
            self.peers_rule,
        ])

    def info(self) -> JsonObject:
        pw = pwd.getpwuid(os.getuid())

        # We want the primary group first in the list, without duplicates.
        # This is a bit awkward because `set()` is unordered...
        group = grp.getgrgid(pw.pw_gid).gr_name
        groups = [group]
        for gr in grp.getgrall():
            if pw.pw_name in gr.gr_mem and gr.gr_name not in groups:
                groups.append(gr.gr_name)

        return {
            'channels': self.channels.capabilities(),
            'os_release': self.get_os_release(),
            'user': {
                'fullname': pw.pw_gecos,
                'gid': pw.pw_gid,
                'group': group,
                'groups': groups,
                'home': pw.pw_dir,
                'name': pw.pw_name,
                'shell': pw.pw_shell,
                'uid': pw.pw_uid,
            },
        }

    @staticmethod
    def get_os_release() -> JsonObject:
        try:
            file = open('/etc/os-release', encoding='utf-8')
        except FileNotFoundError:
            try:
                file = open('/usr/lib/os-release', encoding='utf-8')
            except FileNotFoundError:
                logger.warning("Neither /etc/os-release nor /usr/lib/os-release exists")
                return {}

        with file:
            return parse_os_release(file.read())

    def do_init(self, message: JsonObject) -> None:
        # we're only interested in the case where this is a dict, but
        # 'superuser' may well be `False` and that's not an error
        with contextlib.suppress(JsonError):
            superuser = get_dict(message, 'superuser')
            self.superuser_rule.init(superuser)

    def do_send_init(self) -> None:
        init_args: 'dict[str, JsonValue]' = {
            'capabilities': {'explicit-superuser': True},
            'command': 'init',
            'os-release': self.get_os_release(),
            'version': 1,
        }

        if self.packages is not None:
            init_args['packages'] = dict.fromkeys(self.packages.packages)

        self.write_control(init_args)

    # PackagesListener interface
    def packages_loaded(self) -> None:
        assert self.packages
        bridge_configs = self.packages.get_bridge_configs()
        if self.bridge_configs != bridge_configs:
            self.superuser_rule.set_configs(bridge_configs)
            self.peers_rule.set_configs(bridge_configs)
            self.bridge_configs = bridge_configs


async def run(args) -> None:
    logger.debug("Hi. How are you today?")

    # Unit tests require this
    me = pwd.getpwuid(os.getuid())
    os.environ['HOME'] = me.pw_dir
    os.environ['SHELL'] = me.pw_shell
    os.environ['USER'] = me.pw_name

    logger.debug('Starting the router.')
    router = Bridge(args)
    StdioTransport(asyncio.get_running_loop(), router)

    logger.debug('Startup done.  Looping until connection closes.')

    try:
        await router.communicate()
    except (BrokenPipeError, ConnectionResetError):
        # not unexpected if the peer doesn't hang up cleanly
        pass


def try_to_receive_stderr() -> None:
    try:
        ours, theirs = socket.socketpair()
        with ours:
            with theirs:
                interaction_client.command(2, 'cockpit.send-stderr', fds=[theirs.fileno()])
            _msg, fds, _flags, _addr = socket.recv_fds(ours, 1, 1)
    except OSError:
        return

    try:
        stderr_fd, = fds
        # We're about to abruptly drop our end of the stderr socketpair that we
        # share with the ferny agent.  ferny would normally treat that as an
        # unexpected error. Instruct it to do a clean exit, instead.
        interaction_client.command(2, 'ferny.end')
        os.dup2(stderr_fd, 2)
    finally:
        for fd in fds:
            os.close(fd)


def setup_journald() -> bool:
    # If stderr is a socket, prefer systemd-journal logging.  This covers the
    # case we're already connected to the journal but also the case where we're
    # talking to the ferny agent, while leaving logging to file or terminal
    # unaffected.
    if not stat.S_ISSOCK(os.fstat(2).st_mode):
        # not a socket?  Don't redirect.
        return False

    try:
        import systemd.journal  # type: ignore[import]
    except ImportError:
        # No python3-systemd?  Don't redirect.
        return False

    logging.root.addHandler(systemd.journal.JournalHandler())
    return True


def setup_logging(*, debug: bool) -> None:
    """Setup our logger with optional filtering of modules if COCKPIT_DEBUG env is set"""

    modules = os.getenv('COCKPIT_DEBUG', '')

    # Either setup logging via journal or via formatted messages to stderr
    if not setup_journald():
        logging.basicConfig(format='%(name)s-%(levelname)s: %(message)s')

    if debug or modules == 'all':
        logging.getLogger().setLevel(level=logging.DEBUG)
    elif modules:
        for module in modules.split(','):
            module = module.strip()
            if not module:
                continue

            logging.getLogger(module).setLevel(logging.DEBUG)


def parse_os_release(text: str) -> Dict[str, str]:
    os_release = {}
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith('#'):
            continue
        try:
            k, v = line.split('=')
            (v_parsed, ) = shlex.split(v)  # expect exactly one token
        except ValueError:
            logger.warning('Ignoring invalid line in os-release: %r', line)
            continue
        os_release[k] = v_parsed
    return os_release


def start_ssh_agent() -> None:
    # Launch the agent so that it goes down with us on EOF; PDEATHSIG would be more robust,
    # but it gets cleared on setgid ssh-agent, which some distros still do
    try:
        proc = subprocess.Popen(['ssh-agent', 'sh', '-ec', 'echo SSH_AUTH_SOCK=$SSH_AUTH_SOCK; read a'],
                                stdin=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
        assert proc.stdout is not None

        # Wait for the agent to write at least one line and look for the
        # listener socket.  If we fail to find it, kill the agent — something
        # went wrong.
        for token in shlex.shlex(proc.stdout.readline(), punctuation_chars=True):
            if token.startswith('SSH_AUTH_SOCK='):
                os.environ['SSH_AUTH_SOCK'] = token.replace('SSH_AUTH_SOCK=', '', 1)
                break
        else:
            proc.terminate()
            proc.wait()

    except FileNotFoundError:
        logger.debug("Couldn't start ssh-agent (FileNotFoundError)")

    except OSError as exc:
        logger.warning("Could not start ssh-agent: %s", exc)


def main(*, beipack: bool = False) -> None:
    polyfills.install()

    parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.')
    parser.add_argument('--privileged', action='store_true', help='Privileged copy of the bridge')
    parser.add_argument('--packages', action='store_true', help='Show Cockpit package information')
    parser.add_argument('--bridges', action='store_true', help='Show Cockpit bridges information')
    parser.add_argument('--debug', action='store_true', help='Enable debug output (very verbose)')
    parser.add_argument('--version', action='store_true', help='Show Cockpit version information')
    args = parser.parse_args()

    # This is determined by who calls us
    args.beipack = beipack

    # If we were run with --privileged then our stderr is currently being
    # consumed by the main bridge looking for startup-related error messages.
    # Let's switch back to the original stderr stream, which has a side-effect
    # of indicating that our startup is more or less complete.  Any errors
    # after this point will land in the journal.
    if args.privileged:
        try_to_receive_stderr()

    setup_logging(debug=args.debug)

    # Special modes
    if args.packages:
        Packages().show()
        return
    elif args.version:
        print(f'Version: {__version__}\nProtocol: 1')
        return
    elif args.bridges:
        print(json.dumps([config.__dict__ for config in Packages().get_bridge_configs()], indent=2))
        return

    # The privileged bridge doesn't need ssh-agent, but the main one does
    if 'SSH_AUTH_SOCK' not in os.environ and not args.privileged:
        start_ssh_agent()

    # asyncio.run() shim for Python 3.6 support
    run_async(run(args), debug=args.debug)


if __name__ == '__main__':
    main()
'''.encode('utf-8'),
  'cockpit/channel.py': r'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import codecs
import json
import logging
import traceback
import typing
from typing import BinaryIO, Callable, ClassVar, Collection, Generator, Mapping, Sequence, Type

from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_bool, get_enum, get_str
from .protocol import CockpitProblem
from .router import Endpoint, Router, RoutingRule

logger = logging.getLogger(__name__)


if typing.TYPE_CHECKING:
    _T = typing.TypeVar('_T')
    _P = typing.ParamSpec("_P")


class ChannelRoutingRule(RoutingRule):
    table: 'dict[str, list[Type[Channel]]]'

    def __init__(self, router: Router, channel_types: 'Collection[Type[Channel]]'):
        super().__init__(router)
        self.table = {}

        # Sort the channels into buckets by payload type
        for cls in channel_types:
            entry = self.table.setdefault(cls.payload, [])
            entry.append(cls)

        # Within each bucket, sort the channels so those with more
        # restrictions are considered first.
        for entry in self.table.values():
            entry.sort(key=lambda cls: len(cls.restrictions), reverse=True)

    def capabilities(self) -> JsonObject:
        result: 'dict[str, list[str]]' = {}

        for payload, impls in self.table.items():
            caps: 'list[str]' = []
            for impl in impls:
                caps.extend(impl.capabilities)
            result[payload] = caps
        return result

    def check_restrictions(self, restrictions: 'Collection[tuple[str, object]]', options: JsonObject) -> bool:
        for key, expected_value in restrictions:
            our_value = options.get(key)

            # If the match rule specifies that a value must be present and
            # we don't have it, then fail.
            if our_value is None:
                return False

            # If the match rule specified a specific expected value, and
            # our value doesn't match it, then fail.
            if expected_value is not None and our_value != expected_value:
                return False

        # Everything checked out
        return True

    def apply_rule(self, options: JsonObject) -> 'Channel | None':
        assert self.router is not None

        payload = options.get('payload')
        if not isinstance(payload, str):
            return None

        for cls in self.table.get(payload, []):
            if self.check_restrictions(cls.restrictions, options):
                return cls(self.router)
        else:
            return None

    def shutdown(self) -> None:
        pass  # we don't hold any state


class ChannelError(CockpitProblem):
    pass


class Channel(Endpoint):
    # Values borrowed from C implementation
    BLOCK_SIZE = 16 * 1024
    SEND_WINDOW = 2 * 1024 * 1024

    # Flow control book-keeping
    _send_pings: bool = False
    _out_sequence: int = 0
    _out_window: int = SEND_WINDOW
    _ack_bytes: bool

    # Task management
    _tasks: 'set[asyncio.Task]'
    _close_args: 'JsonObject | None' = None

    # Must be filled in by the channel implementation
    payload: 'ClassVar[str]'
    restrictions: 'ClassVar[Sequence[tuple[str, object]]]' = ()
    capabilities: 'ClassVar[Sequence[str]]' = ()

    # These get filled in from .do_open()
    channel = ''
    group = ''
    is_binary: bool
    decoder: 'codecs.IncrementalDecoder | None'

    # input
    def do_control(self, command: str, message: JsonObject) -> None:
        # Break the various different kinds of control messages out into the
        # things that our subclass may be interested in handling.  We drop the
        # 'message' field for handlers that don't need it.
        if command == 'open':
            self._tasks = set()
            self.channel = get_str(message, 'channel')
            if get_bool(message, 'flow-control', default=False):
                self._send_pings = True
            self._ack_bytes = get_enum(message, 'send-acks', ['bytes'], None) is not None
            self.group = get_str(message, 'group', 'default')
            self.is_binary = get_enum(message, 'binary', ['raw'], None) is not None
            self.decoder = None
            self.freeze_endpoint()
            self.do_open(message)
        elif command == 'ready':
            self.do_ready()
        elif command == 'done':
            self.do_done()
        elif command == 'close':
            self.do_close()
        elif command == 'ping':
            self.do_ping(message)
        elif command == 'pong':
            self.do_pong(message)
        elif command == 'options':
            self.do_options(message)

    def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
        # Already closing?  Ignore.
        if self._close_args is not None:
            return

        # Catch errors and turn them into close messages
        try:
            try:
                self.do_control(command, message)
            except JsonError as exc:
                raise ChannelError('protocol-error', message=str(exc)) from exc
        except ChannelError as exc:
            self.close(exc.get_attrs())

    def do_kill(self, host: 'str | None', group: 'str | None', _message: JsonObject) -> None:
        # Already closing?  Ignore.
        if self._close_args is not None:
            return

        if host is not None:
            return
        if group is not None and self.group != group:
            return
        self.do_close()

    # At least this one really ought to be implemented...
    def do_open(self, options: JsonObject) -> None:
        raise NotImplementedError

    # ... but many subclasses may reasonably want to ignore some of these.
    def do_ready(self) -> None:
        pass

    def do_done(self) -> None:
        pass

    def do_close(self) -> None:
        self.close()

    def do_options(self, message: JsonObject) -> None:
        raise ChannelError('not-supported', message='This channel does not implement "options"')

    # 'reasonable' default, overridden in other channels for receive-side flow control
    def do_ping(self, message: JsonObject) -> None:
        self.send_pong(message)

    def send_ack(self, data: bytes) -> None:
        if self._ack_bytes:
            self.send_control('ack', bytes=len(data))

    def do_channel_data(self, channel: str, data: bytes) -> None:
        # Already closing?  Ignore.
        if self._close_args is not None:
            return

        # Catch errors and turn them into close messages
        try:
            if not self.do_data(data):
                self.send_ack(data)
        except ChannelError as exc:
            self.close(exc.get_attrs())

    def do_data(self, data: bytes) -> 'bool | None':
        """Handles incoming data to the channel.

        Return value is True if the channel takes care of send acks on its own,
        in which case it should call self.send_ack() on `data` at some point.
        None or False means that the acknowledgement is sent automatically."""
        # By default, channels can't receive data.
        del data
        self.close()
        return True

    # output
    def ready(self, **kwargs: JsonValue) -> None:
        self.thaw_endpoint()
        self.send_control(command='ready', **kwargs)

    def __decode_frame(self, data: bytes, *, final: bool = False) -> str:
        assert self.decoder is not None
        try:
            return self.decoder.decode(data, final=final)
        except UnicodeDecodeError as exc:
            raise ChannelError('protocol-error', message=str(exc)) from exc

    def done(self) -> None:
        # any residue from partial send_data() frames? this is invalid, fail the channel
        if self.decoder is not None:
            self.__decode_frame(b'', final=True)
        self.send_control(command='done')

    # tasks and close management
    def is_closing(self) -> bool:
        return self._close_args is not None

    def _close_now(self) -> None:
        self.shutdown_endpoint(self._close_args)

    def _task_done(self, task):
        # Strictly speaking, we should read the result and check for exceptions but:
        #   - exceptions bubbling out of the task are programming errors
        #   - the only thing we'd do with it anyway, is to show it
        #   - Python already does that with its "Task exception was never retrieved" messages
        self._tasks.remove(task)
        if self._close_args is not None and not self._tasks:
            self._close_now()

    def create_task(self, coroutine, name=None):
        """Create a task associated with the channel.

        All tasks must exit before the channel can close.  You may not create
        new tasks after calling .close().
        """
        assert self._close_args is None
        task = asyncio.create_task(coroutine)
        self._tasks.add(task)
        task.add_done_callback(self._task_done)
        return task

    def close(self, close_args: 'JsonObject | None' = None) -> None:
        """Requests the channel to be closed.

        After you call this method, you won't get anymore `.do_*()` calls.

        This will wait for any running tasks to complete before sending the
        close message.
        """
        if self._close_args is not None:
            # close already requested
            return
        self._close_args = close_args or {}
        if not self._tasks:
            self._close_now()

    def send_bytes(self, data: bytes) -> bool:
        """Send binary data and handle book-keeping for flow control.

        The flow control is "advisory".  The data is sent immediately, even if
        it's larger than the window.  In general you should try to send packets
        which are approximately Channel.BLOCK_SIZE in size.

        Returns True if there is still room in the window, or False if you
        should stop writing for now.  In that case, `.do_resume_send()` will be
        called later when there is more room.

        Be careful with text channels (i.e. without binary="raw"): you are responsible
        for ensuring that @data is valid UTF-8. This isn't validated here for
        efficiency reasons.
        """
        self.send_channel_data(self.channel, data)

        if self._send_pings:
            out_sequence = self._out_sequence + len(data)
            if self._out_sequence // Channel.BLOCK_SIZE != out_sequence // Channel.BLOCK_SIZE:
                self.send_control(command='ping', sequence=out_sequence)
            self._out_sequence = out_sequence

        return self._out_sequence < self._out_window

    def send_data(self, data: bytes) -> bool:
        """Send data and transparently handle UTF-8 for text channels

        Use this for channels which can be text, but are not guaranteed to get
        valid UTF-8 frames -- i.e. multi-byte characters may be split across
        frames. This is expensive, so prefer send_text() or send_bytes() wherever
        possible.
        """
        if self.is_binary:
            return self.send_bytes(data)

        # for text channels we must avoid splitting UTF-8 multi-byte characters,
        # as these can't be sent to a WebSocket (and are often confusing for text streams as well)
        if self.decoder is None:
            self.decoder = codecs.getincrementaldecoder('utf-8')(errors='strict')
        return self.send_text(self.__decode_frame(data))

    def send_text(self, data: str) -> bool:
        """Send UTF-8 string data and handle book-keeping for flow control.

        Similar to `send_bytes`, but for text data.  The data is sent as UTF-8 encoded bytes.
        """
        return self.send_bytes(data.encode())

    def send_json(self, msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> bool:
        pretty = self.json_encoder.encode(create_object(msg, kwargs)) + '\n'
        return self.send_text(pretty)

    def do_pong(self, message):
        if not self._send_pings:  # huh?
            logger.warning("Got wild pong on channel %s", self.channel)
            return

        self._out_window = message['sequence'] + Channel.SEND_WINDOW
        if self._out_sequence < self._out_window:
            self.do_resume_send()

    def do_resume_send(self) -> None:
        """Called to indicate that the channel may start sending again."""
        # change to `raise NotImplementedError` after everyone implements it

    json_encoder: 'ClassVar[json.JSONEncoder]' = json.JSONEncoder(indent=2)

    def send_control(self, command: str, **kwargs: JsonValue) -> None:
        self.send_channel_control(self.channel, command, None, **kwargs)

    def send_pong(self, message: JsonObject) -> None:
        self.send_channel_control(self.channel, 'pong', message)


class ProtocolChannel(Channel, asyncio.Protocol):
    """A channel subclass that implements the asyncio Protocol interface.

    In effect, data sent to this channel will be written to the connected
    transport, and vice-versa.  Flow control is supported.

    The default implementation of the .do_open() method calls the
    .create_transport() abstract method.  This method should return a transport
    which will be used for communication on the channel.

    Otherwise, if the subclass implements .do_open() itself, it is responsible
    for setting up the connection and ensuring that .connection_made() is called.
    """
    _transport: 'asyncio.Transport | None' = None
    _send_pongs: bool = True
    _last_ping: 'JsonObject | None' = None
    _create_transport_task: 'asyncio.Task[asyncio.Transport] | None' = None
    _ready_info: 'JsonObject | None' = None

    # read-side EOF handling
    _close_on_eof: bool = False
    _eof: bool = False

    async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport:
        """Creates the transport for this channel, according to options.

        The event loop for the transport is passed to the function.  The
        protocol for the transport is the channel object, itself (self).

        This needs to be implemented by the subclass.
        """
        raise NotImplementedError

    def do_open(self, options: JsonObject) -> None:
        loop = asyncio.get_running_loop()
        self._create_transport_task = asyncio.create_task(self.create_transport(loop, options))
        self._create_transport_task.add_done_callback(self.create_transport_done)

    def create_transport_done(self, task: 'asyncio.Task[asyncio.Transport]') -> None:
        assert task is self._create_transport_task
        self._create_transport_task = None
        try:
            transport = task.result()
        except ChannelError as exc:
            self.close(exc.get_attrs())
            return

        self.connection_made(transport)
        if self._ready_info is not None:
            self.ready(**self._ready_info)
        else:
            self.ready()

    def connection_made(self, transport: asyncio.BaseTransport) -> None:
        assert isinstance(transport, asyncio.Transport)
        self._transport = transport

    def _get_close_args(self) -> JsonObject:
        return {}

    def connection_lost(self, exc: 'Exception | None') -> None:
        self.close(self._get_close_args())

    def do_data(self, data: bytes) -> None:
        assert self._transport is not None
        self._transport.write(data)

    def do_done(self) -> None:
        assert self._transport is not None
        if self._transport.can_write_eof():
            self._transport.write_eof()

    def do_close(self) -> None:
        if self._transport is not None:
            self._transport.close()

    def data_received(self, data: bytes) -> None:
        assert self._transport is not None
        try:
            if not self.send_data(data):
                self._transport.pause_reading()
        except ChannelError as exc:
            self.close(exc.get_attrs())

    def do_resume_send(self) -> None:
        assert self._transport is not None
        self._transport.resume_reading()

    def close_on_eof(self) -> None:
        """Mark the channel to be closed on EOF.

        Normally, ProtocolChannel tries to keep the channel half-open after
        receiving EOF from the transport.  This instructs that the channel
        should be closed on EOF.

        If EOF was already received, then calling this function will close the
        channel immediately.

        If you don't call this function, you are responsible for closing the
        channel yourself.
        """
        self._close_on_eof = True
        if self._eof:
            assert self._transport is not None
            self._transport.close()

    def eof_received(self) -> bool:
        self._eof = True
        self.done()
        return not self._close_on_eof

    # Channel receive-side flow control
    def do_ping(self, message):
        if self._send_pongs:
            self.send_pong(message)
        else:
            # we'll have to pong later
            self._last_ping = message

    def pause_writing(self) -> None:
        # We can't actually stop writing, but we can stop replying to pings
        self._send_pongs = False

    def resume_writing(self) -> None:
        self._send_pongs = True
        if self._last_ping is not None:
            self.send_pong(self._last_ping)
            self._last_ping = None


class AsyncChannel(Channel):
    """A subclass for async/await-style implementation of channels, with flow control

    This subclass provides asynchronous `read()` and `write()` calls for
    subclasses, with familiar semantics.  `write()` doesn't buffer, so the
    `done()` method on the base channel class can be used in a way similar to
    `shutdown()`.  A high-level `sendfile()` method is available to send the
    entire contents of a binary-mode file-like object.

    The subclass must provide an async `run()` function, which will be spawned
    as a task.  The task is cancelled when the channel is closed.

    On the receiving side, the channel will respond to flow control pings to
    indicate that it has received the data, but only after it has been consumed
    by `read()`.

    On the sending side, write() will block if the channel backs up.
    """

    # Receive-side flow control: intermix pings and data in the queue and reply
    # to pings as we dequeue them.  EOF is None.  This is a buffer: since we
    # need to handle do_data() without blocking, we have no choice.
    receive_queue: 'asyncio.Queue[bytes | JsonObject | None]'
    loop: asyncio.AbstractEventLoop

    # Send-side flow control
    write_waiter = None

    async def run(self, options: JsonObject) -> 'JsonObject | None':
        raise NotImplementedError

    async def run_wrapper(self, options: JsonObject) -> None:
        try:
            self.loop = asyncio.get_running_loop()
            self.close(await self.run(options))
        except asyncio.CancelledError:  # user requested close
            self.close()
        except ChannelError as exc:
            self.close(exc.get_attrs())
        except JsonError as exc:
            self.close({'problem': 'protocol-error', 'message': str(exc)})
        except BaseException:
            self.close({'problem': 'internal-error', 'cause': traceback.format_exc()})
            raise

    async def read(self) -> 'bytes | None':
        # Three possibilities for what we'll find:
        #  - None (EOF) → return None
        #  - a ping → send a pong
        #  - bytes (possibly empty) → ack the receipt, and return it
        while True:
            item = await self.receive_queue.get()
            if item is None:
                return None
            if isinstance(item, Mapping):
                self.send_pong(item)
            else:
                self.send_ack(item)
                return item

    async def write(self, data: bytes) -> None:
        if not self.send_data(data):
            self.write_waiter = self.loop.create_future()
            await self.write_waiter

    async def in_thread(self, fn: 'Callable[_P, _T]', *args: '_P.args', **kwargs: '_P.kwargs') -> '_T':
        return await self.loop.run_in_executor(None, fn, *args, **kwargs)

    async def sendfile(self, stream: BinaryIO) -> None:
        with stream:
            while True:
                data = await self.loop.run_in_executor(None, stream.read, Channel.BLOCK_SIZE)
                if data == b'':
                    break
                await self.write(data)

            self.done()

    def do_resume_send(self) -> None:
        if self.write_waiter is not None:
            self.write_waiter.set_result(None)
            self.write_waiter = None

    def do_open(self, options: JsonObject) -> None:
        self.receive_queue = asyncio.Queue()
        self._run_task = self.create_task(self.run_wrapper(options),
                                          name=f'{self.__class__.__name__}.run_wrapper({options})')

    def do_done(self) -> None:
        self.receive_queue.put_nowait(None)

    def do_close(self) -> None:
        self._run_task.cancel()

    def do_ping(self, message: JsonObject) -> None:
        self.receive_queue.put_nowait(message)

    def do_data(self, data: bytes) -> bool:
        self.receive_queue.put_nowait(data)
        return True  # we will send the 'ack' later (from read())


class GeneratorChannel(Channel):
    """A trivial Channel subclass for sending data from a generator with flow control.

    Calls the .do_yield_data() generator with the options from the open message
    and sends the data which it yields.  If the generator returns a value it
    will be used for the close message.
    """
    __generator: 'Generator[bytes, None, JsonObject]'

    def do_yield_data(self, options: JsonObject) -> 'Generator[bytes, None, JsonObject]':
        raise NotImplementedError

    def do_open(self, options: JsonObject) -> None:
        self.__generator = self.do_yield_data(options)
        self.do_resume_send()

    def do_resume_send(self) -> None:
        try:
            while self.send_data(next(self.__generator)):
                pass
        except StopIteration as stop:
            self.done()
            self.close(stop.value)
'''.encode('utf-8'),
  'cockpit/channels/__init__.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


from .dbus import DBusChannel
from .filesystem import FsInfoChannel, FsListChannel, FsReadChannel, FsReplaceChannel, FsWatchChannel
from .http_channel import HttpChannel
from .info import InfoChannel
from .metrics import InternalMetricsChannel
from .packages import PackagesChannel
from .pcp import PcpMetricsChannel
from .stream import SocketStreamChannel, SubprocessStreamChannel
from .trivial import EchoChannel, NullChannel

CHANNEL_TYPES = [  # noqa: RUF067
    DBusChannel,
    EchoChannel,
    FsInfoChannel,
    FsListChannel,
    FsReadChannel,
    FsReplaceChannel,
    FsWatchChannel,
    HttpChannel,
    InfoChannel,
    InternalMetricsChannel,
    NullChannel,
    PackagesChannel,
    PcpMetricsChannel,
    SubprocessStreamChannel,
    SocketStreamChannel,
]
''',
  'cockpit/channels/dbus.py': r'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


# Missing stuff compared to the C bridge that we should probably add:
#
# - removing matches
# - removing watches
# - emitting of signals
# - publishing of objects
# - failing more gracefully in some cases (during open, etc)
#
# Stuff we might or might not do:
#
# - using non-default service names
#
# Stuff we should probably not do:
#
# - emulation of ObjectManager via recursive introspection
# - automatic detection of ObjectManager below the given path_namespace
# - recursive scraping of properties for new object paths
#   (for path_namespace watches that don't hit an ObjectManager)

import asyncio
import errno
import json
import logging
import traceback
import xml.etree.ElementTree as ET

from cockpit._vendor import systemd_ctypes
from cockpit._vendor.systemd_ctypes import Bus, BusError, introspection

from ..channel import Channel, ChannelError

logger = logging.getLogger(__name__)

# The dbusjson3 payload
#
# This channel payload type translates JSON encoded messages on a
# Cockpit channel to D-Bus messages, in a mostly straightforward way.
# See doc/protocol.md for a description of the basics.
#
# However, dbusjson3 offers some advanced features as well that are
# meant to support the "magic" DBusProxy objects implemented by
# cockpit.js.  Those proxy objects "magically" expose all the methods
# and properties of a D-Bus interface without requiring any explicit
# binding code to be generated for a JavaScript client.  A dbusjson3
# channel does this by doing automatic introspection and property
# retrieval without much direction from the JavaScript client.
#
# The details of what exactly is done is not specified very strictly,
# and the Python bridge will likely differ from the C bridge
# significantly. This will be informed by what existing code actually
# needs, and we might end up with a more concrete description of what
# a client can actually expect.
#
# Here is an example of a more complex scenario:
#
# - The client adds a "watch" for a path namespace.  There is a
#   ObjectManager at the given path and the bridge emits "meta" and
#   "notify" messages to describe all interfaces and objects reported
#   by that ObjectManager.
#
# - The client makes a method call that causes a new object with a new
#   interface to appear at the ObjectManager.  The bridge will send a
#   "meta" and "notify" message to describe this new object.
#
# - Since the InterfacesAdded signal was emitted before the method
#   reply, the bridge must send the "meta" and "notify" messages
#   before the method reply message.
#
# - However, in order to construct the "meta" message, the bridge must
#   perform a Introspect call, and consequently must delay sending the
#   method reply until that call has finished.
#
# The Python bridge implements this delaying of messages with
# coroutines and a fair mutex. Every message coming from D-Bus will
# wait on the mutex for its turn to send its message on the Cockpit
# channel, and will keep that mutex locked until it is done with
# sending.  Since the mutex is fair, everyone will nicely wait in line
# without messages getting re-ordered.
#
# The scenario above will play out like this:
#
# - While adding the initial "watch", the lock is held until the
#   "meta" and "notify" messages have been sent.
#
# - Later, when the InterfacesAdded signal comes in that has been
#   triggered by the method call, the mutex will be locked while the
#   necessary introspection is going on.
#
# - The method reply will likely come while the mutex is locked, and
#   the task for sending that reply on the Cockpit channel will enter
#   the wait queue of the mutex.
#
# - Once the introspection is done and the new "meta" and "notify"
#   messages have been sent, the mutex is unlocked, the method reply
#   task acquires it, and sends its message.


class InterfaceCache:
    def __init__(self):
        self.cache = {}
        self.old = set()  # Interfaces already returned by get_interface_if_new

    def inject(self, interfaces) -> None:
        self.cache.update(interfaces)

    async def introspect_path(self, bus, destination, object_path):
        xml, = await bus.call_method_async(destination, object_path,
                                           'org.freedesktop.DBus.Introspectable',
                                           'Introspect')

        et = ET.fromstring(xml)

        interfaces = {tag.attrib['name']: introspection.parse_interface(tag) for tag in et.findall('interface')}

        # Add all interfaces we found: we might use them later
        self.inject(interfaces)

        return interfaces

    async def get_interface(self, interface_name, bus=None, destination=None, object_path=None):
        try:
            return self.cache[interface_name]
        except KeyError:
            pass

        if bus and object_path:
            try:
                await self.introspect_path(bus, destination, object_path)
            except BusError:
                pass

        return self.cache.get(interface_name)

    async def get_interface_if_new(self, interface_name, bus, destination, object_path):
        if interface_name in self.old:
            return None
        self.old.add(interface_name)
        return await self.get_interface(interface_name, bus, destination, object_path)

    async def get_signature(self, interface_name, method, bus=None, destination=None, object_path=None):
        interface = await self.get_interface(interface_name, bus, destination, object_path)
        if interface is None:
            raise KeyError(f'Interface {interface_name} is not found')

        return ''.join(interface['methods'][method]['in'])


def notify_update(notify, path, interface_name, props):
    notify.setdefault(path, {})[interface_name] = {k: v.value for k, v in props.items()}


class DBusChannel(Channel):
    json_encoder = systemd_ctypes.JSONEncoder(indent=2)
    payload = 'dbus-json3'

    matches = None
    name = None
    bus = None
    owner = None

    async def setup_name_owner_tracking(self):
        def send_owner(owner):
            # We must be careful not to send duplicate owner
            # notifications. cockpit.js relies on that.
            if self.owner != owner:
                self.owner = owner
                self.send_json(owner=owner)

        def handler(message):
            _name, _old, new = message.get_body()
            send_owner(owner=new if new != "" else None)
        self.add_signal_handler(handler,
                                sender='org.freedesktop.DBus',
                                path='/org/freedesktop/DBus',
                                interface='org.freedesktop.DBus',
                                member='NameOwnerChanged',
                                arg0=self.name)
        try:
            unique_name, = await self.bus.call_method_async("org.freedesktop.DBus",
                                                            "/org/freedesktop/DBus",
                                                            "org.freedesktop.DBus",
                                                            "GetNameOwner", "s", self.name)
        except BusError as error:
            if error.name == "org.freedesktop.DBus.Error.NameHasNoOwner":
                # Try to start it.  If it starts successfully, we will
                # get a NameOwnerChanged signal (which will set
                # self.owner) before StartServiceByName returns.
                try:
                    await self.bus.call_method_async("org.freedesktop.DBus",
                                                     "/org/freedesktop/DBus",
                                                     "org.freedesktop.DBus",
                                                     "StartServiceByName", "su", self.name, 0)
                except BusError as start_error:
                    logger.debug("Failed to start service '%s': %s", self.name, start_error.message)
                    self.send_json(owner=None)
            else:
                logger.debug("Failed to get owner of service '%s': %s", self.name, error.message)
        else:
            send_owner(unique_name)

    def do_open(self, options):
        self.cache = InterfaceCache()
        self.name = options.get('name')
        self.matches = []

        bus = options.get('bus')
        address = options.get('address')

        try:
            if address is not None:
                if bus is not None and bus != 'none':
                    raise ChannelError('protocol-error', message='only one of "bus" and "address" can be specified')
                logger.debug('get bus with address %s for %s', address, self.name)
                self.bus = Bus.new(address=address, bus_client=self.name is not None)
            elif bus == 'internal':
                logger.debug('get internal bus for %s', self.name)
                self.bus = self.router.internal_bus.client
            else:
                if bus == 'session':
                    logger.debug('get session bus for %s', self.name)
                    self.bus = Bus.default_user()
                elif bus == 'system' or bus is None:
                    logger.debug('get system bus for %s', self.name)
                    self.bus = Bus.default_system()
                else:
                    raise ChannelError('protocol-error', message=f'invalid bus "{bus}"')
        except OSError as exc:
            raise ChannelError('protocol-error', message=f'failed to connect to {bus} bus: {exc}') from exc

        try:
            self.bus.attach_event(None, 0)
        except OSError as err:
            if err.errno != errno.EBUSY:
                raise

        # This needs to be a fair mutex so that outgoing messages don't
        # get re-ordered.  asyncio.Lock is fair.
        self.watch_processing_lock = asyncio.Lock()

        if self.name is not None:
            async def get_ready() -> None:
                async with self.watch_processing_lock:
                    await self.setup_name_owner_tracking()
                    if self.owner:
                        self.ready(unique_name=self.owner)
                    else:
                        self.close({'problem': 'not-found'})
            self.create_task(get_ready())
        else:
            self.ready()

    def add_signal_handler(self, handler, **kwargs):
        r = dict(**kwargs)
        r['type'] = 'signal'
        if 'sender' not in r and self.name is not None:
            r['sender'] = self.name
        # HACK - https://github.com/bus1/dbus-broker/issues/309
        # path_namespace='/' in a rule does not work.
        if r.get('path_namespace') == "/":
            del r['path_namespace']

        def filter_owner(message):
            if self.owner is not None and self.owner == message.get_sender():
                handler(message)

        if self.name is not None and 'sender' in r and r['sender'] == self.name:
            func = filter_owner
        else:
            func = handler
        r_string = ','.join(f"{key}='{value}'" for key, value in r.items())
        if not self.is_closing():
            # this gets an EINTR very often especially on RHEL 8
            while True:
                try:
                    match = self.bus.add_match(r_string, func)
                    break
                except InterruptedError:
                    pass

            self.matches.append(match)

    def add_async_signal_handler(self, handler, **kwargs):
        def sync_handler(message):
            self.create_task(handler(message))
        self.add_signal_handler(sync_handler, **kwargs)

    async def do_call(self, message):
        path, iface, method, args = message['call']
        cookie = message.get('id')
        flags = message.get('flags')

        timeout = message.get('timeout')
        if timeout is not None:
            # sd_bus timeout is μs, cockpit API timeout is ms
            timeout *= 1000
        else:
            # sd_bus has no "indefinite" timeout, so use MAX_UINT64
            timeout = 2 ** 64 - 1

        # We have to figure out the signature of the call.  Either we got told it:
        signature = message.get('type')

        # ... or there aren't any arguments
        if signature is None and len(args) == 0:
            signature = ''

        # ... or we need to introspect
        if signature is None:
            try:
                logger.debug('Doing introspection request for %s %s', iface, method)
                signature = await self.cache.get_signature(iface, method, self.bus, self.name, path)
            except BusError as error:
                self.send_json(error=[error.name, [f'Introspection: {error.message}']], id=cookie)
                return
            except KeyError:
                self.send_json(
                    error=[
                        "org.freedesktop.DBus.Error.UnknownMethod",
                        [f"Introspection data for method {iface} {method} not available"]],
                    id=cookie)
                return
            except Exception as exc:
                self.send_json(error=['python.error', [f'Introspection: {exc!s}']], id=cookie)
                return

        try:
            method_call = self.bus.message_new_method_call(self.name, path, iface, method, signature, *args)
            reply = await self.bus.call_async(method_call, timeout=timeout)
            # If the method call has kicked off any signals related to
            # watch processing, wait for that to be done.
            async with self.watch_processing_lock:
                # TODO: stop hard-coding the endian flag here.
                self.send_json(
                    reply=[reply.get_body()], id=cookie,
                    flags="<" if flags is not None else None,
                    type=reply.get_signature(True))  # noqa: FBT003
        except BusError as error:
            # actually, should send the fields from the message body
            self.send_json(error=[error.name, [error.message]], id=cookie)
        except Exception:
            logger.exception("do_call(%s): generic exception", message)
            self.send_json(error=['python.error', [traceback.format_exc()]], id=cookie)

    async def do_add_match(self, message):
        add_match = message['add-match']
        logger.debug('adding match %s', add_match)

        async def match_hit(message):
            logger.debug('got match')
            async with self.watch_processing_lock:
                self.send_json(signal=[
                    message.get_path(),
                    message.get_interface(),
                    message.get_member(),
                    list(message.get_body())
                ])

        self.add_async_signal_handler(match_hit, **add_match)

    async def setup_objectmanager_watch(self, path, interface_name, meta, notify):
        # Watch the objects managed by the ObjectManager at "path".
        # Properties are not watched, that is done by setup_path_watch
        # below via recursive_props == True.

        async def handler(message):
            member = message.get_member()
            if member == "InterfacesAdded":
                (path, interface_props) = message.get_body()
                logger.debug('interfaces added %s %s', path, interface_props)
                meta = {}
                notify = {}
                async with self.watch_processing_lock:
                    for name, props in interface_props.items():
                        if interface_name is None or name == interface_name:
                            mm = await self.cache.get_interface_if_new(name, self.bus, self.name, path)
                            if mm:
                                meta.update({name: mm})
                            notify_update(notify, path, name, props)
                    self.send_json(meta=meta)
                    self.send_json(notify=notify)
            elif member == "InterfacesRemoved":
                (path, interfaces) = message.get_body()
                logger.debug('interfaces removed %s %s', path, interfaces)
                async with self.watch_processing_lock:
                    notify = {path: dict.fromkeys(interfaces)}
                    self.send_json(notify=notify)

        self.add_async_signal_handler(handler,
                                      path=path,
                                      interface="org.freedesktop.DBus.ObjectManager")
        objects, = await self.bus.call_method_async(self.name, path,
                                                    'org.freedesktop.DBus.ObjectManager',
                                                    'GetManagedObjects')
        for p, ifaces in objects.items():
            for iface, props in ifaces.items():
                if interface_name is None or iface == interface_name:
                    mm = await self.cache.get_interface_if_new(iface, self.bus, self.name, p)
                    if mm:
                        meta.update({iface: mm})
                    notify_update(notify, p, iface, props)

    async def setup_path_watch(self, path, interface_name, recursive_props, meta, notify):
        # Watch a single object at "path", but maybe also watch for
        # property changes for all objects below "path".

        async def handler(message):
            async with self.watch_processing_lock:
                path = message.get_path()
                name, props, invalids = message.get_body()
                logger.debug('NOTIFY: %s %s %s %s', path, name, props, invalids)
                for inv in invalids:
                    try:
                        reply, = await self.bus.call_method_async(self.name, path,
                                                                  'org.freedesktop.DBus.Properties', 'Get',
                                                                  'ss', name, inv)
                    except BusError as exc:
                        logger.debug('failed to fetch property %s.%s on %s %s: %s',
                                     name, inv, self.name, path, exc)
                        continue
                    props[inv] = reply
                notify = {}
                notify_update(notify, path, name, props)
                self.send_json(notify=notify)

        this_meta = await self.cache.introspect_path(self.bus, self.name, path)
        if interface_name is not None:
            interface = this_meta.get(interface_name)
            this_meta = {interface_name: interface}
        meta.update(this_meta)
        if recursive_props:
            self.add_async_signal_handler(handler,
                                          interface="org.freedesktop.DBus.Properties",
                                          path_namespace=path)
        else:
            self.add_async_signal_handler(handler,
                                          interface="org.freedesktop.DBus.Properties",
                                          path=path)

        for name in meta:
            if name.startswith("org.freedesktop.DBus."):
                continue
            try:
                props, = await self.bus.call_method_async(self.name, path,
                                                          'org.freedesktop.DBus.Properties',
                                                          'GetAll', 's', name)
                notify_update(notify, path, name, props)
            except BusError:
                pass

    async def do_watch(self, message):
        watch = message['watch']
        path = watch.get('path')
        path_namespace = watch.get('path_namespace')
        interface_name = watch.get('interface')
        cookie = message.get('id')

        path = path or path_namespace
        recursive = path == path_namespace

        if path is None or cookie is None:
            logger.debug('ignored incomplete watch request %s', message)
            self.send_json(error=['x.y.z', ['Not Implemented']], id=cookie)
            self.send_json(reply=[], id=cookie)
            return

        try:
            async with self.watch_processing_lock:
                meta = {}
                notify = {}
                await self.setup_path_watch(path, interface_name, recursive, meta, notify)
                if recursive:
                    await self.setup_objectmanager_watch(path, interface_name, meta, notify)
                self.send_json(meta=meta)
                self.send_json(notify=notify)
                self.send_json(reply=[], id=message['id'])
        except BusError as error:
            logger.debug("do_watch(%s) caught D-Bus error: %s", message, error.message)
            self.send_json(error=[error.name, [error.message]], id=cookie)

    async def do_meta(self, message):
        self.cache.inject(message['meta'])

    def do_data(self, data):
        message = json.loads(data)
        logger.debug('receive dbus request %s %s', self.name, message)

        if 'call' in message:
            self.create_task(self.do_call(message))
        elif 'add-match' in message:
            self.create_task(self.do_add_match(message))
        elif 'watch' in message:
            self.create_task(self.do_watch(message))
        elif 'meta' in message:
            self.create_task(self.do_meta(message))
        else:
            logger.debug('ignored dbus request %s', message)
            return

    def do_close(self):
        for slot in self.matches:
            slot.cancel()
        self.matches = []
        self.close()
'''.encode('utf-8'),
  'cockpit/channels/filesystem.py': r'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import contextlib
import enum
import errno
import fnmatch
import functools
import grp
import logging
import os
import pwd
import re
import stat
import tempfile
from pathlib import Path
from typing import Callable, Generator, Iterable

from cockpit._vendor.systemd_ctypes import Handle, PathWatch
from cockpit._vendor.systemd_ctypes.inotify import Event as InotifyEvent
from cockpit._vendor.systemd_ctypes.pathwatch import Listener as PathWatchListener

from ..channel import AsyncChannel, Channel, ChannelError, GeneratorChannel
from ..jsonutil import (
    JsonDict,
    JsonDocument,
    JsonError,
    JsonObject,
    get_bool,
    get_int,
    get_object,
    get_str,
    get_str_or_int,
    get_strv,
    json_merge_and_filter_patch,
)

logger = logging.getLogger(__name__)


@functools.lru_cache()
def my_umask() -> int:
    match = re.search(r'^Umask:\s*0([0-7]*)$', Path('/proc/self/status').read_text(), re.M)
    return (match and int(match.group(1), 8)) or 0o077


def tag_from_stat(buf: os.stat_result) -> str:
    return f'1:{buf.st_ino}-{buf.st_mtime}-{buf.st_mode:o}-{buf.st_uid}-{buf.st_gid}'


def tag_from_path(path: 'int | str | Path') -> 'str | None':
    try:
        return tag_from_stat(os.stat(path))
    except FileNotFoundError:
        return '-'
    except OSError:
        return None


def tag_from_fd(fd: int) -> 'str | None':
    try:
        return tag_from_stat(os.fstat(fd))
    except OSError:
        return None


# DEPRECATED: https://github.com/cockpit-project/cockpit/pull/21055
# as of 2024-09-30 there are no more users of this in any known project
class FsListChannel(Channel):
    payload = 'fslist1'

    def send_entry(self, event: str, entry: 'os.DirEntry[str]') -> None:
        if entry.is_symlink():
            mode = 'link'
        elif entry.is_file():
            mode = 'file'
        elif entry.is_dir():
            mode = 'directory'
        else:
            mode = 'special'

        self.send_json(event=event, path=entry.name, type=mode)

    def do_open(self, options: 'JsonObject') -> None:
        path = get_str(options, 'path')
        watch = options.get('watch', True)

        if watch:
            raise ChannelError('not-supported', message='watching is not implemented, use fswatch1')

        try:
            scan_dir = os.scandir(path)
        except FileNotFoundError as error:
            raise ChannelError('not-found', message=str(error)) from error
        except PermissionError as error:
            raise ChannelError('access-denied', message=str(error)) from error
        except OSError as error:
            raise ChannelError('internal-error', message=str(error)) from error

        self.ready()
        for entry in scan_dir:
            self.send_entry("present", entry)

        if not watch:
            self.done()
            self.close()


class FsReadChannel(GeneratorChannel):
    payload = 'fsread1'

    def do_yield_data(self, options: JsonObject) -> Generator[bytes, None, JsonObject]:
        path = get_str(options, 'path')
        max_read_size = get_int(options, 'max_read_size', 16 * 1024 * 1024)

        logger.debug('Opening file "%s" for reading', path)

        try:
            with open(path, 'rb') as filep:
                buf = os.stat(filep.fileno())
                if max_read_size != -1 and buf.st_size > max_read_size:
                    raise ChannelError('too-large')

                if self.is_binary and stat.S_ISREG(buf.st_mode):
                    self.ready(size_hint=buf.st_size)
                else:
                    self.ready()

                while True:
                    data = filep.read1(Channel.BLOCK_SIZE)
                    if data == b'':
                        break
                    logger.debug('  ...sending %d bytes', len(data))
                    if not self.is_binary:
                        data = data.replace(b'\0', b'').decode(errors='ignore').encode()
                    yield data

            return {'tag': tag_from_stat(buf)}

        except FileNotFoundError:
            # Using `yield` and `return {value}` generator, but GeneratorChannel does expect this
            return {'tag': '-'}  # noqa: B901
        except PermissionError as exc:
            raise ChannelError('access-denied') from exc
        except OSError as exc:
            raise ChannelError('internal-error', message=str(exc)) from exc


class FSReplaceAttrs:
    uid: 'int | None' = None
    gid: 'int | None' = None
    supported_attrs = {'user', 'group', 'mode'}
    mode: 'int | None' = None

    def __init__(self, value: JsonObject) -> None:
        # Any unknown keys throw an error
        unsupported_attrs = value.keys() - self.supported_attrs
        if unsupported_attrs:
            raise ChannelError('protocol-error',
                               message=f'"attrs" contains unsupported key(s): {",".join(unsupported_attrs)}',
                               unsupported_attrs=list(unsupported_attrs)) from None

        self.mode = get_int(value, 'mode', None)
        user = get_str_or_int(value, 'user', None)
        group = get_str_or_int(value, 'group', None)

        if user is not None and group is None:
            raise ChannelError('protocol-error', message='"group" attribute is empty while "user" is provided')
        if group is not None and user is None:
            raise ChannelError('protocol-error', message='"user" attribute is empty while "group" is provided')

        if isinstance(user, str):
            try:
                self.uid = pwd.getpwnam(user).pw_uid
            except KeyError:
                raise ChannelError('not-found', message=f'uid not found for {user}') from None
        else:
            self.uid = user

        if isinstance(group, str):
            try:
                self.gid = grp.getgrnam(group).gr_gid
            except KeyError:
                raise ChannelError('not-found', message=f'gid not found for {group}') from None
        else:
            self.gid = group


class FsReplaceChannel(AsyncChannel):
    payload = 'fsreplace1'
    capabilities = 'attrs',

    def delete(self, path: str, tag: 'str | None') -> str:
        if tag is not None and tag != tag_from_path(path):
            raise ChannelError('change-conflict')
        with contextlib.suppress(FileNotFoundError):  # delete is idempotent
            os.unlink(path)
        return '-'

    async def set_contents(
        self, path: str, tag: 'str | None', data: 'bytes | None', size: 'int | None',
        attrs: 'FSReplaceAttrs | None'
    ) -> 'str | None':
        dirname, basename = os.path.split(path)
        tmpname: str | None
        fd, tmpname = tempfile.mkstemp(dir=dirname, prefix=f'.{basename}-')

        def chown_if_required(fd: 'int', buf: 'os.stat_result | None' = None) -> None:
            # Provided attrs are preferred over the existing file permissions
            if attrs is not None and attrs.uid is not None and attrs.gid is not None:
                os.fchown(fd, attrs.uid, attrs.gid)
            elif buf is not None:
                os.fchown(fd, buf.st_uid, buf.st_gid)

        def apply_file_mode(fd: 'int', mode: 'int | None' = None) -> None:
            if attrs is not None and attrs.mode is not None:
                mode = attrs.mode
            elif mode is None:
                mode = 0o666 & ~my_umask()
            os.fchmod(fd, mode)

        try:
            if size is not None:
                logger.debug('fallocate(%s.tmp, %d)', path, size)
                if size:  # posix_fallocate() of 0 bytes is EINVAL
                    await self.in_thread(os.posix_fallocate, fd, 0, size)
                self.ready()  # ...only after that worked

            written = 0
            while data is not None:
                await self.in_thread(os.write, fd, data)
                written += len(data)
                data = await self.read()

            if size is not None and written < size:
                logger.debug('ftruncate(%s.tmp, %d)', path, written)
                await self.in_thread(os.ftruncate, fd, written)

            await self.in_thread(os.fdatasync, fd)

            if tag is None:
                # no preconditions about what currently exists or not
                # calculate the file mode from the umask
                apply_file_mode(fd)
                chown_if_required(fd)
                os.rename(tmpname, path)
                tmpname = None

            elif tag == '-':
                # the file must not exist.  file mode from umask.
                apply_file_mode(fd)
                chown_if_required(fd)
                os.link(tmpname, path)  # will fail if file exists

            else:
                # the file must exist with the given tag
                path_fd = os.open(path, os.O_RDONLY)

                try:
                    buf = os.stat(path_fd)
                    if tag != tag_from_stat(buf):
                        raise ChannelError('change-conflict')
                    # chown/chmod from the existing file permissions
                    apply_file_mode(fd, stat.S_IMODE(buf.st_mode))
                    chown_if_required(fd, buf)
                    try:
                        selinux_context = os.getxattr(path_fd, 'security.selinux')
                        os.setxattr(fd, 'security.selinux', selinux_context)
                        logger.debug("SELinux context '%s' set on '%s'", selinux_context, path)
                    except OSError as exc:
                        logger.exception("Error getting or setting SELinux context from original file: '%s'", exc)
                finally:
                    os.close(path_fd)

                os.rename(tmpname, path)
                tmpname = None

        finally:
            os.close(fd)
            if tmpname is not None:
                os.unlink(tmpname)

        return tag_from_path(path)

    async def run(self, options: JsonObject) -> JsonObject:
        attrs = get_object(options, 'attrs', FSReplaceAttrs, None)
        path = get_str(options, 'path')
        size = get_int(options, 'size', None)
        tag = get_str(options, 'tag', None)

        try:
            # In the `size` case, .set_contents() sends the ready only after
            # it knows that the allocate was successful.  In the case without
            # `size`, we need to send the ready() up front in order to
            # receive the first frame and decide if we're creating or deleting.
            if size is not None:
                tag = await self.set_contents(path, tag, b'', size, attrs)
            else:
                self.ready()
                data = await self.read()
                # if we get EOF right away, that's a request to delete
                if data is None:
                    tag = self.delete(path, tag)
                else:
                    tag = await self.set_contents(path, tag, data, None, attrs)

            self.done()
            return {'tag': tag}

        except FileNotFoundError as exc:
            raise ChannelError('not-found') from exc
        except FileExistsError as exc:
            # that's from link() noticing that the target file already exists
            raise ChannelError('change-conflict') from exc
        except PermissionError as exc:
            raise ChannelError('access-denied') from exc
        except IsADirectoryError as exc:
            # not ideal, but the closest code we have
            raise ChannelError('access-denied', message=str(exc)) from exc
        except OSError as exc:
            raise ChannelError('internal-error', message=str(exc)) from exc


class FsWatchChannel(Channel, PathWatchListener):
    payload = 'fswatch1'
    _tag: 'str | None' = None
    _watch = None

    # The C bridge doesn't send the initial event, and the JS calls read()
    # instead to figure out the initial state of the file.  If we send the
    # initial state then we cause the event to get delivered twice.
    # Ideally we'll sort that out at some point, but for now, suppress it.
    _active = False

    @staticmethod
    def mask_to_event_and_type(mask: InotifyEvent) -> 'tuple[str, str | None]':
        if (InotifyEvent.CREATE or InotifyEvent.MOVED_TO) in mask:
            return 'created', 'directory' if InotifyEvent.ISDIR in mask else 'file'
        elif InotifyEvent.MOVED_FROM in mask or InotifyEvent.DELETE in mask or InotifyEvent.DELETE_SELF in mask:
            return 'deleted', None
        elif InotifyEvent.ATTRIB in mask:
            return 'attribute-changed', None
        elif InotifyEvent.CLOSE_WRITE in mask:
            return 'done-hint', None
        else:
            return 'changed', None

    def do_inotify_event(self, mask: InotifyEvent, _cookie: int, name: 'bytes | None') -> None:
        logger.debug("do_inotify_event(%s): mask %X name %s", self._path, mask, name)
        event, type_ = self.mask_to_event_and_type(mask)
        if name:
            # file inside watched directory changed
            path = os.path.join(self._path, name.decode())
            tag = tag_from_path(path)
            self.send_json(event=event, path=path, tag=tag, type=type_)
        else:
            # the watched path itself changed; filter out duplicate events
            tag = tag_from_path(self._path)
            if tag == self._tag:
                return
            self._tag = tag
            self.send_json(event=event, path=self._path, tag=self._tag, type=type_)

    def do_identity_changed(self, fd: 'int | None', err: 'int | None') -> None:
        logger.debug("do_identity_changed(%s): fd %s, err %s", self._path, fd, err)
        self._tag = tag_from_fd(fd) if fd else '-'
        if self._active:
            self.send_json(event='created' if fd else 'deleted', path=self._path, tag=self._tag)

    def do_open(self, options: JsonObject) -> None:
        self._path = get_str(options, 'path')
        self._tag = None

        self._active = False
        self._watch = PathWatch(self._path, self)
        self._active = True

        self.ready()

    def do_close(self) -> None:
        if self._watch is not None:
            self._watch.close()
            self._watch = None
        self.close()


class Follow(enum.Enum):
    NO = False
    YES = True


class FsInfoChannel(Channel, PathWatchListener):
    payload = 'fsinfo'

    # Options (all get set in `do_open()`)
    path: str
    attrs: 'set[str]'
    fnmatch: str
    targets: bool
    follow: bool
    watch: bool

    # State
    current_value: JsonDict
    effective_fnmatch: str = ''
    fd: 'Handle | None' = None
    pending: 'set[str] | None' = None
    path_watch: 'PathWatch | None' = None
    getattrs: 'Callable[[int, str, Follow], JsonDocument]'

    @staticmethod
    def make_getattrs(attrs: Iterable[str]) -> 'Callable[[int, str, Follow], JsonDocument | None]':
        # Cached for the duration of the closure we're creating
        @functools.lru_cache()
        def get_user(uid: int) -> 'str | int':
            try:
                return pwd.getpwuid(uid).pw_name
            except KeyError:
                return uid

        @functools.lru_cache()
        def get_group(gid: int) -> 'str | int':
            try:
                return grp.getgrgid(gid).gr_name
            except KeyError:
                return gid

        def get_access(dir_fd: int, name: str, mode: int, *, follow_symlinks: bool = False) -> 'bool | None':
            try:
                if name:
                    return os.access(name, mode, dir_fd=dir_fd, follow_symlinks=follow_symlinks)
                else:
                    # This is the given path for which systemd_ctypes has already resolved the symlink
                    return os.access(f'/proc/self/fd/{dir_fd}', mode, follow_symlinks=True)
            except OSError:
                return None

        stat_types = {stat.S_IFREG: 'reg', stat.S_IFDIR: 'dir', stat.S_IFLNK: 'lnk', stat.S_IFCHR: 'chr',
                      stat.S_IFBLK: 'blk', stat.S_IFIFO: 'fifo', stat.S_IFSOCK: 'sock'}
        available_stat_getters = {
            'type': lambda buf: stat_types.get(stat.S_IFMT(buf.st_mode)),
            'tag': tag_from_stat,
            'mode': lambda buf: stat.S_IMODE(buf.st_mode),
            'size': lambda buf: buf.st_size,
            'uid': lambda buf: buf.st_uid,
            'gid': lambda buf: buf.st_gid,
            'mtime': lambda buf: buf.st_mtime,
            'user': lambda buf: get_user(buf.st_uid),
            'group': lambda buf: get_group(buf.st_gid),
        }
        stat_getters = tuple((key, available_stat_getters.get(key, lambda _: None)) for key in attrs)

        def get_attrs(fd: int, name: str, follow: Follow) -> 'JsonDict | None':
            try:
                buf = os.stat(name, follow_symlinks=follow.value, dir_fd=fd) if name else os.fstat(fd)
            except FileNotFoundError:
                return None
            except OSError:
                return {name: None for name, func in stat_getters}

            result = {key: func(buf) for key, func in stat_getters}

            if 'target' in result and stat.S_IFMT(buf.st_mode) == stat.S_IFLNK:
                with contextlib.suppress(OSError):
                    result['target'] = os.readlink(name, dir_fd=fd)

            for (attr, mask) in [('r-ok', os.R_OK), ('w-ok', os.W_OK), ('x-ok', os.X_OK)]:
                if attr in result:
                    result[attr] = get_access(fd, name, mask, follow_symlinks=follow.value)

            return result

        return get_attrs

    def send_update(self, updates: JsonDict, *, reset: bool = False) -> None:
        if reset:
            if set(self.current_value) & set(updates):
                # if we have an overlap, we need to do a proper reset
                self.send_json(dict.fromkeys(self.current_value), partial=True)
                self.current_value = {'partial': True}
                updates.update(partial=None)
            else:
                # otherwise there's no overlap: we can just remove the old keys
                updates.update(dict.fromkeys(self.current_value))

        json_merge_and_filter_patch(self.current_value, updates)
        if updates:
            self.send_json(updates)

    def process_update(self, updates: 'set[str]', *, reset: bool = False) -> None:
        assert self.fd is not None

        entries: JsonDict = {name: self.getattrs(self.fd, name, Follow.NO) for name in updates}

        info = entries.pop('', {})
        assert isinstance(info, dict)  # fstat() will never fail with FileNotFoundError

        if self.effective_fnmatch:
            info['entries'] = entries

        if self.targets:
            info['targets'] = targets = {}
            # 'targets' is used to report attributes about the ultimate target
            # of symlinks, but only if this information would not already be
            # reported.  As such, we exclude '.' and any path which would end
            # up in 'entries' (if it existed).  '..' needs special treatment:
            # it might be `.interesting()` but it won't be in 'entries', so
            # it's always treated as a target.
            for name in {e.get('target') for e in entries.values() if isinstance(e, dict)}:
                if isinstance(name, str) and name != '.':
                    # exclude anything that would end up in 'entries'
                    if (name == '..' or '/' in name or not self.interesting(name)):
                        targets[name] = self.getattrs(self.fd, name, Follow.YES)

        self.send_update({'info': info}, reset=reset)

    def process_pending_updates(self) -> None:
        assert self.pending is not None
        if self.pending:
            self.process_update(self.pending)
        self.pending = None

    def interesting(self, name: str) -> bool:
        if name == '':
            return True
        else:
            # only report updates on entry filenames if we match them
            return fnmatch.fnmatch(name, self.effective_fnmatch)

    def schedule_update(self, name: str) -> None:
        if not self.interesting(name):
            return

        if self.pending is None:
            asyncio.get_running_loop().call_later(0.1, self.process_pending_updates)
            self.pending = set()

        self.pending.add(name)

    def report_error(self, err: int) -> None:
        if err == errno.ENOENT:
            problem = 'not-found'
        elif err in (errno.EPERM, errno.EACCES):
            problem = 'access-denied'
        elif err == errno.ENOTDIR:
            problem = 'not-directory'
        else:
            problem = 'internal-error'

        self.send_update({'error': {
            'problem': problem, 'message': os.strerror(err), 'errno': errno.errorcode[err]
        }}, reset=True)

    def flag_onlydir_error(self, fd: Handle) -> bool:
        # If our requested path ended with '/' then make sure we got a
        # directory, or else it's an error.  open() will have already flagged
        # that for us, but systemd_ctypes doesn't do that (yet).
        if not self.watch or not self.path.endswith('/'):
            return False

        buf = os.fstat(fd)  # this should never fail
        if stat.S_IFMT(buf.st_mode) != stat.S_IFDIR:
            self.report_error(errno.ENOTDIR)
            return True

        return False

    def report_initial_state(self, fd: Handle) -> None:
        if self.flag_onlydir_error(fd):
            return

        self.fd = fd

        entries = {''}
        if self.fnmatch:
            try:
                entries.update(os.listdir(f'/proc/self/fd/{self.fd}'))
                self.effective_fnmatch = self.fnmatch
            except OSError:
                # If we failed to get an initial list, then report nothing from now on
                self.effective_fnmatch = ''

        self.process_update({e for e in entries if self.interesting(e)}, reset=True)

    def do_inotify_event(self, mask: InotifyEvent, cookie: int, rawname: 'bytes | None') -> None:
        logger.debug('do_inotify_event(%r, %r, %r)', mask, cookie, rawname)
        name = (rawname or b'').decode(errors='surrogateescape')

        self.schedule_update(name)

        if name and mask | (InotifyEvent.CREATE | InotifyEvent.DELETE |
                            InotifyEvent.MOVED_TO | InotifyEvent.MOVED_FROM):
            # These events change the mtime of the directory
            self.schedule_update('')

    def do_identity_changed(self, fd: 'Handle | None', err: 'int | None') -> None:
        logger.debug('do_identity_changed(%r, %r)', fd, err)
        # If there were previously pending changes, they are now irrelevant.
        if self.pending is not None:
            # Note: don't set to None, since the handler is still pending
            self.pending.clear()

        if err is None:
            assert fd is not None
            self.report_initial_state(fd)
        else:
            self.report_error(err)

    def do_close(self) -> None:
        # non-watch channels close immediately — if we get this, we're watching
        assert self.path_watch is not None
        self.path_watch.close()
        self.close()

    def do_open(self, options: JsonObject) -> None:
        self.path = get_str(options, 'path')
        if not os.path.isabs(self.path):
            raise JsonError(options, '"path" must be an absolute path')

        attrs = set(get_strv(options, 'attrs'))
        self.getattrs = self.make_getattrs(attrs - {'targets', 'entries'})
        self.fnmatch = get_str(options, 'fnmatch', '*' if 'entries' in attrs else '')
        self.targets = 'targets' in attrs
        self.follow = get_bool(options, 'follow', default=True)
        self.watch = get_bool(options, 'watch', default=False)
        if self.watch and not self.follow:
            raise JsonError(options, '"watch: true" and "follow: false" are (currently) incompatible')
        if self.targets and not self.follow:
            raise JsonError(options, '`targets: "stat"` and `follow: false` are (currently) incompatible')

        self.current_value = {}
        self.ready()

        if not self.watch:
            try:
                fd = Handle.open(self.path, os.O_PATH if self.follow else os.O_PATH | os.O_NOFOLLOW)
            except OSError as exc:
                assert exc.errno  # noqa: PT017 - mypy thinks that errno can be None
                self.report_error(exc.errno)
            else:
                self.report_initial_state(fd)
                fd.close()

            self.done()
            self.close()

        else:
            # PathWatch will call do_identity_changed(), which does the same as
            # above: calls either report_initial_state() or report_error(),
            # depending on if it was provided with an fd or an error code.
            self.path_watch = PathWatch(self.path, self)
'''.encode('utf-8'),
  'cockpit/channels/http_channel.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import http.client
import logging
import socket
import ssl

from ..channel import AsyncChannel, ChannelError
from ..jsonutil import JsonObject, get_dict, get_int, get_object, get_str, typechecked

logger = logging.getLogger(__name__)


class HttpChannel(AsyncChannel):
    payload = 'http-stream2'

    @staticmethod
    def get_headers(response: http.client.HTTPResponse, *, binary: bool) -> JsonObject:
        # Never send these headers
        remove = {'Connection', 'Transfer-Encoding'}

        if not binary:
            # Only send these headers for raw binary streams
            remove.update({'Content-Length', 'Range'})

        return {key: value for key, value in response.getheaders() if key not in remove}

    @staticmethod
    def create_client(options: JsonObject) -> http.client.HTTPConnection:
        opt_address = get_str(options, 'address', 'localhost')
        opt_tls = get_dict(options, 'tls', None)
        opt_unix = get_str(options, 'unix', None)
        opt_port = get_int(options, 'port', None)

        if opt_tls is not None and opt_unix is not None:
            raise ChannelError('protocol-error', message='TLS on Unix socket is not supported')
        if opt_port is None and opt_unix is None:
            raise ChannelError('protocol-error', message='no "port" or "unix" option for channel')
        if opt_port is not None and opt_unix is not None:
            raise ChannelError('protocol-error', message='cannot specify both "port" and "unix" options')

        if opt_tls is not None:
            authority = get_dict(opt_tls, 'authority', None)
            if authority is not None:
                data = get_str(authority, 'data', None)
                if data is not None:
                    context = ssl.create_default_context(cadata=data)
                else:
                    context = ssl.create_default_context(cafile=get_str(authority, 'file'))
            else:
                context = ssl.create_default_context()

            if 'validate' in opt_tls and not opt_tls['validate']:
                context.check_hostname = False
                context.verify_mode = ssl.VerifyMode.CERT_NONE

            return http.client.HTTPSConnection(opt_address, port=opt_port, context=context)

        else:
            return http.client.HTTPConnection(opt_address, port=opt_port)

    @staticmethod
    def connect(connection: http.client.HTTPConnection, opt_unix: 'str | None') -> None:
        # Blocks.  Runs in a thread.
        if opt_unix:
            # create the connection's socket so that it won't call .connect() internally (which only supports TCP)
            connection.sock = socket.socket(socket.AF_UNIX)
            connection.sock.connect(opt_unix)
        else:
            # explicitly call connect(), so that we can do proper error handling
            connection.connect()

    @staticmethod
    def request(
        connection: http.client.HTTPConnection, method: str, path: str, headers: 'dict[str, str]', body: bytes
    ) -> http.client.HTTPResponse:
        # Blocks.  Runs in a thread.
        connection.request(method, path, headers=headers or {}, body=body)
        return connection.getresponse()

    async def run(self, options: JsonObject) -> None:
        logger.debug('open %s', options)

        method = get_str(options, 'method')
        path = get_str(options, 'path')
        headers = get_object(options, 'headers', lambda d: {k: typechecked(v, str) for k, v in d.items()}, None)

        if 'connection' in options:
            raise ChannelError('protocol-error', message='connection sharing is not implemented on this bridge')

        connection = self.create_client(options)

        self.ready()

        body = b''
        while True:
            data = await self.read()
            if data is None:
                break
            body += data

        # Connect in a thread and handle errors
        try:
            await self.in_thread(self.connect, connection, get_str(options, 'unix', None))
        except ssl.SSLCertVerificationError as exc:
            raise ChannelError('unknown-hostkey', message=str(exc)) from exc
        except (OSError, IOError) as exc:
            raise ChannelError('not-found', message=str(exc)) from exc

        # Submit request in a thread and handle errors
        try:
            response = await self.in_thread(self.request, connection, method, path, headers or {}, body)
        except (http.client.HTTPException, OSError) as exc:
            raise ChannelError('terminated', message=str(exc)) from exc

        self.send_control(command='response',
                          status=response.status,
                          reason=response.reason,
                          headers=self.get_headers(response, binary=self.is_binary))

        # Receive the body and finish up
        try:
            while True:
                block = await self.in_thread(response.read1, self.BLOCK_SIZE)
                if not block:
                    break
                await self.write(block)

            logger.debug('reading response done')
            # this returns immediately and does not read anything more, but updates the http.client's
            # internal state machine to "response done"
            block = response.read()
            assert block == b''

            await self.in_thread(connection.close)
        except (http.client.HTTPException, OSError) as exc:
            raise ChannelError('terminated', message=str(exc)) from exc

        self.done()
''',
  'cockpit/channels/info.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


from ..channel import Channel
from ..jsonutil import JsonObject


class InfoChannel(Channel):
    payload = 'info'

    def do_open(self, options: JsonObject) -> None:
        del options

        self.ready()
        self.send_json(self.router.info())
        self.done()
        self.close()
''',
  'cockpit/channels/metrics.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import json
import logging
import sys
import time
from collections import defaultdict
from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Type, Union

from ..channel import AsyncChannel, ChannelError
from ..jsonutil import JsonList, JsonObject, get_int
from ..samples import SAMPLERS, SampleDescription, Sampler, Samples

logger = logging.getLogger(__name__)


class MetricInfo(NamedTuple):
    derive: Optional[str]
    desc: SampleDescription


class InternalMetricsChannel(AsyncChannel):
    payload = 'metrics1'
    restrictions = [('source', 'internal')]

    metrics: List[MetricInfo]
    samplers: Set[Sampler]
    samplers_cache: Optional[Dict[str, Tuple[Type[Sampler], SampleDescription]]] = None

    interval: int = 1000
    need_meta: bool = True
    last_timestamp: float = 0
    next_timestamp: float = 0

    @classmethod
    def ensure_samplers(cls) -> None:
        if cls.samplers_cache is None:
            cls.samplers_cache = {desc.name: (sampler, desc) for sampler in SAMPLERS for desc in sampler.descriptions}

    def parse_options(self, options: JsonObject) -> None:
        logger.debug('metrics internal open: %s, channel: %s', options, self.channel)

        interval = get_int(options, 'interval', self.interval)
        if interval <= 0 or interval > sys.maxsize:
            raise ChannelError('protocol-error', message=f'invalid "interval" value: {interval}')

        self.interval = interval

        metrics = options.get('metrics')
        if not isinstance(metrics, list) or len(metrics) == 0:
            logger.error('invalid "metrics" value: %s', metrics)
            raise ChannelError('protocol-error', message='invalid "metrics" option was specified (not an array)')

        assert self.samplers_cache, "ensure_samples not called"
        sampler_classes = set()
        for metric in metrics:
            # validate it's an object
            name = metric.get('name')
            units = metric.get('units')
            derive = metric.get('derive')

            try:
                sampler, desc = self.samplers_cache[name]
            except KeyError as exc:
                logger.error('unsupported metric: %s', name)
                raise ChannelError('not-supported', message=f'unsupported metric: {name}') from exc

            if units and units != desc.units:
                raise ChannelError('not-supported', message=f'{name} has units {desc.units}, not {units}')

            sampler_classes.add(sampler)
            self.metrics.append(MetricInfo(derive=derive, desc=desc))

        self.samplers = {cls() for cls in sampler_classes}

    def send_meta(self, samples: Samples, timestamp: float) -> None:
        metrics: JsonList = []
        for metricinfo in self.metrics:
            if metricinfo.desc.instanced:
                metrics.append({
                    'name': metricinfo.desc.name,
                    'units': metricinfo.desc.units,
                    'instances': list(samples[metricinfo.desc.name].keys()),
                    'semantics': metricinfo.desc.semantics
                })
            else:
                metrics.append({
                    'name': metricinfo.desc.name,
                    'derive': metricinfo.derive,
                    'units': metricinfo.desc.units,
                    'semantics': metricinfo.desc.semantics
                })

        now = int(time.time()) * 1000
        self.send_json(source='internal', interval=self.interval, timestamp=timestamp * 1000,
                       now=now, metrics=metrics)
        self.need_meta = False

    def sample(self) -> Samples:
        samples: Samples = defaultdict(dict)
        for sampler in self.samplers:
            sampler.sample(samples)
        return samples

    def calculate_sample_rate(self, value: float, old_value: Optional[float]) -> Union[float, bool]:
        if old_value is not None:
            return (value - old_value) / (self.next_timestamp - self.last_timestamp)
        else:
            return False

    def send_updates(self, samples: Samples, last_samples: Samples) -> None:
        data: List[Union[float, List[Optional[Union[float, bool]]]]] = []
        timestamp = time.time()
        self.next_timestamp = timestamp

        for metricinfo in self.metrics:
            value = samples[metricinfo.desc.name]

            if metricinfo.desc.instanced:
                old_value = last_samples[metricinfo.desc.name]
                assert isinstance(value, dict)
                assert isinstance(old_value, dict)

                # If we have less or more keys the data changed, send a meta message.
                if value.keys() != old_value.keys():
                    self.need_meta = True

                if metricinfo.derive == 'rate':
                    instances: List[Optional[Union[float, bool]]] = []
                    for key, val in value.items():
                        instances.append(self.calculate_sample_rate(val, old_value.get(key)))

                    data.append(instances)
                else:
                    data.append(list(value.values()))
            else:
                old_value = last_samples.get(metricinfo.desc.name)
                assert not isinstance(value, dict)
                assert not isinstance(old_value, dict)

                if metricinfo.derive == 'rate':
                    data.append(self.calculate_sample_rate(value, old_value))
                else:
                    data.append(value)

        if self.need_meta:
            self.send_meta(samples, timestamp)

        self.last_timestamp = self.next_timestamp
        self.send_text(json.dumps([data]))

    async def run(self, options: JsonObject) -> None:
        self.metrics = []
        self.samplers = set()

        InternalMetricsChannel.ensure_samplers()

        self.parse_options(options)
        self.ready()

        last_samples: Samples = defaultdict(dict)
        while True:
            samples = self.sample()
            self.send_updates(samples, last_samples)
            last_samples = samples
            await asyncio.sleep(self.interval / 1000)
''',
  'cockpit/channels/packages.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import logging
from typing import Optional

from ..channel import AsyncChannel
from ..data import read_cockpit_data_file
from ..jsonutil import JsonObject, get_dict, get_str
from ..packages import Packages

logger = logging.getLogger(__name__)


class PackagesChannel(AsyncChannel):
    payload = 'http-stream1'
    restrictions = [("internal", "packages")]

    # used to carry data forward from open to done
    options: Optional[JsonObject] = None

    def http_error(self, status: int, message: str) -> None:
        template = read_cockpit_data_file('fail.html')
        self.send_json(status=status, reason='ERROR', headers={'Content-Type': 'text/html; charset=utf-8'})
        self.send_data(template.replace(b'@@message@@', message.encode()))
        self.done()
        self.close()

    async def run(self, options: JsonObject) -> None:
        packages: Packages = self.router.packages  # type: ignore[attr-defined]  # yes, this is evil

        try:
            if get_str(options, 'method') != 'GET':
                raise ValueError(f'Unsupported HTTP method {options["method"]}')

            self.ready()
            if await self.read() is not None:
                raise ValueError('Received unexpected data')

            path = get_str(options, 'path')
            headers = get_dict(options, 'headers')
            document = packages.load_path(path, headers)

            # Note: we can't cache documents right now.  See
            # https://github.com/cockpit-project/cockpit/issues/19071
            # for future plans.
            out_headers = {
                'Cache-Control': 'no-cache, no-store',
                'Content-Type': document.content_type,
            }

            if document.content_encoding is not None:
                out_headers['Content-Encoding'] = document.content_encoding

            if document.content_security_policy is not None:
                policy = document.content_security_policy

                # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
                #
                #    Note: connect-src 'self' does not resolve to websocket
                #    schemes in all browsers, more info in this issue.
                #
                # https://github.com/w3c/webappsec-csp/issues/7
                if "connect-src 'self';" in policy:
                    protocol = headers.get('X-Forwarded-Proto')
                    host = headers.get('X-Forwarded-Host')
                    if not isinstance(protocol, str) or not isinstance(host, str):
                        raise ValueError('Invalid host or protocol header')

                    websocket_scheme = "wss" if protocol == "https" else "ws"
                    websocket_origin = f"{websocket_scheme}://{host}"
                    policy = policy.replace("connect-src 'self';", f"connect-src {websocket_origin} 'self';")

                out_headers['Content-Security-Policy'] = policy

        except ValueError as exc:
            self.http_error(400, str(exc))

        except KeyError:
            self.http_error(404, 'Not found')

        except OSError as exc:
            self.http_error(500, f'Internal error: {exc!s}')

        else:
            self.send_json(status=200, reason='OK', headers=out_headers)
            await self.sendfile(document.data)
''',
  'cockpit/channels/pcp.py': br'''#
# Copyright (C) 2024 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


# PCP Channel for Performance Co-pilot Metrics
#
# This is the replacement of the C implementation of cockpit-pcp, it uses the
# python pcp module (ctypes wrapper) to read PCP archives and directly connect
# to PCP daemons.
#
# Cockpit supports basically two different types of sources
# * archive - reads from a PCP archive(s) in either the default configured location `pcp-archive` or a given path.
# * pmcd / direct - connects to the pmcd daemon and reads metrics
#
# The PCP channel differs in the way it delivers data to the user, when
# requesting an archive, it is read in its entirety while when requesting other
# modes the channel will delivers metrics per given interval.
#
# Global channel options:
# * interval - the interval on which to deliver metrics
# * timestamp - timestamp of the first sample (only an option for archives)
# * limit - amount of samples to return, archive only option
# * omit-instances - multi-instances to not show (for example `lo` interface in network metrics)
# * instances - the multi-instances to show (for example only `/dev/sda`)
#
# Metrics
#
# When opening the metrics channel you can specify the metrics you are
# interested in, a PCP metric is described in pmapi as pmDesc:
#
# class pmDesc:
# pmid  - unique ID of the metric
# type  - data type (PM_TYPE_*)
# indom - the instance domain
# sem   - semantics of the value
# units - dimension and units
#
# Important here are the type, Cockpit only supports PM_TYPE_DOUBLE, and integers as PM_TYPE_U64.
#
# The instance domain denotes if the metric is has multiple instances, for
# example a disk metric can represent data for multiple disks.
#
# See `pminfo -f "kernel.all.load"` for example:
# kernel.all.load
#   inst [1 or "1 minute"] value 0.5
#   inst [5 or "5 minute"] value 0.68000001
#   inst [15 or "15 minute"] value 0.75999999
#
# These metrics are delivered to the JavaScript client as a list of values, the
# meta message contains the list of names of instances for the UI to show.

import asyncio
import ctypes
import glob
import json
import logging
import platform
import sys
import time
from math import isclose
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, NamedTuple, Optional, Sequence, Union

from cockpit.protocol import CockpitProblem

from ..channel import AsyncChannel, ChannelError
from ..jsonutil import JsonObject, JsonValue, get_int, get_objv, get_str, get_strv

if TYPE_CHECKING:  # pragma: no cover
    import cpmapi as c_api
    from pcp import pmapi
else:
    pmapi = None
    c_api = None

logger = logging.getLogger(__name__)


class MetricNotFoundError(CockpitProblem):
    pass


class PcpMetricInfo(Dict[str, JsonValue]):
    def __init__(self, value: JsonObject) -> None:
        self.name = get_str(value, 'name')
        self.derive = get_str(value, 'derive', '')
        self.units = get_str(value, 'units', '')
        super().__init__(name=self.name, derive=self.derive, units=self.units)


class MetricInfo(NamedTuple):
    pmid: int
    derive: str
    desc: Any
    name: str
    factor: float
    units: Any
    instanced: bool  # Multi instance metric


def try_import_pcp() -> None:
    global c_api, pmapi
    if c_api is None or pmapi is None:
        try:
            import cpmapi as c_api
            from pcp import pmapi
        except ImportError as exc:  # pragma: no cover
            raise ChannelError('not-supported', message='python3-pcp not installed') from exc


class ArchiveInfo:
    metric_descriptions: List[MetricInfo]

    def __init__(self, context: 'pmapi.pmContext', start: float, path: str) -> None:
        self.context = context
        self.start = start
        self.path = path
        self.metric_descriptions = []

    def sort_key(self) -> float:
        return self.start


class PcpMetricsChannel(AsyncChannel):
    payload = 'metrics1'

    pcp_dir: str
    archive_batch: int

    context: 'pmapi.pmContext'
    source: str
    interval: int
    start_timestamp: int
    last_timestamp: float
    next_timestamp: float
    limit: int
    last_samples: Any = None
    last_results: 'pmapi.pmResult | None' = None
    metric_descriptions: List[MetricInfo]

    def parse_options(self, options: JsonObject) -> None:
        self.archive_batch = 60
        self.last_timestamp = 0
        self.next_timestamp = 0

        max_size = sys.maxsize
        min_size = -sys.maxsize - 1

        self.interval = get_int(options, 'interval', 1000)
        if self.interval <= 0 or self.interval > max_size:
            raise ChannelError('protocol-error', message=f'invalid "interval" value: {self.interval}')

        self.start_timestamp = get_int(options, 'timestamp', 0)
        if self.start_timestamp / 1000 < min_size or self.start_timestamp / 1000 > max_size:
            raise ChannelError('protocol-error', message=f'invalid "timestamp" value: {self.start_timestamp}')

        if self.start_timestamp < 0:
            self.start_timestamp = int((time.time() * 1000) + self.start_timestamp)

        self.metrics = get_objv(options, 'metrics', PcpMetricInfo)
        self.limit = get_int(options, 'limit', max_size)
        if self.limit <= 0 or self.limit > max_size:
            raise ChannelError('protocol-error', message=f'invalid "limit" value: {self.limit}')

        self.instances = get_strv(options, 'instances', '')
        self.omit_instances = get_strv(options, 'omit-instances', [])
        self.source = get_str(options, 'source', '')
        if self.source == '':
            raise ChannelError('protocol-error', message='no "source" option specified for metrics channel')

    @staticmethod
    def float_to_timeval(timestamp: float) -> 'pmapi.timeval':
        sec = int(timestamp / 1000)
        usec = int((timestamp % 1000) * 1000)
        return pmapi.timeval(sec, usec)

    @staticmethod
    def float_to_timespec(timestamp: float) -> 'pmapi.timespec':
        ts = pmapi.timespec()
        ts.tv_sec = int(timestamp / 1000)
        ts.tv_nsec = int((timestamp % 1000) * 1000000)
        return ts

    @staticmethod
    def get_context_and_name(source: str) -> 'tuple[str, str]':
        if source == "":
            raise ChannelError('protocol-error', message='no "source" option specified for metrics channel')
        elif source.startswith('/'):
            name = source
            context_type = c_api.PM_CONTEXT_ARCHIVE
        elif source == 'pcp-archive':
            hostname = platform.node()
            archive_dir = pmapi.pmContext.pmGetConfig("PCP_LOG_DIR")
            name = f'{archive_dir}/pmlogger/{hostname}'
            context_type = c_api.PM_CONTEXT_ARCHIVE
        elif source == 'direct':
            name = source
            context_type = c_api.PM_CONTEXT_LOCAL
        elif source == 'pmcd':
            name = 'local:'
            context_type = c_api.PM_CONTEXT_HOST
        else:
            raise ChannelError('not-supported',
                               message=f'unsupported "source" option specified for metrics: {source}')

        return (name, context_type)

    def get_archives(self, name: str) -> Sequence[ArchiveInfo]:
        archives = sorted(self.prepare_archives(name), key=ArchiveInfo.sort_key)

        if len(archives) == 0:
            raise ChannelError('not-found')

        # Verify if the given metrics exist in the archive
        for archive in archives:
            for metric in self.metrics:
                metric_desc = None
                # HACK: Replicate C bridge behaviour, if a metric is not found
                # just return an empty error. If we report anything with a
                # message the metrics page won't re-try opening the metric archive.
                try:
                    metric_desc = self.convert_metric_description(archive.context, metric)
                except MetricNotFoundError:
                    raise ChannelError('') from None

                assert metric_desc is not None
                archive.metric_descriptions.append(metric_desc)

        return archives

    def convert_metric_description(self, context: 'pmapi.pmContext', metric: JsonObject) -> MetricInfo:
        name = get_str(metric, 'name', '')
        if name == '':
            raise ChannelError('protocol-error',
                               message='invalid "metrics" option was specified (no name for metric)')
        units = get_str(metric, 'units', '')
        derive = get_str(metric, 'derive', '')

        try:
            pm_ids = context.pmLookupName(name)
        except pmapi.pmErr as exc:
            if exc.errno() == c_api.PM_ERR_NAME:
                logger.error("no such metric: %s", name)
                raise MetricNotFoundError('error', message=f'no such metric: {name}') from None
            else:
                raise ChannelError('internal-error', message=str(exc)) from None

        try:
            pm_desc = context.pmLookupDesc(pm_ids[0])
        except pmapi.pmErr as exc:
            if exc.errno() == c_api.PM_ERR_NAME:
                raise ChannelError('not-found', message=f'no such metric: {name}') from None
            else:
                raise ChannelError('internal-error', message=str(exc)) from None

        # Multi-instance metrics have a domain defined
        instanced = pm_desc.indom != c_api.PM_INDOM_NULL

        if instanced:
            if len(self.instances) > 0:
                context.pmDelProfile(pm_desc, None)
                for instance in self.instances:
                    try:
                        instid = context.pmLookupInDom(pm_desc, instance)
                        context.pmAddProfile(pm_desc, instid)
                    except pmapi.pmErr as exc:
                        logger.debug("Unable to add profile: instance=%s err=%s", instance, exc)

            if len(self.omit_instances) > 0:
                context.pmAddProfile(pm_desc, None)
                for omit_instance in self.omit_instances:
                    try:
                        instid = context.pmLookupInDom(pm_desc, omit_instance)
                        context.pmDelProfile(pm_desc, [instid])
                    except pmapi.pmErr as exc:
                        logger.debug("Unable to remove profile: instance=%s err=%s", omit_instance, exc)

        factor = 1.0
        pm_units = pm_desc.units
        if units:
            try:
                [parsed_units, factor] = context.pmParseUnitsStr(units)
            except pmapi.pmErr as exc:
                if exc.errno() == c_api.PM_ERR_NAME:
                    raise ChannelError('not-found', message=f'no such metric: {name}') from None
                else:
                    raise ChannelError('internal-error', message=str(exc)) from None

            self.try_convert_unit(context, pm_desc, pm_units)

            if units != parsed_units or not isclose(factor, 1.0):
                pm_units = parsed_units

        return MetricInfo(pmid=pm_ids[0],
                          name=name,
                          derive=derive,
                          desc=pm_desc,
                          factor=factor,
                          units=pm_units,
                          instanced=instanced)

    @staticmethod
    def try_convert_unit(context: 'pmapi.pmContext', pm_desc: 'pmapi.pmDesc', pm_units: 'pmapi.pmUnits') -> None:
        """Try to convert a dummy value to validate that the metric is convertible"""
        dummy = pmapi.pmAtomValue()
        dummy.d = 0.0
        try:
            context.pmConvScale(c_api.PM_TYPE_DOUBLE, dummy, [pm_desc], 0, pm_units)
        except pmapi.pmErr as exc:
            raise ChannelError('internal-error', message=str(exc)) from None

    @staticmethod
    def prepare_archives(archive_dir: str) -> Iterable[ArchiveInfo]:
        indexes = glob.glob(glob.escape(archive_dir) + '/*.index')
        for archive_path in indexes:
            logger.debug('opening archive: %r', archive_path)
            try:
                context = pmapi.pmContext(c_api.PM_CONTEXT_ARCHIVE, archive_path)
                log_label = context.pmGetArchiveLabel()
                archive_start = float(log_label.start) * 1000
                yield ArchiveInfo(context, archive_start, archive_path)
            except pmapi.pmErr as exc:
                if exc.errno() != c_api.PM_ERR_LOGFILE:
                    raise ChannelError('not-found', message=f'could not read archive {archive_path}') from None

    @staticmethod
    def semantic_val(sem_id: int) -> str:
        if sem_id == c_api.PM_SEM_COUNTER:
            return "counter"
        elif sem_id == c_api.PM_SEM_INSTANT:
            return "instant"
        elif sem_id == c_api.PM_SEM_DISCRETE:
            return "discrete"

        return ""

    def send_meta(self, results: 'pmapi.pmResult', context: 'pmapi.pmContext') -> None:
        metrics = []

        for metric_desc in self.metric_descriptions:
            desc: Dict[str, Union[str, List[str]]] = {"name": metric_desc.name}

            if metric_desc.derive:
                desc['derive'] = metric_desc.derive

            if isclose(metric_desc.factor, 1.0):
                desc['units'] = str(metric_desc.units)
            else:
                desc['units'] = f"{context.pmUnitsStr(metric_desc.units)}*{1.0 / metric_desc.factor}"

            semantics = self.semantic_val(metric_desc.desc.sem)
            if semantics != "":
                desc['semantics'] = self.semantic_val(metric_desc.desc.sem)

            # We would like to use `context.pmGetInDom(indom=metric_desc.desc.indom)`
            # This returns an set of ([instids], [names])
            #
            # But we can't use this as it has no guarantee for the instance ids order which we need.
            if metric_desc.instanced:
                insts: List[str] = []
                for i in range(results.contents.numpmid):
                    pmid = results.contents.get_pmid(i)
                    if metric_desc.pmid != pmid:
                        continue

                    for j in range(results.contents.get_numval(i)):
                        value = results.contents.get_vlist(i, j)
                        instance_desc = context.pmNameInDom(metric_desc.desc, value.inst)
                        insts.append(instance_desc)

                desc['instances'] = insts

            metrics.append(desc)

        now = int(time.time()) * 1000

        # PCP 7.0+: timespec with nanoseconds; PCP < 7.0: timeval with microseconds
        ms = (results.contents.timestamp.tv_nsec / 1000000
              if hasattr(results.contents.timestamp, 'tv_nsec')
              else results.contents.timestamp.tv_usec / 1000)
        timestamp = int(results.contents.timestamp.tv_sec * 1000 + ms)

        self.send_json(source=self.source,
                       interval=self.interval,
                       timestamp=timestamp,
                       metrics=metrics,
                       now=now)

    def needs_meta_update(self, results: 'pmapi.pmResult') -> bool:
        """
        If a multi-instance metric changes its instances we need to send a new
        meta message when these change. For example when an drive or ethernet
        card is removed out.
        """

        last_results = self.last_results
        if last_results is None:
            return True

        # PCP guarantees order of numpmid between results
        for i in range(results.contents.numpmid):
            if not self.metric_descriptions[i].instanced:
                continue

            numval1 = results.contents.get_numval(i)
            numval2 = last_results.contents.get_numval(i)

            if numval1 != numval2:
                return True

            for j in range(numval1):
                if results.contents.get_inst(i, j) != last_results.contents.get_inst(i, j):
                    return True

        return False

    async def sample(self, context: 'pmapi.pmContext', archive_batch: int, limit: int, total_fetched: int) -> int:
        # HACK: pmFetch only takes an array of ctypes.c_uint, no native type, alternative keep pmids.
        pmids = (ctypes.c_uint * len(self.metric_descriptions))()
        for i, metric in enumerate(self.metric_descriptions):
            pmids[i] = metric.pmid

        while True:
            fetched: List[Any] = []
            try:
                for _ in range(archive_batch):
                    if total_fetched == limit:
                        # Direct sample type
                        if context.type != c_api.PM_CONTEXT_ARCHIVE:
                            return total_fetched
                        self.send_updates(fetched)
                        logger.debug('Reached limit "%s", stopping', self.limit)
                        return total_fetched
                    # Consider using the fetchGroup API https://pcp.readthedocs.io/en/latest/PG/PMAPI.html#fetchgroup-operation
                    results = context.pmFetch(pmids)

                    # The metrics channel sends a meta message the first time
                    # we open the channel and whenever instanced metrics change.
                    if self.needs_meta_update(results):
                        # Flush all metrics and send new meta, but only if there is data
                        if fetched:
                            self.send_updates(fetched)
                            fetched.clear()
                        self.send_meta(results, context)

                    fetched.append(self.parse_fetched_results(context, results))
                    self.last_results = results
                    total_fetched += 1

                self.send_updates(fetched)
                fetched.clear()
                await asyncio.sleep(0)  # yield
            except pmapi.pmErr as exc:
                logger.debug('Fetching error: %r, fetched %r', exc, fetched)
                if exc.errno() != c_api.PM_ERR_EOL:
                    raise ChannelError('internal-error', message=str(exc)) from None

                if len(fetched) > 0:
                    self.send_updates(fetched)

                break

        return total_fetched

    def parse_fetched_results(self, context: 'pmapi.pmContext', results: Any) -> Any:
        metrics = list(self.metrics)
        samples: Dict[str, Optional[float] | list[Optional[float]]] = {}

        samples['timestamp'] = float(results.contents.timestamp)
        for i in range(results.contents.numpmid):
            values: Optional[List[Optional[float]] | Optional[float]] = None
            numval: int = results.contents.get_numval(i)
            metric_desc = self.metric_descriptions[i]
            content_type = metric_desc.desc.type

            # negative numval is an error code we ignore
            if numval < 0:
                samples[metrics[i].name] = None
                continue

            # Unsupported types
            if content_type == c_api.PM_TYPE_AGGREGATE or \
                content_type == c_api.PM_TYPE_EVENT or \
                content_type == c_api.PM_TYPE_STRING:
                samples[metrics[i].name] = None
                continue

            if not metric_desc.instanced:
                values = self.build_sample(context, results, metric_desc, i, 0)
            else:
                vals: List[Optional[float]] = []
                for j in range(numval):
                    vals.append(self.build_sample(context, results, metric_desc, i, j))
                values = vals

            samples[metrics[i].name] = values

        return samples

    def build_sample(self, context: 'pmapi.pmContext', results: Any, metric_desc: MetricInfo,
                     metric: int, instance: int) -> 'float | None':
        pmid = results.contents.get_pmid(metric)
        logger.debug("build_sample pmid=%d, metric=%d, instance=%d", pmid, metric, instance)

        # PCP throws an error when we try to convert a metric with numval <=
        # instanceid, the C bridge returns NAN. We return the Python equivalent None.
        valueset = results.contents.get_vset(metric)
        if valueset.numval <= instance:
            return None

        valfmt = results.contents.get_valfmt(metric)
        value = results.contents.get_vlist(metric, instance)
        content_type = metric_desc.desc.type

        # Make sure we keep the least 48 significant bits of 64 bit numbers
        # since "delta" and "rate" derivation works on those, and the whole
        # 64 don't fit into a double.
        sample_value = None
        atom = None
        if content_type == c_api.PM_TYPE_64:
            try:
                atom = context.pmExtractValue(valfmt,
                                              value,
                                              c_api.PM_TYPE_64,
                                              c_api.PM_TYPE_64)
                sample_value = atom.ll & ((1 << 48) - 1)
            except Exception as exc:
                logger.exception("Unable to extract PCP TYPE_64 value %s", exc)
        elif content_type == c_api.PM_TYPE_U64:
            try:
                atom = context.pmExtractValue(valfmt,
                                              value,
                                              c_api.PM_TYPE_U64,
                                              c_api.PM_TYPE_U64)
                sample_value = (atom.ull << 16) >> 16
            except Exception as exc:
                logger.exception("Unable to extract PCP TYPE_U64 value %s", exc)
        else:
            try:
                atom = context.pmExtractValue(valfmt,
                                              value,
                                              content_type,
                                              c_api.PM_TYPE_DOUBLE)
                sample_value = atom.d
            except Exception as exc:
                logger.exception("Unable to extract PCP value %s", exc)

        # If requested units don't match metrics convert them.
        if metric_desc.desc.units != metric_desc.units:
            try:
                dummy = pmapi.pmAtomValue()
                dummy.d = sample_value
                converted_atom = context.pmConvScale(c_api.PM_TYPE_DOUBLE,
                                                     dummy,
                                                     [metric_desc.desc],
                                                     0,
                                                     metric_desc.units)
                sample_value = converted_atom.d * metric_desc.factor
            except pmapi.pmErr as exc:
                raise ChannelError('internal-error', message=str(exc)) from None

        return sample_value

    # HACK: copied from internalmetrics
    def calculate_sample_rate(self, value: float, old_value: Optional[float]) -> Any:
        if old_value is not None:
            return (value - old_value) / (self.next_timestamp - self.last_timestamp)
        else:
            return False

    def send_updates(self, samples: Sequence[Any]) -> None:
        data: list[list[float | list[float]]] = []
        last_samples = self.last_samples or {}

        for sample in samples:
            assert isinstance(sample['timestamp'], float)
            self.next_timestamp = sample['timestamp']
            sampled_values: list[float | list[float]] = []

            for metricinfo in self.metrics:
                value = sample[metricinfo.name]
                old_value = last_samples.get(metricinfo.name, None)

                if isinstance(value, list):
                    if metricinfo.derive == 'rate':
                        tmp = []
                        for index, val in enumerate(value):
                            old_val = None
                            if old_value is not None:
                                try:
                                    old_val = old_value[index]
                                except IndexError:
                                    pass
                            tmp.append(self.calculate_sample_rate(val, old_val))
                        sampled_values.append(tmp)
                    else:
                        sampled_values.append(value)
                else:
                    if metricinfo.derive == 'rate' and value is not None:
                        sampled_values.append(self.calculate_sample_rate(value, old_value))
                    else:
                        if value is None:
                            sampled_values.append(False)
                        else:
                            sampled_values.append(value)

            data.append(sampled_values)
            self.last_timestamp = self.next_timestamp
            last_samples = sample

        self.last_samples = last_samples

        self.send_data(json.dumps(data).encode())

    async def sample_archives(self, archives: 'Sequence[ArchiveInfo]') -> None:
        total_fetched = 0
        for i, archive in enumerate(archives):
            # Set metric_descriptions to the current archive
            self.metric_descriptions = archive.metric_descriptions

            # Reset last samples and results
            self.last_results = None
            self.last_samples = None
            timestamp = self.start_timestamp

            # TODO can this be smarter?
            # continue when current archive isn't last and next archive starts before timestamp
            if i != len(archives) - 1 and archives[i + 1].start < timestamp:
                continue

            if timestamp < archive.start:
                timestamp = int(archive.start)

            context = archive.context
            try:
                if hasattr(c_api, 'PM_XTB_SET'):
                    # PCP < 7.0: use XTB encoding with timeval
                    context.pmSetMode(c_api.PM_MODE_INTERP | c_api.PM_XTB_SET(c_api.PM_TIME_MSEC),
                                      self.float_to_timeval(timestamp), self.interval)
                else:
                    # PCP 7.0 removed PM_XTB_SET, use new timespec-based API
                    context.pmSetMode(c_api.PM_MODE_INTERP,
                                      self.float_to_timespec(timestamp),
                                      self.float_to_timespec(self.interval))
            except pmapi.pmErr as exc:
                raise ChannelError('internal-error', message=str(exc)) from None

            total_fetched = await self.sample(archive.context, self.archive_batch, self.limit, total_fetched)

    def prepare_direct_context(self, name: str, context_type: str) -> 'pmapi.pmContext':
        try:
            direct_context = pmapi.pmContext(context_type, name)
        except pmapi.pmErr as exc:
            raise ChannelError('internal-error', message=str(exc)) from None

        for metric in self.metrics:
            metric_desc = None
            try:
                metric_desc = self.convert_metric_description(direct_context, metric)
            except MetricNotFoundError:
                raise ChannelError('') from None
            assert metric_desc is not None
            self.metric_descriptions.append(metric_desc)

        return direct_context

    async def run(self, options: JsonObject) -> None:
        self.metric_descriptions = []
        logger.debug('metrics pcp-archive open: %r, channel: %r', options, self.channel)

        self.parse_options(options)
        try_import_pcp()
        # HACK: the mock package test only sets pmapi to true
        if pmapi is True:  # pragma: no cover
            raise ChannelError('not-supported')

        name, context_type = self.get_context_and_name(self.source)

        if context_type == c_api.PM_CONTEXT_ARCHIVE:
            archives = self.get_archives(name)
            self.ready()
            await self.sample_archives(archives)
        else:
            direct_context = self.prepare_direct_context(name, context_type)
            self.ready()

            while True:
                await self.sample(direct_context, 1, 1, 0)
                await asyncio.sleep(self.interval / 1000)
''',
  'cockpit/channels/stream.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import logging
import os
import subprocess
from typing import Dict

from ..channel import ChannelError, ProtocolChannel
from ..jsonutil import JsonDict, JsonObject, get_bool, get_enum, get_int, get_object, get_str, get_strv
from ..transports import SubprocessProtocol, SubprocessTransport, WindowSize

logger = logging.getLogger(__name__)


class SocketStreamChannel(ProtocolChannel):
    payload = 'stream'

    async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> asyncio.Transport:
        if 'unix' in options and 'port' in options:
            raise ChannelError('protocol-error', message='cannot specify both "port" and "unix" options')

        try:
            # Unix
            if 'unix' in options:
                path = get_str(options, 'unix')
                label = f'Unix socket {path}'
                transport, _ = await loop.create_unix_connection(lambda: self, path)

            # TCP
            elif 'port' in options:
                port = get_int(options, 'port')
                host = get_str(options, 'address', 'localhost')
                label = f'TCP socket {host}:{port}'

                transport, _ = await loop.create_connection(lambda: self, host, port)
            else:
                raise ChannelError('protocol-error',
                                   message='no "port" or "unix" or other address option for channel')

            logger.debug('SocketStreamChannel: connected to %s', label)
        except OSError as error:
            logger.info('SocketStreamChannel: connecting to %s failed: %s', label, error)
            if isinstance(error, ConnectionRefusedError):
                problem = 'not-found'
            else:
                problem = 'terminated'
            raise ChannelError(problem, message=str(error)) from error
        self.close_on_eof()
        assert isinstance(transport, asyncio.Transport)
        return transport


class SubprocessStreamChannel(ProtocolChannel, SubprocessProtocol):
    payload = 'stream'
    restrictions = (('spawn', None),)

    def process_exited(self) -> None:
        self.close_on_eof()

    def _get_close_args(self) -> JsonObject:
        assert isinstance(self._transport, SubprocessTransport)
        args: JsonDict = {'exit-status': self._transport.get_returncode()}
        stderr = self._transport.get_stderr()
        if stderr is not None:
            args['message'] = stderr
        return args

    def do_options(self, options: JsonObject) -> None:
        window = get_object(options, 'window', WindowSize, None)
        if window is not None:
            assert isinstance(self._transport, SubprocessTransport)
            self._transport.set_window_size(window)

    async def create_transport(self, loop: asyncio.AbstractEventLoop, options: JsonObject) -> SubprocessTransport:
        args = get_strv(options, 'spawn')
        err = get_enum(options, 'err', ['out', 'ignore', 'message'], 'message')
        cwd = get_str(options, 'directory', '.')
        pty = get_bool(options, 'pty', default=False)
        window = get_object(options, 'window', WindowSize, None)
        environ = get_strv(options, 'environ', [])

        if err == 'out':
            stderr = subprocess.STDOUT
        elif err == 'ignore':
            stderr = subprocess.DEVNULL
        else:
            stderr = subprocess.PIPE

        env: Dict[str, str] = dict(os.environ)
        try:
            env.update(dict(e.split('=', 1) for e in environ))
        except ValueError:
            raise ChannelError('protocol-error', message='invalid "environ" option for stream channel') from None

        try:
            transport = SubprocessTransport(loop, self, args, pty=pty, window=window, env=env, cwd=cwd, stderr=stderr)
            pid = transport.get_pid()
            self._ready_info = {'pid': pid}
            logger.debug('Spawned process args=%s pid=%i', args, pid)
            return transport
        except FileNotFoundError as error:
            raise ChannelError('not-found') from error
        except PermissionError as error:
            raise ChannelError('access-denied') from error
        except OSError as error:
            logger.info("Failed to spawn %s: %s", args, error)
            raise ChannelError('internal-error') from error
''',
  'cockpit/channels/trivial.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import logging

from cockpit.jsonutil import JsonObject

from ..channel import Channel

logger = logging.getLogger(__name__)


class EchoChannel(Channel):
    payload = 'echo'

    def do_open(self, options: JsonObject) -> None:
        self.ready()

    def do_data(self, data: bytes) -> None:
        self.send_bytes(data)

    def do_done(self) -> None:
        self.done()
        self.close()


class NullChannel(Channel):
    payload = 'null'

    def do_open(self, options: JsonObject) -> None:
        self.ready()

    def do_close(self) -> None:
        self.close()
''',
  'cockpit/config.py': br'''#
# Copyright (C) 2023 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import configparser
import logging
import os
from pathlib import Path

from cockpit._vendor.systemd_ctypes import bus

logger = logging.getLogger(__name__)

XDG_CONFIG_HOME = Path(os.getenv('XDG_CONFIG_HOME') or os.path.expanduser('~/.config'))
DOT_CONFIG_COCKPIT = XDG_CONFIG_HOME / 'cockpit'


def lookup_config(filename: str) -> Path:
    config_dirs = os.environ.get('XDG_CONFIG_DIRS', '/etc').split(':')
    fallback = None
    for config_dir in config_dirs:
        config_path = Path(config_dir, 'cockpit', filename)
        if not fallback:
            fallback = config_path
        if config_path.exists():
            logger.debug('lookup_config(%s): found %s', filename, config_path)
            return config_path

    # default to the first entry in XDG_CONFIG_DIRS; that's not according to the spec,
    # but what Cockpit has done for years
    logger.debug('lookup_config(%s): defaulting to %s', filename, fallback)
    assert fallback  # mypy; config_dirs always has at least one string
    return fallback


class Config(bus.Object, interface='cockpit.Config'):
    def __init__(self) -> None:
        self.reload()

    @bus.Interface.Method(out_types='s', in_types='ss')
    def get_string(self, section: str, key: str) -> str:
        try:
            return self.config[section][key]
        except KeyError as exc:
            raise bus.BusError('cockpit.Config.KeyError', f'key {key} in section {section} does not exist') from exc

    @bus.Interface.Method(out_types='u', in_types='ssuuu')
    def get_u_int(self, section: str, key: str, default: int, maximum: int, minimum: int) -> int:
        try:
            value = self.config[section][key]
        except KeyError:
            return default

        try:
            int_val = int(value)
        except ValueError:
            logger.warning('cockpit.conf: [%s] %s is not an integer', section, key)
            return default

        return min(max(int_val, minimum), maximum)

    @bus.Interface.Method()
    def reload(self) -> None:
        self.config = configparser.ConfigParser(interpolation=None)
        cockpit_conf = lookup_config('cockpit.conf')
        logger.debug("cockpit.Config: loading %s", cockpit_conf)
        # this may not exist, but it's ok to not have a config file and thus leave self.config empty
        try:
            self.config.read(cockpit_conf)
        except configparser.Error as exc:
            logger.warning("cockpit.conf is invalid: %s", exc)
            self.config.clear()
            return


class Environment(bus.Object, interface='cockpit.Environment'):
    variables = bus.Interface.Property('a{ss}')

    @variables.getter
    def get_variables(self) -> 'dict[str, str]':
        return os.environ.copy()
''',
  'cockpit/data/__init__.py': br'''# SPDX-License-Identifier: LGPL-2.1-or-later
import sys

if sys.version_info >= (3, 11):  # noqa: RUF067
    import importlib.resources

    def read_cockpit_data_file(filename: str) -> bytes:
        return (importlib.resources.files('cockpit.data') / filename).read_bytes()

else:
    import importlib.abc

    def read_cockpit_data_file(filename: str) -> bytes:
        # https://github.com/python/mypy/issues/4182
        loader = __loader__  # type: ignore[name-defined]
        assert isinstance(loader, importlib.abc.ResourceLoader)

        path = __file__.replace('__init__.py', filename)
        return loader.get_data(path)
''',
  'cockpit/data/fail.html': br'''<!DOCTYPE html>
<html>
<head>
    <title>@@message@@</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
        :root {
            --pf-t--global--background--color--secondary--default: #f2f2f2;
            --pf-v6-c-page__main-container--BackgroundColor: #ffffff;
            --pf-v6-c-empty-state__icon--Color: #707070;
            --pf-t--global--text--color--regular: #151515;
        }
        :root:where(.pf-v6-theme-dark) {
            --pf-t--global--background--color--secondary--default: #151515;
            --pf-v6-c-page__main-container--BackgroundColor: #292929;
            --pf-v6-c-empty-state__icon--Color: #a3a3a3;
            --pf-t--global--text--color--regular: #ffffff;
        }
        html {
            height: 100%;
            display: block;
        }
        body {
            margin: 0;
            font-family: "Red Hat Display", "RedHatDisplay", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif;
            line-height: 1.3;
            color: var(--pf-t--global--text--color--regular);
            background: var(--pf-t--global--background--color--secondary--default);

        }
        img {
            border: 0;
            vertical-align: middle;
        }
        h1 {
            font-size: 1.25rem;
            font-weight: 500;
        }
        @font-face {
            font-family: "Red Hat Display";
            font-style: normal;
            font-weight: 400 700;
            src: url("fonts/RedHatDisplay/RedHatDisplayVF.woff2") format("woff2-variations");
            font-display: fallback;
        }
        .blank-slate-pf {
            text-align: center;
            padding: 90px 120px;
        }
        .pf-v6-svg {
            width: 1em;
            height: 1em;
            vertical-align: -0.125em;
            font-size: 3.5rem;
            color: var(--pf-v6-c-empty-state__icon--Color);
            margin-block-end: 1.5rem;
        }
        .pf-v6-c-page__main-container {
            border-radius: 16px;
            background: var(--pf-v6-c-page__main-container--BackgroundColor);
            margin-inline: 1.5rem;
            block-size: 100%;
        }
    </style>
    <script>
        /*
        * Copyright (C) 2022 Red Hat, Inc.
        * SPDX-License-Identifier: LGPL-2.1-or-later
        */

        function debug(...args) {
            if (window.debugging == "all" || window.debugging?.includes("style")) {
                console.debug([`cockpit-dark-theme: ${document.documentElement.id}:`, ...args].join(" "));
            }
        }

        function changeDarkThemeClass(documentElement, dark_mode) {
            debug(`Setting cockpit theme to ${dark_mode ? "dark" : "light"}`);

            if (dark_mode) {
                documentElement.classList.add('pf-v6-theme-dark');
            } else {
                documentElement.classList.remove('pf-v6-theme-dark');
            }
        }

        function _setDarkMode(_style) {
            const style = _style || localStorage.getItem('shell:style') || 'auto';
            let dark_mode;
            // If a user set's an explicit theme, ignore system changes.
            if ((window.matchMedia?.('(prefers-color-scheme: dark)').matches && style === "auto") || style === "dark") {
                dark_mode = true;
            } else {
                dark_mode = false;
            }
            changeDarkThemeClass(document.documentElement, dark_mode);
        }

        window.addEventListener("storage", event => {
            if (event.key === "shell:style") {
                debug(`Storage element 'shell:style' changed from  ${event.oldValue} to ${event.newValue}`);

                _setDarkMode();
            }
        });

        // When changing the theme from the shell switcher the localstorage change will not fire for the same page (aka shell)
        // so we need to listen for the event on the window object.
        window.addEventListener("cockpit-style", event => {
            if (event instanceof CustomEvent) {
                const style = event.detail.style;
                debug(`Event received from shell with 'cockpit-style'  ${style}`);

                _setDarkMode(style);
            }
        });

        window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
            debug(`Operating system theme preference changed to ${window.matchMedia?.('(prefers-color-scheme: dark)').matches ? "dark" : "light"}`);
            _setDarkMode();
        });

        _setDarkMode();
    </script>
</head>
<body>
    <div class="pf-v6-c-page__main-container">
        <div class="blank-slate-pf">
            <svg class="pf-v6-svg" viewBox="0 0 512 512" fill="currentColor" aria-hidden="true" role="img" width="1em" height="1em"><path d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"></path></svg>
            <h1>@@message@@</h1>
        </div>
    </div>
</body>
</html>
''',
  'cockpit/internal_endpoints.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import glob
import grp
import json
import logging
import os
import pwd
from pathlib import Path
from typing import Dict, Optional

from cockpit._vendor.systemd_ctypes import Variant, bus, inotify, pathwatch

from . import config

logger = logging.getLogger(__name__)


class cockpit_LoginMessages(bus.Object):
    messages: Optional[str] = None

    def __init__(self) -> None:
        fdstr = os.environ.pop('COCKPIT_LOGIN_MESSAGES_MEMFD', None)
        if fdstr is None:
            logger.debug("COCKPIT_LOGIN_MESSAGES_MEMFD wasn't set.  No login messages today.")
            return

        logger.debug("Trying to read login messages from fd %s", fdstr)
        try:
            with open(int(fdstr), 'r') as login_messages:
                login_messages.seek(0)
                self.messages = login_messages.read()
        except (ValueError, OSError, UnicodeDecodeError) as exc:
            # ValueError - the envvar wasn't an int
            # OSError - the fd wasn't open, or other read failure
            # UnicodeDecodeError - didn't contain utf-8
            # For all of these, we simply failed to get the message.
            logger.debug("Reading login messages failed: %s", exc)
        else:
            logger.debug("Successfully read login messages: %s", self.messages)

    @bus.Interface.Method(out_types=['s'])
    def get(self):
        return self.messages or '{}'

    @bus.Interface.Method(out_types=[])
    def dismiss(self) -> None:
        self.messages = None


class cockpit_Machines(bus.Object):
    path: Path
    watch: pathwatch.PathWatch
    pending_notify: Optional[asyncio.Handle]

    # D-Bus implementation
    machines = bus.Interface.Property('a{sa{sv}}')

    @machines.getter
    def get_machines(self) -> Dict[str, Dict[str, Variant]]:
        results: Dict[str, Dict[str, Variant]] = {}

        for filename in glob.glob(f'{self.path}/*.json'):
            try:
                with open(filename) as fp:
                    try:
                        contents = json.load(fp)
                    except json.JSONDecodeError:
                        logger.warning('Invalid JSON in file %s.  Ignoring.', filename)
                        continue
                    # merge
                    for hostname, attrs in contents.items():
                        results[hostname] = {key: Variant(value) for key, value in attrs.items()}
            except OSError as exc:
                logger.error('Unreadable machines.d file %s: %s.  Ignoring.', filename, exc)

        return results

    @bus.Interface.Method(in_types=['s', 's', 'a{sv}'])
    def update(self, filename: str, hostname: str, attrs: Dict[str, Variant]) -> None:
        try:
            with self.path.joinpath(filename).open() as fp:
                contents = json.load(fp)
        except json.JSONDecodeError as exc:
            # Refuse to replace corrupted file
            raise bus.BusError('cockpit.Machines.Error', f'File {filename} is in invalid format: {exc}.') from exc
        except FileNotFoundError:
            # But an empty file is an expected case
            contents = {}

        contents.setdefault(hostname, {}).update({key: value.value for key, value in attrs.items()})

        self.path.mkdir(parents=True, exist_ok=True)
        with open(self.path.joinpath(filename), 'w') as fp:
            json.dump(contents, fp, indent=2)

    def notify(self) -> None:
        def _notify_now() -> None:
            self.properties_changed('cockpit.Machines', {}, ['Machines'])
            self.pending_notify = None

        # avoid a flurry of update notifications
        if self.pending_notify is None:
            self.pending_notify = asyncio.get_running_loop().call_later(1.0, _notify_now)

    # inotify events
    def do_inotify_event(self, mask: inotify.Event, cookie: int, name: Optional[str]) -> None:
        self.notify()

    def do_identity_changed(self, fd: Optional[int], errno: Optional[int]) -> None:
        self.notify()

    def __init__(self):
        self.path = config.lookup_config('machines.d')

        # ignore the first callback
        self.pending_notify = ...
        self.watch = pathwatch.PathWatch(str(self.path), self)
        self.pending_notify = None


class cockpit_User(bus.Object):
    name = bus.Interface.Property('s', value='')
    full = bus.Interface.Property('s', value='')
    id = bus.Interface.Property('i', value=0)
    gid = bus.Interface.Property('i', value=0)
    home = bus.Interface.Property('s', value='')
    shell = bus.Interface.Property('s', value='')
    groups = bus.Interface.Property('as', value=[])

    def __init__(self) -> None:
        user = pwd.getpwuid(os.getuid())
        self.name = user.pw_name
        self.full = user.pw_gecos
        self.id = user.pw_uid
        self.gid = user.pw_gid
        self.home = user.pw_dir
        self.shell = user.pw_shell

        # We want the primary group first in the list, without duplicates.
        # This is a bit awkward because `set()` is unordered...
        groups = [grp.getgrgid(user.pw_gid).gr_name]
        for gr in grp.getgrall():
            if user.pw_name in gr.gr_mem and gr.gr_name not in groups:
                groups.append(gr.gr_name)
        self.groups = groups


EXPORTS = [
    ('/LoginMessages', cockpit_LoginMessages),
    ('/machines', cockpit_Machines),
    ('/user', cockpit_User),
]
''',
  'cockpit/jsonutil.py': r'''#
# Copyright (C) 2023 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


from enum import Enum
from typing import Callable, Container, Dict, List, Mapping, Optional, Sequence, Type, TypeVar, Union

JsonLiteral = Union[str, float, bool, None]

# immutable
JsonValue = Union['JsonObject', Sequence['JsonValue'], JsonLiteral]
JsonObject = Mapping[str, JsonValue]

# mutable
JsonDocument = Union['JsonDict', 'JsonList', JsonLiteral]
JsonDict = Dict[str, JsonDocument]
JsonList = List[JsonDocument]


DT = TypeVar('DT')
T = TypeVar('T')


class JsonError(Exception):
    value: object

    def __init__(self, value: object, msg: str):
        super().__init__(msg)
        self.value = value


def typechecked(value: JsonValue, expected_type: Type[T]) -> T:
    """Ensure a JSON value has the expected type, returning it if so."""
    if not isinstance(value, expected_type):
        raise JsonError(value, f'must have type {expected_type.__name__}')
    return value


# We can't use None as a sentinel because it's often the actual default value
# EllipsisType is difficult because it's not available before 3.10.
# See https://peps.python.org/pep-0484/#support-for-singleton-types-in-unions
class _Empty(Enum):
    TOKEN = 0


_empty = _Empty.TOKEN


def _get(obj: JsonObject, cast: Callable[[JsonValue], T], key: str, default: Union[DT, _Empty]) -> Union[T, DT]:
    try:
        return cast(obj[key])
    except KeyError:
        if default is not _empty:
            return default
        raise JsonError(obj, f"attribute '{key}' required") from None
    except JsonError as exc:
        target = f"attribute '{key}'" + (' elements:' if exc.value is not obj[key] else ':')
        raise JsonError(obj, f"{target} {exc!s}") from exc


def get_bool(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, bool]:
    return _get(obj, lambda v: typechecked(v, bool), key, default)


def get_int(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, int]:
    return _get(obj, lambda v: typechecked(v, int), key, default)


def get_str(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, str]:
    return _get(obj, lambda v: typechecked(v, str), key, default)


def get_str_or_none(obj: JsonObject, key: str, default: Optional[str]) -> Optional[str]:
    return _get(obj, lambda v: None if v is None else typechecked(v, str), key, default)


def get_str_or_int(obj: JsonObject, key: str, default: Optional[Union[str, int]]) -> Optional[Union[str, int]]:
    def as_str_or_int(value: JsonValue) -> Union[str, int]:
        if not isinstance(value, (str, int)):
            raise JsonError(value, 'must be a string or integer')
        return value

    return _get(obj, as_str_or_int, key, default)


def get_dict(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, JsonObject]:
    return _get(obj, lambda v: typechecked(v, dict), key, default)


def get_object(
    obj: JsonObject,
    key: str,
    constructor: Callable[[JsonObject], T],
    default: Union[DT, _Empty] = _empty
) -> Union[DT, T]:
    return _get(obj, lambda v: constructor(typechecked(v, dict)), key, default)


def get_strv(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, Sequence[str]]:
    def as_strv(value: JsonValue) -> Sequence[str]:
        return tuple(typechecked(item, str) for item in typechecked(value, list))
    return _get(obj, as_strv, key, default)


def get_enum(
    obj: JsonObject, key: str, choices: Container[str], default: Union[DT, _Empty] = _empty
) -> Union[DT, str]:
    def as_choice(value: JsonValue) -> str:
        # mypy can't do `__eq__()`-based type narrowing...
        # https://github.com/python/mypy/issues/17101
        if isinstance(value, str) and value in choices:
            return value
        raise JsonError(value, f'invalid value "{value}" not in {choices}')
    return _get(obj, as_choice, key, default)


def get_objv(obj: JsonObject, key: str, constructor: Callable[[JsonObject], T]) -> Union[DT, Sequence[T]]:
    def as_objv(value: JsonValue) -> Sequence[T]:
        return tuple(constructor(typechecked(item, dict)) for item in typechecked(value, list))
    return _get(obj, as_objv, key, ())


def create_object(message: 'JsonObject | None', kwargs: JsonObject) -> JsonObject:
    """Constructs a JSON object based on message and kwargs.

    If only message is given, it is returned, unmodified.  If message is None,
    it is equivalent to an empty dictionary.  A copy is always made.

    If kwargs are present, then any underscore ('_') present in a key name is
    rewritten to a dash ('-').  This is intended to bridge between the required
    Python syntax when providing kwargs and idiomatic JSON (which uses '-' for
    attributes).  These values override values in message.

    The idea is that `message` should be used for passing data along, and
    kwargs used for data originating at a given call site, possibly including
    modifications to an original message.
    """
    result = dict(message or {})

    for key, value in kwargs.items():
        # rewrite '_' (necessary in Python syntax kwargs list) to '-' (idiomatic JSON)
        json_key = key.replace('_', '-')
        result[json_key] = value

    return result


def json_merge_patch(current: JsonObject, patch: JsonObject) -> JsonObject:
    """Perform a JSON merge patch (RFC 7396) using 'current' and 'patch'.
    Neither of the original dictionaries is modified — the result is returned.
    """
    # Always take a copy ('result') — we never modify the input ('current')
    result = dict(current)
    for key, patch_value in patch.items():
        if isinstance(patch_value, Mapping):
            current_value = current.get(key, None)
            if not isinstance(current_value, Mapping):
                current_value = {}
            result[key] = json_merge_patch(current_value, patch_value)
        elif patch_value is not None:
            result[key] = patch_value
        else:
            result.pop(key, None)

    return result


def json_merge_and_filter_patch(current: JsonDict, patch: JsonDict) -> None:
    """Perform a JSON merge patch (RFC 7396) modifying 'current' with 'patch'.
    Also modifies 'patch' to remove redundant operations.
    """
    for key, patch_value in tuple(patch.items()):
        current_value = current.get(key, None)

        if isinstance(patch_value, dict):
            if not isinstance(current_value, dict):
                current[key] = current_value = {}
                json_merge_and_filter_patch(current_value, patch_value)
            else:
                json_merge_and_filter_patch(current_value, patch_value)
                if not patch_value:
                    del patch[key]
        elif current_value == patch_value:
            del patch[key]
        elif patch_value is not None:
            current[key] = patch_value
        else:
            del current[key]
'''.encode('utf-8'),
  'cockpit/osinfo.py': br'''# SPDX-License-Identifier: LGPL-2.1-or-later
# supported OSes for beibooting; entries are os-release keys
# keep this in sync with bots/lib/testmap.py
supported_oses: 'list[dict[str, str | None]]' = [
    # rolling release
    {"ID": "arch", "VERSION_ID": None},

    # match/describe CentOS separately, it's the upstream of all RHEL clones
    {"ID": "centos", "PLATFORM_ID": "platform:el9"},
    {"ID": "centos", "PLATFORM_ID": "platform:el10"},
    {"PLATFORM_ID": "platform:el8"},
    {"PLATFORM_ID": "platform:el9"},
    {"PLATFORM_ID": "platform:el10"},

    {"ID": "debian", "VERSION_ID": "12"},
    {"ID": "debian", "VERSION_ID": "13"},
    # rolling release
    {"ID": "debian", "VERSION_ID": None},

    {"ID": "fedora", "VERSION_ID": "41"},
    {"ID": "fedora", "VERSION_ID": "42"},
    {"ID": "fedora", "VERSION_ID": "43"},

    {"ID": "ubuntu", "VERSION_ID": "22.04"},
    {"ID": "ubuntu", "VERSION_ID": "24.04"},
    {"ID": "ubuntu", "VERSION_ID": "25.04"},
    {"ID": "ubuntu", "VERSION_ID": "25.10"},
]
''',
  'cockpit/packages.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import collections
import contextlib
import functools
import gzip
import io
import itertools
import json
import logging
import mimetypes
import os
import re
import shutil
from pathlib import Path
from typing import (
    BinaryIO,
    Callable,
    ClassVar,
    Dict,
    Generator,
    Iterable,
    List,
    NamedTuple,
    Optional,
    Pattern,
    Sequence,
    Tuple,
    TypeVar,
)

from cockpit._vendor.systemd_ctypes import bus

from . import config
from ._version import __version__
from .jsonutil import (
    JsonError,
    JsonObject,
    JsonValue,
    get_bool,
    get_dict,
    get_enum,
    get_int,
    get_objv,
    get_str,
    get_strv,
    json_merge_patch,
    typechecked,
)

logger = logging.getLogger(__name__)


# In practice, this is going to get called over and over again with exactly the
# same list.  Let's try to cache the result.
@functools.lru_cache()
def parse_accept_language(accept_language: str) -> Sequence[str]:
    """Parse the Accept-Language header, if it exists.

    Returns an ordered list of languages, with fallbacks inserted, and
    truncated to the position where 'en' would have otherwise appeared, if
    applicable.

    https://tools.ietf.org/html/rfc7231#section-5.3.5
    https://datatracker.ietf.org/doc/html/rfc4647#section-3.4
    """

    logger.debug('parse_accept_language(%r)', accept_language)
    locales_with_q = []
    for entry in accept_language.split(','):
        entry = entry.strip().lower()
        logger.debug('  entry %r', entry)
        locale, _, qstr = entry.partition(';q=')
        try:
            q = float(qstr or 1.0)
        except ValueError:
            continue  # ignore malformed entry

        while locale:
            logger.debug('    adding %r q=%r', locale, q)
            locales_with_q.append((locale, q))
            # strip off '-detail' suffixes until there's nothing left
            locale, _, _region = locale.rpartition('-')

    # Sort the list by highest q value.  Otherwise, this is a stable sort.
    locales_with_q.sort(key=lambda pair: pair[1], reverse=True)
    logger.debug('  sorted list is %r', locales_with_q)

    # If we have 'en' anywhere in our list, ignore it and all items after it.
    # This will result in us getting an untranslated (ie: English) version if
    # none of the more-preferred languages are found, which is what we want.
    # We also take the chance to drop duplicate items.  Note: both of these
    # things need to happen after sorting.
    results = []
    for locale, _q in locales_with_q:
        if locale == 'en':
            break
        if locale not in results:
            results.append(locale)

    logger.debug('  results list is %r', results)
    return tuple(results)


def sortify_version(version: str) -> str:
    """Convert a version string to a form that can be compared"""
    # 0-pad each numeric component.  Only supports numeric versions like 1.2.3.
    return '.'.join(part.zfill(8) for part in version.split('.'))


@functools.lru_cache()
def get_libexecdir() -> str:
    """Detect libexecdir on current machine

    This only works for systems which have cockpit-ws installed.
    """
    for candidate in ['/usr/local/libexec', '/usr/libexec', '/usr/local/lib/cockpit', '/usr/lib/cockpit']:
        if os.path.exists(os.path.join(candidate, 'cockpit-askpass')):
            return candidate
    else:
        logger.warning('Could not detect libexecdir')
        # give readable error messages
        return '/nonexistent/libexec'


# HACK: Type narrowing over Union types is not supported in the general case,
# but this works for the case we care about: knowing that when we pass in an
# JsonObject, we'll get an JsonObject back.
J = TypeVar('J', JsonObject, JsonValue)


def patch_libexecdir(obj: J) -> J:
    if isinstance(obj, str):
        if '${libexecdir}/cockpit-askpass' in obj:
            # extra-special case: we handle this internally
            abs_askpass = shutil.which('cockpit-askpass')
            if abs_askpass is not None:
                return obj.replace('${libexecdir}/cockpit-askpass', abs_askpass)
        return obj.replace('${libexecdir}', get_libexecdir())
    elif isinstance(obj, dict):
        return {key: patch_libexecdir(value) for key, value in obj.items()}
    elif isinstance(obj, list):
        return [patch_libexecdir(item) for item in obj]
    else:
        return obj


# A document is a binary stream with a Content-Type, optional Content-Encoding,
# and optional Content-Security-Policy
class Document(NamedTuple):
    data: BinaryIO
    content_type: str
    content_encoding: Optional[str] = None
    content_security_policy: Optional[str] = None


class PackagesListener:
    def packages_loaded(self) -> None:
        """Called when the packages have been reloaded"""


# This wants to be 'dict[str, JsonValue]', but that does not work in Python 3.6 yet
class BridgeConfig(dict, JsonObject):  # type: ignore[type-arg]
    def __init__(self, value: JsonObject):
        super().__init__(value)

        # NB: You almost definitely don't want to access the filesystem here to
        # check for (for example) if a bridge exists or not.  This code might
        # be running on the client side of a beiboot setup.

        self.label = get_str(self, 'label', None)

        self.method = get_enum(self, 'method', ['spawn', 'StartTransientUnit'], 'spawn')
        self.privileged = get_bool(self, 'privileged', default=False)
        self.match = get_dict(self, 'match', {})
        if not self.privileged and not self.match:
            raise JsonError(value, 'must have match rules or be privileged')

        self.environ = get_strv(self, 'environ', ())
        self.spawn = get_strv(self, 'spawn')
        if not self.spawn:
            raise JsonError(value, 'spawn vector must be non-empty')
        self.polkit = get_bool(self, 'polkit', 'pkexec' in self.spawn)

        self.name = self.label or self.spawn[0]


class Condition:
    def get_condition_files(self) -> Iterable[str]:
        raise NotImplementedError

    def check(self, path_exists: Callable[[str], bool]) -> bool:
        raise NotImplementedError


class ConstantCondition(Condition):
    def __init__(self, *, value: bool) -> None:
        self.value = value

    def __str__(self) -> str:
        return f'ConstantCondition({self.value})'

    def get_condition_files(self) -> Iterable[str]:
        return []

    def check(self, path_exists: Callable[[str], bool]) -> bool:
        return self.value


class PathExistsCondition(Condition):
    def __init__(self, path: str, *, exists: bool) -> None:
        self.path = path
        self.exists = exists

    def __str__(self) -> str:
        return f'PathExistsCondition({self.path!r}, exists={self.exists})'

    def get_condition_files(self) -> Iterable[str]:
        yield self.path

    def check(self, path_exists: Callable[[str], bool]) -> bool:
        return self.exists == path_exists(self.path)


class DisjunctiveCondition(Condition):
    def __init__(self, conditions: Iterable[Condition]) -> None:
        self.conditions = conditions

    def __str__(self) -> str:
        return f'DisjunctiveCondition({", ".join(str(c) for c in self.conditions)})'

    def get_condition_files(self) -> Iterable[str]:
        for condition in self.conditions:
            yield from condition.get_condition_files()

    def check(self, path_exists: Callable[[str], bool]) -> bool:
        return any(c.check(path_exists) for c in self.conditions)


# This wants to be 'dict[str, JsonValue]', but that does not work in Python 3.6 yet
class Manifest(dict, JsonObject):  # type: ignore[type-arg]
    # Skip version check when running out of the git checkout (__version__ is None)
    COCKPIT_VERSION = __version__ and sortify_version(__version__)

    def __init__(self, path: Path, value: JsonObject):
        super().__init__(value)
        self.path = path
        self.name = get_str(self, 'name', self.path.name)
        self.bridges = get_objv(self, 'bridges', BridgeConfig)
        self.priority = get_int(self, 'priority', 1)
        self.csp = get_str(self, 'content-security-policy', '')
        self.conditions = get_objv(self, 'conditions', self.parse_condition)

        # Skip version check when running out of the git checkout (COCKPIT_VERSION is None)
        if self.COCKPIT_VERSION is not None:
            requires = get_dict(self, 'requires', {})
            for name, version in requires.items():
                if name != 'cockpit':
                    raise JsonError(name, 'non-cockpit requirement listed')
                if sortify_version(typechecked(version, str)) > self.COCKPIT_VERSION:
                    raise JsonError(version, f'required cockpit version ({version}) not met')

    def parse_condition(self, value: JsonObject) -> Condition:
        if len(value) != 1:
            raise JsonError(value, 'must contain exactly one key/value pair')

        if "path-exists" in value:
            return PathExistsCondition(get_str(value, "path-exists"), exists=True)

        elif "path-not-exists" in value:
            return PathExistsCondition(get_str(value, "path-not-exists"), exists=False)

        elif "any" in value:
            return DisjunctiveCondition(get_objv(value, "any", self.parse_condition))

        else:
            # do *not* ignore manifests with unknown predicates, for forward compatibility
            logger.warning('  %s: ignoring unknown predicate in manifest: %s', self.path, value)
            return ConstantCondition(value=True)

    def get_condition_files(self) -> Iterable[str]:
        for condition in self.conditions:
            yield from condition.get_condition_files()


class Package:
    # For po{,.manifest}.js files, the interesting part is the locale name
    PO_JS_RE: ClassVar[Pattern[str]] = re.compile(r'(po|po\.manifest)\.([^.]+)\.js(\.gz)?')

    # immutable after __init__
    manifest: Manifest
    name: str
    path: Path
    priority: int

    # computed later
    translations: Optional[Dict[str, Dict[str, str]]] = None
    files: Optional[Dict[str, str]] = None

    def __init__(self, manifest: Manifest):
        self.manifest = manifest
        self.name = manifest.name
        self.path = manifest.path
        self.priority = manifest.priority

    def ensure_scanned(self) -> None:
        """Ensure that the package has been scanned.

        This allows us to defer scanning the files of the package until we know
        that we'll actually use it.
        """

        if self.files is not None:
            return

        self.files = {}
        self.translations = {'po.js': {}, 'po.manifest.js': {}}

        for file in self.path.rglob('*'):
            name = str(file.relative_to(self.path))
            if name in ['.', '..', 'manifest.json']:
                continue

            po_match = Package.PO_JS_RE.fullmatch(name)
            if po_match:
                basename = po_match.group(1)
                locale = po_match.group(2)
                # Accept-Language is case-insensitive and uses '-' to separate variants
                lower_locale = locale.lower().replace('_', '-')

                logger.debug('Adding translation %r %r -> %r', basename, lower_locale, name)
                self.translations[f'{basename}.js'][lower_locale] = name
            else:
                # strip out trailing '.gz' components
                basename = re.sub(r'.gz$', '', name)
                logger.debug('Adding content %r -> %r', basename, name)
                self.files[basename] = name

                # If we see a filename like `x.min.js` we want to also offer it
                # at `x.js`, but only if `x.js(.gz)` itself is not present.
                # Note: this works for both the case where we found the `x.js`
                # first (it's already in the map) and also if we find it second
                # (it will be replaced in the map by the line just above).
                # See https://github.com/cockpit-project/cockpit/pull/19716
                self.files.setdefault(basename.replace('.min.', '.'), name)

        # support old cockpit-po-plugin which didn't write po.manifest.??.js
        if not self.translations['po.manifest.js']:
            self.translations['po.manifest.js'] = self.translations['po.js']

    def get_content_security_policy(self) -> str:
        policy = {
            "default-src": "'self'",
            "connect-src": "'self'",
            "form-action": "'self'",
            "base-uri": "'self'",
            "object-src": "'none'",
            "font-src": "'self' data:",
            "img-src": "'self' data:",
        }

        for item in self.manifest.csp.split(';'):
            item = item.strip()
            if item:
                key, _, value = item.strip().partition(' ')
                policy[key] = value

        return ' '.join(f'{k} {v};' for k, v in policy.items()) + ' block-all-mixed-content'

    def load_file(self, filename: str) -> Document:
        content_type, content_encoding = mimetypes.guess_type(filename)
        content_security_policy = None

        if content_type is None:
            content_type = 'text/plain'
        elif content_type.startswith('text/html'):
            content_security_policy = self.get_content_security_policy()

        path = self.path / filename
        logger.debug('  loading data from %s', path)

        return Document(path.open('rb'), content_type, content_encoding, content_security_policy)

    def load_translation(self, path: str, locales: Sequence[str]) -> Document:
        self.ensure_scanned()
        assert self.translations is not None

        # First match wins
        for locale in locales:
            with contextlib.suppress(KeyError):
                return self.load_file(self.translations[path][locale])

        # We prefer to return an empty document than 404 in order to avoid
        # errors in the console when a translation can't be found
        return Document(io.BytesIO(), 'text/javascript')

    def load_path(self, path: str, headers: JsonObject) -> Document:
        self.ensure_scanned()
        assert self.files is not None
        assert self.translations is not None

        if path in self.translations:
            locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))
            return self.load_translation(path, locales)
        else:
            return self.load_file(self.files[path])


class PackagesLoader:
    def path_exists(self, path: str) -> bool:
        return os.path.exists(path)

    @classmethod
    def get_xdg_data_dirs(cls) -> Iterable[str]:
        try:
            yield os.environ['XDG_DATA_HOME']
        except KeyError:
            yield os.path.expanduser('~/.local/share')

        try:
            yield from os.environ['XDG_DATA_DIRS'].split(':')
        except KeyError:
            yield from ('/usr/local/share', '/usr/share')

    @classmethod
    def patch_manifest(cls, manifest: JsonObject, parent: Path) -> JsonObject:
        override_files = [
            parent / 'override.json',
            config.lookup_config(f'{parent.name}.override.json'),
            config.DOT_CONFIG_COCKPIT / f'{parent.name}.override.json',
        ]

        for override_file in override_files:
            try:
                override: JsonValue = json.loads(override_file.read_bytes())
            except FileNotFoundError:
                continue
            except json.JSONDecodeError as exc:
                # User input error: report a warning
                logger.warning('%s: %s', override_file, exc)

            if not isinstance(override, dict):
                logger.warning('%s: override file is not a dictionary', override_file)
                continue

            manifest = json_merge_patch(manifest, override)

        return patch_libexecdir(manifest)

    @classmethod
    def load_manifests(cls) -> Iterable[Manifest]:
        for datadir in cls.get_xdg_data_dirs():
            logger.debug("Scanning for manifest files under %s", datadir)
            for file in Path(datadir).glob('cockpit/*/manifest.json'):
                logger.debug("Considering file %s", file)
                try:
                    manifest = json.loads(file.read_text())
                except json.JSONDecodeError as exc:
                    logger.error("%s: %s", file, exc)
                    continue
                if not isinstance(manifest, dict):
                    logger.error("%s: json document isn't an object", file)
                    continue

                parent = file.parent
                manifest = cls.patch_manifest(manifest, parent)
                try:
                    yield Manifest(parent, manifest)
                except JsonError as exc:
                    logger.warning('%s %s', file, exc)

    @classmethod
    def get_condition_files(cls) -> Iterable[str]:
        for manifest in cls.load_manifests():
            yield from manifest.get_condition_files()

    def check_conditions(self, manifest: Manifest) -> bool:
        for condition in manifest.conditions:
            if not condition.check(self.path_exists):
                logger.debug('  hiding package %s as its %s condition is not met', manifest.path, condition)
                return False

        return True

    def load_packages(self) -> Iterable[Tuple[str, Package]]:
        logger.debug('Scanning for available package manifests:')
        # Sort all available packages into buckets by to their claimed name
        names: Dict[str, List[Manifest]] = collections.defaultdict(list)
        for manifest in self.load_manifests():
            logger.debug('  %s/manifest.json', manifest.path)
            names[manifest.name].append(manifest)
        logger.debug('done.')

        logger.debug('Selecting packages to serve:')
        for name, candidates in names.items():
            # For each package name, iterate the candidates in descending
            # priority order and select the first one which passes all checks
            for candidate in sorted(candidates, key=lambda manifest: manifest.priority, reverse=True):
                try:
                    if self.check_conditions(candidate):
                        logger.debug('  creating package %s -> %s', name, candidate.path)
                        yield name, Package(candidate)
                        break
                except JsonError:
                    logger.warning('  %s: ignoring package with invalid manifest file', candidate.path)

                logger.debug('  ignoring %s: unmet conditions', candidate.path)
        logger.debug('done.')


class Packages(bus.Object, interface='cockpit.Packages'):
    loader: PackagesLoader
    listener: Optional[PackagesListener]
    packages: Dict[str, Package]
    saw_first_reload_hint: bool

    def __init__(self, listener: Optional[PackagesListener] = None, loader: Optional[PackagesLoader] = None):
        self.listener = listener
        self.loader = loader or PackagesLoader()
        self.load()

        # Reloading the Shell in the browser should reload the
        # packages.  This is implemented by having the Shell call
        # reload_hint whenever it starts.  The first call of this
        # method in each session is ignored so that packages are not
        # loaded twice right after logging in.
        #
        self.saw_first_reload_hint = False

    def load(self) -> None:
        self.packages = dict(self.loader.load_packages())
        self.manifests = json.dumps({name: dict(package.manifest) for name, package in self.packages.items()})
        logger.debug('Packages loaded: %s', list(self.packages))

    def show(self) -> None:
        for name in sorted(self.packages):
            package = self.packages[name]
            menuitems = []
            for entry in itertools.chain(
                    get_dict(package.manifest, 'menu', {}).values(),
                    get_dict(package.manifest, 'tools', {}).values()):
                if isinstance(entry, dict):
                    label = get_str(entry, 'label', None)
                    if label is not None:
                        menuitems.append(label)
            print(f'{name:20} {", ".join(menuitems):40} {package.path}')

    def get_bridge_configs(self) -> Sequence[BridgeConfig]:
        def yield_configs() -> Generator[BridgeConfig, None, None]:
            for package in sorted(self.packages.values(), key=lambda package: -package.priority):
                yield from package.manifest.bridges
        return tuple(yield_configs())

    # D-Bus Interface
    manifests = bus.Interface.Property('s', value="{}")

    @bus.Interface.Method()  # type: ignore[misc]
    def reload(self) -> None:
        self.load()
        if self.listener is not None:
            self.listener.packages_loaded()

    @bus.Interface.Method()  # type: ignore[misc]
    def reload_hint(self) -> None:
        if self.saw_first_reload_hint:
            self.reload()
        self.saw_first_reload_hint = True

    def load_manifests_js(self, headers: JsonObject, *, i18n: bool) -> Document:
        logger.debug('Serving /manifests.js')

        chunks: List[bytes] = []

        # Send the translations required for the manifest files, from each package
        if i18n:
            locales = parse_accept_language(get_str(headers, 'Accept-Language', ''))
            for name, package in self.packages.items():
                if name in ['static', 'base1']:
                    continue

                # find_translation will always find at least 'en'
                translation = package.load_translation('po.manifest.js', locales)
                with translation.data:
                    if translation.content_encoding == 'gzip':
                        data = gzip.decompress(translation.data.read())
                    else:
                        data = translation.data.read()

                chunks.append(data)

        chunks.append(b"""
            (function (root, data) {
                if (typeof define === 'function' && define.amd) {
                    define(data);
                }

                if (typeof cockpit === 'object') {
                    cockpit.manifests = data;
                } else {
                    root.manifests = data;
                }
            }(this, """ + self.manifests.encode() + b"""))""")

        return Document(io.BytesIO(b'\n'.join(chunks)), 'text/javascript')

    def load_manifests_json(self) -> Document:
        logger.debug('Serving /manifests.json')
        return Document(io.BytesIO(self.manifests.encode()), 'application/json')

    PATH_RE = re.compile(
        r'/'                   # leading '/'
        r'(?:([^/]+)/)?'       # optional leading path component
        r'((?:[^/]+/)*[^/]+)'  # remaining path components
    )

    def load_path(self, path: str, headers: JsonObject) -> Document:
        logger.debug('packages: serving %s', path)

        match = self.PATH_RE.fullmatch(path)
        if match is None:
            raise ValueError(f'Invalid HTTP path {path}')
        packagename, filename = match.groups()

        if packagename is not None:
            return self.packages[packagename].load_path(filename, headers)
        elif filename == 'manifests.js':
            return self.load_manifests_js(headers, i18n=False)
        elif filename == 'manifests-i18n.js':
            return self.load_manifests_js(headers, i18n=True)
        elif filename == 'manifests.json':
            return self.load_manifests_json()
        else:
            raise KeyError
''',
  'cockpit/peer.py': r'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import logging
import os
from typing import Any, Callable, Sequence

from .jsonutil import JsonObject, JsonValue
from .packages import BridgeConfig
from .protocol import CockpitProblem, CockpitProtocol, CockpitProtocolError
from .router import Endpoint, Router, RoutingRule
from .transports import SubprocessProtocol, SubprocessTransport

logger = logging.getLogger(__name__)


class PeerError(CockpitProblem):
    pass


class PeerExited(Exception):
    def __init__(self, exit_code: int):
        self.exit_code = exit_code


class Peer(CockpitProtocol, SubprocessProtocol, Endpoint):
    done_callbacks: 'list[Callable[[], None]]'
    init_future: 'asyncio.Future[JsonObject] | None'

    def __init__(self, router: Router):
        super().__init__(router)

        # All Peers start out frozen — we only unfreeze after we see the first 'init' message
        self.freeze_endpoint()

        self.init_future = asyncio.get_running_loop().create_future()
        self.done_callbacks = []

    # Initialization
    async def do_connect_transport(self) -> None:
        raise NotImplementedError

    async def spawn(self, argv: Sequence[str], env: Sequence[str], **kwargs: Any) -> asyncio.Transport:
        # Not actually async...
        loop = asyncio.get_running_loop()
        user_env = dict(e.split('=', 1) for e in env)
        return SubprocessTransport(loop, self, argv, env=dict(os.environ, **user_env), **kwargs)

    async def start(self, init_host: 'str | None' = None, **kwargs: JsonValue) -> JsonObject:
        """Request that the Peer is started and connected to the router.

        Creates the transport, connects it to the protocol, and participates in
        exchanging of init messages.  If anything goes wrong, the connection
        will be closed and an exception will be raised.

        The Peer starts out in a frozen state (ie: attempts to send messages to
        it will initially be queued). If init_host is not None then an init
        message is sent with the given 'host' field, plus any extra kwargs, and
        the queue is thawed. Otherwise, the caller is responsible for sending
        the init message and thawing the peer.

        In any case, the return value is the init message from the peer.
        """
        assert self.init_future is not None

        def _connect_task_done(task: 'asyncio.Task[None]') -> None:
            assert task is connect_task
            try:
                task.result()
            except asyncio.CancelledError:  # we did that (below)
                pass                        # we want to ignore it
            except Exception as exc:
                self.close(exc)

        connect_task = asyncio.create_task(self.do_connect_transport())
        connect_task.add_done_callback(_connect_task_done)

        try:
            # Wait for something to happen:
            #   - exception from our connection function
            #   - receiving "init" from the other side
            #   - receiving EOF from the other side
            #   - .close() was called
            #   - other transport exception
            init_message = await self.init_future

        except (PeerExited, BrokenPipeError):
            # These are fairly generic errors. PeerExited means that we observed the process exiting.
            # BrokenPipeError means that we got EPIPE when attempting to write() to it. In both cases,
            # the process is gone, but it's not clear why. If the connection process is still running,
            # perhaps we'd get a better error message from it.
            await connect_task
            # Otherwise, re-raise
            raise

        finally:
            self.init_future = None

            # In any case (failure or success) make sure this is done.
            if not connect_task.done():
                connect_task.cancel()

        if init_host is not None:
            logger.debug('  sending init message back, host %s', init_host)
            # Send "init" back
            self.write_control(None, command='init', version=1, host=init_host, **kwargs)

            # Thaw the queued messages
            self.thaw_endpoint()

        return init_message

    # Background initialization
    def start_in_background(self, init_host: 'str | None' = None, **kwargs: JsonValue) -> None:
        def _start_task_done(task: 'asyncio.Task[JsonObject]') -> None:
            assert task is start_task

            try:
                task.result()
            except (OSError, PeerExited, CockpitProblem, asyncio.CancelledError):
                pass  # Those are expected.  Others will throw.

        start_task = asyncio.create_task(self.start(init_host, **kwargs))
        start_task.add_done_callback(_start_task_done)

    # Shutdown
    def add_done_callback(self, callback: Callable[[], None]) -> None:
        self.done_callbacks.append(callback)

    # Handling of interesting events
    def do_superuser_init_done(self) -> None:
        pass

    def do_authorize(self, message: JsonObject) -> None:
        pass

    def transport_control_received(self, command: str, message: JsonObject) -> None:
        if command == 'init' and self.init_future is not None:
            logger.debug('Got init message with active init_future.  Setting result.')
            self.init_future.set_result(message)
        elif command == 'authorize':
            self.do_authorize(message)
        elif command == 'superuser-init-done':
            self.do_superuser_init_done()
        else:
            raise CockpitProtocolError(f'Received unexpected control message {command}')

    def eof_received(self) -> bool:
        # We always expect to be the ones to close the connection, so if we get
        # an EOF, then we consider it to be an error.  This allows us to
        # distinguish close caused by unexpected EOF (but no errno from a
        # syscall failure) vs. close caused by calling .close() on our side.
        # The process is still running at this point, so keep it and handle
        # the error in process_exited().
        logger.debug('Peer %s received unexpected EOF', self.__class__.__name__)
        return True

    def do_closed(self, exc: 'Exception | None') -> None:
        logger.debug('Peer %s connection lost %s %s', self.__class__.__name__, type(exc), exc)

        if exc is None:
            self.shutdown_endpoint(problem='terminated')
        elif isinstance(exc, PeerExited):
            # a common case is that the called peer does not exist
            if exc.exit_code == 127:
                self.shutdown_endpoint(problem='no-cockpit')
            else:
                self.shutdown_endpoint(problem='terminated', message=f'Peer exited with status {exc.exit_code}')
        elif isinstance(exc, CockpitProblem):
            self.shutdown_endpoint(exc.attrs)
        else:
            self.shutdown_endpoint(problem='internal-error',
                                   message=f"[{exc.__class__.__name__}] {exc!s}")

        # If .start() is running, we need to make sure it stops running,
        # raising the correct exception.
        if self.init_future is not None and not self.init_future.done():
            if exc is not None:
                self.init_future.set_exception(exc)
            else:
                self.init_future.cancel()

        for callback in self.done_callbacks:
            callback()

    def process_exited(self) -> None:
        assert isinstance(self.transport, SubprocessTransport)
        logger.debug('Peer %s exited, status %d', self.__class__.__name__, self.transport.get_returncode())
        returncode = self.transport.get_returncode()
        assert isinstance(returncode, int)
        self.close(PeerExited(returncode))

    # Forwarding data: from the peer to the router
    def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
        if self.init_future is not None:
            raise CockpitProtocolError('Received unexpected channel control message before init')
        self.send_channel_control(channel, command, message)

    def channel_data_received(self, channel: str, data: bytes) -> None:
        if self.init_future is not None:
            raise CockpitProtocolError('Received unexpected channel data before init')
        self.send_channel_data(channel, data)

    # Forwarding data: from the router to the peer
    def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
        assert self.init_future is None
        self.write_control(message)

    def do_channel_data(self, channel: str, data: bytes) -> None:
        assert self.init_future is None
        self.write_channel_data(channel, data)

    def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
        assert self.init_future is None
        self.write_control(message)

    def do_close(self) -> None:
        self.close()


class ConfiguredPeer(Peer):
    config: BridgeConfig
    method: str
    args: Sequence[str]
    env: Sequence[str]

    def __init__(self, router: Router, config: BridgeConfig):
        self.config = config
        self.method = config.method
        self.args = config.spawn
        self.env = config.environ
        super().__init__(router)

    async def do_connect_transport(self) -> None:
        assert self.args is not None
        await self.spawn(self.args, self.env)


class PeerRoutingRule(RoutingRule):
    config: BridgeConfig
    match: JsonObject
    peer: 'Peer | None'

    def __init__(self, router: Router, config: BridgeConfig):
        super().__init__(router)
        self.config = config
        self.match = config.match
        self.peer = None

    def apply_rule(self, options: JsonObject) -> 'Peer | None':
        # Check that we match

        for key, value in self.match.items():
            if key not in options:
                logger.debug('        rejecting because key %s is missing', key)
                return None
            if value is not None and options[key] != value:
                logger.debug('        rejecting because key %s has wrong value %s (vs %s)', key, options[key], value)
                return None

        # Start the peer if it's not running already
        if self.peer is None:
            self.peer = ConfiguredPeer(self.router, self.config)
            self.peer.add_done_callback(self.peer_closed)
            assert self.router.init_host
            self.peer.start_in_background(init_host=self.router.init_host)

        return self.peer

    def peer_closed(self) -> None:
        self.peer = None

    def shutdown(self) -> None:
        if self.peer is not None:
            self.peer.close()


class PeersRoutingRule(RoutingRule):
    rules: 'list[PeerRoutingRule]' = []

    def apply_rule(self, options: JsonObject) -> 'Endpoint | None':
        logger.debug('    considering %d rules', len(self.rules))
        for rule in self.rules:
            logger.debug('      considering %s', rule.config.name)
            endpoint = rule.apply_rule(options)
            if endpoint is not None:
                logger.debug('        selected')
                return endpoint
        logger.debug('      no peer rules matched')
        return None

    def set_configs(self, bridge_configs: Sequence[BridgeConfig]) -> None:
        old_rules = self.rules
        self.rules = []

        for config in bridge_configs:
            # Those are handled elsewhere...
            if config.privileged or 'host' in config.match:
                continue

            # Try to reuse an existing rule, if one exists...
            for rule in list(old_rules):
                if rule.config == config:
                    old_rules.remove(rule)
                    break
            else:
                # ... otherwise, create a new one.
                rule = PeerRoutingRule(self.router, config)

            self.rules.append(rule)

        # close down the old rules that didn't get reclaimed
        for rule in old_rules:
            rule.shutdown()

    def shutdown(self) -> None:
        for rule in self.rules:
            rule.shutdown()
'''.encode('utf-8'),
  'cockpit/polkit.py': r'''#
# Copyright (C) 2023 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import locale
import logging
import os
import pwd
from typing import Dict, List, Sequence, Tuple

from cockpit._vendor.ferny import AskpassHandler
from cockpit._vendor.systemd_ctypes import Variant, bus

# polkit ≥ 127 (Dec 2025) uses a socket-activated helper
HELPER_SOCKET = '/run/polkit/agent-helper.socket'

# older versions use a setuid helper binary;
# that path is valid on at least Debian, Fedora/RHEL, and Arch
HELPER_PATH = '/usr/lib/polkit-1/polkit-agent-helper-1'

AGENT_DBUS_PATH = '/PolkitAgent'

logger = logging.getLogger(__name__)

Identity = Tuple[str, Dict[str, Variant]]


# https://www.freedesktop.org/software/polkit/docs/latest/eggdbus-interface-org.freedesktop.PolicyKit1.AuthenticationAgent.html

# Note that we don't implement the CancelAuthentication() API. pkexec gets called in a way that has no opportunity to
# cancel an ongoing authentication from the pkexec side. On the UI side cancellation is implemented via the standard
# asyncio process mechanism. If we ever need CancelAuthentication(), we could keep a cookie → get_current_task()
# mapping, but that method is not available for Python 3.6 yet.

class org_freedesktop_PolicyKit1_AuthenticationAgent(bus.Object):
    def __init__(self, responder: AskpassHandler):
        super().__init__()
        self.responder = responder

    # confusingly named: this actually does the whole authentication dialog, see docs
    @bus.Interface.Method('', ['s', 's', 's', 'a{ss}', 's', 'a(sa{sv})'])
    async def begin_authentication(self, action_id: str, message: str, icon_name: str,
                                   details: Dict[str, str], cookie: str, identities: Sequence[Identity]) -> None:
        logger.debug('BeginAuthentication: action %s, message "%s", icon %s, details %s, cookie %s, identities %r',
                     action_id, message, icon_name, details, cookie, identities)
        # only support authentication as ourselves, as we don't yet have the
        # protocol plumbing and UI to select an admin user
        my_uid = os.geteuid()
        for (auth_type, subject) in identities:
            if auth_type == 'unix-user' and 'uid' in subject and subject['uid'].value == my_uid:
                logger.debug('Authentication subject %s matches our uid %d', subject, my_uid)
                break
        else:
            logger.warning('Not supporting authentication as any of %s', identities)
            return

        user_name = pwd.getpwuid(my_uid).pw_name

        # Try socket-activated helper first (polkit >= 127), fall back to legacy setuid helper
        try:
            await self._authenticate_socket(user_name, cookie)
        except OSError:
            # Socket not available, fall back to legacy setuid helper
            logger.debug('Socket helper not available, falling back to legacy helper')
            await self._authenticate_suid_helper(user_name, cookie)

    async def _authenticate_suid_helper(self, user_name: str, cookie: str) -> None:
        """Authenticate using legacy setuid helper binary"""
        logger.debug('Trying legacy polkit helper at %s', HELPER_PATH)
        process = await asyncio.create_subprocess_exec(HELPER_PATH, user_name, cookie,
                                                       stdin=asyncio.subprocess.PIPE,
                                                       stdout=asyncio.subprocess.PIPE)
        assert process.stdin is not None
        assert process.stdout is not None

        try:
            await self._communicate(process.stdin, process.stdout)
        except asyncio.CancelledError:
            logger.debug('Cancelled authentication')
        finally:
            try:
                process.terminate()
            except ProcessLookupError:
                pass  # process already exited and was reaped
            res = await process.wait()
            logger.debug('helper exited with code %i', res)

    async def _authenticate_socket(self, user_name: str, cookie: str) -> None:
        """Authenticate using socket-activated helper (polkit >= 127)"""
        logger.debug('Trying socket-activated polkit helper at %s', HELPER_SOCKET)
        reader, writer = await asyncio.open_unix_connection(HELPER_SOCKET)
        try:
            # Send username and cookie followed by newline
            writer.write(f'{user_name}\n{cookie}\n'.encode())
            await writer.drain()

            await self._communicate(writer, reader)
        except asyncio.CancelledError:
            logger.debug('Cancelled authentication')
        finally:
            writer.close()
            await writer.wait_closed()
            logger.debug('socket connection closed')

    async def _communicate(self, stdin: asyncio.StreamWriter, stdout: asyncio.StreamReader) -> None:
        messages: List[str] = []

        async for line in stdout:
            logger.debug('Read line from helper: %s', line)
            command, _, value = line.strip().decode().partition(' ')

            # usually: PAM_PROMPT_ECHO_OFF Password: \n
            if command.startswith('PAM_PROMPT'):
                # Don't pass this to the UI if it's "Password" (the usual case),
                # so that superuser.py uses the translated default
                if value.startswith('Password'):
                    value = ''

                # flush out accumulated info/error messages
                passwd = await self.responder.do_askpass('\n'.join(messages), value, '')
                messages.clear()
                if passwd is None:
                    logger.debug('got PAM_PROMPT %s, but do_askpass returned None', value)
                    raise asyncio.CancelledError('no password given')
                logger.debug('got PAM_PROMPT %s, do_askpass returned a password', value)
                stdin.write(passwd.encode())
                stdin.write(b'\n')
                del passwd  # don't keep this around longer than necessary
                await stdin.drain()
                logger.debug('got PAM_PROMPT, wrote password to helper')
            elif command in ('PAM_TEXT_INFO', 'PAM_ERROR'):
                messages.append(value)
            elif command == 'SUCCESS':
                logger.debug('Authentication succeeded')
                break
            elif command == 'FAILURE':
                logger.warning('Authentication failed')
                break
            else:
                logger.warning('Unknown line from helper, aborting: %s', line)
                break


class PolkitAgent:
    """Register polkit agent when required

    Use this as a context manager to ensure that the agent gets unregistered again.
    """
    def __init__(self, responder: AskpassHandler):
        self.responder = responder
        self.agent_slot = None

    async def __aenter__(self):
        try:
            self.system_bus = bus.Bus.default_system()
        except OSError as e:
            logger.warning('cannot connect to system bus, not registering polkit agent: %s', e)
            return self

        try:
            # may refine that with a D-Bus call to logind
            self.subject = ('unix-session', {'session-id': Variant(os.environ['XDG_SESSION_ID'], 's')})
        except KeyError:
            logger.debug('XDG_SESSION_ID not set, not registering polkit agent')
            return self

        agent_object = org_freedesktop_PolicyKit1_AuthenticationAgent(self.responder)
        self.agent_slot = self.system_bus.add_object(AGENT_DBUS_PATH, agent_object)

        # register agent
        locale_name = locale.setlocale(locale.LC_MESSAGES, None)
        await self.system_bus.call_method_async(
            'org.freedesktop.PolicyKit1',
            '/org/freedesktop/PolicyKit1/Authority',
            'org.freedesktop.PolicyKit1.Authority',
            'RegisterAuthenticationAgent',
            '(sa{sv})ss',
            self.subject, locale_name, AGENT_DBUS_PATH)
        logger.debug('Registered agent for %r and locale %s', self.subject, locale_name)
        return self

    async def __aexit__(self, _exc_type, _exc_value, _traceback):
        if self.agent_slot:
            await self.system_bus.call_method_async(
                'org.freedesktop.PolicyKit1',
                '/org/freedesktop/PolicyKit1/Authority',
                'org.freedesktop.PolicyKit1.Authority',
                'UnregisterAuthenticationAgent',
                '(sa{sv})s',
                self.subject, AGENT_DBUS_PATH)
            self.agent_slot.cancel()
            logger.debug('Unregistered agent for %r', self.subject)
'''.encode('utf-8'),
  'cockpit/polyfills.py': br'''#
# Copyright (C) 2023 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import contextlib
import os
import socket


def install():
    """Add shims for older Python versions"""

    # introduced in 3.9
    if not hasattr(socket, 'recv_fds'):
        import _socket
        import array

        def recv_fds(sock, bufsize, maxfds, flags=0):
            fds = array.array("i")
            msg, ancdata, flags, addr = sock.recvmsg(bufsize, _socket.CMSG_LEN(maxfds * fds.itemsize))
            for cmsg_level, cmsg_type, cmsg_data in ancdata:
                if (cmsg_level == _socket.SOL_SOCKET and cmsg_type == _socket.SCM_RIGHTS):
                    fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
            return msg, list(fds), flags, addr

        socket.recv_fds = recv_fds

    # introduced in 3.9
    if not hasattr(os, 'waitstatus_to_exitcode'):
        def waitstatus_to_exitcode(status: int) -> int:
            if os.WIFEXITED(status):
                return os.WEXITSTATUS(status)
            elif os.WIFSIGNALED(status):
                return -os.WTERMSIG(status)
            else:
                raise ValueError(f'Invalid wait status: {status}')

        os.waitstatus_to_exitcode = waitstatus_to_exitcode

    # introduced in 3.7
    if not hasattr(contextlib, 'AsyncExitStack'):
        class AsyncExitStack:
            async def __aenter__(self):
                self.cms = []
                self.async_cms = []
                return self

            async def enter_async_context(self, cm):
                result = await cm.__aenter__()
                self.async_cms.append(cm)
                return result

            def enter_context(self, cm):
                result = cm.__enter__()
                self.cms.append(cm)
                return result

            async def __aexit__(self, exc_type, exc_value, traceback):
                for cm in self.async_cms:
                    cm.__aexit__(exc_type, exc_value, traceback)
                for cm in self.cms:
                    cm.__exit__(exc_type, exc_value, traceback)

        contextlib.AsyncExitStack = AsyncExitStack
''',
  'cockpit/protocol.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import json
import logging
import traceback

from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_int, get_str, get_str_or_none, typechecked

logger = logging.getLogger(__name__)


class CockpitProblem(Exception):
    """A type of exception that carries a problem code and a message.

    Depending on the scope, this is used to handle shutting down:

      - an individual channel (sends problem code in the close message)
      - peer connections (sends problem code in close message for each open channel)
      - the main stdio interaction with the bridge

    It is usually thrown in response to some violation of expected protocol
    when parsing messages, connecting to a peer, or opening a channel.
    """
    attrs: JsonObject

    def __init__(self, problem: str, msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
        kwargs['problem'] = problem
        self.attrs = create_object(msg, kwargs)
        super().__init__(get_str(self.attrs, 'message', problem))

    def get_attrs(self) -> JsonObject:
        if self.attrs['problem'] == 'internal-error' and self.__cause__ is not None:
            return dict(self.attrs, cause=traceback.format_exception(
                self.__cause__.__class__, self.__cause__, self.__cause__.__traceback__
            ))
        else:
            return self.attrs


class CockpitProtocolError(CockpitProblem):
    def __init__(self, message: str, problem: str = 'protocol-error'):
        super().__init__(problem, message=message)


class CockpitProtocol(asyncio.Protocol):
    """A naive implementation of the Cockpit frame protocol

    We need to use this because Python's SelectorEventLoop doesn't supported
    buffered protocols.
    """
    transport: 'asyncio.Transport | None' = None
    buffer = b''
    _closed: bool = False
    _communication_done: 'asyncio.Future[None] | None' = None

    def do_ready(self) -> None:
        pass

    def do_closed(self, exc: 'Exception | None') -> None:
        pass

    def transport_control_received(self, command: str, message: JsonObject) -> None:
        raise NotImplementedError

    def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
        raise NotImplementedError

    def channel_data_received(self, channel: str, data: bytes) -> None:
        raise NotImplementedError

    def frame_received(self, frame: bytes) -> None:
        header, _, data = frame.partition(b'\n')

        if header != b'':
            channel = header.decode('ascii')
            logger.debug('data received: %d bytes of data for channel %s', len(data), channel)
            self.channel_data_received(channel, data)

        else:
            self.control_received(data)

    def control_received(self, data: bytes) -> None:
        try:
            message = typechecked(json.loads(data), dict)
            command = get_str(message, 'command')
            channel = get_str(message, 'channel', None)

            if channel is not None:
                logger.debug('channel control received %s', message)
                self.channel_control_received(channel, command, message)
            else:
                logger.debug('transport control received %s', message)
                self.transport_control_received(command, message)

        except (json.JSONDecodeError, JsonError) as exc:
            raise CockpitProtocolError(f'control message: {exc!s}') from exc

    def consume_one_frame(self, data: bytes) -> int:
        """Consumes a single frame from view.

        Returns positive if a number of bytes were consumed, or negative if no
        work can be done because of a given number of bytes missing.
        """

        try:
            newline = data.index(b'\n')
        except ValueError as exc:
            if len(data) < 10:
                # Let's try reading more
                return len(data) - 10
            raise CockpitProtocolError("size line is too long") from exc

        try:
            length = int(data[:newline])
        except ValueError as exc:
            raise CockpitProtocolError("frame size is not an integer") from exc

        start = newline + 1
        end = start + length

        if end > len(data):
            # We need to read more
            return len(data) - end

        # We can consume a full frame
        self.frame_received(data[start:end])
        return end

    def connection_made(self, transport: asyncio.BaseTransport) -> None:
        logger.debug('connection_made(%s)', transport)
        assert isinstance(transport, asyncio.Transport)
        self.transport = transport
        self.do_ready()

        if self._closed:
            logger.debug('  but the protocol already was closed, so closing transport')
            transport.close()

    def connection_lost(self, exc: 'Exception | None') -> None:
        logger.debug('connection_lost')
        assert self.transport is not None
        self.transport = None
        self.close(exc)

    def close(self, exc: 'Exception | None' = None) -> None:
        if self._closed:
            return
        self._closed = True

        if self.transport:
            self.transport.close()

        self.do_closed(exc)

    def write_channel_data(self, channel: str, payload: bytes) -> None:
        """Send a given payload (bytes) on channel (string)"""
        # Channel is certainly ascii (as enforced by .encode() below)
        frame_length = len(channel + '\n') + len(payload)
        header = f'{frame_length}\n{channel}\n'.encode('ascii')
        if self.transport is not None:
            logger.debug('writing to transport %s', self.transport)
            self.transport.write(header + payload)
        else:
            logger.debug('cannot write to closed transport')

    def write_control(self, msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
        """Write a control message.  See jsonutil.create_object() for details."""
        logger.debug('sending control message %r %r', msg, kwargs)
        pretty = json.dumps(create_object(msg, kwargs), indent=2) + '\n'
        self.write_channel_data('', pretty.encode())

    def data_received(self, data: bytes) -> None:
        try:
            self.buffer += data
            while self.buffer:
                result = self.consume_one_frame(self.buffer)
                if result <= 0:
                    return
                self.buffer = self.buffer[result:]
        except CockpitProtocolError as exc:
            self.close(exc)

    def eof_received(self) -> bool:
        return False


# Helpful functionality for "server"-side protocol implementations
class CockpitProtocolServer(CockpitProtocol):
    init_host: 'str | None' = None
    authorizations: 'dict[str, asyncio.Future[JsonObject]] | None' = None
    next_auth_id = 0

    def do_send_init(self) -> None:
        raise NotImplementedError

    def do_init(self, message: JsonObject) -> None:
        pass

    def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
        raise NotImplementedError

    def transport_control_received(self, command: str, message: JsonObject) -> None:
        if command == 'init':
            if get_int(message, 'version') != 1:
                raise CockpitProtocolError('incorrect version number')
            self.init_host = get_str(message, 'host')
            self.do_init(message)
        elif command == 'kill':
            self.do_kill(get_str_or_none(message, 'host', None), get_str_or_none(message, 'group', None), message)
        elif command == 'authorize':
            self.do_authorize(message)
        else:
            raise CockpitProtocolError(f'unexpected control message {command} received')

    def do_ready(self) -> None:
        self.do_send_init()

    # authorize request/response API
    async def request_authorization_object(
        self, challenge: str, timeout: 'int | None' = None, **kwargs: JsonValue
    ) -> JsonObject:
        if self.authorizations is None:
            self.authorizations = {}
        cookie = str(self.next_auth_id)
        self.next_auth_id += 1
        future = asyncio.get_running_loop().create_future()
        try:
            self.authorizations[cookie] = future
            self.write_control(None, command='authorize', challenge=challenge, cookie=cookie, **kwargs)
            return await asyncio.wait_for(future, timeout)
        finally:
            self.authorizations.pop(cookie)

    async def request_authorization(
        self, challenge: str, timeout: 'int | None' = None, **kwargs: JsonValue
    ) -> str:
        return get_str(await self.request_authorization_object(challenge, timeout, **kwargs), 'response')

    def do_authorize(self, message: JsonObject) -> None:
        cookie = get_str(message, 'cookie')

        if self.authorizations is None or cookie not in self.authorizations:
            logger.warning('no matching authorize request')
            return

        self.authorizations[cookie].set_result(message)
''',
  'cockpit/remote.py': r'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import getpass
import logging
import re
import socket
from typing import Dict, List, Optional, Tuple

from cockpit._vendor import ferny

from .jsonutil import JsonObject, JsonValue, get_str, get_str_or_none
from .peer import Peer, PeerError
from .router import Router, RoutingRule

logger = logging.getLogger(__name__)


class PasswordResponder(ferny.AskpassHandler):
    PASSPHRASE_RE = re.compile(r"Enter passphrase for key '(.*)': ")

    password: Optional[str]

    hostkeys_seen: List[Tuple[str, str, str, str, str]]
    error_message: Optional[str]
    password_attempts: int

    def __init__(self, password: Optional[str]):
        self.password = password

        self.hostkeys_seen = []
        self.error_message = None
        self.password_attempts = 0

    async def do_hostkey(self, reason: str, host: str, algorithm: str, key: str, fingerprint: str) -> bool:
        self.hostkeys_seen.append((reason, host, algorithm, key, fingerprint))
        return False

    async def do_askpass(self, messages: str, prompt: str, hint: str) -> Optional[str]:
        logger.debug('Got askpass(%s): %s', hint, prompt)

        match = PasswordResponder.PASSPHRASE_RE.fullmatch(prompt)
        if match is not None:
            # We never unlock private keys — we rather need to throw a
            # specially-formatted error message which will cause the frontend
            # to load the named key into the agent for us and try again.
            path = match.group(1)
            logger.debug("This is a passphrase request for %s, but we don't do those.  Abort.", path)
            self.error_message = f'locked identity: {path}'
            return None

        assert self.password is not None
        assert self.password_attempts == 0
        self.password_attempts += 1
        return self.password


class SshPeer(Peer):
    session: Optional[ferny.Session] = None
    host: str
    user: Optional[str]
    password: Optional[str]
    private: bool

    async def do_connect_transport(self) -> None:
        assert self.session is not None
        logger.debug('Starting ssh session user=%s, host=%s, private=%s', self.user, self.host, self.private)

        basename, colon, portstr = self.host.rpartition(':')
        if colon and portstr.isdigit():
            host = basename
            port = int(portstr)
        else:
            host = self.host
            port = None

        responder = PasswordResponder(self.password)
        options = {"StrictHostKeyChecking": 'yes'}

        if self.password is not None:
            options.update(NumberOfPasswordPrompts='1')
        else:
            options.update(PasswordAuthentication="no", KbdInteractiveAuthentication="no")

        try:
            await self.session.connect(host, login_name=self.user, port=port,
                                       handle_host_key=self.private, options=options,
                                       interaction_responder=responder)
        except (OSError, socket.gaierror) as exc:
            logger.debug('connecting to host %s failed: %s', host, exc)
            raise PeerError('no-host', error='no-host', message=str(exc)) from exc

        except ferny.SshHostKeyError as exc:
            if responder.hostkeys_seen:
                # If we saw a hostkey then we can issue a detailed error message
                # containing the key that would need to be accepted.  That will
                # cause the front-end to present a dialog.
                _reason, host, algorithm, key, fingerprint = responder.hostkeys_seen[0]
                error_args = {'host-key': f'{host} {algorithm} {key}', 'host-fingerprint': fingerprint}
            else:
                error_args = {}

            if isinstance(exc, ferny.SshChangedHostKeyError):
                error = 'invalid-hostkey'
            elif self.private:
                error = 'unknown-hostkey'
            else:
                # non-private session case.  throw a generic error.
                error = 'unknown-host'

            logger.debug('SshPeer got a %s %s; private %s, seen hostkeys %r; raising %s with extra args %r',
                         type(exc), exc, self.private, responder.hostkeys_seen, error, error_args)
            raise PeerError(error, error_args, error=error, auth_method_results={}) from exc

        except ferny.SshAuthenticationError as exc:
            logger.debug('authentication to host %s failed: %s', host, exc)

            results = dict.fromkeys(exc.methods, "not-provided")
            if 'password' in results and self.password is not None:
                if responder.password_attempts == 0:
                    results['password'] = 'not-tried'
                else:
                    results['password'] = 'denied'

            raise PeerError('authentication-failed',
                            error=responder.error_message or 'authentication-failed',
                            auth_method_results=results) from exc

        except ferny.SshError as exc:
            logger.debug('unknown failure connecting to host %s: %s', host, exc)
            raise PeerError('internal-error', message=str(exc)) from exc

        args = self.session.wrap_subprocess_args(['cockpit-bridge'])
        await self.spawn(args, [])

    def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
        if host == self.host:
            self.close()
        elif host is None:
            super().do_kill(host, group, message)

    def do_authorize(self, message: JsonObject) -> None:
        if get_str(message, 'challenge').startswith('plain1:'):
            cookie = get_str(message, 'cookie')
            self.write_control(command='authorize', cookie=cookie, response=self.password or '')
            self.password = None  # once is enough...

    def do_superuser_init_done(self) -> None:
        self.password = None

    def __init__(self, router: Router, host: str, user: Optional[str], options: JsonObject, *, private: bool) -> None:
        super().__init__(router)
        self.host = host
        self.user = user
        self.password = get_str(options, 'password', None)
        self.private = private

        self.session = ferny.Session()

        superuser: JsonValue
        init_superuser = get_str_or_none(options, 'init-superuser', None)
        if init_superuser in (None, 'none'):
            superuser = False
        else:
            superuser = {'id': init_superuser}

        self.start_in_background(init_host=host, superuser=superuser)


class HostRoutingRule(RoutingRule):
    remotes: Dict[Tuple[str, Optional[str], Optional[str]], Peer]

    def __init__(self, router):
        super().__init__(router)
        self.remotes = {}

    def apply_rule(self, options: JsonObject) -> Optional[Peer]:
        assert self.router is not None
        assert self.router.init_host is not None

        host = get_str(options, 'host', self.router.init_host)
        if host == self.router.init_host:
            return None

        user = get_str(options, 'user', None)
        # HACK: the front-end relies on this for tracking connections without an explicit user name;
        # the user will then be determined by SSH (`User` in the config or the current user)
        # See cockpit_router_normalize_host_params() in src/bridge/cockpitrouter.c
        if user == getpass.getuser():
            user = None
        if not user:
            user_from_host, _, _ = host.rpartition('@')
            user = user_from_host or None  # avoid ''

        if get_str(options, 'session', None) == 'private':
            nonce = get_str(options, 'channel')
        else:
            nonce = None

        assert isinstance(host, str)
        assert user is None or isinstance(user, str)
        assert nonce is None or isinstance(nonce, str)

        key = host, user, nonce

        logger.debug('Request for channel %s is remote.', options)
        logger.debug('key=%s', key)

        if key not in self.remotes:
            logger.debug('%s is not among the existing remotes %s.  Opening a new connection.', key, self.remotes)
            peer = SshPeer(self.router, host, user, options, private=nonce is not None)
            peer.add_done_callback(lambda: self.remotes.__delitem__(key))
            self.remotes[key] = peer

        return self.remotes[key]

    def shutdown(self) -> None:
        for peer in set(self.remotes.values()):
            peer.close()
'''.encode('utf-8'),
  'cockpit/router.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import asyncio
import collections
import logging
from typing import Dict, List, Optional

from .jsonutil import JsonObject, JsonValue
from .protocol import CockpitProblem, CockpitProtocolError, CockpitProtocolServer

logger = logging.getLogger(__name__)


class ExecutionQueue:
    """Temporarily delay calls to a given set of class methods.

    Functions by replacing the named function at the instance __dict__
    level, effectively providing an override for exactly one instance
    of `method`'s object.
    Queues the invocations.  Run them later with .run(), which also reverses
    the redirection by deleting the named methods from the instance.
    """
    def __init__(self, methods):
        self.queue = collections.deque()
        self.methods = methods

        for method in self.methods:
            self._wrap(method)

    def _wrap(self, method):
        # NB: this function is stored in the instance dict and therefore
        # doesn't function as a descriptor, isn't a method, doesn't get bound,
        # and therefore doesn't receive a self parameter
        setattr(method.__self__, method.__func__.__name__, lambda *args: self.queue.append((method, args)))

    def run(self) -> None:
        logger.debug('ExecutionQueue: Running %d queued method calls', len(self.queue))
        for method, args in self.queue:
            method(*args)

        for method in self.methods:
            delattr(method.__self__, method.__func__.__name__)


class Endpoint:
    router: 'Router'
    __endpoint_frozen_queue: Optional[ExecutionQueue] = None

    def __init__(self, router: 'Router'):
        router.add_endpoint(self)
        self.router = router

    def freeze_endpoint(self) -> None:
        assert self.__endpoint_frozen_queue is None
        logger.debug('Freezing endpoint %s', self)
        self.__endpoint_frozen_queue = ExecutionQueue({self.do_channel_control, self.do_channel_data, self.do_kill})

    def thaw_endpoint(self) -> None:
        assert self.__endpoint_frozen_queue is not None
        logger.debug('Thawing endpoint %s', self)
        self.__endpoint_frozen_queue.run()
        self.__endpoint_frozen_queue = None

    # interface for receiving messages
    def do_close(self) -> None:
        raise NotImplementedError

    def do_channel_control(self, channel: str, command: str, message: JsonObject) -> None:
        raise NotImplementedError

    def do_channel_data(self, channel: str, data: bytes) -> None:
        raise NotImplementedError

    def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
        raise NotImplementedError

    # interface for sending messages
    def send_channel_data(self, channel: str, data: bytes) -> None:
        self.router.write_channel_data(channel, data)

    def send_channel_control(
        self, channel: str, command: str, msg: 'JsonObject | None', **kwargs: JsonValue
    ) -> None:
        self.router.write_control(msg, channel=channel, command=command, **kwargs)
        if command == 'close':
            self.router.endpoints[self].remove(channel)
            self.router.drop_channel(channel)

    def shutdown_endpoint(self, msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
        self.router.shutdown_endpoint(self, msg, **kwargs)


class RoutingError(CockpitProblem):
    pass


class RoutingRule:
    router: 'Router'

    def __init__(self, router: 'Router'):
        self.router = router

    def apply_rule(self, options: JsonObject) -> Optional[Endpoint]:
        """Check if a routing rule applies to a given 'open' message.

        This should inspect the options dictionary and do one of the following three things:

            - return an Endpoint to handle this channel
            - raise a RoutingError to indicate that the open should be rejected
            - return None to let the next rule run
        """
        raise NotImplementedError

    def shutdown(self) -> None:
        raise NotImplementedError


class Router(CockpitProtocolServer):
    routing_rules: List[RoutingRule]
    open_channels: Dict[str, Endpoint]
    endpoints: 'dict[Endpoint, set[str]]'
    no_endpoints: asyncio.Event  # set if endpoints dict is empty
    _eof: bool = False

    def __init__(self, routing_rules: List[RoutingRule]):
        for rule in routing_rules:
            rule.router = self
        self.routing_rules = routing_rules
        self.open_channels = {}
        self.endpoints = {}
        self.no_endpoints = asyncio.Event()
        self.no_endpoints.set()  # at first there are no endpoints

    def info(self) -> JsonObject:
        """Used by the 'info' channel.  Gets overridden in Bridge."""
        return {}

    def check_rules(self, options: JsonObject) -> Endpoint:
        for rule in self.routing_rules:
            logger.debug('  applying rule %s', rule)
            endpoint = rule.apply_rule(options)
            if endpoint is not None:
                logger.debug('    resulting endpoint is %s', endpoint)
                return endpoint
        else:
            logger.debug('  No rules matched')
            raise RoutingError('not-supported')

    def drop_channel(self, channel: str) -> None:
        try:
            self.open_channels.pop(channel)
            logger.debug('router dropped channel %s', channel)
        except KeyError:
            logger.error('trying to drop non-existent channel %s from %s', channel, self.open_channels)

    def add_endpoint(self, endpoint: Endpoint) -> None:
        self.endpoints[endpoint] = set()
        self.no_endpoints.clear()

    def shutdown_endpoint(self, endpoint: Endpoint, msg: 'JsonObject | None' = None, **kwargs: JsonValue) -> None:
        channels = self.endpoints.pop(endpoint)
        logger.debug('shutdown_endpoint(%s, %s) will close %s', endpoint, kwargs, channels)
        for channel in channels:
            self.write_control(msg, command='close', channel=channel, **kwargs)
            self.drop_channel(channel)

        if not self.endpoints:
            self.no_endpoints.set()

        # were we waiting to exit?
        if self._eof:
            logger.debug('  endpoints remaining: %r', self.endpoints)
            if not self.endpoints and self.transport:
                logger.debug('  close transport')
                self.transport.close()

    def do_kill(self, host: 'str | None', group: 'str | None', message: JsonObject) -> None:
        endpoints = set(self.endpoints)
        logger.debug('do_kill(%s, %s).  Considering %d endpoints.', host, group, len(endpoints))
        for endpoint in endpoints:
            endpoint.do_kill(host, group, message)

    def channel_control_received(self, channel: str, command: str, message: JsonObject) -> None:
        if self.init_host is None:
            raise CockpitProtocolError('channel control message received before init')

        # If this is an open message then we need to apply the routing rules to
        # figure out the correct endpoint to connect.  If it's not an open
        # message, then we expect the endpoint to already exist.
        if command == 'open':
            if channel in self.open_channels:
                raise CockpitProtocolError('channel is already open')

            try:
                logger.debug('Trying to find endpoint for new channel %s payload=%s', channel, message.get('payload'))
                endpoint = self.check_rules(message)
            except RoutingError as exc:
                self.write_control(exc.get_attrs(), command='close', channel=channel)
                return

            self.open_channels[channel] = endpoint
            self.endpoints[endpoint].add(channel)
        else:
            try:
                endpoint = self.open_channels[channel]
            except KeyError:
                # sending to a non-existent channel can happen due to races and is not an error
                return

        # At this point, we have the endpoint.  Route the message.
        endpoint.do_channel_control(channel, command, message)

    def channel_data_received(self, channel: str, data: bytes) -> None:
        if self.init_host is None:
            raise CockpitProtocolError('channel data message received before init')

        try:
            endpoint = self.open_channels[channel]
        except KeyError:
            return

        endpoint.do_channel_data(channel, data)

    def eof_received(self) -> bool:
        logger.debug('eof_received(%r)', self)

        endpoints = set(self.endpoints)
        for endpoint in endpoints:
            endpoint.do_close()

        self._eof = True
        logger.debug('  endpoints remaining: %r', self.endpoints)
        return bool(self.endpoints)

    _communication_done: Optional[asyncio.Future] = None

    def do_closed(self, exc: Optional[Exception]) -> None:
        # If we didn't send EOF yet, do it now.
        if not self._eof:
            self.eof_received()

        if self._communication_done is not None:
            if exc is None:
                self._communication_done.set_result(None)
            else:
                self._communication_done.set_exception(exc)

    async def communicate(self) -> None:
        """Wait until communication is complete on the router and all endpoints are done."""
        assert self._communication_done is None
        self._communication_done = asyncio.get_running_loop().create_future()
        try:
            await self._communication_done
        except (BrokenPipeError, ConnectionResetError):
            pass  # these are normal occurrences when closed from the other side
        finally:
            self._communication_done = None

            # In an orderly exit, this is already done, but in case it wasn't
            # orderly, we need to make sure the endpoints shut down anyway...
            await self.no_endpoints.wait()
''',
  'cockpit/samples.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import errno
import logging
import os
import re
from typing import Any, Callable, DefaultDict, Iterable, List, NamedTuple, Optional, Tuple

from cockpit._vendor.systemd_ctypes import Handle

USER_HZ = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
MS_PER_JIFFY = 1000 / (USER_HZ if (USER_HZ > 0) else 100)
HWMON_PATH = '/sys/class/hwmon'

# we would like to do this, but mypy complains; https://github.com/python/mypy/issues/2900
# Samples = collections.defaultdict[str, Union[float, Dict[str, Union[float, None]]]]
Samples = DefaultDict[str, Any]

logger = logging.getLogger(__name__)


def read_int_file(rootfd: int, statfile: str, default: Optional[int] = None, key: bytes = b'') -> Optional[int]:
    # Not every stat is available, such as cpu.weight
    try:
        fd = os.open(statfile, os.O_RDONLY, dir_fd=rootfd)
    except FileNotFoundError:
        return None

    try:
        data = os.read(fd, 1024)
    except OSError as e:
        # cgroups can disappear between the open and read
        if e.errno != errno.ENODEV:
            logger.warning('Failed to read %s: %s', statfile, e)
        return None
    finally:
        os.close(fd)

    if key:
        start = data.index(key) + len(key)
        end = data.index(b'\n', start)
        data = data[start:end]

    try:
        # 0 often means "none", so replace it with default value
        return int(data) or default
    except ValueError:
        # Some samples such as "memory.max" contains "max" when there is a no limit
        return None


class SampleDescription(NamedTuple):
    name: str
    units: str
    semantics: str
    instanced: bool


class Sampler:
    descriptions: List[SampleDescription]

    def sample(self, samples: Samples) -> None:
        raise NotImplementedError


class CPUSampler(Sampler):
    descriptions = [
        SampleDescription('cpu.basic.nice', 'millisec', 'counter', instanced=False),
        SampleDescription('cpu.basic.user', 'millisec', 'counter', instanced=False),
        SampleDescription('cpu.basic.system', 'millisec', 'counter', instanced=False),
        SampleDescription('cpu.basic.iowait', 'millisec', 'counter', instanced=False),

        SampleDescription('cpu.core.nice', 'millisec', 'counter', instanced=True),
        SampleDescription('cpu.core.user', 'millisec', 'counter', instanced=True),
        SampleDescription('cpu.core.system', 'millisec', 'counter', instanced=True),
        SampleDescription('cpu.core.iowait', 'millisec', 'counter', instanced=True),
    ]

    def sample(self, samples: Samples) -> None:
        with open('/proc/stat') as stat:
            for line in stat:
                if not line.startswith('cpu'):
                    continue
                cpu, user, nice, system, _idle, iowait = line.split()[:6]
                core = cpu[3:] or None
                if core:
                    prefix = 'cpu.core'
                    samples[f'{prefix}.nice'][core] = int(nice) * MS_PER_JIFFY
                    samples[f'{prefix}.user'][core] = int(user) * MS_PER_JIFFY
                    samples[f'{prefix}.system'][core] = int(system) * MS_PER_JIFFY
                    samples[f'{prefix}.iowait'][core] = int(iowait) * MS_PER_JIFFY
                else:
                    prefix = 'cpu.basic'
                    samples[f'{prefix}.nice'] = int(nice) * MS_PER_JIFFY
                    samples[f'{prefix}.user'] = int(user) * MS_PER_JIFFY
                    samples[f'{prefix}.system'] = int(system) * MS_PER_JIFFY
                    samples[f'{prefix}.iowait'] = int(iowait) * MS_PER_JIFFY


class MemorySampler(Sampler):
    descriptions = [
        SampleDescription('memory.free', 'bytes', 'instant', instanced=False),
        SampleDescription('memory.used', 'bytes', 'instant', instanced=False),
        SampleDescription('memory.cached', 'bytes', 'instant', instanced=False),
        SampleDescription('memory.swap-used', 'bytes', 'instant', instanced=False),
    ]

    def sample(self, samples: Samples) -> None:
        with open('/proc/meminfo') as meminfo:
            items = {k: int(v.strip(' kB\n')) for line in meminfo for k, v in [line.split(':', 1)]}

        samples['memory.free'] = 1024 * items['MemFree']
        samples['memory.used'] = 1024 * (items['MemTotal'] - items['MemAvailable'])
        samples['memory.cached'] = 1024 * (items['Buffers'] + items['Cached'])
        samples['memory.swap-used'] = 1024 * (items['SwapTotal'] - items['SwapFree'])


class CPUTemperatureSampler(Sampler):
    # Cache found sensors, as they can't be hotplugged.
    sensors: Optional[List[str]] = None

    descriptions = [
        SampleDescription('cpu.temperature', 'celsius', 'instant', instanced=True),
    ]

    @staticmethod
    def detect_cpu_sensors(dir_fd: int) -> Iterable[str]:
        # Read the name file to decide what to do with this directory
        try:
            with Handle.open('name', os.O_RDONLY, dir_fd=dir_fd) as fd:
                name = os.read(fd, 1024).decode().strip()
        except FileNotFoundError:
            return

        if name == 'atk0110':
            # only sample 'CPU Temperature' in atk0110
            predicate: 'Callable[[str], bool] | None' = (lambda label: label == 'CPU Temperature')
        elif name == 'cpu_thermal':
            # labels are not used on ARM
            predicate = None
        elif name == 'coretemp':
            # accept all labels on Intel
            predicate = None
        elif name in ['k8temp', 'k10temp']:
            predicate = None
        else:
            # Not a CPU sensor
            return

        # Now scan the directory for inputs
        for input_filename in os.listdir(dir_fd):
            if not input_filename.endswith('_input'):
                continue

            if predicate:
                # We need to check the label
                try:
                    label_filename = input_filename.replace('_input', '_label')
                    with Handle.open(label_filename, os.O_RDONLY, dir_fd=dir_fd) as fd:
                        label = os.read(fd, 1024).decode().strip()
                except FileNotFoundError:
                    continue

                if not predicate(label):
                    continue

            yield input_filename

    @staticmethod
    def scan_sensors() -> Iterable[str]:
        try:
            top_fd = Handle.open(HWMON_PATH, os.O_RDONLY | os.O_DIRECTORY)
        except FileNotFoundError:
            return

        with top_fd:
            for hwmon_name in os.listdir(top_fd):
                with Handle.open(hwmon_name, os.O_RDONLY | os.O_DIRECTORY, dir_fd=top_fd) as subdir_fd:
                    for sensor in CPUTemperatureSampler.detect_cpu_sensors(subdir_fd):
                        yield f'{HWMON_PATH}/{hwmon_name}/{sensor}'

    def sample(self, samples: Samples) -> None:
        if self.sensors is None:
            self.sensors = list(CPUTemperatureSampler.scan_sensors())

        for sensor_path in self.sensors:
            with open(sensor_path) as sensor:
                temperature = int(sensor.read().strip())
                if temperature == 0:
                    return

            samples['cpu.temperature'][sensor_path] = temperature / 1000


class DiskSampler(Sampler):
    descriptions = [
        SampleDescription('disk.all.read', 'bytes', 'counter', instanced=False),
        SampleDescription('disk.all.written', 'bytes', 'counter', instanced=False),
        SampleDescription('disk.dev.read', 'bytes', 'counter', instanced=True),
        SampleDescription('disk.dev.written', 'bytes', 'counter', instanced=True),
    ]

    def sample(self, samples: Samples) -> None:
        with open('/proc/diskstats') as diskstats:
            all_read_bytes = 0
            all_written_bytes = 0

            for line in diskstats:
                # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
                fields = line.strip().split()
                dev_major = fields[0]
                dev_name = fields[2]
                num_sectors_read = fields[5]
                num_sectors_written = fields[9]

                # ignore mdraid
                if dev_major == '9':
                    continue

                # ignore device-mapper
                if dev_name.startswith('dm-'):
                    continue

                # Skip partitions
                if dev_name[:2] in ['sd', 'hd', 'vd'] and dev_name[-1].isdigit():
                    continue

                # Ignore nvme partitions
                if dev_name.startswith('nvme') and 'p' in dev_name:
                    continue

                read_bytes = int(num_sectors_read) * 512
                written_bytes = int(num_sectors_written) * 512

                all_read_bytes += read_bytes
                all_written_bytes += written_bytes

                samples['disk.dev.read'][dev_name] = read_bytes
                samples['disk.dev.written'][dev_name] = written_bytes

            samples['disk.all.read'] = all_read_bytes
            samples['disk.all.written'] = all_written_bytes


class CGroupSampler(Sampler):
    descriptions = [
        SampleDescription('cgroup.memory.usage', 'bytes', 'instant', instanced=True),
        SampleDescription('cgroup.memory.limit', 'bytes', 'instant', instanced=True),
        SampleDescription('cgroup.memory.sw-usage', 'bytes', 'instant', instanced=True),
        SampleDescription('cgroup.memory.sw-limit', 'bytes', 'instant', instanced=True),
        SampleDescription('cgroup.cpu.usage', 'millisec', 'counter', instanced=True),
        SampleDescription('cgroup.cpu.shares', 'count', 'instant', instanced=True),
    ]

    cgroups_v2: Optional[bool] = None

    def sample(self, samples: Samples) -> None:
        if self.cgroups_v2 is None:
            self.cgroups_v2 = os.path.exists('/sys/fs/cgroup/cgroup.controllers')

        if self.cgroups_v2:
            cgroups_v2_path = '/sys/fs/cgroup/'
            for path, _, _, rootfd in os.fwalk(cgroups_v2_path):
                cgroup = path.replace(cgroups_v2_path, '')

                if not cgroup:
                    continue

                samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.current', 0)
                samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.max')
                samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.swap.current', 0)
                samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.swap.max')
                samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.weight')
                usage_usec = read_int_file(rootfd, 'cpu.stat', 0, key=b'usage_usec')
                if usage_usec:
                    samples['cgroup.cpu.usage'][cgroup] = usage_usec / 1000
        else:
            memory_path = '/sys/fs/cgroup/memory/'
            for path, _, _, rootfd in os.fwalk(memory_path):
                cgroup = path.replace(memory_path, '')

                if not cgroup:
                    continue

                samples['cgroup.memory.usage'][cgroup] = read_int_file(rootfd, 'memory.usage_in_bytes', 0)
                samples['cgroup.memory.limit'][cgroup] = read_int_file(rootfd, 'memory.limit_in_bytes')
                samples['cgroup.memory.sw-usage'][cgroup] = read_int_file(rootfd, 'memory.memsw.usage_in_bytes', 0)
                samples['cgroup.memory.sw-limit'][cgroup] = read_int_file(rootfd, 'memory.memsw.limit_in_bytes')

            cpu_path = '/sys/fs/cgroup/cpu/'
            for path, _, _, rootfd in os.fwalk(cpu_path):
                cgroup = path.replace(cpu_path, '')

                if not cgroup:
                    continue

                samples['cgroup.cpu.shares'][cgroup] = read_int_file(rootfd, 'cpu.shares')
                usage_nsec = read_int_file(rootfd, 'cpuacct.usage')
                if usage_nsec:
                    samples['cgroup.cpu.usage'][cgroup] = usage_nsec / 1000000


class CGroupDiskIO(Sampler):
    IO_RE = re.compile(rb'\bread_bytes: (?P<read>\d+).*\nwrite_bytes: (?P<write>\d+)', flags=re.S)
    descriptions = [
        SampleDescription('disk.cgroup.read', 'bytes', 'counter', instanced=True),
        SampleDescription('disk.cgroup.written', 'bytes', 'counter', instanced=True),
    ]

    @staticmethod
    def get_cgroup_name(fd: int) -> str:
        with Handle.open('cgroup', os.O_RDONLY, dir_fd=fd) as cgroup_fd:
            cgroup_name = os.read(cgroup_fd, 2048).decode().strip()

            # Skip leading ::0/
            return cgroup_name[4:]

    @staticmethod
    def get_proc_io(fd: int) -> Tuple[int, int]:
        with Handle.open('io', os.O_RDONLY, dir_fd=fd) as io_fd:
            data = os.read(io_fd, 4096)

            match = re.search(CGroupDiskIO.IO_RE, data)
            if match:
                proc_read = int(match.group('read'))
                proc_write = int(match.group('write'))

                return proc_read, proc_write

            return 0, 0

    def sample(self, samples: Samples) -> None:
        with Handle.open('/proc', os.O_RDONLY | os.O_DIRECTORY) as proc_fd:
            reads = samples['disk.cgroup.read']
            writes = samples['disk.cgroup.written']

            for path in os.listdir(proc_fd):
                # non-pid entries in proc are guaranteed to start with a character a-z
                if path[0] < '0' or path[0] > '9':
                    continue

                try:
                    with Handle.open(path, os.O_PATH, dir_fd=proc_fd) as pid_fd:
                        cgroup_name = self.get_cgroup_name(pid_fd)
                        proc_read, proc_write = self.get_proc_io(pid_fd)
                except (FileNotFoundError, PermissionError, ProcessLookupError):
                    continue

                reads[cgroup_name] = reads.get(cgroup_name, 0) + proc_read
                writes[cgroup_name] = writes.get(cgroup_name, 0) + proc_write


class NetworkSampler(Sampler):
    descriptions = [
        SampleDescription('network.interface.tx', 'bytes', 'counter', instanced=True),
        SampleDescription('network.interface.rx', 'bytes', 'counter', instanced=True),
    ]

    def sample(self, samples: Samples) -> None:
        with open("/proc/net/dev") as network_samples:
            for line in network_samples:
                fields = line.split()

                # Skip header line
                if fields[0][-1] != ':':
                    continue

                iface = fields[0][:-1]
                samples['network.interface.rx'][iface] = int(fields[1])
                samples['network.interface.tx'][iface] = int(fields[9])


class MountSampler(Sampler):
    descriptions = [
        SampleDescription('mount.total', 'bytes', 'instant', instanced=True),
        SampleDescription('mount.used', 'bytes', 'instant', instanced=True),
    ]

    def sample(self, samples: Samples) -> None:
        with open('/proc/mounts') as mounts:
            for line in mounts:
                # Only look at real devices
                if line[0] != '/':
                    continue

                fs_spec, fs_file, _fs_vfstype, fs_mntopts, *_rest = line.split()

                # Skip read-only loop mounts
                if '/loop' in fs_spec and 'ro' in fs_mntopts.split(','):
                    continue
                # Hide flatpaks
                if 'revokefs-fuse' in fs_spec and 'flatpak' in fs_file:
                    continue

                try:
                    res = os.statvfs(fs_file)
                except OSError:
                    continue
                frsize = res.f_frsize
                total = frsize * res.f_blocks
                samples['mount.total'][fs_file] = total
                samples['mount.used'][fs_file] = total - frsize * res.f_bfree


class BlockSampler(Sampler):
    descriptions = [
        SampleDescription('block.device.read', 'bytes', 'counter', instanced=True),
        SampleDescription('block.device.written', 'bytes', 'counter', instanced=True),
    ]

    def sample(self, samples: Samples) -> None:
        with open('/proc/diskstats') as diskstats:
            for line in diskstats:
                # https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats
                [_, _, dev_name, _, _, sectors_read, _, _, _, sectors_written, *_] = line.strip().split()

                samples['block.device.read'][dev_name] = int(sectors_read) * 512
                samples['block.device.written'][dev_name] = int(sectors_written) * 512


SAMPLERS = [
    BlockSampler,
    CGroupSampler,
    CGroupDiskIO,
    CPUSampler,
    CPUTemperatureSampler,
    DiskSampler,
    MemorySampler,
    MountSampler,
    NetworkSampler,
]
''',
  'cockpit/superuser.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


import array
import asyncio
import contextlib
import getpass
import logging
import os
import shutil
import socket
import subprocess
from os.path import basename
from tempfile import TemporaryDirectory
from typing import Sequence

from cockpit._vendor import ferny
from cockpit._vendor.bei.bootloader import make_bootloader
from cockpit._vendor.systemd_ctypes import Bus, Variant, bus

from .beipack import BridgeBeibootHelper
from .jsonutil import JsonObject, get_str
from .packages import BridgeConfig
from .peer import ConfiguredPeer, Peer, PeerError
from .polkit import PolkitAgent
from .router import Router, RoutingError, RoutingRule

logger = logging.getLogger(__name__)


def sudo_supports_askpass(sudo_path: str) -> bool:
    try:
        # Returns 0 if -A is supported, non-zero if it's not
        subprocess.run(
            [sudo_path, '-A', '--help'],
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            check=True
        )
        return True
    except (subprocess.CalledProcessError, OSError):
        return False


def is_valid_superuser_config(config: BridgeConfig) -> bool:
    if not config.privileged:
        return False
    command = shutil.which(config.spawn[0])
    if command is None:
        return False
    if basename(command) == 'sudo' and not sudo_supports_askpass(command):
        return False
    return True


class SuperuserPeer(ConfiguredPeer):
    responder: ferny.AskpassHandler

    def __init__(self, router: Router, config: BridgeConfig, responder: ferny.AskpassHandler):
        super().__init__(router, config)
        self.responder = responder

    async def start_transient_unit(self, args: 'Sequence[str]', stderr: object) -> asyncio.Transport:
        ours, theirs = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
        loop = asyncio.get_running_loop()

        unit_name = f"cockpit-superuser-{os.getpid()}.service"

        system = Bus.default_system()
        msg = system.message_new_method_call(
            "org.freedesktop.systemd1",
            "/org/freedesktop/systemd1",
            "org.freedesktop.systemd1.Manager",
            "StartTransientUnit",
            "ssa(sv)a(sa(sv))",
            unit_name,
            "fail",
            [
                ("Description", {"t": "s", "v": "Cockpit privileged bridge"}),
                ("Type", {"t": "s", "v": "exec"}),
                ("User", {"t": "s", "v": "root"}),
                ("StandardInputFileDescriptor", {"t": "h", "v": theirs}),
                ("StandardOutputFileDescriptor", {"t": "h", "v": theirs}),
                ("StandardErrorFileDescriptor", {"t": "h", "v": stderr}),
                ("ExecStart", {"t": "a(sasb)", "v": [(shutil.which(args[0]), args, False)]}),
            ],
            [],
        )
        msg.set_allow_interactive_authorization(True)

        # We fire and forget.  If we catch an authentication error, that'll be
        # raised as a BusError which will propagate upwards to our caller (ie:
        # the Start method) and get displayed as an error dialog.  If we get
        # other errors (like failure to spawn the named executable for some
        # reason or unusual exit codes) then we will see the stderr output but
        # otherwise won't get any notification about it.  The amount of work
        # required to do this "properly" is quite high and it's not super
        # useful.
        await system.call_async(msg)

        transport, protocol = await loop.create_connection(lambda: self, sock=ours)
        assert protocol is self
        return transport

    async def do_connect_transport(self) -> None:
        async with contextlib.AsyncExitStack() as context:
            if self.config.polkit:
                logger.debug('connecting polkit superuser peer transport %r', self.args)
                await context.enter_async_context(PolkitAgent(self.responder))
            else:
                logger.debug('connecting non-polkit superuser peer transport %r', self.args)

            responders: 'list[ferny.InteractionHandler]' = [self.responder]

            if '# cockpit-bridge' in self.args:
                logger.debug('going to beiboot superuser bridge %r', self.args)
                helper = BridgeBeibootHelper(self, ['--privileged'])
                responders.append(helper)
                stage1 = make_bootloader(helper.steps, gadgets=ferny.BEIBOOT_GADGETS).encode()
            else:
                stage1 = None

            agent = ferny.InteractionAgent(responders)

            if 'SUDO_ASKPASS=ferny-askpass' in self.env:
                tmpdir = context.enter_context(TemporaryDirectory())
                ferny_askpass = ferny.write_askpass_to_tmpdir(tmpdir)
                env: Sequence[str] = [f'SUDO_ASKPASS={ferny_askpass}']
            else:
                env = self.env

            if self.config.method == 'StartTransientUnit':
                transport = await self.start_transient_unit(self.args, stderr=agent)
            else:
                transport = await self.spawn(self.args, env, stderr=agent, start_new_session=True)

            if stage1 is not None:
                transport.write(stage1)

            try:
                await agent.communicate()
            except ferny.InteractionError as exc:
                raise PeerError('authentication-failed', message=str(exc)) from exc


class CockpitResponder(ferny.AskpassHandler):
    commands = ('ferny.askpass', 'cockpit.send-stderr')

    async def do_custom_command(
        self, command: str, args: 'tuple[object, ...]', fds: 'list[int]', stderr: str
    ) -> None:
        if command == 'cockpit.send-stderr':
            with socket.socket(fileno=fds[0]) as sock:
                fds.pop(0)
                # socket.send_fds(sock, [b'\0'], [2])  # New in Python 3.9
                sock.sendmsg([b'\0'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array("i", [2]))])


class AuthorizeResponder(CockpitResponder):
    def __init__(self, router: Router):
        self.router = router
        self.authorize_attempted = False

    async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None':
        if self.authorize_attempted:
            logger.info("noninteractive authorize during init already attempted, rejecting")
            return None
        self.authorize_attempted = True

        hexuser = ''.join(f'{c:02x}' for c in getpass.getuser().encode('ascii'))
        password = await self.router.request_authorization(f'plain1:{hexuser}')
        # translate "no password" from authorize protocol (empty string) to ferny protocol (None)
        return None if password == '' else password


class SuperuserRoutingRule(RoutingRule, CockpitResponder, bus.Object, interface='cockpit.Superuser'):
    superuser_configs: Sequence[BridgeConfig] = ()
    pending_prompt: 'asyncio.Future[str] | None'
    peer: 'SuperuserPeer | None'

    # D-Bus signals
    prompt = bus.Interface.Signal('s', 's', 's', 'b', 's')  # message, prompt, default, echo, error

    # D-Bus properties
    bridges = bus.Interface.Property('as', value=[])
    current = bus.Interface.Property('s', value='none')
    methods = bus.Interface.Property('a{sv}', value={})

    # RoutingRule
    def apply_rule(self, options: JsonObject) -> 'Peer | None':
        superuser = options.get('superuser')

        if not superuser or self.current == 'root':
            # superuser not requested, or already superuser?  Next rule.
            return None
        elif self.peer or superuser == 'try':
            # superuser requested and active?  Return it.
            # 'try' requested?  Either return the peer, or None.
            return self.peer
        else:
            # superuser requested, but not active?  That's an error.
            raise RoutingError('access-denied')

    # ferny.AskpassHandler
    async def do_askpass(self, messages: str, prompt: str, hint: str) -> 'str | None':
        assert self.pending_prompt is None
        echo = hint == "confirm"
        self.pending_prompt = asyncio.get_running_loop().create_future()
        try:
            logger.debug('prompting for %s', prompt)
            # with sudo, all stderr messages are treated as warning/errors by the UI
            # (such as the lecture or "wrong password"), so pass them in the "error" field
            self.prompt('', prompt, '', echo, messages)
            return await self.pending_prompt
        finally:
            self.pending_prompt = None

    def __init__(self, router: Router, *, privileged: bool = False):
        super().__init__(router)

        self.pending_prompt = None
        self.peer = None
        self.startup = None

        if privileged or os.getuid() == 0:
            self.current = 'root'

    def peer_done(self) -> None:
        self.current = 'none'
        self.peer = None

    async def go(self, name: str, responder: ferny.AskpassHandler) -> None:
        if self.current != 'none':
            raise bus.BusError('cockpit.Superuser.Error', 'Superuser bridge already running')

        assert self.peer is None
        assert self.startup is None

        for config in self.superuser_configs:
            if name in (config.name, 'any'):
                break
        else:
            raise bus.BusError('cockpit.Superuser.Error', f'Unknown superuser bridge type "{name}"')

        self.current = 'init'
        self.peer = SuperuserPeer(self.router, config, responder)
        self.peer.add_done_callback(self.peer_done)

        try:
            await self.peer.start(init_host=self.router.init_host)
        except asyncio.CancelledError:
            raise bus.BusError('cockpit.Superuser.Error.Cancelled', 'Operation aborted') from None
        except (OSError, PeerError) as exc:
            raise bus.BusError('cockpit.Superuser.Error', str(exc)) from exc

        self.current = self.peer.config.name

    def set_configs(self, configs: Sequence[BridgeConfig]) -> None:
        logger.debug("set_configs() with %d items", len(configs))
        configs = [config for config in configs if is_valid_superuser_config(config)]
        self.superuser_configs = tuple(configs)
        self.bridges = [config.name for config in self.superuser_configs]
        self.methods = {c.label: Variant({'label': Variant(c.label)}, 'a{sv}') for c in configs if c.label}

        logger.debug("  bridges are now %s", self.bridges)

        # If the currently active bridge config is not in the new set of configs, stop it
        if self.peer is not None:
            if self.peer.config not in self.superuser_configs:
                logger.debug("  stopping superuser bridge '%s': it disappeared from configs", self.peer.config.name)
                self.shutdown()

    def cancel_prompt(self) -> None:
        if self.pending_prompt is not None:
            self.pending_prompt.cancel()
            self.pending_prompt = None

    def shutdown(self) -> None:
        self.cancel_prompt()

        if self.peer is not None:
            self.peer.close()

        # close() should have disconnected the peer immediately
        assert self.peer is None

    # Connect-on-startup functionality
    def init(self, params: JsonObject) -> None:
        name = get_str(params, 'id', 'any')
        responder = AuthorizeResponder(self.router)
        self._init_task = asyncio.create_task(self.go(name, responder))
        self._init_task.add_done_callback(self._init_done)

    def _init_done(self, task: 'asyncio.Task[None]') -> None:
        logger.debug('superuser init done! %s', task.exception())
        self.router.write_control(command='superuser-init-done')
        del self._init_task

    # D-Bus methods
    @bus.Interface.Method(in_types=['s'])  # type: ignore[misc]
    async def start(self, name: str) -> None:
        await self.go(name, self)

    @bus.Interface.Method()  # type: ignore[misc]
    def stop(self) -> None:
        self.shutdown()

    @bus.Interface.Method(in_types=['s'])  # type: ignore[misc]
    def answer(self, reply: str) -> None:
        if self.pending_prompt is not None:
            logger.debug('responding to pending prompt')
            self.pending_prompt.set_result(reply)
        else:
            logger.debug('got Answer, but no prompt pending')
''',
  'cockpit/transports.py': br'''#
# Copyright (C) 2022 Red Hat, Inc.
# SPDX-License-Identifier: GPL-3.0-or-later


"""Bi-directional asyncio.Transport implementations based on file descriptors."""

import asyncio
import collections
import ctypes
import errno
import fcntl
import logging
import os
import select
import signal
import struct
import subprocess
import termios
from threading import Thread
from typing import Any, ClassVar, Sequence

from .jsonutil import JsonObject, get_int

sys_prctl = ctypes.CDLL(None).prctl


def prctl(*args: int) -> None:
    if sys_prctl(*args) != 0:
        raise OSError('prctl() failed')


SET_PDEATHSIG = 1


logger = logging.getLogger(__name__)


IOV_MAX = 1024  # man 2 writev


class _Transport(asyncio.Transport):
    BLOCK_SIZE: ClassVar[int] = 1024 * 1024

    # A transport always has a loop and a protocol
    _loop: asyncio.AbstractEventLoop
    _protocol: asyncio.Protocol

    _queue: 'collections.deque[bytes] | None'
    _in_fd: int
    _out_fd: int
    _closing: bool
    _is_reading: bool
    _eof: bool
    _eio_is_eof: bool = False

    def __init__(self,
                 loop: asyncio.AbstractEventLoop,
                 protocol: asyncio.Protocol,
                 in_fd: int = -1, out_fd: int = -1,
                 extra: 'dict[str, object] | None' = None):
        super().__init__(extra)

        self._loop = loop
        self._protocol = protocol

        logger.debug('Created transport %s for protocol %s, fds %d %d', self, protocol, in_fd, out_fd)

        self._queue = None
        self._is_reading = False
        self._eof = False
        self._closing = False

        self._in_fd = in_fd
        self._out_fd = out_fd

        os.set_blocking(in_fd, False)
        if out_fd != in_fd:
            os.set_blocking(out_fd, False)

        self._protocol.connection_made(self)
        self.resume_reading()

    def _read_ready(self) -> None:
        logger.debug('Read ready on %s %s %d', self, self._protocol, self._in_fd)
        try:
            data = os.read(self._in_fd, _Transport.BLOCK_SIZE)
        except BlockingIOError:  # pragma: no cover
            return
        except OSError as exc:
            if self._eio_is_eof and exc.errno == errno.EIO:
                # PTY devices return EIO to mean "EOF"
                data = b''
            else:
                # Other errors: terminate the connection
                self.abort(exc)
                return

        if data != b'':
            logger.debug('  read %d bytes', len(data))
            self._protocol.data_received(data)
        else:
            logger.debug('  got EOF')
            self._close_reader()
            keep_open = self._protocol.eof_received()
            if not keep_open:
                self.close()

    def is_reading(self) -> bool:
        return self._is_reading

    def _close_reader(self) -> None:
        self.pause_reading()
        self._in_fd = -1

    def pause_reading(self) -> None:
        if self._is_reading:
            self._loop.remove_reader(self._in_fd)
            self._is_reading = False

    def resume_reading(self) -> None:
        # It's possible that the Protocol could decide to attempt to unpause
        # reading after _close_reader() got called.  Check that the fd is != -1
        # before actually resuming.
        if not self._is_reading and self._in_fd != -1:
            self._loop.add_reader(self._in_fd, self._read_ready)
            self._is_reading = True

    def _close(self) -> None:
        pass

    def abort(self, exc: 'Exception | None' = None) -> None:
        self._closing = True
        self._close_reader()
        self._remove_write_queue()
        self._loop.call_soon(self._protocol.connection_lost, exc)
        self._close()

    def can_write_eof(self) -> bool:
        raise NotImplementedError

    def write_eof(self) -> None:
        assert not self._eof
        self._eof = True
        if self._queue is None:
            logger.debug('%s got EOF.  closing backend.', self)
            self._write_eof_now()
        else:
            logger.debug('%s got EOF.  bytes in queue, deferring close', self)

    def get_write_buffer_size(self) -> int:
        if self._queue is None:
            return 0
        return sum(len(block) for block in self._queue)

    def get_write_buffer_limits(self) -> 'tuple[int, int]':
        return (0, 0)

    def set_write_buffer_limits(self, high: 'int | None' = None, low: 'int | None' = None) -> None:
        assert high is None or high == 0
        assert low is None or low == 0

    def _write_eof_now(self) -> None:
        raise NotImplementedError

    def _write_ready(self) -> None:
        logger.debug('%s _write_ready', self)
        assert self._queue is not None

        try:
            n_bytes = os.writev(self._out_fd, self._queue)
        except BlockingIOError:  # pragma: no cover
            n_bytes = 0
        except OSError as exc:
            self.abort(exc)
            return

        logger.debug('  successfully wrote %d bytes from the queue', n_bytes)

        while n_bytes:
            block = self._queue.popleft()
            if len(block) > n_bytes:
                # This block wasn't completely written.
                logger.debug('  incomplete block.  Stop.')
                self._queue.appendleft(block[n_bytes:])
                break
            n_bytes -= len(block)
            logger.debug('  removed complete block.  %d remains.', n_bytes)

        if not self._queue:
            logger.debug('%s queue drained.')
            self._remove_write_queue()
            if self._eof:
                logger.debug('%s queue drained.  closing backend now.')
                self._write_eof_now()
            if self._closing:
                self.abort()

    def _remove_write_queue(self) -> None:
        if self._queue is not None:
            self._protocol.resume_writing()
            self._loop.remove_writer(self._out_fd)
            self._queue = None

    def _create_write_queue(self, data: bytes) -> None:
        logger.debug('%s creating write queue for fd %s', self, self._out_fd)
        assert self._queue is None
        self._loop.add_writer(self._out_fd, self._write_ready)
        self._queue = collections.deque((data,))
        self._protocol.pause_writing()

    def write(self, data: bytes) -> None:
        # this is a race condition with subprocesses: if we get and process the the "exited"
        # event before seeing BrokenPipeError, we'll try to write to a closed pipe.
        # Do what the standard library does and ignore, instead of assert
        if self._closing:
            logger.debug('ignoring write() to closing transport fd %i', self._out_fd)
            return

        assert not self._eof

        if self._queue is not None:
            self._queue.append(data)

            # writev() will complain if the queue is too long.  Consolidate it.
            if len(self._queue) > IOV_MAX:
                all_data = b''.join(self._queue)
                self._queue.clear()
                self._queue.append(all_data)

            return

        try:
            n_bytes = os.write(self._out_fd, data)
        except BlockingIOError:
            n_bytes = 0
        except OSError as exc:
            self.abort(exc)
            return

        if n_bytes != len(data):
            self._create_write_queue(data[n_bytes:])

    def close(self) -> None:
        if self._closing:
            return

        self._closing = True
        self._close_reader()

        if self._queue is not None:
            # abort() will be called from _write_ready() when it's done
            return

        self.abort()

    def get_protocol(self) -> asyncio.BaseProtocol:
        return self._protocol

    def is_closing(self) -> bool:
        return self._closing

    def set_protocol(self, protocol: asyncio.BaseProtocol) -> None:
        raise NotImplementedError

    def __del__(self) -> None:
        self._close()


class SubprocessProtocol(asyncio.Protocol):
    """An extension to asyncio.Protocol for use with SubprocessTransport."""
    def process_exited(self) -> None:
        """Called when subprocess has exited."""
        raise NotImplementedError


class WindowSize:
    def __init__(self, value: JsonObject):
        self.rows = get_int(value, 'rows')
        self.cols = get_int(value, 'cols')


class SubprocessTransport(_Transport, asyncio.SubprocessTransport):
    """A bi-directional transport speaking with stdin/out of a subprocess.

    Note: this is not really a normal SubprocessTransport.  Although it
    implements the entire API of asyncio.SubprocessTransport, it is not
    designed to be used with asyncio.SubprocessProtocol objects.  Instead, it
    pair with normal Protocol objects which also implement the
    SubprocessProtocol defined in this module (which only has a
    process_exited() method).  Whatever the protocol writes is sent to stdin,
    and whatever comes from stdout is given to the Protocol via the
    .data_received() function.

    If stderr is configured as a pipe, the transport will separately collect
    data from it, making it available via the .get_stderr() method.
    """

    _pty_fd: 'int | None' = None
    _process: 'subprocess.Popen[bytes] | None' = None
    _returncode: 'int | None' = None
    _stderr: 'Spooler | None'

    def get_stderr(self, *, reset: bool = False) -> str:
        if self._stderr is not None:
            return self._stderr.get(reset=reset).decode(errors='replace')
        else:
            return ''

    def watch_exit(self, process: 'subprocess.Popen[bytes]') -> None:
        def child_exited(pid: int, status: int) -> None:
            assert pid == process.pid
            assert isinstance(self._protocol, SubprocessProtocol)

            try:
                self._returncode = os.waitstatus_to_exitcode(status)
            except ValueError:
                self._returncode = status

            logger.debug('Process exited with status %d', self._returncode)
            if not self._closing:
                self._protocol.process_exited()

        def pidfd_ready() -> None:
            pid, status = os.waitpid(process.pid, 0)  # should never block
            self._loop.remove_reader(pidfd)
            os.close(pidfd)
            child_exited(pid, status)

        def waitpid_thread() -> None:
            pid, status = os.waitpid(process.pid, 0)  # will block
            self._loop.call_soon_threadsafe(child_exited, pid, status)

        # We first try to create a pidfd to track the process.  If that doesn't
        # work, we spawn a thread to do a blocking waitpid().
        try:
            pidfd = os.pidfd_open(process.pid)
            self._loop.add_reader(pidfd, pidfd_ready)
        except (AttributeError, OSError):
            Thread(name=f'cockpit-waitpid-{process.pid}', target=waitpid_thread, daemon=True).start()

    def __init__(self,
                 loop: asyncio.AbstractEventLoop,
                 protocol: SubprocessProtocol,
                 args: Sequence[str],
                 *,
                 pty: bool = False,
                 window: 'WindowSize | None' = None,
                 **kwargs: Any):

        # go down as a team -- we don't want any leaked processes when the bridge terminates
        def preexec_fn() -> None:
            prctl(SET_PDEATHSIG, signal.SIGTERM)
            if pty:
                fcntl.ioctl(0, termios.TIOCSCTTY, 0)

        if pty:
            self._pty_fd, session_fd = os.openpty()

            if window is not None:
                self.set_window_size(window)

            kwargs['stderr'] = session_fd
            self._process = subprocess.Popen(args,
                                             stdin=session_fd, stdout=session_fd,
                                             preexec_fn=preexec_fn, start_new_session=True, **kwargs)
            os.close(session_fd)

            in_fd, out_fd = self._pty_fd, self._pty_fd
            self._eio_is_eof = True

        else:
            self._process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                                             preexec_fn=preexec_fn, **kwargs)
            assert self._process.stdin
            assert self._process.stdout
            in_fd = self._process.stdout.fileno()
            out_fd = self._process.stdin.fileno()

        if self._process.stderr is not None:
            self._stderr = Spooler(loop, self._process.stderr.fileno())
        else:
            self._stderr = None

        super().__init__(loop, protocol, in_fd, out_fd)
        self.watch_exit(self._process)

    def set_window_size(self, size: WindowSize) -> None:
        assert self._pty_fd is not None
        rows = max(0, min(size.rows, 2**16 - 1))
        cols = max(0, min(size.cols, 2**16 - 1))
        fcntl.ioctl(self._pty_fd, termios.TIOCSWINSZ, struct.pack('2H4x', rows, cols))

    def can_write_eof(self) -> bool:
        assert self._process is not None
        return self._process.stdin is not None

    def _write_eof_now(self) -> None:
        assert self._process is not None
        assert self._process.stdin is not None
        self._process.stdin.close()
        self._out_fd = -1

    def get_pid(self) -> int:
        assert self._process is not None
        return self._process.pid

    def get_returncode(self) -> 'int | None':
        return self._returncode

    def get_pipe_transport(self, fd: int) -> asyncio.Transport:
        raise NotImplementedError

    def send_signal(self, sig: signal.Signals) -> None:  # type: ignore[override] # mypy/issues/13885
        assert self._process is not None
        # We try to avoid using subprocess.send_signal().  It contains a call
        # to waitpid() internally to avoid signalling the wrong process (if a
        # PID gets reused), but:
        #
        #  - we already detect the process exiting via our pidfd
        #
        #  - the check is actually harmful since collecting the process via
        #    waitpid() prevents our pidfd-based watcher from doing the same,
        #    resulting in an error.
        #
        # It's on us now to check it, but that's easy:
        if self._returncode is not None:
            logger.debug("won't attempt %s to process %i.  It exited already.", sig, self._process.pid)
            return

        try:
            os.kill(self._process.pid, sig)
            logger.debug('sent %s to process %i', sig, self._process.pid)
        except ProcessLookupError:
            # already gone? fine
            logger.debug("can't send %s to process %i.  It's exited just now.", sig, self._process.pid)

    def terminate(self) -> None:
        self.send_signal(signal.SIGTERM)

    def kill(self) -> None:
        self.send_signal(signal.SIGKILL)

    def _close(self) -> None:
        if self._pty_fd is not None:
            os.close(self._pty_fd)
            self._pty_fd = None

        if self._process is not None:
            if self._process.stdin is not None:
                self._process.stdin.close()
                self._process.stdin = None
            try:
                self.terminate()  # best effort...
            except PermissionError:
                logger.debug("can't kill %i due to EPERM", self._process.pid)


class StdioTransport(_Transport):
    """A bi-directional transport that corresponds to stdin/out.

    Can talk to just about anything:
        - files
        - pipes
        - character devices (including terminals)
        - sockets
    """

    def __init__(self, loop: asyncio.AbstractEventLoop, protocol: asyncio.Protocol, stdin: int = 0, stdout: int = 1):
        super().__init__(loop, protocol, stdin, stdout)

    def can_write_eof(self) -> bool:
        return False

    def _write_eof_now(self) -> None:
        raise RuntimeError("Can't write EOF to stdout")


class Spooler:
    """Consumes data from an fd, storing it in a buffer.

    This makes a copy of the fd, so you don't have to worry about holding it
    open.
    """

    _loop: asyncio.AbstractEventLoop
    _fd: int
    _contents: 'list[bytes]'

    def __init__(self, loop: asyncio.AbstractEventLoop, fd: int):
        self._loop = loop
        self._fd = -1  # in case dup() raises an exception
        self._contents = []

        self._fd = os.dup(fd)

        os.set_blocking(self._fd, False)
        loop.add_reader(self._fd, self._read_ready)

    def _read_ready(self) -> None:
        try:
            data = os.read(self._fd, 8192)
        except BlockingIOError:  # pragma: no cover
            return
        except OSError:
            # all other errors -> EOF
            data = b''

        if data != b'':
            self._contents.append(data)
        else:
            self.close()

    def _is_ready(self) -> bool:
        if self._fd == -1:
            return False
        return select.select([self._fd], [], [], 0) != ([], [], [])

    def get(self, *, reset: bool = False) -> bytes:
        while self._is_ready():
            self._read_ready()

        result = b''.join(self._contents)
        if reset:
            self._contents = []
        return result

    def close(self) -> None:
        if self._fd != -1:
            self._loop.remove_reader(self._fd)
            os.close(self._fd)
            self._fd = -1

    def __del__(self) -> None:
        self.close()
''',
}))
from cockpit.bridge import main as main
main(beipack=True)
