Coverage for daklib/sandbox.py: 41%
81 statements
« prev ^ index » next coverage.py v7.6.0, created at 2026-05-10 21:38 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2026-05-10 21:38 +0000
1# SPDX-License-Identifier: GPL-2.0-or-later
2# © 2026, Ansgar 🙀 <ansgar@debian.org>
4"""Sandbox external commands via `systemd-run --user`"""
6import logging
7import os
8import subprocess
9from collections.abc import Sequence
10from dataclasses import dataclass
11from functools import cache
13from daklib.config import Config
15_logger = logging.getLogger(__name__)
18@dataclass
19class Sandbox:
20 private_devices = True
21 private_ipc = True
22 private_network = True
24 inaccessible_paths: Sequence[str] | None = None
25 extra_inaccessible_paths: Sequence[str] = ()
26 temporary_file_systems: Sequence[str] | None = None
27 extra_temporary_file_systems: Sequence[str] = ()
28 read_only_paths: Sequence[str] | None = None
29 extra_read_only_paths: Sequence[str] = ()
30 read_write_paths: Sequence[str] | None = None
31 extra_read_write_paths: Sequence[str] = ()
32 bind_read_only_paths: Sequence[str] | None = None
33 extra_bind_read_only_paths: Sequence[str] = ()
34 bind_read_write_paths: Sequence[str] | None = None
35 extra_bind_read_write_paths: Sequence[str] = ()
37 restrict_address_families: Sequence[str] | None = ()
38 system_call_filter: Sequence[str] | None = ("@system-service",)
41@cache
42def _default_sandbox() -> Sandbox:
43 return Sandbox(
44 read_only_paths=("/",),
45 )
48def _effective(
49 paths: Sequence[str] | None,
50 extra_paths: Sequence[str],
51 default_paths: Sequence[str] | None,
52) -> Sequence[str]:
53 if paths is None:
54 paths = default_paths or ()
55 return (*paths, *extra_paths)
58def build_sandbox_command(sandbox: Sandbox, original_cmd: Sequence[str]) -> list[str]:
59 default = _default_sandbox()
61 cmd: list[str] = [
62 "systemd-run",
63 "--user",
64 "--pipe",
65 "--wait",
66 "--quiet",
67 "--collect",
68 "--service-type=exec",
69 "--expand-environment=no",
70 "--same-dir",
71 # Capabilities
72 "-pCapabilityBoundingSet=",
73 # Security
74 "-pNoNewPrivileges=yes",
75 "-pLockPersonality=yes",
76 # System call filtering
77 "-pSystemCallArchitectures=native",
78 ]
80 if "TMPDIR" in os.environ:
81 cmd.append("-ETMPDIR")
83 if sandbox.private_devices:
84 cmd.append("-pPrivateDevices=yes")
86 if sandbox.private_ipc:
87 cmd.append("-pPrivateIPC=yes")
89 if sandbox.private_network:
90 cmd.append("-pPrivateNetwork=yes")
92 if effective_inaccessible_paths := _effective(
93 sandbox.inaccessible_paths,
94 sandbox.extra_inaccessible_paths,
95 default.inaccessible_paths,
96 ):
97 cmd.append(f"-pInaccessiblePaths={" ".join(effective_inaccessible_paths)}")
99 if temporary_file_systems := _effective(
100 sandbox.temporary_file_systems,
101 sandbox.extra_temporary_file_systems,
102 default.temporary_file_systems,
103 ):
104 cmd.append(f"-pTemporaryFileSystem={" ".join(temporary_file_systems)}")
106 if read_only_paths := _effective(
107 sandbox.read_only_paths, sandbox.extra_read_only_paths, default.read_only_paths
108 ):
109 cmd.append(f"-pReadOnlyPaths={" ".join(read_only_paths)}")
111 if read_write_paths := _effective(
112 sandbox.read_write_paths,
113 sandbox.extra_read_write_paths,
114 default.read_write_paths,
115 ):
116 cmd.append(f"-pReadWritePaths={" ".join(read_write_paths)}")
118 if bind_read_only_paths := _effective(
119 sandbox.bind_read_only_paths,
120 sandbox.extra_bind_read_only_paths,
121 default.bind_read_only_paths,
122 ):
123 cmd.append(f"-pBindReadOnlyPaths={" ".join(bind_read_only_paths)}")
125 if bind_read_write_paths := _effective(
126 sandbox.bind_read_write_paths,
127 sandbox.extra_bind_read_write_paths,
128 default.bind_read_write_paths,
129 ):
130 cmd.append(f"-pBindReadWritePaths={" ".join(bind_read_write_paths)}")
132 if (restrict_address_families := sandbox.restrict_address_families) is not None:
133 cmd.append(f"-pRestrictAddressFamilies={" ".join(restrict_address_families)}")
135 if (system_call_filter := sandbox.system_call_filter) is not None:
136 cmd.append(f"-pSystemCallFilter={" ".join(system_call_filter)}")
138 cmd.append("--")
139 cmd.extend(original_cmd)
140 return cmd
143def _run_sandboxed(
144 sandbox: Sandbox, cmd: Sequence[str], **kwargs
145) -> subprocess.CompletedProcess:
146 sandboxed_cmd = build_sandbox_command(sandbox, cmd)
147 return subprocess.run(sandboxed_cmd, **kwargs)
150@cache
151def _sandbox_enabled() -> bool:
152 config = Config()
153 if not config.find_b("Dinstall::Sandbox::Enable", True): 153 ↛ 157line 153 didn't jump to line 157 because the condition on line 153 was always true
154 return False
156 # Check for working sandbox.
157 try:
158 _run_sandboxed(_default_sandbox(), ["/usr/bin/true"], check=True)
159 except Exception as e:
160 raise Exception("Sandbox is not available.") from e
162 return True
165def run(
166 cmd: Sequence[str], *, sandbox: Sandbox, **kwargs
167) -> subprocess.CompletedProcess:
168 if not _sandbox_enabled(): 168 ↛ 170line 168 didn't jump to line 170 because the condition on line 168 was always true
169 return subprocess.run(cmd, **kwargs)
170 return _run_sandboxed(sandbox, cmd, **kwargs)