1import ../make-test-python.nix {
2 name = "systemd-confinement";
3
4 nodes.machine =
5 { pkgs, lib, ... }:
6 let
7 testLib = pkgs.python3Packages.buildPythonPackage {
8 name = "confinement-testlib";
9 format = "setuptools";
10 unpackPhase = ''
11 cat > setup.py <<EOF
12 from setuptools import setup
13 setup(name='confinement-testlib', py_modules=["checkperms"])
14 EOF
15 cp ${./checkperms.py} checkperms.py
16 '';
17 };
18
19 mkTest =
20 name: testScript:
21 pkgs.writers.writePython3 "${name}.py"
22 {
23 libraries = [
24 pkgs.python3Packages.pytest
25 testLib
26 ];
27 }
28 ''
29 # This runs our test script by using pytest's assertion rewriting, so
30 # that whenever we use "assert <something>", the actual values are
31 # printed rather than getting a generic AssertionError or the need to
32 # pass an explicit assertion error message.
33 import ast
34 from pathlib import Path
35 from _pytest.assertion.rewrite import rewrite_asserts
36
37 script = Path('${pkgs.writeText "${name}-main.py" ''
38 import errno, os, pytest, signal
39 from subprocess import run
40 from checkperms import Accessibility, assert_permissions
41
42 ${testScript}
43 ''}') # noqa
44 filename = str(script)
45 source = script.read_bytes()
46
47 tree = ast.parse(source, filename=filename)
48 rewrite_asserts(tree, source, filename)
49 exec(compile(tree, filename, 'exec', dont_inherit=True))
50 '';
51
52 mkTestStep =
53 num:
54 {
55 description,
56 testScript,
57 config ? { },
58 serviceName ? "test${toString num}",
59 rawUnit ? null,
60 }:
61 {
62 systemd.packages = lib.optional (rawUnit != null) (
63 pkgs.writeTextFile {
64 name = serviceName;
65 destination = "/etc/systemd/system/${serviceName}.service";
66 text = rawUnit;
67 }
68 );
69
70 systemd.services.${serviceName} = {
71 inherit description;
72 requiredBy = [ "multi-user.target" ];
73 confinement = (config.confinement or { }) // {
74 enable = true;
75 };
76 serviceConfig = (config.serviceConfig or { }) // {
77 ExecStart = mkTest serviceName testScript;
78 Type = "oneshot";
79 };
80 }
81 // removeAttrs config [
82 "confinement"
83 "serviceConfig"
84 ];
85 };
86
87 parametrisedTests =
88 lib.concatMap
89 (
90 { user, privateTmp }:
91 let
92 withTmp = if privateTmp then "with PrivateTmp" else "without PrivateTmp";
93
94 serviceConfig =
95 if user == "static-user" then
96 {
97 User = "chroot-testuser";
98 Group = "chroot-testgroup";
99 }
100 else if user == "dynamic-user" then
101 {
102 DynamicUser = true;
103 }
104 else
105 { };
106
107 in
108 [
109 {
110 description = "${user}, chroot-only confinement ${withTmp}";
111 config = {
112 confinement.mode = "chroot-only";
113 # Only set if privateTmp is true to ensure that the default is false.
114 serviceConfig =
115 serviceConfig
116 // lib.optionalAttrs privateTmp {
117 PrivateTmp = true;
118 };
119 };
120 testScript =
121 if user == "root" then
122 ''
123 assert os.getuid() == 0
124 assert os.getgid() == 0
125
126 assert_permissions({
127 'bin': Accessibility.READABLE,
128 'nix': Accessibility.READABLE,
129 'run': Accessibility.READABLE,
130 ${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
131 ${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
132 ${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
133 })
134 ''
135 else
136 ''
137 assert os.getuid() != 0
138 assert os.getgid() != 0
139
140 assert_permissions({
141 'bin': Accessibility.READABLE,
142 'nix': Accessibility.READABLE,
143 'run': Accessibility.READABLE,
144 ${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
145 ${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
146 ${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
147 })
148 '';
149 }
150 {
151 description = "${user}, full APIVFS confinement ${withTmp}";
152 config = {
153 # Only set if privateTmp is false to ensure that the default is true.
154 serviceConfig =
155 serviceConfig
156 // lib.optionalAttrs (!privateTmp) {
157 PrivateTmp = false;
158 };
159 };
160 testScript =
161 if user == "root" then
162 ''
163 assert os.getuid() == 0
164 assert os.getgid() == 0
165
166 assert_permissions({
167 'bin': Accessibility.READABLE,
168 'nix': Accessibility.READABLE,
169 ${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
170 'run': Accessibility.WRITABLE,
171
172 'proc': Accessibility.SPECIAL,
173 'sys': Accessibility.SPECIAL,
174 'dev': Accessibility.WRITABLE,
175
176 ${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
177 ${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
178 })
179 ''
180 else
181 ''
182 assert os.getuid() != 0
183 assert os.getgid() != 0
184
185 assert_permissions({
186 'bin': Accessibility.READABLE,
187 'nix': Accessibility.READABLE,
188 ${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
189 'run': Accessibility.STICKY,
190
191 'proc': Accessibility.SPECIAL,
192 'sys': Accessibility.SPECIAL,
193 'dev': Accessibility.SPECIAL,
194 'dev/shm': Accessibility.STICKY,
195 'dev/mqueue': Accessibility.STICKY,
196
197 ${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
198 ${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
199 })
200 '';
201 }
202 ]
203 )
204 (
205 lib.cartesianProduct {
206 user = [
207 "root"
208 "dynamic-user"
209 "static-user"
210 ];
211 privateTmp = [
212 true
213 false
214 ];
215 }
216 );
217
218 in
219 {
220 imports = lib.imap1 mkTestStep (
221 parametrisedTests
222 ++ [
223 {
224 description = "existence of bind-mounted /etc";
225 config.serviceConfig.BindReadOnlyPaths = [ "/etc" ];
226 testScript = ''
227 assert Path('/etc/passwd').read_text()
228 '';
229 }
230 (
231 let
232 symlink = pkgs.runCommand "symlink" {
233 target = pkgs.writeText "symlink-target" "got me";
234 } "ln -s \"$target\" \"$out\"";
235 in
236 {
237 description = "check if symlinks are properly bind-mounted";
238 config.confinement.packages = lib.singleton symlink;
239 testScript = ''
240 assert Path('${symlink}').read_text() == 'got me'
241 '';
242 }
243 )
244 {
245 description = "check if StateDirectory works";
246 config.serviceConfig.User = "chroot-testuser";
247 config.serviceConfig.Group = "chroot-testgroup";
248 config.serviceConfig.StateDirectory = "testme";
249
250 # We restart on purpose here since we want to check whether the state
251 # directory actually persists.
252 config.serviceConfig.Restart = "on-failure";
253 config.serviceConfig.RestartMode = "direct";
254
255 testScript = ''
256 assert not Path('/tmp/canary').exists()
257 Path('/tmp/canary').touch()
258
259 if (foo := Path('/var/lib/testme/foo')).exists():
260 assert Path('/var/lib/testme/foo').read_text() == 'works'
261 else:
262 Path('/var/lib/testme/foo').write_text('works')
263 print('<4>Exiting with failure to check persistence on restart.')
264 raise SystemExit(1)
265 '';
266 }
267 {
268 description = "check if /bin/sh works";
269 testScript = ''
270 assert Path('/bin/sh').exists()
271
272 result = run(
273 ['/bin/sh', '-c', 'echo -n bar'],
274 capture_output=True,
275 check=True,
276 )
277 assert result.stdout == b'bar'
278 '';
279 }
280 {
281 description = "check if suppressing /bin/sh works";
282 config.confinement.binSh = null;
283 testScript = ''
284 assert not Path('/bin/sh').exists()
285 with pytest.raises(FileNotFoundError):
286 run(['/bin/sh', '-c', 'echo foo'])
287 '';
288 }
289 {
290 description = "check if we can set /bin/sh to something different";
291 config.confinement.binSh = "${pkgs.hello}/bin/hello";
292 testScript = ''
293 assert Path('/bin/sh').exists()
294 result = run(
295 ['/bin/sh', '-g', 'foo'],
296 capture_output=True,
297 check=True,
298 )
299 assert result.stdout == b'foo\n'
300 '';
301 }
302 {
303 description = "check if only Exec* dependencies are included";
304 config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
305 testScript = ''
306 with pytest.raises(FileNotFoundError):
307 Path(os.environ['FOOBAR']).read_text()
308 '';
309 }
310 {
311 description = "check if fullUnit includes all dependencies";
312 config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
313 config.confinement.fullUnit = true;
314 testScript = ''
315 assert Path(os.environ['FOOBAR']).read_text() == 'eek'
316 '';
317 }
318 {
319 description = "check if shipped unit file still works";
320 config.confinement.mode = "chroot-only";
321 rawUnit = ''
322 [Service]
323 SystemCallFilter=~kill
324 SystemCallErrorNumber=ELOOP
325 '';
326 testScript = ''
327 with pytest.raises(OSError) as excinfo:
328 os.kill(os.getpid(), signal.SIGKILL)
329 assert excinfo.value.errno == errno.ELOOP
330 '';
331 }
332 ]
333 );
334
335 config.users.groups.chroot-testgroup = { };
336 config.users.users.chroot-testuser = {
337 isSystemUser = true;
338 description = "Chroot Test User";
339 group = "chroot-testgroup";
340 };
341 };
342
343 testScript = ''
344 machine.wait_for_unit("multi-user.target")
345 '';
346}