summaryrefslogtreecommitdiff
path: root/test/v9fs.py
blob: 30efe9fbea08a85236dc6d9947cfb1c4556397b3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#!/usr/bin/env python3
"""Opt-in Linux kernel-mount probe; the editor always runs unprivileged.

Run after `sudo -v` with a native Pardes binary and the runtime v9fs helper.
The helper alone runs through sudo, creates a private mount namespace, mounts
9P, drops privileges, and execs this file's worker. No FUSE or global mount.
"""
import argparse
import json
import os
import pwd
from pathlib import Path
import subprocess
import sys
import tempfile
import time
import traceback

from fs import session
from ninep import Client


def write_existing(path, data, *, truncate=False):
    flags = os.O_WRONLY | (os.O_TRUNC if truncate else 0)
    fd = os.open(path, flags)
    try:
        assert os.write(fd, data) == len(data), str(path)
    finally:
        os.close(fd)


def worker(mountpoint, socket, uid, gid, original_namespace):
    assert os.getresuid() == (uid, uid, uid), os.getresuid()
    assert os.getresgid() == (gid, gid, gid), os.getresgid()
    assert set(os.getgroups()) == set(os.getgrouplist(pwd.getpwuid(uid).pw_name, gid)), os.getgroups()
    assert os.readlink('/proc/self/ns/mnt') != original_namespace
    status = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines())
    for field in ('CapEff', 'CapPrm', 'CapAmb'):
        assert int(status[field].strip(), 16) == 0, (field, status[field])
    entries = Path('/proc/self/mountinfo').read_text().splitlines()
    mounted = [line for line in entries if line.split()[4] == str(mountpoint)]
    assert len(mounted) == 1 and ' - 9p ' in mounted[0], mounted
    assert not any(field.startswith(('shared:', 'master:')) for field in mounted[0].split()[6:])

    tree = mountpoint
    assert {'os', 'index', 'pane', 'status', 'look', 'exec', 'log', 'screen', 'README'} <= set(os.listdir(tree))
    assert 'self' not in os.listdir(tree) and 'new' not in os.listdir(tree)
    assert 'new' not in os.listdir(tree / 'pane'), 'a listing would make a pane per stat'
    # A direct connection provides independent evidence for VFS reads/writes.
    with Client(socket) as client:
        assert (tree / 'index').read_bytes() == client.read('/index')
        assert (tree / 'pane/1/body').read_bytes() == b'initial\n'

        before = client.read('/index')
        subprocess.run(['ls', '-l', str(tree), str(tree / 'pane' / '1')], check=True, capture_output=True, timeout=5)
        subprocess.run(['find', str(tree / 'pane'), '-ls'], check=True, capture_output=True, timeout=5)
        assert (tree / 'README').read_bytes() == client.read('/README')
        assert client.read('/index') == before, 'browsing created panes'
        # Opening pane/new makes a pane and the read names it, so no trip
        # through the index. Whether a repeated path reaches the server at all
        # is the kernel's dentry cache's business, so the second pane comes
        # over the wire, where the open is exact.
        def serials():
            return {int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()}

        def mkpane():
            known = serials()
            made = int((tree / 'pane' / 'new').read_bytes())
            assert made not in known, 'the open of new made no pane'
            return made

        serial = mkpane()
        another = int(client.read('/pane/new'))
        assert serial != another, 'a second open of new reused a pane'
        (tree / 'pane' / str(another)).rmdir()
        assert another not in serials()
        pane = tree / 'pane' / str(serial)
        wire = f'/pane/{serial}'
        assert str(serial) in os.listdir(tree / 'pane')
        assert serial in [int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()]

        write_existing(pane / 'body', b'kernel body\n', truncate=True)
        assert client.read(wire + '/body') == b'kernel body\n'
        # Reopen must observe changes made through another 9P connection.
        client.write(wire + '/body', b'wire update\n', truncate=True)
        assert (pane / 'body').read_bytes() == b'wire update\n'
        write_existing(pane / 'body', b'appended\n')
        assert client.read(wire + '/body') == b'wire update\nappended\n'
        write_existing(pane / 'addr', b'#0,#4')
        write_existing(pane / 'data', b'v9fs')
        assert client.read(wire + '/body') == b'v9fs update\nappended\n'

        # Exercise an actual shell redirection, including its O_TRUNC open.
        subprocess.run(['/bin/sh', '-c', 'printf "kernel-probe\\n" > "$1/name"',
                        'v9fs-probe', str(pane)], check=True, timeout=5)
        tag = client.read(wire + '/tag')
        assert tag.split(maxsplit=1)[0] == str(socket.parent / 'kernel-probe').encode(), tag
        # Children inherit this single mount and can use ordinary tools.
        copied = subprocess.run(['/bin/cat', str(pane / 'body')],
                                check=True, capture_output=True, timeout=5).stdout
        assert copied == b'v9fs update\nappended\n'

        command = b'Msg kernel-v9fs-ready'
        write_existing(pane / 'body', command, truncate=True)
        write_existing(pane / 'event', f'MX0 {len(command)}\n'.encode())
        # Event writes acknowledge dispatch, so reopen screen until rendered.
        deadline = time.monotonic() + 5
        while True:
            screen = json.loads((tree / 'screen').read_bytes())
            if 'kernel-v9fs-ready' in ''.join(cell[0] for cell in screen['cells']):
                break
            assert time.monotonic() < deadline, 'Exec result was not rendered'
            time.sleep(.01)

        # Reading OS files through the exported tree does not recurse through
        # the mount: the core still lives in the supervisor's namespace.
        assert (mountpoint / 'os' / str(socket.parent).lstrip('/') / 'kernel.txt').read_bytes() == b'initial\n'
        (tree / 'pane' / str(serial)).rmdir()
        assert serial not in [int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()]

    print('v9fs: namespace isolation, privilege drop, inherited mount, directory refresh, '
          'text edits, control writes, Exec and screen checks passed', flush=True)


def run_helper(command, timeout=30):
    # Keep the caller's controlling terminal: sudo credentials are commonly
    # scoped to it. setsid/start_new_session makes an earlier sudo -v useless.
    # The elevated helper itself creates the separate *mount* namespace.
    child = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                             text=True)
    try:
        stdout, stderr = child.communicate(timeout=timeout)
    except subprocess.TimeoutExpired:
        # sudo forwards signals to its command. Keep the server alive while
        # stopping the mount user, then let session() clean up.
        child.terminate()
        try:
            child.communicate(timeout=5)
        except subprocess.TimeoutExpired:
            child.kill()
            child.communicate(timeout=5)
        raise RuntimeError('kernel probe timed out; no compatibility result')
    if child.returncode:
        raise RuntimeError(f'kernel probe failed ({child.returncode})\n{stdout}{stderr}')
    return stdout


def run(binary, helper):
    if sys.platform != 'linux':
        raise RuntimeError('this probe requires Linux v9fs')
    if os.getuid() == 0:
        raise RuntimeError('run the driver as your normal user; only the mount helper uses sudo')
    # Never prompt from a build step. An unavailable prerequisite is a failure,
    # not a skipped test that might be mistaken for mounted-filesystem coverage.
    available = subprocess.run(['sudo', '-n', '-v'], capture_output=True, text=True, timeout=5)
    if available.returncode:
        raise RuntimeError('mount authorization unavailable: run sudo -v in your terminal, then retry\n'
                           + available.stderr.strip())
    namespace = os.readlink('/proc/self/ns/mnt')
    with tempfile.TemporaryDirectory(prefix='pardes-v9fs-') as directory:
        root = Path(directory)
        target = root / 'mount'
        target.mkdir()
        with session(str(binary), root, 'kernel') as (client, address):
            command = ['sudo', '-n', '--', str(helper), str(address), str(target),
                       str(os.getuid()), str(os.getgid()), '--', sys.executable, '-B',
                       str(Path(__file__).resolve()), '--worker', str(target), str(address),
                       str(os.getuid()), str(os.getgid()), namespace]
            print(run_helper(command), end='')
            assert os.readlink('/proc/self/ns/mnt') == namespace
            assert list(target.iterdir()) == [], 'mount escaped its private namespace'
            assert client.read('/pane/1/body') == b'initial\n', 'core stopped serving after probe exit'
    print('v9fs: supervisor namespace unchanged and session cleanup passed')


def main():
    if len(sys.argv) > 1 and sys.argv[1] == '--worker':
        if len(sys.argv) != 7:
            raise RuntimeError('invalid internal worker arguments')
        worker(Path(sys.argv[2]), Path(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5]), sys.argv[6])
        return
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('binary', type=lambda text: Path(text).resolve(strict=True))
    parser.add_argument('helper', type=lambda text: Path(text).resolve(strict=True))
    args = parser.parse_args()
    run(args.binary, args.helper)


if __name__ == '__main__':
    try:
        main()
    except (AssertionError, OSError, RuntimeError, subprocess.SubprocessError) as error:
        if len(sys.argv) > 1 and sys.argv[1] == '--worker':
            traceback.print_exc()
        print(f'v9fs: {error}', file=sys.stderr)
        sys.exit(1)