| #!/usr/bin/python3 |
| # |
| # Copyright (C) 2022 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| import json |
| import os |
| import textwrap |
| |
| |
| def _bool(value): |
| """Convert string to one of None, True, False.""" |
| if isinstance(value, bool): |
| return value |
| if value is None: |
| return None |
| if value.to_lower() in ('', 'none', 'null'): |
| return None |
| if value in ('False', 'false'): |
| return False |
| return bool(value) |
| |
| |
| class Envar(): |
| """Environment variables.""" |
| |
| def __init__(self, *args, name=None, value=None): |
| assert not args, "Envar only accepts kwargs" |
| self.name = name |
| self.value = value |
| |
| def __str__(self): |
| if self.value is None: |
| # Use the current value of the variable. |
| return f'envar: "{self.name}"\n' |
| return f'envar: "{self.name}={self.value}"\n' |
| |
| |
| class MountPt(object): |
| def __init__(self, |
| _kw_only=(), |
| src="", |
| prefix_src_env="", |
| src_content="", |
| dst="", |
| prefix_dst_env="", |
| fstype="", |
| options="", |
| is_bind=None, |
| rw=None, |
| is_dir=None, |
| mandatory=None, |
| is_symlink=None, |
| nosuid=None, |
| nodev=None, |
| noexec=None): |
| assert _kw_only == (), "MountPt only accepts kwargs" |
| # These asserts may need to be revisited if we ever use prefix_src_env |
| # or prefix_dst_env. |
| assert not src or os.path.abspath(src) == src, "Paths must be absolute" |
| assert not dst or os.path.abspath(dst) == dst, "Paths must be absolute" |
| self.src = src |
| self.prefix_src_env = prefix_src_env |
| self.src_content = src_content |
| self.dst = dst |
| self.prefix_dst_env = prefix_dst_env |
| self.fstype = fstype |
| self.options = options |
| self.is_bind = _bool(is_bind) |
| self.rw = _bool(rw) |
| self.is_dir = _bool(is_dir) |
| self.mandatory = _bool(mandatory) |
| self.is_symlink = _bool(is_symlink) |
| self.nosuid = _bool(nosuid) |
| self.nodev = _bool(nodev) |
| self.noexec = _bool(noexec) |
| |
| def __str__(self): |
| ret = "mount {\n" |
| if self.src: |
| ret += f" src: {json.dumps(self.src)}\n" |
| if self.prefix_src_env: |
| ret += f" prefix_src_env: {json.dumps(self.prefix_src_env)}\n" |
| if self.src_content: |
| ret += f" src_content: {json.dumps(self.src_content)}\n" |
| if self.dst: |
| ret += f" dst: {json.dumps(self.dst)}\n" |
| if self.prefix_dst_env: |
| ret += f" prefix_dst_env: {json.dumps(self.prefix_dst_env)}\n" |
| if self.fstype: |
| ret += f" fstype: {json.dumps(self.fstype)}\n" |
| if self.options: |
| ret += f" options: {json.dumps(self.options)}\n" |
| if self.is_bind is not None: |
| ret += f" is_bind: {json.dumps(self.is_bind)}\n" |
| if self.rw is not None: |
| ret += f" rw: {json.dumps(self.rw)}\n" |
| if self.is_dir is not None: |
| ret += f" is_dir: {json.dumps(self.is_dir)}\n" |
| if self.mandatory is not None: |
| ret += f" mandatory: {json.dumps(self.mandatory)}\n" |
| if self.is_symlink is not None: |
| ret += f" is_symlink: {json.dumps(self.is_symlink)}\n" |
| if self.nosuid is not None: |
| ret += f" nosuid: {json.dumps(self.nosuid)}\n" |
| if self.nodev is not None: |
| ret += f" nodev: {json.dumps(self.nodev)}\n" |
| if self.noexec is not None: |
| ret += f" noexec: {json.dumps(self.noexec)}\n" |
| ret += "}\n\n" |
| return ret |
| |
| def __eq__(self, other): |
| return (isinstance(other, MountPt) and self.src == other.src |
| and self.prefix_src_env == other.prefix_src_env |
| and self.src_content == other.src_content |
| and self.dst == other.dst |
| and self.prefix_dst_env == other.prefix_dst_env |
| and self.fstype == other.fstype |
| and self.options == other.options |
| and self.is_bind == other.is_bind and self.rw == other.rw |
| and self.is_dir == other.is_dir |
| and self.mandatory == other.mandatory |
| and self.is_symlink == other.is_symlink |
| and self.nosuid == other.nosuid and self.nodev == other.nodev |
| and self.noexec == other.noexec) |
| |
| def copy(self): |
| return MountPt(src=self.src, |
| prefix_src_env=self.prefix_src_env, |
| src_content=self.src_content, |
| dst=self.dst, |
| prefix_dst_env=self.prefix_dst_env, |
| fstype=self.fstype, |
| options=self.options, |
| is_bind=self.is_bind, |
| rw=self.rw, |
| is_dir=self.is_dir, |
| mandatory=self.mandatory, |
| is_symlink=self.is_symlink, |
| nosuid=self.nosuid, |
| nodev=self.nodev, |
| noexec=self.noexec) |
| |
| |
| class NsjailConfigOption(object): |
| """Options for nsjail configuration.""" |
| |
| def __init__(self, name, value, comment=""): |
| self.name = name |
| self.value = value |
| self.comment = comment |
| |
| def __str__(self): |
| return f"{self.comment}\n{self.name}: {self.value}" |
| |
| |
| class Nsjail(object): |
| def __init__(self, cwd, verbose=False): |
| self.cwd = cwd |
| self.verbose = verbose |
| # Add the mount points that we always need. |
| self.mounts = [ |
| # Mount proc so that the PID namespace can be shared between the |
| # parent and child process. |
| MountPt(src="/proc", |
| dst="/proc", |
| is_bind=True, |
| rw=True, |
| mandatory=True), |
| # Some commands may need /etc/alternatives to reach the correct |
| # binary. |
| MountPt(src="/etc/alternatives", |
| dst="/etc/alternatives", |
| is_bind=True, |
| rw=False, |
| mandatory=False), |
| |
| # TODO: we may need to use something other than tmpfs for this, |
| # because of some tests, etc. |
| MountPt(dst="/tmp", |
| fstype="tmpfs", |
| rw=True, |
| is_bind=False, |
| noexec=False, |
| nodev=True, |
| nosuid=True), |
| |
| # Some tools need /dev/shm to created a named semaphore. Use a new |
| # tmpfs to limit access to the external environment. |
| MountPt(dst="/dev/shm", fstype="tmpfs", rw=True, is_bind=False), |
| |
| # Add the expected tty devices. |
| MountPt(src="/dev/tty", dst="/dev/tty", rw=True, is_bind=True), |
| # These are symlinks to /proc/self/fd/{0,1,2}. |
| MountPt(src="/proc/self/fd/0", dst="/dev/stdin", is_symlink=True), |
| MountPt(src="/proc/self/fd/1", dst="/dev/stdout", is_symlink=True), |
| MountPt(src="/proc/self/fd/2", dst="/dev/stderr", is_symlink=True), |
| |
| # Map the working User ID to a username |
| # Some tools like Java need a valid username |
| # Inner trees building with Soong also expect the nobody UID to be |
| # available to setup its own nsjail. |
| MountPt( |
| src_content="user:x:999999:65533:user:/tmp:/bin/bash\n" |
| "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n", |
| dst="/etc/passwd", |
| mandatory=False), |
| |
| # Define default group |
| MountPt(src_content="group::65533:user\n" |
| "nogroup::65534:nobody\n", |
| dst="/etc/group", |
| mandatory=False), |
| |
| # Empty mtab file needed for some build scripts that check for |
| # images being mounted |
| MountPt(src_content="\n", dst="/etc/mtab", mandatory=False), |
| |
| # Explicitly mount required device file nodes |
| # |
| # This will enable a chroot based NsJail sandbox. A chroot does not |
| # provide device file nodes. So just mount the required device file |
| # nodes directly from the host. |
| # |
| # Note that this has no effect in a docker container, since in that |
| # case NsJail will just mount the container device nodes. When we |
| # use NsJail in a docker container we mount the full file system |
| # root. So the container device nodes were already mounted in the |
| # NsJail. |
| |
| # Some tools (like llvm-link) look for file descriptors in /dev/fd |
| MountPt(src="/proc/self/fd", |
| dst="/dev/fd", |
| is_symlink=True, |
| mandatory=False), |
| |
| # /dev/null is a very commonly used for silencing output |
| MountPt(src="/dev/null", dst="/dev/null", rw=True, is_bind=True), |
| |
| # /dev/urandom used during the creation of system.img |
| MountPt(src="/dev/urandom", |
| dst="/dev/urandom", |
| rw=False, |
| is_bind=True), |
| |
| # /dev/random used by test scripts |
| MountPt(src="/dev/random", |
| dst="/dev/random", |
| rw=False, |
| is_bind=True), |
| |
| # /dev/zero is required to make vendor-qemu.img |
| MountPt(src="/dev/zero", dst="/dev/zero", is_bind=True), |
| MountPt(src="/lib", dst="/lib", is_bind=True, rw=False), |
| MountPt(src="/bin", dst="/bin", is_bind=True, rw=False), |
| MountPt(src="/sbin", dst="/sbin", is_bind=True, rw=False), |
| MountPt(src="/usr", dst="/usr", is_bind=True, rw=False), |
| MountPt(src="/lib64", |
| dst="/lib64", |
| is_bind=True, |
| rw=False, |
| mandatory=False), |
| MountPt(src="/lib32", |
| dst="/lib32", |
| is_bind=True, |
| rw=False, |
| mandatory=False), |
| ] |
| |
| self.envars = [ |
| # Some tools in the build toolchain expect a $HOME to be set Point |
| # $HOME to /tmp in case the toolchain needs to write something out |
| # there |
| Envar(name="HOME", value="/tmp"), |
| # By default nsjail does not propagate the environment into the |
| # jail. We need the path to be set up. There are a few ways to solve |
| # this problem, but to avoid an undocumented dependency we are |
| # explicit about the path we inject. |
| Envar(name="PATH", value="/usr/bin:/usr/sbin:/bin:/sbin"), |
| ] |
| self.options = [] |
| |
| def make_cwd_writable(self): |
| """Mark things under cwd writable.""" |
| for mount in self.mounts: |
| if mount.dst.startswith(self.cwd) and not mount.rw: |
| if self.verbose: |
| print(f"Marking {mount.dst} r/w") |
| mount.rw = True |
| |
| def add_mountpt(self, **kwargs): |
| """Add a mountpoint to the config.""" |
| self.mounts.append(MountPt(**kwargs)) |
| |
| def add_envar(self, **kwargs): |
| """Add an envar to the config.""" |
| self.envars.append(Envar(**kwargs)) |
| |
| def add_option(self, **kwargs): |
| """Add an option to the njsail config.""" |
| self.options.append(NsjailConfigOption(**kwargs)) |
| |
| def copy(self): |
| """Return a copy of ourselves.""" |
| ret = Nsjail(self.cwd, verbose=self.verbose) |
| ret.mounts = [x.copy() for x in self.mounts()] |
| return ret |
| |
| @property |
| def mount_points(self): |
| """Return the list of mount points. |
| |
| Returns a list of mount points (destinations) for the nsjail. |
| """ |
| return (x.dst for x in self.mounts) |
| |
| def add_nsjail(self, other): |
| """Add another Nsjail object to this one.""" |
| # WARNING: clone_newnet option of inner tree should not be merged into |
| # the combined nsjsail.cfg. |
| assert other.cwd.startswith(self.cwd), "Must be a subdir" |
| our_mounts = {x.dst: x for x in self.mounts} |
| for mount in other.mounts: |
| if mount.dst not in our_mounts: |
| self.mounts.append(mount) |
| else: |
| assert mount == our_mounts[mount.dst] |
| |
| def generate_config(self, fn): |
| """Generate the nsjail config file. |
| |
| Args: |
| fn: (str) The name of the file to write, or None to not create. |
| |
| Returns: |
| (str) The configuration written. |
| """ |
| data = textwrap.dedent(f"""\ |
| name: "android-build-sandbox" |
| description: "Sandboxed Android Platform Build." |
| description: "No network access and a limited access to local host resources." |
| |
| log_level: {"INFO" if self.verbose else "WARNING"} |
| # All configuration options are described in |
| # https://github.com/google/nsjail/blob/master/config.proto |
| |
| # Run once then exit |
| mode: ONCE |
| |
| # No time limit |
| time_limit: 0 |
| |
| # Limits memory usage |
| rlimit_as_type: SOFT |
| # Maximum size of core dump files |
| rlimit_core_type: SOFT |
| # Limits use of CPU time |
| rlimit_cpu_type: SOFT |
| # Maximum file size |
| rlimit_fsize_type: SOFT |
| # Maximum number of file descriptors opened |
| rlimit_nofile_type: SOFT |
| # Maximum stack size |
| rlimit_stack_type: SOFT |
| # Maximum number of threads |
| rlimit_nproc_type: SOFT |
| |
| # Allow terminal control |
| # This let's users cancel jobs with CTRL-C without exiting the |
| # jail. |
| skip_setsid: true |
| |
| # Below are all the host paths that shall be mounted |
| # to the sandbox |
| |
| # TODO: Determine if we need to have /proc from outside of the jail. |
| mount_proc: false |
| |
| # The user must mount the source to /src using --bindmount |
| # It will be set as the initial working directory |
| cwd: "{self.cwd}" |
| |
| # The sandbox User ID was chosen arbitrarily |
| uidmap {{ |
| inside_id: "999999" |
| outside_id: "" |
| count: 1 |
| }} |
| |
| # The sandbox Group ID was chosen arbitrarily |
| gidmap {{ |
| inside_id: "65533" |
| outside_id: "" |
| count: 1 |
| }} |
| |
| # Share PID namespace between parent and child process. |
| # Sharing the PID namespace ensures that the Bazel daemon does not |
| # get killed after every invocation. |
| clone_newpid: false |
| |
| """) |
| for envar in self.envars: |
| data += f'{envar}' |
| data += '\n' |
| for mount in self.mounts: |
| data += f'{mount}' |
| data += '\n' |
| for option in self.options: |
| data += f"{option}" |
| |
| if fn: |
| os.makedirs(os.path.dirname(fn), exist_ok=True) |
| with open(fn, "w", encoding="iso-8859-1") as f: |
| f.write(data) |
| return data |