1 import ../make-test-python.nix {
2 name = "systemd-confinement";
4 nodes.machine = { pkgs, lib, ... }: let
5 testLib = pkgs.python3Packages.buildPythonPackage {
6 name = "confinement-testlib";
9 from setuptools import setup
10 setup(name='confinement-testlib', py_modules=["checkperms"])
12 cp ${./checkperms.py} checkperms.py
16 mkTest = name: testScript: pkgs.writers.writePython3 "${name}.py" {
17 libraries = [ pkgs.python3Packages.pytest testLib ];
19 # This runs our test script by using pytest's assertion rewriting, so
20 # that whenever we use "assert <something>", the actual values are
21 # printed rather than getting a generic AssertionError or the need to
22 # pass an explicit assertion error message.
24 from pathlib import Path
25 from _pytest.assertion.rewrite import rewrite_asserts
27 script = Path('${pkgs.writeText "${name}-main.py" ''
28 import errno, os, pytest, signal
29 from subprocess import run
30 from checkperms import Accessibility, assert_permissions
34 filename = str(script)
35 source = script.read_bytes()
37 tree = ast.parse(source, filename=filename)
38 rewrite_asserts(tree, source, filename)
39 exec(compile(tree, filename, 'exec', dont_inherit=True))
46 serviceName ? "test${toString num}",
49 systemd.packages = lib.optional (rawUnit != null) (pkgs.writeTextFile {
51 destination = "/etc/systemd/system/${serviceName}.service";
55 systemd.services.${serviceName} = {
57 requiredBy = [ "multi-user.target" ];
58 confinement = (config.confinement or {}) // { enable = true; };
59 serviceConfig = (config.serviceConfig or {}) // {
60 ExecStart = mkTest serviceName testScript;
63 } // removeAttrs config [ "confinement" "serviceConfig" ];
66 parametrisedTests = lib.concatMap ({ user, privateTmp }: let
67 withTmp = if privateTmp then "with PrivateTmp" else "without PrivateTmp";
69 serviceConfig = if user == "static-user" then {
70 User = "chroot-testuser";
71 Group = "chroot-testgroup";
72 } else if user == "dynamic-user" then {
77 { description = "${user}, chroot-only confinement ${withTmp}";
79 confinement.mode = "chroot-only";
80 # Only set if privateTmp is true to ensure that the default is false.
81 serviceConfig = serviceConfig // lib.optionalAttrs privateTmp {
85 testScript = if user == "root" then ''
86 assert os.getuid() == 0
87 assert os.getgid() == 0
90 'bin': Accessibility.READABLE,
91 'nix': Accessibility.READABLE,
92 'run': Accessibility.READABLE,
93 ${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
94 ${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
95 ${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
98 assert os.getuid() != 0
99 assert os.getgid() != 0
102 'bin': Accessibility.READABLE,
103 'nix': Accessibility.READABLE,
104 'run': Accessibility.READABLE,
105 ${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
106 ${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
107 ${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
111 { description = "${user}, full APIVFS confinement ${withTmp}";
113 # Only set if privateTmp is false to ensure that the default is true.
114 serviceConfig = serviceConfig // lib.optionalAttrs (!privateTmp) {
118 testScript = if user == "root" then ''
119 assert os.getuid() == 0
120 assert os.getgid() == 0
123 'bin': Accessibility.READABLE,
124 'nix': Accessibility.READABLE,
125 ${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
126 'run': Accessibility.WRITABLE,
128 'proc': Accessibility.SPECIAL,
129 'sys': Accessibility.SPECIAL,
130 'dev': Accessibility.WRITABLE,
132 ${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
133 ${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
136 assert os.getuid() != 0
137 assert os.getgid() != 0
140 'bin': Accessibility.READABLE,
141 'nix': Accessibility.READABLE,
142 ${lib.optionalString privateTmp "'tmp': Accessibility.STICKY,"}
143 'run': Accessibility.STICKY,
145 'proc': Accessibility.SPECIAL,
146 'sys': Accessibility.SPECIAL,
147 'dev': Accessibility.SPECIAL,
148 'dev/shm': Accessibility.STICKY,
149 'dev/mqueue': Accessibility.STICKY,
151 ${lib.optionalString privateTmp "'var': Accessibility.READABLE,"}
152 ${lib.optionalString privateTmp "'var/tmp': Accessibility.STICKY,"}
156 ]) (lib.cartesianProduct {
157 user = [ "root" "dynamic-user" "static-user" ];
158 privateTmp = [ true false ];
162 imports = lib.imap1 mkTestStep (parametrisedTests ++ [
163 { description = "existence of bind-mounted /etc";
164 config.serviceConfig.BindReadOnlyPaths = [ "/etc" ];
166 assert Path('/etc/passwd').read_text()
170 symlink = pkgs.runCommand "symlink" {
171 target = pkgs.writeText "symlink-target" "got me";
172 } "ln -s \"$target\" \"$out\"";
174 description = "check if symlinks are properly bind-mounted";
175 config.confinement.packages = lib.singleton symlink;
177 assert Path('${symlink}').read_text() == 'got me'
180 { description = "check if StateDirectory works";
181 config.serviceConfig.User = "chroot-testuser";
182 config.serviceConfig.Group = "chroot-testgroup";
183 config.serviceConfig.StateDirectory = "testme";
185 # We restart on purpose here since we want to check whether the state
186 # directory actually persists.
187 config.serviceConfig.Restart = "on-failure";
188 config.serviceConfig.RestartMode = "direct";
191 assert not Path('/tmp/canary').exists()
192 Path('/tmp/canary').touch()
194 if (foo := Path('/var/lib/testme/foo')).exists():
195 assert Path('/var/lib/testme/foo').read_text() == 'works'
197 Path('/var/lib/testme/foo').write_text('works')
198 print('<4>Exiting with failure to check persistence on restart.')
202 { description = "check if /bin/sh works";
204 assert Path('/bin/sh').exists()
207 ['/bin/sh', '-c', 'echo -n bar'],
211 assert result.stdout == b'bar'
214 { description = "check if suppressing /bin/sh works";
215 config.confinement.binSh = null;
217 assert not Path('/bin/sh').exists()
218 with pytest.raises(FileNotFoundError):
219 run(['/bin/sh', '-c', 'echo foo'])
222 { description = "check if we can set /bin/sh to something different";
223 config.confinement.binSh = "${pkgs.hello}/bin/hello";
225 assert Path('/bin/sh').exists()
227 ['/bin/sh', '-g', 'foo'],
231 assert result.stdout == b'foo\n'
234 { description = "check if only Exec* dependencies are included";
235 config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
237 with pytest.raises(FileNotFoundError):
238 Path(os.environ['FOOBAR']).read_text()
241 { description = "check if fullUnit includes all dependencies";
242 config.environment.FOOBAR = pkgs.writeText "foobar" "eek";
243 config.confinement.fullUnit = true;
245 assert Path(os.environ['FOOBAR']).read_text() == 'eek'
248 { description = "check if shipped unit file still works";
249 config.confinement.mode = "chroot-only";
252 SystemCallFilter=~kill
253 SystemCallErrorNumber=ELOOP
256 with pytest.raises(OSError) as excinfo:
257 os.kill(os.getpid(), signal.SIGKILL)
258 assert excinfo.value.errno == errno.ELOOP
263 config.users.groups.chroot-testgroup = {};
264 config.users.users.chroot-testuser = {
266 description = "Chroot Test User";
267 group = "chroot-testgroup";
272 machine.wait_for_unit("multi-user.target")