#
# Copyright (C) 2017 Codethink Limited
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see .
#
# Authors:
# Tristan Maat
# Tristan Van Berkom
import os
import sys
import stat
import signal
import subprocess
from contextlib import contextmanager, ExitStack
import psutil
from .._exceptions import SandboxError, PlatformError
from .. import utils
from .. import _signals
from ._mounter import Mounter
from ._mount import MountMap
from . import Sandbox, SandboxFlags, SandboxCommandError
class SandboxChroot(Sandbox):
_FUSE_MOUNT_OPTIONS = {"dev": True}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
uid = self._get_config().build_uid
gid = self._get_config().build_gid
if uid != 0 or gid != 0:
raise SandboxError(
"Chroot sandboxes cannot specify a non-root uid/gid "
"({},{} were supplied via config)".format(uid, gid)
)
self.mount_map = None
@classmethod
def check_available(cls):
cls._uid = os.getuid()
cls._gid = os.getgid()
available = cls._uid == 0
if not available:
cls._dummy_reasons += ["uid is not 0"]
raise SandboxError("can not run chroot if uid is not 0")
@classmethod
def check_sandbox_config(cls, local_platform, config):
# With the chroot sandbox, the UID/GID in the sandbox
# will match the host UID/GID (typically 0/0).
if config.build_uid != cls._uid or config.build_gid != cls._gid:
return False
host_os = local_platform.get_host_os()
host_arch = local_platform.get_host_arch()
# Check host os and architecture match
if config.build_os != host_os:
raise PlatformError("Configured and host OS don't match.")
if config.build_arch != host_arch:
raise PlatformError("Configured and host architecture don't match.")
return True
def _run(self, command, flags, *, cwd, env):
if not self._has_command(command[0], env):
raise SandboxCommandError(
"Staged artifacts do not provide command " "'{}'".format(command[0]), reason="missing-command"
)
stdout, stderr = self._get_output()
# Create the mount map, this will tell us where
# each mount point needs to be mounted from and to
self.mount_map = MountMap(self, flags & SandboxFlags.ROOT_READ_ONLY, self._FUSE_MOUNT_OPTIONS)
# Create a sysroot and run the command inside it
with ExitStack() as stack:
os.makedirs("/var/run/buildstream", exist_ok=True)
# FIXME: While we do not currently do anything to prevent
# network access, we also don't copy /etc/resolv.conf to
# the new rootfs.
#
# This effectively disables network access, since DNs will
# never resolve, so anything a normal process wants to do
# will fail. Malicious processes could gain rights to
# anything anyway.
#
# Nonetheless a better solution could perhaps be found.
rootfs = stack.enter_context(utils._tempdir(dir="/var/run/buildstream"))
stack.enter_context(self.create_devices(self._root, flags))
stack.enter_context(self.mount_dirs(rootfs, flags, stdout, stderr))
if flags & SandboxFlags.INTERACTIVE:
stdin = sys.stdin
else:
stdin = stack.enter_context(open(os.devnull, "r"))
# Ensure the cwd exists
if cwd is not None:
workdir = os.path.join(rootfs, cwd.lstrip(os.sep))
os.makedirs(workdir, exist_ok=True)
status = self.chroot(rootfs, command, stdin, stdout, stderr, cwd, env, flags)
self._vdir._mark_changed()
return status
# chroot()
#
# A helper function to chroot into the rootfs.
#
# Args:
# rootfs (str): The path of the sysroot to chroot into
# command (list): The command to execute in the chroot env
# stdin (file): The stdin
# stdout (file): The stdout
# stderr (file): The stderr
# cwd (str): The current working directory
# env (dict): The environment variables to use while executing the command
# flags (:class:`SandboxFlags`): The flags to enable on the sandbox
#
# Returns:
# (int): The exit code of the executed command
#
def chroot(self, rootfs, command, stdin, stdout, stderr, cwd, env, flags):
def kill_proc():
if process:
# First attempt to gracefully terminate
proc = psutil.Process(process.pid)
proc.terminate()
try:
proc.wait(20)
except psutil.TimeoutExpired:
utils._kill_process_tree(process.pid)
def suspend_proc():
group_id = os.getpgid(process.pid)
os.killpg(group_id, signal.SIGSTOP)
def resume_proc():
group_id = os.getpgid(process.pid)
os.killpg(group_id, signal.SIGCONT)
try:
with _signals.suspendable(suspend_proc, resume_proc), _signals.terminator(kill_proc):
process = subprocess.Popen( # pylint: disable=subprocess-popen-preexec-fn
command,
close_fds=True,
cwd=os.path.join(rootfs, cwd.lstrip(os.sep)),
env=env,
stdin=stdin,
stdout=stdout,
stderr=stderr,
# If you try to put gtk dialogs here Tristan (either)
# will personally scald you
preexec_fn=lambda: (os.chroot(rootfs), os.chdir(cwd)),
start_new_session=flags & SandboxFlags.INTERACTIVE,
)
# Wait for the child process to finish, ensuring that
# a SIGINT has exactly the effect the user probably
# expects (i.e. let the child process handle it).
try:
while True:
try:
_, status = os.waitpid(process.pid, 0)
# If the process exits due to a signal, we
# brutally murder it to avoid zombies
if not os.WIFEXITED(status):
utils._kill_process_tree(process.pid)
# Unlike in the bwrap case, here only the main
# process seems to receive the SIGINT. We pass
# on the signal to the child and then continue
# to wait.
except KeyboardInterrupt:
process.send_signal(signal.SIGINT)
continue
break
# If we can't find the process, it has already died of
# its own accord, and therefore we don't need to check
# or kill anything.
except psutil.NoSuchProcess:
pass
# Return the exit code - see the documentation for
# os.WEXITSTATUS to see why this is required.
if os.WIFEXITED(status):
code = os.WEXITSTATUS(status)
else:
code = -1
except subprocess.SubprocessError as e:
# Exceptions in preexec_fn are simply reported as
# 'Exception occurred in preexec_fn', turn these into
# a more readable message.
if str(e) == "Exception occurred in preexec_fn.":
raise SandboxError(
"Could not chroot into {} or chdir into {}. "
"Ensure you are root and that the relevant directory exists.".format(rootfs, cwd)
) from e
# Otherwise, raise a more general error
raise SandboxError("Could not run command {}: {}".format(command, e)) from e
return code
# create_devices()
#
# Create the nodes in /dev/ usually required for builds (null,
# none, etc.)
#
# Args:
# rootfs (str): The path of the sysroot to prepare
# flags (:class:`.SandboxFlags`): The sandbox flags
#
@contextmanager
def create_devices(self, rootfs, flags):
devices = []
# When we are interactive, we'd rather mount /dev due to the
# sheer number of devices
if not flags & SandboxFlags.INTERACTIVE:
for device in Sandbox.DEVICES:
location = os.path.join(rootfs, device.lstrip(os.sep))
os.makedirs(os.path.dirname(location), exist_ok=True)
try:
if os.path.exists(location):
os.remove(location)
devices.append(self.mknod(device, location))
except OSError as err:
if err.errno == 1:
raise SandboxError(
"Permission denied while creating device node: {}.".format(err)
+ "BuildStream reqiures root permissions for these setttings."
)
raise
yield
for device in devices:
os.remove(device)
# mount_dirs()
#
# Mount paths required for the command.
#
# Args:
# rootfs (str): The path of the sysroot to prepare
# flags (:class:`.SandboxFlags`): The sandbox flags
# stdout (file): The stdout
# stderr (file): The stderr
#
@contextmanager
def mount_dirs(self, rootfs, flags, stdout, stderr):
# FIXME: This should probably keep track of potentially
# already existing files a la _sandboxwrap.py:239
@contextmanager
def mount_point(point, **kwargs):
mount_source_overrides = self._get_mount_sources()
if point in mount_source_overrides: # pylint: disable=consider-using-get
mount_source = mount_source_overrides[point]
else:
mount_source = self.mount_map.get_mount_source(point)
mount_point = os.path.join(rootfs, point.lstrip(os.sep))
with Mounter.bind_mount(mount_point, src=mount_source, stdout=stdout, stderr=stderr, **kwargs):
yield
@contextmanager
def mount_src(src, **kwargs):
mount_point = os.path.join(rootfs, src.lstrip(os.sep))
os.makedirs(mount_point, exist_ok=True)
with Mounter.bind_mount(mount_point, src=src, stdout=stdout, stderr=stderr, **kwargs):
yield
with ExitStack() as stack:
stack.enter_context(self.mount_map.mounted(self))
stack.enter_context(mount_point("/"))
if flags & SandboxFlags.INTERACTIVE:
stack.enter_context(mount_src("/dev"))
stack.enter_context(mount_src("/tmp"))
stack.enter_context(mount_src("/proc"))
for mark in self._get_marked_directories():
stack.enter_context(mount_point(mark["directory"]))
# Remount root RO if necessary
if flags & flags & SandboxFlags.ROOT_READ_ONLY:
root_mount = Mounter.mount(rootfs, stdout=stdout, stderr=stderr, remount=True, ro=True, bind=True)
# Since the exit stack has already registered a mount
# for this path, we do not need to register another
# umount call.
root_mount.__enter__()
yield
# mknod()
#
# Create a device node equivalent to the given source node
#
# Args:
# source (str): Path of the device to mimic (e.g. '/dev/null')
# target (str): Location to create the new device in
#
# Returns:
# target (str): The location of the created node
#
def mknod(self, source, target):
try:
dev = os.stat(source)
major = os.major(dev.st_rdev)
minor = os.minor(dev.st_rdev)
target_dev = os.makedev(major, minor)
os.mknod(target, mode=stat.S_IFCHR | dev.st_mode, device=target_dev)
except PermissionError as e:
raise SandboxError("Could not create device {}, ensure that you have root permissions: {}")
except OSError as e:
raise SandboxError("Could not create device {}: {}".format(target, e)) from e
return target