#!/usr/bin/env python3
# Copyright 2021 Simon McVittie
# SPDX-License-Identifier: LGPL-2.1-or-later

import errno
import logging
import os
import subprocess
import sys
import tempfile
import termios
import unittest

try:
    import seccomp
except ImportError:
    print('1..0 # SKIP cannot import seccomp Python module')
    sys.exit(0)


# This is the @default set from systemd as of 2021-10-11
DEFAULT_SET = set('''
brk
cacheflush
clock_getres
clock_getres_time64
clock_gettime
clock_gettime64
clock_nanosleep
clock_nanosleep_time64
execve
exit
exit_group
futex
futex_time64
get_robust_list
get_thread_area
getegid
getegid32
geteuid
geteuid32
getgid
getgid32
getgroups
getgroups32
getpgid
getpgrp
getpid
getppid
getrandom
getresgid
getresgid32
getresuid
getresuid32
getrlimit
getsid
gettid
gettimeofday
getuid
getuid32
membarrier
mmap
mmap2
munmap
nanosleep
pause
prlimit64
restart_syscall
rseq
rt_sigreturn
sched_getaffinity
sched_yield
set_robust_list
set_thread_area
set_tid_address
set_tls
sigreturn
time
ugetrlimit
'''.split())

# This is the @basic-io set from systemd
BASIC_IO_SET = set('''
_llseek
close
close_range
dup
dup2
dup3
lseek
pread64
preadv
preadv2
pwrite64
pwritev
pwritev2
read
readv
write
writev
'''.split())

# This is the @filesystem-io set from systemd
FILESYSTEM_SET = set('''
access
chdir
chmod
close
creat
faccessat
faccessat2
fallocate
fchdir
fchmod
fchmodat
fcntl
fcntl64
fgetxattr
flistxattr
fremovexattr
fsetxattr
fstat
fstat64
fstatat64
fstatfs
fstatfs64
ftruncate
ftruncate64
futimesat
getcwd
getdents
getdents64
getxattr
inotify_add_watch
inotify_init
inotify_init1
inotify_rm_watch
lgetxattr
link
linkat
listxattr
llistxattr
lremovexattr
lsetxattr
lstat
lstat64
mkdir
mkdirat
mknod
mknodat
newfstatat
oldfstat
oldlstat
oldstat
open
openat
openat2
readlink
readlinkat
removexattr
rename
renameat
renameat2
rmdir
setxattr
stat
stat64
statfs
statfs64
statx
symlink
symlinkat
truncate
truncate64
unlink
unlinkat
utime
utimensat
utimensat_time64
utimes
'''.split())

# Miscellaneous syscalls used during process startup, at least on x86_64
ALLOWED = DEFAULT_SET | BASIC_IO_SET | FILESYSTEM_SET | set('''
arch_prctl
ioctl
madvise
mprotect
mremap
prctl
readdir
umask
'''.split())

# Syscalls we will try to use, expecting them to be either allowed or
# blocked by our allow and/or deny lists
TRY_SYSCALLS = [
    'chmod',
    'chroot',
    'clone3',
    'ioctl TIOCNOTTY',
    'ioctl TIOCSTI CVE-2019-10063',
    'ioctl TIOCSTI',
    'listen',
    'prctl',
]


class Test(unittest.TestCase):
    def setUp(self) -> None:
        here = os.path.dirname(os.path.abspath(__file__))

        if 'G_TEST_SRCDIR' in os.environ:
            self.test_srcdir = os.getenv('G_TEST_SRCDIR') + '/tests'
        else:
            self.test_srcdir = here

        if 'G_TEST_BUILDDIR' in os.environ:
            self.test_builddir = os.getenv('G_TEST_BUILDDIR') + '/tests'
        else:
            self.test_builddir = here

        self.bwrap = os.getenv('BWRAP', 'bwrap')
        self.try_syscall = os.path.join(self.test_builddir, 'try-syscall')

        completed = subprocess.run(
            [
                self.bwrap,
                '--ro-bind', '/', '/',
                'true',
            ],
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=2,
        )

        if completed.returncode != 0:
            raise unittest.SkipTest(
                'cannot run bwrap (does it need to be setuid?)'
            )

    def tearDown(self) -> None:
        pass

    def test_no_seccomp(self) -> None:
        for syscall in TRY_SYSCALLS:
            print('# {} without seccomp'.format(syscall))
            completed = subprocess.run(
                [
                    self.bwrap,
                    '--ro-bind', '/', '/',
                    self.try_syscall, syscall,
                ],
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=2,
            )

            if (
                syscall == 'ioctl TIOCSTI CVE-2019-10063'
                and completed.returncode == errno.ENOENT
            ):
                print('# Cannot test 64-bit syscall parameter on 32-bit')
                continue

            if syscall == 'clone3':
                # If the kernel supports it, we didn't block it so
                # it fails with EFAULT. If the kernel doesn't support it,
                # it'll fail with ENOSYS instead.
                self.assertIn(
                    completed.returncode,
                    (errno.ENOSYS, errno.EFAULT),
                )
            elif syscall.startswith('ioctl') or syscall == 'listen':
                self.assertEqual(completed.returncode, errno.EBADF)
            else:
                self.assertEqual(completed.returncode, errno.EFAULT)

    def test_seccomp_allowlist(self) -> None:
        with tempfile.TemporaryFile() as allowlist_temp:
            allowlist = seccomp.SyscallFilter(seccomp.ERRNO(errno.ENOSYS))

            if os.uname().machine == 'x86_64':
                # Allow Python and try-syscall to be different word sizes
                allowlist.add_arch(seccomp.Arch.X86)

            for syscall in ALLOWED:
                try:
                    allowlist.add_rule(seccomp.ALLOW, syscall)
                except Exception as e:
                    print('# Cannot add {} to allowlist: {!r}'.format(syscall, e))

            allowlist.export_bpf(allowlist_temp)

            for syscall in TRY_SYSCALLS:
                print('# allowlist vs. {}'.format(syscall))
                allowlist_temp.seek(0, os.SEEK_SET)

                completed = subprocess.run(
                    [
                        self.bwrap,
                        '--ro-bind', '/', '/',
                        '--seccomp', str(allowlist_temp.fileno()),
                        self.try_syscall, syscall,
                    ],
                    pass_fds=(allowlist_temp.fileno(),),
                    stdin=subprocess.DEVNULL,
                    stdout=subprocess.DEVNULL,
                    stderr=2,
                )

                if (
                    syscall == 'ioctl TIOCSTI CVE-2019-10063'
                    and completed.returncode == errno.ENOENT
                ):
                    print('# Cannot test 64-bit syscall parameter on 32-bit')
                    continue

                if syscall.startswith('ioctl'):
                    # We allow this, so it is executed (and in this simple
                    # example, immediately fails)
                    self.assertEqual(completed.returncode, errno.EBADF)
                elif syscall in ('chroot', 'listen', 'clone3'):
                    # We don't allow these, so they fail with ENOSYS.
                    # clone3 might also be failing with ENOSYS because
                    # the kernel genuinely doesn't support it.
                    self.assertEqual(completed.returncode, errno.ENOSYS)
                else:
                    # We allow this, so it is executed (and in this simple
                    # example, immediately fails)
                    self.assertEqual(completed.returncode, errno.EFAULT)

    def test_seccomp_denylist(self) -> None:
        with tempfile.TemporaryFile() as denylist_temp:
            denylist = seccomp.SyscallFilter(seccomp.ALLOW)

            if os.uname().machine == 'x86_64':
                # Allow Python and try-syscall to be different word sizes
                denylist.add_arch(seccomp.Arch.X86)

            # Using ECONNREFUSED here because it's unlikely that any of
            # these syscalls will legitimately fail with that code, so
            # if they fail like this, it will be as a result of seccomp.
            denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chmod')
            denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chroot')
            denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'prctl')
            denylist.add_rule(
                seccomp.ERRNO(errno.ECONNREFUSED), 'ioctl',
                seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI),
            )

            denylist.export_bpf(denylist_temp)

            for syscall in TRY_SYSCALLS:
                print('# denylist vs. {}'.format(syscall))
                denylist_temp.seek(0, os.SEEK_SET)

                completed = subprocess.run(
                    [
                        self.bwrap,
                        '--ro-bind', '/', '/',
                        '--seccomp', str(denylist_temp.fileno()),
                        self.try_syscall, syscall,
                    ],
                    pass_fds=(denylist_temp.fileno(),),
                    stdin=subprocess.DEVNULL,
                    stdout=subprocess.DEVNULL,
                    stderr=2,
                )

                if (
                    syscall == 'ioctl TIOCSTI CVE-2019-10063'
                    and completed.returncode == errno.ENOENT
                ):
                    print('# Cannot test 64-bit syscall parameter on 32-bit')
                    continue

                if syscall == 'clone3':
                    # If the kernel supports it, we didn't block it so
                    # it fails with EFAULT. If the kernel doesn't support it,
                    # it'll fail with ENOSYS instead.
                    self.assertIn(
                        completed.returncode,
                        (errno.ENOSYS, errno.EFAULT),
                    )
                elif syscall in ('ioctl TIOCNOTTY', 'listen'):
                    # Not on the denylist
                    self.assertEqual(completed.returncode, errno.EBADF)
                else:
                    # We blocked all of these
                    self.assertEqual(completed.returncode, errno.ECONNREFUSED)

    def test_seccomp_stacked(self, allowlist_first=False) -> None:
        with tempfile.TemporaryFile(
        ) as allowlist_temp, tempfile.TemporaryFile(
        ) as denylist_temp:
            # This filter is a simplified version of what Flatpak wants

            allowlist = seccomp.SyscallFilter(seccomp.ERRNO(errno.ENOSYS))
            denylist = seccomp.SyscallFilter(seccomp.ALLOW)

            if os.uname().machine == 'x86_64':
                # Allow Python and try-syscall to be different word sizes
                allowlist.add_arch(seccomp.Arch.X86)
                denylist.add_arch(seccomp.Arch.X86)

            for syscall in ALLOWED:
                try:
                    allowlist.add_rule(seccomp.ALLOW, syscall)
                except Exception as e:
                    print('# Cannot add {} to allowlist: {!r}'.format(syscall, e))

            denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chmod')
            denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chroot')
            denylist.add_rule(
                seccomp.ERRNO(errno.ECONNREFUSED), 'ioctl',
                seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI),
            )

            # All seccomp programs except the last must allow prctl(),
            # because otherwise we wouldn't be able to add the remaining
            # seccomp programs. We document that the last program can
            # block prctl, so test that.
            if allowlist_first:
                denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'prctl')

            allowlist.export_bpf(allowlist_temp)
            denylist.export_bpf(denylist_temp)

            for syscall in TRY_SYSCALLS:
                print('# stacked vs. {}'.format(syscall))
                allowlist_temp.seek(0, os.SEEK_SET)
                denylist_temp.seek(0, os.SEEK_SET)

                if allowlist_first:
                    fds = [allowlist_temp.fileno(), denylist_temp.fileno()]
                else:
                    fds = [denylist_temp.fileno(), allowlist_temp.fileno()]

                completed = subprocess.run(
                    [
                        self.bwrap,
                        '--ro-bind', '/', '/',
                        '--add-seccomp-fd', str(fds[0]),
                        '--add-seccomp-fd', str(fds[1]),
                        self.try_syscall, syscall,
                    ],
                    pass_fds=fds,
                    stdin=subprocess.DEVNULL,
                    stdout=subprocess.DEVNULL,
                    stderr=2,
                )

                if (
                    syscall == 'ioctl TIOCSTI CVE-2019-10063'
                    and completed.returncode == errno.ENOENT
                ):
                    print('# Cannot test 64-bit syscall parameter on 32-bit')
                    continue

                if syscall == 'ioctl TIOCNOTTY':
                    # Not denied by the denylist, and allowed by the allowlist
                    self.assertEqual(completed.returncode, errno.EBADF)
                elif syscall in ('clone3', 'listen'):
                    # We didn't deny these, so the denylist has no effect
                    # and we fall back to the allowlist, which doesn't
                    # include them either.
                    # clone3 might also be failing with ENOSYS because
                    # the kernel genuinely doesn't support it.
                    self.assertEqual(completed.returncode, errno.ENOSYS)
                elif syscall == 'chroot':
                    # This is denied by the denylist *and* not allowed by
                    # the allowlist. The result depends which one we added
                    # first: the most-recently-added filter "wins".
                    if allowlist_first:
                        self.assertEqual(
                            completed.returncode,
                            errno.ECONNREFUSED,
                        )
                    else:
                        self.assertEqual(completed.returncode, errno.ENOSYS)
                elif syscall == 'prctl':
                    # We can only put this on the denylist if the denylist
                    # is the last to be added.
                    if allowlist_first:
                        self.assertEqual(
                            completed.returncode,
                            errno.ECONNREFUSED,
                        )
                    else:
                        self.assertEqual(completed.returncode, errno.EFAULT)
                else:
                    # chmod is allowed by the allowlist but blocked by the
                    # denylist. Denying takes precedence over allowing,
                    # regardless of order.
                    self.assertEqual(completed.returncode, errno.ECONNREFUSED)

    def test_seccomp_stacked_allowlist_first(self) -> None:
        self.test_seccomp_stacked(allowlist_first=True)

    def test_seccomp_invalid(self) -> None:
        with tempfile.TemporaryFile(
        ) as allowlist_temp, tempfile.TemporaryFile(
        ) as denylist_temp:
            completed = subprocess.run(
                [
                    self.bwrap,
                    '--ro-bind', '/', '/',
                    '--add-seccomp-fd', '-1',
                    'true',
                ],
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
            )
            self.assertIn(b'bwrap: Invalid fd: -1\n', completed.stderr)
            self.assertEqual(completed.returncode, 1)

            completed = subprocess.run(
                [
                    self.bwrap,
                    '--ro-bind', '/', '/',
                    '--seccomp', '0a',
                    'true',
                ],
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
            )
            self.assertIn(b'bwrap: Invalid fd: 0a\n', completed.stderr)
            self.assertEqual(completed.returncode, 1)

            completed = subprocess.run(
                [
                    self.bwrap,
                    '--ro-bind', '/', '/',
                    '--add-seccomp-fd', str(denylist_temp.fileno()),
                    '--seccomp', str(allowlist_temp.fileno()),
                    'true',
                ],
                pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()),
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
            )
            self.assertIn(
                b'bwrap: --seccomp cannot be combined with --add-seccomp-fd\n',
                completed.stderr,
            )
            self.assertEqual(completed.returncode, 1)

            completed = subprocess.run(
                [
                    self.bwrap,
                    '--ro-bind', '/', '/',
                    '--seccomp', str(allowlist_temp.fileno()),
                    '--add-seccomp-fd', str(denylist_temp.fileno()),
                    'true',
                ],
                pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()),
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
            )
            self.assertIn(
                b'--add-seccomp-fd cannot be combined with --seccomp',
                completed.stderr,
            )
            self.assertEqual(completed.returncode, 1)

            completed = subprocess.run(
                [
                    self.bwrap,
                    '--ro-bind', '/', '/',
                    '--add-seccomp-fd', str(allowlist_temp.fileno()),
                    '--add-seccomp-fd', str(allowlist_temp.fileno()),
                    'true',
                ],
                pass_fds=(allowlist_temp.fileno(), allowlist_temp.fileno()),
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
            )
            self.assertIn(
                b"bwrap: Can't read seccomp data: ",
                completed.stderr,
            )
            self.assertEqual(completed.returncode, 1)

            allowlist_temp.write(b'\x01')
            allowlist_temp.seek(0, os.SEEK_SET)
            completed = subprocess.run(
                [
                    self.bwrap,
                    '--ro-bind', '/', '/',
                    '--add-seccomp-fd', str(denylist_temp.fileno()),
                    '--add-seccomp-fd', str(allowlist_temp.fileno()),
                    'true',
                ],
                pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()),
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
            )
            self.assertIn(
                b'bwrap: Invalid seccomp data, must be multiple of 8\n',
                completed.stderr,
            )
            self.assertEqual(completed.returncode, 1)


def main():
    logging.basicConfig(level=logging.DEBUG)

    try:
        from tap.runner import TAPTestRunner
    except ImportError:
        TAPTestRunner = None    # type: ignore

    if TAPTestRunner is not None:
        runner = TAPTestRunner()
        runner.set_stream(True)
        unittest.main(testRunner=runner)
    else:
        print('# tap.runner not available, using simple TAP output')
        print('1..1')
        program = unittest.main(exit=False)
        if program.result.wasSuccessful():
            print('ok 1 - %r' % program.result)
        else:
            print('not ok 1 - %r' % program.result)
            sys.exit(1)

if __name__ == '__main__':
    main()
